멘토링/Codesoom

TDD의 세계/헥사고날이 뭐예요?

Alex_beom 2024. 3. 9. 02:01
이 글은 필자의 이전 블로그인 https://velog.io/@beomdrive/CodeSoom-4에서 핵심 내용을 중심으로 요약한 후에, 내용을 추가 보완하였습니다.

 

Classicist TDD vs Mockist TDD

이번주 과제부터는 미션에 제한 조건이 걸려있었다. 모든 기능은 TDD로 구현해야 한다는 조건이었다. 강의에서 아샬님은 Mockist TDD 방식을 사용해서 아래와 같은 순서로 개발하신 것 같았다.

  1. 정상인 상황 성공 테스트 작성 → 테스트 실패 → 실제 구현 (테스트 성공되도록) → 테스트 성공
  2. 예외 상황 실패 테스트 작성 → 테스트 실패 → 실제 구현 (테스트 성공되도록) → 테스트 성공

 

추가로 mock을 사용하여 내부 동작을 정의할 경우에는, verify()를 통해 실제 메서드가 호출 됐는지 행위 검증을 수행하는 것 같았다.
그렇다면 Mockist TDD와 Classicist TDD의 차이는 무엇일까?

 

[Mockist TDD]

“행위 검증” 테스트로써 의존 객체의 특정 행동이 이루어졌는지 검증하는 테스트


장점

  • 각각 의존하고 있는 요소들을 모두 격리시켜 독립적으로 단위 테스트를 진행할 수 있다.
  • 의존하고 있는 요소 중에 기능이 아직 미구현인 부분이 있어도 테스트 코드를 작성할 수 있다.
  • 외부 요인으로 인한 테스트 실패를 걱정할 필요가 없다.
  • Spring 환경을 사용할 필요가 없으므로 테스트가 빠르다.

단점

  • 의존 객체에 대한 구현을 직접 설정하기에, 실제 동작과 다를 수 있으므로 테스트의 신뢰성이 낮다.
  • 의존 객체의 구현에 강하게 연관되어 있는 특징 때문에, 기존 구현의 동작이 바뀐다면 테스트가 바로 깨진다.
  • 기존 구현의 Input/Output 동작이 동일하더라도, 내부적으로 리팩터링을 하면 테스트가 깨질 수 있다.


[Classicist TDD]

“상태 검증” 테스트로써 의존 객체의 구현보다는 실제 입/출력 값을 통해 검증하는 테스트

 

장점

  • 실제 의존 객체를 사용하여 테스트를 진행하므로 테스트의 신뢰성이 높아진다.
  • 의존 객체의 구현에 얽매이지 않고 Input/Output 값을 통해 검증하므로, 기존 구현이 변경되거나 내부적으로 리팩터링을 하더라도 입/출력값만 동일하다면 테스트는 깨지지 않는다.

단점

  • 실제 객체들을 사용하므로, 하나의 의존 객체의 테스트가 실패하면 다른 연관된 테스트들도 연쇄적으로 테스트가 실패한다.
  • 모든 의존 객체를 다 구현해야 테스트가 통과하므로, 추후 개발 예정인 부분을 미구현 상태로 둔다면 나머지 부분을 테스트하기 어렵다.

 

나는 개인적으로 Classicist이다.

테스트에서 Mock을 남발할 경우 실제 구현 동작과 다른 상황이 발생할 수 있어 테스트의 신뢰성이 떨어지고, 프러덕션 코드의 리팩터링이 들어갈 때마다 테스트 코드도 함께 변경해줘야 하는 비용이 발생하기 때문이다.

 

하지만 Mockist와 Classicist의 장/단점을 정리하다 보니 고민이 하나 생겼다.

실제 객체를 사용해서 TDD를 진행하려면 일반적인 API 개발에서는 Repository → Service → Controller 순서대로 구현해야 되는 건가?

또한 API CRUD 개발에서 데이터 생성(CREATE)과 데이터 삭제(DELETE)를 먼저 만들어야 하는 것인가?

(조회를 하기 위해서는 데이터를 생성하고, 조회한 후에 데이터를 삭제해 줘야 각 테스트 간의 독립성이 보장되므로)

 

아마 돌이켜 보면 실제 객체를 사용하는 것 == 실제 DB 데이터를 사용하는 것으로 생각했었던 것 같다.

이번 주 리뷰어님인 영환 멘토님에게 여러 질문을 드렸더니 다음과 같은 답변을 해주셨다.

  • mock 사용을 지양하면 왜 Repository -> Service -> Controller 순서로 개발해야 하나요?! 그리고 그것은 왜 Classic TDD라고 불리나요?
  • mock을 사용하면 계층을 나눌 수는 있지만 실제동작과는 전혀 다를 수 있어요. mock이 꼭 없어도 FakeObject 만들 수 있죠!
  • 하지만 Mock에 대해서 좀 더 고민을 해보는 시간을 가져볼게요. mock이 필요한 것은 어디인가요? 아마 DB 때문일 것입니다.
    사이드 이팩트가 있는 코드와 비즈니스 로직(도메인)을 한번 제대로 분리해 본다면 비즈니스 로직에 대한 단위 테스트는 Mock 없이, DB 없이도 가능할 것입니다.
  • 테스트에 mock이 많아지는 것은 mock이 필요한 코드를 만들었기 때문입니다.

Mock vs FakeObject

첫 번째로 주신 질문인, mock을 지양한다면 왜 Repository -> Service -> Controller 순서로 개발하는 Inside-Out 방식이 적합하다고 생각했는지 다시금 곰곰이 생각해 보았다.

실제 객체의 입/출력값을 통해 테스트가 진행돼야 되니까 당연히 데이터를 만드는 영속성 계층부터 시작해야 되는 것이 아닐까?라고 생각을 했었던 것 같다.

하지만 리뷰어님의 답변에서 처음 들어본 키워드가 들어있었다. FakeObject가 뭘까?
FakeObject는 실제 객체가 동작한 것처럼 출력값이 전달되도록 만드는 테스트용 객체로써, 실제 객체의 구현 로직을 간소화(?)시킨 버전을 의미한다.

 

Mock과 FakeObject의 차이는 Mock은 실제 객체의 깡통을 가져다가 행위를 재정의 해주는 것이고, FakeObject는 실제 객체가 동작한 것처럼 입력값에 따라 실 객체와 동일한 출력값을 반환해 주는 방식인 것 같았다.
따라서 FakeObject를 사용한다면 Mock과는 다르게 실제 구현이 변경되더라도, 반환 값을 바꾸지 않는 이상 테스트를 그대로 사용할 수 있는 장점이 있는 것 같았다. 

이런 식으로 생각을 정리하니까 위에서 얘기했던 mock을 지양한다면 Repository -> Service -> Controller 순서로 개발하는 Inside-Out 방식이 굳이 아니어도 될 것 같다는 생각이 자연스럽게 들었다. Controller를 먼저 개발하더라도 Service 객체의 FakeObject를 생성하면 되니 말이다. 즉, 우리가 직접 제어할 수 있는 내부 로직들이라면 최대한 실객체나 FakeObject를 사용하는 것이 좋을 것 같다.

 

그럼 Mock을 사용하기 적절한 상황은 무엇일까?

코드숨 3주차 - [1년 지난 시점에서의 견해]에서도 잠깐 얘기했지만, 우리가 직접 제어하기 어렵거나 테스트할 때마다 매번 실제로 호출하기 어려운 요소들은 Mock을 사용하는 것이 바람직할 것이다.

예를 들면 외부 날씨 정보 API, 주식 정보 API, 구글 메일 발송 프로그램, Message Queue 등이 있을 것이다.


추가로 비즈니스 로직(도메인)은 FakeObject로 대체하였다.
방법은 간단했다. 실제 DB를 사용하지 않고 객체를 하나 생성해 아래와 같이 ArrayList를 하나 선언했다.
private List<Product> products = new ArrayList<>();

 

해당 products 변수는 이제 Heap 메모리에 저장되어 있는 Product 도메인의 임시 데이터베이스가 되는 것이다.
기타 CRUD 로직은 모두 해당 변수 products에 저장하고, 삭제하고, 수정하고, 조회한다.
이렇게 하면 FakeObject의 본 목적대로 실제 객체가 동작한 것처럼 출력값이 전달되는 효과를 얻을 수 있다.

앞으로 단위 테스트를 작성할 때 FakeObject 사용을 지향하고, FakeObject로 구현하기 어려운 기타 제약 사항이 있는 곳에서는 부분적으로 mock을 사용해 개발하면 좋을 것 같다는 생각이 들었다.

1년 반이 지난 시점에서 해당 내용은 좀 수정해보고 싶다.
저 때 당시에는 리뷰어님이 말씀해 주신 "사이드 이팩트가 있는 코드와 비즈니스 로직(도메인)을 한번 제대로 분리해보기"를 정확히 잘 이해하지 못했었던 것 같다.
지금은 아래와 같이 조금 다른 생각을 가지고 있다.

 

현재 필자는 실무에서 아래와 같이 테스트 코드를 관리한다.

  1. Application 로직과 Domain 로직(비즈니스 로직)을 명확히 분리한다.
  2. Domain 로직이 잘 분리되었다면 POJO가 되므로 실 객체를 활용한 단위 테스트를 작성한다. (테스트할 메서드 외 아무것도 필요하지 않음)
  3. Application Layer 혹은 Presentation Layer 등 통합 테스트를 진행할 때는 웬만하면 Real DB를 사용하여 진행한다.
  4. Real DB는 웬만하면 상용에서 사용하는 DB를 동일하게 사용하는 편이고, 테스트 환경 용 DB를 따로 구축하며 각 테스트가 끝날 때마다 TRUNCATE를 실행하여 테스트 간 독립성을 보장해 준다.
  5. 실무에서 FakeObject는 잘 사용하지 않고, Mock은 외부 모듈에 한해서 필요시에 사용한다.
    • FakeObject는 정말 간단한 것이라면 사용할 수도 있겠지만, 조금이라도 복잡한 DB 기능을 대체할 때는 FakeObject의 자체 테스트 코드도 추가로 필요하기 때문에 오히려 비용이 증가한다.

Hexagonal-Architecture

이번주에 영환 멘토님은 헥사고널 아키텍처에 대해 소개해주셨다.

 

헥사고널 아키텍처는 무엇일까? 포트와 어댑터 아키텍처, 육각형 아키텍처 등 이름이 여러 개인 헥사고널 아키텍처는 아래와 같은 구조를 지니게 된다.

 

추가로 자료를 조사해 보며 정리한 내용은 아래와 같다. 헥사고널 아키텍처의 핵심 요소는 크게 4가지인 것 같다.

 

어댑터

  • 인커밍 어댑터(ex. Presentation(Controller))
  • 아웃고잉 어댑터(ex. Persistence(DB))

포트 (인터페이스)

  • 인커밍 포트 (ex. usecase 인터페이스)
  • 아웃고잉 포트 (ex. persistence 인터페이스)

usecase (application)

  • 유스케이스 구현체 (Service)

domain (entity)

  • 순수한 POJO 객체
  • Domain Repository
  • Domain 객체 (JPA Entity를 편의상 Domain과 같이 쓰는 경우 Entity도 포함)

 

헥사고널의 특징은 모든 계층의 의존 방향이 도메인 엔티티로 향하는 것이다.

따라서 도메인 엔티티는 바깥쪽 코드에 아무 곳에도 의존하지 않게 됨으로써, 외부 환경 변경에 의한 코드 수정이 일어날 경우의 수가 줄어든다. 또한 어댑터를 사용하는 곳은 모두 Interface를 통해 의존 역전이 일어나므로 웹이나 영속성(DB) 등의 외부 환경이 변경돼도 유스케이스는 변경이 일어나지 않아 외부 환경을 쉽게 갈아 끼우거나 수정할 수 있게 된다.

좀 더 깊은 이해를 위해 프로젝트에 아래와 같이 적용해 보았다.

 

여기에서 한 가지 궁금한 점은 영속성 어댑터 부분이었다.
헥사고널 아키텍처의 장점을 최대한 살리는 방법은 모든 객체 간의 결합도를 제거하는 것으로 이해했는데, 그렇다면 영속성 어댑터에서도 Spring JPA와 Persistence Adapter랑 분리해야 하는 것이 아닐까?라는 의문이 들었다.
관련 질문에 대한 답변은 아래와 같다.

 

https://github.com/CodeSoom/spring-week4-assignment-1/pull/84#discussion_r1014831272

 

정리한 내용은 다음과 같다.

  • 순수 도메인 로직만을 위해서라면 Spring JPA와의 분리도 당연히 진행해야 한다.
  • 하지만 Spring을 항상 쓰고, Spring JPA를 사용하는 것이 도메인의 복잡성을 야기시키지 않는다면 분리하지 않는 것도 방법이다.
  • 또한 Spring JPA를 분리하게 될 때 생산성이 많이 저하될 것으로 예상되는 경우 분리하지 않는 것이 좋다.

 

따라서 모든 코드숨 회고에서 한 번씩은 말했듯이 기술에 정답은 없으니, 상황에 따라 적절한 판단을 팀과 함께 결정해 나가면 될 것 같다.

1년 반이 지난 시점에서 필자도 현재 Domain 객체와 JPA Entity는 같이 사용하고 있다.
물론 Entity의 변경이 자주 일어나는 복잡한 도메인이라면 Domain과 Entity를 분리할 수도 있지만, 일반적으로는 Entity의 변경 빈도수는 적고 오히려 Domain과 Entity를 분리했을 때 생기는 매핑 지옥이 훨씬 더 큰 비용이 발생하기 때문이다.

 

 

내가 느꼈던 헥사고널 아키텍처의 장점을 객체지향 원칙으로 정리해 보며 글을 마무리하겠다.

  1. OCP : 외부 환경(표현 계층 / 영속성 계층)이 변경되어도 유스 케이스를 수정할 필요 없이 어댑터만 새로 구현하면 되므로, 개방-폐쇄 원칙(OCP)을 잘 준수할 수 있다. 또한, 비즈니스 로직은 오직 비즈니스 요구사항이 변경될 때만 수정되며, 외부 환경의 변화에는 영향을 받지 않으므로, OCP 원칙을 잘 지켜지는 것을 볼 수 있다.
  2. DIP : 응용 계층은 영속성 계층의 구현체가 아닌, 인터페이스(포트)에 의존하기 때문에 DB 모듈을 변경하더라도 응용 계층의 코드 수정이 일어나지 않으므로 의존성 역전 원칙(DIP)을 잘 지켜지는 것을 볼 수 있다.
  3. SRP : 응용 계층은 어댑터 계층의 변경과는 무관하게, 오직 유스 케이스의 요구사항이 변경될 때만 수정된다. 이는 객체는 오직 하나의 변경 이유만을 가져야 한다는 단일 책임 원칙(SRP)을 잘 지켜지는 것을 볼 수 있다.