GYUD-TECH

단위 테스트를 작성하며 객체지향 적용하기 본문

기술-개발

단위 테스트를 작성하며 객체지향 적용하기

GYUD 2025. 1. 27. 23:22

뉴젯 서비스의 메일 수신 로직을 스프링 서버로 분리하면서 객체지향에 초점을 맞추어 코드를 작성하고 있다.

Service 계층은 Repository에서 데이터를 가져와서 도메인에 비즈니스 로직을 실현하는 역할을, Repository는 데이터를 가져와서 도메인으로 변화하는 역할을 수행한다. 이를 통해 확장성이 좋은 구조를 설계하려 한다.

 

테스트 코드를 먼저 작성 후 비즈니스 로직을 작성하면서, 테스트를 얼마나 간단하게 작성할 수 있는지를 고민하였다. 이 과정에서 테스트가 어려운 객체들의 역할을 잘게 나누고, 외부 주입을 통해 숨겨진 입력을 제거하면서 더욱 객체지향스럽게 구조를 바꾸고 있다.

 

이 과정에서 했던 좋은 설계에 대해 많이 고민하게 되었고, 자연스럽게 디자인 패턴과 DTO의 등장을 이해하는 경험이 되었다.


설계

특정 유저에게 메일이 도착하면 mail_reciever 에서 해당 뉴스레터를 구독하는 기능이 필요하다. 이를 위해 메일 수신자 email 값을 바탕으로 데이터베이스에서 User를 조회하는 기능이 필요하다.

 

이를 구현하기 위해서 UserRepoisitory 인터페이스를 선언하고, ActiveUser를 email로 조회하는 역할을 할당하였다.

public interface UserRepository {
  ActiveUser findActiveUserByEmail(String email);
}

UserRepository를 구현하는 UserRepositoryImpl은 findActiveUserByEmail 메서드를 오버라이딩하여 구현하였다.

@Component
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepository {

  private final UserJpaRepository userJpaRepository;
  private final SubscriptionJpaRepository subscriptionJpaRepository;
  private final NewsletterJpaRepository newsletterJpaRepository;

  @Override
  public ActiveUser findActiveUserByEmail(String email) {
    // UserJpaEntity 조회
    UserJpaEntity userJpaEntity = userJpaRepository.findByEmailAndStatus(email,
        UserStatus.ACTIVE).orElseThrow(NoActiveUserException::new);

    // 유저가 구독한 SubscriptionJpaEntity 조회
    List<SubscriptionJpaEntity> subscriptionJpaEntityList = subscriptionJpaRepository
        .findAllByUserId(userJpaEntity.getId());

    // subscriptionJpaEntity 정보로 Newsletter 정보 조회
    List<NewsletterJpaEntity> newsletterJpaEntityList = subscriptionJpaEntityList.stream()
		.map(subscription -> newsletterJpaRepository.findById(subscription.getId())
			.orElseThrow(() -> new NewsletterNotFoundException("id에 해당하는 뉴스레터가 없습니다")))
		.toList();
    
    // newsletter 도메인으로 변환
    List<Newsletter> newsletterList = newsletterJpaEntityList.stream()
		.map(NewsletterJpaEntity::toNewsletter)
		.toList();
        
    // User 도메인으로 변환
    return userJpaEntity.toActiveUser(newsletterList);
}

 

User에는 List<Newsletter> subscribedNewsletter 라는 구독한 뉴스레터 필드가 필요하였기 때문에 newsletterRepository도 조회 하도록 하였는데 이런 구현은 테스트 하기 어려운 코드로 이어졌다.

 

테스트하기 어려운 코드

테스트를 작성하면서 되도록 외부 서비스(ex: DB)와 연동이 없는 단위 테스트를 위주로 코드를 작성하여 테스트 실행 시간을 절약하고자 신경쓰고 있다.

 

그래서 @SpringBootTest를 사용하지 않고 fake 객체를 이용하여 findActiveUserByEmail 메서드를 테스트하고자 FakeUserRepository를 정의하였다.

public class FakeUserRepository implements UserJpaRepository {
  private static final String TEST_EMAIL = "exist@example.com";

  @Override
  public Optional<UserJpaEntity> findByEmailAndStatus(String email, UserStatus status) {
    if (email.equals(TEST_EMAIL) && status == UserStatus.ACTIVE) {
      return Optional.of(new UserJpaEntity());
    }
    return Optional.empty();
  }
  
  // 다른 메서드들 정의
}

 

이후 활성 유저 조회에 성공했을 때의 테스트 코드와 활성 유저 조회에 실패하였을 때의 테스트 코드를 작성하였다.

class UserRepositoryImplTest {

    @Test
    public void 이메일로_존재하는_활성_유저_조회() {
       //given
       String email = "exist@example.com";
       UserRepository userRepository = 
           new UserRepositoryImpl(new FakeUserRepository(), new FakeSubscriptionRepository(), new FakeNewsletterRepository());

       //when
       ActiveUser activeUser = userRepository.findActiveUserByEmail(email);

       //then
       Assertions.assertEquals(activeUser.getEmail(), email);
    }

    @Test
    public void 존재하지_않는_이메일의_활성_유저_조회() {
       //given
       String email = "exist@example.com";
       UserRepository userRepository = 
           new UserRepositoryImpl(new FakeUserRepository(), new FakeSubscriptionRepository(), new FakeNewsletterRepository());

       //then
       Assertions.assertThrows(InvalidUserException.class,
          () -> userRepository.findActiveUserByEmail(email));
    }

}

하지만 한개의 메서드를 테스트하기 위해서 Fake 객체 클래스 3개를 만드는 것이 과하다고 느껴졌다.

mockito를 사용하면 클래스 파일 자체가 줄어들긴 하지만, 테스트 코드가 복잡한건 마찬가지였다.

@Test
public void 이메일로_존재하는_활성_유저_조회() {
  //given
  String email = "exist@example.com";
  
  // fake 객체 정의
  UserJpaRepository userFakeRepository = mock(UserJpaRepository.class);
  when(userJpaRepository.findByEmailAndStatus(email, UserStatus.ACTIVE))
			.then(Optional.of(new UserJpaEntity()));
  
  SubscriptionJpaRepository subscriptionFakeRepository = mock(SubscriptionJpaRepository.class);
  // fake 객체 동작 정의
  
  NewsletterJpaRepository newsletterFakeRepository = mock(NewsletterJpaRepository.class);
  // fake 객체 동작 정의
  
  // fake 객체 주입
  UserRepository userRepository = new UserRepositoryImpl(userFakeRepository, subscriptionFakeRepository, newsletterFakeRepository);
  

  //when
  ActiveUser activeUser = userRepository.findActiveUserByEmail(email);

  //then
  Assertions.assertEquals(activeUser.getEmail(), email);
}

@Test
public void 존재하지_않는_이메일의_활성_유저_조회() {
  // 위와 동일
  
  //then
  Assertions.assertThrows(InvalidUserException.class,
      () -> userRepository.findActiveUserByEmail(email));
}

테스트의 목적email에 해당하는 ActiveUser가 있으면 해당 User를 리턴하고, 없으면 InvalidUserException을 발생시키는 것을 확인하는 것인데 subscriptionRepository 와 newsletterRepository 까지 주입해주고 있었다.

뿐만 아니라 subscriptionFakeRepository 객체의 동작을 정의하기 위해 userJpaEntity의 id 필드값도 모두 설정해야하는 등 검증과는 불필요한 코드들이 많이 필요했다.

 

설계가 이상하다고 생각하여 다시 UserRepositoryImpl의 역할에 대해서 고민해보았다.

UserRepositoryImpl의 역할은 이메일로 활성유저를 조회하는 것인데 이와는 관계 없는 subscription 조회, newslettere 조회, User 생성의 역할도 함께 가지고 있었다.

 

이를 해결하기 위해 두가지 방식을 떠올렸다.

 

1. Business layer의 mail_reciever에서 필요한 값을 모두 모으고, 조합해서 User 객체를 생성하는 방식

각자의 조회 로직을 분리하고, mail_reciever에서 필요한 값들을 모두 조회한 후에 User 도메인을 만드는 방식이다. 하지만 이 방식의 경우 List<Newsletter>를 포함하지 않은 User 객체를 잠시 저장해놓기 위해 새로운 객체를 정의해야만 했다. 또한 필요한 값을 모두 조회한 이후에는 mail_reciever에서 List<Newsletter>가 포함된 User 객체를 생성해야하는 번거로움이 있었다.

 

2. Infrastructure layer에 UserFactory 객체를 만들어서 필요한 값을 모두 조회하고 User를 리턴하는 방식

User 제작을 위해 필요한 값을 조회하는 로직을 UserFactory에 만들고, 이를 조립하여 User를 리턴하는 방식이다. 이렇게 하면 임시 저장을 위한 새로운 User 객체를 정의할 필요가 없어진다. 또한 Business layer와 Infrastructure layer 간에는 오로지 domain만 참조하도록 하여 의존성이 약해진다.

(추후에 domain 참조가 아닌 dto를 사용하도록 변경하였다)

 

결과적으로 UserFactory가 User 생성의 역할을 맡아 필요한 값을 조회하고, User를 제작하는 역할을 수행하였다.

@Component
@RequiredArgsConstructor
public class UserFactory implements UserRepository {

  private final UserEntityRepository userEntityRepository;
  private final NewsletterEntityRepository newsletterEntityRepository;

  @Override
  public ActiveUser findActiveUserByEmail(String email) {
    UserJpaEntity userJpaEntity = userEntityRepository.findActiveUserByEmail(email);
    List<NewsletterJpaEntity> newsletterJpaEntityList = newsletterEntityRepository
	  .findNewsletterListByUserId(userJpaEntity.getId());
    List<Newsletter> newsletterList = newsletterJpaEntityList.stream()
	  .map(NewsletterJpaEntity::toNewsletter)
	  .toList();

    return ActiveUser.create(userJpaEntity.getEmail(), newsletterList);
  }
}

 

이 방식을 적용하여 userEntityRepository, newsletterJpaRepository 모두 단일 책임만을 가질 수 있었다. User 제작하는 역할은 UserFactory에 할당하여 제작에 필요한 값을 Repository에 명령함으로써 역할을 명확히 드러낼 수 있었다. 또한 코드가 간단해지니 테스트 코드도 쉽게 작성할 수 있었다.


글을 작성하면서도 설계가 계속 바뀌어서 글을 작성하기가 어려웠다. 글 작성 이후에도 설계가 변경되어, 현재는 dto 객체가 추가되고, User에서 뉴스레터 리스트를 관리하지 않도록 설계가 변경되었다. 아직도 계속 역할과 책임을 분리하며 의존관계를 약하게 하기 위해 고민하고 있다. 

 

역할을 잘 분리했다고 생각했지만, 테스트코드 작성이 어려운 것을 겪은 후 다시 코드를 보니 너무 많은 책임을 수행하고 있는 객체들을 확인할 수 있었다. User라는 공통적인 키워드를 바탕으로 ActiveUser와 InActiveUser를 분리하였지만 수행하는 역할이 완전히 달라서 다시 설계를 되돌리기도 하였다.

 

이런 경험을 통해 역할로 객체를 정의해야 한다는 것을 직접 경험할 수 있었다. 또한 시행착오 과정에서 자연스럽게 dto 객체가 등장하고, 디자인 패턴이 적용하며 관련 내용을 함께 공부할 수 있었다.

 

'기술-개발' 카테고리의 다른 글

Stream 적용기  (0) 2023.10.30
좋은 커밋 메시지 작성법  (1) 2023.10.25
클린코드를 연습하는 이유  (1) 2023.10.24
기술명세서 작성법  (1) 2023.10.24