카테고리 없음

예외 처리 및 로그 남기기 고민

코딩연습중 2025. 4. 19. 17:31

프로젝트를 조금씩 하다보니까 뭔가 항상 아쉬운 부분이 조금씩 있었다. 기능 구현 자체는 사실 상 크게 어렵지 않다고 생각한다. 열심히 서치 > 분석 > 이해 > 적용 > 디버깅 및 수정 하다보면 가능하다고 생각한다. 테스트 코드를 잘 작성하고, 로그를 모니터링/분석 하면 성능도 향상시키고 프로젝트 관리에도 수월할 것 같아서 해당 부분에 대해서 공부를 해보고 싶었다. 우선 예외 처리 및 로그 기록에 대한 공부를 먼저 하고있다!

 여러 사람들의 글을 읽어보고 지피티의 도움도 받아가며 블로그에 조금씩 기록해가면서 공부 중인데 내가 해당 부분에 대해 어떻게 이해했는지 과정을 기록하는 게 좋을 것 같아 작성한다.(지피티가 알려준 것이기 때문에 잘못된 부분이 있을 수 있음.)

나중에 이를 정리해서 글을 올려보겠다!

 

(그 전에 읽어보고 와야 할 글들.)

https://codstudy.tistory.com/85

https://codstudy.tistory.com/86

https://codstudy.tistory.com/87

https://codstudy.tistory.com/88

https://codstudy.tistory.com/89

 

 

그 전에 공부하며 참고했던 블로그. 
https://jojoldu.tistory.com/734

https://ryumodrn.tistory.com/141

https://elky84.github.io/2022/01/31/exception_process_02/

https://jojoldu.tistory.com/712

 

 

1. 아까 물어봤듯이 slow api는 interceptor로 일정 시간 지나면 db에 로그? 정보 저장하는 거라고 이해했고, 그 외에 로그 레벨이 있잖아? 보통은 debug/info/warn/error을 사용한다고 알고있는데 debug라든지 info log는 그냥 파일로 저장하게끔 logback으로 저장하기만 해도 충분한거지? 그리고 warn이나 error들은 따로 추가적으로 elk로 관리하거나 db에 저장하면 되는건가? elk로 로그 모니터링 할 때 debug/info 단계의 로그도 전부 저장해야하나? 그리고 로그를 내가 발생시켜야 하잖아? 예외 처리를 통해 warn/error 단계의 로그를 남길 때, debug/info 단계의 로그를 남길 때 보통 어떻게 로그를 만드는지 모르겠어. 특히 warn/error 로그를 남길 때는 어디에서, 어떤 부분이 문제가 생긴건지 알 수 있어야 하잖아? 발생된 예외를 catch한 부분에서 해당 로그를 만들어야 한다고 생각하는데 아직 조금 어려운 것 같아

 

 

✅ 1. 로그 레벨별 저장 위치: 어떻게 구성하냐?

기본 전략: 로그 레벨별로 중요도에 따라 저장 위치와 방식이 달라짐

로그 레벨 저장 위치 설명

DEBUG, INFO 로컬 파일 (logback) 너무 많아서 DB/ELK로 바로 보내기엔 과함
WARN, ERROR 로그 파일 + ELK or DB 분석/모니터링 대상, 문제 징후니까 따로 추적

✅ 요약:

  • DEBUG/INFO: 보통 파일에만 저장
  • WARN/ERROR: 필요에 따라 ELK 연동 or DB 저장까지 확대

✅ 2. ELK로 로그를 수집할 땐?

모든 로그를 보내는 건 가능하지만, 실무에선 레벨 필터링을 해서 전송량을 조절해.

  • DEBUG: 거의 안 보냄 (로그 양이 너무 많음)
  • INFO: 주요 비즈니스 로그만 선택 전송
  • WARN/ERROR: 무조건 수집 (알림/모니터링 대상)

실무 설정 예시 (logback → Logstash 연동):

<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
  <level>WARN</level>
</filter>

→ 이러면 WARN 이상만 Logstash로 전송


✅ 3. 로그는 "내가 직접 발생시켜야 한다"

완전 맞는 말이야.
서버는 뭘 로그로 남길지 모름. "어떤 시점에 뭘 남길지"는 개발자가 log.xxx()로 지정해야 해.


✅ 4. 로그는 어떻게, 어디서 남기느냐?

📌 예외 상황: WARN, ERROR 레벨

❗ 전형적인 예시 ①

try {
    userRepository.save(user);
} catch (DataAccessException e) {
    log.error("❌ DB 저장 중 오류 발생: user={}, message={}", user, e.getMessage(), e);
    throw new CustomException("회원가입 실패");
}

e까지 넣으면 스택트레이스도 로그에 포함됨
📌 로그엔 항상: 어떤 데이터에서, 어떤 문제가 발생했는지, 예외 메시지 포함!

❗ 전형적인 예시 ② (외부 API 연동 실패)

try {
    kakaoApi.call(...);
} catch (Exception e) {
    log.warn("⚠️ 카카오 API 호출 실패 - reason={}", e.getMessage(), e);
}

📌 비정상 상황 감지 (예외 없지만 이상할 때): WARN

if (result.isEmpty()) {
    log.warn("🔍 검색 결과 없음 - keyword={}, user={}", keyword, userId);
}

📌 시스템 흐름 추적: INFO

log.info("📥 사용자 로그인 요청 - id={}", loginId);

📌 디버깅용 상세 정보: DEBUG

log.debug("📦 입력 파라미터: {}", requestDto);

이건 개발 환경에서만 찍히게 설정 (운영에서는 안 보이게)


✅ 5. 로그 메시지 구성 팁

구성 요소는 딱 3가지:

  1. 무슨 상황인가?
    "회원가입 실패" / "외부 API 호출 실패" / "검색 결과 없음"
  2. 어떤 데이터와 관련 있는가?
    userId, 입력값, 요청 URL 등
  3. 에러가 있다면 예외 객체 포함
    e.getMessage(), e 자체 (Throwable)

✔️ 추천 로그 포맷

log.error("❌ [카카오API] 호출 실패: code={}, message={}, userId={}", response.getCode(), response.getMsg(), userId);

로그 쌓이는 양 줄이면서도 한 눈에 원인과 상황을 알 수 있게


✅ 마지막 정리

레벨 언제 쓰는가 저장 위치

DEBUG 파라미터, 내부 흐름 추적 dev 환경 파일 로그
INFO 정상적인 시스템 흐름 파일 로그 (가끔 ELK도)
WARN 비정상 or 의심 상황 (예외는 없음) 파일 + ELK
ERROR 예외 발생, 문제 확정 파일 + ELK + 알림 (필요시)

✅ 로그 설계는 결국 이렇게 봐야 해

  • 🔍 DEBUG: "문제가 생겼을 때 자세히 확인할 수 있어야 함"
  • 🚦 INFO: "사용자 흐름을 전체적으로 추적할 수 있어야 함"
  • ⚠️ WARN: "예외는 없지만 뭔가 수상할 때"
  • ❌ ERROR: "예외 터졌고, 추적해야 할 상황"

 

 

2. 아 만약에 어떤 메서드에서 예외가 발생했다면 그 부분을 catch한 부분에서 일단 로그를 발생시키고 그 예외에 대한 처리(프론트에 응답을 주기 위한 처리?)는 다른 영역인거야?


“모든 예외를 하나로 싸서 500만 보내면” 안 되고,
예외의 종류별로 계층 구조를 만들고,
적절한 상태 코드 + 메시지로 응답을 주는 구조가 베스트야.


✅ 왜 예외를 계층 구조로 나누냐?

이유 설명

📌 의미 구분 어떤 예외가 비즈니스 로직 실패인지, 시스템 오류인지 구분
📦 재사용성 공통 예외 처리기에서 분기 처리 가능
✨ 응답 메시지 통일 ErrorResponse 포맷 통일해서 프론트 UX 향상
🔥 디버깅 쉬움 로그 보고도 원인 바로 파악 가능

✅ 예외 계층 구조 예시

GlobalException (extends RuntimeException)
├── BusinessException        // 사용자 요청 오류 (잘못된 값, 비인가 등)
│   ├── AuthException        // 인증/인가 관련
│   ├── ValidationException  // 유효성 검사 실패
│   └── ResourceNotFoundException
└── SystemException          // DB 오류, 외부 API 실패, 예상치 못한 내부 오류 등

✅ 예외 클래스 예시 코드

📌 공통 상위 예외: GlobalException

public abstract class GlobalException extends RuntimeException {
    private final HttpStatus status;

    public GlobalException(String message, HttpStatus status) {
        super(message);
        this.status = status;
    }

    public HttpStatus getStatus() {
        return status;
    }
}

📌 인증 예외: AuthException

public class AuthException extends GlobalException {
    public AuthException(String message) {
        super(message, HttpStatus.UNAUTHORIZED);
    }
}

📌 유효성 실패 예외: ValidationException

public class ValidationException extends GlobalException {
    public ValidationException(String message) {
        super(message, HttpStatus.BAD_REQUEST);
    }
}

📌 서버 오류 예외: SystemException

public class SystemException extends GlobalException {
    public SystemException(String message) {
        super(message, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

✅ 예외 처리기: @RestControllerAdvice

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(GlobalException.class)
    public ResponseEntity<ErrorResponse> handleCustom(GlobalException e) {
        return ResponseEntity.status(e.getStatus())
                .body(new ErrorResponse(e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
        // 로그는 여기서 찍음
        log.error("❌ 예상 못 한 예외 발생", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponse("서버에 문제가 발생했습니다."));
    }
}

✅ 공통 응답 포맷 ErrorResponse

public class ErrorResponse {
    private String message;
    private LocalDateTime timestamp = LocalDateTime.now();

    public ErrorResponse(String message) {
        this.message = message;
    }

    // getter, setter
}

프론트에서는 error.message만 보여주고, 나머지는 로그로 확인


✅ 실무 기준 예외 발생 예시

if (!passwordEncoder.matches(password, user.getPassword())) {
    log.warn("❗ 로그인 실패: 잘못된 비밀번호 - id={}", user.getId());
    throw new AuthException("비밀번호가 일치하지 않습니다.");
}

📦 전체 흐름 정리

[비정상 상황 발생]
 → throw new CustomException()
     → @RestControllerAdvice가 catch
         → 상태코드 + 메시지 응답
         → 로그는 발생지 or advice에서 기록

✅ 보너스: 예외 계층을 어떻게 나누냐?

예외 종류 예시 클래스 상태코드

인증/인가 실패 AuthException 401, 403
잘못된 요청 ValidationException, BadRequestException 400
찾을 수 없음 ResourceNotFoundException 404
중복 요청 DuplicateResourceException 409
서버 내부 오류 SystemException 500
외부 API 실패 ExternalServiceException 502 or 500

✅ 마무리 요약

  • 예외를 계층화하면: 책임 분리 + 응답 일관성 + 관리 편리
  • ExceptionHandler로 응답을 통일하고
  • 로그는 예외 발생 지점에서 명확하게 남겨줘야 디버깅이 쉬움

 

 

3. 근데 이 사이트를 보면 표준 예외를 사용하는 것을 추천한다는데 어떻게 생각해? 물론 다 장단점이 있고 상황마다 답은 다르겠지만

 

그래서 표준 예외 vs 커스텀 예외, 언제 쓰고 어떻게 균형을 잡을지를 실전 기준으로 설명해줄게.


✅ "표준 예외"란?

Java에서 기본 제공하는 예외들:

예외 클래스 설명

IllegalArgumentException 잘못된 인자 전달
IllegalStateException 객체 상태 불일치
NullPointerException NPE
UnsupportedOperationException 구현 안 된 기능
RuntimeException 런타임 예외 최상위
Exception 체크 예외 최상위

🧠 표준 예외 사용의 장점

장점 설명

✅ 이해하기 쉽다 모든 자바 개발자에게 익숙함
✅ 로그만 보고도 의도 파악 쉬움 IllegalArgumentException = 유효성 문제, 직관적
✅ 외부 라이브러리/프레임워크와 잘 통함 예외 캐치, 처리, 메시지 등에서 호환성 좋음
✅ 불필요한 예외 클래스 폭발 방지 하나하나 만들지 않아도 됨

❌ 한계도 분명 있음

단점 설명

❌ API 에러 응답에 상태코드 맵핑 힘듦 IllegalStateException → 400? 409? 불명확
❌ 메시지가 일관되지 않음 메시지 포맷 통일이 어려움
❌ 응답 포맷 커스터마이징 어렵다 status, code, fieldErrors 등 관리 힘듦

✅ 커스텀 예외의 목적은 "응답 제어와 도메인 의미 전달"

단순히 예외를 "던지고 끝"이 아니라
"왜 실패했는지를 프론트에 정확하게 알려주기 위한 설계"

{
  "code": "USER_NOT_FOUND",
  "message": "존재하지 않는 사용자입니다.",
  "status": 404
}

이런 응답을 위해선 보통 커스텀 예외가 필요해.


🔍 실무에서 추천하는 균형 전략

구분 추천 방식

내부 코드에서의 유효성 실패 IllegalArgumentException, IllegalStateException 등 표준 예외 사용
컨트롤러 응답용 예외 처리 CustomException, BusinessException 등 커스텀 예외 사용
외부 API 오류, 인증 실패 등 커스텀 예외로 명확하게 설계
예상 못 한 버그, NPE 등 그냥 RuntimeException, Exception → 500으로 처리

✨ Hybrid 전략 (실무에서 많이 씀)

내부에서는 표준 예외를 사용하고, @ControllerAdvice나 서비스 레이어에서 잡아서 커스텀으로 변환

try {
    validateUserAge(age); // 내부에서 IllegalArgumentException 발생
} catch (IllegalArgumentException e) {
    throw new ValidationException("나이 값이 잘못되었습니다.");
}
  • 내부에서는 표준 예외로 로직 표현
  • 사용자 응답은 커스텀 예외로 명확하게

💬 결론: 너의 말대로 상황에 따라 다름

상황 표준 예외 커스텀 예외

코드 간결함, 내부 로직용 ✅ 적합 ❌ 과함
REST API 응답 제어, 코드 메시지, 문서화 ❌ 불명확 ✅ 강력함
팀 전체가 이해하기 쉬운 코드 ✅ 친숙함 ✅ 명시적 의미 부여 가능

✅ 추천 전략

“표준 예외로는 충분할 때는 쓰고, 사용자 응답을 제어해야 할 땐 커스텀으로 감싼다”

이게 실무에서 가장 균형 잡힌 방식이야.


 

 

 

4. 근데 궁금한 게 하이브리드 전략에서 try { validateUserAge(age); // 내부에서 IllegalArgumentException 발생 } catch (IllegalArgumentException e) { throw new ValidationException("나이 값이 잘못되었습니다."); } 이런 식으로 코드를 쓴다고 했잖아? 근데 내부에서는 표준 예외를 사용하고 프론트에 응답을 보낼 때 커스텀으로 변환하는 과정까지는 잘 이해했거든? 근데 사실 진짜로 서비스 내부 로직에 뭔가 문제가 있어서 저런 exception이 발생하는 경우도 있을 거 아니야? 근데 로그를 찍지 않아도 돼? exception이 발생하는 경우에는 나는 웬만해서는 로그를 쓰는 게 좋다고 생각하는데! 만약에 로그를 찍게 된다면 표준 예외가 발생했다는 정보를 넣어야할지 아니면 커스텀으로 변환한 정보를 넣어야할지 알려줘

 


"표준 예외를 잡아서 커스텀 예외로 변환할 때 로그를 어디에 남기느냐", 그리고
**"어떤 예외 정보를 로그에 남기느냐"**는 문제 진단의 관점에서 매우 중요해.


✅ 기본적인 결론 먼저

로그는 “문제의 원인”을 남겨야 한다.
→ 따라서 IllegalArgumentException을 catch한 시점에서 로그를 남기는 게 맞고,
→ 로그에는 원래 발생한 표준 예외 정보를 포함하는 게 좋다.


🔍 코드로 보면 이렇게 하는 게 베스트

try {
    validateUserAge(age);
} catch (IllegalArgumentException e) {
    log.warn("❗ 유효하지 않은 나이 값 전달됨 - age={}, message={}", age, e.getMessage(), e);
    throw new ValidationException("나이 값이 잘못되었습니다.");
}

포인트 이유

warn 수준 예상 가능한 사용자 입력 오류니까 error까지는 아님
e.getMessage() 표준 예외의 원인 메시지 로그로 남기기
e 자체 로그 시스템이 스택트레이스까지 기록 가능하게

✅ 커스텀 예외 던질 땐 로그 다시 찍지 않아도 됨

이미 원인 로그를 찍었기 때문에,
throw new ValidationException(...) 할 때는 단순히 변환만 하면 돼.


🔄 다른 방식도 있긴 해

❗ 또는, 전역 예외 처리기에서 로그를 찍는 경우도 있어:

@ExceptionHandler(GlobalException.class)
public ResponseEntity<?> handleCustom(GlobalException e) {
    log.warn("Custom Exception 발생: {}, status={}", e.getMessage(), e.getStatus());
    return ...
}

이 방식이면 로직 내부에서는 로그 안 찍고,
예외가 전역 처리기로 전달되었을 때 한 번만 로그를 찍는 구조야.

하지만 이건 “어떤 예외가 자주 발생하는지 로그 집계” 목적일 땐 좋지만,
**"어디서 예외가 났는지 정확히 추적"**하기엔 부족할 수 있어.


✅ 실무에서는 어느 방식이 더 흔하냐?

스타일 특징 사용 비율

발생 지점에서 로그 → 전역 처리기는 응답만 원인 추적 쉬움, 문제 위치 정확 ✅ 많이 씀 (추천)
전역 처리기에서 로그만 찍음 로그 집중 관리, 반복 방지 🔵 단순 프로젝트 or 로그량 조절 시
둘 다 찍음 로그 중복 위험 있음 ⚠️ 잘 안 씀

✅ 마무리 요약

질문 답

표준 예외 → 커스텀 예외 변환 시 로그 남겨야 해? ✅ 남겨야 함 (특히 원인을 명확히 파악하려면)
어디에서 로그를 남겨야 해? 예외를 catch한 발생 지점에서
로그에는 어떤 정보 담아야 해? 문제 설명, 파라미터, e.getMessage(), e (스택트레이스)
커스텀 예외 던질 때 또 로그 찍어야 해? ❌ 이미 찍었으면 생략
전역 예외 처리기에서도 찍을까? 필요에 따라, 하지만 중복은 피하는 게 좋아

 

 

 

 

5. 예외마다 자동으로 로그 남기는 추상화된 ExceptionFactory throwExceptionWithLogging(...) 같은 헬퍼 유틸도 설계 가능해 😎 오 이거 두개도 재밌어 보이는데 보통 어떤 방법이 더 보편적으로 사용되는지, 각각의 장단점이 뭔지 포함해서 설명해줘

 

아주 좋아. 이건 예외 처리를 반복 없이, 일관되게, 덜 귀찮게 하려는 고급 설계야 😎
실무에서도 이 두 가지 패턴은 꽤 자주 논의돼. 특히 대규모 서비스나 공통 라이브러리에서는 아주 유용해.


✅ 1. ExceptionFactory 패턴

예외를 생성할 때 자동으로 로그도 남기고, 필요한 정보도 함께 다루는 일종의 예외 생성기 클래스


📦 사용 방식 예시

throw ExceptionFactory.auth("로그인 실패: 토큰 만료");

이 내부에서 AuthException을 만들고 로그도 자동으로 찍는 식


🔧 구현 예시

@Slf4j
public class ExceptionFactory {

    public static AuthException auth(String message) {
        log.warn("AuthException 발생 - {}", message);
        return new AuthException(message);
    }

    public static ValidationException validation(String message) {
        log.warn("ValidationException 발생 - {}", message);
        return new ValidationException(message);
    }

    public static SystemException system(String message, Throwable cause) {
        log.error("SystemException 발생 - {}", message, cause);
        return new SystemException(message, cause);
    }

    // ... 다른 예외들 추가 가능
}

✅ 장점

장점 설명

✅ 예외 생성 + 로깅 일원화 실수로 로그 안 찍는 것 방지
✅ 반복 제거 매번 log.error + throw 안 해도 됨
✅ 메시지 일관성 메시지 포맷 강제 가능 ([AUTH] ~, [VALIDATION] ~ 등)

❌ 단점

단점 설명

❌ 예외를 외부에서 생성함 → 테스트 불편 IDE에서 자동 추적 어려워짐
❌ 익숙하지 않은 패턴 팀에 설명/교육 필요
❌ 예외 내부에 로직이 숨어버림 디버깅 시 단방향 흐름이 깨짐

✅ 2. throwExceptionWithLogging(...) 헬퍼 함수

기존 예외 객체를 전달하고, 로그를 남기고, 던지는 것까지 한 줄로 묶는 함수


📦 사용 방식 예시

throw ExceptionHelper.throwWithLog(new ValidationException("나이 값 오류"));

어떤 예외든 외부에서 만들어서 넘기고, 내부에서 로깅 + 리턴


🔧 구현 예시

@Slf4j
public class ExceptionHelper {

    public static <T extends RuntimeException> T throwWithLog(T e) {
        if (e instanceof SystemException) {
            log.error("SystemException 발생", e);
        } else if (e instanceof GlobalException) {
            log.warn("Custom 예외 발생 - {}: {}", e.getClass().getSimpleName(), e.getMessage());
        } else {
            log.error("Unknown 예외 발생", e);
        }
        return e;
    }
}

✅ 장점

장점 설명

✅ 어떤 예외든 한 줄로 처리 가능 throw ExceptionHelper.throwWithLog(...)
✅ IDE에서 예외 추적 편함 예외 직접 생성해서 넘기므로 추적 가능
✅ 호출부 자유로움 인자만 넘기면 되니까 커스터마이징 유연함

❌ 단점

단점 설명

❌ 단순히 로그 + throw만 하므로 기능 확장 어려움 메시지 템플릿 강제화 불가
❌ log 찍는 위치와 throw 위치가 묘하게 분리됨 디버깅 시 혼란 가능
❌ 일부 개발자에겐 생소함 “저거 왜 굳이 써?”라는 반응도 나옴

✅ 실무에서는 어떤 게 더 보편적일까?

상황 추천

대규모 공통 예외 구조, API 코드 일관성 필요 🔥 ExceptionFactory
단순한 예외처리 반복 줄이고 싶을 때 👍 ExceptionHelper
작은 프로젝트 / 로직 단순 ❌ 둘 다 안 쓰고 직접 로그 + throw

실제로 ExceptionFactory 방식은 공통 라이브러리나 핵심 모듈에서 자주 사용되고,
Helper 방식은 유틸성으로 가볍게 적용하는 프로젝트에서 많이 씀.


✨ 실전에서 이런 식으로 응용 가능

// 기존
log.warn("비밀번호 불일치");  
throw new AuthException("비밀번호가 잘못되었습니다");

// 개선
throw ExceptionFactory.auth("비밀번호 불일치");

🎯 정리 비교표

항목 ExceptionFactory ExceptionHelper

예외 + 로그 묶기 ✅ ✅
예외 내부에서 생성 ✅ ❌ (외부 생성)
메시지 템플릿 통일 ✅ 강제 가능 ❌ 유연함
IDE 자동 추적 ❌ 약함 ✅ 강함
커스터마이징 유연성 중간 높음
실무 사용 비율 중간~상 중간

😎 마무리 추천

✅ 로그 누락 방지 + 일관된 예외 메시지를 원한다면 → ExceptionFactory
✅ 그냥 반복 줄이고 싶고, 자유롭게 예외 던지고 싶다면 → throwWithLog


 

6. 근데 저걸 보면 ExceptionFactory에서 로그를 찍을 때 너무 정보가 적은 것 같아. 만약에 내가 개발하려는 서비스에서 elk를 통해 로그를 수집하여 모니터링하고 싶을 때, 로그의 단계 정보, 예외 발생 위치, 발생 원인과 같은 정보를 담지 않아도 돼? 단순히 debug나 info 단계가 아닌 저렇게 warn 레벨의 로그라면 세부정보가 포함되어야 할 것 같은데 보통 실무에서 저렇게 간단하게 써? 그냥 일관성을 위해서?

 

 

이야… 이건 진짜 로그 설계의 핵심을 정확하게 찌른 질문이야.
너처럼 “ELK로 수집해서 모니터링할 거고, warn이나 error는 꼭 자세한 정보를 담아야 한다”는 감각이 있으면, 로그 전략이 단단해지고 문제 추적도 훨씬 쉬워져.

그래서 정답부터 말하자면:


✅ "경고(warn), 에러(error) 로그는 무조건 세부정보가 포함되어야 한다"

  • 로그 레벨이 높아질수록:
    • 어디서, 왜, 누구에 의해, 어떤 상황에서 발생했는지를 담는 게 실무의 표준이야.
  • ExceptionFactory가 단순 메시지 하나만 찍고 끝나는 건 "실용적인 기본 버전"일 뿐, 실제론 확장해서 정보 더 담는 게 맞아.

✅ 왜냐하면... ELK, Kibana 같은 모니터링 시스템은 로그를 이렇게 보는 구조야:

필드 예시

@timestamp 2025-03-24T16:15:05Z
level WARN
service user-api
message "[AuthException] 로그인 실패 - userId=abc123"
path /api/login
class AuthService
method login()
traceId b8adf12345ef
stacktrace (선택적으로 포함)

이런 식으로 “컨텍스트 정보”가 충분히 담겨야, Kibana에서 필터링, 집계, 알림이 가능해.


✅ 실무에서는 ExceptionFactory도 이렇게 확장함

public class ExceptionFactory {

    public static AuthException auth(String message, Map<String, Object> context, Throwable cause) {
        log.warn("[AuthException] {} | context={} | cause={}", message, context, cause != null ? cause.getMessage() : null, cause);
        return new AuthException(message, cause);
    }
}

🔥 핵심 포인트:

  • context: userId, 요청 path, 관련된 주요 파라미터 등
  • cause: 내부 예외 객체 포함해서 root cause 기록
  • 로그 메시지도 포맷을 통일 ([ExceptionName] message | context | cause)

✅ 실전 예시

throw ExceptionFactory.auth(
    "비밀번호 불일치",
    Map.of("userId", loginId, "ip", request.getRemoteAddr()),
    e
);

→ Kibana에서 다음과 같이 검색 가능:

  • level:WARN AND message:"비밀번호 불일치" AND context.userId:loginId

✅ 일관성과 정보량의 균형

설계 기준 설명

메시지 포맷 통일 `[ExceptionType] message
context는 Map key-value로, Kibana에서 JSON 형태로 파싱 가능
스택트레이스 포함 여부 WARN은 선택, ERROR는 대부분 포함 (문제 추적 용도)
traceId, userId, path Mapped Diagnostic Context(MDC)로 자동 포함시키기도 함 (logback + MDC)

✅ 결론 정리

질문 정리된 답변

warn, error 로그는 정보가 많아야 해? ✅ 무조건 포함해야 실무에서 의미 있음
단순 메시지로 로그 찍는 건 왜 하나? 기본 버전이거나, 로그 양 줄이기 위함 (개발 초기 or 간단 상황)
ExceptionFactory에 정보 더 담아야 해? ✅ context, cause, traceId 등 확장 필요
일관성을 위해 단순하게 한다? ❌ 일관성은 메시지 포맷에서만! 정보를 줄이면 실무에서 쓸모 없음

😎 추가로 도와줄 수 있어

  • ExceptionFactory 고도화 버전 만들어주기 (Map<String, Object> context 포함)
  • MDC로 로그에 자동으로 userId, traceId 등 삽입하기
  • logback-spring.xml에 JSON 로그 포맷 설정해서 ELK 최적화

지금 만들고 있는 서비스에서 Kibana까지 염두에 두고 로그 설계할 거면,
그걸 기준으로 ExceptionFactory, logback, LoggingFilter까지 맞춰서 구조화해줄 수 있어!

진짜 실무 수준으로 세팅해보고 싶어? 😎

 

 

7. 일단 ExceptionFactory 고도화 버전 만들어주기 (Map<String, Object> context 포함) MDC로 로그에 자동으로 userId, traceId 등 삽입하기 이것부터 알고싶어!

 

// ✅ ExceptionFactory 고도화 버전 (context + cause 포함)

import lombok.extern.slf4j.Slf4j;

import java.util.Map;

@Slf4j
public class ExceptionFactory {

    public static AuthException auth(String message, Map<String, Object> context) {
        return auth(message, context, null);
    }

  // message: 로그 내용
  // context: 예) Map.of("userId", loginId, "ip", request.getRemoteAddr())
  // cause: exception 종류
  /*
    throw ExceptionFactory.auth(
        "비밀번호 불일치",
        Map.of("userId", loginId, "ip", request.getRemoteAddr()),
        e
    );
    이렇게 호출
  */
    public static AuthException auth(String message, Map<String, Object> context, Throwable cause) {
        log.warn("[AuthException] {} | context={} | cause={}", message, context, cause != null ? cause.getMessage() : "none", cause);
        return new AuthException(message, cause);
    }

    public static ValidationException validation(String message, Map<String, Object> context) {
        return validation(message, context, null);
    }

    public static ValidationException validation(String message, Map<String, Object> context, Throwable cause) {
        log.warn("[ValidationException] {} | context={} | cause={}", message, context, cause != null ? cause.getMessage() : "none", cause);
        return new ValidationException(message, cause);
    }

    public static SystemException system(String message, Map<String, Object> context, Throwable cause) {
        log.error("[SystemException] {} | context={} | cause={}", message, context, cause != null ? cause.getMessage() : "none", cause);
        return new SystemException(message, cause);
    }
}


// ✅ MDC 설정 예시 (Interceptor)

import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

@Component
public class LoggingInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // Trace ID: 각 요청별 고유한 ID 생성
        String traceId = UUID.randomUUID().toString();
        MDC.put("traceId", traceId);

        // User ID: 로그인 사용자라면 설정 (예시: 헤더에서 추출하거나 SecurityContext에서)
        String userId = request.getHeader("X-User-Id");
        if (userId != null) {
            MDC.put("userId", userId);
        }

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        MDC.clear(); // 메모리 누수 방지
    }
}


// ✅ logback-spring.xml 에서 MDC 로그 항목 추가

/*
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - traceId=%X{traceId}, userId=%X{userId}, msg=%msg%n</pattern>
*/

 

 

 

8. logback-spring.xml에 JSON 로그 포맷 설정해서 ELK 최적화 이거 고려해서 ExceptionFactory, logback, LoggingFilter까지 맞춰서 구조화해줘!

 

// ✅ ExceptionFactory 고도화 버전 (context + cause 포함)

import lombok.extern.slf4j.Slf4j;
import java.util.Map;

@Slf4j
public class ExceptionFactory {

    public static AuthException auth(String message, Map<String, Object> context) {
        return auth(message, context, null);
    }

    public static AuthException auth(String message, Map<String, Object> context, Throwable cause) {
        log.warn("{}", buildLog("AuthException", message, context, cause));
        return new AuthException(message, cause);
    }

    public static ValidationException validation(String message, Map<String, Object> context) {
        return validation(message, context, null);
    }

    public static ValidationException validation(String message, Map<String, Object> context, Throwable cause) {
        log.warn("{}", buildLog("ValidationException", message, context, cause));
        return new ValidationException(message, cause);
    }

    public static SystemException system(String message, Map<String, Object> context, Throwable cause) {
        log.error("{}", buildLog("SystemException", message, context, cause));
        return new SystemException(message, cause);
    }

    private static String buildLog(String type, String message, Map<String, Object> context, Throwable cause) {
        return new StringBuilder()
                .append("{")
                .append("\"type\":\"" + type + "\",")
                .append("\"message\":\"" + message + "\",")
                .append("\"context\":\"" + context + "\",")
                .append("\"cause\":\"" + (cause != null ? cause.getMessage() : "none") + "\"")
                .append("}")
                .toString();
    }
}


// ✅ MDC 설정 예시 (Interceptor)

import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

@Component
public class LoggingInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String traceId = UUID.randomUUID().toString();
        MDC.put("traceId", traceId);

        String userId = request.getHeader("X-User-Id");
        if (userId != null) {
            MDC.put("userId", userId);
        }
        MDC.put("path", request.getRequestURI());
        MDC.put("method", request.getMethod());

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        MDC.clear();
    }
}


// ✅ logback-spring.xml 예시 (JSON 포맷 + ELK 최적화)

/*
<configuration>
    <appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
            <providers>
                <timestamp>
                    <fieldName>timestamp</fieldName>
                </timestamp>
                <pattern>
                    <pattern>
                        {
                            "level": "%level",
                            "logger": "%logger{36}",
                            "thread": "%thread",
                            "message": "%message",
                            "traceId": "%X{traceId}",
                            "userId": "%X{userId}",
                            "path": "%X{path}",
                            "method": "%X{method}"
                        }
                    </pattern>
                </pattern>
            </providers>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="JSON_CONSOLE"/>
    </root>
</configuration>
*/

 

 

좋아! 이제 구조가 ELK 연동을 고려한 실무형 로그 시스템으로 완전히 정리됐어 😎
정리해보면:


✅ 구성 요약

구성 요소 기능

ExceptionFactory 예외 로그를 JSON 형식으로 일관되게 기록, context 포함
LoggingInterceptor 요청마다 MDC에 traceId, userId, path, method 자동 삽입
logback-spring.xml 로그를 JSON 형태로 포맷팅, ELK에 최적화된 구조로 출력

✅ 로그 예시 (ELK 수집 시 형태)

{
  "timestamp": "2025-03-24T16:40:32.456+09:00",
  "level": "WARN",
  "logger": "com.example.auth.ExceptionFactory",
  "thread": "http-nio-8080-exec-1",
  "message": "{\"type\":\"AuthException\",\"message\":\"비밀번호 불일치\",\"context\":\"{userId=abc123, ip=1.2.3.4}\",\"cause\":\"잘못된 비밀번호\"}",
  "traceId": "abc123-def456...",
  "userId": "abc123",
  "path": "/api/login",
  "method": "POST"
}

이 구조는 Kibana에서 검색, 필터링, 시각화가 매우 쉬운 형태야.


👉 다음에 적용할 수 있는 확장 예시

  • traceId를 Response Header로 내려주는 기능 (X-Trace-Id)
  • 모든 요청/응답 바디까지 로그로 남기는 LoggingFilter
  • Slf4j MDCAdapter로 비동기 로그에도 traceId 유지

필요하면 바로 이어서 도와줄게!
다음으로 연결하고 싶은 기능 있어? 아니면 ELK 연결까지도 생각하고 있어? 😎

 

 

 

 

 

 

 

 

 

 

MDC 및 ELK 관련 공부하기.