들어가는 말
현재 만들고 있는 사이드 프로젝트인 아르티에서 안 좋은 신호들을 발견했던 걸 정리해 둔 글이 있다. 아르티 아키텍처 디자인에 대해서 끄적에서는 주로 뚱뚱한 서비스 클래스를 어떻게 분리할 것인가에 대해 다룬다. 글을 쓴 이후에도 문제를 해결하기 위해서 여러 글을 찾아봤는데, 이때 헥사고날 아키텍처라는 것을 알게 됐다.
현재 팀원과 나는 "만들면서 배우는 클린 아키텍처"라는 책을 읽고, 프로젝트에 적용 중이다. 이번 주에는 내가 회원 도메인을 개선하기로 맡았는데, 과정을 기록하고 팀원에게 공유하는 것이 포스팅의 목적이다. 이외에도 쿼리나 테스트 코드를 개선하고 싶었는데 이번 기회에 시도해 봐야겠다. (기존 테스트 코드는 모든 스프링 빈을 컨테이너에 담아서 실행하기 때문에 시간이 오래 걸릴뿐더러, 테스트 코드가 없는 프로덕션 코드도 많다)
1단계. 기존 테스트를 돌려본다
FirebaseApp name [DEFAULT] already exists 라는 경고 메시지와 함께 컨트롤러 테스트 코드가 실패하고 있었다. 원인은 firebaseInitializeApp을 여러 번 호출해서 발생하는 문제라고 한다. 일단 한 번만 호출하도록 보장하게끔 코드를 변경해서 해결했다.
@Bean
public FirebaseApp firebaseApp() throws IOException {
log.info("Initializing Firebase.");
FileInputStream serviceAccount =
new FileInputStream("./firebase.json");
FirebaseOptions options = new FirebaseOptions.Builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
// 이 부분!
FirebaseApp app = FirebaseApp.getApps()
.stream()
.filter(app1 -> app1.getName().equals(FirebaseApp.DEFAULT_APP_NAME))
.findFirst()
.orElseGet(() -> FirebaseApp.initializeApp(options));
log.info("FirebaseApp initialized" + app.getName());
return app;
}
2단계, 필요한 애노테이션들을 구현하기
service, controller 등의 기존 스프링 애노테이션을 사용해도 괜찮지만, 유지보수하기 쉽게 컴포넌트를 식별하기 위함이다.
3단계, 커맨드 유스케이스(UseCase) 분리
까다로운 작업이라고 생각이 들지만, 인텔리제이를 믿어보기로 했다. 현재 service 레이어의 클래스는 아래와 같다.
UserService는 다른 클라이언트 코드에서 자주 사용된다. 왜냐하면 다른 클라이언트 코드들은 로직을 수행하기 위해서 회원 찾기 기능을 사용하기 때문이다. (테스트 코드, 컨트롤러에서도 구체적인 UserService를 의존하고 있다.) 우선 결합도를 낮추기 위해서 인터페이스를 추출한다.
3.1 단계, UseService 인터페이스 추출
대상 클래스를 열고 커서를 클래스 내부에 위치 시키자. 이후에는 Search Action에 들어가서(윈도우 기준 ctrl + shift + a) "extract interface"를 검색해 보자. 혹은 Refactor this(윈도우 기존 ctrl + shift + alt + t)에서 "extract inteface"를 선택하자.
- 기존 UserService에서 인터페이스 추출 -> UserService
- 기존 UserService -> UserServiceImpl로 이름 변경
3.2 단계, UserService 인터페이스 이름 변경
추출했다면, UserService가 인터페이스가 된다. 기존 클라이언트 코드들은 인터페이스를 바라보게 된다. 그 다음으로 해야 할 작업은 인터페이스 이름 변경이다. 윈도우 기준 shift + F6으로 클래스 이름을 변경할 수 있다.
- UserService -> UserUseCase로 이름 변경
그러면 아래와 같이 변수명도 바꿀꺼냐고 물어보는데, 어차피 지울 거니깐 그대로 내버려두어도 괜찮을 것 같다.
그러면 다음과 같이 클라이언트 코드들은 모두 인터페이스를 바라보게 된다. DIP를 만족하도록 수정한 것이다. (인텔리제이 고마워!)
인터페이스를 추출하기 이전에는 다음과 같은 문제가 있다.
많은 외부 클라이언트 코드가 하나의 뚱뚱한 클래스에 의존하게 되어서 변경에 취약해진다. Fan-in은 안으로 들어오는 의존성(컴포넌트 내부의 클래스에 의존하는 컴포넌트 외부의 클래스 개수)을 의미한다. 반대는 Fan-out이라고 하는데, 이에 대한 자세한 내용은 "로버트 C. 마틴 - 클린 아키텍처"에서 찾아 보면 좋을 듯하다. (14장, 컴포넌트 결합)
핵심은 안정적인 인터페이스를 둠으로 클라이언트 코드를 보호할 수 있다는 것이다. (의존성 역전)
중간 정리
우선 UseCase를 크게 하나 추출했다.(UserUserCase) 이후에는 UserServiceImpl에서 책임을 분리하는 작업을 하고 싶다. 예를 들어, UserServiceImpl#register가 있다고 가정하면, RegisterUserService로 클래스를 추출(책임 위임)한 다음, RegisterUseCase 인터페이스를 추출하고 싶다.
마지막에는 UserUseCase를 사용하는 모든 곳에서 ISP를 만족하게끔 변경하는게 목표이다. 인터페이스를 추출했다는 가정하에 UserUseCase#register를 사용하는 클라이언트 코드는 다른 코드를 사용할 필요가 없다. 예를 들어, UserUseCase#findByName은 필요가 없다. 즉, ISP를 위반한다.
ISP를 만족하게 수정이 되면, UserUseCase, UserServiceImpl은 제거해도 될 것이라 생각된다. 이렇게 하는게 맞는지 확신이 서지 않는다. 왜냐하면, 어차피 인터페이스 추출해도 클라이언트 코드를 변경해야하기 때문이다.(클라이언트에서는 자신이 사용하는 UseCsae만 의존하도록 변경해야 함) 그래도 일단 계속 고고해보자.
첨언하자면, 현재 상태는 아래와 같다.
1. 외부 클라이언트 코드 -> UserUseCase <- UserServiceImpl
그리고 다음과 같은 작업을 할 것이다.
2. 외부 클라이언트 코드 -> UserUseCase <- UserServiceImpl -> RegisterUserService
위 구성을 아래와 같이 변경 (ISP 만족하도록)
3. 외부 클라이언트 코드 -> RegisterUserUseCase <- RegisterUserService
4. UserUseCase, UserServiceImpl 삭제
register를 사용하는 클라이언트 코드 A가 UserUseCase의 register가 아닌 RegisterUseCase의 register를 사용하도록 변경해야 한다. 이처럼 자신이 사용하는 인터페이스에만 의존하도록 변경하면 ISP를 만족할 수 있다.
인터페이스를 추출해도 클라이언트 코드를 변경해야한다는 말은 UserUseCase를 둬도 어차피 ISP를 만족하게 하는 과정 중에서 클라이언트 코드를 변경해야 하기 때문이다. 따라서 현재는 구성을 변경하는 것(단순히 책임을 분리하고 위임)이라 생각이 들어서 굳이 3.1 단계, 3.2 단계를 수행할 필요가 없다고 느꼈다. 그래도 밑져야 본전이니.. 경험으로 삼자
현재까지 한일
- UserService 인터페이스 추출
- UserService 인터페이스 이름 변경 (UserUseCase)
- 기존 UserService를 UserServiceImpl로 변경
- 내용에는 생략했지만, 중간중간 테스트 실행하고 커밋하기!
앞으로 할일 목록
- UserServiceImpl에서 책임을 분리함
- 책임을 할당할 적절한 클래스를 추출 ex ) RegisterUserService
- 추출한 클래스의 인터페이스를 생성 ex ) RegisterUseCase
- 클라이언트 코드에서 ISP를 만족하도록 변경
3.4 단계, 책임 분리와 할당
UserServiceImpl의 책임을 줄여보자. 우선 register부터 추출하려고 한다. register 메서드 명에 커서를 올리고, 인텔리제이 리팩터링 도구에서 위임자 추출을 선택하자.
위임자를 추출하면, 조금 지저분하게 뽑힌다. 원래 있던 곳(UserServiceImpl)과 새로 생성된 위임 클래스(RegisterUserService)를 정리하면 아래와 같다. (순환 참조 제거, 의존성 추가, 필요한 애노테이션 추가)
... RegisterUserService
@UseCase
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RegisterUserService {
private final UserRepository userRepository;
@Transactional
public CreateUserResponseDto register(String uid, String username, String picture) {
User user = userRepository.findByUid(uid)
.orElse(User.create(uid, username, picture));
if (user.getId() == null) {
userRepository.save(user);
}
return new CreateUserResponseDto(user.getId());
}
}
... UserServiceImpl
@Override
public CreateUserResponseDto register(String uid, String username, String picture) {
return registerUserService.register(uid, username, picture);
}
테스트를 통과 시키고, 커밋을 남겼다. 이제 우리는 인터페이스를 추출할 것이다. 헥사고날 아키텍처에서는 여기서 추출한 인터페이스를 인커밍 포트로 사용한다.
인터페이스 추출 옵션에서 [원본 클래스... 사용]을 선택할 경우에는 클라이언트 코드에서 인터페이스를 사용하도록 하는 것 같다. 현재 나는 임시 클래스(UserServiceImpl)에서만 사용하고 있기 때문에 그냥 [인터페이스 추출]을 선택했다.
최종적으로 다음과 같은 서비스 클래스가 만들어졌다.
@UseCase
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class RegisterUserService implements RegisterUserUseCase {
private final UserRepository userRepository;
@Override
@Transactional
public CreateUserResponseDto register(String uid, String username, String picture) {
User user = userRepository.findByUid(uid)
.orElse(User.create(uid, username, picture));
if (user.getId() == null) {
userRepository.save(user);
}
return new CreateUserResponseDto(user.getId());
}
}
3.5 단계, 단위 테스트 작성
이제 단위 테스트를 작성해보자.
class RegisterUserServiceTest {
private final UserRepository userRepository = Mockito.mock(UserRepository.class);
private final RegisterUserService registerUserService = new RegisterUserService(userRepository);
@Test
void register_이미_등록된_사용자라면_ID를_그대로_반환한다() {
givenUser();
CreateUserResponseDto actual = registerUserService.register("1", "test", null);
assertThat(actual.getId()).isEqualTo(1L);
}
@Test
void register_이미_등록된_사용자라면_저장소에_저장하지_않는다(){
givenUser();
registerUserService.register("1", "test", null);
then(userRepository)
.should(never())
.save(any());
}
@Test
void register_신규_사용자라면_새로운_id를_부여받는다() {
Long expectedId = 2L;
givenSaveWillReturnUserWithId(expectedId);
CreateUserResponseDto actual = registerUserService.register("1", "test", null);
assertThat(actual.getId()).isEqualTo(expectedId);
}
private void givenUser() {
given(userRepository.findByUid(any()))
.willReturn(Optional.ofNullable(defaultUser().build()));
}
private void givenSaveWillReturnUserWithId(Long id) {
given(userRepository.save(any()))
.willReturn(defaultUser()
.withId(id)
.build());
}
}
다음과 같은 흐름으로 단위 테스트를 작성하고, 코드 개선을 했다.
- 기존 테스트에서 단위 테스트로 이동할 것이 있는지 확인(기존에 없었음..ㅠㅠ)
- 필요한 의존성을 모킹 하기
- 놓쳤던 엣지 케이스가 있는지 확인하기
- 테스트를 구현하고 맞는지 검증하기, 실패하면 코드 수정
- 리팩터링
결과적으로 다음과 같은 이쁜 코드를 만들게 됐다. (해당 클래스는 커맨드를 담당하기 때문에 읽기 전용 트랜잭션을 false로 설정) 나중에는 UserRepository와 Dto도 이름을 바꿔줘야겠다!
@UseCase
@Transactional
@RequiredArgsConstructor
public class RegisterUserService implements RegisterUserUseCase {
private final UserRepository userRepository;
@Override
public CreateUserResponseDto register(final String uid, final String username,
final String picture) {
final Optional<User> user = userRepository.findByUid(uid);
return user.map(entity -> new CreateUserResponseDto(entity.getId()))
.orElseGet(() -> new CreateUserResponseDto(userRepository
.save(User.create(uid, username, picture))
.getId()));
}
}
3.6 단계, 패키지 정리
이후에는 아키텍처가 보이도록 패키지 정리를 했다.
마무리
위와 같은 과정을 반복해서 다른 커맨드성 유스케이스도 분리했다. 최종 결과는 다음과 같다.
인텔리제이 기능을 이용해서 뚱뚱한 클래스 UserService의 커맨드 기능들을 수월하게 분리했다. 미리 의존성 관리를 잘하고, 좋은 코드를 작성하고, 단위 테스트를 작성했더라면 더 수월했을 것이라 느꼈다. 앞으로는 코드를 작성할 때 이러한 부분들에 더욱 시간을 투자해야겠다고 뼈저리게 느꼈다. 다음에 내가 해야 할 일은 읽기 전용 유스케이스를 분리하고, 영속성 어댑터를 만드는 일이다. 그전에 하나 리마인드 해둘 것이 있다.
아래는 UserWithdrawalService의 단위 테스트에서 가져왔다. 아래 코드의 가장 큰 문제점은 withdraw가 호출됐는지, save가 호출됐는지와 같은 내부 구현에 대해 많은 관심을 가지는 것이다. 즉, 내부 구현을 검증한다.
@Test
void delete_사용자를_찾을_수_없으면_예외를_발생한다() {
givenUserFindWillFail();
assertThatThrownBy(() -> userWithdrawalService.delete(1L))
.isInstanceOf(UserNotFoundException.class);
}
@Test
void delete_사용자_삭제_요청을_받으면_firebase에서_삭제되도록_요청한다() {
givenUser();
userWithdrawalService.delete(1L);
then(jwtService)
.should()
.withdraw(any());
}
@Test
void delete_사용자_삭제_요청을_받으면_사용자의_모든_데이터를_삭제한다() {
givenUser();
userWithdrawalService.delete(1L);
then(categoryRepository)
.should()
.deleteAllByUser(any());
}
@Test
void delete_데이터베이스에서_사용자를_삭제하도록_요청한다() {
User user = defaultUser().build();
givenUserByReference(user);
userWithdrawalService.delete(1L);
then(userRepository)
.should()
.delete(eq(user));
}
관련해서 향로님 블로그에 테스트 코드에서 내부 구현 검증 피하기 라는 글이 있다. 시간이 나면 확인해 보면 좋을 것 같다. 현재로선 Fake Repository를 만들어서 사용하는 방법만 떠오른다. 더 좋은 방법이 있다면 이 부분은 따로 글을 작성하도록 하겠다.
'개발(레거시) > 문제 해결' 카테고리의 다른 글
레거시 코드를 헥사고날 아키텍처로 전환(Final) - 회원 도메인에서 웹, 인증 어댑터 분리 (0) | 2023.04.04 |
---|---|
레거시 코드를 헥사고날 아키텍처로 전환(2) - 회원 도메인에서 읽기 전용 유스 케이스 분리, 영속성 어댑터 만들기 (0) | 2023.03.31 |
아르티 리팩터링 기록 - publish (0) | 2023.01.23 |
아르티 아키텍처 디자인에 대해서 끄적 (0) | 2023.01.23 |
Intelij Ultimate 다이어그램 안나올 경우 (0) | 2022.12.11 |