모니터링 구축기 : Logback + PLG

2025. 9. 26. 03:48·프로젝트

최근 좋은 기회로 EAT-SSU라는 숭실대 학식앱을 운영하는 팀에 합류하게 되었습니다.

모니터링을 구축하게 된 계기와 그 과정에 대해서 이야기해보고자 합니다.

 

📌 로그와 모니터링이 왜 필요할까?

사실, 지금까지의 프로젝트에서는 이러한 부분들을 크게 생각해 봤던 적이 없습니다. 

왜냐고요..? 대규모 트래픽을 대상으로 운영을 돌려볼 일이 없었기 때문입니다.

EAT-SSU는 MAU가 3500+ 정도 되었고 종종 문제가 발생할 때마다 모니터링을 꼭 구현하고야 말겠다는 생각을 했습니다.

  • 현재 구조에서는 slack을 통한 에러 알림과

  • 외부 모니터링 서버에서 Grafana와 Prometheus를 통한 기본적인 모니터링은 되고 있었습니다.

그런데 위와 같은 구조는 충분하다고 느끼지는 못했습니다.

 

예를 들어서, 어떤 문제가 터진 경우 

"~~ 한 문제가 있으니 확인 부탁드립니다."

라는 요청을 받은 경우 EC2에 직접 들어가서 로그를 보며 Grafana의 수치 정도를 참고해야 했고 이는 발생한 문제들을 해결하기에는 조금 어려운 부분이 있었습니다.

 

또한, 가끔은 서버의 메모리 사용량이 100%에 도달해서 서버가 접속이 되지 않으면 문제를 확인할 수 없었으며, 복구를 하기 위해서 서버를 재가동할 경우에는 로그가 다 날아가 버려서 문제를 알 수가 없었습니다.

 

사실, 이런 저의 경험들의 Case 말고도 로깅이 왜 중요한지는 누구나 다 알고 있을 것이라고 생각합니다.

 

물론, 팀원들이 이런 로그와 모니터링에 대한 필요를 못 느끼는 것은 아니었지만 항상 리소스가 문제입니다. 새로 업데이트할 부분들도 있고 기존 레거시 코드의 문제를 고치다 보면 새로운 것들을 할 수 있는 시간이 많이 부족하더라고요.

그래서 제가 해보기로 했습니다 

📌 로깅은 언제 찍어야 할까?

미리 결론을 말씀드리자면 최선이 아니더라도 다른 저의 게시물에서는 공식 문서나 레퍼런스등의 근거를 가지고 저 나름의 답을 찾았는데..

로깅을 언제 찍어야 하는가?

 

이 질문에 대한 답은 없는 것 같아요.

 

당연히 다 찍으면 좋겠죠. 그런데, 프로젝트상 제한이 있을 수도 있고 상황마다 필요한 부분이 다 다르니까요.

여러 레퍼런스를 뒤져봤는데 아래의 블로그의 내용이 저는 가장 공감이 되었습니다.

 

https://jaeseo0519.tistory.com/449

 

로그는 대체 왜, 언제, 어디서, 무엇을 남겨야 하는가?

💡 개인적인 고찰일 뿐 정답이 아닙니다.로그가 필요한 이유는 정말 많지만, 여기선 주로 디버깅 관점에서 분석하고 있습니다.혹시나 더 나은 아이디어나 다른 관점이 있다면, 부디 언제든지

jaeseo0519.tistory.com

 

위의 글을 꼼꼼히 읽어보고 느낀 점은 여러 정보들을 알게 되긴 했는데, 그래서 로그를 언제 찍어야 하는가에 대한 질문에 말끔하게 대답하기는 여전히 어렵더라고요.

 

이외에도 여러 레퍼런스들을 읽어봤는데 우선은 그냥 해보는 게 좋을 것 같다는 생각을 했습니다(처음부터 완벽할 필요는 없으니까요).

📌 Logback-spring.xml

stdout으로 단순히 로그를 찍는 것보다 logback-spring.xml을 사용할 때 더 큰 이점이 있습니다.

 

  • 로그 패턴(시간, 스레드, 클래스명 등) 자유롭게 설정.
  • 파일로 Rolling(일자별/용량별) 가능.
  • 특정 패키지/클래스 단위로 로그 레벨 다르게 적용 가능.
  • AsyncAppender 등 성능 최적화 옵션 제공.

등을 적용할 수 있기 때문입니다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 색상 출력 지원 -->
    <conversionRule conversionWord="clr"
                    converterClass="org.springframework.boot.logging.logback.ColorConverter"/>

    <!-- 로그 형식 설정 예시 :2025-09-25 12:34:56.789 [INFO] [http-nio-9000-ex] [reqId=123e4567] [com.eatssu.api.UserController] - 내용
    Thread : 동시성 문제 관련 사용, Request : 요청 단위 -->
    <property name="LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %clr([%level]) [%thread] [reqId=%X{requestId}] %cyan(%logger{36}) - %msg%n"/>

    <!-- 콘솔 출력 (stdout) -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- 비동기 래핑 -->
    <appender name="ASYNC_CONSOLE" class="ch.qos.logback.classic.AsyncAppender">
        <neverBlock>true</neverBlock>
        <appender-ref ref="CONSOLE"/>
    </appender>

    <!-- Root Logger -->
    <root level="INFO">
        <appender-ref ref="ASYNC_CONSOLE"/>
    </root>
</configuration>

제가 사용한 xml은 다음과 같습니다. logback을 통해서 log를 외부 파일로 저장하는 롤링이라는 기능을 활용할 수도 있습니다.

저도 처음에 위의 기능을 적용하고 EC2에서 컨테이너 간 공유하는 Volume으로 로그를 저장하는 방식으로 구현을 했는데요..

 

쿠버네티스 공식 문서에서 Docker Container 자체에 stdout을 통해서 로그를 남기는 것을 기본으로 생각하고 있고, 수집기에서 긁어갈 수 있는 방법을 찾았기 때문에 별도의 롤링을 설정하지 않는 방향으로 수정했습니다. (첫 문단 마지막줄 참고)

제가 Logback 설정에서 신경 쓴 부분은 다음과 같습니다.

 

1️⃣ 로그의 형식

로그의 형식을 아래처럼 고정했습니다. 실제 로그는 예시는 다음과 같아요.

"%d{yyyy-MM-dd HH:mm:ss.SSS} %clr([%level]) [%thread] [reqId=%X{requestId}] %cyan(%logger{36}) - %msg%n"

REQUEST 이런 부분은 내부에 들어가는 내용으로 추후에 설정을 해준 것이고. 일단 "-" 앞까지만 봐주면 됩니다.

 

이 부분에서 두 가지 부분을 추가했습니다.

  • Thread 정보 : 멀티 스레드 환경에서 동시성 관련 문제가 발생하면 원인을 파악하기 위해 추가했습니다.
  • RequestId : 요청이 뒤섞이게 되어도 요청 단위로 로그를 골라 보기 위해 추가했습니다. 

이때, RequestId를 추출하기 위해서는 MDC(Mapped Diagnostic Context)라는 것을 구현해 주어야 합니다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MDCLoggingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String requestId = ((HttpServletRequest) request).getHeader("X-RequestID");
        if (requestId == null) {
            requestId = UUID.randomUUID().toString().replace("-", "");
        }
        MDC.put("requestId", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

그러기 위해서 위의 필터를 추가해 주었어요!  관련 레퍼런스

2️⃣ 비동기 처리

로깅은 단순한 출력 이상의 비용을 수반하기 때문에 애플리케이션 성능에 직접적인 영향을 줄 수 있습니다. 특히 동기 방식으로 로그를 기록하면, I/O 작업이 요청 처리 흐름을 잠시 멈추게 되어 지연을 유발할 수 있습니다. 이를 최소화하기 위해 Logback에서는 AsyncAppender를 사용해 로그 기록을 별도의 스레드에서 처리하도록 지원합니다.

<appender name="ASYNC_CONSOLE" class="ch.qos.logback.classic.AsyncAppender">
        <neverBlock>true</neverBlock>
        <appender-ref ref="CONSOLE"/>
    </appender>

이 부분의 파라미터와 설정은 공식문서를 참고했습니다.

 

  • queueSize (기본값 256)
    • 로그 이벤트를 저장하는 블로킹 큐의 최대 크기
    • 즉, 비동기 처리 시 한 번에 담아둘 수 있는 로그 이벤트 개수
  • discardingThreshold (기본값 20%)
    • 큐에 남은 용량이 20% 이하가 되면,
      TRACE / DEBUG / INFO 레벨 로그를 버리고
      WARN / ERROR 레벨 로그만 유지

로그 큐가 가득 찼을 때 neverBlock을 false로 두면 애플리케이션 스레드가 대기하게 되어 요청 처리에 지장을 줄 수 있습니다. 반면 true로 설정하면 로그는 일부 유실되더라도 서비스 처리를 우선시해 장애를 방지할 수 있다는 장점이 있습니다

 

저는 당연히 성능과 안정성을 위해 neverBlock=true 설정을 고려하는 것이 맞다고 생각했습니다.

 

이에 따라서, neverBlock=true 설정만 해주었고 queueSize와 discarding Threshold는 우선적으로 디폴트 값을 사용하기로 했습니다.

 

동시 요청이 많거나 로그 발생량이 큰 서비스라면 queueSize를 키우고 discardingThreshold를 0에 가깝게 조정해 유실을 최소화하는 게 좋고, 반대로 로그보다 서비스 응답 안정성이 더 중요한 경우엔 discardingThreshold를 높여 성능을 우선시하면 좋은데요. 결국은 트래픽을 보고 유동적으로 조정을 해야 하기 때문입니다.

📌 Controller Layer

로그는 당연히 각각의 상황에 맞게 적용하는 것이 가장 좋습니다. 그런데 로그와 관련된 많은 글들을 보면 AOP를 사용하는 경우가 상당히 많습니다. 개인적으로 서비스 레이어에서는 상황에 맞게 로깅을 한다고 해도 Controller Layer 같은 경우는 하나의 AOP로 통일된 구조를 가지는 게 중복 코드를 줄일 수 있는 좋은 방법이라고 생각했습니다.

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class ControllerLogAspect {

    private final ObjectMapper objectMapper;

    @Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
    public void restController() {}

    @Around("restController()")
    public Object logApi(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();

        HttpServletRequest request =
                ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String uri = request.getRequestURI();
        String method = request.getMethod();

        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        String[] paramNames = methodSignature.getParameterNames();
        Object[] args = joinPoint.getArgs();

        // 요청자
        String userIdLog = IntStream.range(0, args.length)
                .filter(i -> args[i] instanceof CustomUserDetails)
                .mapToObj(i -> {
                    CustomUserDetails user = (CustomUserDetails) args[i];
                    return "userId=" + user.getId();
                })
                .findFirst()
                .orElse("userId=anonymous");

        // 나머지 요청 인자
        String otherArgsJson = IntStream.range(0, args.length)
                .filter(i -> !(args[i] instanceof HttpServletRequest))
                .filter(i -> !(args[i] instanceof CustomUserDetails))
                .filter(i -> !(args[i] instanceof org.springframework.validation.BindingResult))
                .mapToObj(i -> {
                    String name = (paramNames != null && i < paramNames.length) ? paramNames[i] : "arg" + i;
                    Object arg = args[i];
                    try {
                        String value;
                        if (arg != null) {
                            Map<String, Object> safeMap = toSafeMap(arg);
                            value = objectMapper.writeValueAsString(safeMap);
                        } else {
                            value = "null";
                        }
                        if (value.length() > 200) value = value.substring(0, 200) + "...(truncated)";
                        return name + "=" + value;
                    } catch (Exception e) {
                        return name + "=" + String.valueOf(arg);
                    }
                })
                .collect(Collectors.joining(", "));

        String argsJson = userIdLog + (otherArgsJson.isEmpty() ? "" : ", " + otherArgsJson);

        log.info("REQUEST {} {} args={}", method, uri, argsJson);

        try {
            Object result = joinPoint.proceed();
            long time = System.currentTimeMillis() - start;

            String resultJson;
            try {
                resultJson = objectMapper.writeValueAsString(result);
                if (resultJson.length() > 600) {
                    resultJson = resultJson.substring(0, 600) + "...(truncated)";
                }
            } catch (Exception e) {
                resultJson = String.valueOf(result);
            }

            log.info("RESPONSE {} {} ({} ms) result={}", method, uri, time, resultJson);
            return result;
        } catch (Throwable e) {
            long time = System.currentTimeMillis() - start;
            log.error("EXCEPTION {} {} ({} ms) cause={}", method, uri, time, e.getMessage(), e);
            throw e;
        }
    }

    private Map<String, Object> toSafeMap(Object arg) {
        Map<String, Object> result = new HashMap<>();
        for (Field field : arg.getClass().getDeclaredFields()) {
            field.setAccessible(true);
            try {
                Object value = field.get(arg);
                if (field.isAnnotationPresent(LogMask.class)) {
                    value = "***";
                }
                result.put(field.getName(), value);
            } catch (IllegalAccessException e) {
                result.put(field.getName(), "ERROR");
            }
        }
        return result;
    }
}

코드가 굉장히 길지만 요약하면 다음과 같습니다.

  • 맨 앞에 요청하는 UserId를 둔다. 단, 로그인 없이 호출 가능한 API가 있으니 없다면 anonymous를 반환한다.
  • Query Parameter, PathVariable, 그리고 Request Body 등 모든 입력 인자를 기록한다 (어떤 요청이 오류를 냈는지 알아야 하니까요!)
  • Response에는 간단한 소요 시간(쿼리는 로그가 아니라 매트릭의 영역)과 결과를 출력한다.

나름대로 괜찮죠? 그런데 코드가 복잡해진 이유는 사실 민감한 정보와 필요 없는 부분을 포함시키지 않기 위한 부분들이 추가되었기 때문입니다.

 

Servlet Request 같은 경우는 로그가 길어지기만 했고 CustomUserDetails (인증 관련 헤더 정보)는 로그로 출력되면 절대로 안 되는 민감한 정보입니다. 필터에서 이런 부분들을 걸러줍니다.

 

또한, 특정 API에서는 입력값에서 민감한 정보 (ex IdToken, Email)등이 있었습니다. 이런 부분들을 걸러주기 위해 커스텀 어노테이션을 만들어서 마스킹이 필요한 부분에 어노테이션을 달아주었고, 위의 필터에서 마스킹했습니다.

// 커스텀 어노테이션
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface LogMask {
}


// 적용 예시
@Schema(title = "카카오 로그인 및 회원가입")
public record KakaoLoginRequest(
        @LogMask
        @NotBlank(message = "이메일을 입력해주세요.")
        @Email(message = "올바른 이메일 주소를 입력해주세요.")
        @Schema(description = "이메일", example = "test@email.com")
        String email,

        @LogMask
        @Schema(description = "providerId", example = "10378247832195")
        String providerId
) {}

최종 로그의 모습은 다음과 같습니다.

 

요청 예시

REQUEST GET /v2/reviews/my args=userId=51, lastReviewId=null, pageable={"sort":{"empty":false,"sorted":true,"unsorted":false},"offset":0,"pageNumber":0,"pageSize":20,"paged":true,"unpaged":false}

응답 예시

RESPONSE GET /v2/reviews/my (428 ms) result={"isSuccess":true,"code":1000,"message":"요청에 성공하였습니다.","result":{"numberOfElements":10,"hasNext":false,"dataList":[{"reviewId":169,"rating":null,"wri

마스킹된 민감 정보 예시

REQUEST POST /oauths/kakao args=userId=anonymous, request={"providerId":"***","email":"***"}

📌 Service Layer

서비스 레이어에서는 제 나름대로의 판단을 바탕으로 하나 하나 로그를 추가했습니다. 제가 처음에 첨부했던 장문의 로그와 관련된 내용을 담고 있는 블로그에서는 Layer 간 로그를 중복으로 찍는 것이 좋다고 했습니다.

 

실제로도 현업이라면 저도 어쩌면 그것이 맞다고 생각하긴 하는데요, 현재는 모놀리스 구조의 프로젝트이고 굳이 중복되는 로그들을 꼼꼼히 찍을 필요가 없다고 생각했습니다.

이에 따라서, 저는 validation 로직이 검사를 하고 Error를 터뜨리는 부분에는 로그를 찍지 않았습니다. 이건 Controller AOP 로거가 찍는 것으로 충분히 판단 가능하다고 생각했기 때문입니다.

대신 저는 다음과 같은 부분들에 집중했습니다.

1️⃣  에러는 터지지 않지만 문제가 될 수도 있는 부분들

  • 특정 리스트가 비어 있으면 안 되는데 그것에 따라서 결과가 비어버리는 경우
  • 비즈니스 로직에 따라서 말이 안 되는 상황 

이런 부분들은 주관적으로 판단해 대부분 INFO와 WARN으로 로그를 찍어주었습니다.

2️⃣ ⭐ 엔티티의 값이 변화하는 경우 (비동기 처리)

이외에 가장 집중했던 부분이 이 부분입니다. 어떤 값이 바뀌는 UPDATE or DELETE 로직은 반드시 로깅을 해야 한다고 생각합니다!

그래야 어떤 처리를 통해 어떻게 되었는지 로그로써 확인할 수 있기 때문입니다. 

 

그런데 이 부분에서 고려해야 할 부분이 있습니다.

"만약 문제가 생겨서 트랜잭션이 롤백이 된다면?"

로그는 찍자 마자 비동기 처리 Queue에 들어가게 될 텐데 트랜잭션이 롤백이 된다고 해서 해당 로그가 찍히지 않는 게 아닙니다.

 

즉, 트랜잭션은 롤백이 되어서 벌어지지 않은 일임에도 불구하고 실제로는 로그가 찍힐 수 있습니다.

 

저는 이런 문제를 해결하고자 Event 발행 -> Commit이 끝난 후 비동기 처리 구조를 사용했습니다.

// Event DTO
public record LogEvent(String message) {
    public static LogEvent of(String message) {
        return new LogEvent(message);
    }
}

// Event Listener
@Component
@RequiredArgsConstructor
@Slf4j
public class LogEventListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleLogEvent(LogEvent event) {
        log.info(event.message());
    }
}
DTO를 세분화해서 상황마다 만드는 게 좋을까?

라는 생각도 해보았지만, 저희 프로젝트에서는 별도로 비동기 처리할 로그가 영속성 컨텍스트가 변화하는 경우 말고는 없어 보였고 이는 모두 그냥 INFO로 찍을 것 같았습니다. 

 

그래서 우선은 위와 같이 간단하게 String을 담는 DTO를 만들었습니다.

@Transactional
    public void deleteReview(CustomUserDetails userDetails, Long reviewId) {
        User user = userRepository.findById(userDetails.getId())
                                  .orElseThrow(() -> new BaseException(NOT_FOUND_USER));

        Review review = reviewRepository.findById(reviewId)
                                        .orElseThrow(() -> new BaseException(NOT_FOUND_REVIEW));

        if (review.isNotWrittenBy(user)) {
            throw new BaseException(REVIEW_PERMISSION_DENIED);
        }

        review.resetMenuLikes();

        eventPublisher.publishEvent(LogEvent.of(
                String.format("Review deleted: reviewId=%d, userId=%d", review.getId(), user.getId())
        ));

        reviewRepository.delete(review);
    }

 

그리고 위처럼 실제 로직에서 미리 로그 스크립트를 이벤트로 발행을 하도록 한 것이죠! (민감 정보는 포함시키지 않습니다)

 

여기까지! 이제 드디어 로깅에 대한 부분이 끝났습니다. PLG 스택으로 넘어가죠.

📌 PLG 스택

최종 모습은 다음과 같습니다. 사실 컨테이너를 올리고 Grafana를 연결하는 과정은 크게 어렵지 않기 때문에 생략하겠습니다.

 

그런데 몇 가지 참고할 사항들만 작성하겠습니다.

✅ promtail-config.yml

server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
   - url: http://(Loki 서버의 IP):3100/loki/api/v1/push    

scrape_configs:
  - job_name: eatssu-logs
    static_configs:
      - targets:
          - localhost
        labels:
          job: eatssu-app
          __path__: /var/lib/docker/containers/*/*.log
  • 이 부분에서 로그를 긁어오는 경로를 위처럼 도커 컨테이너의 로그 volume으로 설정해 주고..
docker run -d --name=promtail \
  -v /var/lib/docker/containers:/var/lib/docker/containers:ro \
  -v /var/log:/var/log:ro \
  -v (입력해야할 파일 경로):/etc/promtail/promtail-config.yml \
  grafana/promtail:latest \
  -config.file=/etc/promtail/promtail-config.yml
  • 그리고 Promtail 컨테이너를 실행할 때 볼륨을 함께 주입해 주세요.

✅ loki-config.yml

limits_config: retention_period: 120h

로키 설정은 다 다르겠지만 rentention_period를 통해 로그가 보관될 기간을 정할 수 있어요!

  • 너무 많은 용량이 쌓이지 않도록 조정하면 좋습니다.
😺 이상으로 이번 포스트를 마치겠습니다

다음에는 모니터링을 통해서 얻은 인사이트와 실제 문제 발견으로 이어지는 부분에 대한 포스팅을 할 수 있으면 좋겠습니다!

'프로젝트' 카테고리의 다른 글

@Modifying의 문제점을 알고 사용하자  (0) 2025.09.24
양방향 매핑을 왜 함부로 사용하면 안될까? 삭제 성능 최적화  (0) 2025.09.23
테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI  (1) 2025.07.01
JPA 최적화는 어떻게 해야할까?  (0) 2025.05.28
배포의 모든 것 - 5. Blue-Green 무중단 배포 적용  (2) 2025.05.01
'프로젝트' 카테고리의 다른 글
  • @Modifying의 문제점을 알고 사용하자
  • 양방향 매핑을 왜 함부로 사용하면 안될까? 삭제 성능 최적화
  • 테스트 코드는 어떻게 작성해야 할까? - 좋은 테스트 코드 작성 TMI
  • JPA 최적화는 어떻게 해야할까?
나는 정말 개발이 하고 싶었다
나는 정말 개발이 하고 싶었다
개발 혼자 공부하기
  • 나는 정말 개발이 하고 싶었다
    감자밭
    나는 정말 개발이 하고 싶었다
  • 전체
    오늘
    어제
    • 분류 전체보기 (32)
      • ETC (3)
      • 알고리즘 (0)
      • Java (0)
      • DB (2)
      • Spring (0)
      • 프로젝트 (18)
      • Server (3)
      • CS (0)
        • 운영체제 (0)
      • Infra (4)
        • IAC (1)
        • AWS (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • hELLO· Designed By정상우.v4.10.3
나는 정말 개발이 하고 싶었다
모니터링 구축기 : Logback + PLG
상단으로

티스토리툴바