테스트 주도 개발

테스트 주도 개발을 하자는 이야기는 아닙니다. 테스트 주도 개발은 좋은 개발 방법론이라고 생각은 합니다. 하지만 제가 이번 글에서 말하고자 하는 것은 테스트 주도 개발이 아니라 테스트 가능한 코드를 작성하는 것입니다. 글이 끝나갈 때 쯤엔 마치 테스트 주도 개발이 옳다는 듯이 얘기하고 있을 가능성도 충분히 높습니다.

테스트 가능한 코드

테스트 가능한 코드는 충분히 우리가 컨트롤 할 수 있는 정도의 크기로 기능과 코드를 나누어 지고, 추상화되어 있어야 합니다. 그리고 이 작은 검증된 테스트들은 코드가 모여서 만들어진 더욱 상위의 테스트에 대한 검증을 수월하게 합니다. 그에 대한 몇가지 주제와 예시를 작성해보겠습니다.

모듈 분리

어떤 웹 서비스를 개발한다고 가정해봅시다. 여러분들은 가장 간단한 형태의 회원가입을 만들어야합니다. 이 회원가입 과정은 사용자에게서 이메일과 이름, 비밀번호를 받습니다. 그리고 입력받은 이메일에 랜덤하게 생성된 인증 번호를 발송하고, 사용자가 인증 번호를 입력하면 회원가입이 완료됩니다. 이 과정은 크게 2개의 유스케이스로 작성될 수 있습니다.

  1. 회원 가입 정보 입력
  2. 사용자 이메일 인증

그러면 혹시, 설마, 그럴리 없겠지만, rest api로 만든다고 했을 때, 회원 가입 정보 입력을 SignUp이란 핸들러 하나를 만들고, 모든 로직을 거기에 다 넣으시진 않겠죠? 그렇게 작성하신다면, 단일 테스트 지점은 SignUp이란 핸들러가 되어 버립니다. 그럼 이건 테스트 가능한 코드일까요?

회원 가입 과정 안에도 저는 최소한 아래 함수를 분리할 수 있다고 생각합니다.

  1. 이메일 검증: 올바른 이메일로 입력이 되었는 가
  2. 중복 이메일 검증: 이미 가입된 이메일인가
  3. 사용자 이름 검증: 서비스 정책에 맞는 이름인가
  4. 중복 사용자 이름 검증: 이미 가입된 사용자 이름인가
  5. 패스워드 검증: 서비스 정책에 맞는 패스워드인가

필요에 따라, IP가 제재를 당한 기록이 있다거나, 이메일이 벤을 당한 적이 있다거나, 하는 부가적인 요소도 있을 수 있지만, 여기서는 이정도만 다루겠습니다.

최소 5개의 위 함수들을 사용해서 SignUp 핸들러를 작성하면, 단일 테스트 지점은 SignUp이 아니라, 위 5개의 함수가 됩니다. 그리고 이 함수들이 테스트를 통과한다면, 우리는 SignUp 핸들러가 내부 로직적으로는 정상적으로 동작하니, 핸들러로써 제대로 동작하는 지만 테스트하면 됩니다.

단일 책임 원칙

모듈 분리와 일맥상통 하는 부분입니다.

위와 동일하게 웹 서비스를 만드는 걸 가정해보겠습니다. 게시글, 댓글, 닉네임, 이메일, 등등 모든 부분에서 들어가서는 안되는 문자나 단어들을 필터링 해야하는 상황이 온다고 가정해봅시다. 회원가입할 때 필터링을 하는 코드를 같이 작성한다거나, 게시글을 등록할 때 필터링 하는 코드를 같이 작성할 수 있을 것입니다. 하지만 이렇게 되면 문자열 필터링도 각각 회원가입이나 게시글 등록 테스트 중에 파악해야합니다.

당연하게도 이 상황에서 또한, 문자열 필터링에 대한 부분을 따로 분리합니다. 문자열 처리만을 다루는 패키지를 작성한다거나, 문자열 필터링을 담당하는 객체를 만들어서 단일 책임을 가지게 한다면 해당 책임에 대해서만 테스트를 하면 되니 훨씬 단일 테스트 지점이 작아지고 관리하기 쉬워집니다. 프로젝트 구성 전반이 책임을 잘 분리해서 구성되어 있다면, 새로운 책임과 그에 따른 테스트를 추가하는 것이 어렵지 않을 것입니다.

의존성 주입

다시 회원가입을 구현해보겠습니다. 아까는 작성하지 않았지만, 입력받은 정보를 RDB에 작성하기도 해야합니다. 또 이메일 인증 번호는 이메일을 키로, 인증 번호를 값으로 하며, TTL을 가지고 저장해 놓으면 되니, 레디스에 저장하도록 합니다. 그러면 SignUp 핸들러는 RDB와 레디스 커넥션을 어디에서 참조해야 할까요?

가장 쉬운 방법은 전역 변수를 사용하는 것일 겁니다. 물론 프레임워크의 힘을 빌려서, asp .net core의 경우만 해도 싱글톤 객체를 서비스에 추가한 후 매번 자동으로 주입할 수 있습니다. 스프링이나 장고도 그러합니다. 하지만 모든 상황에서 그들의 힘을 빌릴 수는 없으니 로우하게 생각해보겠습니다.

그래서, 전역 변수를 사용하면 테스트하기 어려워집니다. 덤으로 프로덕션에서 사이드 이펙트로 의도치 않은 상황에 죽을 가능성까지 안고 가야합니다. 회원가입이 정상적으로 동작하는 지 확인하기 위해 우리는 이 시점에 할 필요도 없는 RDB와 레디스의 정상 동작 여부까지, 직접 인스턴스를 띄워서 확인해야합니다.

그럼 다시 생각해서, 회원가입할 때 우리는 RDB와 레디스에 쿼리를 요청할 수 있는 클라이언트들을 호출할 때나 핸들러를 감싸는 객체를 생성할 때 넣어줍니다. 패러미터로 클라이언트를 주입해주게 되면 각 테스트를 진행할 때, 외부 커넥션이나 객체의 실제 구현에 구애받지 않고 동작을 테스트할 수 있습니다.

가장 일반적으로 해당 패러미터 타입의 가짜(Mock) 구현체를 넘겨주어, 대신 처리하도록 합니다. 예를 들어, 데이터베이스의 목 클라이언트의 경우엔 단순 인메모리에 값을 넣고 빼는 걸 할 수도 있죠.

인터페이스 추상화

인터페이스 추상화는 의존성 주입과 비슷한 내용입니다.

회원가입 핸들러에 들어갈 RDB와 레디스 클라이언트를 다시 생각해보겠습니다. 당연히 raw query를 넘겨서 해결할 일은 없을 테고, func (c *Client) SetUser(username string, email string, password string) error같은 메서드를 만들어서 처리하게 될 것입니다. 레디스의 경우에도 func (r *RedisClient) SetValidationCode(email string, code string, ttl time.Duration)같은 형식으로 메서드를 만들 겁니다.

그럼 의존성 주입으로 처리해서 테스트할 때는 회원가입 핸들러에 가짜 클라이언트를 넣어주는데, 어떤 식으로 넣어주어야 하는지가 걸리게 됩니다. 실 구현체의 타입을 아무런 추상화 없이 패터미터에 넣고, 다른 가짜 구현체를 대신 넣기는 어렵습니다. 패러미터가 함수 시그니처로 되어 있고, 일급 객체나 함수 포인터를 넣어준다면 가능하겠지만요.

하지만 고수준 언어를 사용하는 입장에서 좀 더 우아하게 가짜 구현체를 넣어주고 싶습니다. 그렇게 하기 위해 존재하는 것이, 인터페이스 추상화라고 생각합니다. 특정 동작을 하는(메서드를 가지고 있는) 구현체라면 모두 해당 인터페이스를 구현하고 있기에 문제 없다라고 보는 것입니다. 저는 덕 타이핑 영역으로만 글을 쓰겠습니다.

type DatabaseClient interface {
    SetUser(username string, email string, password string) error
    GetUserByEmail(email string) (username string, password string, err error)
    GetUserByName(username string) (email string, password string, err error)
    RemoveUserByEmail(email string) error
    RemoveUserByName(username string) error
}

그 어떤 데이터베이스 커넥션을 가진 클라이언트든 위 DatabaseClient 인터페이스만 구현하면 인터페이스에 정의된 메서드를 호출하여 처리할 수 있습니다. 그러면 진짜 구현체라면 내부에 외부 데이터베이스에 대한 커넥션을 가지고 실제로 처리하도록 작업하면 되고, 테스트를 위한 가짜 구현체라면 내부에 map[string]User같은 해시맵 형식의 자료구조를 사용하여 처리할 수도 있습니다.

이거 완전…

OOP의 5 principles와 유사하지 않나요?

저는 객체지향의 5가지 원칙이 특정 분야를 제외하고, 일반적인 프로젝트를 구성하는 대부분의 경우의 기초라고 생각합니다.