과거 프로젝트를 진행하며 Request DTO의 Int 필드에
Json null 데이터가 0으로 저장된 원인에 대해 살펴보겠습니다.
버그 당시는 우선 @Min(1)을 통해 핫픽스를 진행했습니다.
단순히 String은 null이 예외처리되고, Int는 0이 된다로 소중한 경험을 마무리 짓기보다
무슨 원리로 null이 0으로 변환되어 들어가게 됐는 지, RequestBody의 매핑 과정을 통해 그 이유를 찾아보겠습니다.
상황 예시
당시 상황의 코드를 간략화하면 다음과 같습니다.
코틀린을 사용할 때, Body의 DTO로 자주 이용하는 Data Class를 이용해 컨트롤러를 작성했습니다.
Int, String 둘 다 엘비스 연산자 (?) 가 없으므로 NotNull이 필요하지 않습니다. (하지만 이해의 편의를 위해)
과거 저는, age name 모두 null 값은 들어온다면 400에러가 발생할 것으로 예상했습니다.
하지만 Test 결과는 멀쩡하게 200의 응답코드와 0이라는 age 값을 반환하는 것을 볼 수 있습니다.
코틀린의 Int는 자바의 원시형 int로 매핑되며
이 곳에 null 값을 넣으려 한다면 반드시 Exception이 발생할텐데 무슨 일로 0이 들어간 걸까요?
전부터 궁금했던 서블릿의 DTO 매핑 과정을 단계별로 확인하며 null이 0으로 변한 원인을 알아보겠습니다.
제가 현재 이해하고 있는 스프링의 흐름을 도식화한 그림입니다.
크게 row Http 요청을 받게 되는 서블릿과 비지니스 로직을 순수 자바 코드만을 사용해 처리하는 MVC 부분으로 나누어 이해하면 쉬울 것 같습니다.
따라서 프론트 컨트롤러를 담당하는 디스패처 서블릿은 Http 요청에 맞는 컨트롤러를 찾고 추가적으로
Body의 Json Data를 자바 객체로 바꿔주는 역할을 합니다.
(김영한 님의 스프링 MVC 1편 요청 매핑 핸들러 어댑터 구조 참고)
핸들러 어댑터가 바로 이 RequestBody를 생성하는 위치입니다.
사실 이 Body 뿐 아니라 RequestParam, ModelAttribute 등 순수 패킷 데이터와 자바 객체 간의 전환하는 모든 작업을
이 곳에서 수행한다고 합니다.
즉. 컨트롤러를 만들어보면 쉽게 볼 수 있는 주렁주렁 달린 여러 객체들을 핸들러 어댑터에서 변환하며
구체적으로 이러한 각각의 변환은 모두 ArgumentResolver 인터페이스의 구현체들을 이용해 변환합니다.
라이브러리 코드로 따라가기
자 이제 우리가 원하는 RequestBody Converting 포인트를 찾기 위해선
RequestBody를 객체로 변환하는 ArgumentResolver를 구현체를 찾아 보겠습니다.
위 주석을 보면 클래스가 RequestBody와 ResponseBody를 해석하는 클래스라고 합니다.
더 나아가 Validation이 Failed 한다면 400 Error를 던질 것이라고 적혀있네요.
실제로 상속 관계를 보면 다음과 같이 HandlerMethodArgumentResolver를 상속하는 것을 볼 수 있습니다.
이 클래스에서 객체를 리턴하는 핵심이 되는 메소드인 resolveArgument 내부의 Object 처리 로직을 따라가다 보면
추상클래스에서 구체적인 구현 로직이 나오게 됩니다.
매개 변수와 내부 코드를 보아하니 Request의 Header와 param 등을 분석해 Object를 만들기 위한
적절한 Convert 구현체를 찾는 코드로 보입니다.
이러한 단서들을 기반으로 Convert 구현체를 선정했다면 read함수를 실행해 body에 들어갈 Object를 구해옵니다.
정확히 Jackson 클래스가 주입되는 위치를 찾지는 못했으나, 스프링이 Jackson 라이브러리를 채택하고 있기에
주입될 Json converter로는 MappingJackson2HttpMessageConverter을 변환에 사용할 것으로 예측됩니다.
이 클래스의 read 함수 구현은 다음과 같습니다.
inputMessage의 Body내용을 inputStream으로 바꾸고 이를 다시 objectReader를 활용해 해석합니다.
Jackson에서는 IntDeserializer를 이용해 inputStream을 int로 읽습니다.
이제 마지막입니다.
만약 inputStream이 없을 경우 _empty라는 인스턴스 필드 값을 리턴하게 됩니다.
이제서야 그토록 찾아 해매던 0이라는 값을 발견할 수 있었습니다.
즉 원시형 int에는 null을 집어 넣는 행위를 하지 않고, 그냥 0으로 바꿔서 넣어버립니다.
애초에 Int Serializer가 "age":null을 0으로 해석한 시점에서
age가 원시형이든 @NotNull 어노테이션이 달려 있든 무시하게 됐던 겁니다.
어떻게 처리해야 하는가
결국 null이 0이 되는 건 어쩔 수 없습니다. (int 직렬화를 새롭게 만드는 건 비효율적이고 위험합니다.)
다음과 같은 방법들로 제어가 가능할 것 같은데요.
1. Int?를 사용해 int 래핑 클래스로 매핑하기
data class UserInfo(
@field:NotNull val name : String,
val age : Int?
){
init {
if(age == null){
throw IllegalStateException()
}
}
}
앞서 서술했듯, 코틀린의 Int -> 자바의 int (원시형), Int? -> 자바의 Integer
로 취급되는 것을 이용해 null로 매핑한 후, 생성자 주입 시점에서 null을 제한하는 것입니다.
이 방식을 사용하면, 원시형을 사용하지 않기 때문에 null로 들어온 Json 데이터가 정확히 null로 변환된다는 장점이 있습니다.
2. Request DTO 검증 구체화 하기
data class UserInfo(
@field:Length(min=1, max=8)val name : String,
@field:Min(20) @field:Max(30)val age : Int
)
끝으로 Int를 사용하돼, 검증 조건을 강화하는 것입니다.
저는 앞으로의 DTO 생성에선 이 방법을 사용할 것 같습니다.
field 어노테이션을 통해, 이름의 크기를 1~8자로 제한, 나이는 20~30세 사이인 데이터만 허락하겠다
혹은 init 시점에서 원하는 검증을 추가적으로 진행할 것 같습니다.
배운 점
단순히 어노테이션을 달아놓으면 Json이 객체로 변환된다만 알고 쓰던 개발자에서
스프링에서 MVC 분리를 위해 내부에 어떤 과정을 거쳤고, 어떤 클래스를 사용했는 지 조금 알게된 것 같습니다.
아직 InputStream과 같은 직렬화에 대한 지식이 부족하다고 느꼈고, 추후 공부를 진행해야 할 것 같습니다.
스프링이 아니더라도, Controller에서 객체를 매핑하는 모든 과정이 비슷하지 않을까 추측합니다.
또한 스프링의 라이브러리 코드를 하나하나 보는 과정에서, 추상 클래스와 인터페이스가 정말 많이 쓰이는 것을 볼 수 있었습니다.
대부분이 구현체를 생성하고 이를 인터페이스로 업캐스팅을 통해 구현보단 인터페이스에 의존하는 것을 볼 수 있었습니다.
또한 추상 클래스 상속을 통해 공통적인 설계도를 미리 짜놓고, 추가적인 확장에는 열려 있도록 설계하는 형태가 많았습니다.
앞으로 제 코드에 SOLID 원칙을 적용하기 위해선 정교하게 짜여진 스프링의 코드들을 참고하면 좋을 것 같습니다.
'회고' 카테고리의 다른 글
우테코 프리코스 후기 (1) | 2024.11.16 |
---|---|
[회고] 우아한테크코스 프리코스 7기 1주차 (0) | 2024.10.29 |