직전글 문제를 해결하다가 .모듈 간 순환 의존성(Circular Dependency)을 해결한 경험을 공유하고자 합니다. 일관된 API 응답을 위해 GlobalExceptionHandler를 도입하는 과정에서 겪었던 문제와 해결 과정을 단계별로 풀어보겠습니다.
1. 문제의 시작: 일관된 API 응답의 꿈
모든 API는 성공이든 실패든 일관된 형식으로 응답을 내려주는 것이 좋습니다. 저희 프로젝트에서는 아래와 같은 BaseResponse를 공통 응답 형식으로 사용하고 있었습니다.
// In 'response-module'
@Getter
public class BaseResponse<T> {
private final boolean success;
private final String code;
private final String message;
private final T result;
// 성공 응답 생성자...
// 'exception-module'의 CustomErrorCode를 참조하는 실패 응답
public static <T> BaseResponse<T> fail(CustomErrorCode errorCode) {
return new BaseResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null);
}
}
그리고 서비스 로직에서 발생하는 예외를 한곳에서 처리하고, 위 BaseResponse.fail() 형식으로 깔끔하게 반환하기 위해 @RestControllerAdvice를 사용한 GlobalExceptionHandler 도입을 결정했습니다.
2. 첫 번째 시도: "예외니까 exception-module에 두면 되겠지?"
가장 먼저 든 생각은 '예외 처리기니까 당연히 exception-module에 있어야지!' 였습니다. 그래서 아래와 같이 코드를 작성하고 exception-module에 위치시켰습니다.
// In 'exception-module' (This causes a problem!)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomBaseException.class)
public ResponseEntity<BaseResponse<?>> handleCustomBaseException(CustomBaseException e) {
CustomErrorCode errorCode = e.getErrorCode();
// ... BaseResponse를 생성해서 반환해야 하는데...
return ResponseEntity
.status(errorCode.getStatus())
.body(BaseResponse.fail(errorCode)); // <-- ERROR!
}
}
하지만 이 구조는 곧바로 빌드 실패라는 벽에 부딪혔습니다. 이유는 순환 의존성 때문이었습니다.
response-module은BaseResponse.fail(errorCode)때문에exception-module을 필요로 합니다.- 그런데
GlobalExceptionHandler가BaseResponse를 사용하게 되면서,exception-module이response-module을 필요로 하게 된 것입니다.
response-module→exception-module→response-module
이런 순환 고리는 빌드 시스템이 의존성을 해결할 수 없게 만들어 프로젝트를 망가뜨립니다. 명백히 잘못된 설계였죠.
3. 두 번째 시도: "그럼 각 API 모듈에 두면 어떨까?"
순환 의존성을 피하기 위해 GlobalExceptionHandler를 각 API 모듈(예: auth-module, order-module 등)에 각각 복사해서 넣는 방법을 생각했습니다.
- 장점: 순환 의존성 문제가 해결됩니다. 각 API 모듈은
response-module과exception-module에 의존하고 있으므로GlobalExceptionHandler가 두 모듈의 클래스를 사용하는 데 아무런 문제가 없습니다. - 단점: 끔찍한 코드 중복이 발생합니다. API 모듈이 10개라면, 똑같은
GlobalExceptionHandler코드가 10번 복사/붙여넣기 됩니다. 이는 DRY(Don't Repeat Yourself) 원칙을 위배하며 유지보수를 최악으로 만듭니다.
이 방법 역시 좋은 해결책이 아니었습니다.
4. 최종 해결책: "역할에 맞는 새로운 모듈을 만들자!" - common-api
고민 끝에 문제의 본질을 다시 생각했습니다. GlobalExceptionHandler의 역할은 무엇일까요? 단순히 '예외를 잡는 것'이 아니라, '웹 계층에서 발생한 예외를 HTTP 응답으로 변환하는 것'입니다. 즉, 이 클래스는 순수한 예외 도메인이 아닌, 웹(API) 계층에 속한 공통 기능입니다.
그래서 다음과 같은 구조를 가진 새로운 모듈, common-api를 만들었습니다.
1. common-api 모듈 생성 및 의존성 설정
이 모듈은 웹 계층의 공통 기능을 담당하며, response-module과 exception-module을 모두 의존합니다.
dependencies {
// Spring Web과 관련된 기능이므로 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-web'
// BaseResponse와 CustomException을 모두 사용해야 함
implementation project(':response-module')
implementation project(':exception-module')
}
2. GlobalExceptionHandler를 common-api 모듈로 이동
이제 GlobalExceptionHandler는 자신의 역할에 맞는 common-api 모듈에 위치하게 됩니다. 순환 의존성 걱정 없이 BaseResponse와 CustomErrorCode를 마음껏 사용할 수 있습니다.
package com.example.common.api.handler;
import com.example.response.BaseResponse;
import com.example.exception.CustomBaseException;
import com.example.exception.CustomErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomBaseException.class)
public ResponseEntity<BaseResponse<?>> handleCustomBaseException(CustomBaseException e) {
CustomErrorCode errorCode = e.getErrorCode();
return ResponseEntity
.status(errorCode.getStatus())
.body(BaseResponse.fail(errorCode));
}
}
3. 각 API 모듈의 의존성 간소화
마지막으로, 모든 API 모듈(auth-module 등)의 의존성을 common-api 하나만 바라보도록 수정합니다.
dependencies {
// common-api 하나만 의존하면 끝!
implementation project(':common-api')
// auth-module에 필요한 다른 의존성들...
}
Gradle의 전이 의존성(Transitive Dependencies) 덕분에 auth-module은 common-api를 통해 response-module과 exception-module의 기능을 자동으로 사용할 수 있습니다.
결론: 역할에 맞는 집을 찾아주자
이번 경험을 통해 모듈을 설계할 때는 단순히 이름이나 기능의 유사성으로 코드를 배치하는 것이 아니라, '아키텍처 계층 내에서의 역할과 책임'을 기준으로 배치해야 한다는 것을 다시 한번 깨달았습니다.
GlobalExceptionHandler는 exception-module이 아닌, 웹 계층 공통 로직을 다루는 common-api에 위치함으로써 프로젝트는 순환 의존성 문제를 해결하고, 중복을 제거했으며, 각 모듈의 역할을 명확히 하는 이상적인 구조를 갖추게 되었습니다.
[ auth-module ] [ band-module ] ...
| | |
+-------------------+-------------------+
|
[ common-api-module ] <- GlobalExceptionHandler 위치
|
+--------------------+--------------------+
| |
[ response-module ] [ exception-module ]
(BaseResponse) (Custom Exceptions, ErrorCode)
| |
+-----------------------------------------+
(response-module이 exception-module을 의존)
최종 아키택처
'BindProject' 카테고리의 다른 글
| 이미지 모듈 리팩토링 여정 (3) | 2025.07.24 |
|---|---|
| 페이지네이션 완전 정복: `COUNT` 내 서비스에 맞는 최적의 전략 찾기 (1) | 2025.07.24 |
| 전역 에러 핸들러로 try-catch를 걷어내기 (3) | 2025.07.23 |
| MSA 환경에서 스케줄링 작업의 동시성 문제, ShedLock과 모듈화로 해결하기. (4) | 2025.07.22 |
| 2025-07-19 업체/룸 요구사항 정리 회의 (2) | 2025.07.19 |