코드숨 5주차 - Validation & Exception & DTO has a Entity, 그 속으로

gibeom·2022년 11월 13일
1

멘토링

목록 보기
5/15
post-thumbnail

이번 주에는 지난주에 개발했던 장난감(Product) API에 요청 값 검증(validation) 기능과 회원 생성, 수정, 삭제 API를 TDD로 구현하는 미션이었다.
이제 코드숨의 절반이 지나는 5주차인데, 다 끝나지 않은 현시점에도 종립 멘토님과 영환 멘토님 덕분에 많은 것을 배우고 있다는 것을 느끼면서 코드숨 시작 전의 내 모습과 지금의 내 모습을 볼 때 꽤 성장한 것 같아보여 뿌듯함을 느꼈다.
또한 이번 주는 체감상 가장 많은 것을 깨달았던 주차이기도 하면서, 동시에 일정이 너무 빡쌔기도 한 주차였다..! 분명 이번 주 미션들의 분량은 그렇게 많지 않았는데 메인 미션 시작을 금요일날 시작했다.

왜 메인 미션을 금요일날부터 시작하게 됐는지, 그러면서도 왜 이번 주가 가장 많은 것을 습득했던 주차였는지 하나씩 소개해보려고 한다.


Exception과 HTTP를 분리하기

https://velog.io/@beomdrive/Exception과-HTTP를-분리해보자
배운 점 : 객체 지향적 관점을 늘리게 된 경험이었다.

먼저 기존에 스스로 개발해보았던 Exception 핸들링에 관한 부분을 객체지향적으로 개선하는 과정이다. 나는 2주차 때 Exception을 핸들링해보자! | @RestControllerAdvice라는 블로그 글을 작성하면서 전역적으로 발생하는 Exception에 대해서 핸들링을 했었다.
여기서 종립 멘토님은 아래와 같은 피드백을 주셨다.

Exception에서 HTTP 관련 코드를 떼어낸다면 어떤 것이 좋을까?
객체지향에서 의존이란 무엇일까?
떼어낸다면 어떻게 떼어내야 할까?
이런 생각의 흐름을 해당 주제 밑에 링크해 놓은 블로그에서 정리해놓았다.

요약하자면 Exception 객체는 “유의미한 예외 정보를 리턴”하는 책임을 가지고 있고, HTTP는 여러 네트워크 통신 방법중에 하나이다. 즉, HTTP 통신이 아닌 상황에서는 해당 Exception 객체를 재사용할 수 없게 된다.
따라서 대부분의 현업에서는 HTTP 통신을 위주로 하기 때문에 Exception 객체에서 HTTP를 관리하는 것이 실용적일 수는 있지만, 객체지향적인 관점에서는 관심사를 분리해주는 것이 바람직해 보였다.



Exception 객체 관리

위의 블로그와 동일 : https://velog.io/@beomdrive/Exception과-HTTP를-분리해보자
배운 점 : 객체지향 언어인 Java를 잘 사용하는 방법에 대해서 다시 한번 생각하게 된 경험이었다.

나는 처음에 예외 객체의 관리를 모든 공통 부모인 HttpBusinessException으로 묶어서 관리하려고 했었다.
그 이유는 “모든 비지니스 로직에서 논리적 에러가 발생했을 때 예외를 던진다”라는 공통점이 있다고 생각했기 때문이다.

하지만 이것은 모든 계층 간 데이터 전달값을 DTO가 아닌 HashMap으로 사용하는 꼴이라는 것을 리뷰를 통해 깨우쳤다.
아래는 예외 객체에 대한 나의 첫 설계이다. 이렇게 HttpException으로 모두 묶어버린다면 각기 다른 처리를 유연하게 하지 못한다는 특징이 있다.


프로젝트와 언어와 프레임워크를 꿰뚫는 원칙을 잘 고민해 볼 필요가 있죠. 예를 들어 이런 겁니다. "그냥 전부 HashMap을 써도 되는데 왜 Entity와 DTO를 만드는가?" 같은 거죠. Java에서는 섬세하게 나눠진 타입을 만들어 문제로 접근해갑니다. 타입이 문제 해결의 핵심 도구 중 하나가 되는 거죠.
물론 이게 "답"은 아닙니다. 전혀 다른 접근을 선택한 라이브러리나 언어 ,프레임워크도 있죠. 예를 들어 HashMap으로 모든 것을 처리하는 것을 더 선호하는 언어도 있어요. js 라던가 clojure라던가 php가 그렇죠.
더 Java 다운 코드를 작성하고 싶다면 사고 방식의 핵심에 타입을 두고 논리를 펼쳐나가거나 문제에 접근해보는 것도 훈련해 볼 필요가 있어요.

Java는 타입이 있는 언어이다. 또한 Java에서 객체란 사용자 정의 타입이다. 따라서 Java 언어를 잘 사용하는 방법은 타입을 잘 활용하면서 객체 지향적으로 잘 분리하는 것인 것 같았다.
따라서 위의 방법처럼 모든 예외를 하나로 묶어버리기 보다는 각각의 예외를 따로따로 분리하는 것이 결합도를 낮추는 방법인 것 같았다.

나는 아래와 같이 예외 객체를 관리함으로써, 각 예외에 대해 결합도는 낮추고 응집도를 높이면서 유연하게 사용할 수 있도록 설계해보았다. (자세한건 블로그에 다 기록해놓았다.)



Spring은 charset = “UTF-8”을 사용하지 않는다

MockMvc 테스트 시 인코딩 문제로 검증 실패 | MockMvcBuilderCustomizer
배운 점 : Spring 5.2부터는 charset을 사용하지 않고, 기본 인코딩 문자가 더 이상 UTF-8이 아니다.

나는 아래와 같이 MockMvc를 사용하여 컨트롤러의 웹 통합테스트를 작성하였다.

@Test
void list() throws Exception {
    mockMvc.perform(
                    get("/products")
                            .accept(MediaType.APPLICATION_JSON_UTF8)
            )
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("쥐돌이")));
}

멘토님은 아래와 같은 질문을 주셨다.

그러면서 노션이나 블로그를 사용하여, 문제의 상황 - 원인 - 해결 - 참고자료 순으로 정리해볼 것을 권유하셨다. 따라서 나는 위에 기재해 놓은 블로그를 통해서 학습 내용을 정리해보았다.

요약하자면 Chrome 같은 메인 브라우저들이 이제는 charset=UTF-8 설정이 없어도 UTF-8 특수문자를 잘 해석하기 때문에 스프링은 charset을 사용하지 않는 방향으로 나아갔고, 그로 인해 MediaType.APPLICATION_JSON_UTF8 또한 자연스럽게 Deprecated가 된 것이다.
APPLICATION_JSON_UTF8은 (application/json; charset=UTF-8)의 속성 값을 가지고 있기 때문이다.

따라서 나는 스프링의 의도대로 charset을 사용하지 않고 깨지는 테스트를 피하려면, 테스트의 결과 값을 바꾸는 것이 맞다고 판단하여 아래와 같이 설정해주었다.

import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcBuilderCustomizer;
import org.springframework.stereotype.Component;
import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder;

import java.nio.charset.StandardCharsets;

// @AutoconfigureMockMvc를 사용하여 MockMvc를 주입하는 경우에 한해서만 적용됨
@Component
class MockMvcCharacterEncodingCustomizer implements MockMvcBuilderCustomizer {
    @Override
    public void customize(ConfigurableMockMvcBuilder<?> builder) {
        builder.alwaysDo(result -> result.getResponse().setCharacterEncoding(StandardCharsets.UTF_8.name()));
    }
}

확실히 블로그를 통해서 생각을 정리하는 과정을 가져야 머릿속에 잘 남는 거 같았다.

앞으로도 개발을 하다가 어떠한 벽에 가로 막힌다면 문제 상황 - 원인 - 해결 - 참고자료 순으로 정리하여 블로그에 기록하며 해결해 나갈 것 같다.



ResponseDTO has a Entity

RestController에서 ResponseDto로 멤버변수 없이 반환하기 | MappingJackson2HttpMessageConverter
배운 점 : Controller에서 클라이언트로 응답할 때, 변수 없이 getter만으로도 정상적으로 잘 반환된다.

나는 개발을 하다가 아래의 내용이 궁금해서 멘토님에게 질문을 드렸다.

모든 도메인에서 동일하게 RequestDto -> Command -> Entity -> ResponseDto 변환이 진행할 것으로 보이는데, 해당 인터페이스를 공통 객체로 추상화를 하고싶은데, 어떤식으로 진행해야될 지 모르겠습니다...😂

그러자 종립 멘토님은 감사하게 멘토님의 노하우를 하나 공유해주셨다.
그 중에서 응답을 하기 위해 안에서 바깥으로 나가는 방향(Service → Controller)에서 Response 객체를 구성하는 방법도 소개해주셨다.


말 그대로 아래의 코드처럼 ResponseDto가 Entity 객체를 몰래 가지고 있는 방법이다.

public class CreateProductResponseDto {
    @JsonIgnore
    private Product product;

    public CreateProductResponseDto(Product product) {
        this.product = product;
    }

    public Long getId() {
        return this.product.getId();
    }

    public String getName() {
        return this.product.getName();
    }
}

여기서 나는 내 상식 선에서는 “변수가 없는데 어떻게 반환이 되는거지..?” 라는 의문이 들어 이해가 되지 않았었다. 이해가 안된다면 어떻게 해야될까?
위에서 다짐한 것 처럼 문제 상황 - 원인 - 해결 - 참고자료 순으로 위에 기재해 놓은 블로그 링크에 정리해봤다.

요약하자면 일반 @Controller는 응답 반환 시 ViewResolver로 전달되고, @RestController 또는 일반 @Controller@ResponseBody가 붙어있다면 MessageConverter로 전달된다.

이 때 MessageConverter는 반환 타입에 맞는 Converter로 위임을 하는데, 여기에서 나는 CreateProductResponseDto를 통해 객체에 데이터를 담고, application/json 콘텐츠 타입으로 반환되므로 MappingJackson2HttpMessageConverter로 응답이 위임된다.

이 때 Jackson이 데이터를 매핑할 때 사용되는 방식이 Java의 프로퍼티(getter, setter)를 통해 매핑하므로, 별다른 변수를 선언하지 않아도 getter만 존재한다면 정상적으로 반환되는 것이었다.
추가로 getter 사용 대신 멤버 변수로 사용하고 싶다면, @JsonProperty를 지정해주면 된다.


이처럼 위와 같은 학습을 하느라 메인 과제를 금요일부터 시작하게 되었다. 하지만 미션보다 더 중요한 내용들을 알차게 학습했다고 생각해서 너무 뿌듯하고 좋은 주차였던 것 같다.



추가로 평소에 헷갈렸던 TDD에 관한 내용을 작성해보겠다.

TDD와 MVC 계층(Repository/Service/Controller)의 개발 순서는 별개이다

TDD 방법론은 단순히 프러덕션 코드를 작성하기 전, 테스트를 먼저 작성하는 방법이다.
따라서 TDD는 일반 MVC 계층 구조의 서비스 레이어인 (R/S/C)와는 완전히 별개이므로, 분리해서 생각하여야 한다.

나는 TDD에서는 Classicist TDD와 Mockist TDD를 통해 MVC 계층 구조를 Inside-Out 방식 혹은 Outside-In 방식을 사용한다는 것에 집중했었는데, 본질을 제대로 이해해야 될 것 같다.
TDD는 단순히 테스트를 먼저 작성하는 개발 방법론일 뿐이고, 그 위에 MVC 같은 계층 구조에서의 개발 방법이 Inside-Out 방식 혹은 Outside-In 방식으로 도출되는 것인 것 같다.

일반적인 TDD 방법론에 따른 개발 순서는 다음과 같다.

  1. 테스트를 먼저 작성해서 테스트가 실패할 수 밖에 없는 상황을 만든다. (프러덕션 코드가 없기 때문)
  2. 테스트 코드의 피드백을 받으며 테스트가 성공되도록 코딩한다.
  3. 테스트가 성공되게 프러덕션 코드를 작성했다면, 리팩토링을 진행한다.
  4. 1 ~ 3 반복

테스트 작성은 제일 먼저 생각나는 테스트 케이스를 테스트 코드로 만들면서 작업하면 된다.

코드숨에서 주어진 과제에서의 추천 방법은 다음과 같다.

  1. 모든 게 올바르게 동작하는 것을 가정하는 Happy path에 대한 테스트를 먼저 작성한다.
  2. 테스트가 통과되도록 구현 후에 리팩토링을 진행한다.
  3. 예외 케이스에 대한 테스트를 작성하고 구현, 리팩토링을 진행한다.

이런 순서를 추천하는 이유는 처음부터 너무 많은 기능과 예외 케이스를 생각하다 보면 복잡성에 압도되어 구현하기 어려워지기 때문이라고 한다.


정리해보자!
TDD와 Repository/Service/Controller의 개발 순서는 서로 영향을 주지 않는다. 서로 관련 없는 별개의 개념이니 이를 혼동하지 말자.
그렇다면 만약 MVC 계층 구조에서 TDD를 통해 Controller를 먼저 구현하고 싶다면 어떻게 해야될까?
Controller 테스트를 먼저 만들면 된다.

하지만 일반적인 MVC 구조에서의 Controller 테스트는 보통 Service를 통하여 원하는 응답 값이 잘 반환되는지 확인할 것이다.
그럼 이때 Test Double을 사용하면 되는 것이다. Test Double은 여러가지가 있지만 대표적으로는 Mock, FakeObject 등이 있을 것이다.

TDD 관련해서 개념들이 헷갈렸는데 종립 멘토님께서 제대로 잡아주셔서 다시 한번 정리하게 된 것 같다 😊

profile
꾸준함의 가치를 향해 📈

1개의 댓글

comment-user-thumbnail
2023년 10월 15일

덕분에 좋은 내용 잘 보고 갑니다.

ResponseDTO has a Entity 개념은 신기하네요!
링크 달아주신 노션은 권한이 없다고 볼 수가 없네요 ㅎㅎ.

답글 달기