Test6분 읽기

단위 테스트 왜 짬? 시간이 남아도냐?

44

단위테스트 왜 짜는지 모르겠다.
                                                - 김민우, 1주차 멘토링 첫날


나는 E2E 테스트를 고객 관점에서 주로 짜왔다.
나는 나름 실리주의 성향이 조금 있어, E2E 테스트를 활용하면 적은 수의 테스트를 작성하면서도, 유지보수 쉽게 검증할 수 있겠다고 생각했기 때문이다.

잘 이해가 되지 않을 것 같아, E2E 테스트 시나리오를 전달하면 다음과 같다.

text

유저가 정상 로그인 Id와 정상 비밀번호 등을 입력하면 회원가입이 성공한다.

유저가 비밀번호를 틀리게 입력하면 401 unAuthorized를 반환한다.

유저가 같은 이메일로 가입을 시도하면 409 Conflict를 반환한다.

위와 같이 테스트를 작성하면 실제 API를 타서 동작하기 때문에 시나리오만 잘 정의하면 테스트 예외를 놓치지 않으면서도 정말 또렷하게 테스트가 성공한 것을 볼 수 있다.
위와 더불어 리팩토링 할 때도 강점을 가진다.

내가 만약 UserServiceUserRegistrationServiceUserAuthenticationService 로 쪼갠다고 하자.
혹은 이메일 형식 검증을 UserService 에서 Email VO 로 옮긴다면?
혹은 중복 체크 메서드(existsByLoginId) 를 들어낸다면?

위 시나리오 테스트들은 한 줄도 안 바뀐다. 사용자가 보는 결과가 그대로이기 때문이다.

그런데 같은 검증을 단위 테스트로 짰다면 어떻게 될까?

  • UserServiceTest → 두 클래스로 쪼개졌으니 둘로 갈아엎기
  • EmailValidatorTest → 검증 위치가 VO 로 갔으니 통째로 제거
  • EmailVOTest → 새로 생성
  • UserRepositoryTest → 중복체크가 빠졌으니 일부 제거

리팩토링 한번에 단위 테스트 네 곳을 수정해야 한다.

검증 가치 대비 유지보수 비용이 너무 크지 않나? 한 시나리오를 N군데에 박아두고 고치는게 그냥 싫었다.(귀찮아..)

물론 E2E를 고집하는 방식도 좋은 점만 있는게 아니다. 내가 납득할 수 있는 찜찜함이 한가지가 있다. 바로 테스트 회귀이다.

E2E 테스트는 깨졌을 때 어디서 깨졌는지 단위테스트에 비해 잘 안 보인다.

예를 들어 중복 이메일이면 409가 떨어져야하는데 200이 떨어진다. 거기까지는 알 수 있으나, 정확히 어느 분기에서 났는지, 이메일 정규식이 바뀐건지 등 응답값만으로는 알 수 없다.

하지만 단위테스트를 쓴다면 throwsConflict_whenEmailAlreadyExists가 깨졌다면 곧바로 이메일 중복처리에서 깨졌다는 것을 알 수 있다.

이게 단위 테스트의 강점이고.. 깨진 위치를 즉시 가리킨다.

그럼에도 E2E를 선호했던 이유는 어쨌든 테스트를 촘촘히 작성하면 테스트가 어디서 깨졌는지 파악이 용이하고 그마저도 AI를 쓰면 어디서 깨졌는지 금방 찾아낼 수 있기 때문이다. (단 이는 서비스의 복잡도에 따라 서로 의견이 다를 수 있는 부분이니 경계해서 듣길 바란다)


여기까지가 기존의 내 입장이었다.

하지만 나는 그 결론을 받아들이지 않았다. 똥고집이긴 한듯..

위치를 즉시 가리키는 게 그렇게 중요한가? AI 가 스택 트레이스 보고 어느 분기에서 깨졌는지 다 찾아주는데? 한 시나리오를 N 군데 박는 비용은 그대로인데?

그래도 자바 진영에서 단위 테스트가 디폴트인 데에는 이유가 있겠지 라고 의심하면서 코드를 짰다.
그러다 세 가지 정도 내가 납득 가능한 방향이 있었다.

그 기록을 시작해 보자.


첫 번째. AI 가 리팩토링 비용을 줄였다

내가 단위 테스트를 의심한 가장 큰 이유는 리팩토링 비용이었다.

도입부에서 보였듯, 리팩토링 한 번에 단위 테스트 네 곳이 출렁인다. UserService 가 두 개로 쪼개지면 그 안의 단위 테스트도 두 개로 갈아엎어야 하고, 검증을 VO 로 끌어올리면 흩어진 단위 테스트는 의미가 사라진다. 한 시나리오를 N 군데에 박아두고 으으 너무 귀찮다.

그런데 이 비용이 많이 약해졌다.

UserService 를 둘로 쪼개면, AI가 단위 테스트도 그 자리에서 두 개로 분리해준다.
검증을 VO 로 옮기면, 흩어진 단위 테스트를 정리하고 VO 단위 테스트를 새로 만들어준다. 수동으로 N개를 고치던게 AI 에게 한 번 맡기는 걸로 줄었다.

이건 단위 테스트에 대한 의심을 가장 약하게 만든 자리다. "리팩토링 비용이 크다" 라는 명제 자체는 여전히 참이지만, 그 비용을 누가 치르느냐가 바뀌었다.


두 번째 E2E 의 비용은 작성이 아니라 '실행' 이다.

이미지

E2E 한 번 돌리려면 Spring 컨텍스트를 띄우고, DB 연결을 열고, HTTP 서버를 켠다.

이게 명확한 사실이지만, 그래서 실제로 테스트 여러 개를 짜서 한번 직접 시간 지표를 측정해보려 했다.
같은 검증을 E2E와 단위 두 레벨로 짜고 여러 개수 표본을 도출해 시간을 비교해보려고 했으나,

짜다보니 비교 자체가 공정하지 않다는 걸 깨달았다.

E2E는 시나리오 단위로 적게 짠다. (회원가입 정상/중복/형식 위반 등)
단위 테스트는 단위 하나하나 많이 짜게된다. 즉 같은 개수로 비교는 옳지 않다.

그래서 측정은 포기했다.

다만 한 가지는 확실한 건 E2E가 더 느리다는 거다. Spring 컨텍스트 올리는 비용이 ms 단위로 끝날 리 없다.
E2E를 아무리 탄탄히 짜도, 단위 테스트 한 개보다 빠를 수는 없다.

그래서 AI 덕분에 코드 부담이 적은 지금, 단위 테스트의 이점이 커진다.


세 번째. 단위 테스트는 설계를 돕는 거울이다

이게 내가 느낀 가장 큰 강점이다.

TDD 원칙대로 테스트를 먼저 짜려고 했다. User.changePassword 가 검증해야 할 분기를 머릿속에 그렸다

  • 인증 (현재 PW 일치)
  • 같음 비번 거부 (새 PW ≠ 현재 PW)
  • 형식 위반
  • 생년월일 포함.

그런데 코드를 보니 이런 모양이었다.

hljs java
public void changePassword(String newEncodedPassword) {
    if (newEncodedPassword == null || newEncodedPassword.isBlank()) {
        throw new CoreException(BAD_REQUEST, "비밀번호는 필수입니다.");
    }
    this.encodedPassword = newEncodedPassword;
}

User가 받은 값을 저장만 한다.
내가 검증하고 싶었던 네 정책은 다 메서드 밖에 흩어져 있었다. 이 시그니처로는 User 단위 테스트로 "받은 값이 저장되는가" 만 검증할 수 있다.
나머지 분기는 다 Service 단위 테스트로 가야 한다.

여기서 설계의 이상한 직관이 느껴졌다.
내가 짜려던 단위 테스트가 그 자리에서 풀리지 않는다는 것, 책임 위치가 틀렸다는 직관이었다.

hljs java
public void changePassword(String currentPassword, String newPassword, PasswordEncoder encoder) {
    if (!encoder.matches(currentPassword, this.encodedPassword)) throw UNAUTHORIZED;
    if (currentPassword.equals(newPassword)) throw BAD_REQUEST;
    Password validated = Password.of(newPassword, new Birth(this.birth));
    this.encodedPassword = encoder.encode(validated.value());
}

이제 머릿속에 그렸던 네 분기가 User 단위 테스트로 자연스럽게 풀린다. 정책 네 개가 User 한 자리에 응집됐다.

  • 단위 테스트가 그 자리에서 풀리지 않으면 → 책임 위치가 틀린 것
  • 단위 테스트가 자연스럽게 풀리면 → 책임이 그 자리에 응집된 것

단위 테스트는 "이 클래스가 자기 책임을 정말 갖고 있는가" 를 생각하게 해주었다.

이건 E2E 가 줄 수 없는 강점이다. E2E 는 결과만 본다. 어디서 검증하는지는 안 묻는다. 단위 테스트는 그 자리를 즉시 가리킨다.

가능한 한 단위로 짜는 건, 비용 절감이 아니라 내가 좋은 코드를 짜고 있는가 의 신호라는 것을 체감했다.


그래서 지금은?

결론은 무조건 단위 테스트가 아니라 구조의 명확성을 기준으로 가른다.
구조가 명확한 곳에서는 단위 테스트를 기반으로 간다 (E2E를 안 짠다는 건 아니다).
책임이 제자리에 있으면 리팩토링이 나도 테스트가 사방으로 출렁이지 않고, 단위 테스트가 그 자리에서 풀리는지가 곧 설계가 제대로 섰는지의 신호가 된다.
반대로 구조가 명확하지 않고 계속 바뀌어서 테스트 유지보수 자체가 스트레스가 되는 곳에서는 E2E의 비중을 높인다.
그리고 구조를 명확히 짤 수 있다는 건 그 조직의 실력과 성숙도가 받쳐준다는 뜻이기도 하다.
그래서 나는 트레이드오프를 이렇게 결정했다

단위 테스트를 디폴트로 두되, 그 전제는 "구조가 명확하다 / 명확히 짤 수 있는 조직이다"라는 것.
그 전제가 흔들리면 E2E로 무게중심을 옮긴다.

그리고 가장 중요한 것은 어떤 테스트를 활용하든 요구사항을 정확히 지키는 테스트가 가장 중요하다.


TL;DR
단위 테스트의 리팩토링 비용이 AI로 줄어든 지금, 단위 테스트는 단순 검증을 넘어 "책임이 제자리에 있는가"를 비추는 설계의 강점을 갖는다.
리팩토링 비용과 좋은 설계 등을 고려하여 어떤 테스트를 활용할지 적절히 선택하자.

Comments 0

0/500

No comments yet. Be the first to leave one.

인기 글