📕 목차
1. 개요
2. 여러 개의 UseCase
3. 단일 UseCase
4. 파사드 패턴을 더 준수해보기
1. 개요
📌 배경
예전에 Service Layer 분리에 대한 글을 작성했고, 결론적으로 파사드 패턴을 적용하는 식으로 개선할 수 있음을 보였다.
그리고 멀티 모듈 프로젝트에서도 디자인 패턴을 잘 적용해서 진행하고 있다고 생각했는데, 보면 볼 수록 코드가 마음에 안 든다.
개발이 진행될 수록 테스트도 점점 어려워지고, 설계에 너무 많은 시간을 쏟게되는 문제점이 존재했다.
오늘은 이러한 문제의 근본적인 원인에 대해 다시 한 번 되돌아 보았다.
2. 여러 개의 UseCase
📌 구조
초기에 멀티 모듈을 참고하기 위해 살펴봤던 수많은 프로젝트가 위와 같은 구조를 갖고 있었다.
이 당시에 들었던 생각은 '관리해야 할 클래스가 쓸 데 없이 늘어나지 않을까?'라는 우려가 컸다.
하지만 위 구조의 진짜 문제점은 코드 경직성을 높임으로써 단위 테스트를 힘들게 만든다는 점이었다.
📌 예시
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final SignUpUseCase signUpUseCase;
private final SignInUseCase signInUseCase;
private final SignOutUseCas signOutUseCase;
@PostMapping("/sign-up")
public ResponseEntity<?> signUp() {
return ResponseEntity.ok(signUpUseCase.execute());
}
@PostMapping("/sign-in")
public ResponseEntity<?> signIn() {
return ResponseEntity.ok(signInUseCase.execute());
}
@GetMapping("/sign-out")
public ResponseEntity<?> signOut() {
return ResponseEntity.ok(signOutUseCase.execute());
}
}
방금 막 대충 작성한 거라 디테일한 부분은 패스하고, 대충 이런 식으로 코드를 작성하게 될 것이다.
문제는 AuthController에 의존성이 추가될 수록 단위 테스트를 위한 코드에도 @MockBean을 추가해주어야만 하는 점이다.
@WebMvcTest(classes = {AuthController.class})
class AuthControllerUnitTest {
@MockBean
private SignUpUseCase signUpUseCase;
@MockBean
private SignInUseCase signInUseCase;
@MockBean
private SignOutUseCase signOutUseCase;
...
}
이는 상당히 번거롭다.
만약 변경이 잦고, 의존하는 UseCase가 많은 Controller라면 충돌도 상당히 빈번하게 일어날 것이며,
관심 사항이 아닌 스니펫이 과하게 차지하여 테스트 코드를 읽는 이로 하여금 혼란을 줄 수도 있다.
3. 단일 UseCase
📌 구조
그래서 나는 위의 구조로 프로젝트를 진행했다.
메서드 명으로 충분히 UseCase를 표현할 수 있다고 생각했고, UseCase를 모두 분리했을 때보다 중복 코드를 처리하는 점에서도 이점을 얻을 수 있다고 생각했기 때문이다.
그리고 보다 세부적인 비지니스 로직이나 중복 로직에 대해선 Helper 클래스와 Util을 적극 활용함으로써 클린 코드를 유지할 수 있을 거라고 믿었다.
물론 정말 그랬다면 딱히 이 포스트를 작성하지 않았을 것이다.
위 설계는 이전 방식보다도 최악이었다.
- Controller Layer Unit Test는 확실히 수월해졌지만 Service Layer Unit Test가 지옥이 되었다.
- UseCase가 너무 많은 의존성을 가지게 되어 여전히 코드 경직성이 크다.
- 우발적 중복과 중복을 오인하는 경우가 잦아 SRP 원칙을 위배하는 경우가 너무 많아진다.
📌 예시
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final AuthUseCase authUseCase;
@PostMapping("/sign-up")
public ResponseEntity<?> signUp() {
return ResponseEntity.ok(authUseCase.signUp());
}
@PostMapping("/sign-in")
public ResponseEntity<?> signIn() {
return ResponseEntity.ok(authUseCase.signIn());
}
@GetMapping("/sign-out")
public ResponseEntity<?> signOut() {
return ResponseEntity.ok(authUseCase.signOut());
}
}
확실히 Controller만 보면 이전 방식보다 괜찮아 보이겠지만, 대신 UseCase가 지옥이 된다.
@Service
@RequiredArgsConstructor
public class AuthUseCase {
private final UserDomainService userDomainService;
private final OauthDomainService oauthDomainService;
private final JwtProvider jwtProvider;
@Transactional
public AuthDto signUp() {
...
}
@Transactional
public AuthDto signIn() {
...
}
@Transactional
public AuthDto signOut() {
...
}
}
DomainService는 단순히 DB에서 데이터를 가져오고 가공하는 정도만을 처리하므로 비지니스 로직이 UseCase 내부에서 처리될 것이다.
간혹 코드가 너무 복잡해지거나, 저수준 정책을 포함하는 경우 SignUpService 같은 중간 계층 서비스를 하나 더 만들 수도 있기야 하겠지만, 그럼 처음의 방식을 부정했던 나 자신을 다시 부정하는 꼴이 된다.
하지만 아직 지옥은 시작하지도 않았다.
테스트 케이스를 작성해보면 뭔가 심각하게 잘못 돌아가고 있음을 알게 된다.
📌 타락한 테스트
만약 AuthUseCase에서 회원가입 로직을 테스트하고 싶다고 하자.
그렇다면 보통 다음과 같이 코드를 작성할 것이다.
@ExtendWith(MockitoExtension.class)
class SignUpUnitTest {
private AuthUseCase authUseCase;
@MockBean
prviate UserDomainService userDomainService;
@MockBean
private OauthDomainService oauthDomainService;
@MockBean
private JwtProvider jwtProvider;
...
}
그런데 갑자기 회원가입 메서드를 위해 SignOutService라는 의존성을 UseCase에 추가해주었다고 하자.
SignUpUnitTest는 해당 클래스를 전혀 사용하지 않음에도 빈이 주입되지 않아 테스트 케이스가 실패하는 기이한 현상을 겪게 된다.
테스트를 수행함에 있어 이러한 전조 증상은 설계가 잘못되었음을 의미한다.
개선책을 알아보기 전에 한 가지 더 문제점을 알아보자.
아, 참고로 위 문제를 조금 더티하게나마 개선시킬 수 있는 방법은 Test 클래스의 내부 클래스로 테스트 케이스를 작성하면 된다. ^^
📌 우발적 중복과 중복
중복은 제거해야 마땅하지만, 우발적 중복은 그렇지 않다.
겉보기엔 똑같은 동작을 수행하지만 실제로는 다른 의미를 내포하고 있기 때문이다.
이러한 우연은 그저 우연으로 인해 발생했을 뿐이지 절대로 코드를 묶어서는 안 된다.
왜냐하면, 정책이 변경되어 한 곳에서 로직을 수정해야 할 일이 발생했을 경우 다른 함수에서도 영향을 받기 때문이다.
문제는 나처럼 UseCase를 묶는 순간, 이런 우발적 중복 발생에 대응하기가 매우 어려워진다는 점이다.
안 그래도 애자일 방식으로 개발한다고 초기 도메인이 단순한 상황에서 개발을 하다보면, 세상 모든 게 중복으로 보인다.
그래서 열심히 제거해버렸는데 추후 서비스 정책이 변경되어 코드도 수정하려 할 때 쯤 머리가 복잡해지기 시작한다.
"아니, 나는 A를 바꿨는데 왜 B가 바뀌냐고!"
그나마 테스트 케이스를 열심히 작성해두었기 때문에, 아직까지는 변경에 있어 큰 어려움을 겪었던 적은 없었다.
하지만 그렇다고 해서 수월했던 것도 아니다.
4. 파사드 패턴을 더 준수해보기
📌 설계
인정할 건 인정하자.
내가 대안책이랍시고 떠올렸던 두 번째 방법은 성대하게 실패했던 설계였다.
그렇다고 해서 명백한 단점이 존재하는 첫 번째 방법을 그대로 사용하는 것 또한 내키지는 않았다.
그러다 파사드 패턴의 특징을 더 잘 살려보는 방법에 대해 고민하다가 나온 세 번째 방법이 위의 설계에 해당한다.
- UseCase는 오로지 라우터 역할을 하며, 매퍼 클래스를 사용해 적절한 응답을 반환하기만 한다. (여전히 메서드로 use case를 구분하는 것또한 가능하다.)
- Controller는 하나의 UseCase 의존성만 가지면 되므로 첫 번째 설계의 문제점을 없앨 수 있다.
- Service가 각각 분리되어 있으므로 독립적으로 단위 테스트를 구성할 수 있다. 더 이상 다른 UseCase 변경 사항에 대한 영향을 받지 않는다.
- 약간의 중복은 허용한다. 어쩌면 우발적 중복일 가능성이 더 크다. 하지만 중복이라는 확신이 생긴다면 Helper, Util 등을 활용하여 개선시킬 수 있다.
📌 예시
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final AuthUseCase authUseCase;
@PostMapping("/sign-up")
public ResponseEntity<?> signUp() {
return ResponseEntity.ok(authUseCase.signUp());
}
@PostMapping("/sign-in")
public ResponseEntity<?> signIn() {
return ResponseEntity.ok(authUseCase.signIn());
}
@GetMapping("/sign-out")
public ResponseEntity<?> signOut() {
return ResponseEntity.ok(authUseCase.signOut());
}
}
@Service
@RequiredArgsConstructor
public class AuthUseCase {
private final SignUpService signUpService;
private final SignInService signInService;
private final SignOutService signOutService;
public AuthDto signUp() {
return AuthMapper.toAuthDto(signUpService.execute());
}
public AuthDto signIn() {
return AuthMapper.toAuthDto(signInService.execute());
}
public AuthDto signOut() {
return AuthMapper.toAuthDto(signOutService.execute());
}
}
여기서 테스트 케이스를 작성할 일이 있다면, AuthController, AuthUseCase, 각각의 Service, 그리고 Mapper에 해당한다.
내가 구상해놓고 이렇게 말하려니 좀 이상하긴 한데, 위 코드의 아름다움이 보이는가?
이제 그 어떤 변화도 서로에게 영향을 주지 못한다.
SignOutService의 세부 정책이 바뀐다고 해서 SignUpService에 영향을 주지 않는다는 의미다.
그리고 비지니스 로직의 변화나 UseCase 내에 메서드가 추가되는 것이 기존의 Controller 테스트 코드에 영향을 주지도 않으며, 변화에 보다 유연해졌다.
모두 독립적으로 존재하기 때문에 TDD로 개발하는 것이 더 수월하고 편해졌다.
📌 결론
뭐, 지금은 신문물을 접한 사람처럼 떠들고 있지만 또 얼마 지나지 않아서 위 방법의 문제점을 혼자 찾아내고, 다른 패턴을 연구할 수도 있을 수 있겠다 싶다.
두 번째 방법 떠올렸을 때도 난 내가 천잰줄 알았다. 하...ㅋ 물론 이렇게 될 것도 알고 있었지만.
애초에 파사드 패턴을 잘못 이해해서 발생한 문제라 바보라고 보는 게 맞나.
위의 내용들은 어디까지나 하나의 의견일 뿐, 절대 답이 될 수 없다.