정글에서 온 개발자

Go 웹서버 테스트를 위한 아키텍쳐 리팩토링 본문

고민과 의사결정

Go 웹서버 테스트를 위한 아키텍쳐 리팩토링

dev-diver 2024. 12. 19. 16:08

Golang으로 작성된 레거시 프로젝트를 이어받아서 feature를 개발하던 중 테스트 코드를 작성하고 싶어졌다.

이유는 내가 짠 api에서 성능 개선을 해보겠다고, 혹은 response 데이터를 변경하면서 이전에 잡았던 버그를 다시 내는 일이 몇 번 있었기 때문이다.

이 때마다 hot fix로 PR을 다시 날리는 것도 귀찮고, 내가 짠 코드라고 방심하고 함부로 코드를 정리하다가 큰일 날 수도 있겠구나 하는 생각이 들었다.

종합적으로 테스트 목적 중 ‘회귀 방지’를 위해서 도입하고자 했다.

고민

테스트 코드를 짜려고 하다 보니 걸리는 게 있었다.

  1. api 하나를 통째로 여러 케이스로 검사하는 게 unit 테스트가 맞나?
  2. 테스트가 어떻게 실제 DB에 영향을 안 줄 수 있을까?

1. API 전체 테스트?

단위 테스트에서 말하는 ‘단위’는 정하기 나름이라고 한다.
그러나 API는 여러 모듈이 잘 돌아가는지 보는 ‘통합테스트’, ‘e2e 테스트’, ‘기능 테스트’ 에 가깝다.

레거시 코드에서는 모듈이라고 할 것이 없고 api 핸들러가 DB 호출까지 접근하게 구성돼 있었다.
통합되어 있어 테스트해야 하는 ‘유닛’ 하나로 보이지만, 사실상 단일 책임 원칙(SRP), 관심 분리(SOC)가 제대로 안 되어있는 상태일 뿐이다.

빠른 개발을 위해 필요했을 수 있지만 테스트를 위해서는 코드 분리가 필요하다고 생각했다.
이렇게하면 테스트하고 싶은 로직 ‘단위’로 테스트를 할 수 있을 것 같았다.

2. 테스트가 어떻게 실제 DB에 영향을 안 줄 수 있을까?

영속성이 있는 객체에 접근할 때, read 연산이면 그나마 괜찮지만, write를 하는 API 기능을 테스트할 때마다 db가 잘못 쓰인다면 문제가 있다.

가장 쉬운 건, Mock이나 Stub을 만들어 DB를 대체하는 것이다. 이렇게 하면 DB 호출까지의 로직을 테스트 할 수 있다. mock 라이브러리를 쓰면 sql 검증도 할 수 있다.

두 번째 방법은 테스트 할 때, 테스트용 인메모리 데이터베이스를 띄우거나, (docker나 , 실제 구성된 테스트용 db로) 테스트용 데이터베이스로 연결해주는 방법이다. mock 라이브러리가 없을 때 사용할 수 있다.. 여러 단위 테스트를 하기 위해서는 인메모리 DB더라도 테이블 정의는 한번에 하고, 테스트용 DB의 경우에도 테이블 생성은 테스트마다 하지 않는 것이 좋겠다.

실행

의존성 주입

위의 해결을 위해서는 ‘의존성 주입’(DI; Dependency Injection) 이라는 것이 필요했다.

사용할 DB를 미리 정한 코드가 아니고, 상황에 따라 사용할 DB를 바꿔 줄 수 있도록 짜는 것이다. 레거시 코드는 db 모듈의 전역 변수를 통해 DB에 연결하고 있었다.

크게 생성자에 매개변수로 넘겨주는 방법(생성자 주입)과 , 세터를 만들어 따로 호출(세터 주입)하는 방법이 있다. 둘의 차이는 생성 이후의 가변성에 있는데, 현재는 가변성이 필요 없어 생성자 주입으로 모두 대체 하였다.

레이어 나누기 - 모듈을 어떤 기준으로 나눌까?

이렇게 의존성 모듈을 나누면서 의존성 주입을 할 때, ‘모듈을 어떤 기준으로 나눌까?’ 라는 질문이 자연스럽게 나온다.

이 부분은 별 고민 없이 결정했다. 이전에 연습겸 Layerd Architecture 패턴(Controller - Service- Repository)에 대한 테스트 코드를 짜봤다 . Spring을 잠깐 공부하면서도 이에 익숙해졌다. 레거시에 맞게 내가 새로 짠 코드도 이를 염두에 두고 DTO를 작성했다. 마지막으로 웹 서버 개발에서 흔한 아키텍쳐 패턴이라 특별한 반대 이유가 생각나지 않는 한 써도 괜찮다고 생각했다.
MVC(Model-View-Controller)가 아닌 그 변형이다. View는 SPA로 빠져있어 만들 필요가 없고, Service가 추가됐다.

Service 레이어는 필요 없는데 남들이 쓰니까 추가하는 게 아닐까?
Controller가 단순히 Service를 포장하는 껍데기로서 파라미터의 유효성 검사 역할을 하도록 하고, 메인 로직을 Service가 담당하면서 Model을 조합하면 개발이 편할 것 같다고 생각해 적용하기로 했다.

최종적으로 다음과 같이 나눌 수 있다.

  1. Handler (Controller)
    • request을 받아 유효성 검사, 단일 Service에 요청을 보낸 후, 요청을 적당히 파싱해 response
    • 유효성 검사 외 조건 분기는 되도록 피한다.
  2. Service
    • 사실상의 메인 로직. Model을 조합한다. 필요에 따라 다른 도메인의 Model을 사용할 수도 있다.
  3. Model
    • DB에 직접 접근한다.
    • 이 때, 간단한 Join 쿼리는 쓸 수 있지만, 쿼리를 두 번 날려야 한다면 메소드를 분리한다.

도입 전략 - 점진

레거시 코드는 api가 많아서 문서화도 점진적으로 하나씩 도입하고 있었는데, 전체 API를 한번에 해당 아키텍쳐로 바꾸려면 한 달은 필요할 것 같았다.

레거시에서 특정 프레임워크를 사용한 것도 아니였기 때문에 일부분만 레어어를 나누어 적용할 수 있다. 내가 새로 작성한 엔티티에 대한 API부터 도입했다.

테스트 코드를 작성하기 전이였기 때문에, Postman으로 테스트 했다..ㅎㅎ

테스트 계획

위와 같이 나눈 경우 각 계층을 다음과 같이 테스트 할 수 있다.

핸들러

  • 유효성 검사를 잘 하는지? 올바른 형태로 반환해 주는지?
  • 적절한 형태로 서비스에 위임하는지?
  • 테스트는 유효성에 대한 엣지 케이스(경계값 분석) 정도만 있으면 될 것 같다.
  • 유효성 기준이 변경되거나 DTO가 바뀌면 TDD가 가능해 보인다.

서비스

  • 분기가 필요하다면, 분기를 할 수 있는 케이스 (동등 분할과 경로 커버리지)
  • 올바른 Model을 적절한 인수로 call 했는지?
  • model에서 실행하는 쿼리들을 트랜잭션하는 서비스의 경우, go-sqlmock을 여기서 사용할 수도 있다.

Model

  • Mocking한 DB에 접근한다 (go-sqlmock으로 maraidb에 특화된 쿼리도 실행할 수 있다고 한다.)
  • 쿼리를 변경했을 때 제대로 작동하는지 검사할 수 있다는데, 쿼리 개선에 도움이 될 것 같다.

관심사 분리를 하니, 테스트 코드도 별로 하는 게 없어보인다. 하지만 분리를 하지 않았다면 핸들러 TC 2개, 서비스 TC 3개로 끝날게 조합되어 2*3개의 TC를 작성해야 할 것이다. 여러 관심사를 통합해 테스트 할수록 이렇게 곱연산이 된다.

마무리

테스트

그동안 제대로 공부하고 시작해야 한다는 생각과 테스트에 자료를 찾을 때마다 마주치는 TDD 라는 개념에 갇혀있었다. 때문에 테스트의 필요성은 항상 느끼고 있었지만, 테스트를 하면서 실제 프로젝트를 진행한 적은 없었다.

그러나 다시 생각해보면 테스트를 좋은 방법론에 정확히 부합하게 짜지 못했을 때 보는 손해는 ‘지저분해지는 commit’, ‘나의 시간’ 정도기 때문에, 일단은 코딩을 처음 배울 때처럼 시행착오를 거치면서 익히는 방법을 택했다. 우선은 블로그와 GPT를 뒤지면서 내가 맞다고 생각하는 방향으로 여러가지 시도해보고 책을 읽으면서 방향을 수정할 계획이다.

현재 엔티티 저장 기능의 핸들러 코드만 작성해 놓은 상태다. 테스트에 익숙해지기 전까지 TDD를 적용할 의향은 없다.

아키텍쳐와 프레임워크는 다르다

레거시에서 프레임워크의 강제성이 없어 빠른 개발을 할 수 있는 것이 장점이였는데, 아키텍쳐를 도입하면 코드만 복잡해지고 개발 속도가 느려질까 걱정돼 여러 자료를 찾아봤다.

그러나 아키텍쳐와 프레임워크는 다른 것이였다. 결정적으로 프레임워크의 핵심인 ‘제어의 역전’ 이 없다면 프레임워크가 아닌 것이다. 아키텍쳐 도입으로 복잡성이 증가할 수 있는건 사실이지만 복잡하다고 유지보수 용이성과 개발속도가 낮아지는 것은 아니고 오히려 도움이 될 수도 있다.

모듈별로 관심사를 분리해놓고 보니 코드 파악도 한눈에 돼서 좋았다. 요즘은 VSC에서 ctrl+p 키로 파일도 금방 찾을 수 있으니 폴더링의 복잡성도 크게 문제는 안 된다.

참조