로그에 대한 내용을 담은 글이었는데, 여기에 추가적으로 하나 더 아주 유용한 기능을 기록해보려 한다.
서버를 운영하던 중 사용자의 요청이 어디서부터 시작해서 어디서 끝나는지 로그가 무수히 찍히는 상황에서는 인지하기가 쉽지가 않다.
어떤 작업을 했고 어떤 과정을 거쳤는지, 에러가 났다면 어디서 시작해서 어떤 에러가 발생했는지 로그 자체는 남지만 과정의 흐름을 이해하기가 쉽지 않았다. 그래서 요청부터 응답까지의 사용자 흐름을 한눈에 파악할 수 있는 좋은 기능인 Logback MDC를 사용하는 방법을 기록하려고 한다.
우선, 요즘 대부분의 Slf4j 구현체는 Logback을 사용한다. 물론 log4j를 사용할수도 있다.
나는 Logback을 사용할거고 여기서 제공하는 MDC 기능을 사용하겠다. 쉽게 말해 사용자의 요청부터 응답까지의 로그를 하나의 흐름으로 확인하는 방법이다.
logback-spring.xml 파일 작성
우선, logback 설정 파일을 좀 수정해줘야 한다.
logback-spring.xml vs logback.xml
스프링 부트에서 로그 설정 파일을 정의할 때, logback.xml 또는 logback-spring.xml 파일을 사용할 수 있다. 차이점은:
logback-spring.xml은 스프링 부트만의 확장 기능을 사용할 수 있다. 예를 들어, profile 기반 설정 등을 지원한다.
logback.xml은 순수 Logback 설정 파일로, 스프링 부트의 확장 기능은 사용할 수 없다.
따라서, 스프링 부트를 사용 중이라면, logback-spring.xml 파일을 사용하는게 더 유연한 설정을 할 수 있다.
이 파일은 어디에 이미 있는게 아니다. 그래서 필요하다면 개발자가 직접 생성해서 만들어야 한다. 그 위치는 `src/main/resources`이다. 여기에 이 파일을 만들어두면 스프링 부트가 자동으로 인식하여 시작될 때 이 로그 설정을 적용한다.
우선, pattern 부분을 수정해야 한다. 내가 커스텀해서 사용자의 요청부터 응답까지의 한 과정을 동일한 식별자로 남길 수 있는 부분을 추가해야 한다. 나는 [%X{identifier}] 라고 값을 넣었다.
%X{key}는 Logback에서 로그 출력 시 MDC에 저장된 특정 키의 값을 참조하는데 사용된다.
그래서 MDC.put("key", value)로 저장한 값을 참조하게 된다.
각 패키지 별로 로그 레벨을 설정할 수 있다. `root`는 기본으로 info로 설정하고 그 내부적으로 필요한 패키지만 레벨을 수정하는게 좋을것이다. `root`부터 debug로 설정하면 별별 로그가 다 찍힐테니까.
내가 만드는 프로젝트의 패키지의 루트는 `hello`이다. 그래서 <logger>를 추가적으로 만들어서 `hello`패키지의 로그 레벨을 debug로 설정했다.
`additivity="false"`는 해당 Logger 에서 처리한 로그가 상위 Logger 에 전파되지 않도록 설정하는 기능이다. 이게 왜 필요하냐면, `hello`는 결국 `root` 하위 패키지이다. 그래서 저렇게 `root`와 `hello`에 대한 <logger>를 만들고 이 속성을 추가하지 않으면 두 개의 logger가 같은 로그를 찍는다. 그게 싫으니 `hello`레벨에서 찍은 로그는 `root`에서는 찍지 않아도 된다는 의미이다.
HandlerInterceptor로 요청마다 식별자 설정
스프링에서 사용할 수 있는 인터셉터를 사용해서 요청이 들어올 때 서블릿에 도착하기 전 고유 식별자를 만들어 모든 흐름의 로그에 같은 식별자를 남기자.
모든 요청에 대한 응답이 끝나고 사용자에게 돌아가기 전에 호출되는 afterCompletion() 메서드에서 만들어진 MDC 값을 초기화한다.
여기서 이런 의문이 든다면 굉장히 좋은 의문이다.
"요청이 동시에 두개가 들어왔을 때 요청A는 일찍 끝나서 MDC.clear()가 호출됐을 때 요청B는 계속 처리중이라 중간에 `identifer`의 값이 날라가는 것 아닌가?"
→ MDC(Mapped Diagnostic Context)는 스레드 로컬 기반으로 동작한다. 즉, MDC에 저장된 데이터는 각 요청마다 개별 스레드에 저장되므로, 한 요청에서 MDC.clear()가 호출되더라도 다른 요청의 MDC 데이터에는 영향을 미치지 않는다. 그래서 안심해도 된다.
이렇게 인터셉터를 만들었으면, 이제 인터셉터를 등록하면 된다.
WebMvcConfigurer에서 인터셉터 등록
package hello.login;
import hello.login.web.interceptor.LoggingInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoggingInterceptor());
}
}
하나의 요청에는 언제나 동일한 식별자가 기록됨을 확인
[2024-09-04 12:29:06] [http-nio-8080-exec-1] [8fd24009-(CLIENT-IP)-0:0:0:0:0:0:0:1] DEBUG c.g.controller.HealthzController - [healthz] Health Check
[2024-09-04 12:29:06] [http-nio-8080-exec-1] [8fd24009-(CLIENT-IP)-0:0:0:0:0:0:0:1] DEBUG c.gemmachat.service.HealthzService - [healthz] Wow condition is good 🎉
사용자가 요청을 했고 그 요청이 처리하는 과정이 계속해서 같은 식별자인 `8fd24009-(CLIENT-IP)-0:0:0:0:0:0:0:1`로 기록되고 있음을 확인할 수 있다. 나는 내가 내 서버에 요청했기 때문에 0:0:0:0:0:0:0:1(localhost)로 찍히고 있다.
이렇게 설정해두면, 요청부터 응답까지의 모든 과정을 동일한 식별자로 기록해서 한눈에 한 요청이 어떤 작업부터 어떤 흐름으로 무엇을 했는지 보기가 편하고 에러가 발생하더라도 어디서 출발해서 어떤 부분에서 에러를 마주했고 그 에러가 무엇인지 로그를 통해 트래킹하는게 쉬워진다.
보너스. 서블릿 필터로 적용
"어? 저는 스프링 인터셉터 싫은데요? 서블릿 필터로는 안되나요?" → 됩니다! (굳이? 라는 생각이 들지만)
Filter를 구현한다. 동일하게 구현하면 된다. 다만, Filter는 doFilter 메서드 안에서 MDC.clear()도 응답을 사용자에게 내보내기 전에 처리해줘야 한다.
WebConfig
package hello.login;
import hello.login.web.filter.LogFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean<LogFilter> logFilter() {
FilterRegistrationBean<LogFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new LogFilter());
filterFilterRegistrationBean.setOrder(1);
filterFilterRegistrationBean.addUrlPatterns("/*");
return filterFilterRegistrationBean;
}
}
서블릿 필터를 등록한다.
실행결과
[2024-09-04 15:59:48] [http-nio-8080-exec-3] [bed864ac-f818-4ef3-a2fc-8fd249a06da2-(CLIENT-IP)-0:0:0:0:0:0:0:1] INFO hello.login.web.item.ItemController - HI
근데 이게 한번 제대로 팍 이해하고 넘어가지 않으면 그놈이 그놈같고 이게 뭔 차인가 싶으니 제대로 딱 정리 한번 하기로 마음 먹었다.
우선, 반드시 지켜야할 건 운영 시스템에는 System.out.println()같은 시스템 콘솔을 사용해서 필요한 정보를 출력하지 않고 별도의 로깅 라이브러리를 사용해서 로그를 출력해야 한다. 왜 그럴까? 가장 큰 이유는 불 필요한 로그를 운영 시스템에 찍을 필요가 없는데 이 시스템 콘솔에 찍는건 불 필요한 로그까지 다 남게 되기 때문이다.
여기서 불필요한 로그라는 건? 아마 `TRACE`, `DEBUG` 레벨의 로그일거다. 운영상에 찍지 않고 개발중이거나 디버깅할때 또는 버그를 잡기 위해서 찍어보는 로그. 이게 운영상에 찍히게 되면 로그가 남발이 되니까 빨리 필요한 정보를 캐치하는것도 쉽지 않고, 로그 파일에 지저분하게 남기 때문에 성능도 가독성도 떨어지는 사태가 발생한다.
근데, 시스템 콘솔로 찍는 경우는 이 레벨이란게 없기 때문에 모든 로그가 다 남게 된다. 그리고 가장 최악은 이 시스템 콘솔에 뭔가를 찍을때 연산 작업이 들어간다면 그것이야말로 성능의 가장 불필요한 낭비가 된다. 그래서 운영상에선 System.out.println()이런 시스템 콘솔에 직접 출력하는 것은 안된다.
여기서 말하는 연산 작업이란? 아래 같은 코드를 말한다.
int a = 10;
int b = 5;
System.out.println("a + b: " + (a + b));
참고로, 연산 작업이 아니더라도 그냥 기본적으로 System.out 보다 로그 라이브러리(내부 버퍼링, 멀티 쓰레드 등등)를 사용하는게 더 성능이 좋다.
로깅 라이브러리
스프링 부트를 사용한다면, 스프링 부트 로깅 라이브러리(spring-boot-starter-logging)가 함께 포함된다.
스프링 부트 로깅 라이브러리는 기본으로 다음 로깅 라이브러리를 사용한다.
SLF4J - `https://www.slf4j.org`
Logback - `https://logback.qos.ch`
라이브러리를 두개나 사용하는 건가요? 아니다.
로그 라이브러리는 Logback, Log4J, Log4J2 등 수많은 라이브러리가 있는데 A 프로젝트는 이것, B 프로젝트는 저것 이렇게 프로젝트 또는 회사마다 다 다른 라이브러리를 사용하면 연동의 문제가 생기니 이럴때 항상 뭐다? 인터페이스 - 구현체가 등장한다.
그래서, 인터페이스가 SLF4J고 그 구현체가 Logback, Log4J, Log4J2가 된다. 실무에서는 스프링 부트가 기본으로 제공하는 Logback을 대부분 사용한다. 그럼 어떻게 사용하면 될까?
LogTestController
package net.cwchoiit.springmvc.basic;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LogTestController {
private final Logger log = LoggerFactory.getLogger(LogTestController.class);
@GetMapping("/logging")
public String logging() {
log.info("LogTestController.logging");
return "ok";
}
}
위 코드와 같이 스프링 부트 프로젝트에서 간단한 컨트롤러를 만들었다. 그리고 org.slf4j 패키지에 들어있는 Logger, LoggerFactory를 통해 log 인스턴스를 만들어 낸다. 그리고 log.info()와 같이 찍으면 된다. 그래서 실제로 이 URL로 요청을 날리면 다음과 같이 로그가 찍힌다.
우선 시스템 콘솔에 직접 찍는거보다 훨씬 많은 정보를 보여준다. 시간, 로그레벨, 쓰레드 정보, 패키지+클래스, 로그 내용까지.
근데 로그의 진가는 이것이 아니라 다음과 같은 것이다.
LogTestController
package net.cwchoiit.springmvc.basic;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LogTestController {
private final Logger log = LoggerFactory.getLogger(LogTestController.class);
@GetMapping("/logging")
public String logging() {
log.trace("trace");
log.debug("debug");
log.info("info");
log.warn("warn");
log.error("error");
return "ok";
}
}
내가 모든 레벨에 대해서 로그를 출력하면 다음과 같은 결과를 얻는다.
어? TRACE, DEBUG 레벨은 안 찍혔다. 이게 로그의 진가이다. 내가 설정한 레벨부터 상위 레벨까지만 로그를 찍어주기 때문에 불필요한 로그를 남기지 않게 된다. 그럼 이 레벨은 어떻게 조정할까?
바로, application.yml 파일에서 지정하면 된다.
logging:
level:
net.cwchoiit.springmvc: DEBUG
이렇게 로그 레벨을 패키지별로 지정할 수 있다. 근데 아 이거 귀찮고 나는 모든 패키지가 다 DEBUG 레벨이면 좋겠어! 하면 이렇게 하면 된다.
application.yml
logging:
level:
root: DEBUG
근데 이러면 아예 프로젝트 자체 레벨을 바꾸는 거라 프로젝트 내 모든 라이브러리들 안에 찍은 로그들도 이 레벨에 맞춰 출력되기 때문에 내가 찍지도 않은 여러 로그들이 찍힐건데 여튼 방법은 이렇다. (기본값은 INFO)
참고로 로그 레벨은 다음과 같다.
TRACE
DEBUG
INFO
WARN
ERROR
아래로 내려갈수록 더 심각도가 높은것이고 위처럼 DEBUG로 로그 레벨을 설정하면 DEBUG, INFO, WARN, ERROR 로그가 찍히게 된다. 그래서 운영 시스템에서 만약 TRACE로 로그 레벨을 설정하면 큰일이 난다 큰일이! 로그 폭탄을 맞게 된다. 그래서 레벨을 설정할 수 있는 것이다 환경에 따라.
예를 들면, 로컬 환경은 TRACE로, 개발 서버에선 DEBUG로, 운영 서버에선 INFO로 이렇게 설정해서 각 서버 환경에 맞게 필요한 로그만 찍으면 보기도 좋고, 불필요한 정보도 남지 않고 성능에도 도움이 된다. 근데 System.out.println() 같은 건 그런게 없다. 그래서 사용하면 안된다.
그리고 또 다른 장점은, 이 시스템 콘솔에 직접 출력하는 건 결국 콘솔에만 남기 때문에 보존이 불가능하지만 로그는 원한다면 설정을 통해 파일로 남길수도 있다. 그리고 파일로 남길 때는 일별로 남기는게 가능하고 특정 용량 이상이 되면 로그를 분할 할수도 있기 때문에 장점만 있다.
중요!
로그를 찍을때도 이렇게 찍을 수가 있다.
String name = "cwchoiit";
log.trace("your name = " + name);
절대로 이렇게 찍으면 안된다. 반드시 다음과 같이 찍어야 한다.
String name = "cwchoiit";
log.trace("your name = {}", name);
왜 그럴까? 만약 내가 설정한 로그 레벨이 DEBUG라면, 이 TRACE 레벨의 로그는 출력되지 않을 것이다. 근데 출력을 하지 않는데도 불구하고 + 연산이 실행된다. 즉, 사용도 안 하는데 메모리와 CPU를 사용하게 된다는 것이다. 그리고 저렇게 연산을 하게 되면
"your name = cwchoiit"
라는 문자열이 만들어지는데 이걸 또 가지고 있는다. (물론 이후에 GC에 의해 사용 안되면 정리되긴 한다) 그럼 가지고 있는 동안 또 메모리를 사용하는 것이다. 그래서 절대로 저렇게 사용하면 안된다.
"그럼 이 방식은 메모리와 CPU 안 사용해요?"
String name = "cwchoiit";
log.trace("your name = {}", name);
사용하지 않는다. 왜냐하면, 로그 레벨이 DEBUG이기 때문에 이 log.trace()라는 메서드는 호출되지 않는다. 호출이 안되고 파라미터로 넘기는 코드만 있을 뿐이라서 호출되지 않으면 메모리도 CPU도 사용되지 않기 때문에 아무런 문제가 일어나지 않는다.
Part.2를 봤다면, CPU 사용량, 메모리 사용량, 톰캣의 쓰레드, DB 커넥션 풀과 같이 공통으로 사용되는 기술 메트릭은 이미 등록되어 있다. 이런 등록된 메트릭을 사용해서 대시보드를 구성하고 모니터링 하면 된다. 여기서 더 나아가서 비즈니스에 특화된 부분을 모니터링 하고 싶다. 예를 들어, 주문수, 취소수, 재고 수량 같은 메트릭들이 있다. 이 부분은 공통으로 만드는 게 아니라 각각의 비즈니스에 특화된 부분들이다.
이런 메트릭도 시스템을 운영하는데 굉장히 많은 도움이 된다. 예를 들어, 취소수가 갑자기 급증하거나 재고 수량이 임계치 이상으로 쌓이는 부분들은 기술적인 메트릭으로 확인할 수 없는 우리 시스템의 비즈니스 문제를 빠르게 파악하는데 도움을 준다.
그래서 이런 자기만의 메트릭을 만들어보자.
우선 코드가 필요하다. 간단한 주문 관련 코드를 만들자.
OrderService
package hello.order;
import java.util.concurrent.atomic.AtomicInteger;
public interface OrderService {
void order();
void cancel();
AtomicInteger getStock(); // Atomic : 멀티 쓰레드 환경에서 안전하게 값에 쓰기를 할 수 있는 방법
}
OrderServiceV0
package hello.order.v0;
import hello.order.OrderService;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
public class OrderServiceV0 implements OrderService {
private final AtomicInteger stock = new AtomicInteger(100);
@Override
public void order() {
log.info("주문");
stock.decrementAndGet();
}
@Override
public void cancel() {
log.info("취소");
stock.incrementAndGet();
}
@Override
public AtomicInteger getStock() {
return stock;
}
}
OrderConfigV0
package hello.order.v0;
import hello.order.OrderService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OrderConfigV0 {
@Bean
OrderService orderService() {
return new OrderServiceV0();
}
}
OrderController
package hello.controller;
import hello.order.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/order")
public String order() {
log.info("order");
orderService.order();
return "order";
}
@GetMapping("/cancel")
public String cancel() {
log.info("cancel");
orderService.cancel();
return "cancel";
}
@GetMapping("/stock")
public int stock() {
log.info("stock");
return orderService.getStock().get();
}
}
간단한 OrderService 인터페이스와 버전별 구현체가 등록될 것이고 컨트롤러가 있다.
그리고 버전별로 계속 빈으로 등록될 서비스와 설정 클래스가 달라지기 때문에 스캔 경로도 수정해줘야 한다.
ActuatorApplication
package hello;
import hello.order.v0.OrderConfigV0;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
@Import(OrderConfigV0.class)
@SpringBootApplication(scanBasePackages = "hello.controller")
public class ActuatorApplication {
public static void main(String[] args) {
SpringApplication.run(ActuatorApplication.class, args);
}
}
이제 주문, 취소, 현재 재고수량에 관련된 컨트롤러에 접속하면 정상적으로 잘 노출되는 것을 확인할 수 있을것이다. 이제 메트릭을 만들어보자.
메트릭 등록 V1 - 카운터
우선, 하나씩 증가하는 메트릭을 만들어보자.
메트릭을 등록하려면 먼저 MeterRegistry 라는 클래스를 주입받아야 한다. 액츄에이터를 사용하면 이 MeterRegistry는 자동으로 빈으로 등록된다. 그래서 이 녀석을 통해 카운터나 게이지를 등록해보자.
OrderServiceV1
package hello.order.v1;
import hello.order.OrderService;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
public class OrderServiceV1 implements OrderService {
private final AtomicInteger stock = new AtomicInteger(100);
private final MeterRegistry meterRegistry;
public OrderServiceV1(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public void order() {
log.info("주문");
stock.decrementAndGet();
Counter.builder("my.order")
.tag("class", this.getClass().getName())
.tag("method", "order")
.description("order")
.register(meterRegistry)
.increment();
}
@Override
public void cancel() {
log.info("취소");
stock.incrementAndGet();
Counter.builder("my.order")
.tag("class", this.getClass().getName())
.tag("method", "cancel")
.description("order")
.register(meterRegistry)
.increment();
}
@Override
public AtomicInteger getStock() {
return stock;
}
}
위 코드를 보면, 먼저 MeterRegistry를 주입받고, order()와 cancel()에서 메트릭을 만들고 있다.
그리고 Counter라는 io.micrometer.core.instrument 패키지에 있는 인터페이스를 통해 카운터 메트릭을 등록한다.
builder("my.order")는 메트릭의 이름으로 표현될 부분이고, tag는 메트릭에 달린 태그들을 만들어준다. 그리고 register(meterRegistry)로 메트릭으로 등록한 후에 메서드가 호출될 때 한번씩 그 값을 증가시키는 increment()를 호출한다.
이렇게 하면 끝이다. 이제 V1 Config 클래스를 만들어서 빈으로 등록하자.
OrderConfigV1
package hello.order.v1;
import hello.order.OrderService;
import hello.order.v0.OrderServiceV0;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OrderConfigV1 {
@Bean
OrderService orderService(MeterRegistry meterRegistry) {
return new OrderServiceV1(meterRegistry);
}
}
이번엔 io.micrometer.core.instrument 패키지에 있는 Timer라는 녀석을 가져온다. 마찬가지로 직접 등록하기 때문에 MeterRegistry도 가져와야 한다. 그래서 아까 카운터를 등록할때와 유사하게 등록을 해주면 된다. 근데 이 Timer는 이 인스턴스로 받아서 record()안에서 실행 로직을 수행해야 한다. 그 점이 좀 다르다는 것 확인하고! 그리고 코드가 너무 짧기 때문에 뭐 실행 시간이나 최대 시간을 계산하는게 매우 의미없을 수 있어서 랜덤하게 sleep()을 적용했다.
OrderConfigV3
package hello.order.v3;
import hello.order.OrderService;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OrderConfigV3 {
@Bean
OrderService orderService(MeterRegistry meterRegistry) {
return new OrderServiceV3(meterRegistry);
}
}
설정 클래스에 빈으로 잘 등록해준다. 스프링 부트 실행 클래스에 이 설정 클래스로 바꾸는건 생략하겠다. 계속 했던거니까!
그리고 주문과 취소를 여러번 좀 요청한 다음 메트릭을 보면 이렇게 나온다.
카운터도 있는데 총 걸린 시간과 최대 시간이 나온다. 보면 MAX값이 0인 이유는 위에서 말한것처럼 3분 간격으로 계속 체크를 한다. 내가 지난 3분 동안 요청이 없어서 값이 0이다. 그리고 전체 걸린 시간과 카운트가 있으면? 평균시간도 구할 수 있다.
그래서 이 또한 그라파나로 이쁘게 가시화해보자.
최대 실행 시간에 대한 그래프
평균 실행 시간에 대한 그래프
이렇게 시간에 관련된 메트릭 데이터도 그라파나로 이쁘게 볼 수 있게 됐다. 이제 이 Timer를 AOP로 바꿔보자. 이미 스프링이 다 만들어 놓은것을 가져다가 사용만 하면 된다.
메트릭 등록 - @Timed
이제 AOP로 간단하게 등록해보자.
OrderServiceV4
package hello.order.v4;
import hello.order.OrderService;
import io.micrometer.core.annotation.Timed;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
@Timed(value = "my.order")
@Slf4j
public class OrderServiceV4 implements OrderService {
private final AtomicInteger stock = new AtomicInteger(100);
@Override
public void order() {
log.info("주문");
stock.decrementAndGet();
sleep(500);
}
private static void sleep(int l) {
try {
Thread.sleep(l + new Random().nextInt(200));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
@Override
public void cancel() {
log.info("취소");
stock.incrementAndGet();
sleep(200);
}
@Override
public AtomicInteger getStock() {
return stock;
}
}
@Timed 애노테이션을 사용하면 된다. 이 애노테이션은 클래스 레벨도 가능하고 메서드 레벨도 다 가능하다.
아까 @Counted랑 다른게 없어서 바로 넘어가자.
OrderConfigV4
package hello.order.v4;
import hello.order.OrderService;
import io.micrometer.core.aop.TimedAspect;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OrderConfigV4 {
@Bean
OrderService orderService() {
return new OrderServiceV4();
}
@Bean
public TimedAspect timedAspect(MeterRegistry meterRegistry) {
return new TimedAspect(meterRegistry);
}
}
마찬가지로 이 @Timed도 TimedAspect를 빈으로 꼭! 등록을 해줘야 한다. 이렇게 해주면 끝!
메트릭 등록 - 게이지
게이지는 임의로 오르내릴 수 있는 단일 숫자 값을 나타내는 메트릭이다. 예를 들면, 재고 수량이나 주식의 현재가 같은 올랐다가도 내렸다가도 할 수 있는 값을 말한다. 그럼 위에서 계속 작업했던 주문 관련 서비스의 재고 수량을 가지고 게이지 메트릭을 만들어보자.
참고로, 게이지로 메트릭을 만들어야하나 카운터로 메트릭을 만들어야하나 고민이 된다면 "값이 줄거나 오르거나 둘 다 가능한가?"를 생각해보면 된다. 떨어지기도 하고 오르기도 하는 경우라면 그냥 게이지로 만들면 된다.
StockConfigV1
package hello.order.gauge;
import hello.order.OrderService;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StockConfigV1 {
@Bean
public MyStockMetric myStockMetric(OrderService orderService, MeterRegistry registry) {
return new MyStockMetric(orderService, registry);
}
@Slf4j
static class MyStockMetric {
private OrderService orderService;
private MeterRegistry meterRegistry;
public MyStockMetric(OrderService orderService, MeterRegistry meterRegistry) {
this.orderService = orderService;
this.meterRegistry = meterRegistry;
}
// 메트릭을 확인할 때 마다 (`/actuator/*` 요청을 의미) 이 메서드가 호출 된다.
@PostConstruct
public void init() {
Gauge.builder("my.stock", orderService, service -> {
log.info("stock gauge call");
return service.getStock().get();
}).register(meterRegistry);
}
}
}
위 코드가 게이지 메트릭을 등록하는 코드이다.
우선 MyStockMetric 이라는 클래스를 만들었다. 이 클래스는 OrderService, MeterRegistry를 주입받는다.
@PostConstruct 애노테이션으로 빈으로 등록된 후 실행될 메서드 init()을 만든다.
안에서 Gauge라는 io.micrometer.core.instrument 패키지에 있는 녀석을 가져와서 builder("my.stock")이라는 메트릭 이름을 준다. 그리고 OrderService를 넘기고 그 서비스의 현재 재고 수량을 반환하는 메서드를 리턴한다.
이렇게 하면 이제 메트릭을 외부에서 확인할 때마다 (예: 누군가가 `/actuator/*`로 요청을 날린다, 프로메테우스가 주기적으로 메트릭 정보를 수집하기 위해 요청을 하는 경우) 이 게이지의 세번째 파라미터인 람다 함수가 호출된다.
그리고 이 MyStockMetric 클래스를 빈으로 등록한다.
이러면 끝이다. 스프링 부트 시작 클래스에 이 설정 클래스 등록하는 것 잊지말고! 실행하면 다음과 같이 계속해서 1초마다 로그가 찍힐것이다. 왜 그럴까? 내가 프로메테우스로 1초마다 `/actuator/prometheus`로 데이터 받아오는 작업을 하게 했기 때문.
이렇게 한 후 간단하게 그라파나로 이쁘게 보여줘보자. 게이지에 대한 쿼리는 아주 간단하다. 내가 100개가 있는 상태에서 주문 요청을 여러번 했더니 저렇게 그래프가 꺾였다. 이제 취소도 하면 또 올라오게 된다. 이런게 게이지다.
그냥 이렇게 작성하면 된다. MeterBinder 라는 타입을 반환하는 빈을 등록하면 된다. 나머지 코드는 동일하니 생략.
결론
직접 나만의 메트릭을 만들어 보았다. 실제 프로덕션 서비스 환경에서 유용하게 사용할 수 있을 것 같다. 추적해야 하는 의미있는 데이터를 가지고 나중에 메트릭을 만들어 봐야겠다. 그리고 실무 모니터링 환경에 대해 좀 더 자세히 얘기하고 고민해보자.
이 강의에서 배운 실무 모니터링 환경에 대해 애기해 보겠다.
모니터링 3단계
대시보드
애플리케이션 추적 - 핀포인트
로그
대시보드
전체를 한눈에 볼 수 있는 가장 큰 뷰 마이크로미터, 프로메테우스, 그라파나 이렇게해서 사용하면 된다.
모니터링 대상
시스템 메트릭 (CPU, 메모리)
애플리케이션 메트릭 (톰캣 쓰레드 풀, DB 커넥션 풀, 애플리케이션 호출 수)
비즈니스 메트릭 (주문수, 취소수)
애플리케이션 추적
주로 각각의 HTTP 요청을 추적, 일부는 마이크로서비스 환경에서 분산 추적. 핀포인트를 사용하면 된다.
로그
가장 자세한 추적, 원하는대로 커스텀 가능.
같은 HTTP 요청을 묶어서 확인할 수 있는 방법이 중요하고 그 방법은 MDC를 적용하면 된다.
파일로 직접 로그를 남기는 경우
일반 로그와 에러 로그를 구분해서 파일로 남기기
클라우드에 로그를 저장하는 경우
검색이 잘 되도록 구분
정리를 하자면, 각각 용도가 다르다. 관찰을 할 땐 전체에서 좁게 가야한다. 핀포인트는 정말 좋다. 핀포인트는 무조건 사용할 것 마이크로서비스 분산 모니터링도 가능하고 대용량 트래픽에도 가능하다.
알람
모니터링 툴에서 일정 이상 수치가 넘어가면 슬랙 연동하기
알람은 2가지 종류(경고, 심각)로 꼭 구분해서 관리
왜 그럴까? 경고는 하루 1번 정도 사람이 그냥 들어가서 있나? 하고 보면 된다. 푸시 알림도 필요없다. 근데 심각은 즉시 확인해야 한다. 그래서 푸시 알림도 필요하다. 푸시 알림이 경고까지 적용되면 알림이 와도 느슨한 태도가 될 수 있어서 안된다. 그리고 업무와 삶에 방해가 되지 않아야 한다.
예를 들면,
디스크 사용량 70% - 경고
디스크 사용량 80, 90% - 심각
CPU 사용량 40% - 경고
CPU 사용량 50% - 심각
그리고 알림으로 정해놓은 것 중 알림이 아니어도 될 것 같다싶으면 바로바로 처리해야 한다. 이것도 알림 자체에 느슨한 태도를 유발할 수 있는 원인!
근데 그 전에 원래 사용하던 모니터링 툴이 있는데 모니터링 툴을 교체한다고 하면 어떻게 될까?
예를 들어, 기존에 사용하던 모니터링 툴이 JMX 모니터링 툴이었다고 해보자. 그럼 이 모니터링 툴에 지표를 전달하기 위해 JMX API를 사용해서 데이터를 전달하는데 중간에 프로메테우스를 사용한다고 하면 원래라면 도구가 다르니 전달하는 방식도 다르고 그럼 API도 교체해야 할 것이다. 그럼 모니터링 툴을 바꿨을뿐인데 애플리케이션에 수정이 일어난다.
이런 불편한 상황을 해결하기 위해 나타난 라이브러리가 마이크로미터(Micrometer)이다.
마이크로미터는 모니터링 툴에 전달하는 지표를 추상화해놓은 라이브러리이다. 그러니까 이 추상화가 이렇게 중요하다.
코드에서도 인터페이스와 그 인터페이스를 구현한 구현체가 아무리 많아지고 사용하는 기술이 바뀌어도 의존하고 있는 것이 인터페이스 하나 뿐이라면 기술이 바뀌어도 클라이언트 코드에는 수정이 필요없어진다. 마찬가지로 이 마이크로미터를 사용하면 모니터링 툴이 바뀌든 두개를 사용하든 상관없이 같은 API를 사용해서 지표를 전달할 수 있다.
그럼, 모니터링 툴을 사용하기 전에 어떤 지표가 있는지 확인해보자. 이 지표도 역시 스프링 부트의 액츄에이터가 우리를 위해 만들어준다.
액츄에이터를 활성화 시키는 내용은 바로 이 전 포스팅에 있으니 참고하고 이미 활성화되어 있다고 가정하고 시작해보자.
다음 URL에 접속해보자.
`http://yourbaseURL/actuator/metrics`
여기에 접속하면 스프링 부트가 우릴 위해 만들어주는 여러가지 지표가 있다.
보면 disk.free, disk.total, http.server.requests 등 여러 지표들이 있다.
그럼 이 여러 지표들 중 하나를 선택해서 더 자세히 볼 수 있는데 그 방법은 위 URL에 지표까지 넣어주는 것이다.
이건 URI가 `/log`이고 응답 코드가 200인 요청에 대해서만 필터링한 메트릭을 보여주는 URL이다.
이렇게 액츄에이터는 여러 모니터링을 위한 지표들을 제공한다. 이 지표들을 이제 모니터링 툴과 연동해서 사용할 수 있어보인다.
그럼 어떤 지표들이 있는지 조금 더 자세하게 알아보자.
마이크로미터와 액츄에이터가 기본으로 제공하는 다양한 메트릭을 확인해보자.
JVM 메트릭
시스템 메트릭
애플리케이션 시작 메트릭
스프링 MVC 메트릭
톰캣 메트릭
데이터 소스 메트릭
로그 메트릭
기타 수 많은 메트릭과 사용자가 직접 정의하는 메트릭
JVM 메트릭
JVM 관련 메트릭을 제공한다. `jvm.`으로 시작한다.
메모리 및 버퍼 풀 세부 정보
가비지 수집 관련 통계
스레드 활용
로드 및 언로드된 클래스 수
JVM 버전 정보
JIT 컴파일 시간
시스템 메트릭
시스템 메트릭을 제공한다. `system.`, `process.`, `disk.`으로 시작한다.
CPU 지표
파일 디스크립터 메트릭
가동 시간 메트릭
사용 가능한 디스크 공간
애플리케이션 시작 메트릭
애플리케이션 시작 시간 메트릭을 제공한다.
application.started.time: 애플리케이션을 시작하는데 걸리는 시간
application.ready.time: 애플리케이션이 요청을 처리할 준비가 되는데 걸리는 시간
스프링은 내부에 여러 초기화 단계가 있고 각 단계별로 내부에서 애플리케이션 이벤트를 발행한다.
ApplicationStartedEvent: 스프링 컨테이너가 완전히 실행된 상태이다. 이후에 커맨드 라인 러너가 호출된다.
ApplicationReadyEvent: 커맨드 라인 러너가 실행된 이후에 호출된다.
스프링 MVC 메트릭
스프링 MVC 컨트롤러가 처리하는 모든 요청을 다룬다. `http.server.requests`
`tag`를 이용해서 다음 정보를 분류해서 확인할 수 있다.
uri: 요청 URI
method: GET, POST와 같은 HTTP 메서드
status: 200, 400, 500 같은 HTTP Status 코드
exception: 예외
outcome: 상태 코드를 그룹으로 모아서 확인 (1xx: INFORMATIONAL, 2xx: SUCCESS, 3xx: REDIRECTION, 4xx: CLIENT_ERROR, 5xx: SERVER_ERROR)
데이터소스 메트릭
DataSource, 커넥션 풀에 관한 메트릭을 확인할 수 있다. `jdbc.connections.`로 시작한다.
최대 커넥션, 최소 커넥션, 활성 커넥션, 대기 커넥션 수 등을 확인할 수 있다.
히카리 커넥션 풀을 사용하면 `hikaricp.`를 통해 히카리 커넥션 풀의 자세한 메트릭을 확인할 수 있다.
로그 메트릭
logback.events: logback 로그에 대한 메트릭을 확인할 수 있다.
trace, debug, info, warn, error 각각의 로그 레벨에 따른 로그 수를 확인할 수 있다.
예를 들어서 `error` 로그 수가 급격히 높아진다면 위험한 신호로 받아들일 수 있다.
톰캣 메트릭
톰캣 메트릭은 `tomcat.`으로 시작한다. 톰캣 메트릭을 모두 사용하려면 다음 옵션을 켜야한다. (옵션을 켜지 않으면 `tomcat.session.`관련 정보만 노출된다.
application.yml
server:
tomcat:
mbeanregistry:
enabled: true
이 옵션을 키면 다음과 같이 `tomcat.session.`외에도 `tomcat.xxx.` 메트릭도 제공이 된다.
예를 들어, `tomcat.threads.config.max` 메트릭은 톰캣이 제공하는 쓰레드의 최대 개수를 보여준다. 그리고 현재 사용중인 쓰레드 수는 `tomcat.threads.current`로 확인할 수 있다.
기타 메트릭
HTTP 클라이언트 메트릭(RestTemplate, WebClient)
캐시 메트릭
작업 실행과 스케쥴 메트릭
스프링 데이터 레포지토리 메트릭
몽고DB 메트릭
레디스 메트릭
사용자 정의 메트릭
사용자가 직접 메트릭을 정의할 수도 있다. 예를 들어서 주문수, 취소수를 메트릭으로 만들 수 있다.
사용자 정의 메트릭을 만들기 위해서는 마이크로미터의 사용법을 먼저 이해해야 한다. 이 부분은 뒤에서 다룬다.
중간 정리
액츄에이터를 통해서 수많은 메트릭이 자동으로 만들어지는 것을 확인했다. 그런데 이러한 메트릭들을 어딘가에 지속해서 보관해야 과거의 데이터들도 확인할 수 있을것이다. 따라서 메트릭을 지속적으로 수집하고 보관할 데이터베이스가 필요하다. 그리고 이러한 메트릭들을 그래프를 통해서 한눈에 쉽게 확인할 수 있는 대시보드도 필요하다.
메트릭의 데이터베이스: 프로메테우스
애플리케이션에서 발생한 메트릭을 그 순간만 확인하는 것이 아니라 과거 이력까지 함께 확인하려면 메트릭을 보관하는 DB가 필요하다.
이렇게 하려면 어디선가 메트릭을 지속해서 수집하고 DB에 저장해야 한다. 프로메테우스가 바로 이런 역할을 담당한다.
그럼 그라파나는?
프로메테우스가 DB라고 하면, 이 DB에 있는 데이터를 불러서 사용자가 보기 편하게 보여주는 대시보드가 필요한데 그라파나는 매우 유연하게 데이터를 그래프로 보여주는 툴이다. 수 많은 그래프를 제공하고 프로메테우스를 포함한 다양한 데이터소스를 지원한다.
어떤 흐름으로 데이터를 보관하고 대시보드에 보여주는지는 다음과 같다.
스프링 부트 액츄에이터와 마이크로미터를 사용하면 수 많은 메트릭을 자동으로 생성한다.
마이크로미터 프로메테우스 구현체는 프로메테우스가 읽을 수 있는 포맷으로 메트릭을 생성한다.
프로메테우스는 이렇게 만들어진 메트릭을 지속해서 수집한다.
프로메테우스는 수집한 메트릭을 내부 DB에 저장한다.
사용자는 그라파나 대시보드 툴을 통해 그래프로 편리하게 메트릭을 조회한다. 이때 필요한 데이터는 프로메테우스를 통해서 조회한다.
그럼 이제 프로메테우스를 설치해야 한다. 아래 링크에서 설치하자.
설치하면 실행해보면 되는데 MacOS 유저 기준으로 설명한다. 실행을 최초에 하면 이러한 화면이 보인다.
그래서 System Settings > Privacy & Security > Open Anyway 버튼을 클릭해주자.
실행이 잘 된다면 다음과 같은 화면이 보여야한다.
프로메테우스는 기본 포트가 9090이다. 그래서 localhost:9090으로 가보면 이런 화면이 뜨면 된다.
태그, 레이블: 위 결과에서 `error`, `exception`, `instance`, `job`, `method`, `outcome`, `status`, `uri`는 각각의 메트릭 정보를 구분해서 사용하기 위한 태그이다. 마이크로미터는 이를 태그라고 하고 프로메테우스는 레이블이라고 한다.
숫자: 끝에 마지막에 보면 1919, 1 이런 값이 보인다. 이게 바로 해당 메트릭의 값이다.
보이는것처럼 특정 시간에 사용자 요청이 급격하게 올라가고 특정 시간에 급격하게 내려가는 것을 확인할 수 있다.
이와 비슷하게 rate()라는 것도 있는데, 이건 비율로 보여주는 거고 increase()는 정적인 숫자로 결과를 보여주는 것이라고 보면 된다.
irate()도 있는데 irate()는 rate()와 유사한데 범위 벡터에서 초당 순간 증가율을 계산한다. 급격하게 증가한 내용을 확인하기 좋다.
irate() 그래프
정리
게이지는 값이 계속 변하는, 오르락 내리락하는 값을 그래프로 표현한다. 카운터는 값이 단조롭게 증가하는 카운터는 increase(), rate()등을 사용해서 표현하면 된다. 이렇게 하면 카운터에서 특정 시간에 얼마나 고객의 요청이 들어왔는지 확인할 수 있다. 그러나, 프로메테우스의 단점은 한눈에 들어오는 대시보드를 만들어보기 어렵다는 점이다. 위에서도 뭔가 보기 위해 계속 지표를 변경하고, 시간을 바꾸고 등등의 수작업이 들어가는데 이런 부분을 그라파나로 해결할 수 있다.
tar -zxvf grafana-enterprise-11.1.0.darwin-amd64.tar.gz
참고로 이 설치는 다음 링크에서 자세히 확인해볼 수 있다.
설치를 다 마치면 `/bin` 폴더에 들어가야 한다. 들어가면 `grafana-server` 라는 실행 파일이 있다. 실행하자.
실행하면 쭉 로그가 찍히는데 대략 이렇게 생겼다.
잘 실행됐는지 확인하려면 `localhost:3000` 으로 들어가보자. 그라파나는 기본 포트가 3000이다.
최초 접속 정보는 `admin/admin` 이다. 추후에 변경할 수 있다.
로그인에 성공하면 다음과 같은 화면이 보일것이다.
그라파나 - 프로메테우스 연동
이제 프로메테우스로부터 데이터를 받아 그라파나에 데이터를 대시보드로 이쁘게 보여주자.
그러려면 우선 다음이 실행중이어야 한다.
애플리케이션 서버
프로메테우스 서버
그라파나 서버
그리고 연동하기 위해 좌측 사이드바에 Connections > Data sources를 클릭한다.
그럼 다음과 같은 화면이 나온다. Add data source 클릭
그럼 바로 앞에 프로메테우스가 보여진다. 클릭.
커넥션 주소를 넣어줘야 한다. 프로메테우스는 9090으로 띄워져 있다.
나머지는 필요없다. 최하단에 Save & test 버튼 클릭
잘 연동되면 다음과 같은 화면이 보인다.
그라파나 대시보드 만들기
이제 연동도 했으니 대시보드를 만들어보자. 좌측 사이드바에 Dashboards 클릭
그럼 이러한 화면이 보여진다. New > New dashboards 버튼 클릭
클릭하면 화면이 하나 보일텐데 우선 Save 버튼을 눌러서 대시보드를 저장하자.
그 다음 다시 처음 화면으로 돌아가서 + Add visualization 버튼 클릭
그럼 아래와 같은 화면이 보여진다. 여기서 가장 먼저 확인할 건 Datasource가 프로메테우스로 잘 되어 있는지 확인하자.
그 다음 그 하단에 쿼리를 날려서 데이터를 프로메테우스로부터 가져온다. 그러기 위해 우선 Builder 대신 Code를 선택하자.
가장 간단한 CPU 사용량을 확인해보자. 하단 사진처럼 `system_cpu_usage`을 입력하고 Run queries 버튼 클릭
그럼 위처럼 데이터를 가져와서 차트로 보여준다. 여기에 한 가지 지표를 더 추가하자.
하단에 + Add query 버튼 클릭
`process_cpu_usage`를 입력하고 Run queries 버튼을 클릭하면 두 지표가 동시에 보여진다.
그럼 보자. 두 지표가 동시에 이쁘게 잘 나온다. 상대적으로 System CPU는 거의 잡아먹지 않고 Process CPU가 좀 더 많이 사용중인걸 한 눈에 볼 수 있다. 근데 보여지는 이름이 맘에 들지 않는다. 그래서 이름을 좀 더 간결하게 바꿔주자. 아래 사진처럼 특정 지표에 하단 Options 버튼을 클릭하면 Legend라는 단어가 보인다. 이걸 범례라고도 하는데 이 값을 Custom으로 변경해주자.
그런 다음 값을 "System cpu"로 입력해주면 다음과 같이 화면에 보이는 값이 변경된다.
마찬가지로 Process cpu도 적용해주자. 그런 다음 이 패널의 제목을 다음과 같이 변경해주자.
다 했으면 우측 상단에 Apply 버튼 클릭. 그럼 이렇게 보여진다.
대시보드 만드는 거 어렵지 않다. 깔끔하게 잘 만들었다! 하나 더 만들어보자.
이번엔 디스크 사용량을 추가해보자. 이젠 여기까지 직접 할 수 있다.
두 개의 쿼리가 있는데, 하나는 전체 용량이고 하나는 전체 용량에서 여유 용량을 뺀 즉, 사용량이다.
이런식으로 연산도 가능하다.
근데, 다 좋은데 좌측에 값이 바이트 값으로 나와있어서 보기가 어렵다. 사람이 보기 편하게 바꾸고 싶은데 이럴땐 우측에 보면
Standard options > Unit 이것을 수정해주면 된다. Data > bytes(SI)로 수정해보자.
그럼 아래와 같이 깔끔하게 보여진다.
그리고 또 저장하자. 그럼 이렇게 잘 보여진다.
드래그로 이쁘게 한 줄로 만들어보자.
이렇게 이쁘게 하나하나 꾸며서 만들면 이제 시각적으로 메트릭 정보를 얻을 수 있게 됐다.
근데, 프로젝트 할 때마다 이렇게 하나 하나 만드는 것도 여간 귀찮은 일이 아니다. 그러면? 이미 만들어둔 대시보드를 가져다가 사용할 수 있는 기능을 제공한다. 그것도 아주 잘 만들어 놓은. 그것을 사용해보자.
그라파나 공유 대시보드 활용
사람들이 자기가 만든 대시보드를 공유하는 사이트가 있다.
위 링크에 접속하면 여러 대시보드가 있는데, 여기에 "Spring"이라고 쳐보자.
그럼 아래처럼 여러개가 나온다. 저기 JVM (Micrometer)와 Spring Boot 2.1 System Monitor 이 두 개는 엄청 유명하다.
Spring Boot 2.1 System Monitor
Spring Boot 2.1 System Monitor 이거를 사용해보자. 클릭해서 들어가보면 다음과 같이 보여진다.
여기서 우측에 Copy ID to clipboard 버튼 클릭
그리고 다시 그라파나로 돌아오자. 그래서 대시보드에 새로운 대시보드를 만들어보자.
Dashboards > New > Import 클릭
여기에 아까 복사한 ID를 넣고 Load 클릭
그럼 이런 화면이 나오는데 다른건 손댈게 없고 데이터소스만 프로메테우스로 잘 선택해주자. 그리고 Import 클릭
그럼 짜잔! 아름다운 대시보드가 만들어진다. 아래로 내리면 끝도 없이 많다!
이 대시보드를 수정할 수도 있다. 위에 설정 버튼을 눌러보자.
그럼 이 화면에서 Make editable 버튼 클릭
그럼 대시보드 들어가서 이렇게 어떤 쿼리를 쓴건지도 확인 가능하고 수정도 할 수 있다.
저기 쿼리에서 보면 instance, application은 딱 보니 변수로 받는거 같다. 이 변수 어디서 오는걸까?
이 대시보드가 여러 인스턴스나 애플리케이션으로 적용할 수가 있다. 그래서 맨 위로 가보면 이런게 있다.
여기서 어떤 인스턴스나 애플리케이션을 선택하느냐에 따라 저 값을 동적으로 바꿀 수 있게 변수로 받고 있다.
정말 유용한거 같다! 근데 다 좋은데 우리의 스프링 부트는 톰캣으로 띄워지는데 톰캣에 대한 정보가 없는게 아쉽다. 그래서 변경해보자.
지금 데이터가 거의 없는 패널이 있다. 바로 Jetty Statistics 패널이다.
이걸 톰캣으로 바꿔보자.
그 다음에 Thread Config Max를 톰캣으로 변경해보자.
그리고 쿼리를 이렇게 변경해주자. 그럼 200개로 나온다. 톰캣은 기본 쓰레드 최대수가 200이다.
그런 다음, 지금 현재 사용중인 쓰레드 개수를 보고 싶다. 그래서 이 부분을 수정하자. 지금은 전부 Jetty로 되어 있어서 데이터가 안 나온다.
그래서 딱 이 두개의 쿼리를 적용해보자.
tomcat_threads_current_threads는 현재 톰캣에서 확보해 둔 쓰레드 개수를 의미하고
tomcat_threads_busy_threads는 지금 사용중인 쓰레드 수를 의미한다. 만약, 이 busy 쓰레드가 200개가 되면 애플리케이션이 죽을거다. 최대가 200개니까. 여튼 이렇게 하고 Apply 버튼 클릭하면 이제 잘 보여진다.
JVM (Micrometer)
이번엔 또 유명하다고 했던 JVM (Micrometer) 이거를 사용해보자.
똑같이 임포트를 해보면 다음과 같이 생겼다.
이렇게, JVM 쪽에 좀 더 초점을 둔 여러 데이터를 가시화했다. 둘 다 유용하게 사용할 수 있다.
대시보드 테스트 해보기
대시보드를 깔끔하게 다 구성했으니, 실제로 JVM 메모리라던가, CPU 사용량에 급격한 과부하를 줘서 어떻게 대시보드가 표현되는지 파악해보자. 우선 CPU 사용량을 급격하게 늘리기 위해 다음과 같은 코드를 짜보자.
TrafficController
package hello.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@RestController
public class TrafficController {
@GetMapping("/cpu")
public String cpu() {
log.info("cpu");
long value = 0;
for (int i = 0; i < 10000000000000L; i++) {
value++;
}
return String.valueOf(value);
}
}
다음과 같이 연산을 아주 아주 많이 반복해서 실행하면 CPU 사용량이 급격하게 올라갈거다. 이 컨트롤러를 호출해보자.
그럼 이러한 결과를 볼 수 있다. 갑자기 팍 치솟는 구간이 생긴다.
이번엔 JVM 메모리를 OOM 내보자. 이런 코드를 짜보자.
TrafficController
package hello.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@RestController
public class TrafficController {
private List<String> list = new ArrayList<>();
@GetMapping("/jvm")
public String jvm() {
log.info("jvm");
for (int i = 0; i < 10000000; i++) {
list.add("hello jvm!" + i);
}
return "ok";
}
}
리스트에 계속 계속 뭘 담아보자. 그럼 이러한 대시보드의 변화를 볼 수 있다.
보면 JVM Memory의 최대치에 거의 근접해가는 사용량을 확인할 수 있다. 그리고 이 최대치에 도달하면? OOM이 터진다.
실제로 터져버려서 이런 에러가 뜬다. 대시보드에 적용되기도 전에 터져서 대시보드엔 보이지 않지만.
이번엔 커넥션 풀에 커넥션을 계속 사용해보자. 어떻게 될까?
TrafficController
package hello.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@RestController
public class TrafficController {
@Autowired
DataSource dataSource;
@GetMapping("/jdbc")
public String jdbc() throws SQLException {
Connection connection = dataSource.getConnection();
log.info("connection info = {} ", connection);
// connection.close(); 원래는 이렇게 닫아야한다.
return "ok";
}
}
이런 코드를 짜보자. 원래는 사용한 커넥션은 반납을 해야 한다. 그렇지 않으면 큰일난다. 근데 큰일을 내보자.
그리고 계속 호출해보자. 10번까지. (기본이 10개다)
그리고 일단 한 3번 호출해보자. 그럼 대시보드에 이렇게 보여진다.
Active가 3개, 전체는 10개다. 이제 10개를 넘겨보자.
그럼 다음과 같이 Pending 커넥션이 생기게 된다. 계속 기다리고 있게 된다. 커넥션을 반납하지 않고 있기 때문에.
이런 모습이 보인다면, "큰일났다!" 라고 생각하면 된다. 그리고 이런 에러 로그가 보일거다.
이번엔 에러 로그를 계속 찍어보자. 이것도 확인이 되면 "어 뭐가 문제가 생긴것 같다!" 라고 느껴야 한다.
그래서 실제 저기서 알려주는 health, caches, conditions 등등 여러 종류의 URL로 접속해보면 현재 스프링 부트의 서버 정보 관련된 내용이 나온다.
엔드포인트 설정하기
엔드포인트를 사용하려면 다음 2가지 과정이 모두 필요하다.
엔드포인트 활성화
엔드포인트 노출
엔드포인트를 활성화 한다는 것은 해당 기능 자체를 사용할지 말지 on, off를 선택하는 것이다.
엔드포인트를 노출하는 것은 활성화된 엔드포인트를 HTTP에 노출할지 아니면 JMX에 노출할지 선택하는 것이다. 엔드포인트를 활성화하고 추가로 HTTP를 통해서 웹에 노출할지, 아니면 JMX를 통해서 노출할지 두 위치에 모두 노출할지 노출 위치를 지정해주어야 한다.
물론 활성화가 되어 있지 않으면 노출도 되지 않는다. 그런데 엔드포인트는 대부분 기본으로 활성화 되어 있다. (shutdown 제외) 노출이 되어 있지 않을 뿐이다. 따라서 어떤 엔드포인트를 노출할지 선택하면 된다. 참고로 HTTP와 JMX를 선택할 수 있는데, 보통 JMX는 잘 사용하지 않으므로 HTTP에 어떤 엔드포인트를 노출할지 선택하면 된다.
각각의 엔드포인트를 통해서 개발자는 애플리케이션 내부의 수 많은 기능을 관리하고 모니터링 할 수 있다.
스프링 부트가 기본으로 제공하는 다양한 엔드포인트에 대해서 알아보자. 다음은 자주 사용하는 기능 위주로 정리했다.
엔드포인트 목록
beans: 스프링 컨테이너에 등록된 스프링 빈을 보여준다.
conditions:condition을 통해서 빈을 등록할 때 평가 조건과 일치하거나 일치하지 않는 이유를 표시한다.
configprops:@ConfigurationProperties를 보여준다.
health: 애플리케이션 헬스 정보를 보여준다.
httpexchanges: HTTP 호출 응답 정보를 보여준다. HttpExchangeRepository를 구현한 빈을 별도로 등록해야 한다.
info: 애플리케이션 정보를 보여준다.
loggers: 애플리케이션 로거 설정을 보여주고 변경도 할 수 있다.
shutdown: 애플리케이션을 종료한다. 이 기능은 기본으로 비활성화 되어 있다.
전체 엔드포인트는 공식 메뉴얼을 참고
Health 정보
이 정보가 은근히 아주 쏠쏠하게 도움이 많이 되는데 예를 들면 DB 상태, 디스크 상태 등 여러 유용한 정보를 보여주기 때문에 이 기능을 잘 사용하면 좋다. 기본으로는 별 정보가 안나온다. 근데 다음과 같이 show-details 옵션을 always로 변경하면 더 자세한 정보를 출력해준다.
이렇게 설정해 둔 채로 `/actuator/health` 로 이동해보면 다음과 같이 보여진다.
우선, 첫번째 status는 UP 또는 DOWN을 표시할 수 있는데, 아래 components 목록 중 하나라도 DOWN이라면 저 status는 DOWN이 된다. DB의 헬스 상태를 확인을 어떻게 할까? 예전에는 실제로 디비에 더미 쿼리를 날려서 날린 쿼리를 잘 응답하는지 알아봤는데 요새는 디비의 상태 체크를 해주는 옵션 자체가 디비마다 있다. 그래서 그 방식으로 헬스 체크를 하고, 정상 응답을 받으면 다음과 같이 UP 상태로 띄워지게 된다.
그래서 만약에 이 정보를 보고 DB가 DOWN인 상태라면 어? 이 애플리케이션 또는 이 서버의 디비가 현재 맛이 갔네? 라는 사실을 빠르게 인지할 수 있고 그에 따른 대응도 당연히 빨라질 수 밖에 없다. 이 DB상태가 DOWN이 되면 알림을 보내는 기능을 구현할수도 있고 여러 방법을 통해서 말이다.
저런 세부적인 내용까지 볼 필요없고 그냥 상태가 UP인지 DOWN인지만 보고 싶으면 다음과 같이 show-components를 always로 설정하면 된다.
그래서, 이 JSON 데이터를 대시보드로 이쁘게 꾸밀수도 있고, 알림 설정을 해놔서 상태가 DOWN이 되면 곧바로 담당자에게 알림을 보내는 기능을 통해 애플리케이션의 장애를 빠르게 대응할 수 있게 된다. 일단, 어디서 어떤 문제가 생겼는지를 바로 체크할 수 있다는 것 자체가 대응의 시간을 전폭적으로 줄여주기 때문에 상당히 유용한 기능이라고 볼 수 있다.
info: 애플리케이션 정보
이번엔 info에 대해 알아보자. 이 info는 애플리케이션 정보를 알려준다. 예를 들면 OS 정보, JVM정보, 환경 변수 정보, Git 정보등을 말이다.
INFO레벨부터 로그가 찍혔다. 이 이유는 해당 패키지에 대한 로그 레벨이 INFO이기 때문이다. 실제로 그런지 액츄에이터로 확인해보자.
이는 ROOT의 기본 로그 레벨이 INFO라서 그 하위 패키지들은 따로 변경하지 않는 이상 전부 ROOT의 로그 레벨을 따라간다.
근데 이 액츄에이터는 이렇게 로그 레벨을 확인하는 기능도 있지만 실행중인 애플리케이션의 로그 레벨을 변경할 수도 있다.
예를 들어보자. 만약 운영중인 실제 서버가 어떤 장애가 났는데 해당 장애를 알기 위해 DEBUG로 찍은 로그를 확인하고 싶다. 보통은 로컬 또는 개발 서버에는 TRACE, DEBUG로 로그 레벨을 잡고 운영 중인 서버는 INFO부터 로그 레벨을 잡는게 일반적이다. 그럼 운영 중인 서버에서는 DEBUG 로그는 출력되지 않기 때문에 디버깅을 하기 어려운 환경이다. 이러한 상황일때 방법은 두가지가 있다.
이 파일에 logging.level.{원하는 패키지}: 로그레벨을 설정하면 된다. 그러나 이건 어떤 불편함이 있냐면, 이렇게 하면 로그 레벨을 바꾸고 다시 실행해야 하는 부분과 실행해서 원하는 작업을 다 끝내면 다시 로그 레벨을 원래대로 돌려놓고 또 다시 실행해야 하는 이런 단계를 거쳐야하고 그 단계를 거치면서 서버 다운 타임이 생기게 된다. 보통은 이런 경우를 원하지는 않을 것이다.
그럼 이럴땐 액츄에이터를 이용하면 된다. 액츄에이터로 로그 레벨을 확인하는 방법은 저렇게 전역으로 확인하는 방법도 있지만 딱 특정 패키지만을 확인하는 방법도 있다. 다음과 같이 path 마지막에 원하는 패키지명을 적어주면 된다.
개발을 하다보면 로컬 환경에서 사용될 빈과 운영 환경에서 사용될 빈이 달라져야 하는 경우가 더러 있다.
예를 들면, 결제 관련 빈은 로컬 환경에서 테스트를 위해 가짜 결제 빈을 등록해서 테스트만을 위해 수행되어야 하고 운영 환경에서는 실제 결제 서비스를 통한 결제가 이루어져야 한다. 이런 경우에 구분된 빈이 스프링 컨테이너에 등록되어야 하는데 이걸 환경에 따라 편리하게 나눌수가 있다.
@Profile 애노테이션을 활용하면 된다.
다음 코드를 보자.
PayClient
package hello.pay;
public interface PayClient {
void pay(int money);
}
LocalPayClient
package hello.pay;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LocalPayClient implements PayClient {
@Override
public void pay(int money) {
log.info("로컬 결제 money={}", money);
}
}
ProdPayClient
package hello.pay;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ProdPayClient implements PayClient {
@Override
public void pay(int money) {
log.info("운영 결제 money={}", money);
}
}
PayClient라는 인터페이스를 하나 만들고 이를 구현하는 구현체(LocalPayClient, ProdPayClient)를 만들었다.
이 두 구현체를 빈으로 동시에 등록할 순 없다. 왜냐하면 둘 다 PayClient를 구현하는 구현체이므로. (물론 원한다면 할 수는 있다 근데 그게 지금 목적이 아니니)
그래서 Configuration 클래스를 하나 만들어보자.
PayConfig
package hello.pay;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Slf4j
@Configuration
public class PayConfig {
@Bean
@Profile("default")
public PayClient localPayClient() {
log.info("LocalPayClient 빈 등록");
return new LocalPayClient();
}
@Bean
@Profile("prod")
public PayClient prodPayClient() {
log.info("ProdPayClient 빈 등록");
return new ProdPayClient();
}
}
두 빈을 등록하는데 @Profile 애노테이션으로 LocalPayClient는 @Profile("default")일 때 등록되는 구현체다. ProdPayClient는 @Profile("prod")일 때 등록되는 구현체다. 이렇게 현재 프로필에 따라 빈으로 등록되는 구현체를 지정할 수 있다. 스프링 부트에서 해주는 아주 편리하고 좋은 기능이다.
그럼 이제 사용하는 서비스 코드를 보자.
OrderService
package hello.pay;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class OrderService {
private final PayClient payClient;
public void order(int money) {
payClient.pay(money);
}
}
이 OrderService는 PayClient를 주입받는다. 어떤걸 주입받을지 이 OrderService는 알지 못한다. 이것 또한 유지보수에 좋은 코드이다. OCP원칙. 주입 시점을 이후로 미루는 것.
그리고 이 코드를 실제로 호출해서 사용해봐야 하는데 지금은 컨트롤러나 뭐 웹 서버를 띄우는게 아니니까 ApplicationRunner를 구현해서 스프링이 띄워질때 호출되는 코드가 생기도록 해보자.
OrderRunner
package hello.pay;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* ApplicationRunner는 이 구현체를 스프링이 뜨는 시점에 자동으로 실행해준다.
* */
@Component
@RequiredArgsConstructor
public class OrderRunner implements ApplicationRunner {
private final OrderService orderService;
@Override
public void run(ApplicationArguments args) throws Exception {
orderService.order(10000);
}
}
이렇게 코드를 작성하면 스프링이 띄워질때 이 OrderRunner라는 ApplicationRunner를 구현한 구현체의 구현 메서드인 run()이 호출된다. 프로필을 아무것도 주지않고 (즉, default 프로필) 실행해보자. 실행결과는 다음과 같다.
2024-06-30T20:33:35.161+09:00 INFO 21109 --- [ main] hello.ExternalReadApplication : No active profile set, falling back to 1 default profile: "default"
2024-06-30T20:33:35.477+09:00 INFO 21109 --- [ main] hello.pay.PayConfig : LocalPayClient 빈 등록
2024-06-30T20:33:35.583+09:00 INFO 21109 --- [ main] hello.datasource.MyDataSource : url: local.db.com
2024-06-30T20:33:35.584+09:00 INFO 21109 --- [ main] hello.datasource.MyDataSource : username: username
2024-06-30T20:33:35.584+09:00 INFO 21109 --- [ main] hello.datasource.MyDataSource : password: password
2024-06-30T20:33:35.584+09:00 INFO 21109 --- [ main] hello.datasource.MyDataSource : maxConnection: 1
2024-06-30T20:33:35.584+09:00 INFO 21109 --- [ main] hello.datasource.MyDataSource : timeout: PT3.5S
2024-06-30T20:33:35.584+09:00 INFO 21109 --- [ main] hello.datasource.MyDataSource : options: [CACHE, ADMIN]
2024-06-30T20:33:35.638+09:00 INFO 21109 --- [ main] hello.ExternalReadApplication : Started ExternalReadApplication in 0.767 seconds (process running for 1.109)
2024-06-30T20:33:35.640+09:00 INFO 21109 --- [ main] hello.pay.LocalPayClient : 로컬 결제 money=10000
결과를 보면 알 수 있듯, 로컬 결제 빈이 등록되어 실행됐다. 만약 프로필을 `prod`로 주고 실행하면 다음과 같은 실행결과가 도출된다.
2024-06-30T20:38:38.289+09:00 INFO 21334 --- [ main] hello.ExternalReadApplication : The following 1 profile is active: "prod"
2024-06-30T20:38:38.649+09:00 INFO 21334 --- [ main] hello.pay.PayConfig : ProdPayClient 빈 등록
2024-06-30T20:38:38.743+09:00 INFO 21334 --- [ main] hello.datasource.MyDataSource : url: local.db.com
2024-06-30T20:38:38.743+09:00 INFO 21334 --- [ main] hello.datasource.MyDataSource : username: username
2024-06-30T20:38:38.743+09:00 INFO 21334 --- [ main] hello.datasource.MyDataSource : password: password
2024-06-30T20:38:38.743+09:00 INFO 21334 --- [ main] hello.datasource.MyDataSource : maxConnection: 1
2024-06-30T20:38:38.743+09:00 INFO 21334 --- [ main] hello.datasource.MyDataSource : timeout: PT3.5S
2024-06-30T20:38:38.743+09:00 INFO 21334 --- [ main] hello.datasource.MyDataSource : options: [CACHE, ADMIN]
2024-06-30T20:38:38.791+09:00 INFO 21334 --- [ main] hello.ExternalReadApplication : Started ExternalReadApplication in 0.83 seconds (process running for 1.191)
2024-06-30T20:38:38.792+09:00 INFO 21334 --- [ main] hello.pay.ProdPayClient : 운영 결제 money=10000
MyDataSourceEnvConfig 여기에서 MyDataSource를 빈으로 등록한 다음 필요한 값들을 Environment를 통해 외부 설정으로부터 가져온다. 이 Environment는 어떤 외부 설정이던 상관없이 가져올 수 있게 스프링이 제공해주는 아주 좋은 추상화된 객체이므로 위 코드처럼 값들을 가져올 수 있다.
한번 서버를 실행해보면 결과는 다음과 같다.
보이는 것처럼 @PostConstruct에 의해 호출된 데이터값들이 잘 보여진다.
근데, 이 방식은 어떤 단점이 있는가하면, 이 Environment를 직접 주입받는 것 자체에 있다. 주입을 직접 받는것도 불편하지만 받은 객체를 통해 계속해서 .getProperty()를 호출해서 꺼내와야만 한다. 이것을 반복하는 게 단점이다. 스프링은 @Value를 통해서 외부 설정값을 주입 받는 더욱 편리한 기능을 제공한다.
@Value
이 방법은 스프링이 외부 설정값을 편리하게 주입받게 해주는 방법이다. (사실 이 방법도 내부적으로는 Environment를 사용한다)
이렇게해도 가져올 수 있다. 그리고 이 방법은 외부 설정에서 값을 찾지 못하면 기본값으로 대체할 수 있는 기능도 제공하는데 이는 ":"로 기본값을 추가적으로 작성하면 된다.
@Value("${my.datasource.etc.max-connection:2}")
private int maxConnection;
이렇게 :2 라고 해두면 없는 경우 기본값을 2로 받아오겠다는 의미가 된다.
그래서 이렇게 간단하게 가져올 수는 있다만 이 방법 역시 하나하나 외부 설정 정보의 키 값을 입력받고 주입 받아와야 하는 부분이 번거롭다. 그리고 설정 데이터를 보면 하나하나 분리되어 있는 게 아니라 정보의 묶음으로 되어 있다. my.datasource.xxx 이렇게 말이다.
그 말은 이 부분을 객체로 변환해서 사용할 수 있다면 더 편리하고 더 좋을 것이다. 그 방법을 알아보자.
외부 설정 사용 - @ConfigurationProperties
이번에는 외부 설정 값을 객체로 가져올 수 있는 방법에 대해 알아보자.
우선, 객체로 가져오려면 객체가 필요하다.
MyDataSourcePropertiesV1
package hello.datasource;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@Data
@ConfigurationProperties("my.datasource")
public class MyDataSourcePropertiesV1 {
private String url;
private String username;
private String password;
private Etc etc;
@Data
public static class Etc {
private int maxConnection;
private Duration timeout;
private List<String> options = new ArrayList<>();
}
}
이 객체를 보면, @ConfigurationProperties("my.datasource") 애노테이션이 있다. 이 애노테이션을 사용하면, .yml 파일이나 .properties 파일에 값을 등록한 `my.datasource`키에 대한 값을 객체로 변환해서 읽어오게된다.
그리고 그 중에 url, username, password는 바로 접근할 수 있지만, max-connection, timeout, options는 etc라는 키 하위에 존재한다. 다음 application.yml 파일을 비교해서 봐보자.
이 타입이 아닌 값이 외부 설정으로 부터 들어오면 에러를 뱉어낸다. 또한 계층 구조도 일치하는지 확인해주기 때문에 안전하고 재사용 가능한 객체 형태의 외부 설정값을 가져올 수 있다.
자, 객체 형태로 외부 설정을 가져와 빈으로 등록해서 여기저기 주입을 통해 사용할 수 있다는 것도 알게됐다. 근데 한가지 불편한 점이 있는데 아래 @ConfigurationProperties("my.datasource") 애노테이션을 붙였는데,
@Data
@ConfigurationProperties("my.datasource")
public class MyDataSourcePropertiesV1 {...}
여기서 또 @EnableConfigurationProperties(MyDataSourcePropertiesV1.class) 애노테이션을 붙여야 한다는 점이다.
@Slf4j
@EnableConfigurationProperties(MyDataSourcePropertiesV1.class)
public class MyDataSourcePropertiesConfigV1 {...}
그냥 한번만 애노테이션을 달면 좋겠는데, 귀찮게 두번 다 해줘야한다. 이 또한 해결 방법이 있다. 아래 애노테이션을 스프링 부트의 메인 클래스에 붙여주면 메인 클래스부터 하위 모든 패키지를 찾아서 @ConfigurationProperties 애노테이션을 등록한 클래스를 빈으로 알아서 등록해준다.
package hello.config;
import hello.datasource.MyDataSource;
import hello.datasource.MyDataSourcePropertiesV1;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@Slf4j
// @EnableConfigurationProperties(MyDataSourcePropertiesV1.class)
public class MyDataSourcePropertiesConfigV1 {
private final MyDataSourcePropertiesV1 properties;
public MyDataSourcePropertiesConfigV1(MyDataSourcePropertiesV1 properties) {
this.properties = properties;
}
@Bean
public MyDataSource dataSource() {
return new MyDataSource(
properties.getUrl(),
properties.getUsername(),
properties.getPassword(),
properties.getEtc().getMaxConnection(),
properties.getEtc().getTimeout(),
properties.getEtc().getOptions());
}
}
그리고 이제 @EnableConfigurationProperties(MyDataSourcePropertiesV1.class) 이 애노테이션을 주석처리했다.
실행해보면 결과는 동일하게 잘 동작한다.
실행결과
2024-06-22T15:11:34.007+09:00 INFO 1827 --- [ main] hello.ExternalReadApplication : Starting ExternalReadApplication using Java 17.0.9 with PID 1827 (/Users/choichiwon/Spring/external-read/out/production/classes started by choichiwon in /Users/choichiwon/Spring/external-read)
2024-06-22T15:11:34.008+09:00 INFO 1827 --- [ main] hello.ExternalReadApplication : No active profile set, falling back to 1 default profile: "default"
2024-06-22T15:11:34.179+09:00 INFO 1827 --- [ main] hello.datasource.MyDataSource : url=local.db.com
2024-06-22T15:11:34.179+09:00 INFO 1827 --- [ main] hello.datasource.MyDataSource : username=local_user
2024-06-22T15:11:34.179+09:00 INFO 1827 --- [ main] hello.datasource.MyDataSource : password=local_pw
2024-06-22T15:11:34.179+09:00 INFO 1827 --- [ main] hello.datasource.MyDataSource : maxConnection=1
2024-06-22T15:11:34.179+09:00 INFO 1827 --- [ main] hello.datasource.MyDataSource : timeout=PT1M
2024-06-22T15:11:34.179+09:00 INFO 1827 --- [ main] hello.datasource.MyDataSource : options=[LOCAL, CACHE]
2024-06-22T15:11:34.217+09:00 INFO 1827 --- [ main] hello.ExternalReadApplication : Started ExternalReadApplication in 0.387 seconds (process running for 0.578)
남은 문제
어느 정도 해결된 것 같지만, 한가지 정말 불편한 점이 있다. Lombok의 @Data 애노테이션을 사용했는데 이 애노테이션은 Setter를 자동으로 만들어주는 것이다. Setter는 되도록이면 없어야 한다. 적어도 난 그렇게한다. 세터로부터 파생되는 찾기 힘든 문제들이 너무 많기 때문에. 그래서 이 Setter 대신 생성자를 통해서 외부 설정 값을 넣는 방법을 알아보자.
외부 설정 사용 - @ConfigurationProperties (생성자를 통해서)
위에 말한것처럼, 남은 문제가 있다. Setter를 남겨두고 싶지 않다. 그래서 생성자를 통해 외부 설정 값을 주입하는 방법이 있다.
우선 V2를 만든다. 여기서는 @Data를 지우고, @Getter만 남겨두자. 그리고 이 클래스의 생성자를 만들면 된다. 위 코드처럼. 그럼 끝이다.
실행결과
2024-06-29T15:22:08.257+09:00 INFO 71180 --- [ main] hello.datasource.MyDataSource : url=local.db.com
2024-06-29T15:22:08.257+09:00 INFO 71180 --- [ main] hello.datasource.MyDataSource : username=local_user
2024-06-29T15:22:08.257+09:00 INFO 71180 --- [ main] hello.datasource.MyDataSource : password=local_pw
2024-06-29T15:22:08.257+09:00 INFO 71180 --- [ main] hello.datasource.MyDataSource : maxConnection=1
2024-06-29T15:22:08.257+09:00 INFO 71180 --- [ main] hello.datasource.MyDataSource : timeout=PT1M
2024-06-29T15:22:08.257+09:00 INFO 71180 --- [ main] hello.datasource.MyDataSource : options=[DEFAULT]
2024-06-29T15:22:08.297+09:00 INFO 71180 --- [ main] hello.ExternalReadApplication : Started ExternalReadApplication in 0.397 seconds (process running for 0.589)
Process finished with exit code 0
@PostConstruct 애노테이션으로부터 실행되는 메서드의 로그가 잘 출력됐고, 값도 잘 들어갔다. 그리고 V2에 보면 내부 클래스(Etc)의 생성자에 보면 @DefaultValue라는 애노테이션이 있다. 이건 외부 설정으로부터 해당 키가 없으면 그 값에 기본값을 넣어주는 방법이다. 그래서 해당 키가 있으면 그 값을 그대로 가져오고 없으면 저기에 선언한 기본값을 넣어준다.
스프링 3.0 이전에는 생성자에 @ConstructorBinding 애노테이션이 없으면 안됐다. 필수적으로 달아줘야 했던 애노테이션인데, 3.0 이후에는 생성자가 하나뿐이라면 생략 가능하다. 그래서 위 코드에서도 해당 애노테이션은 없다.
남은 문제
남은 문제가 또 있다고?! 라고 생각하겠지만, 문제라기보단 더 많은 기능을 원한다. 이제는 생성자를 통해서 외부 설정값을 받아오고, 타입 안정성도 체크가 된다. 근데 검증을 하고 싶다. 예를 들면, max-connection이 최소 1보단 커야 이 값의 의미가 있다고 가정할때 0이 들어오면 아무런 의미없게 애플리케이션은 정상 동작을 하지 않을것이다. 그러나 타입 안정성에는 아무런 문제가 없기 때문에 어떤 에러로 인식시켜 주지 않는다. 내가 원하는건 만약 내가 실수로 0 또는 그보다 작은 수를 적었으면 바로 에러를 띄워 알려주길 원한다. 이게 가능할까?
외부 설정 사용 - @ConfigurationProperties (검증기 도입)
이제는 한 단계 더 나아가서 검증 기능까지 추가해서 더 안전한 외부 설정 주입을 사용해보자.
참고로, 위 두개 중 패키지 이름에 jakarta.validation으로 시작하는 것은 자바 표준 검증기에서 지원하는 기능이다. org.hibernate.validator로 시작하는 것은 자바 표준 검증기에서 아직 표준화 된 기능은 아니고 하이버네이트 검증기라는 표준 검증기의 구현체에서 직접 제공하는 기능이다. 대부분 하이버네이트 검증기를 사용하므로 크게 문제될 건 없다.
Part.1에서는 세가지를 배웠다. OS 환경 변수, 자바 시스템 속성, 커맨드 라인 인수.
근데 배우고 보니 저 세가지 방법 모두가 코드에서 가져오는 방식이 다 다르다는 것을 깨달았다. 그럼 여기서 스프링은 이를 두고보지 않는다. 우리에게 추상화 기능을 제공해서 아주 편리하게 어떻게 설정값을 지정했던 상관없이 한 가지 방법으로 모든 방법을 사용할 수 있도록 한다. 다음 그림을 보자.
스프링에서는 커맨드 라인 옵션 인수이던, 자바 시스템 속성이던, OS 환경변수이던, 설정 데이터(파일)이던 상관없이 딱 하나 `Environment` 객체를 통해서 원하는 값을 가져올 수 있다.
이 Environment는 역할(인터페이스)이고 이를 구현한 여러 구현체가 있다. 우리는 그 각각의 세부적인 구현체에 대해 자세히 알 필요없이 그저 Environment만 가져다가 사용하면 된다. 이것이 바로 변경가능한 부분과 변경하지 않아도 되는 부분을 잘 분리했다라고 말 할 수 있는 상황이다.
근데, 한가지 궁금한 부분이 생긴다. 그럼 만약 OS 환경 변수와 자바 시스템 속성 둘 다 또는 그 이상이 모두 같은 key를 가지는 값이 있을땐 무엇을 가져올까? 스프링이 자체적으로 우선순위를 만들어 두었다. 그 우선순위에 대해 알아보자.
예를 들어, 다음과 같이 커맨드 라인 옵션 인수와 자바 시스템 속성으로 같은 키를 지정했다면 어떤 값을 가져올까?
실행결과
url: devurl, username: dev_user, password: dev_pw
결과는 커맨드 라인 옵션 인수값을 가져온다. 즉, 자체적으로 우선순위가 커맨드 라인 옵션 인수가 더 높다는 뜻이다. 그럼 이걸 외워야하나?
우선순위는 상식 선에서 딱 2가지만 기억하면 된다.
더 유연한 것이 우선권을 가진다 (변경하기 어려운 파일 보다 실행 시 원하는 값을 줄 수 있는 자바 시스템 속성이 더 우선권을 가진다)
범위가 넓은 것보다 좁은 것이 우선권을 가진다(OS 환경 변수처럼 전역으로 여기저기 프로그램에서 가져올 수 있는 값보다 JVM 안에서만 접근 가능한 자바 시스템 속성이 더 우선순위가 높고, JVM 안에서 모두 접근 가능한 경우보다 커맨드 라인 옵션 인수는 main의 args를 통해서 들어오기 때문에 이 커맨드 라인 옵션 인수가 더 우선순위가 높다)
이제 스프링이 제공해주는 추상화 `Environment`를 통해 어떻게 외부에 데이터(설정값)를 저장했다고 해도 편리하게 가져다가 사용할 수 있게 됐다. 그럼 남은 한 가지, 외부 파일을 통해 가져오는 것도 알아보자.
설정 데이터1 - 외부 파일
지금까지 배운 내용으로 외부로부터 설정 데이터를 가져올 수 있게 됐다. 근데 사실 설정값이 지금이야 3개뿐이니 굉장히 간단하고 편해보이지만 실제 운영서버에서 사용되는 설정 데이터는 몇십개 몇백개도 존재할 수 있다. 그럼 벌써 머리 아프다. 관리하기가 굉장히 난처해진다.
그래서 그렇게 많은 데이터를 관리하기엔 파일로 관리하는게 최고다.
그래서 파일로 관리를 하고 애플리케이션 로딩 시점에 해당 파일을 읽어들이면 된다. 그 중에서도 .properties 파일이나 .yml 파일이 key=value 형식으로 설정값을 관리하기에 아주 적합하다.
그래서 .jar 파일을 빌드를 통해 만들고 그 .jar 파일이 있는 경로에 application.yml 파일을 만들어보자.
위 사진과 같이 .jar 파일이 존재하는 경로에 application.yml 파일을 만들어서 그 안에 key=value 값을 넣었다.
이 상태에서 애플리케이션을 실행해보자.
보이는 것과 같이 외부 파일을 읽어들여 값을 잘 찍는것을 볼 수 있다. 근데 이렇게 하는 것은 어떤 불편함이 있냐?
서버가 10대라면 10대 서버마다 이러한 파일을 다 만들어야 하는 불편함이 있다. 그리고 설정값이 변경되면 또 10대 모두 다 변경해줘야 한다.
그럼 어떻게 해결할까?
설정 데이터2 - 내부 파일 분리
이 외부 파일을 관리하는 것은 상당히 쉽지 않은 일이다. 설정을 변경할 때 마다 서버에 들어가서 각각의 변경 사항을 수정해두어야 한다. (물론 이것을 자동화 하기 위해 노력을 할 수는 있다)
이런 문제를 해결하는 간단한 방법은 설정 파일을 프로젝트 내부에 포함해서 관리하는 것이다. 그리고 빌드 시점에 함께 빌드되게 하는 것이다. 이렇게 하면 애플리케이션을 배포할 때 설정 파일의 변경 사항도 함께 배포할 수 있다. 쉽게 이야기해서 jar 하나로 설정 데이터까지 포함해서 관리하는 것이다.
위 그림을 보면 프로젝트 안에 설정 데이터를 포함하고 있다.
개발용 설정 파일: application-dev.properties
운영용 설정 파일: application-prod.properties
빌드 시점에 개발, 운영 설정 파일을 모두 포함해서 빌드한다.
app.jar는 개발, 운영 두 설정 파일을 모두 가지고 배포된다.
실행할 때 어떤 설정 데이터를 읽어야 할지 최소한의 구분은 필요하다.
실행할 때 외부 설정을 사용해서 개발 서버는 dev라는 값을 제공하고, 운영 서버는 prod라는 값을 제공하면 된다. 그리고 스프링에서 이것을 프로필이라고 미리 정의해두고 사용자들에게 제공하고 있다.
"그럼 이 프로필 정보는 어떻게 넘겨요?" 지금까지 했던 커맨드 라인 옵션 인수, VM 옵션 등으로 넘길 수 있다.
우선, 이 내부 설정 파일을 만들어보자. main/resources 경로에 다음 파일을 추가하자.
.properties 파일로 사용해도 되고 .yml 파일로 사용해도 된다. 이것도 취향 차이인데, 개인적으로 .yml 파일을 더 선호한다.
그 이유는 크게 2가지가 있다.
들여쓰기를 통한 가시화 증대 (본인 취향)
중복 키가 존재하면 .properties는 가장 마지막에 작성한 값이 적용되는 반면, .yml 파일은 에러를 발생시켜준다.
난 이 두번째 이유가 너무 좋다. 다음 예시를 보자.
application-dev.properties
이 파일에 `url` 이라는 키를 세 개나 중복해서 작성했다. 지금이야 한 눈에 보이지만 이 파일이 꽤나 커져서 위에서 작성했는지 까먹고 아래에서 또 작성할 여지가 분명히 존재한단 말이다. 아마 이 빨간줄은 인텔리제이 유료 버전이 똑똑하게 알려주는 것 같은데 다른 IDE나 기본 파일 에디터는 이런 것도 안 알려줄거다.
이 상태로 실행해보면 결과가 다음과 같다.
가장 마지막에 선언한 값이 적용된다. 뭐 어떻게 보면 유연하다고 볼 수도 있는데 난 이런 유연함은 싫다.
application-dev.yml
이 상태로 실행해보자. 다음과 같이 에러가 발생한다. 그리고 친절하게 DuplicateKeyException 이라고 알려준다. 얼마나 좋은가!
아무튼 이런 이유로 .yml 파일을 선호하고 .yml 파일로 작성했고 계속 진행해보자.
스프링은 이런 곳에서 사용하기 위해 프로필이라는 개념을 지원한다. `spring.profiles.active`외부 설정에 값을 넣으면 해당 프로필을 사용한다고 판단한다. 그리고 프로필에 따라 다음과 같은 규칙으로 해당 프로필에 맞는 내부 설정 파일을 조회한다.
java -jar Xxx.jar --spring.profiles.active=dev (커맨드 라인 옵션 인수)
한번 더 주의! VM 옵션은 -jar 앞에 작성해야 하고, 커맨드 라인 옵션 인수는 -jar 뒤에 작성해야 한다.
실행결과
이제 정말 간단하게 설정 데이터를 프로젝트 내에 위치시켜 외부로 빼서 서버마다 설정값이 변경되면 적용해줄 필요도 없고 빌드 파일 자체에 모든것이 담겨있게 됐다. 그러나 인간의 욕심은 끝이 없다. 이제 이 파일이 나뉘어져 있다 보니 각각의 설정값이 어떤 대조점이 있는지 한눈에 보기 어렵다는 점이 있다. 그럼 이건 또 어떻게 해결할까?
설정 데이터3 - 내부 파일 합체
스프링은 또 이 설정 파일을 각각 분리해서 관리하면 한눈에 전체가 들어오지 않는 단점이 있다는 것을 알고 물리적인 하나의 파일 안에서 논리적으로 영역을 구분하는 방법을 제공한다. 그리고 이 방법을 대부분 실무에서 많이 사용하고 있다.
대부분 실무에서 많이 사용하고 있다는 말은 통상적이지 절대적은 아니다. 파일을 application-prod.yml, application-dev.yml로 구분하는 방식도 많이 사용한다. 난 오히려 이게 더 편하기도 하다. 약간 취향의 차이로 생각하면 좋을 것 같다.
결과를 보면 알 수 있듯 두개의 프로필이 활성화됐다. 그럼 위에서 말한것처럼 위에서 아래로 읽어 들이는 설정 파일은 결국 'dev'도 읽고 'prod'도 읽으니까 최종적으로 url, username, password의 값은 'prod'값으로 적용이 될 수 밖에 없다는 것을 안다.
결론과 우선순위 - 전체
이제 전체적으로 외부 설정을 읽어 들일 때 우선순위가 어떻게 적용되는지 확인해보자.
아래로 내려갈수록 우선순위가 높은것이다.
설정 데이터(application.yml)
OS 환경 변수
자바 시스템 속성
커맨드 라인 옵션 인수
@TestPropertySource (테스트에서 사용)
이걸 외우는게 아니라, 그냥 두가지 큰 틀을 알고 있으면 된다.
더 유연한 것이 우선권을 가진다. (변경하기 어려운 파일보다 실행 시 원하는 값을 줄 수 있는 자바 시스템 속성이 더 우선권을 가진다)
범위가 넓은 것보다 좁은 것이 더 우선권을 가진다. (다른 말로, 더 디테일한게 우선권을 가진다)
하나의 애플리케이션을 여러 다른 환경에서 사용해야 할 때가 있다. 대표적으로 개발이 잘 진행되고 있는지 내부에서 확인하는 용도의 개발 환경, 그리고 실제 고객에게 서비스하는 운영 환경이 있다.
이렇게 두 환경에서는 서로 다른 데이터와 서로 다른 환경을 사용한다.
개발 환경: 개발 서버, 개발 DB
운영 환경: 운영 서버, 운영 DB
문제는 각각의 환경에 따라서 서로 다른 설정값이 존재한다는 점인데, 대표적으로 DB만 하더라도 개발 환경에서는 개발DB를 사용하고 개발DB에 접근하려면 dev.db.com 이라는 URL 정보가 필요하고 운영 환경에서 운영DB에 접근하려면 prod.db.com 이라는 URL을 사용해야 한다.
이 문제를 해결하는 가장 단순한 방법은 다음과 같이 각각의 환경에 맞게 애플리케이션을 빌드하는 것이다.
개발 환경에는 `dev.db.com`이 필요하므로 이 값을 애플리케이션 코드에 넣은 다음에 빌드해서 개발용 jar 파일을 만든다.
운영 환경에는 `prod.db.com`이 필요하므로 이 값을 애플리케이션 코드에 넣은 다음에 빌드해서 운영용 jar 파일을 만든다.
그러나 이 방식은 이러한 문제들이 있다.
환경에 따라 빌드를 여러번 해야 한다.
개발 버전과 운영 버전의 빌드 결과물이 다르다. 따라서 개발 환경에서 검증 되더라도 운영 환경에서 다른 빌드 결과를 사용하기 때문에 예상치 못한 문제가 발생할 수 있다. 개발용 빌드가 끝나고 검증한 다음에 운영용 빌드를 해야 하는데 그 사이에 누군가 다른 코드를 변경할 수도 있다. 한마디로 진짜 같은 소스코드에서 나온 결과물인지 검증하기가 어렵다.
각 환경에 맞추어 최종 빌드가 되어 나온 빌드 결과물은 다른 환경에서 사용할 수 없으므로 유연성이 떨어진다. 향후 다른 환경이 필요하면 그곳에 맞도록 또 빌드를 해야한다.
결론은, 위 방식은 안 좋은 방식이다. 그래서 보통은 다음과 같이 빌드를 한번만 하고 각 환경에 맞추어 실행 시점에 외부 설정값을 주입한다.
배포 환경과 무관하게 하나의 빌드 결과물을 만든다. 이 안에는 설정값을 두지 않는다.
설정값은 실행 시점에 각 환경에 따라 외부에서 주입한다.
개발 서버에서는 app.jar를 실행할 때 `dev.db.com` 값을 외부 설정으로 주입한다.
운영 서버에서는 app.jar를 실행할 때 `prod.db.com` 값을 외부 설정으로 주입한다.
이런 방식을 사용한다면 빌드도 한번만 하면 되고, 개발 버전과 운영 버전의 빌드 결과물이 같기 때문에개발 환경에서 검증되면 운영 환경에서도 믿고 사용할 수 있다. 그리고 이후에 새로운 환경이 추가되어도 별도의 빌드 과정 없이 같은 app.jar를 사용해서 손쉽게 새로운 환경을 추가할 수 있다.
유지보수하기 좋은 애플리케이션 개발의 가장 기본 원칙은 변하는 것과 변하지 않는 것을 분리하는 것이다. (DI, 전략 패턴, OCP원칙 등)
이제, 이러한 내용을 인지한 채 외부 설정에 대해 공부해보자.
외부 설정
애플리케이션을 실행할 때 필요한 설정값을 외부에서 어떻게 불러와서 애플리케이션에 전달할 수 있을까?
외부 설정은 일반적으로 다음 4가지 방법이 있다.
OS 환경 변수: OS에서 지원하는 외부 설정, 해당 OS를 사용하는 모든 프로세스에서 사용 (Ex: JAVA_HOME)
자바 시스템 속성: 자바에서 지원하는 외부 설정, 해당 JVM안에서 사용
자바 커맨드 라인 인수: 커맨드 라인에서 전달하는 외부 설정, 실행시 main(args) 메서드에서 사용
외부 파일(설정 데이터): 프로그램에서 외부 파일을 직접 읽어서 사용
애플리케이션에서 특정 위치의 파일을 읽도록 해둔다. (Ex: data/env.txt)
그리고 각 서버마다 해당 파일안에 다른 설정 정보를 남겨둔다.
개발 서버 env.txt: url=dev.db.com
운영 서버 env.txt: url=prod.db.com
하나씩 차근차근 직접 해보면서 알아가보자.
외부 설정 - OS 환경 변수
우선, OS별 환경 변수가 어떻게 저장되어 있는지 확인하는 방법이 있다. 검색하면 바로 나올거고 Mac은 `printenv`를 입력하면 된다.
그럼, 이 OS 환경 변수를 코드상에서 어떻게 읽을까? 간단하다. 다음 코드를 보자.
OsEnv
package hello.external;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
@Slf4j
public class OsEnv {
public static void main(String[] args) {
Map<String, String> envMap = System.getenv();
for (Map.Entry<String, String> keyValue : envMap.entrySet()) {
log.info("key: {}, value: {}", keyValue.getKey(), keyValue.getValue());
}
}
}
실행결과
> Task :OsEnv.main()
15:45:14.720 [main] INFO hello.external.OsEnv - key: __CFBundleIdentifier, value: com.jetbrains.intellij
15:45:14.723 [main] INFO hello.external.OsEnv - key: PATH, value: /Users/cw.choiit/.local/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Library/Apple/usr/bin:/usr/local/go/bin:/Users/cw.choiit/flutter/bin:/Users/cw.choiit/.pub-cache/bin:/Users/cw.choiit/Library/Android/sdk
15:45:14.723 [main] INFO hello.external.OsEnv - key: ANDROID_SDK, value: /Users/cw.choiit/Library/Android/sdk
15:45:14.723 [main] INFO hello.external.OsEnv - key: SHELL, value: /bin/zsh
15:45:14.723 [main] INFO hello.external.OsEnv - key: PAGER, value: less
15:45:14.723 [main] INFO hello.external.OsEnv - key: IJ_RESTARTER_LOG, value: /Users/cw.choiit/Library/Logs/JetBrains/IntelliJIdea2024.1/restarter.log
15:45:14.723 [main] INFO hello.external.OsEnv - key: LSCOLORS, value: Gxfxcxdxbxegedabagacad
15:45:14.723 [main] INFO hello.external.OsEnv - key: JAVA_HOME, value: /Users/cw.choiit/Library/Java/JavaVirtualMachines/openjdk-22.0.1/Contents/Home
15:45:14.724 [main] INFO hello.external.OsEnv - key: OLDPWD, value: /
15:45:14.724 [main] INFO hello.external.OsEnv - key: USER, value: cw.choiit
15:45:14.724 [main] INFO hello.external.OsEnv - key: ZSH, value: /Users/cw.choiit/.oh-my-zsh
...
그런데, OS 환경 변수는 이 프로그램 뿐 아니라 다른 곳 어디서도 읽어올 수가 있다. 여러 프로그램에서 사용하는게 맞을 때도 있지만 딱 이 자바 프로그램에만 적용하고 싶을때가 있다. 이렇게 특정 자바 프로그램 안에서만 사용할 수 있는 자바 시스템 속성에 대해 알아보자.
외부 설정 - 자바 시스템 속성
자바 시스템 속성(Java System Properties)은 실행한 JVM 안에서 접근 가능한 외부 설정이다. 추가로 자바가 내부에서 미리 설정해두고 사용하는 속성들도 있다.
자바 시스템 속성은 다음과 같이 자바 프로그램을 실행할 때 사용한다.
java -Durl=dev -jar app.jar
`-D` VM옵션을 통해서 key=value 형시기을 주면 된다.
순서에 주의해야 한다. `-D` 옵션이 -jar 보다 앞에 있다.
그럼 이렇게 실행하면 코드로 어떻게 읽을까? 이것도 간단하다.
JavaSystemProperties
package hello.external;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.Properties;
@Slf4j
public class JavaSystemProperties {
public static void main(String[] args) {
Properties properties = System.getProperties();
for (Map.Entry<Object, Object> keyValue : properties.entrySet()) {
log.info("key = {}, value = {}", keyValue.getKey(), keyValue.getValue());
}
}
}
System.getProperties()로 전체 속성을 가져올 수 있다. (타입이 Properties인데 이 Properties는 Map의 자식 타입이다)
실행결과
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = apple.awt.application.name, value = JavaSystemProperties
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = sun.management.compiler, value = HotSpot 64-Bit Tiered Compilers
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = ftp.nonProxyHosts, value = local|*.local|169.254/16|*.169.254/16|lx.astxsvc.com|*.lx.astxsvc.com|localhost|*.localhost
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = java.runtime.version, value = 18.0.2+0
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = user.name, value = cw.choiit
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = path.separator, value = :
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = os.version, value = 14.5
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = java.runtime.name, value = OpenJDK Runtime Environment
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = file.encoding, value = UTF-8
15:49:42.647 [main] INFO hello.external.JavaSystemProperties - key = java.vm.name, value = OpenJDK 64-Bit Server VM
...
위에서 VM 옵션으로 url, username, password를 추가했다. 그리고 출력해보면 결과는 다음과 같다.
실행결과
...
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = sun.io.unicode.encoding, value = UnicodeBig
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = socksNonProxyHosts, value = local|*.local|169.254/16|*.169.254/16|lx.astxsvc.com|*.lx.astxsvc.com|localhost|*.localhost
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = java.class.version, value = 62.0
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - key = username, value = testuser
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - url = dev
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - username = testuser
16:01:27.819 [main] INFO hello.external.JavaSystemProperties - password = test
사용자가 직접 코드로도 자바 시스템 속성을 추가할 수 있다 다만, 이건 코드 내에서 직접 작성하는 것이라서 외부로 설정을 분리하는 효과는 없다.
다음과 같이 System.setProperty()로 직접 추가가 가능하다.
System.setProperty("Hi", "Hello World");
실행결과
...
16:03:35.282 [main] INFO hello.external.JavaSystemProperties - key = Hi, value = Hello World
...
외부 설정 - 커맨드 라인 인수
커맨드 라인 인수(Command Line Arguments)는 애플리케이션 실행 시점에 외부 설정값을 main(args) 메서드의 args 파라미터로 전달하는 방법이다.
다음과 같이 사용한다.
java -jar app.jar dataA dataB
순서에 주의해야 한다. `.jar` 뒤에 스페이스로 구분해서 데이터를 전달한다. 위의 경우 dataA, dataB가 args에 전달된다.
자바 시스템 속성이랑 유사하지만 명확히 구분지어야 한다.
`-D`옵션은 `.jar` 앞에, 커맨드 라인 인수는 `.jar` 뒤에
그럼 이렇게 들어오는 인수는 다음과 같이 받으면 된다.
CommandLineV1
package hello.external;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CommandLineV1 {
public static void main(String[] args) {
for (String arg : args) {
log.info("{}", arg);
}
}
}
실행결과
> Task :CommandLineV1.main()
16:06:53.708 [main] INFO hello.external.CommandLineV1 - dataA
16:06:53.711 [main] INFO hello.external.CommandLineV1 - dataB
근데 보면 알겠지만, 이는 `key=value` 형식이 아니다. 애플리케이션 개발하는 입장에서는 명확하게 특정 키에 해당하는 값을 찾는게 편할 때가 많다. 근데 이 경우 통으로 문자열이다. 그래서 만약 저 형식으로 데이터를 `A=dataA B=dataB` 이렇게 넣어도 저 문자열 하나가 그냥 통 문자열이라서 가져다 사용하는 개발자 입장에서는 `=`를 기준으로 데이터를 파싱하고 변환하고 이러한 번거로움이 있다.
근데 이를 스프링에서는 편리하게 사용할 수 있도록 스프링 만의 표준 방식을 정의해서 제공해준다. 그것을 알아보자.
외부 설정 - 커맨드 라인 옵션 인수
그러니까 스프링은 커맨드 라인 인수를 받을 때 key=value 형식으로 받게 도와준다. 개발자들은 이런 key=value 형식의 데이터를 선호한다. 그래서 스프링이 이렇게 받을 수 있게 도와주는데 그 방법은 `--`이다.
위 코드를 보면 ApplicationArguments를 주입받는다. 그리고 그 녀석을 통해서 값을 똑같이 가져오는 코드를 작성했다. 그리고 돌려보면 실행결과는 다음과 같다.
실행결과
이렇게 스프링 부트는 저 객체를 빈으로 자동으로 등록해주고 우리가 어디서든 주입받아 사용할 수 있게 도와준다.
결론
이렇게 외부로부터 어떤 데이터(설정값, 변수)를 가져오는 방법을 알아봤다. 모두 자주 사용되는 방식이다.
근데 문제가 있다. 모두 다 값을 가져오는 방식이 천차만별이다. OS 환경 변수값, 자바 시스템 속성값, 커맨드 라인 인수를 가져오는 방법이 모두 다르다. 이럴때 생각해야 하는건 `추상화`다. 인터페이스와 구현(역할과 구현)을 활용해서 스프링은 이 외부로부터 설정값을 가져오는 방식을 통합했다. 그 방법을 Part.2에서 알아보자