단위 테스트, 도대체 어디까지 작성해야 할까?

JWT 예제를 통한 단위 테스트의 경계 인식하기

나재은 (Jaeeun Na)
11 min readFeb 13, 2022
Photo by Will Francis on Unsplash

어가는 말

테스트에 대한 중요성이 강조되면서 많은 개발자가 좋은 테스트 코드를 작성하려고 노력하고 있다. 좋은 테스트 코드는 중요하다. 잘 작성된 테스트 코드는 개발자에게 다양한 피드백 도구가 된다. 하지만 엉망으로 작성된 테스트 코드는 오히려 우리의 발목을 잡는다. 코드를 조금 변경하니 마구잡이로 실패하는 테스트를 그냥 지워버리고 싶은 충동을 우리는 한 번쯤 겪어봤을 것이다.

좋은 테스트 코드를 위해 모든 코드를 테스트한다는 생각은 효율적이지 않다. 프로젝트에는 중요한 코드가 있고 그렇지 않은 코드가 있기 때문에, 모든 코드에 대해서 테스트를 작성하는 것은 비용대비 효과가 떨어진다. 우리는 중요한 부분에 대해서만 테스트를 작성하고 싶다. 코드를 가능한 한 조금만 작성하고 싶다.

그러나 누가 “이 코드는 중요하다.”고 말해주는가? 기획자? 개발자? 디자이너? 결국 코드를 작성하는 사람은 개발자 본인이므로 판단은 개발자의 몫이다. 개발자는 변경에 대한 미래를 대비하면서도 프로젝트 안에서 비즈니스 가치를 만드는 코드와 그렇지 않은 코드를 식별해야 하는 무거운 책임을 동시에 안고 있다.

여기서 “비즈니스 가치를 만드는 부분”이 앞서 말한 “중요한 부분”이라고 생각해볼 수 있다. 개발자는 무엇을 기준으로 중요한 부분을 식별할 수 있을까? 어떻게 하면 중요한 부분을 찾아낼 수 있을까? 여기서 단위 테스트가 도움을 줄 수 있을까? 이 글을 통해 도대체 어디까지를 단위 테스트로 바라봐야 하는지 말하고자 한다.

해결하려는 문제

본격적으로 들어가기 전에 해결하고자 하는 문제를 간단하게 소개하겠다.

  • 서버는 각 사용자를 token을 통해 유일하게 식별한다.
  • 서버는 이 token을 생성하는데 JWT(JSON Web Tokens — RFC 7519)를 사용한다.
  • 사용자는 id/pw 를 입력하여 본인을 식별할 수 있는 token 을 얻을 수 있다.
  • token에는 유효시간이 존재하며, 그 기간만 사용할 수 있다.
  • 사용자 중에는 admin이 있으며, 보안을 강화하기 위해 admintoken 유효기간은 비교적 더 짧다.
  • 사용자는 pc/mobile 둘 중 하나에서 로그인 할 수 있으며, 접속하는 기기에 따라 유효시간이 다를 수 있다.

편의상 이해하는데 필요한 부분만 코드를 작성하였다.
(import 등 코드가 완전하지 않을 수 있다)

TokenProvider 클래스가 가장 중요해보인다. 따라서 첫 번째 질문은 이것이다.

첫 번째 질문: 이 클래스를 테스트해야 할까?

newToken() 메서드는 token에 대한 보안정책을 가지고 있으며, 이 정책은 요청자가 관리자인지 여부, 접속한 기기 여부 등을 확인하고 있다.

우리는 앞서 중요한 코드에 대해서만 테스트를 작성하기로 했다. 따라서 newToken() 메서드가 보안정책을 갖고 있다고 해도, 이 내용이 중요한지 여부는 환경과 상황에 따라 다르다.

무엇이 중요한 코드인지 구분하는 방법의 하나는, 코드가 다음 질문들에 대해 “그렇다” 라고 말할 수 있는지를 확인해보는 것이다.

  • “애플리케이션이 해결하려는 문제를 이 코드가 직접적으로 다루고 있는가?”
  • “애플리케이션이 해결하려는 문제와 이 코드는 관련성이 높은가?”
  • “애플리케이션이 정상적인 동작을 하기 위해서 필요한 정책, 규칙, 설정을 이 코드가 조작, 제어, 생성, 수정, 삭제하고 있는가?”
  • “기획자 혹은 이해관계자의 대화에서 나온 개념 혹은 용어가 이 코드에 들어가 있는가?”
  • “이 코드가 실제 서비스에 적용된다면 매출 상승, 보안 강화, 사용자 증가 등 실질적인 비즈니스 가치에 도움이 되는가?”
  • “이 코드가 다른 곳에 없는 이 애플리케이션만의 차별화된 부분을 다루고 있는가?”

“그렇다”라고 대답한 개수가 많을수록 중요한 코드일 가능성이 높다.

TokenProvider 는 직접 token을 생성하여 관련 보안 개념을 구체화한다. token은 이후 수행될 모든 API에서 직/간접적으로 사용되며 사용자를 유일하게 식별하는 역할을 한다. token을 생성/조작하는 로직이 제대로 구현되지 않는다면 보안 측면에서도 심각한 위협이 될 수 있다.

따라서 TokenProvider는 중요한 로직이다. 다만 당신의 애플리케이션에서는 다를 수 있다는 점을 기억하자.

답변: 테스트하려는 코드가 애플리케이션이 해결하려는 문제와 얼마나 밀접한 연관성을 가졌는지 확인하라.

일단 token 생성에 성공하는 경우에 대해서 먼저 테스트 코드를 작성하기로 했다.
다음은 첫 번째로 시도한 테스트 코드이다.

첫 번째 테스트 코드

assertThat() 을 작성할 즈음에 개발자는 고민에 빠지게 된다. (accessToken), (refreshToken) 에 각각 어떤 값을 넣어야 할까?

어떤 값을 넣어야 하는지 알 수 없으므로 개발자는 테스트를 계속 작성할 수 없다. 실제로 메서드를 실행하지 않고 테스트를 완성할 수 있을까? 실제로 메서드를 실행해보고 그 결괏값을 집어넣으면, 테스트 데이터를 제대로 입력했다고 말할 수 있을까? 어쩌면, 애초에 이 테스트를 작성하기로 결정한 게 문제였을까? 왜 개발자는 테스트를 완성할 수 없을까? 이런 문제가 발생하는 근본 원인이 무엇일까?

두 번째 질문: 단위 테스트를 완성할 수 없는 이유는 무엇일까?

이어서 보기 전에 잠시 멈춰서 이유를 생각해보자. 지금 이 글을 읽고 있는 사람은 테스트에 많은 관심이 있을 것이다. 독자 본인이 저 상황이라고 가정하고, 만약 나라면 어떻게 했을지 1분만 고민해보도록 하자.

당신의 결론은 무엇인가? 다양한 이유가 있겠지만, 여기서 테스트를 완성할 수 없는 이유는 Token 클래스 내부에 숨겨진 의존성이 존재하기 때문이다.

답변: 로직 내부 숨겨진 의존성 때문이다.

Token 클래스를 다시 확인해보자.

accessToken, refreshTokenString 타입이지만 그 내용은 JWT 이다.

개념적으로는 “사용자를 식별하는데 사용하는 정보” 일 뿐이지만, 기술적으로는 추상적인 그 개념을 JWT가 변환하여 String 타입으로 구체화하는 것이다. 따라서 이 클래스에는 JWT에 대한 숨겨진 의존성이 존재한다.

그렇다면 우리는 다음 중 무엇을 테스트해야 하는가?
정답을 하나 골라보자.

  1. 사용자의 token 정보 (id, 만료 기간 등)
  2. JWT (문자열 값)
  3. 둘 다

정답은 1번이다. 우리는 JWT library를 테스트하지 않을 것이며, 단지 token이 갖게 될 정보가 무엇인지를 검증하고 싶은 것이다. 우리는 중요한 부분만 테스트하고 싶다. 이제 token이 갖는 정보만 테스트를 할 수 있도록 다시 작성한 두 번째 테스트 코드를 보자.

두 번째 테스트 코드

크게 세 가지가 달라졌다.

# 1. 현재 시간에 대한 추상화 도입: TimeServer

현재 시각에 대한 의존성을 분리하고자 이를 추상화해서 TimeServer interface를 도입, 테스트에서 시간에 대한 제어를 가능하도록 만들었다.

# 2. JWT 에 대한 로직만 JwtProvider 클래스로 분리

우리는 JWT library를 테스트하지 않을 것이라고 했다. 따라서 TokenProvider에서 JWT에 대한 로직 모두를 JwtProvider으로 이동했다. 더 이상 JWT와 직/간접적으로 의존하는 로직은 TokenProvider에 없다.

# 3. JwtProvider의 메서드는 Date 가 아니라 Instant를 이해한다.

JwtProvider의 존재 이유는 무엇인가? 이 클래스는 TokenProvider 만을 위한 것이다. TokenProviderDate가 아닌 Instant를 사용하기를 원하므로 JwtProviderInstant를 이해해야 한다. Date <-> Instant로 상호 변환하는 것은 중요한 로직이 아니므로 이를 테스트하지 않을 것이며, 테스트해서도 안된다.

리팩토링의 핵심이 mocking이 아니라는 것에 주의하라. mocking을 사용하지 않고 TokenInfo 같이 정보만을 담는 클래스를 추가해서 newToken()TokenInfo를 반환하고, 이를 테스트하게 변경해도 된다. 다만 요구사항에 존재하지 않는 클래스를 추가하는 것은 최대한 피하고 싶었고, 가장 단순하게 테스트를 작성하고 싶었다. 그리고 이것이 일반적으로 통용되는 KISS(Keep It Simple, Stupid) 원칙에도 부합한다.

이제 TokenProvidertoken 정책을 결정하기만 하며, token이 어떻게 변환되어 사용자에게 전달되는지는 신경쓰지 않는다. 형식을 지정하는 부분은 온전히 JwtProvider의 책임으로 이동했다.

여기까지 흐름을 한 번 살펴보자.

개발자는 코드를 작성했고
단위 테스트를 시도했으나 실패했고
숨겨진 의존성을 찾아내 분리한 뒤
테스트가 가능하도록 만들고 보니
자연스럽게 클래스의 책임이 분리되었다.

여기까지 오니 다음과 같은 의문이 든다.

세 번째 질문: 그렇다면 처음 프로덕션 코드를 작성했을 때는 숨겨진 의존성이 왜 문제가 되지 않았을까?

테스트가 아닌 프로덕션 코드를 작성했을 때는 왜 문제가 되지 않았을까? 프로덕션 코드에서는 어디서도 Token 클래스를 직접적으로 사용한 곳이 없기 때문이다.

TokenController에서 Token를 전달하기만 하지, 개발자가 실제로 그 내용을 가지고 조작하는 로직을 작성하지 않았기 때문에 문제가 없었던 것이다. 그 내용을 조작하려면 항상 JWT 를 사용해서 역변환을 거쳤기 때문에 문제가 드러나지 않은 것이다. 위에서 언급한 3. JwtProvider의 메서드는 Date 가 아니라 Instant를 이해한다. 의 맥락과 동일하다.

답변: 개발자가 코드를 사용하는 입장(클라이언트)이 되어보지 않았기 때문이다.

위 내용은 기회가 된다면 다른 글에서 더 자세히 다룰 예정이다.

후! 마무리가 얼마 남지 않았다.
조금만 더 힘내서 남은 질문들에 대해 답변을 해보도록 하자.

남겨진 의문점들

# 그럼 JwtProvider는 어쩌고? 이건 테스트 하지 않을건가?

JwtProvider는 테스트하지 않는다. 라이브러리를 다시 테스트할 필요가 있을까? 믿고 써야 하지 않을까? 믿고 쓸 수 없더라도 최소한 바퀴를 재발명하지는 말자. 그것은 통합 테스트의 영역이다. 단위 테스트는, 중요한 부분만 테스트해야만 한다. token의 형태가 어떻게 되는지 중요할까? “서버는 각 사용자를 유일하게 식별해야 한다” 라는 요구사항에 대해서, token이 어떤 형식으로 전달되는지가 어떤 의미가 있을까?

그래도 아직 JwtProvider를 테스트하고 싶은가? 그렇다면 혹시 이 클래스 안에 token 정책을 결정하는 로직이 조금이라도 남아있는지 확인하라. 만약 있다면 TokenProvider 으로 모두 옮겨라. JwtProvider를 테스트해야한다는 생각이 들지 않을때까지 모두 옮기도록 하자.

또한 JwtProviderpublic이 아님에 주의하라. 이 클래스는 TokenProvider와 동일한 수준의 패키지에 위치해야 한다. 또한 구현 세부사항에 해당하므로 이 클래스의 존재조차 패키지 외부로 드러나서는 안된다.

# mocking을 과도하게 쓰는거 아닌가? 다른 구조를 도입하면 mocking을 제거할 수 있지 않을까?

잘못된 mocking은 좋지 않다. 제대로 쓴 mocking은 옳다. 정답은 없다. TokenPolicy 같은 객체를 추가해도 되었을 것이다. 앞서 말한 TokenInfo도 나쁘지 않다. 그러나 비교적 간단한 기능에 비해 복잡한 클래스 구조를 가지게 되는 것은 가급적 피하고 싶다. mocking이 절대악은 아니다. 모든 도구에는 장단점이 있으며, mocking도 마찬가지다. 도구에는 잘못이 없으며 제대로 알고 사용하는 사람과 그렇지 않은 사람만 있다.

반환 값을 테스트하는 것은 간단하고, 이해하기 쉽고, 편리하다. mocking을 통해 클래스의 상호작용을 테스트하는 것은 비교적 복잡하고, 신경쓸 것이 많고, 도구를 학습해야 한다. 이는 클래스 설계의 문제이며 글의 주제를 벗어난다. 선택은 여러분의 몫이며 이 글은 테스트를 “어디까지” 작성해야 하는지에 대한 경계를 다루는 것이지, “어떻게” 테스트해야 하는지를 다루지 않는다.

마치는 글

이제 정리할 시간이다.
처음으로 돌아가보자. 무엇이 중요한 부분이었는가?

  • 중요한 부분: token에 담기는 정보가 무엇인가?
  • 중요하지 않은 부분: token이 어떤 형태를 가지는가?

TokenProvider는 중요한 부분과 중요하지 않은 부분을 모두 가지고 있었기 때문에 단위 테스트를 작성할 수 없었던 것이고, 리팩토링 이후 중요한 부분만을 결정하도록 변경되었다. 이 과정에서 개발자는 숨겨진 의존성이 무엇인지 파악할 수 있어야 했다. 무엇이 세부사항인지 결정해야 했다. 미래에도 세부사항으로 남아있을 부분을 예상해야 했다. 예측 정확도를 높이기 위해 요구사항을 깊게 이해해야 했다.

하고 싶은 이야기는 여기까지다. 내용을 이해하는데 위의 예제가 얼마나 도움이 되었을지 모르겠다. 단위 테스트를 적극적으로 도입하면서 겪게 되는 여러분들의 이야기를 기다리겠다.

나의 글을 통해 더 많은 개발자들이 단위 테스트를 이해하는데 도움이 되기를 바란다.

피드백은 언제나 환영입니다.
감사합니다. ( _ _)

--

--

나재은 (Jaeeun Na)

품질 높은 소프트웨어의 가치를 믿습니다. 빠르게 가는 유일한 방법은 제대로 가는 것이라 생각합니다. 현재 토스페이먼츠에서 데브옵스 개발자로 일하고 있습니다.