이전 글 ..
공통 예외 처리 적용하기(2)
지난 글에서는 프로젝트의 예외 처리 구조를 도메인별로 관리할 수 있도록 개선하였습니다.
이번 포스팅에서는 기존에 사용하던 EntityNotFoundException 을 BaseException 을 상속받는 구조로 변경하고, 예외 메시지를 하드 코딩하던 부분을 공통 로직으로 대체하는 리팩토링을 진행해보려 합니다.
현재는 프로젝트 초기 단계라 예외 클래스가 많지는 않지만, 이번 작업을 통해 구성한 공통 처리 방식은 앞으로 추가될 예외 클래스에서도 재사용 가능하도록 설계 해두려 합니다.
결과적으로 예외 처리 방식의 일관성을 높이고, 코드 중복을 줄이며 유지보수하기 쉬운 구조로 만드는 것이 목표입니다.
1. 기존 EntityNotFoundException 구조
public class EntityNotFoundException extends RuntimeException {
public EntityNotFoundException(String message) {
super(message);
}
}
JPA를 통해 엔티티를 조회했을 때, 해당 데이터가 존재하지 않는 경우 EntityNotFoundException 을 직접 던지는 방식으로 처리해왔습니다.
사용 예시 (비즈니스 로직 일부)
return memberChatroomRepository.findByMemberAndChatroom(member, chatroom)
.orElseThrow(() -> new EntityNotFoundException(
String.format("회원[%d]은 채팅방[%d]에 존재하지 않습니다.", member.getId(), chatroom.getId()))
);
이처럼 예외 발생 시 메시지를 직접 String.format() 으로 하드코딩하여 정의하고 있었고, 도메인이 달라질수록 메시지의 형태나 규칙도 일관성이 떨어지고 오타나 누락 등도 실제 런타임 이전에는 확인하기 어려운 구조였습니다.
이러한 문제를 해결하기 위해, 지난 글에서 다룬 PaymentConfirmException 처럼 에러 코드를 Enum 으로 정의하고, 예외는 BaseException 을 상속받는 구조로 통일하여 공통 예외 처리 로직으로 변경해보겠습니다.
2. 예외 처리 구조 설계
예외 메시지를 설정할 때, 단순히 "해당 엔티티가 존재하지 않습니다" 와 같은 모호한 표현이 아니라, 예를 들어 "채팅방(id:1)이 존재하지 않습니다" 또는 "회원(id:1)이 존재하지 않습니다" 처럼 구체적인 엔티티 정보(예: id)를 포함하고자 했습니다.
특히 회원-채팅방처럼 두 엔티티 간 관계를 나타내는 경우에는, "회원(id:1)은 채팅방(id:1)에 참여되어 있지 않습니다" 와 같이 여러 엔티티의 ID를 함께 표시해야 할 상황도 자주 발생합니다.
이를 해결하기 위해, 에러 코드를 Enum 으로 정의하고, 예외 메시지의 가변적인 값은 String.format() 스타일로 동적으로 치환할 수 있도록 설계할 예정입니다.
예를 들어 다음과 같이 메시지 템플릿을 enum에 정의해두고 비즈니스 로직에서는 간결하게 사용할 수 있습니다.
MEMBER_CHATROOM_NOT_FOUND("회원(id: %d)은 채팅방(id: %d)에 참여되어 있지 않습니다.")
// 비즈니스 로직
throw new EntityNotFoundException(EntityErrorCode.MEMBER_CHATROOM_NOT_FOUND, memberId, chatroomId);
// 결과 추력
"회원(id: 1)은 채팅방(id: 3)에 참여되어 있지 않습니다"
3. 소스 코드 변경
3.1. ResCode
package com.pretallez.common.response;
import org.springframework.http.HttpStatusCode;
public interface ResCode {
HttpStatusCode getHttpStatusCode();
Integer getCode();
String getDescription();
// 해당 메서드 추가
default String getFormattedDescription(Object... args) {
return String.format(getDescription(), args);
}
// 해당 메서드 추가
default String getMessage() {
if (this instanceof Enum<?>) {
return ((Enum<?>) this).name();
}
return "Unknown";
}
}
예외 메시지를 가변 인자 형식으로 동작으로 구성할 수 있도록 ResCode 인터페이스에 두 가지 메서드를 추가하였습니다.
3.1.1. getFormattedDescription(Object .. args)
예외 메시지에 동적으로 값을 삽입할 수 있도록 String.format() 을 활용한 메서드입니다.
예를 들어, description 값이 다음과 같을 때
"회원(id: %d)이 존재하지 않습니다."
args에 memberId를 전달하면 실제로 반환되는 메시지는 다음과 같이 포맷팅됩니다.
"회원(id: 1)이 존재하지 않습니다."
가변 인자를 받아야 하므로, 매개변수 타입은 Object... 로 선언하였습니다.
이로 인해 String, int, long 등 다양한 타입의 값을 처리할 수 있습니다.
3.1.2. getMessage()
기존에는 각 Enum 클래스에서 직접 오버라이딩하던 getMessage() 를 인터페이스에 기본 구현으로 제공하여, 중복 구현을 제거했습니다.
ResCode 를 구현한 클래스가 enum 인 경우, enum.name() 값을 반환하고, 그 외의 경우에는 "Unknown" 을 반환하도록 예외 처리 하였습니다.
3.2. EntityErrorCode
@Getter
@RequiredArgsConstructor
public enum EntityErrorCode implements ResCode {
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, 40400, "회원(id:%d) 엔티티를 찾을 수 없습니다."),
CHATROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 40400, "채팅방(id:%d) 엔티티를 찾을 수 없습니다."),
MEMBERCHATROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 40400, "회원채팅방(회원id:%d, 채팅방id:%d) 엔티티를 찾을 수 없습니다."),
BOARD_NOT_FOUND(HttpStatus.NOT_FOUND, 40400, "게시글(id:%d) 엔티티를 찾을 수 없습니다."),
VOTEPOST_NOT_FOUND(HttpStatus.NOT_FOUND, 40400, "투표게시글 엔티티를 찾을 수 없습니다."),
PAYMENT_CONFIRM_ERROR_MISMATCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 10000, "결제 과정에서 서버 에러가 발생했습니다. 관리자에게 문의해주세요.");
private final HttpStatusCode httpStatusCode;
private final Integer code;
private final String description;
// getMessage 오버라이딩 제거
}
ResCode 인터페이스에서 기본 구현한 getMessage() 덕분에, 각 enum 클래스에서는 getMessage() 를 직접 구현할 필요가 없어졌습니다.
3.3. BaseException
@Getter
public abstract class BaseException extends RuntimeException {
private final ResCode resCode;
private final String description;
protected BaseException(ResCode resCode, Object... args) {
super(resCode.getFormattedDescription(args));
this.resCode = resCode;
this.description = getMessage();
}
}
이제 BaseException 은 생성자에서 가변 인자(Object... args)를 받아, ResCode 에 정의된 메시지 템플릿에 String.format() 을 적용한 완성된 예외 메시지를 생성합니다.
super()를 통해 해당 메시지를 RuntimeException 의 기본 메시지로 설정하고, getMessage() 를 호출하여 description 필드에도 저장해두도록 구성했습니다.
3.4. EntityNotFoundException
@Getter
public class EntityException extends BaseException {
public EntityException(ResCode resCode, Object... args) {
super(resCode, args);
}
}
기존의 EntityNotFoundException 은 이름이 너무 구체적이라, 프로젝트 규모를 고려했을 때 다양한 엔티티 관련 예외를 포괄하기엔 한정적인 느낌이 있었습니다.
따라서 클래스명을 EntityException 으로 변경하여 단순 조회 실패 뿐 아니라 엔티티와 관련된 예외 상황에 사용할 수 있도록 리팩토링 하였습니다.
해당 예외 클래스에도 Object... args 를 가변 인자로 받아 상위 클래스인 BaseException 의 생성자를 호출합니다.
이로 인해 ResCode 에 정의된 메시지 포맷에 동적으로 데이털터를 주입할 수 있고, 예외 발생 시 구체적인 엔티티 정보가 포함된 메시지를 클라이언트에게 전달할 수 있게 됩니다.
3.5. BaseExceptionHandler
@Slf4j(topic = "BaseExceptionHandler")
@RestControllerAdvice
@Order(value = Integer.MIN_VALUE)
public class BaseExceptionHandler {
@ExceptionHandler(BaseException.class)
public ResponseEntity<CustomApiResponse<Void>> handleBaseExceptionHandler(BaseException baseException) {
log.error("BaseException exception occurred: {}", baseException.getMessage(), baseException);
ResCode errorCode = baseException.getResCode();
return ResponseEntity
.status(errorCode.getHttpStatusCode())
.body(CustomApiResponse.ERROR(errorCode, baseException.getDescription())); // description 필드 추가
}
}
// CustomApiResponse 일부
// ...
public static CustomApiResponse<Void> ERROR(ResCode resCode, String description) {
return new CustomApiResponse<>(resCode, description, null, null);
}
// ...
예외 발생 시, BaseExceptionHandler 에서는 @ExceptionHandler(BaseException.class) 를 통해 모든 도메인 예외(EntityException, PaymentConfirmException 등)를 공통으로 처리하고 있습니다.
이제 BaseException 에서 구성된 포맷팅된 메시지(description)를 응답 객체인 CustomApiResponse 에 전달할 수 있도록 ERROR() 정적 메서드에 인자로 넘겨줍니다.
4. 실제 사용 및 테스트 결과
아래는 회원이 채팅방에 존재하지 않는 경우, MemberChatroom 엔티티가 존재하지 않음을 판단하고 예외를 발생시키는 비즈니스 로직입니다.
public boolean checkMemberInChatroom(Long memberId, Long chatroomId) {
validateInput(memberId, chatroomId);
boolean exists = memberChatroomRepository.existsMemberInChatroom(memberId, chatroomId);
if (!exists) {
throw new EntityException(EntityErrorCode.MEMBERCHATROOM_NOT_FOUND, memberId, chatroomId);
// 기존 코드
// throw new EntityNotFoundException(String.format("회원[%d]은 채팅방[%d]에 존재하지 않습니다.", memberId, chatroomId));
}
return true;
}
위 예외 로직을 Postman 을 통해 호출하여, 예외 메시지가 정상적으로 클라이언트에게 전달되는지 테스트를 진행했습니다.
채팅 메시지를 보냈을 때, 회원이 채팅방에 참가되어있지 않으면 위와 같이 "회원채팅방(id:1, 채팅방id:1) 엔티티를 찾을 수 없습니다." 를 정상적으로 출력합니다.
마무리 ..
이번 리팩토링을 통해, 기존에 하드코딩으로 처리하던 예외 메시지를 도메인 기반의 ResCode 와 가변 인자 포맷팅 구조로 개선함으로써 명확한 예외 처리 방식을 갖추게 되었습니다.
지금은 EntityException 하나만 적용했지만, 앞으로 도메인이 확장되고 다양한 예외 상황이 생길 수록 이번 구조가 재사용되며 유지보수성과 생산성 모두를 높이는 기반이 되지 않을까 생각합니다!
'Spring' 카테고리의 다른 글
[Spring] AOP 동작 원리 (1) | 2025.05.23 |
---|---|
[Spring] - 공통 예외 처리 적용하기(1) (0) | 2025.04.05 |