좋은 테스트 코드는 어떻게 만들까?

Youngoh Kim
Spoonlabs
Published in
8 min readApr 12, 2021

with Clean Architecture(Non-Dependency)

스푼 라디오 메시징팀에서 채팅 서버 개발을 맡고 있는 Elliott입니다.

지난번에 작성한 블로그 내용의 연장선으로 어떤 내용을 공유하는 것이 좋을까 고민을 하다 신뢰성 있는 코드 개발에 필수적인 올바른 테스트 코드를 작성하는 방법에 대하여 공유하려고 합니다.

Clean Architecture

Clean Architecture란 Robert C. Martin이라는 개발자가 우리가 알고 있는 다양한 코드 작성에서 주의해야 할 SOLID 원칙 같은 내용들을 총집합하여 정리한 것이라고 생각하면 된다. 이 아키텍쳐를 구현하는 데는 언어, 프레임워크에 대한 제한이 없다.

필자의 경우 웹 개발에 한해서는 항상 CleanArchitecture 예찬론자이다.

그 이유는 이전 블로그에서 작성한 내용대로 이 패턴을 잘 활용해서 개발을 진행하면서 애플리케이션의 안정성과 신뢰도를 크게 상승시킨 경험이 있고, 이 패턴의 가장 궁극적인 용도는 테스트 코드를 종속성에서 자유롭게 하여 올바른 테스트 코드를 작성할 수 있도록 자연스럽게 유도할 수 있다는 장점 때문이다. 한번 익숙해지기 시작하면 매우 편리하고 안정적으로 프로젝트에 적용할 수 있다.

clean architecture diagram

Clean Architecture 의 콘셉트는 위의 그림과 같은데 애플리케이션 계층에서 종속성은 항상 한 방향으로만 발생하고 바깥쪽에서는 어떤 상황이 발생하더라도 전혀 상관없다. 우리는 그저 이 패턴대로 서비스를 계층별로 나누어 작성하고 중요한 구현부만 바꿔가서 사용하면 되는 것이다.

Example

├── app
│ ├── domain
│ │ ├── model
│ │ │ └── user.go
│ │ ├── repository
│ │ │ └── user_repository.go
│ │ └── service
│ │ └── user_service.go
│ ├── interface
│ │ └── memory
│ │ └── user_repository.go
│ └── usecase
│ ├── user_usecase.go
│ └── user_usecase_test.go
├── cmd
│ └── api
│ └── main.go
├── go.mod
└── go.sum

Entities

우리는 API를 개발할 때 API에서 다룰 데이터, 데이터 처리, 제약조건 등을 설계하게 되는데 이에 해당하는 계층이 Entity layer이다.

Entity layer에는 크게 3 개의 파트가 존재하는데 Model, Repository, Service가 그것들이다.

  • Model: 모델은 말 그대로 애플리케이션에서 다룰 데이터의 구조를 의미한다. 예를 들면 데이터베이스의 테이블 구조를 떠올리면 된다.
  • Repository: 레포지토리는 모델에 어떤 동작을 처리할 것인가를 의미하는데 모델에 대한 CRUD와 같은 방식을 의미하며 인터페이스로 작성된다.
  • Service: 서비스는 비즈니스 로직에서 모델과 레포지토리를 이용한 동작을 할 때의 제약조건을 설정할 수 있는데 이것은 유저 이름 또는 이메일의 형식이나 중복체크 등이 될 수 있다.

Model

user model

Model은 캡슐화를 위하여 일종의 팩토리 패턴과 모델의 필드값을 가져오기 위한 메서드들로 구성한다. 이것은 API에서 자주 사용되는 ORM으로도 적절하게 사용할 수 있고 단순한 JSON의 형태로도 사용할 수 있다.

Repository

user repository

UserRepository의 경우 인터페이스로 만드는데 그 이유는 안쪽으로의 종속성만을 가지기 위함이기도 하지만 repository가 하는 일은 데이터에 대한 어떤 동작을 할 것인가를 정의하기 때문에 이를 인터페이스로 구현해두면 어떤 데이터베이스를 쓰더라도 모두 유연하게 대응될 수 있다.

Service

user service

Service layer는 위와 같이 구현하는데 레포지토리에서 어떤 데이터베이스를 사용하거나 로직과는 상관없이 사용 목적에 맞는 함수를 불러와 사용하기 때문에 제약조건과 관련된 내용만 추가해서 사용할 수 있다.

Use Cases

Usecase는 애플리케이션에서 사용될 비즈니스 로직을 작성한다. 해당 스펙은 인터페이스로 정의되며 애플리케이션에서 사용될 수 있도록 유즈케이스 인터페이스를 리턴 받을 수 있도록 개발된다.

user usecase

유즈케이스의 경우 위처럼 Service, Repository, Model을 종합적으로 사용하는데 Entity 레이어의 리소스를 어떻게 활용하여 비즈니스 로직에 사용하게 될 것인가를 작성했기 때문에 코드가 단순화될 수 있다.

Interface

Interface 계층에서는 인터페이스로 정의된 내용들을 실제로 구현하는 계층으로 우리의 예제에서는 repository를 구현하게 되어있다.

user repository implemented in an in-memory way

위 코드는 Entity layer의 repository를 구현한 코드로 데이터의 저장을 인-메모리 방식으로 처리하게 된다. 만약에 이 코드를 다른 방식으로 구현한다면 MySQL, Postgresql, MariaDB 어떤 것을 사용하더라도 상관없게 된다.

우리는 그저 구현된 어떤 repository를 사용할지 Main 영역에서 선택하기만 하면 된다. 이 부분이 작성하는 블로그 내용의 핵심으로 덕분에 우리는 DB에 종속되지 않은 테스트 코드 작성을 할 수 있게 된다. 순수하게 비즈니스 로직만을 검증할 수 있는 테스트 코드를 작성하면 되는 것이다.

API Service

이제 우리는 위에서 작성한 팩토리 패턴의 메서드를 가져다 API 핸들러에서 적절하게 사용해 주기만 하면 된다.

API에서 사용할 핸들러의 용도에 맞게 repo, service, usecase를 사용하여 구현체를 가져와 핸들러에서 사용하게 된다.

이미 중요한 비즈니스 로직, 제약조건, 데이터 처리를 위한 부분은 구현이 되었기 때문에 핸들러에서는 유즈케이스만 가져와서 사용하면 핸들러의 로직이 단순화되기 때문에 코드의 가독성과 유지보수성이 향상된다. 이 패턴에 익숙하지 않으면 다소 코드가 파편화되어 보일 수 있지만 이것은 어디까지나 규칙에 의해 정의된 것이기 때문에 혼란스러워할 필요가 없다.

Test Code

API 서비스 구현에서 우리는 이미 구현된 repo, service, usecase 구현체를 가져와 핸들러에서 사용하게 구현했다. 하지만 실제 프로덕트 개발에서는 In-Memory 방식으로 사용하지 않고 별도의 데이터베이스를 통하여 구현하게 끔 되어있을 텐데 테스트 코드는 비즈니스 로직에 대한 검증을 하기 위한 단위 테스트 구현이기 때문에 In-Memory로 구현된 구현체를 가져와 커버리지를 검증하기 위한 방식으로 개발하면 된다.

위처럼 각 비즈니스 로직을 케이스에 따라 테스트 코드를 작성하면 외부의 다른 솔루션을 사용하는 것과는 별개로 커버리지를 안정적으로 가져갈 수 있다. 위의 코드를 실제로 실행하게 되면 아래와 같은 결과를 얻을 수 있다.

user@mac ~/blog/app/usecase master●: go test -v -cover
=== RUN TestListUser
--- PASS: TestListUser (0.00s)
=== RUN TestRegisterUser
--- PASS: TestRegisterUser (0.00s)
=== RUN TestRegisterUser_Duplicated
--- PASS: TestRegisterUser_Duplicated (0.00s)
PASS
coverage: 77.8% of statements
ok example.com/m/app/usecase 0.014s

일반적인 API라면 비즈니스 로직에 대한 다양한 케이스를 테스트 코드로 작성하기만 해도 커버리지는 70퍼센트 이상 나오게 된다. 그래서 개인적으로는 애플리케이션의 커버리지는 최소한 70퍼센트를 제한으로 두어 CI & CD에 제한을 둬 적용하도록 하는 것이 올바른 정책이라고 생각한다.

이외에 애플리케이션에서 사용하는 다양한 종속성 패키지에 대한 에러 케이스까지 사용하려면 Too much 한 검증이다.

물론 이것을 구현하지 못하는 것은 아니지만 그러려면 고의로 종속성 패키지에 대한 에러를 뱉을 수 있게끔 하기 위하여 별도의 인터페이스를 만들어 구현체에 넣는 방식으로 개발해야 하기 때문에 사용되는 패키지가 늘어날수록 많은 검증 코드가 필요할 것이다.

Conclusion

코딩 스타일은 개인의 취향이지만 적어도 프로덕트로 사용되는 코드라면 그에 맞는 신뢰성을 갖기 위한 최소한의 안전장치가 필요한데 우리는 서비스를 개발하면서 다양한 이유 때문에 이런 안전장치를 가져가지 않는 경우가 많다. 또는 이런 방식으로 종속성에서 비교적 자유로운 올바른 테스트 코드를 작성하는 법을 몰라서 그런 경우도 있다.

이유야 어찌 됐든 개발자 개인의 업무 피로도 감소와 서비스의 안정성을 위해서라도 이런 방식의 안전장치를 가져가는 것은 필수적이라고 생각한다.

작성한 모든 예제들은 “Golang”으로 작성되었지만 CleanArchitecture는 golang으로만 구현 가능한 것이 아니다.

References

--

--