[Spring Boot] 멀티모듈에서 ErrorHandler를 구현해보자.

오송주·2024년 10월 9일
0

SpringBoot

목록 보기
3/3
post-thumbnail

시작하기전, 글에서 서술할 프로젝트의 깃허브 링크를 첨부하겠습니다.
https://github.com/Team-LoopCat/Helper_Backend
또한, 이 글은 멀티모듈과 커스텀 에러 핸들링에 대해 공부 하신후 보는것을 추천드립니다.

문제상황

SpringBoot를 하는 사람이라면, Custom Handler가 무엇인지 알것이라 생각한다.

Custom Handler의 역할은, 어떠한 에러가 발생했을때 그 에러를 감지하고 분석해 우리가 원하는데로 Response를 보낼 수 있도록 하는 것이다. 그런데 오늘, 이 Custom Handler에 대한 에러를 겪게되었다.

내가 진행하고 있는 프로젝트는 3개의 멀티모듈로 이루어진 멀티 모듈 프로젝트이다.

내 프로젝트의 멀티 모듈의 종류는 다음과 같다

  • core :
    business logic과 controller 코드들을 포함하고 있는 모듈
  • infrastructure :
    errorHandler, jwt, security같은 기본적인 설정과 필수적인 코드를 포함한 모듈
  • persistence :
    DB관련 로직 (Entity, Repository, Mapper)등이 포함된 모듈

즉, infrastructure 모듈이 이 모든 모듈의 error 처리를 담당하고 있다고 생각하면 된다. 모든 비지니스 에러의 기반이 되는 BusinessException 파일과 모든 ErrorCode를 담고있는 Enum 파일도 infrastructure 파일에 존재한다.

그런데, 여기서 문제 하나가 발생했다. 바로 core가 infrastructure를 의존하고 있지 않은것이다. 밑의 gradle 코드를 읽어보면 알겠지만, infrastructure는 core를 의존하고 있고 core는 아무 모듈도 의존하고 있지 않다.

// infrastructure의 gradle
dependencies {
    implementation(project(":helper-core"))
    implementation(project(":helper-persistence"))

    // jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.10.7'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.10.7'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.10.7'
    
    // ..(이후 생략)
}

// core의 gradle
dependencies {
    testImplementation platform('org.junit:junit-bom:5.9.1')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    
    // ..(이후 생략)
}

때문에, 에러 처리를 위해 꼭 의존해야 하는 파일을 core에서는 의존을 할 수 없는 상황이 발생하였다. 따라서, 정상적인 에러 처리가 불가해젔다.

처음에는 막막했으나, 차분히 생각해보니 해결방법이 떠오르기 시작했다. 때문에 그 해결방법과 해결하기까지의 과정을 공유하고자 한다.

해결 과정

해결법 1 - core가 infrastructure를 의존하면 되지 않을까? (실패)

첫번째로 고안한 해결법은 단순하게 core가 infrastructure를 의존해 ErrorCode와 BusinessException을 의존 할 수 있도록 하는 방법이였다. 단순하게 core의 grade 파일에 의존관련 코드 한줄만 추가하면 되는 방법이니 나쁘지는 않아보였다.

// core의 gradle
dependencies {
    implementation(project(":helper-infrastructure"))

    testImplementation platform('org.junit:junit-bom:5.9.1')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    
    // ..(이후 생략)
}

하지만 내가 간과한 문제점이 하나 있었으니, 바로 순환참조(circular dependency)의 발생이였다. core가 infrastructure를 참조하고, infrastructure가 core를 참조하면, 순환참조라는 문제가 생긴다. 물론 순환참조는 다른 모듈을 하나 생성, 그 모듈에 의존시키는 방법으로 우회할수야 있다만.. 그러면 모듈관 관계성을 복잡하게 만들고 클린 아키텍쳐도 지키지 못한다. 또한 이거 하나 때문에 에러 전담 처리용 모듈 하나를 만들자니.. 일이 너무 커진다.

해결법 2 - core에 BusinessException과 비슷한 기능을 하는 파일을 제작하자 (성공)

두번째로 고안한 해결법은 BusinessException과 비슷한 기능을 하는 파일을 제작해, 그 에러를 탐지하는 ErrorHandler를 추가하자는 아이디어였다. 사실, BusinessException과 같은 에러 발생용 class를 제작하고 에러를 발생시키면, 탐지하는 로직을 제작하는건 쉽다.

때문에 core모듈에 위와 같이 파일들을 추가하였다. 여기서 ErrorCode를 대체하는 파일은 GlobalErrorCode와 AuthErrorCode, BusinessException을 대신하는 파일은 CoreBusinessException이다. 그럼 파일 내용을 보며 설명을 이어나가겠다.

GlobalErrorCode와 AuthErrorCode

아이디어 자체는 단순했다. 'GlobalErrorCode를 Enum으로 만들어서 거기에 Business로직에서 발생하는 모든 에러에 대한 정보를 넣자!' 그런데 여기서 이런 생각이 들었다. '잠깐, 이거 에러 양이 엄청날껀데.. 그걸 한 파일 안에서 관리하다 보면 유지 보수성 떨어질 것 같은데?' 때문에, 도메인별로 에러를 분리해 사용하기로 결정했고, 이에 대한 결과가 AuthErrorCode이다. 이제 내용을 보면서 설명을 자세히 해보도록 하겠다.

// GlobalErrorCode
@Getter
@RequiredArgsConstructor
public class GlobalErrorCode {
    private final int ErrorStatus;
    private final String ErrorMessage;
}

// AuthErrorCode
public class AuthErrorCode extends GlobalErrorCode {
    public static AuthErrorCode PASSWORD_MISMATCHES() {
        return new AuthErrorCode(403, "비밀번호가 일치하지 않습니다");
    }

    public static AuthErrorCode USER_NOT_FOUND() {
        return new AuthErrorCode(404, "유저를 찾을 수 없습니다");
    }

    public AuthErrorCode(int ErrorStatus, String ErrorMessage) {
        super(ErrorStatus, ErrorMessage);
    }
}

보면, ErrorStatus와 ErrorMessage를 GlobalErrorCode에 등록해놓고, AuthErrorCode에서 extend해와서 해당 속성들을 사용하고 있는것을 볼 수 있다. AuthErrorCode는 자기 자신을 생성하는 메서드를 사용해 에러별 정보를 담고있는 인스턴스를 생성 하는 것을 볼 수 있다.

CoreBusinessException

아래 코드는 CoreBusinessException 코드이다. RuntimeException을 extend하고, GlobalErrorCode의 타입을 가지는 errorCode라는 속성을 가지고 있는것을 볼 수 있다.

package org.example.common.exception;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class CoreBusinessException extends RuntimeException {
    public final GlobalErrorCode errorCode;
}

해당 클래스를 extend해와서 커스텀 에러를 생성할 수 있다.

추가 수정

ErrorHandler에 다음과 같이 CoreBusinessException 타입의 에러를 감지할 수 있는 코드를 추가해주고

@RestControllerAdvice(basePackages = {"org.example"})
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    protected ResponseEntity<ErrorResponse> exceptionHandler (BusinessException e) {
        ErrorCode errorCode = e.errorCode;
        ErrorResponse response = ErrorResponse.of(errorCode, errorCode.getMessage());

        return new ResponseEntity<>(response, HttpStatusCode.valueOf(errorCode.getCode()));
    }

	// 추가된 코드
    @ExceptionHandler(CoreBusinessException.class)
    protected ResponseEntity<ErrorResponse> coreExceptionHandler (CoreBusinessException e) {
        GlobalErrorCode errorCode = e.errorCode;
        ErrorResponse response = ErrorResponse.of(errorCode, errorCode.getErrorMessage());

        return new ResponseEntity<>(response, HttpStatusCode.valueOf(errorCode.getErrorStatus()));
    }
}

CoreBusinessException이 발생했을때 우리가 원하는 응답 형식으로 바꾸기 위해 Error Response 클래스에 다음과 같이 코드를 추가해주자.

public record ErrorResponse (
        Integer errorCode,
        String message,
        String description,
        LocalDateTime timestamp
) {
    public static ErrorResponse of (ErrorCode errorCode, String description) {
        return new ErrorResponse(errorCode.getCode(), errorCode.getMessage(), description, LocalDateTime.now());
    }

	// 추가된 코드
    public static ErrorResponse of (GlobalErrorCode errorCode, String description) {
        return new ErrorResponse(errorCode.getErrorStatus(), errorCode.getErrorMessage(), description, LocalDateTime.now());
    }
}

이러면 모든 수정이 완료되었다!

다음과 같이 사용한다면 정상적으로 작동하는것을 확인할 수 있을것이다.

// 에러 등록하기
package org.example.domain.auth.exception;

import org.example.common.exception.CoreBusinessException;
import org.example.domain.auth.exception.ErrorCode.AuthErrorCode;

public class PasswordMismatchException extends CoreBusinessException {
    public static final PasswordMismatchException EXCEPTION = new PasswordMismatchException();

    public PasswordMismatchException() { super(AuthErrorCode.PASSWORD_MISMATCHES()); }
}

// 사용하는 방법
public LoginResponseDto execute(LoginRequestDto requestDto) {
        User user = loginService.getUserById(requestDto.id()).orElseThrow(() -> UserNotFoundException.EXCEPTION);

        if (!bCryptPasswordEncoder.matches(requestDto.password(), user.getPassword())) {
            throw PasswordMismatchException.EXCEPTION;
        }
	    // 후략


이렇게 잘 작동하는걸 확인할 수 있다.

0개의 댓글