레거시 코드를 헥사고날 아키텍처로 전환(2)과 이어지는 글입니다
1단계, 컨트롤러 패키지 위치 변경
기존에 존재하는 컨트롤러의 위치를 adapter 내부로 이동시켰다.
2단계, 컨트롤러에서 필요한 기존 userService에 대한 의존성 제거
@RequestMapping("/user")
@InboundAdapter
@RequiredArgsConstructor
public class UserController {
private final JwtService jwtService;
private final RegisterUserUseCase registerUserUseCase;
private final UserWithdrawalUseCase userWithdrawalUseCase;
private final RenameUserUseCase renameUserUseCase;
private final GetUserThumbnailQuery getUserThumbnailQuery;
private final GetUserQuery getUserQuery;
@PostMapping()
public ResponseEntity<CreateUserResponseDto> register(
HttpServletRequest request, @RequestParam("uid") String uid) {
...
}
내부의 UserService와 관련된 모든 메서드를 이전에 만든 UseCase와 Query를 사용하도록 변경한다.
3단계, 컨트롤러를 분리하자
컨트롤러의 개수는 너무 작은 것보다 너무 많은 게 유지보수 관점에서 낫다고 한다. 단, 네이밍을 잘하는 것이 핵심이다. 기존에 단일 컨트롤러는 사용하는 포트가 많다. 즉, 많은 참조를 가지는 문제가 발생한다. 따라서 나는 컨트롤러 클래스를 유스케이스별로 분리했다.
이 과정에서 처음에 의도했던 대로 자신이 사용하는 인터페이스들만 사용하도록 해 ISP를 만족하게 될 것이다. 예를 들어, GetUserController는 자신이 사용하는 GetUserQuery에 대해서만 알고 있고, GetUserQuery를 구현하는 GetUserService는 out 포트인 LoadUserPort만 알게 된다. (그 과정에서 기존에 UserService는 점점 사용하지 않는 메서드들이 생기게 된다.)
@WebAdapter
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
class GetUserController {
private final GetUserQuery getUserQuery;
@GetMapping("/me")
public ResponseEntity<User> me(Authentication authentication) {
Long userId = getUserId(authentication);
User user = getUserQuery.loadUserById(userId);
return ResponseEntity.ok().body(user);
}
}
또한 분리하는 과정에서 이전에는 없던, 컨트롤러에 대한 통합 테스트를 작성했다.
@WebMvcTest(controllers = GetUserController.class)
class GetUserControllerTest extends BaseControllerIntegrationTest {
@MockBean
private GetUserQuery getUserQuery;
@Test
void testMe() throws Exception {
givenUserByReference(defaultUser().withId(1L).build());
mvc.perform(get("/user/me")
.header("Content-Type", "application/json")
)
.andExpect(status().isOk());
then(getUserQuery).should()
.loadUserById(eq(1L));
}
}
반복되는 빈 설정이나, 컨트롤러 클래스의 파라미터인 Authentication에 대해서 스터빙을 하기 위해서 BaseControllerIntegrationTest를 만들었다. 이 클래스는 테스트 대상이 아니므로 abstract로 작성됐다.
@WebMvcTest
@MockBean(JpaMetamodelMappingContext.class)
public abstract class BaseControllerIntegrationTest {
@Autowired
protected MockMvc mvc;
@MockBean
protected JwtService jwtService;
@MockBean
protected UserDetailsService userDetailsService;
protected void givenUserByReference(User user) {
loadUserDetailsWillReturnReference(user);
givenTestToken(user.getUid(), user.getName(), user.getProfileImage());
}
private void loadUserDetailsWillReturnReference(User user) {
given(userDetailsService.loadUserByUsername(any()))
.willReturn(builder()
.username(String.valueOf(user.getId()))
.password(user.getUid())
.authorities("user")
.build());
}
private void givenTestToken(String uid, String name, String picture) {
given(jwtService.decode(any()))
.willReturn(...);
}
}
컨트롤러 테스트가 단위 테스트가 아니라, 통합 테스트인 이유는 다음과 같다.
- @WebMvcTest 애노테이션은 스프링이 특정 요청 경로, 자바와 JSON 간의 매핑, HTTP 입력 검증에 필요한 전체 객체 네트워크를 인스턴스화하도록 한다.
- 웹 컨트롤러가 이 네트워크의 일부로서 잘 동작하는지 검증한다.
- 웹 컨트롤러는 스프링 프레임워크에 강하게 묶여 있기 때문에 격리된 상태로 테스트하기보다는, 통합된 상태로 테스트하는 것이 합리적이다. (단위 테스트로 테스트하면, 매핑, 유효성 검증, HTTP 항목에 대한 커버리지가 낮아짐)
최종적으로 컨트롤러를 분리한 결과는 다음과 같다.
컨트롤러를 분리하다 보니, 기존 UserController는 자연스레 사라졌다. 2단계에서 이미 UserService에 대한 의존성도 가지치기를 해서 거의 지울 수 있는 상태에 이르렀다. (더 이상 뚱뚱한 서비스 클래스는 없다)
하지만, 한 가지 아직 해결하지 못한 부분이 있다. 아래 코드를 보자.
category/service/CategoryService
@Transactional
public Long create(CreateCategoryRequestDto createCategoryRequestDto, Long userId) {
UserJpaEntity user = findUser(userId);
String name = createCategoryRequestDto.getName();
validateDuplicateCategory(name, user);
return createCategory(name, user).getId();
}
findUser 내부에서는 UserService#findById를 이용한다. 카테고리뿐만 아니라, 모든 서비스 클래스에서 UserJpaEntity와 UserService를 의존한다. 다른 Jpa 엔티티들이 UserJpaEntity에 대한 참조가 아닌, Long 타입 Id를 유지하도록 하면 해결할 수 있겠지만, 아직은 그 부분에 대해서 고려할 부분이 많아서 다음과 같이 임시 조치를 했다.
global/deprecated/LoadUserJpEntityApi
/**
* @author le2ksy
* @deprecated {@code findById} deprecated for package-dependency
* @apiNote
* UserJpaEntity를 로드하는 API입니다. 클래스 의존성을 생각해보면,
* 외부 클라이언트 코드(서비스, 도메인 레이어)에서 Persistence Adapter의 JpaEntity를 의존합니다.
* 외부 클라이언트 코드에서는 이 API를 이용해서 주로 외래키 제약을 만족시키거나, 자신의 리소스에 접근하고 있는지
* 확인할 때 사용합니다.
* <br><br>
* 따라서, 최종적으로 의존성을 제거하기 위해서는 Category#user가 아닌, Category#ownerId를 가지고 있는 설계가
* 필요할 것 같다고 생각했습니다. 이에 대해서는 더욱 이야기를 해볼 필요가 있을 것 같습니다.
*/
@Deprecated
public interface LoadUserJpaEntityApi {
UserJpaEntity findById(Long id);
}
global/deprecated/UserJpaEntityLoader
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
class UserJpaEntityLoader implements LoadUserJpaEntityApi {
private final EntityManager em;
@Override
public UserJpaEntity findById(Long id) {
return Optional.ofNullable(em.find(UserJpaEntity.class, id))
.orElseThrow(UserNotFoundException::new);
}
}
1편에서 만든 UserUseCase, UserServiceImpl에 대해서 다음과 같은 작업을 수행했다.
- UserUseCase의 이름을 LoadUserEntity로 변경
- 원래 뚱뚱한 클래스였던 UserServiceImpl을 UserJpaEntityLoader로 이름 변경
- global/deprecated 패키지로 위치 변경
- @Deprecated 애너테이션 붙이기
- Deprecated 이유 문서화
4단계, 인증 어댑터를 만들자
기존에는 global/authentication 패키지에 인증, 인가 관련 클래스들을 만들어놨다. 이때, JwtDecoder (헤더에 있는 토큰을 firebase에게 유효한지 요청)는 수행이 적절하게 이루어지면, FirebaseToken을 반환하도록 구현했다. FirebaseToken은 외부 라이브러리에서 왔으며, final class라서 Mockito로 mock 객체를 만들지 못한다.
물론, 설정을 바꾸면 할 수 있다고는 하는데, 억지로 모킹 하는 건 내가 원치 않았다.
FirebaseToken 때문에 아래 RegisterUserController를 테스트할 때 어려움이 있었다. jwtService#decode의 반환값을 원하는 FirebaseToken으로 스터빙하고 싶었는데, FirebaseToken가 new도 못하고, Builder도 없고, 모킹도 못한다.
adapter/in/web/RegisterController
@WebAdapter
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
class RegisterUserController {
private final JwtService jwtServuce;
private final RegisterUserUseCase registerUserUseCase;
@PostMapping()
public ResponseEntity<RegisterUserResponse> register(
HttpServletRequest request, @RequestParam("uid") String uid) {
String authorization = request.getHeader("Authorization");
FirebaseToken decodedToken = jwtServuce.decode(authorization);
validateUidWithToken(uid, decodedToken);
return ResponseEntity.status(HttpStatus.CREATED).body(
registerUserUseCase.register(decodedToken.getUid(), decodedToken.getName(),
decodedToken.getPicture()));
}
private void validateUidWithToken(String uid, FirebaseToken decodedToken) {
if (!decodedToken.getUid().equals(uid)) {
throw new InvalidValueException();
}
}
}
/**
* A decoded and verified Firebase token. Can be used to get the uid and other user attributes
* available in the token. See {@link FirebaseAuth#verifyIdToken(String)} and
* {@link FirebaseAuth#verifySessionCookie(String)} for details on how to obtain an instance of
* this class.
*/
public final class FirebaseToken {
private final Map<String, Object> claims;
FirebaseToken(Map<String, Object> claims) {
checkArgument(claims != null && claims.containsKey("sub"),
"Claims map must at least contain sub");
this.claims = ImmutableMap.copyOf(claims);
}
...
}
맨 위의 클래스 다이어그램을 보면 ArtieToken이란 게 있는데, FirebaseToken을 대체하기 위해서 구현했다. 디코더는 FirebaseToken 대신 ArtieToken을 반환하도록 구현했다.
@Getter
@RequiredArgsConstructor
public class ArtieToken {
private final String uid;
private final String name;
private final String picture;
}
음.. 만들고 의존성을 확인해 보니, 기존 global/authentication 패키지에 JwtFilter(인가 필터)와 JwtExceptionFilter를 제외하고 나머지 클래스들은 User 패키지에서 사용한다.
뿐만 아니라, AWS에도 Firebase Authentication과 같은 서비스가 있던 게 기억이 났다. 그래서 나는 "그러면 대체 가능해야지?"라는 판단을 하고 인증 어댑터로 만들기로 결정했다.
우선 적절한 out 포트를 만들어 줬다.
application/port/out/TokenParsingPort
public interface TokenParsingPort {
ArtieToken parseToken(String header);
}
application/port/out/DeleteExternalUserPort
public interface DeleteExternalUserPort {
void delete(String uid);
}
이후에는 이를 구현하는 FirebaseAuthenticationAdapter를 기존 JwtServiceImpl을 기반으로 만들었다. 기존 클래스들의 이름을 더욱 역할이 잘 보이는 이름으로 변경해 주고 단위 테스트도 작성했다.
@Slf4j
@AuthenticationAdapter
@RequiredArgsConstructor
class FirebaseAuthenticationAdapter implements DeleteExternalUserPort, TokenParsingPort {
private final FirebaseUserRemover firebaseUserRemover;
private final JwtDecoder decoder;
private final TokenGenerator tokenGenerator;
@Override
public ArtieToken parseToken(String header) {
validateHeader(header);
return tokenGenerator.generateDomainToken(decoder
.decode(refineHeaderAsToken(header)));
}
@Override
public void delete(String uid) {
firebaseUserRemover.remove(uid);
}
private void validateHeader(String header) {
if (header == null || !header.startsWith("Bearer ") || header.trim().equals("Bearer")) {
throw new NotExistValidTokenException();
}
}
private String refineHeaderAsToken(String header) {
String authType = "Bearer";
if (header.startsWith(authType)) {
header = header.substring(authType.length()).trim();
}
return header;
}
}
class FirebaseAuthenticationAdapterTest {
private final JwtDecoder jwtDecoder = Mockito.mock(JwtDecoder.class);
private final FirebaseUserRemover firebaseUserRemover = Mockito.mock(FirebaseUserRemover.class);
private final TokenGenerator tokenGenerator = Mockito.mock(TokenGenerator.class);
private final FirebaseAuthenticationAdapter adapterUnderTest =
new FirebaseAuthenticationAdapter(firebaseUserRemover, jwtDecoder, tokenGenerator);
@Test
void parseToken_헤더가_null인_경우_예외를_발생한다() {
assertThatThrownBy(() -> {
adapterUnderTest.parseToken(null);
}).isInstanceOf(NotExistValidTokenException.class);
}
@Test
void parseToken_헤더가_Bearer_로_시작하지_않는_경우_예외를_발생한다() {
assertThatThrownBy(() -> {
adapterUnderTest.parseToken("Drink Beer");
}).isInstanceOf(NotExistValidTokenException.class);
}
@ParameterizedTest(name = "[{index}] Authorization 헤더가 {0}인 경우")
@ValueSource(strings = {"Bearer", "Bearer ", " Bearer", " Bearer", "Bearer "})
void parseToken_헤더가_Bearer만_포함하는_경우_예외를_발생한다(String header) {
assertThatThrownBy(() -> {
adapterUnderTest.parseToken(header);
}).isInstanceOf(NotExistValidTokenException.class);
}
@Test
void parseToken_유효한_Bearer_토큰이라면_새로운_사용자_인증_토큰을_반환한다() {
User user = defaultUser()
.withUid("uid")
.withName("tomcat")
.withProfileImage("sample.com")
.build();
givenArtieTokenByReference(user);
ArtieToken actual = adapterUnderTest.parseToken("Bearer valid token");
assertThat(actual.getUid()).isEqualTo("uid");
assertThat(actual.getName()).isEqualTo("tomcat");
assertThat(actual.getPicture()).isEqualTo("sample.com");
}
@Test
public void parseToken_토큰이_들어오면_토큰의_타입을_제거해서_decoder에게_전달한다() throws Exception {
String header = "Bearer tomcat";
adapterUnderTest.parseToken(header);
then(jwtDecoder)
.should()
.decode(eq("tomcat"));
}
@Test
public void delete_Remover에게_삭제_요청을_한다() {
adapterUnderTest.delete("uid");
then(firebaseUserRemover)
.should()
.remove(eq("uid"));
}
private void givenArtieTokenByReference(User user) {
given(tokenGenerator.generateDomainToken(any()))
.willReturn(new ArtieToken(user.getUid(), user.getName(), user.getProfileImage()));
}
}
RegisterUserController에서 바로 out 포트의 JwtParsingPort를 사용하는 것이 조금 깨림칙하긴 하다. (지름길을 탔나?)
public ResponseEntity<RegisterUserResponse> register(
HttpServletRequest request, @RequestParam("uid") String uid) {
String authorization = request.getHeader("Authorization");
ArtieToken decodedToken = tokenParsingPort.parseToken(authorization);
validateUidWithToken(uid, decodedToken);
return ResponseEntity.status(HttpStatus.CREATED).body(
registerUserUseCase.register(decodedToken.getUid(), decodedToken.getName(),
decodedToken.getPicture()));
}
5 단계, 패키지 정리
모든 구현과 단위 테스트 작성이 마친 이후에는 패키지 정리를 했다. authentication adapter를 보면, 다음과 같이 exception도 넣어주고, 모든 클래스의 가시성을 package-private으로 만들어서 우발적인 의존성을 제한해 줬다. (persistence adapter도 pacakage-private으로 만들어야 하지만, 현재 user가 아닌 다른 패키지들에서 의존을 가지고 있어서, 몇 개는 public으로 만들었다....)
그리고 기존에 무슨무슨 DTO 이런 게 있었는데, 무슨무슨 Response로 이름을 변경해 주고 in port에 넣어줬다. 예외 같은 경우에는 고민이 정말 많았는데, 우선 서비스 패키지에 위치시켰다. (UserNotFoundException) 찾아보니, 도메인 예외와 서비스 예외를 분리하고 매핑하는 작업을 하는 것 같은데, 아직은 적용하지 않았다.
결과
회원 도메인에 헥사고날을 적용한 결과는 아래와 같다. 회고에 더욱 자세히 다루긴 할 테지만, 아직 개선해야 할 부분이 많다. 그래도 이전 레거시보다는 의존성이 잘 관리되고 있다.
이전에는 UserController -> UserService -> UserRepository로 구성되어 시스템이 무슨 일을 하는지 명확하지 않았다. 하지만 이제는 확실히 시스템의 의도가 보이는 설계가 되었다.
그림과 함께 보면 좋다.
main/user :
test/user :
우선 적용기는 여기까지 작성하고 다음에는 회고를 할 것이다. 끝!
'개발(레거시) > 문제 해결' 카테고리의 다른 글
kotlin + spring boot 프로젝트에서 DTO 검증 체크리스트 (0) | 2023.08.04 |
---|---|
레거시 코드를 헥사고날 아키텍처로 전환(2) - 회원 도메인에서 읽기 전용 유스 케이스 분리, 영속성 어댑터 만들기 (0) | 2023.03.31 |
레거시 코드를 헥사고날 아키텍처로 전환(1) - 회원 도메인에서 쓰기 전용 유스 케이스 분리 (2) | 2023.03.29 |
아르티 리팩터링 기록 - publish (0) | 2023.01.23 |
아르티 아키텍처 디자인에 대해서 끄적 (0) | 2023.01.23 |