이번 내용에는 기술적인 구현 부분은 하나도 없다! 백엔드 자체에서 기술적인 구현에 대해서는 어려운 부분이 없었기 때문이다. 대부분 설계와 관련된 고민을 했는데 이 고민을 하게 된 이유는 사실상 처음 구현 해 보는 헥사고날 아키텍처를 내 것으로 습득하고 싶었기 때문이다.
그래서 그 토대가 되는 내용은 헥사고날 아키텍처로 코드 구현 해 보기 포스팅을 통해 정리도 해 봤다.
위 글을 통한 기본틀을 넘어서 챌린지 마이크로서비스를 구현 하면서 고민했던 중요 포인트를 남겨 보기로 한다. 헥사고날 아키텍처의 관점뿐만 아닌 DDD와 개발 전반에 여러 고민을 했다.
처음에는 단 1개의 루트 도메인과 서브 도메인으로 이루어진 루트 애그리거트 단위를 코드로 구현하는 게 좀 더 코드 자체로 상태나 행위를 잘 나타낸다고 생각했다. 가장 단순한 예시로 들자면 글을 올리는 Post와 그 Post에 속하는 댓글인 Comment 관계가 있다고 가정한다.
public class Post {
private Long id;
private String content;
private List<Comment> comments; // Comment는 Post에 속한다.
public Post(String content) {
this.content = content;
this.comments = new ArrayList<>();
}
// 댓글 추가는 루트 엔티티인 Post를 통해서만 가능
public void addComment(String commentContent) {
Comment comment = new Comment(commentContent);
this.comments.add(comment);
}
public List<Comment> getComments() {
return Collections.unmodifiableList(comments);
}
}
public class Comment {
private String content;
public Comment(String content) {
this.content = content;
}
}
Post는 Comment를 포함하며, Post를 통해서만 Comment에 접근하며 추가할 수 있다. 나는 처음에는 이런 관계가 완벽한 도메인을 코드로 나타내고 있다는 생각을 했다. 그런데 막상 구현을 해 보면서 LifeCycle이 다른 것에서부터 조금 의심을 하기 시작했다.
헥사고날 아키텍처의 관점에서 도메인 영역에서는 이런 문제 자체를 알지도 못하고, 사전에 염두할 필요도 없다. 그래서 Comment를 간접적으로 참조할 수 있는 구조로 변경하고 다음과 같이 처리했다.
public class Post {
private Long id;
private String content;
private List<Long> commentIds; // Comment 객체 대신 식별자만 저장
public Post(String content) {
this.content = content;
this.commentIds = new ArrayList<>();
}
// 댓글 추가는 식별자만 추가
public void addComment(Long commentId) {
this.commentIds.add(commentId);
}
public List<Long> getCommentIds() {
return Collections.unmodifiableList(commentIds);
}
}
결국 별도의 식별자를 갖는 Comment를 도메인으로 선언하고 관리하도록 했다. 이런 방식으로 설계하면 DDD를 추구하는 방식의 도메인 코드만으로 관계를 표현하기는 어려울 수 있지만, 이는 일종의 Trade Off이며, 적당한 단위로 도메인을 구분하고 트랜잭션 범위를 작게 관리하며 유지보수와 확장성 측면에서 이득이 된다고 생각했다.
결론: 작은 단위로 나누는 것이 성능과 확장성을 고려했을 때 유리하다고 판단하여 이를 선택했다.
원래 예외처리와 같은 부분은 기능 개발 이후 나중으로 미루려고 했다. 그런데 헥사고날 아키텍처로 구현하면서 예외 처리에 대한 내용을 한 곳에서 관리하는 것이 좋다고 느껴졌다. 도메인과 애플리케이션 헥사곤의 각 영역에서 커스텀 예외를 만들고 처리하는 것이 맞는 것 같았다.
실제 구현에서는 이 2가지 구분을 제대로 하면서 관리하고 있지는 못했다. 사전 설계를 통해서 정한것이 아니라 예외를 구현하면서 이상함을 느껴 관련 내용을 찾아보니 위와 같은 예외의 범위를 나누는 것이 맞다는 사실을 알았다. 나중에 리팩토링을 통해서 개선하기로 한다.
또 현재는 도메인 영역에서의 잘못된 값에 대해서 IllegalArgumentException
를 주로 사용하고 있지만, 향후에는 공통화와 예외의 계층화를 통해 적은 예외 클래스로도 자세한 예외 메시지를 전달할 수 있도록 개선할 예정이다.
@Override
public Like save(Like like) throws AlreadyLikedException { // unchecked 예외지만 명시적으로 선언
try {
return likeJpaRepository.save(LikeJPAEntity.fromDomain(like)).toDomain();
} catch (DataIntegrityViolationException e) { // 유니크 제약 조건 위반시 발생
throw new AlreadyLikedException(like.getChallengeId());
}
}
실제 나의 코드로 예시를 들자면 1개의 게시물에는 1명씩만 좋아요가 가능하다. 같은 사람은 중복이 발생하지 않아야 하는 규칙이다. 이것은 프레임워크 헥사곤영역에서는 유니크 제약 조건으로 나타난다.
이 예외의 경우 구체적으로는 DataIntegrityViolationException
로 나타나는데, 이것을 내가 만든 커스텀 예외인 AlreadyLikedException
로 변환하고, 이것을 통해서 무슨 이유로 에러가 발생했는지 정확한 메시지를 전달 할 수 있게 했다.
그리고 내부영역에서는 DataIntegrityViolationException
라는 것 자체를 모르는 구조이다. 헥사고날 아키텍처의 중요한 개념인 의존성과 의존성 방향에 맞춰서 예외도 처리할 필요가 있기 때문이다.
결론: 예상 가능한 예외 상황에 대해 커스텀 예외를 적극적으로 구현하여 사용자에게 더 사용하기 편한 API를 제공해야 한다.
사소할 정도로 작은 것에 대해 많은 고민과 변경을 시도를 했다.
결론적으로는 정답이 없는 것에 대해 고민을 했던 것인데, 정답은 없어도 좀 더 나은 선택지, 혹은 지금 이 순간에 있어서는 왜 이런 선택을 하는게 맞는지, 왜 바꾸는 것이 맞는지를 계속 고민했다.
간단하게는 삭제 메서드의 경우 반환 값은 어떻게 처리 하는 것이 좋을까에 대한 고민을 예시로 들 수 있겠다. DB의 데이터를 실제로 삭제하는 경우 (Hard Delete) 반환 값은 다음과 같이 생각 해 볼 수 있었다.
처음에는 void가 맞다고 생각했다.
그러다가 테스트 코드를 작성하면서, 이 메서드에 대한 검증이 조금 까다롭다는 생각에 반환 값을 고민한다. 객체 자체를 직접 쓸까, 아니면 식별자를 쓸까란 고민을 하다가 또 이 값 자체의 의미가 있지는 않고 결국은 성공 혹은 실패만을 나타내면 충분하지 않은가란 생각에 Boolen을 고민한다.
단순히 고민이 끝이아니라 나는 위에서 제시한 모든 방법을 코드로 직접 코드의 변경을 하면서 선택지를 변경 했기 때문에 실질적인 개발 단계의 진전은 없이 시간을 쓰게 된다.
이 과정 자체가 무의미할 수도 있겠지만 개인적으로는 이러한 삽질이 값졌다. 이 코드 변경의 과정과 결과 검증을 위해 사전 테스트 코드를 계속 작성했었고 이 과정에서 다시 한번 테스트 코드의 필요성과 효용성을 느끼게 되었다.
결국 나의 결론은 void로 내렸다. 삭제한 객체 자체는 앞으로 쓰지 않을 것이며, 쓰이지 않아야 하는 것이 맞기 때문이란 판단이었다.
결론: 내릴 수 없다! 이것은 비슷한 내용을 찾아본 결과 명확한 답이 없다고 한다. 나의 경우에는 Hard Delete의 경우에는 반환 타입은 void로 내릴 수 있었다.
원칙은 최대한 적은 파라미터를 사용하기이다. 파라미터가 많으면 많을수록 사용하는 입장에서도 까다롭기 때문이다. 만약 파라미터가 너무 많아진다는 생각이 들면 일종의 눈속임으로 이 파라미터를 전달하는 객체를 따로 만들어 볼 수도 있겠다.
하지만 이 원칙에도 예외가 있다. 내부에 존재해서 테스트를 힘들게 하는 요소들은 모두 외부 파라미터로 만들어야 한다.
조각 피자 전문점을 예시로 들어보겠다. 이 조각 피자 전문점은 오후 10시 이후에 조각 피자를 주문하면 동일한 조각 피자 한 조각을 추가로 더 주는 '피자 나이트'를 주요 프로모션으로 제공한다.
(시간 조건에 해당하면 주문 수의 2배에 해당하는 조각 피자를 받을 수 있다)
이때 주문시간은 외부에서 주입받아야 테스트가 쉬워진다.
@Getter
public class PizzaOrder {
private final Long orderId;
private final String customerName;
private final int quantity; // 주문한 피자 조각 수
private final LocalDateTime orderTime;
private final int totalSlices;
/**
* 피자 주문을 생성하는 팩토리 메서드.
*
* @param orderId 주문 ID
* @param customerName 고객 이름
* @param quantity 주문한 피자 조각 수
* @param orderTime 주문 시간
* @return 생성된 PizzaOrder 객체
*/
public static PizzaOrder create(Long orderId, String customerName, int quantity, LocalDateTime orderTime) {
if (customerName == null || quantity <= 0) {
throw new IllegalArgumentException("customerName과 quantity는 반드시 포함되어야 합니다.");
}
if (orderTime == null) {
orderTime = LocalDateTime.now();
}
int totalSlices = calculateTotalSlices(quantity, orderTime);
return new PizzaOrder(orderId, customerName, quantity, orderTime, totalSlices);
}
private PizzaOrder(Long orderId, String customerName, int quantity, LocalDateTime orderTime, int totalSlices) {
this.orderId = orderId;
this.customerName = customerName;
this.quantity = quantity;
this.orderTime = orderTime;
this.totalSlices = totalSlices;
}
/**
* 총 피자 조각 수를 계산하는 메서드.
*
* @param quantity 주문한 피자 조각 수
* @param orderTime 주문 시간
* @return 총 제공 피자 조각 수
*/
private static int calculateTotalSlices(int quantity, LocalDateTime orderTime) {
int total = quantity;
if (orderTime.getHour() >= 22) { // 오후 10시 이후 주문 시
total += quantity; // 1 + 1 프로모션 적용
}
return total;
}
}
위 코드를 테스트 코드로 작성 하면 다음과 같이 테스트 해 볼 수 있다.
public class PizzaOrderTest {
@Test
@DisplayName("오후 10시 이전에 주문하면 총 피자 조각 수는 주문 수량과 동일하다")
public void testCreateOrderBefore10PM() {
// Given: 오후 9시에 4조각의 피자를 주문함
Long orderId = 1L;
String customerName = "John Dough";
int quantity = 4;
LocalDateTime orderTime = LocalDateTime.of(2024, 10, 13, 21, 0); // 오후 9시
// When: 주문 객체를 생성함
PizzaOrder order = PizzaOrder.create(orderId, customerName, quantity, orderTime);
// Then: 총 조각 수는 4조각이어야 함
assertEquals(4, order.getTotalSlices(), "오후 10시 이전 주문 시 총 조각 수가 주문 수량과 동일해야 합니다.");
}
@Test
@DisplayName("오후 10시 이후에 주문하면 총 피자 조각 수는 주문 수량의 두 배가 된다")
public void testCreateOrderAfter10PM() {
// Given: 오후 10시 30분에 4조각의 피자를 주문함
Long orderId = 2L;
String customerName = "Jane Dough";
int quantity = 4;
LocalDateTime orderTime = LocalDateTime.of(2024, 10, 13, 22, 30); // 오후 10시 30분
// When: 주문 객체를 생성함
PizzaOrder order = PizzaOrder.create(orderId, customerName, quantity, orderTime);
// Then: 총 조각 수는 8조각이어야 함 (1 + 1 프로모션 적용)
assertEquals(8, order.getTotalSlices(), "오후 10시 이후 주문 시 총 조각 수가 주문 수량의 두 배가 되어야 합니다.");
}
@Test
@DisplayName("주문 시간이 null일 경우 현재 시간이 할당되고 총 피자 조각 수는 현재 시간에 따라 달라진다")
public void testCreateOrderWithNullOrderTime() {
// Given: 주문 시간 없이 2조각의 피자를 주문함
Long orderId = 3L;
String customerName = "Baby Dough";
int quantity = 2;
LocalDateTime orderTime = null;
// When: 주문 객체를 생성함
PizzaOrder order = PizzaOrder.create(orderId, customerName, quantity, orderTime);
// Then: 주문 시간이 null이 아니어야 하고, 총 조각 수는 2조각 또는 4조각이다.
assertNotNull(order.getOrderTime(), "주문 시간이 null인 경우 현재 시간이 할당되어야 한다.");
assertEquals(2, order.getTotalSlices(), "주문 시간이 null인 경우 총 조각 수는 현재의 조건에 영향을 받는다 2 or 4");
}
}
다른 2개의 테스트는 의도한대로 동작할 것이고, 검증도 바로 할 수 있다. 만약 시간이라는 값을 외부에서 받아서 사용하지 않는다면 testCreateOrderWithNullOrderTime
의 테스트 코드 예시처럼 동작한다. 이 테스트는 사용자의 실제 시간에 의존하는 테스트로 테스트를 실행하는 시간에 따라 영향을 받기에 매번 테스트의 성공과 실패를 보장하지 않는 잘못된 방식의 테스트코드가 된다. 이런 비슷한 이유로 테스트를 어렵게 만드는 요소는 파라미터로 반드시 사용하기로 한다.
이런 나름의 원칙을 세웠기에 파라미터에 대한 고민은 대부분 쉽게 결정 할 수 있었다. 몇 가지 결정하기 힘들었던 요인 중에 하나는 도메인 규칙등에 의해 정해져 있거나, 유추할 수 있는 값, 내부 관계에 따라서 추적하면 찾을 수 있는 값의 경우는 파라미터로 전달을 하는 것이 맞는가에 대한 것은 약간 까다로웠다. 이 요인에 의한 고민은 케이스마다 조금씩 달랐기에 아직까진 명확한 판단의 근거를 세울 순 없었다. 잠정적인 결론으로는 이 경우도 테스트 편의성을 해치는가 해치지 않는가를 근거를 따라가기로 했다.
결론: 테스트의 편의성을 해치지 않는 범위에서 파라미터를 결정한다.
위 예시 말고도 정말 사소한 패키지 구조나 네이밍은 어떻게 할 것인가 등 수 많은 자잘한 고민과 변경을 통해서 왜 이런 선택을 했는지 자체 검증과 판단을 내렸다. 미래에 보면 정답은 아닐지 몰라도, 현재의 관점에서는 직접 생각한 결론을 낼 수 있어서 좋았다. 만약 실무였다면 이미 정해져 있는 구현 구조를 베끼기 바빴을 것이고, 왜 이렇게 하는지에 대한 이유도 아마 제대로 이해 하지 못했을 것이다. 이런 변경이 가능했던 이유는 테스트코드가 바탕이 되었는데, 이 테스트 코드는 다음과 같은 규칙으로 작성 했다.
테스트코드를 기계적으로 작성했다. 이 기계적이라는 표현 자체가 그리 긍정적인 느낌인 아니고, 나도 테스트코드를 만들면서도 어떤 부분은 테스트코드가 없어도 되지 않겠나란 생각이 계속 들었다. 그럼에도 가장 실무에서 실천하기 힘든작업이 매 코드마다 테스트 코드를 작성하는 행동이라고 판단했다. 지금 토이프로젝트가 아니라면 제대로 할 수 없을 것 같아서 기계적으로라도 만들어 보기로 했다.
이 거울 같다라는 것은 구현 코드를 만들면 상응하는 테스트코드를 만들었다는 것을 의미한다.
도메인을 정의 하면 그 도메인을 테스트 하는 코드를 만든다. 아주 단순한 원칙이다. TDD를 원칙적으로 따르면 테스트 코드를 먼저 작성하고, 이 테스트를 실패하는 구현 코드를 작성해야 하겠지만 전체적인 코드를 먼저 만들고, 테스트코드를 만들고, 다시 이 테스트코드에서 원래 코드를 개선 하는 서로 상호작용하며 만드는 과정을 거쳤다.
이렇게 단순한 원칙이지만 달리 생각하면, 구현한 코드 class 수 x2 만큼의 테스트 코드를 작성해야 한다.
테스트 코드의 효용성은 매 순간마다 크게 느끼지만, 막상 작성 하는 것 자체가 생각보다 큰 노동이며 단순히 기능 구현이라는 목적 자체의 생산성을 떨어뜨리는 요소로 생각 되기 쉽다.
나는 이 부분에 대해서는 몇 가지 원칙을 세우고 작성 했는데 그것은 다음과 같다.
구분 | 테스트 작성 여부 및 방식 |
---|---|
도메인 코드 | 예외 없이 테스트 코드를 작성한다 |
애플리케이션 코드 | 서비스만 테스트하며, Mocking을 통한 테스트 진행 |
내부 통신용 DTO | 테스트하지 않는다 |
외부 통신용 DTO | 테스트 코드를 작성한다 |
프레임워크 헥사곤 Outbound Adapter | 라이브러리나 프레임워크 사용 시 기본 토대만 테스트. 복잡도가 있는 경우 추가 테스트 작성 |
Controller | 특별한 이유가 없으면 별도 테스트 작성하지 않음 |
각 세부 내용을 설명하면 다음과 같다.
이것에 대해서는 이견은 없을 것으로 생각한다. DDD의 개념을 생각하면 가장 핵심이 되는 코드이자, 비지니스로직이 모두 들어가 있는 코드이다. 따라서 당연히 완벽에 가까운 테스트 코드를 작성하려 해야하며, 이 도메인을 나타내기 위한 값 객체 내에서도 별도의 생성 규칙이나 비지니스로직이 들어가 있다면 당연히 테스트 코드를 작성해야 한다. 별도의 의존성 자체가 없기 때문에 가장 빠르게 테스트 할 수 있으면서도 가장 많은 테스트 케이스를 보유하는 테스트 코드가 될 것이다.
애플리케이션 영역의 서비스코드를 테스트 코드로 작성해야 한다. 헥사고날 아키텍처에서 인터페이스로 선언한 UseCase에 대한 실제 구현체에 대한 테스트 코드에 해당한다. 이 영역의 테스트코드가 나는 가장 까다로웠다. 실제 구현체와는 상관 없는 테스트를 진행 해야 했고(최대한 단위테스트를 지향한다) 매번 동일한 결과를 가져오려면 결국은 Mocking을 통한 테스트 코드를 작성 해야 한다.
이 Mocking을 사용한 예시로, 주문과 결제에 대한 테스트 코드를 다음과 같이 작성해 본다.
@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService;
@Test
@DisplayName("유효한 주문 생성 시 주문을 저장하고 주문 객체를 반환한다")
void givenValidOrder_whenCreateOrder_thenSavesOrderAndReturnsOrder() {
// Given
String product = "피자";
int quantity = 2;
double price = 15.0;
double totalAmount = quantity * price;
given(paymentService.processPayment(totalAmount)).willReturn(true);
Order savedOrder = new Order(1L, product, quantity, price);
given(orderRepository.save(any(Order.class))).willReturn(savedOrder);
// When
Order result = orderService.createOrder(product, quantity, price);
// Then
assertNotNull(result);
assertEquals(1L, result.getOrderId());
assertEquals(product, result.getProduct());
assertEquals(quantity, result.getQuantity());
assertEquals(price, result.getPrice());
then(paymentService).should(times(1)).processPayment(totalAmount);
then(orderRepository).should(times(1)).save(any(Order.class));
}
@Test
@DisplayName("결제 실패 시 예외를 던진다")
void givenPaymentFailure_whenCreateOrder_thenThrowsException() {
// Given
String product = "피자";
int quantity = 2;
double price = 15.0;
double totalAmount = quantity * price;
given(paymentService.processPayment(totalAmount)).willReturn(false);
// When & Then
Exception exception = assertThrows(IllegalStateException.class, () -> {
orderService.createOrder(product, quantity, price);
});
assertEquals("결제에 실패했습니다.", exception.getMessage());
then(paymentService).should(times(1)).processPayment(totalAmount);
then(orderRepository).should(never()).save(any(Order.class));
}
}
BDD 스타일로 테스트 코드를 작성 했다. OrderService 를 테스트 하는데, OrderService 내부에서 의존하고 있는 외부의 OrderRepository, PaymentService의 동작을 가짜(Mock)로 처리하기 위해 @Mock을 사용한다. 이 가짜로 만든 의존성을 다시 OrderService에 @InjectMocks을 통해서 주입하면 외부의 의존성을 사용하지 않은 테스트를 진행 할 수 있게 되는 구조다.
Mocking을 사용하면 외부 의존성 없이 일관성 있는 테스트가 가능하나, 어떤 동작을 하고, 어떤 값을 반환하게 될지를 직접 정의하고 코드로 표현해야 한다. 따라서 이 작업에서 실수를 한다면 잘못된 테스트를 만들게 되므로 주의를 많이 가져야하며, 동시에 내가 의도한 동작을 다시금 생각 할 수 있는 테스트 코드 작성 단계라고 생각한다.
willReturn을 통해 어떤 값을 반환하는 등의 동작 결과를 먼저 선언하고,
then을 통해서 검증하는 구조이다. 자세한 API의 동작과 사용방법은 생략한다.
추가로 애플리케이션의 서비스만 테스트 해야 한다는 결론은 아주 당연한 결론이다. InputPort, OutputPort 에 해당하는 코드는 모두 인터페이스이기 때문에 테스트코드에서 주입을 받아서 처리 하는 것이 불가능 하다.
내부 통신의 경우
DTO의 개념적인 측면을 따르면 단순히 통신을 하기 위한 데이터의 전달용 객체이다. 따라서 내부에 별도의 비지니스로직이 들어가있지 않아야 하며, 사용 편의를 위해 내부적으로 데이터의 변환 정도를 메서드로 정의 할 수 있을 것 같다. 메서드를 호출할 때 필요한 값이 없다면 자연스럽게 검증 할 수 있기에 따로 테스트 코드를 만들 필요는 없다고 생각한다.
외부 통신의 경우
외부 통신을 하는 경우에는 테스트코드가 필요하다. 내부의 통신과 다른 점은 내/외부 서로 통신을 하면서 데이터의 변환이 일어날 가능성이 높기 때문이며, 그런 이유에서 DTO를 만들었기 때문이다.
단순히 내부 통신을 위한 DTO는 모든 데이터를 전부 전달하는 것이 아닌 필요한 데이터만 전송하려는 측면이 강하다. 그러나 외부 통신은 서로의 호환을 위해 특정 데이터로 전환하는 작업이 있을 가능성이 높다. 적어도 나의 경우에는 그랬다.
예시로 프론트엔드와 통신하기 위해 사용하는 Request 객체를 만들어보면 알 수 있다.
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class FileData {
private final String originalFilename;
private final byte[] content;
private final String contentType;
public static FileData create(String originalFilename, byte[] content, String contentType) {
return new FileData(originalFilename, content, contentType);
}
}
@Getter
@Builder
public class CreateInputDTO {
private Long userId;
private String nickName;
private FileData attachedFile;
}
사전의 전제조건 코드이다. 내부 도메인 영역에서 파일은 FileData라는 값 객체를 사용해서 관리한다. 그리고 사용자의 업로드 정보를 받기 위해 내부 통신으로는 CreateInputDTO를 사용한다.
이제 외부 통신을 위한 코드는 다음과 같다.
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class CreateRequest {
@NotNull(message = "userId는 필수입니다.")
private Long userId;
@NotBlank(message = "nickName은 필수입니다.")
private String nickName;
private MultipartFile uploadFile;
@Builder
public CreateRequest(Long userId, String nickName, MultipartFile uploadFile) {
this.userId = userId;
this.nickName = nickName;
this.uploadFile = uploadFile;
}
public CreateInputDTO toDTO() {
return CreateInputDTO.builder()
.userId(this.userId)
.nickName(this.nickName)
.attachedFile(convert(uploadFile))
.build();
}
private FileData convert(MultipartFile multipartFile) {
try {
return FileData.create(
multipartFile.getOriginalFilename(),
multipartFile.getBytes(),
multipartFile.getContentType()
);
} catch (IOException e) {
throw new RuntimeException("파일 읽기 실패", e);
}
}
}
웹 계층에서 사용자는 파일 업로드를 Multipart객체를 통해서 전달하기는 것으로 구현이 됐다. 이것은 스프링 프레임워크에서 파일 업로드의 편의를 위해 만들어진 객체이다. 그러나 내부로 들어가서는 구체적인 Multipart객체를 사용 할 수는 없다. 이 외부의 기술에 의존을 하지 않는다는 원칙을 위배하기 때문이다. 그래서 내부에서 항상 동일하게 동작 할 수 있도록 변환하는 작업이 필요하다.
이런식으로 데이터의 변환이 있을 가능성이 높았고, 외부와 통신하는 Request 객체에는 따로 값이 제대로 전달 되었는지 등의 검증로직도 넣을 수 있다. 이런 이유로 외부와의 통신이 있는 DTO는 테스트 코드를 작성 해야 한다.
JPA를 사용하고, Spring Data JPA를 사용해서 구체적인 구현을 하고 있다고 예시를 들어보자.
@Service
public class PostService {
private final PostRepository postRepository;
@Autowired
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
// 게시글 생성
public Post createPost(String content) {
Post post = new Post(content);
return postRepository.save(post);
}
// 게시글 조회
public Optional<Post> getPost(Long id) {
return postRepository.findById(id);
}
// 게시글 수정
@Transactional
public Optional<Post> updatePost(Long id, String newContent) {
Optional<Post> optionalPost = postRepository.findById(id);
optionalPost.ifPresent(post -> post.setContent(newContent));
return optionalPost;
}
// 게시글 삭제
public void deletePost(Long id) {
postRepository.deleteById(id);
}
}
save(), findById(), deleteById()와 같은 동작을 일일이 테스트 할 필요가 있을까? 또 게시글의 수정과 같은 경우는 Dirty Check로 동작했기 때문에 따로 update라는 동작도 없다.
만약 JPA를 처음 접한 사람이라면 학습의 목적으로 테스트코드를 작성하는 것은 공부에 도움이 된다. 그리고 본격적으로 사용법을 알게된 이후라면 단순히 DB와의 연결 자체를 단순히 테스트 할 정도로, 1~ 2개 정도의 동작만 테스트 해 봐도 충분하다는 입장이다.
동작에 대한 검증은 해당 프레임워크의 개발자들과 사용자에 의해서 검증 되었다고 보자는 입장이다. 문제가 있었다면 커뮤니티 내에서 끊임없이 문제가 제기되고 수정이 적용 되었을 것이다.
다만 직접 구현하면서 여러 DB의 객체를 다루고 수정하면서 제대로 동작자체가 미심쩍거나, Native Query와 같이 사용자가 직접 개입한 구현이 있어서 검증이 필요한 메서드는 반드시 테스트 코드를 작성해야 한다.
내가 백엔드 개발자이기 때문에 이런 생각이 강한 것인지 모르겠지만, Controller는 굳이 테스트코드가 필요할까란 생각이 든다. Controller는 어떤 End Point요청에 어떤 방식으로 데이터를 전달 했을 때, 어떤 형식으로 데이터를 받는 것을 확인하는 테스트하는 구조가 된다. 단위 테스트를 최대한 지향하면 결국은 이 Controller도 Mocking을 통한 테스트를 쓰게 된다.
특정 End Point에 어떤 데이터로 통신하고 어떤 값을 받게 될 지는 사실 Request, Reseponse 객체에 이미 나타난 사실이다.
어떤 방식으로 통신하고 응답 받을 것인가에 대해서는 Controller에 나타나는데 이 부분을 테스트하는 것은 상대적으로 효용가치가 떨어진다고 개인적으로 생각한다. Controller에 대해서는, API 테스트 툴을 활용해서 직접적인 통합 테스트를 해 보는게 더 값진 결과라고 개인적으로 생각한다.
사실 100% 테스트코드를 만드는 것이 이상적인 것은 알고 있다. 그럼에도 현실적인 이유로 타협하는 지점을 가져야 한다고 생각한다.(생산성 문제)
결국 결론은 직접 구현하게되는 코드들은 (특히 도메인 코드) 끊임없이 테스트하고 변경 가능성도 있기에 언제나 의도한대로 제대로 동작하는지 테스트 하는게 맞다고 생각한다. 그리고 상대적으로 외부의 기술은 비교적 적은 테스트를 가져도 좋고, 외부에 드러나는 결과인 프레임워크 헥사곤 영역은 내부 영역이 튼튼하게 설계 되었다면 자연스럽게 완성된 결과를 가져올 것으로 믿자는 관점이다.
헥사고날 아키텍처를 구현하면서, 그리고 TDD를 공부하고 테스트코드를 작성해 가며 어렴풋이 알 것 같았던 핵심은 '몰?루'라는 자체 결론을 내리게 되었다.
도메인, 애플리케이션, 프레임워크 각 헥사곤에서 중요한 것은 외부의 것은 전혀 알지 못하는 상태여야만 했다. 가장 내부인 도메인 헥사곤은 특히 외부의 구현 될 기술 자체를 모르고, 단순히 타입이나 행동을 정의하고, 내부 규칙 등 만을 포함하고 있어야 한다.
이 간단한 원칙을 실제 코드로 구현 해 보면 결코 쉽지는 않았다. 자연스럽게 DB나 UI 등의 실제 구현을 자꾸 생각하게 되고, 코드로도 미래의 구현을 생각해서 코딩 스타일이나 형태 자체를 미리 예상하고 만들기 때문에 일종의 기술오염 현상이 종종 나타났다. 물론 아직도 남아있는 코드가 있을지 모르겠지만 발견을 하게 되는 경우가 있다면 다시 수정하고 반복 했다.
물론 100% 완벽이라는 것은 없고 어느 정도 상충을 하는 부분이 생길지도 모르겠으나, 최대한 그 원칙을 노력하려고 했다.
어느 시점에 '이 객체는 어디까지 알아야 할까?' '이것은 알 필요가 없는 정보이다.' 와 같은 판단을 해 보면서 구현을 해보려고 했다.
결국 결론은 최대한 모르는 것이 유리하다. 각 헥사곤이 서로에 대해 알지 못할수록, 의존성이 줄어들어 변경에 더 유연하게 대응할 수 있다. 이는 테스트가 쉬운 코드, 변경 가능성이 있는 부분을 격리시킨 코드로 이어진다. 도메인, 애플리케이션, 프레임워크 각 헥사곤이 서로의 세부 구현을 몰라도 협력할 수 있도록 설계하는 것이 헥사고날 아키텍처의 핵심이다. 이를 통해 미래의 변화에도 탄력적으로 대응할 수 있는 시스템을 만들 수 있을 것이다.
그래서 나는 이번 구현을 통해서 많은 것을 모르는 개발자를 목표로 하게 되었다!
설계 구조에 있어서는 몰?루는게 힘이다! Ignorance as Strength!