멀티 모듈 구조의 프로젝트를 개발하는 과정에서 Exception에 대해 기존 단일 모듈 프로젝트의 구조와는 다르게 설계해야겠다는 생각이 들었습니다.
그 과정에서 고민했던 내용을 기술합니다.
멀티모듈에서 Exception 처리를 고민하는 이유
모듈간 의존성 규칙
문제 상황 예시 : web모듈에서 결국 jpa의존성이 필요하게 되는 상황
- web, batch, store-jpa, resclient 등으로 여러 모듈로 나누면 각 모듈은 자신이 꼭 필요한 의존성만을 포함해야 합니다.
- 하지만 가끔 기존 관성에 의해서 아래와 같이 web 모듈에서 바로store-jpa 모듈의 참조가 이루어져야 하는 경우가 있습니다
@RequiredArgsConstructor
public class WebModuleSomeService {
private final SomeJpaRepository someJpaRepository;
public void saveSomeEntity(SomeRequest request) {
SomeEntity entity = SomeConverter.toSomeEntity(request);
someJpaRepository.save(entity);
}
}
- 위 코드가 동작하려면 web 모듈에서도 jpa관련 의존성(spring-boot-starter-data-jpa)을 포함하고 있어야 하는 상황이 발생합니다.
- 결국, 모듈 간 연동을 위해서 web모듈에서도 jpa관련 의존성이 필요하게 되는 모듈화와의 의도와 맞지 않는 상황에 바로 직면하게 됩니다.
Domain 모듈의 추상화를 이용한 해결
- 개발 중에 이런 상황은 피할 수 없기 때문에 domain 모듈을 이용하여 추상화를 통한 모듈 간의 연동을 구성합니다.
// web 모듈
@RequiredArgsConstructor
public class WebModuleSomeService {
private final SomeStore someStore;
public void saveSomeEntity(SomeRequest request) {
SomeDomain domain = SomeConverter.toSomeDomain(request);
someStore.save(domain);
}
}
// domain 모듈
// domain 모듈에서는 domain객체와 store-jpa와의 연동을 위한 추상화를 제공할 interface만 제공
public class SomeDomain {
// ....
}
public interface SomeStore {
SomeDomain save(SomeDomain domain);
}
// store-jpa 모듈
@Component
@RequiredArgsConstructor
public class SomeJpaStore implements SomeStore {
private final SomeJpaRepository someJpaRepository;
public SomeDomain save(SomeDomain domain) {
SomeEntity entity = SomeDomainConverter.toSomeEntity(domain);
someJpaRepository.save(entity);
return SomeDomainConverter.toSomeDomain(entity);
}
}
- domain 모듈
- domain 모듈은 다른 모듈 간의 연동에서 중간 연결고리의 역할을 담당하게 됩니다.
- 연결고리의 역할을 하기 때문에 되도록 POJO형태로 유지합니다.
- 내부의 다른 모듈을 전혀 의존하지 않아야 합니다.
- 외부 의존성은 lombok, spring-boot-configuration-processor 등 필수적인 의존성만을 포함합니다. (저의 경우에는 subprojects 에서 정의한 공통 의존성(lombok, spring-boot-configuration-processor등)을 제외하고는 다른 의존성을 추가하지는 않는 편입니다.)
- web 모듈
- domain 모듈에서 정의되어 있는 store 관련 의존성만을 주입받아 관련 비즈니스 로직을 작성합니다.
- store-jpa 모듈
- domain 모듈에서 정의한 추상화의 상세로직을 구현합니다.
Exception 처리에 발생하는 이슈
- store-jpa모듈에서 임의의 CusotmException을 추가해서 사용하고 web 모듈에서 해당 CustomException을 handling 하려고 할 때 불필요한 의존성을 추가해야 하는 상황이 발생합니다.
- 해당 CustomException을 400 Bad Request으로 처리하기 위해 평소와 같이 @ResponseStatus를 사용하려 하면 해당 기능을 찾을 수 없는 오류를 마주하게 됩니다.
- @ResponseStatus는 spring-boot-starter-web 의존성에 포함되어 있는 기능인데 store-jpa 모듈은 해당 의존성을 포함하지 않기 때문에 해당 기능을 사용할 수 없기 때문입니다.
아래 예시 프로젝트를 이용해 좀 더 상세한 내용과 해결책을 알아보겠습니다.
예시 프로젝트 (주문서 생성)
예시 프로젝트 소스코드는 아래 repository에 업로드 되어있습니다.
https://github.com/cobiyu/multi-module-exception
예시 프로젝트 로직
- 웹 모듈 controller에서 주문을 생성할 가격(price)정보를 받아서 주문(Order) 도메인을 생성합니다.
- 생성한 도메인으로 도메인 모듈의 주문서 생성 interface를 호출합니다.
- 도메인 모듈의 주문 생성 interface를 구현한 store-jpa의 구현체를 이용해서 주문서 생성 함수를 실행하여 주문 정보를 db에 저장합니다.
Validation 관련 Custom Exception을 추가해야한다면?
사용할 Custom Exception
체크할 validation 로직은 price가 음수일 경우 Exception을 발생시키려 합니다.
InvalidPriceException이라는 Custom Exception을 생성하겠습니다.
Error Case 1. app-web 모듈 내부에 위치
InvalidPriceException을 app-web 모듈에 위치시켜서 사용해보겠습니다.
store-jpa에서는 InvalidPriceException 사용 불가
store-jpa 모듈 에서는 app-web 모듈에 의존하지 않는 구조이기 때문에 app-web의 Exception을 찾을 수 없는 상태입니다.
다음은 InvalidPriceException을 store-jpa 모듈에 위치시켜서 사용해보겠습니다.
Error Case 2. store-jpa 모듈 내부에 위치
Status: 500 발생
정상적으로 Exception이 handling 되는것처럼 보이지만 Status : 500 으로 response 됩니다.
validation error는 400 Bad Request로 처리되어야 하기 때문에 InvalidPriceException에 @ResponseStatus(code = HttpStatus.BAD_REQUEST) 를 추가해보겠습니다.
@ResponseStatus 사용 불가
위 사진과 같이 store-jpa 모듈 내부에서는 ResponseStatus 를 사용할 수 없습니다.
@ResponseStatus는 spring-boot-starter-web (Spring MVC) 에 포함되어 있는 기능이기 때문입니다.
Error Case 3. domain 모듈 내부에 위치
- InvalidPriceException을 domain 모듈에 위치시켜서 사용해보면 app-web과 store-jpa 양쪽에서 모두 사용 가능해지기는 합니다.
- 하지만 여전히 @ResponseStatus 는 사용하지 못하기 때문에 API에서는 status: 500 으로 동작하게 됩니다.
ControllerAdvice와 ExceptionHandler
- domain 모듈에 위치시킨 상태에서 아래 코드 처럼 InvalidPriceException의 경우에 400 Bad Request를 response하도록 작성해도 무방하긴 합니다.
@ExceptionHandler(InvalidPriceException.class)
public ResponseEntity<?> InvalidPriceExceptionHandler(InvalidPriceException e) {
return ResponseEntity.status(400).body(
Map.of(
"somemessage", e.getMessage()
)
);
}
// Exception이 추가될떄 마다 계속 추가....
- 하지만 도메인 모듈에 Exception이 추가될때마다 계속해서 @ExceptionHandler를 추가해줘야하는 번거로움이 동반됩니다.
추상 클래스를 이용한 Custom Exception
Status Code별 추상 클래스
먼저 domain모듈에 큰 범주의 Status code별로 4xx code 추상클래스를 정의합니다.
- Status4xxException
@Getter
public abstract class Status4xxException extends RuntimeException{
private final Integer statusCode;
public Status4xxException(String message) {
super(message);
statusCode = 400;
}
public Status4xxException(String message, Throwable e) {
super(message, e);
this.statusCode = 400;
}
public Status4xxException(String message, Integer statusCode) {
super(message);
this.statusCode = statusCode;
}
public Status4xxException(String message, Integer statusCode, Throwable e) {
super(message, e);
this.statusCode = statusCode;
}
}
그리고 InvalidPriceException은 Bad Request성격의 Exception이니 Status4xxException을 상속받아 다시 구현합니다.
- InvalidPriceException
public class InvalidPriceException extends Status4xxException{
public InvalidPriceException(String message) {
super(message);
}
public InvalidPriceException(String message, Integer statusCode) {
super(message, statusCode);
}
}
이렇게 생성한 InvalidPriceException을 web모듈, store모듈에서 용도에 맞게 각각 사용합니다.
이렇게 만들어진 Exception의 위치는 store모듈, web모듈, domain모듈 어디에든 있어도 관계 없습니다.
status code를 의미하는 추상화가 domain모듈에 있기 때문입니다.
@RestControllerAdvice
그리고 web모듈에서 처리해야하는 Exception handling 로직은 아래 코드와 같이 status code 추상화를 이용하여 처리합니다.
@ExceptionHandler(Status4xxException.class)
public ResponseEntity<ApiResponse<?>> badRequestHandler(Status4xxException e) {
int statusCode = e.getProcessStatus().getStatusCode();
String messageForClient = e.getMessageForClient();
messageForClient = StringUtils.defaultIfEmpty(messageForClient,DEFAULT_MESSAGE);
return ResponseEntity.status(statusCode).body(
ApiResponse.builder()
.errorMessage(messageForClient)
.processStatus(e.getProcessStatus())
.build()
);
}
batch, kafka listner 등의 application 모듈
web만을 예시로 들었지만 batch나 kafka listener 등의 application모듈도 같은 방식으로 exception handling을 작성하면 됩니다.
- 각 application 성격에 맞는 exception 추상화를 정의
- 추상화별로 추상 클래스 exception 추가
- 추가한 추상화 클래스 exception을 이용하여 각 app의 exception handling 방식으로 처리
'Java' 카테고리의 다른 글
외부 API 연동 테스트 코드 @RestClientTest (2) | 2021.10.17 |
---|---|
@SpringBootTest / @DataJpaTest 차이점 과 JPA 영속성 컨텍스트 (5) | 2021.09.12 |
람다(Lambda)와 익명 클래스 :: Lambda can be replaced with method reference (0) | 2021.08.01 |
람다 캡처링 :: Variable used in lambda expression should be final or effectively final의 이유 (7) | 2021.07.14 |
Mockito @Mock @MockBean @Spy @SpyBean 차이점 (27) | 2020.09.13 |