1. 사건의 발단
날씨 앱을 만드는 과정에서
사용자가 설정창에서 ON 해놓은 정보들만 메인 화면에 띄울 수 있는 기능이 있다.
그래서 dto를 이렇게 잡고 시작했다.
public record DisplayDto(
boolean precipitation,
boolean wind,
boolean dust
) {}
자.. 이제 수정 하기 위해 코드를 짰다.
// 메인 화면 날씨 상세 정보 보기 (강수량, 퓽향/풍속, 미세먼지)
@PutMapping("/display")
public ApiResponse<String> updateDisplay(@AuthUser User user, @RequestBody SettingReqDto.DisplayDto displayDto) {
settingService.updateDisplay(user, displayDto);
return ApiResponse.onSuccess("메인 화면 날씨 상세 정보 보기 설정이 완료되었습니다.");
}
// 메인 화면 날씨 상세 정보 변경
public void updateDisplay(User user, SettingReqDto.DisplayDto displayDto) {
Setting setting = user.getSetting();
setting.setPrecipitation(displayDto.precipitation());
setting.setWind(displayDto.wind());
setting.setDust(displayDto.dust());
settingRepository.save(setting);
}
2. 개요
그런데 화면을 보면, 3가지의 값에 대한 토글이 있고, on/off를 할 수 있다.

그런데 여타 다른 화면에서도 토글들이 너무 많고
한 토글 당 한 API를 만들다 보니,
API 명세서와 코드가 너무 길어지고 지저분해졌다.
그래서 이렇게 이 정도의 한 페이지는 DTO로 묶었다. (기능상도 명확해서)
그리고 변하는 값만 프런트에게 보내달라고 했다.
- 여담 : boolean값의 JSON의 형식
난 처음에... 바보같이 boolean 값을 따옴표와 함께 json으로 전달해버렸다.
(추후에 이것 때문에 문제가 터진 줄 알았지만... 아니었다...)
어떻게 보내야 할까?
{"some_parameter": "true"}
또는
{"some_parameter": true}
정답은 2번이다..
결론 : 따옴표 안에 문자열 이외의 다른 내용을 입력하면 안 된다.
정수 및 double 값에도 동일하다.
참고자료 : https://stackoverflow.com/questions/51936278/can-should-boolean-values-be-passed-in-json-with-quotes
3. 테스트
(각 테스트 마다 default로 모두 true로 놓고 실행)
전체 변경 PUT에 알맞은 모든 정보를 보냈다?!

당연히~잘 변경되는 것을 알 수 있다.....
자, 이제 일부만 변경하는 방법을 생각해보자.
1. 일부만 보낸다!

-> 나머지 값들이 False로 다 변했다..
2. ""를 보낸다.

사실 이것은 당연하다...
PUT 메서드를 사용하는 클라이언트는 해당 자원의 상태를 모두 알고 있다고 가정되어야 한다.
PUT 메서드는 요청 경로에 자원이 존재하는 경우 해당 자원을 payload 정보와 교체하는 메서드이다.
즉, PUT 메서드를 사용할 때 전송하는 payload만으로 자원의 전체 상태를 나타낼 수 있어야 한다.
새로운 자원을 생성해야 하는 경우 완전한 상태의 자원을 저장해야 하고 새로운 자원으로 대체하는 경우 대체하는 자원이 완전한 상태를 가지고 있어야 하기 때문이다.
만약 PUT의 정의대로 전달 받은 payload가 기존 정보를 대체하도록 구현한 경우 payload 정보가 불완전한 상태로 전송된다면 일부 entity의 field값들은 null로 변경될 수 있다.
4. PATCH!? 테스트
그리고 바보처럼 PATCH를 안쓰고 뭘 했지?

당연히, 전체를 보내면 잘 온다.
일부를 보내보았다.


그냥.. 똑같이 나머지가 false로 변해버렸다..
5. 이유
일단 실행 순서를 보자.
SettingReqDto.DisplayDto displayDto로 request를 받는다.
그러나 JSON 데이터에 존재하지 않는 필드(precipitation, wind)는 해당 타입의 기본값으로 설정된다.
boolean 타입의 기본값은 false이다.
그래서.....precipitation과 wind 필드에는 false 값이 할당되었던 것.
(사실 이정도는 예상했다.. 가 아니고 JAVA의 기본ㅎ..)
6. NULL 로 처리하기?
NULL을 그냥 넘겨 버리고, 그걸 처리하면 되지 않나??
public record DisplayDto(
Boolean precipitation,
Boolean wind,
Boolean dust
) {}
public void updateDisplay(User user, SettingReqDto.DisplayDto displayDto) {
Setting setting = user.getSetting();
if (displayDto.precipitation() != null) {
setting.setPrecipitation(displayDto.precipitation());
}
if (displayDto.wind() != null) {
setting.setWind(displayDto.wind());
}
if (displayDto.dust() != null) {
setting.setDust(displayDto.dust());
}
settingRepository.save(setting);
}
- 혹은 삼항 연산자로 간단하게!
public void updateDisplay(User user, SettingReqDto.DisplayDto displayDto) {
Setting setting = user.getSetting();
setting.setPrecipitation(displayDto.precipitation() != null ? displayDto.precipitation() : setting.getPrecipitation());
setting.setWind(displayDto.wind() != null ? displayDto.wind() : setting.getWind());
setting.setDust(displayDto.dust() != null ? displayDto.dust() : setting.getDust());
settingRepository.save(setting);
}

자... 원하는 대로 해결됐따!!
7. boolean? Boolean?
dto의 타입을 boolean으로 주고 빈값일때 false로 떴다.
그이유는 boolean은 primitive type이기 때문에 null을 못받기 때문 boolean의 기본 타입은 false으로 변환된것.......
null이 들어와도 false로 받게된 것이다!
-> 그래서 dto에서는 nullable한 타입을 쓰는게 좋다고하여 Boolean으로 변경해주었다
이것만 바꿔주면, 어차피 오토 박싱, 오토 언박싱 기능도 제공하기 때문에.. 큰 문제가 없다고 판단.
https://jaehoney.tistory.com/101
-primitive Type (기본형 타입)
null을 가질수 없다 만약 null 값을 넣고 싶다면 wrapper class를 활용해야한다.
스택에 메모리가 저장된다.
-Reference Type 참조형 타입
primitive type을 제외한 모든 타입들은 참조형 타입
null을 담을 수 있다.
하지만 런타임 에러가 발생한다
그래서 NullPointException(NPE)이 발생하므로 변수의 값을 넣어야한다.
(그놈의 NPE!!!)
메모리의 힙에 실제 값을 저장하고 그 참조값을 갖는 변수는 스택에 저장한다.
클래스타입, 인터페이스 타입, 배열타입, 열거타입이 있다
- 래퍼클래스 Wrapper class
8개의 기본 타입에 해당하는 데이터를 객체로 포장해주는 클래스
Boolean은 기본자료형인 boolean을 박스화한(boxed primitive type) 자료형인 것이다.
래퍼클래스의 부모는 Object
래퍼클래스를 사용하는 이유: 기본 자료형을 클래스화 하므로 클래스의 장점을 갖게하기 위해서
사실 여기서 끝내도 된다....
8. 끝인가? 또 문제는 없는가?
바로 개발자의 마인드...
뭔가 쉽게 풀리면 빈틈이 많다는 느낌이 든다..
바로
서버 입장에서는 클라이언트가 정말 의도해서 값을 null로 준건지,
아니면 그냥 값을 아래와 같이 안 줘서 null로 온건지 확인할 방법이 없다는 점이다.
즉 아래의 이 요청과
{
"dust":false
}
이 요청을 구분할 방법이 없다는 말이다.
{
"wind":null,
"dust":false
}
9. NULL 명시적으로 처리하기
public record DisplayDto(
Optional<Boolean> precipitation,
Optional<Boolean> wind,
Optional<Boolean> dust
) {}
Optional을 사용하면 속성이 존재하지 않는 경우를 명시적으로 표현할 수 있고,
null을 허용하는 방식은 속성이 null인 경우 변경하지 않는 것으로 처리할 수 있다!!
-- 사실 여기서 끝내도 된다... 하지만... 또 끝까지 파고들어보자...
10. Optional의 사용
보통 Optional 하고 끝내도 되지만, 나는 욕심이 많다.
원래 Optional이 이런 용도인가?
이렇게 해도 작동하긴 하겠지만, 좋은 생각은 아닌 것 같았다.
1. Bean은 Optional 필드를 가질 수 없다.
Optional은 오로지 메서드의 반환 값으로 사용되기 위해 설계된 것이다.
2. Optional은 절대로 null이 될 수 없다.
Optional의 목적은 null을 감싸 NPE(Null Pointer Exception)을 방지하기 위한 것이므로, 코드 내부에서 Optional이 Null이 되도록 설계해서는 안된다.
따라서 Optional을 반환하는 메서드를 호출하는 코드는 Optional이 null이 아님을 항상 확신할 수 있어야 한다.
가장 중요한 것은 Optional이 null이 될 가능성을 두면 안된다는 것이다.
아래는 우리가 흔히 Repo에서 User를 Optional 형식으로 가져올 때 나타나는 노란경고줄이다.


여기서도 알 수 있듯이 말이다..
모야? Null 체크만 해주면 되네~
public record DisplayDto(
Optional<Boolean> precipitation,
Optional<Boolean> wind,
Optional<Boolean> dust
) {
@JsonCreator
public DisplayDto(
@JsonProperty("precipitation") Boolean precipitation,
@JsonProperty("wind") Boolean wind,
@JsonProperty("dust") Boolean dust
) {
this.precipitation = Optional.ofNullable(precipitation);
this.wind = Optional.ofNullable(wind);
this.dust = Optional.ofNullable(dust);
}
}
따라서 위의 코드에서는 @JsonCreator 및 @JsonProperty를 사용하여 직렬화 및 역직렬화 과정에서 명시적으로 값의 존재 유무를 처리한다.
@JsonCreator는 해당 클래스의 생성자가 JSON 데이터를 사용하여 객체를 생성할 때 사용된다.
@JsonProperty는 JSON 데이터의 필드와 해당 클래스의 필드를 매핑할 때 사용된다.
이를 통해 JSON 데이터가 제대로 처리되고 값이 존재하지 않는 경우에도 올바르게 처리될 수 있도록 한다.
이렇게하면 값이 존재하지 않을 때에도 빈 Optional을 반환하게 되므로, null이 아닌 안전한 방식으로 데이터를 다룰 수 있...
을 줄 알았다...

하지만.. 이런 오류를 맞이 하고 만다.

레코드 클래스의 생성자는 다른 생성자를 호출할 수 없다..........
따라서 @JsonCreator로 지정된 생성자는 다른 생성자를 호출하는 형태로 사용할 수 없다.
이쯤 됐더니 정신이 나갈 거 같아서 타인의 도움을 받기로 했다.
11. JsonNullable과 MapStruct 를 사용
타인의 블로그의 힘을 좀 빌려본다..
Spring Boot : 명시적인 Null 값으로 부분 업데이트(PATCH) 수행하기
Patch API에서 명시적인 Null 값 구분하기
velog.io
요약하자면..
JsonNullable 사용 이유:
JsonNullable은 Optional과 유사하게 동작하지만, 명시적으로 null이 아님을 나타내는 데에 사용.
Bean에는 Optional 필드를 가질 수 없으며, Optional은 오로지 메서드의 반환 값으로 사용된다.
따라서 목적에 맞게 JsonNullable를 선택한다.
MapStruct 사용 이유:
MapStruct는 유연하고, 자동으로 매핑되지 않더라도 매핑 과정을 커스터마이징하는 데 문제가 없다.
컴파일 타임에 코드를 생성하기 때문에 런타임에 실행되지 않으므로 성능이 향상됩니다.
DTO를 모델 클래스와 매핑하기:
MapStruct를 사용하여 DTO와 모델 클래스 간의 매핑을 구현한다.
JsonNullable을 처리하기 위해 JsonNullableMapper를 사용하고, 필드가 null이 아닌 경우에만 업데이트하도록 설정한다.
12. PUT? PATCH?
이쯤에서 고민 되는 것은,
🤔 그래서 일부 수정이니까 PATCH로 쓰고, JsonNullable, MapStruct를 써야하냐!?
이다.

아무리 그래도 일부 수정인데 PUT? PATCH?
보통 PUT과 PATCH에 대해 찾아보면,
엔티티 전체를 수정하는가 일부를 수정하는가 와 관한 얘기가 대부분이다.
(뭐 멱등성이고 뭐고 딮한 얘기는 잠시 접어두고..)
PUT은 어쩌고, PATCH는 일부 변경 어쩌고 정의는 다 안다고 치고,
우리가 흔히 REST API 라고 말하는 것이 진정으로 REST한가.... 부터 의문이다.
아래 띵강의 보고 오면 이해가 된다.
https://youtu.be/RP_f5dMoHFc?si=w0isupnktK3aHaW9
현실적으로 100% REST API를 만들기 위해서는 생각보다 지켜야 할 것이 너무나 많다.
코딩하다보면, 편하자고 한 규칙들이 오히려 발목을 잡고 시간을 끌게 만들 때가 많다.
개인적으로 API의 URI를 통해 대략적으로 자원의 의미를 파악할 수 있고,
Method를 통해 자원의 변화를 파악할 수 있으면 그만이라고 생각한다.
프로젝트원들이 해당 API를 보고 이해할 수 있고, 코드가 문제없이 돌아간다면 끝이라는 이야기다.
PATCH와 PUT의 관계도 비슷하다.
어떤 곳에서는 수정은 무조건 PUT 으로 하기도 하고,
어떤 곳에서는 상황에 따라 PATCH와 PUT을 조금 더 디테일하게 나누기도 한다.
개인적으로는 전자를 선호한다.
- Entity가 상황에 따라 계속해서 변할 수 있기 때문
User Entity에 id와 username만 있었다고 해보자.
{
"id" : 1,
"username" : "나는 나"
}
여기서 PUT과 PATCH를 구분한다면, 이름을 변경하는 API는 PUT으로 만들어야 한다.
현재 필드는 name만 존재하니까 = 전체
그런데 age 라는 필드도 User에 추가되었다면?
그럼, 원래 있던 name만 변경하는 API는 PATCH로 변경되어야 한다.
그 API는 모든 필드를 수정하는 것이 아니라, age와 name중 name만 수정하기 때문이다.
이렇게 되면, API의 method를 변경해야 하는데 이런 작업이 쉬운 것은 아니라고 생각한다...
- 추가적으로 Null의 위험성
PATCH의 경우 정의는 "리소스의 일부분을 수정한다" 라고 알고 있다.
DB, ORM , Database Mapper을 배우다 보면, 느끼는 바가 있다.
{
"id" : 1,
"username" : "나는 나",
"age" : 10
}
해당 정보가 db에 있고,
PATCH 로 age = 20 만을 변경하기 위해서 다음 JSON 형식을 보낸다고 해보자.
{
"id" : 1,
"age" : 20
}
그런데 우리가 ORM을 쓴다거나 요청 mapping에서 DTO라는 객체를 받게 되면,
user_name에 해당되는 field가 null로 적용이 된다.
이는 kotlin, java등 여러 언어에서 parsing할 때 나오는 현상이다.
(지금까지 고생한 것도 이놈의 Null)
그리고 이를 다시 db에 적용하기 위해서 entity로 변환하는 과정에서
userName이라는 변수가 기존에 값이 유지되는게 아닌
실제 객체의 형태는 아래와 같이 될수가 있다는 것이다.
class User {
id = 1
age = 20
userName = null
}
이렇게 되는 경우, userName이 같이 변환이 되는 불상사가 일어날 수 있다.
PUT으로도 모든 변경 사항은 충분하다.
GET을 통해서 정보를 받아오고 PUT으로 정보를 수정함에 있어서
GET에서 받은 부분 중 원본+수정본을 같이 보냄으로써
어떠한 field의 수정이 일어났는지 굳이 서버에서 또 후처리 해줄 필요가 없다는 것이다.
그래서 보통 굳이 PUT보다 PATCH를 사용하지 않는다고 한다..
13. 결론
사실 애초에 설계의 잘못이다.
Dto를 세개로 쪼개던지,...
GET을 통해 원본+수정본을 함께 PUT으로 보내 요구되는 형태로 값을 보내라고 프런트와 입을 맞추던지
아니면 6번 해결책 (Boolean이나, Optional 쓰고 Null 체크) 에서 끝내는게 답이다.
1. 사건의 발단
날씨 앱을 만드는 과정에서
사용자가 설정창에서 ON 해놓은 정보들만 메인 화면에 띄울 수 있는 기능이 있다.
그래서 dto를 이렇게 잡고 시작했다.
public record DisplayDto(
boolean precipitation,
boolean wind,
boolean dust
) {}
자.. 이제 수정 하기 위해 코드를 짰다.
// 메인 화면 날씨 상세 정보 보기 (강수량, 퓽향/풍속, 미세먼지)
@PutMapping("/display")
public ApiResponse<String> updateDisplay(@AuthUser User user, @RequestBody SettingReqDto.DisplayDto displayDto) {
settingService.updateDisplay(user, displayDto);
return ApiResponse.onSuccess("메인 화면 날씨 상세 정보 보기 설정이 완료되었습니다.");
}
// 메인 화면 날씨 상세 정보 변경
public void updateDisplay(User user, SettingReqDto.DisplayDto displayDto) {
Setting setting = user.getSetting();
setting.setPrecipitation(displayDto.precipitation());
setting.setWind(displayDto.wind());
setting.setDust(displayDto.dust());
settingRepository.save(setting);
}
2. 개요
그런데 화면을 보면, 3가지의 값에 대한 토글이 있고, on/off를 할 수 있다.

그런데 여타 다른 화면에서도 토글들이 너무 많고
한 토글 당 한 API를 만들다 보니,
API 명세서와 코드가 너무 길어지고 지저분해졌다.
그래서 이렇게 이 정도의 한 페이지는 DTO로 묶었다. (기능상도 명확해서)
그리고 변하는 값만 프런트에게 보내달라고 했다.
- 여담 : boolean값의 JSON의 형식
난 처음에... 바보같이 boolean 값을 따옴표와 함께 json으로 전달해버렸다.
(추후에 이것 때문에 문제가 터진 줄 알았지만... 아니었다...)
어떻게 보내야 할까?
{"some_parameter": "true"}
또는
{"some_parameter": true}
정답은 2번이다..
결론 : 따옴표 안에 문자열 이외의 다른 내용을 입력하면 안 된다.
정수 및 double 값에도 동일하다.
참고자료 : https://stackoverflow.com/questions/51936278/can-should-boolean-values-be-passed-in-json-with-quotes
3. 테스트
(각 테스트 마다 default로 모두 true로 놓고 실행)
전체 변경 PUT에 알맞은 모든 정보를 보냈다?!

당연히~잘 변경되는 것을 알 수 있다.....
자, 이제 일부만 변경하는 방법을 생각해보자.
1. 일부만 보낸다!

-> 나머지 값들이 False로 다 변했다..
2. ""를 보낸다.

사실 이것은 당연하다...
PUT 메서드를 사용하는 클라이언트는 해당 자원의 상태를 모두 알고 있다고 가정되어야 한다.
PUT 메서드는 요청 경로에 자원이 존재하는 경우 해당 자원을 payload 정보와 교체하는 메서드이다.
즉, PUT 메서드를 사용할 때 전송하는 payload만으로 자원의 전체 상태를 나타낼 수 있어야 한다.
새로운 자원을 생성해야 하는 경우 완전한 상태의 자원을 저장해야 하고 새로운 자원으로 대체하는 경우 대체하는 자원이 완전한 상태를 가지고 있어야 하기 때문이다.
만약 PUT의 정의대로 전달 받은 payload가 기존 정보를 대체하도록 구현한 경우 payload 정보가 불완전한 상태로 전송된다면 일부 entity의 field값들은 null로 변경될 수 있다.
4. PATCH!? 테스트
그리고 바보처럼 PATCH를 안쓰고 뭘 했지?

당연히, 전체를 보내면 잘 온다.
일부를 보내보았다.


그냥.. 똑같이 나머지가 false로 변해버렸다..
5. 이유
일단 실행 순서를 보자.
SettingReqDto.DisplayDto displayDto로 request를 받는다.
그러나 JSON 데이터에 존재하지 않는 필드(precipitation, wind)는 해당 타입의 기본값으로 설정된다.
boolean 타입의 기본값은 false이다.
그래서.....precipitation과 wind 필드에는 false 값이 할당되었던 것.
(사실 이정도는 예상했다.. 가 아니고 JAVA의 기본ㅎ..)
6. NULL 로 처리하기?
NULL을 그냥 넘겨 버리고, 그걸 처리하면 되지 않나??
public record DisplayDto(
Boolean precipitation,
Boolean wind,
Boolean dust
) {}
public void updateDisplay(User user, SettingReqDto.DisplayDto displayDto) {
Setting setting = user.getSetting();
if (displayDto.precipitation() != null) {
setting.setPrecipitation(displayDto.precipitation());
}
if (displayDto.wind() != null) {
setting.setWind(displayDto.wind());
}
if (displayDto.dust() != null) {
setting.setDust(displayDto.dust());
}
settingRepository.save(setting);
}
- 혹은 삼항 연산자로 간단하게!
public void updateDisplay(User user, SettingReqDto.DisplayDto displayDto) {
Setting setting = user.getSetting();
setting.setPrecipitation(displayDto.precipitation() != null ? displayDto.precipitation() : setting.getPrecipitation());
setting.setWind(displayDto.wind() != null ? displayDto.wind() : setting.getWind());
setting.setDust(displayDto.dust() != null ? displayDto.dust() : setting.getDust());
settingRepository.save(setting);
}

자... 원하는 대로 해결됐따!!
7. boolean? Boolean?
dto의 타입을 boolean으로 주고 빈값일때 false로 떴다.
그이유는 boolean은 primitive type이기 때문에 null을 못받기 때문 boolean의 기본 타입은 false으로 변환된것.......
null이 들어와도 false로 받게된 것이다!
-> 그래서 dto에서는 nullable한 타입을 쓰는게 좋다고하여 Boolean으로 변경해주었다
이것만 바꿔주면, 어차피 오토 박싱, 오토 언박싱 기능도 제공하기 때문에.. 큰 문제가 없다고 판단.
https://jaehoney.tistory.com/101
-primitive Type (기본형 타입)
null을 가질수 없다 만약 null 값을 넣고 싶다면 wrapper class를 활용해야한다.
스택에 메모리가 저장된다.
-Reference Type 참조형 타입
primitive type을 제외한 모든 타입들은 참조형 타입
null을 담을 수 있다.
하지만 런타임 에러가 발생한다
그래서 NullPointException(NPE)이 발생하므로 변수의 값을 넣어야한다.
(그놈의 NPE!!!)
메모리의 힙에 실제 값을 저장하고 그 참조값을 갖는 변수는 스택에 저장한다.
클래스타입, 인터페이스 타입, 배열타입, 열거타입이 있다
- 래퍼클래스 Wrapper class
8개의 기본 타입에 해당하는 데이터를 객체로 포장해주는 클래스
Boolean은 기본자료형인 boolean을 박스화한(boxed primitive type) 자료형인 것이다.
래퍼클래스의 부모는 Object
래퍼클래스를 사용하는 이유: 기본 자료형을 클래스화 하므로 클래스의 장점을 갖게하기 위해서
사실 여기서 끝내도 된다....
8. 끝인가? 또 문제는 없는가?
바로 개발자의 마인드...
뭔가 쉽게 풀리면 빈틈이 많다는 느낌이 든다..
바로
서버 입장에서는 클라이언트가 정말 의도해서 값을 null로 준건지,
아니면 그냥 값을 아래와 같이 안 줘서 null로 온건지 확인할 방법이 없다는 점이다.
즉 아래의 이 요청과
{
"dust":false
}
이 요청을 구분할 방법이 없다는 말이다.
{
"wind":null,
"dust":false
}
9. NULL 명시적으로 처리하기
public record DisplayDto(
Optional<Boolean> precipitation,
Optional<Boolean> wind,
Optional<Boolean> dust
) {}
Optional을 사용하면 속성이 존재하지 않는 경우를 명시적으로 표현할 수 있고,
null을 허용하는 방식은 속성이 null인 경우 변경하지 않는 것으로 처리할 수 있다!!
-- 사실 여기서 끝내도 된다... 하지만... 또 끝까지 파고들어보자...
10. Optional의 사용
보통 Optional 하고 끝내도 되지만, 나는 욕심이 많다.
원래 Optional이 이런 용도인가?
이렇게 해도 작동하긴 하겠지만, 좋은 생각은 아닌 것 같았다.
1. Bean은 Optional 필드를 가질 수 없다.
Optional은 오로지 메서드의 반환 값으로 사용되기 위해 설계된 것이다.
2. Optional은 절대로 null이 될 수 없다.
Optional의 목적은 null을 감싸 NPE(Null Pointer Exception)을 방지하기 위한 것이므로, 코드 내부에서 Optional이 Null이 되도록 설계해서는 안된다.
따라서 Optional을 반환하는 메서드를 호출하는 코드는 Optional이 null이 아님을 항상 확신할 수 있어야 한다.
가장 중요한 것은 Optional이 null이 될 가능성을 두면 안된다는 것이다.
아래는 우리가 흔히 Repo에서 User를 Optional 형식으로 가져올 때 나타나는 노란경고줄이다.


여기서도 알 수 있듯이 말이다..
모야? Null 체크만 해주면 되네~
public record DisplayDto(
Optional<Boolean> precipitation,
Optional<Boolean> wind,
Optional<Boolean> dust
) {
@JsonCreator
public DisplayDto(
@JsonProperty("precipitation") Boolean precipitation,
@JsonProperty("wind") Boolean wind,
@JsonProperty("dust") Boolean dust
) {
this.precipitation = Optional.ofNullable(precipitation);
this.wind = Optional.ofNullable(wind);
this.dust = Optional.ofNullable(dust);
}
}
따라서 위의 코드에서는 @JsonCreator 및 @JsonProperty를 사용하여 직렬화 및 역직렬화 과정에서 명시적으로 값의 존재 유무를 처리한다.
@JsonCreator는 해당 클래스의 생성자가 JSON 데이터를 사용하여 객체를 생성할 때 사용된다.
@JsonProperty는 JSON 데이터의 필드와 해당 클래스의 필드를 매핑할 때 사용된다.
이를 통해 JSON 데이터가 제대로 처리되고 값이 존재하지 않는 경우에도 올바르게 처리될 수 있도록 한다.
이렇게하면 값이 존재하지 않을 때에도 빈 Optional을 반환하게 되므로, null이 아닌 안전한 방식으로 데이터를 다룰 수 있...
을 줄 알았다...

하지만.. 이런 오류를 맞이 하고 만다.

레코드 클래스의 생성자는 다른 생성자를 호출할 수 없다..........
따라서 @JsonCreator로 지정된 생성자는 다른 생성자를 호출하는 형태로 사용할 수 없다.
이쯤 됐더니 정신이 나갈 거 같아서 타인의 도움을 받기로 했다.
11. JsonNullable과 MapStruct 를 사용
타인의 블로그의 힘을 좀 빌려본다..
Spring Boot : 명시적인 Null 값으로 부분 업데이트(PATCH) 수행하기
Patch API에서 명시적인 Null 값 구분하기
velog.io
요약하자면..
JsonNullable 사용 이유:
JsonNullable은 Optional과 유사하게 동작하지만, 명시적으로 null이 아님을 나타내는 데에 사용.
Bean에는 Optional 필드를 가질 수 없으며, Optional은 오로지 메서드의 반환 값으로 사용된다.
따라서 목적에 맞게 JsonNullable를 선택한다.
MapStruct 사용 이유:
MapStruct는 유연하고, 자동으로 매핑되지 않더라도 매핑 과정을 커스터마이징하는 데 문제가 없다.
컴파일 타임에 코드를 생성하기 때문에 런타임에 실행되지 않으므로 성능이 향상됩니다.
DTO를 모델 클래스와 매핑하기:
MapStruct를 사용하여 DTO와 모델 클래스 간의 매핑을 구현한다.
JsonNullable을 처리하기 위해 JsonNullableMapper를 사용하고, 필드가 null이 아닌 경우에만 업데이트하도록 설정한다.
12. PUT? PATCH?
이쯤에서 고민 되는 것은,
🤔 그래서 일부 수정이니까 PATCH로 쓰고, JsonNullable, MapStruct를 써야하냐!?
이다.

아무리 그래도 일부 수정인데 PUT? PATCH?
보통 PUT과 PATCH에 대해 찾아보면,
엔티티 전체를 수정하는가 일부를 수정하는가 와 관한 얘기가 대부분이다.
(뭐 멱등성이고 뭐고 딮한 얘기는 잠시 접어두고..)
PUT은 어쩌고, PATCH는 일부 변경 어쩌고 정의는 다 안다고 치고,
우리가 흔히 REST API 라고 말하는 것이 진정으로 REST한가.... 부터 의문이다.
아래 띵강의 보고 오면 이해가 된다.
https://youtu.be/RP_f5dMoHFc?si=w0isupnktK3aHaW9
현실적으로 100% REST API를 만들기 위해서는 생각보다 지켜야 할 것이 너무나 많다.
코딩하다보면, 편하자고 한 규칙들이 오히려 발목을 잡고 시간을 끌게 만들 때가 많다.
개인적으로 API의 URI를 통해 대략적으로 자원의 의미를 파악할 수 있고,
Method를 통해 자원의 변화를 파악할 수 있으면 그만이라고 생각한다.
프로젝트원들이 해당 API를 보고 이해할 수 있고, 코드가 문제없이 돌아간다면 끝이라는 이야기다.
PATCH와 PUT의 관계도 비슷하다.
어떤 곳에서는 수정은 무조건 PUT 으로 하기도 하고,
어떤 곳에서는 상황에 따라 PATCH와 PUT을 조금 더 디테일하게 나누기도 한다.
개인적으로는 전자를 선호한다.
- Entity가 상황에 따라 계속해서 변할 수 있기 때문
User Entity에 id와 username만 있었다고 해보자.
{
"id" : 1,
"username" : "나는 나"
}
여기서 PUT과 PATCH를 구분한다면, 이름을 변경하는 API는 PUT으로 만들어야 한다.
현재 필드는 name만 존재하니까 = 전체
그런데 age 라는 필드도 User에 추가되었다면?
그럼, 원래 있던 name만 변경하는 API는 PATCH로 변경되어야 한다.
그 API는 모든 필드를 수정하는 것이 아니라, age와 name중 name만 수정하기 때문이다.
이렇게 되면, API의 method를 변경해야 하는데 이런 작업이 쉬운 것은 아니라고 생각한다...
- 추가적으로 Null의 위험성
PATCH의 경우 정의는 "리소스의 일부분을 수정한다" 라고 알고 있다.
DB, ORM , Database Mapper을 배우다 보면, 느끼는 바가 있다.
{
"id" : 1,
"username" : "나는 나",
"age" : 10
}
해당 정보가 db에 있고,
PATCH 로 age = 20 만을 변경하기 위해서 다음 JSON 형식을 보낸다고 해보자.
{
"id" : 1,
"age" : 20
}
그런데 우리가 ORM을 쓴다거나 요청 mapping에서 DTO라는 객체를 받게 되면,
user_name에 해당되는 field가 null로 적용이 된다.
이는 kotlin, java등 여러 언어에서 parsing할 때 나오는 현상이다.
(지금까지 고생한 것도 이놈의 Null)
그리고 이를 다시 db에 적용하기 위해서 entity로 변환하는 과정에서
userName이라는 변수가 기존에 값이 유지되는게 아닌
실제 객체의 형태는 아래와 같이 될수가 있다는 것이다.
class User {
id = 1
age = 20
userName = null
}
이렇게 되는 경우, userName이 같이 변환이 되는 불상사가 일어날 수 있다.
PUT으로도 모든 변경 사항은 충분하다.
GET을 통해서 정보를 받아오고 PUT으로 정보를 수정함에 있어서
GET에서 받은 부분 중 원본+수정본을 같이 보냄으로써
어떠한 field의 수정이 일어났는지 굳이 서버에서 또 후처리 해줄 필요가 없다는 것이다.
그래서 보통 굳이 PUT보다 PATCH를 사용하지 않는다고 한다..
13. 결론
사실 애초에 설계의 잘못이다.
Dto를 세개로 쪼개던지,...
GET을 통해 원본+수정본을 함께 PUT으로 보내 요구되는 형태로 값을 보내라고 프런트와 입을 맞추던지
아니면 6번 해결책 (Boolean이나, Optional 쓰고 Null 체크) 에서 끝내는게 답이다.