군만두의 IT 공부 일지

[스터디2] 11. 테스트와 설계 본문

프로그래밍/객체지향

[스터디2] 11. 테스트와 설계

mandus 2025. 2. 22. 11:32

목차

 

 

드디어 이 책을 마무리하게 되었다. 여기서는 테스트를 SOLID 법칙에 따라 어떻게 설계하는지 설명한다. 책을 읽으면서 SRP에서는 실제 프로젝트에서 팀원이 튜터님한테 Repository 의존성을 많이 주입받고 있다고 지적받은 상황이 떠올랐다. 이런 부분을 유의해야 할 것 같다.

16 . 테스트와 설계

테스트와 소프트웨어 설계가 추구하는 가치에 교집합이 있다. 좋은 설계는 시스템이 모듈로 분해되고 각 모듈이 독립적으로 개발될 수 있게 하는 것을 추구한다. 더 나아가 시스템이 확장될 수 있는 것을 추구한다. 좋은 설계를 갖춘 코드는 대부분 테스트하기도 쉽다.

테스트와 좋은 설계
1. 테스트하기 어려운 코드는 좋은 설계 원칙을 적용함으로써 테스트하기 쉽게 만들 수 있다.
2. 어떤 방식이 좋은 설계인지 헷갈린다면 테스트하기 쉬운 코드를 선택하면 된다.

16.1 테스트와 SRP

테스트를 작성함으로써 단일 책임 원칙(SRP)을 지키지 않았던 코드가 단일 책임 원칙을 지키는 방향으로 변경되는 예시를 본다.

// 회원가입과 로그인 메서드가 포함된 UserService
@Service
@Builder
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final VerificationEmailSender verificationEmailSender;
    private final ClockHolder clockHolder;

    @Transactional
    public User register(UserCreateDto userCreateDto) {
        if (userRepository.findByEmail(userCreateDto.getEmail()).isPresent()) {
            throw new EmailDuplicatedException();
        }

        User user = User.builder()
            .email(userCreateDto.getEmail())
            .nickname(userCreateDto.getNickname())
            .status(UserStatus.PENDING)
            .verificationCode(UUID.randomUUID().toString())
            .build();

        user = userRepository.save(user);
        verificationEmailSender.send(user);

        return user;
    }

    @Transactional
    public User login(String email) {
        User user = userRepository.getByEmail(email);
        if (!user.isActiveStatus()) {
            throw new UserIsNotActiveException();
        }

        user.login(clockHolder);
        user = userRepository.save(user);

        return user;
    }
}
// 회원가입과 로그인 테스트가 포함된 UserServiceTest
public class UserServiceTest {

    @Test
    public void 중복된_이메일_회원가입_요청이_오면_에러가_발생한다() {
        // 코드 14.14 참조
    }

    @Test
    public void 이메일_회원가입을_하면_가입_보류_상태가_된다() {
        // 코드 14.14 참조
    }

    @Test
    public void 존재하지_않는_사용자에_로그인하려면_에러가_발생한다() {
        // given
        final long current = 1672498800000L; // 2023-01-01 00:00:00+09:00
        ClockHolder clockHolder = new TestClockHolder();
        clockHolder.setCurrentTimestamp(current);

        // then
        assertThrows(UserNotFoundException.class, () -> {
            // when
            UserService userService = UserService.builder()
                .verificationEmailSender(new DummyVerificationEmailSender())
                .userRepository(new FakeUserRepository())
                .clockHolder(clockHolder)
                .build();

            User user = userService.login("foobar@localhost.com");
        });
    }

    @Test
    public void 로그인하려는_사용자_아직_가입_보류_상태이면_에러가_발생한다() {
        // given
        UserCreateDto userCreateDto = UserCreateDto.builder()
            .email("foobar@localhost.com")
            .nickname("foobar")
            .build();

        UserRepository userRepository = new FakeUserRepository();
        userRepository.save(User.builder()
            .id(1L)
            .email("foobar@localhost.com")
            .nickname("foobar")
            .status(UserStatus.PENDING) // 이 유저는 가입 보류 상태이다.
            .verificationCode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
            .build());

        final long current = 1672498800000L; // 2023-01-01 00:00:00+09:00
        ClockHolder clockHolder = new TestClockHolder();
        clockHolder.setCurrentTimestamp(current);

        // then
        assertThrows(UserIsNotActiveException.class, () -> {
            // when
            UserService userService = UserService.builder()
                .verificationEmailSender(new DummyVerificationEmailSender())
                .userRepository(userRepository)
                .clockHolder(clockHolder)
                .build();

            User user = userService.login("foobar@localhost.com");
        });
    }

    @Test
    public void 사용자가_로그인하면_마지막_로그인_시간이_기록된다() {
        // given
        UserCreateDto userCreateDto = UserCreateDto.builder()
            .email("foobar@localhost.com")
            .nickname("foobar")
            .build();

        final long current = 1672498800000L; // 2023-01-01 00:00:00+09:00
        ClockHolder clockHolder = new TestClockHolder();
        clockHolder.setCurrentTimestamp(current);

        // when
        UserService userService = UserService.builder()
            .verificationEmailSender(new DummyVerificationEmailSender())
            .userRepository(new FakeUserRepository())
            .clockHolder(clockHolder)
            .build();

        User user = userService.login("foobar@localhost.com");

        // then
        assertThat(user.getLastLoginTimestamp()).isEqualTo(1672498800000L);
    }
}

위 코드들은 결정적이며 무난하게 잘 동작한다. 하지만 아래와 같은 맹점이 있다.

 

1. 테스트 케이스에서 UserService 컴포넌트를 생성하고자 VerificationEmailSender의 대역인 DummyVerificationEmailSender 객체를 넣어주고 있다.

  login 메서드는 verificationEmailSender 객체를 필요로 하지 않기 때문에 불필요한 의존성이다.

 

2. verificationEmailSender 자리에 명시적으로 null을 지정한다. 'verificationEmailSender(null)'을 적지 않는 방법도 있다.

위 방법은 서비스의 취지를 어긋난 방향이므로 좋지 않다. 차라리 Dummy를 꾸준히 넣어주는 편이 낫다. 마찬가지로 UserService 컴포넌트에서 ClockHolder 객체도 필요 없다.

 

'이 컴포넌트에 뭔가 문제가 있는 것 같은데?'라는 생각은 테스트가 '이렇게 작성하면 불필요한 의존성이 생기니 이를 분리하는 것이 어떨까요?'라고 신호를 보내는 것이다.

 

UserService 컴포넌트 분리
- 회원가입하려는 사용자를 위한 UserRegister 컴포넌트
- 로그인하려는 사용자를 위한 AuthenticationService 컴포넌트
// UserService 컴포넌트를 분할해서 별도의 컴포넌트로 작성
@Service
@Builder
@RequiredArgsConstructor
public class UserRegister {

    private final UserRepository userRepository;
    private final VerificationEmailSender verificationEmailSender;

    @Transactional
    public User register(UserCreateDto userCreateDto) {
        if (userRepository.findByEmail(userCreateDto.getEmail()).isPresent()) {
            throw new EmailDuplicatedException();
        }

        User user = User.builder()
            .email(userCreateDto.getEmail())
            .nickname(userCreateDto.getNickname())
            .status(UserStatus.PENDING)
            .verificationCode(UUID.randomUUID().toString())
            .build();

        user = userRepository.save(user);
        verificationEmailSender.send(user);

        return user;
    }
}

@Service
@Builder
@RequiredArgsConstructor
public class AuthenticationService {

    private final UserRepository userRepository;
    private final ClockHolder clockHolder;

    @Transactional
    public User login(String email) {
        User user = userRepository.getByEmail(email);
        if (!user.isActiveStatus()) {
            throw new UserIsNotActiveException();
        }

        user.login(clockHolder);
        user = userRepository.save(user);

        return user;
    }
}

단일 책임 관점에서 UserService 컴포넌트를 분리하는 것이 맞다.

 

의존성을 고민한 결과, 단일 책임을 지키게 될 수 있다.
// 회원가입 테스트와 로그인 테스트를 분리
public class RegisterTest {

    @Test
    public void 중복된_이메일_회원가입_요청이_오면_에러가_발생한다() {
        // 코드 14.14를 참조해주세요.
    }

    @Test
    public void 이메일_회원가입을_하면_가입_보류_상태가_된다() {
        // 코드 14.14를 참조해주세요.
    }
}

public class AuthenticationTest {

    @Test
    public void 존재하지_않는_사용자에_로그인하려면_에러가_발생한다() {
        // 코드 16.2를 참조해주세요.
    }

    @Test
    public void 로그인하려는_사용자_아직_가입_보류_상태이면_에러가_발생한다() {
        // 코드 16.2를 참조해주세요.
    }

    @Test
    public void 사용자가_로그인하면_마지막_로그인_시간이_기록된다() {
        // 코드 16.2를 참조해주세요.
    }
}

UserService 컴포넌트를 분리하기 전에, UserServiceTest 또한 회원 가입을 위한 테스트와 로그인을 위한 테스트를 분리할 수 있다. 이것은 UserService가 두 개의 컴포넌트로 분리될 수 있음을 암시한다. 이런 식으로 테스트는 단일 책임 원칙을 고민하게 한다.

16.2 테스트와 ISP

테스트는 인터페이스 분리를 유도한다. 통합된 인터페이스보다 특수한 목적을 처리하는 인터페이스를 만드는 것은 인터페이스 분리 원칙(ISP)에 부합한다. 테스트가 기능 단위로 작성되는 경우가 많기 때문에 테스트를 작성함을써 기존에 코드를 작성하던 시각과 다른 시각을 제공한다.

 

예를 들면, EmailSender 인터페이스를 기능 단위로 나눠 다음과 같은 인터페이스로 분리한다. 역할이 세분화되어 있어 테스트하기 쉬울 것이다. 그리고 인터페이스를 세분화하더라도 구현 컴포넌트는 하나로 유지할 수 있다.

  • VerificationEmailSender
  • WelcomeEmailSender
  • AdvertisementEmailSender
  • ChargeEmailSender

16.3 테스트와 OCP, DIP

좋은 설계를 갖춘 시스템은 유연하다. 개방 폐쇄 원칙(OCP)을 따르는 시스템은 새로운 기능으로 확장해야 할 때 언제든 새로운 기능을 맞이할 준비가 되어 있고, 자유롭게 코드를 추가할 수 있으면서도 기존 코드를 건드리지 않아도 돼서 변경에는 닫혀 있다.

 

더 나아가 의존성 역전 원칙(DIP)을 추구하는 것도 시스템의 유연성을 높인다. 예를 들어, 회원가입 사례에서 UserService 컴포넌트가 VerificationEmailSender 인터페이스 같은 추상에 의존하는 것이 아니라, 구현체인 VerificationEmailSenderImpl 컴포넌트에 직접 의존하는 형태로 개발되어 있다. 이렇게 작성된 테스트는 구현체의 세부 구현에 의존하는 결과를 만들기 때문에 권장하지 않는다.

16.4 테스트와 LSP

'어떤 것을 테스트해야 할지?'에 대해서 Right-BICEP은 어떤 것을 테스트해야 하는지, CORRECT 원칙은 테스트 환경을 가장할 때 데이터의 경계 조건에는 어떤 것이 있는지를 알려준다.

 

Right-BICEP
- Right: 결과가 올바른지 확인해 봐야 한다.
- Boundary(경계): 경계 조건에서 코드가 정상적으로 동작하는지 확인해 봐야 한다.
- Cross-Check: 검증이 사용된 다른 수단이 있다면 이를 비교해 봐야 한다.
- Error Conditions(오류 상황): 오류 상황에서도 프로그램이 정상적으로 동작하는지 확인해 봐야 한다.
- Performance: 프로그램이 예상한 성능 수준을 유지하는지 확인해 봐야 한다.
CORRECT
- Conformance(적합성): 데이터 포맷이 제대로 적용되었는지 확인해야 한다.
- Ordering(정렬): 출력에 순서가 보장되어야 한다면 이를 확인해 봐야 한다.
- Range(범위): 입력에 허용된 값이 있다면 해당 값 안에서 정상 동작하는지 확인해 봐야 한다.
- Reference(존재): 조회, 입력, 상태에 따라 어떤 동작을 하는지 확인해 봐야 한다.
- Existence(존재): null 값이나 blank 같은 값을 입력할 때 정상 동작하는지 확인해 봐야 한다.
- Cardinality(요소 개수): 입력된 개수가 0, 1, 2, … 여러 개일 때 예상된 동작을 하는지 확인해 봐야 한다.
- Time(시간): 입력 처리 순서가 보장되는지 확인해 봐야 한다.

 

책에서 말하는 '어떤 것을 테스트해야 하느냐?'라는 질문에 더 어울리는 답변은 다음과 같다.

 

유지하고 싶은 상태가 있다면 테스트로 작성한다.
어떤 시스템에 응당 있어야 할 테스트 케이스가 없다면 해당 케이스는 시스템에서 유지하지 않아도 된다고 판단하는 것일 수 있다.

 

리스코프 치환 원칙(LSP)는 상속을 통해 파생된 클래스가 기본 클래스를 대체할 수 있어야 한다는 원칙이다. 이 원칙이 깨지는 상황은 크게 두 가지로 정리할 수 있다.

  1. 원칙을 지키고 있는 파생 클래스를 수정했더니 기본 클래스를 대체하지 못하는 경우
  2. 새로운 파생 클래스가 처음부터 기본 클래스를 대체하지 못하는 경우

테스트 코드에 템플릿 메서드 패턴을 적용하거나, 인터페이스를 만들어서 사용하는 방법, 매개변수 값 변경 테스트 방식(parameterized test)을 이용해 이를 해결할 수 있다.

 

이 글은 『자바/스프링 개발자를 위한 실용주의 프로그래밍』 책을 학습한 내용을 정리한 것입니다.
Comments