카테고리 없음

자바 공부#4_04. 커스텀 예외 사용

코딩연습중 2025. 4. 19. 16:58

저번에 표준 예외와 커스텀 예외를 비교하면서 커스텀 예외에 대해 얘기했었는데 커스텀 예외에 대해서 조금 더 공부해보고 싶어 글을 남긴다.

(이번 글은 온전히 내 개인적인 의견이다.)

 

예외 처리의 근본적인 목적이 무엇일까?

예외는 정상적인 프로그램 흐름에서 벗어나는 예외적인 상황을 처리하기 위해 사용된다.

즉, "예외가 발생함을 인지하고, 어떤 예외가 발생했는지와 같은 예외의 정보를 알아야 하며, 그 예외를 어떻게 처리하는지"까지가 예외 처리의 프로세스 및 목적이라고 생각한다. 또 이는 시스템을 개선하는 데에 중요한 역할을 한다. 

 

예외의 용도

클라이언트 응답용 예외

예를 들어 회원가입 시, 서버에서 받은 파라미터의 값이 유효한지 검사를 하게 되는데,

이 때 이름에 "홍길동"이 아닌 "ㅎㅗㅇㄱㅣㄹㄷㅗㅇ" 과 같이 잘못된 값이 넘어왔을 때, 이를 서버에 기록할 필요가 있나?

아니다.

 

다시 생각해보니 개발할 때에 아무리 테스트를 한다고 해도 내가 빼먹은 예외 상황이 있을 수도 있음. 그런 경우에는 BusinessException이 발생하더라도 log에 기록을 해놓는 게 맞는 것 같다. (물론 운영 환경에서는 불필요한 로그를 줄이고, 중요한 것만 기록해서 노이즈 없이 모니터링 할 수 있도록~)

이를 위해서 개발 환경과 운영 환경에 따라 로그 전략을 달리 해야한다. (결국 로그 작성 전략이 중요.)

 

사용자의 잘못된 입력, 권한 없음, 존재하지 않는 리소스 등과 같이 사용자의 잘못된 요청에 대해 클라이언트 단에 exception 발생을 알리기 위한 경우라면 로그를 남길 필요는 없다. 그저 클라이언트에 명확한 에러 메시지를 주는 것이 목적이기 때문이다. 

그래서 다음과 같이 클라이언트 응답을 위한 커스텀 예외 클래스를 하나로 두고 일관된 메시지를 전달하여 깔끔하게 관리할 수 있다.

public class BusinessException extends RuntimeException {
    private final String errorCode;
    private final String message;

    public BusinessException(String errorCode, String message) {
        super(message, null, false, false); // 성능 최적화를 위해 stack trace 비활성화
        this.errorCode = errorCode;
        this.message = message;
    }
}

(단지 클라리언트에 응답을 주는 목적으로 예외를 발생시키는 것이라면 굳이 stack trace를 활성화시킬 필요 없음. 괜히 비용만 더 들기 때문.)

 

 

근데 의문이 생겼다. 만약 공격자가 의도적으로 자꾸 정보를 바꿔서 서버에 요청을 보내는 경우도 있을텐데, 만약 이를 그냥 비즈니스 예외로 처리하여 로그를 남겨두지 않으면 기록에도 안 남아서 모르는 거 아닌가? (보안에 대한 지식은 많이 부족..)

근데 이런 악의적인 요청이 반복되는 건 BusinessException에서 담당할 일이 아니라 따로 수상한 반복 요청에 대한 로그를 남기는 로직을 포함해야 할 것 같다. 
보안을 위해 Spring Security와 같은 보안 필터가 있고, IP를 추적하여 막도록 해야한다.
다시 생각해보니 예외는 정말 말 그대로 "예외"만을 처리해야하는 것이지, 그 외의 요인을 예외 처리를 통해 해결하는 건 부적합한 것 같다.

 

 

 

서버 디버깅용 예외

클라이언트에게 응답을 주기 위해 exception을 throw하는 경우와는 다르게, 시스템 내부에서 문제가 발생한 경우, 이는 기록으로 관리하여 개발자가 추적 및 해결할 수 있도록 해야한다. (따라서 이 때에 로그를 잘 남기는 것이 중요!)

예를 들면 DB 연결 실패, 외부 API 호출 실패, NullPointerException 등이 있다. 

서버 디버깅용 예외를 작성할 때에는 로그를 잘 남기는 것이 중요한데, 이는 따로 로그 남기는 방법에 대한 글로 올리겠다.

일단 간단히 설명하자면, 로그에 들어가야 할 정보로는 에러 코드, 메시지, 예외 발생 위치가 있다.

 

public class InternalServerException extends RuntimeException {
    private final String errorCode;

    public InternalServerException(String errorCode, String message) {
        super(message); // 기본 stack trace 포함
        this.errorCode = errorCode;
    }
}

 

(로그에는 반드시 stack trace를 남겨야 한다. 이를 기반으로 이슈 추적!)

 

🤔 의문이 생겼다.

보통은 공통 ErrorCode를 enum으로 따로 관리하는데, 클라이언트 문제로 사용되는 에러코드와 서버 문제로 사용되는 에러코드가 같아야 하나?

 

아니다.

클라이언트와 서버는 예외 발생의 책임 주체가 다르기 때문에,
에러 코드도 명확하게 분리해서 관리하는 것이 유지보수와 로깅, 모니터링 모두에 유리하다.

 

 


 

 

1. Prefix 사용

public enum ErrorCode {
    INVALID_INPUT("C001", "잘못된 입력입니다."),
    USER_NOT_FOUND("C002", "존재하지 않는 사용자입니다."),
    INTERNAL_SERVER_ERROR("S001", "서버 내부 오류입니다."),
    DB_CONNECTION_FAILED("S002", "DB 연결에 실패했습니다.");
}

 

(클라이언트 잘못인 경우, C로 시작하는 코드, 시스템의 문제일 경우, S로 시작하는 코드를 정의할 수 있다.)

위와 같이 에러코드에 prefix를 붙이면 파악이 쉽다.

 

(errorcode prefix의 또 다른 예시)

@AllArgsConstructor
public enum ErrorCode {

    TOO_MANY_REQUEST(429, "GLOBAL_429_1", "과도한 요청을 보내셨습니다. 잠시 기다려 주세요.");
    private Integer status;
    private String code;
    private String reason;
    
    public ErrorReason getErrorReason() {
        return ErrorReason.builder().reason(reason).code(code).status(status).build();
    }
}

 

2. Type 필드

public enum ErrorCode {
    INVALID_INPUT("C001", "잘못된 입력입니다.", ErrorType.CLIENT),
    USER_NOT_FOUND("C002", "존재하지 않는 사용자입니다.", ErrorType.CLIENT),
    INTERNAL_SERVER_ERROR("S001", "서버 내부 오류입니다.", ErrorType.SERVER),
    DB_CONNECTION_FAILED("S002", "DB 연결에 실패했습니다.", ErrorType.SERVER);

    private final String code;
    private final String message;
    private final ErrorType type;

    ErrorCode(String code, String message, ErrorType type) {
        this.code = code;
        this.message = message;
        this.type = type;
    }

    public enum ErrorType {
        CLIENT, SERVER
    }

    // Getter methods...
}

 

Type필드의 장점

1. 클라이언트/서버 책임 구분 명확

2. 모니터링/알림/로깅 분기 제어. 

  • 예를 들어, Type.SERVER인 에러만 슬랙에 경고 알림을 보낼 수 있음.

3. 에러 응답 일관성 예외 핸들러에서

  • Type에 따라 HTTP Status 결정 가능
    예: CLIENT → 400, SERVER → 500

4. 보안 측면 필터링

  • 서버 타입 예외만 에러 메시지를 "Internal Server Error"로 통일, 클라이언트 오류는 구체적인 메시지 제공 가능

❗️Type 필드의 단점

단점 설명 해결책
중복 정보가 될 수 있음 이미 HttpStatus나 도메인 enum이 역할을 구분하고 있을 수 있음 명확한 책임 구분 기준 정하기
코드 작성 시 오버헤드 enum 항목 만들 때마다 Type을 꼭 지정해야 함 기본값 부여 또는 IDE 템플릿 사용
일관성 없을 시 혼란 개발자마다 CLIENT vs SERVER 기준이 다르면 애매함 팀 내 정의 문서 or 코드 리뷰로 통일
확장성 고민 필요 CLIENT/SERVER 외에 AUTH, SYSTEM, NETWORK 등 세분화할 경우 복잡도 증가 enum 계층화 or 코드 매핑 전략 필요

 

위와 같이 Type 필드를 사용하게 된다면 고급 로깅/운영/보안 정책을 유연하게 하는 좋은 수단이 되지만, 명확한 기준이 없거나 체계적인 관리가 불가능한 경우, 오히려 혼란을 일으킬 수 있다는 문제가 있어 보인다. 

 

 

 


 

 

ErrorCode를 Interface로?

ErrorCode를 enum으로 정의하는 건 많이 봐왔지만 Interface로 정의하는 경우를 발견했다. 처음 보는 경우였고, 글을 읽어보니 조금 더 공부해보고 정리해보면 좋을 것 같아 이어서 작성해보았다.

 

에러 코드를 세세하게 나누는 게 과연 장점일까?

( https://blog.letsdev.me/your-errorcode-enum-to-interface-kor 블로그 글을 정리한 내용.)

에러 코드를 너무 세세하게 나누다보면 분산 환경에서 협업 시 소통 비용을 늘린다는 문제가 있다. 따라서 에러 코드도 적당히 추상적으로 쓰는게 좋은 전략이다.

 

이처럼 일원화된 에러 코드 제공 시 대략 이런 문제들을 극복할 필요가 있습니다.

  • 서로 다른 리소스/피처의 에러 코드를 한 곳에 묶어서 글로벌하게 관리하게 됩니다.
  • MSA라면 서로 다른 마이크로 서비스를 개발하고 있는 구성원과 소통 비용이 발생할 수 있습니다.
  • 멀티 모듈 프로젝트라면 공통 모듈이 각 서비스를 구성하는 모듈들에 종속되는 듯한 오묘한 관계가 됩니다. 서비스에서 사용할 여러 세부 에러코드를 공통 모듈이나 그에 인접한 단계의 모듈에 모으게 되기 때문이죠. (멀티모듈을 모른다면, 패키지보다 고도화된 구분이라고 생각하세요.)

\=> 요약하면, 서비스를 구분해서 개발할 때 ErrorCode의 관리가 이상하고 번거롭다는 것입니다.

 

위의 글을 보면, 특히 MSA 구조가 추세인 요즘, 에러 코드의 관리 전략도 고민해보아야 한다.

따라서 마이크로서비스마다 각자 관리하는 에러코드를 독립적으로 관리하고, 필요함녀 다른 서비스의 에러코드도 전달받아서 사용할 수 있어야 편하다.

 

자세한 내용은 아래의 두 블로그를 참고해서 공부해보고 적용해보면 좋을 것 같다!

에러코드를 enum이 아닌 interface로!
-  https://blog.letsdev.me/your-errorcode-enum-to-interface-kor
- https://devnm.tistory.com/27

 

 

 

참고:

error code 도메인 별 분리

- https://devnm.tistory.com/27

커스텀 에러 코드

- https://dgjinsu.tistory.com/74