[SpringBoot] PATCH Method의 null처리 / MapStruct

hwhyeons·2025년 8월 3일

HTTP Method에서 자원 수정을 위해서는 PUT Method를 사용하는 방법과
PATCH Method를 사용하는 방법이 있다.

이 둘의 차이를 먼저 간략하게 살펴보자.

메서드목적특징
PUT전체 자원 교체누락된 필드는 제거됨 (기존 상태 무시하고 새로운 상태로 대체)
PATCH일부 자원 수정일부 필드만 전달하고 수정 가능, 나머지는 유지

또한 PUT은 리소스가 없다면 새로 추가하는 역할로도 사용할 수 있다.


처음 이렇게만 공부했을 때는, 아 이런 용도에 맞게 사용하면 되겠구나! 하고 넘겼지만,
막상 실제 스프링부트 프로젝트에서 PATCH Method의 코드를 작성하다보니 고민되는 부분이 있었다.


바로 그 부분은, PATCH 메소드의 "일부만 수정"부분이였다.

클라이언트에서 HTTP로 요청을 보냈을 때 그 일부를 어떻게 인식해야할지였다.


왜냐면 나는 보통 요청을 받을 때 POST를 주로 사용하다보니,
Body를 DTO를 만들어서 받는 경우가 많았는데,
PATCH Method는 어떻게 구현해야될지 궁금했다.

먼저 간단하게 아래와 같은 코드를 작성했다.


@RestController
public class MemberController {

    @Autowired
    private MemberService memberService; // Assuming you have a MemberService for business logic

    @Data
    static class MemberDto {
        private Long id;
        private String name;
        private String email;
        private String phoneNumber;
    }

    @PatchMapping("/members")
    public void patchTest(@RequestBody MemberDto memberDto) {
        System.out.println("patchTest called");
    }
}

PATCH로 실제 업데이트를 하기 전에, 테스트를 위해서 저 출력문에 브레이크 포인트를 걸고, 객체의 일부 속성을 누락시켰을 때 Bad Request없이 제대로 인식이 되는지 확인해보았다.

POSTMAN으로 의도적으로 phoneNumber만 제외하고 요청을 아래와 같이 보내봤다.

그 결과 서버 측 브레이크 포인트에서 DTO의 멤버를 확인해보니

phoneNumber은 null로 들어가는 것을 확인할 수 있었다.


이제 다시 본론으로 돌아와서 PATCH Mapping에서
어떻게 "일부 필드만 수정"을 할 것인지를 생각해서 코드를 작성했다.


첫번째 방법 : 직접 null체크

PATCH Method는 전달 받은 필드만 수정하고 나머지는 그대로 둔다.
예를들어 id가 1번인 멤버의 phoneNumber이 010-1234-5678이였다면,
위 사진처럼 phoneNumber이 null인 경우에는 phoneNumber은 수정하지 않고
이름과 이메일만 수정되어야한다.

따라서 서버측 코드에서는

@PatchMapping("/members")
public void patchTest(@RequestBody MemberDto memberDto) {
    if (memberDto.getEmail() != null) {
        System.out.println("Updating email to: " + memberDto.getEmail());
    }
    if (memberDto.getPhoneNumber() != null) {
        System.out.println("Updating phone number to: " + memberDto.getPhoneNumber());
    }
    if (memberDto.getName() != null) {
        System.out.println("Updating name to: " + memberDto.getName());
    }
}

하지만 이 방법으로 막상 구현을 해보니, 여기서 여러가지 생각이 들었다.

  1. 수정을 하지 않기 위해서 일부러 누락을 시킨것과, 기존 데이터를 의도적으로 null로 바꾸는 것을 어떻게 구분할 것인가?
  2. 새로운 필드가 추가되면 일일이 로직을 추가해야되는 것인가?

1번의 경우, 핸드폰 번호를 예시로 하면, 저 멤버의 핸드폰 번호가 있었지만 폰을 해지해서 번호가 없어져서 null로 넣어야하는 경우와, 핸드폰번호는 변경 없이 이메일만 변경하는 경우를 구분할 수가 없는 것이다.

이를 요청 코드로 표현하면 아래와 같다

{
	"email":"abc@gmail.com",
	"phoneNumber":null
}

으로 실제 폰번호를 없애기 위해서 null을 넣는 경우와

{
	"email":"abc@gmail.com"
}

핸드폰 번호를 안바꾸고 이메일만 바꾸기 위해서의 경우를 구분할 수 있는가?였다.

테스트해보니, 저렇게 null을 직접 체크해주는 방법으로는 이를 구분할 수가 없다.

서버 입장에서는 어차피 두 경우 모두 phoneNumber멤버에 null이 있을 뿐
폰번호 삭제를 위해 의도된 null인지 폰번호 수정안하려고 넣은 null인지는 알 수가 없다.

(이 부분에 대해서는 아래에서 설명하도록 하겠다)


직접 null체크하는 방식의 문제 두번째는, 새로운 멤버가 추가되었을 때다.
@Data
static class MemberDto {
    private Long id;
    private String name;
    private String email;
    private String phoneNumber;
    private String address; // 새로 추가
}

와 같이 새롭게 address라는 필드가 생겼다고 해보자.

저렇게 address를 추가했을 때, 내가 방금 작성한 patch method에
address필드를 안넣는다고 컴파일 타임에 오류가 발생할까?
당연히 아니다.
기존에 쓰던 필드를 지웠다면 오류가 나겠지만, 내가 address를 추가했다고 해서

@PatchMapping("/members")
public void patchTest(@RequestBody MemberDto memberDto) {
    if (memberDto.getEmail() != null) {
        System.out.println("Updating email to: " + memberDto.getEmail());
    }
    if (memberDto.getPhoneNumber() != null) {
        System.out.println("Updating phone number to: " + memberDto.getPhoneNumber());
    }
    if (memberDto.getName() != null) {
        System.out.println("Updating name to: " + memberDto.getName());
    }
}

이 코드가 갑자기 컴파일 타임에 오류가 날 일은 없다.

만약 내가 address라는 필드를 새로 추가 했는데, 이렇게 특정 메소드를 까먹고 수정해주지 않고, 클라이언트가 address만 수정하기 위해 PATCH를 요청했을 때
서버에서는 아무일도 일어나지 않을 것이다.

지금이야 내가 컨트롤러에 간단하게 메소드 하나만 구현해놨으니 다행이지만,
만약 여러개의 메소드가 있다면 전부 찾아서 address필드를 추가해줄 생각을 하니 실수 하기도 너무 쉬울 것 같다는 생각이 든다.

Java가 정적타입언어인게 큰 장점인데, 이러면 그런 장점이 소용이 없다.

특히 나는 다른사람에 비해서? 이렇게 하나 수정될 때 자동으로 오류로 잡아주지 못하는 경우 좀 스트레스를 느끼는 편이다.
바로 오류 못잡고 런타임에서 실행하는 중에 갑자기 오류나면 그것만큼 불편한게 없다.

그래서 나는 파이썬같은 동적타입 언어로 작업할 때도 typing 모듈로 타입힌트를 다 적어두고 린터를 이용해서 최대한 컴파일 타임에 미리 다 체크하고 실행할려고 하기 때문에 이렇게 컴파일 타임에 IDE 또는 컴파일러가 바로 캐치해주지 못하는 것은 큰 고민포인트다.

이런 두가지 관점에서 궁금증을 가지고 다른 사람들은 어떻게 작성하는지,
그리고 일반적인 방법이 따로 있는건지 등을 찾아보게 되었다.



두번째 방법 : MapStruct 사용

이번에는 MapStruct라는 라이브러리를 사용해볼 것이다.

Gradle 기준으로 아래와 같이 의존성과 어노테이션 프로세서를 추가해줘야 한다.

dependencies {
    implementation 'org.mapstruct:mapstruct:1.5.5.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}

MemberMapper라는 인터페이스를 만들어주자.
이 인터페이스를 스프링 빈에 등록하고, MapStruct가 자동으로 MemberMapperImpl 클래스를 만들어준다.

import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;

@Mapper(componentModel = "spring",
        nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface MemberMapper {
    Member toEntity(MemberDto dto);
    MemberDto toDto(Member entity);
    void updateFromDto(MemberDto dto, @MappingTarget Member entity);

}

이 코드에서 사용해볼 메소드는 updateFromDto()다.

위와 같이 설정하면, MapStruct가 자동으로 MemberMapperImpl을 만들면서
저 updateFromDto()를 만들어준다.

또한 nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE로 설정 했으므로
MapStruct에서 매핑 시 null 값인 필드는 무시하게 된다.


제대로된 테스트를 위해 MemberController의 patch()함수를 아래와 같이 수정했다

    @PatchMapping("/members/{id}")
    public ResponseEntity<?> patchTest(@PathVariable Long id, @RequestBody MemberDto memberDto) {
        Member member = memberRepository.findById(id).orElseThrow();
        memberMapper.updateFromDto(memberDto, member); // null 필드 무시하고 덮어씀
        memberRepository.save(member);
        return ResponseEntity.ok(memberMapper.toDto(member));
    }

테스트 코드는 아래 코드처럼 작성했다

@SpringBootTest
@AutoConfigureMockMvc
class TestSpringbootApplicationTests {

	@Autowired
	private MockMvc mockMvc;
	@Autowired
	private MemberRepository memberRepository;
	@Autowired
	private ObjectMapper objectMapper;


	@Test
	void contextLoads() {
	}

	@BeforeEach
	void setUp() {
		memberRepository.deleteAll(); // 매 테스트 전 초기화
	}


	/**
	 * patch시에 null필드는 무시하는지 테스트용
	 * @throws Exception
	 */
	@Test
	void testPatchMember_emailOnly() throws Exception {
		Member member = new Member();
		member.setId(1L);
		member.setEmail("before@email.com");
		member.setName("NameBefore");
		member.setPhoneNumber("010-1234-5678");
		memberRepository.save(member);

		// 다른건 건들지 않고 email만 변경하는 테스트
		MemberDto memberDto = new MemberDto();
		memberDto.setEmail("after@email.com");

		// 저장이 되었는지부터 테스트
		mockMvc.perform(get("/members").accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isOk());


		// JSON으로 변환
		String jsonBody = objectMapper.writeValueAsString(memberDto);


		// PATCH 요청을 보내고 응답을 검증
		mockMvc.perform(patch("/members/" + member.getId())
				.contentType(MediaType.APPLICATION_JSON)
				.content(jsonBody))
				.andExpect(status().isOk())
				.andExpect(jsonPath("$.email").value("after@email.com"))
				.andExpect(jsonPath("$.name").value("NameBefore"))  // 기존 값 유지
				.andExpect(jsonPath("$.phoneNumber").value("010-1234-5678"));
	}

}

멤버를 임시로 만들어서 저장 후에, patch후에, 이메일만 변하고
나머지는 그대로인지 테스트한다.

-> 정상적으로 통과한다


실제로 MemberMapperImpl의 코드가 어떻게 구현되어있는지 확인을 해보자.

아래 경로에 MemberMapperImpl.java파일이 생성되어있는 것을 볼 수 있다
(..프로젝트경로)/build/generated/sources/annotationProcessor/(..패키지생략)/mapper/MemberMapperImpl.java

해당 파일을 열어서 updateFromDto() 함수는

@Override
public void updateFromDto(MemberDto dto, Member entity) {
    if ( dto == null ) {
        return;
    }

    if ( dto.getId() != null ) {
        entity.setId( dto.getId() );
    }
    if ( dto.getName() != null ) {
        entity.setName( dto.getName() );
    }
    if ( dto.getEmail() != null ) {
        entity.setEmail( dto.getEmail() );
    }
    if ( dto.getPhoneNumber() != null ) {
        entity.setPhoneNumber( dto.getPhoneNumber() );
    }
}

의도한대로 자동으로 생성된 것을 확인할 수 있다.

이렇게 MapStruct를 이용하면 새로운 멤버가 추가 되었을 때는
자동으로 null 체크 코드를 생성해줘서 컴파일 타임에 바로 처리가 가능하다는 장점은 있지만,

여전히

{
	"email":"abc@gmail.com",
	"phoneNumber":null
}

으로 실제 폰번호를 없애기 위해서 null을 넣는 경우와

{
	"email":"abc@gmail.com"
}

핸드폰 번호를 안바꾸고 이메일만 바꾸기 위해서의 경우를 구분할 수 있는 기능은 없다.



세번째 방법 : Map을 이용하기

만약 PATCH를 처리할 때 위에서 계속 썼던 것처럼
RequestBody + DTO 방식을 쓰게되면,
클라이언트 요청 JSON에서 빠진 key들은 전부 알아서 null로 설정되므로
DTO에 있는 null이 클라이언트에서 직접 null로 설정한건지 아니면
key가 없어서 null이 된 것인지 모른다.

그래서 가장 간단한 방법은 요청 인자를 Map으로 받는 방법이다.

컨트롤러를 아래처럼 수정해보자

    @PatchMapping("/members/{id}")
    public ResponseEntity<?> patchMember(@PathVariable Long id, @RequestBody Map<String, Object> body) {
        Member member = memberRepository.findById(id).orElse(null);

        if (body.containsKey("name")) {
            String name = (String) body.get("name");
			member.setName(name);
        }

        if (body.containsKey("email")) {
            String email = (String) body.get("email");
            member.setEmail(email);
        }

        // (이하 생략..)
        
    }

기존 테스트 코드는 ObjectMapper이용한 직렬화 때문에 jsonBody문자열에
null이 다 들어가므로, 이번에는 PostMan을 이용해서 테스트 했다.

http://127.0.0.1:8080/members/1 경로에다가 아래 JSON 요청 전송하면

{
    "email":"test@gmail.com"
}

patchMember()에 브레이크포인트를 걸고 확인하면

이렇게 email말고 나머지는 null이 아니라 그냥 Key자체가 존재하지 않기 때문에
사용자가 직접 null을 넣은 것인지, 아니면 부분 수정을 안하려고 null을 넣은 것인지 구분이 가능하다.


만약 사용자가 핸드폰번호를 아예 없애기 위해서

{
    "email":"test@gmail.com",
    "phoneNumber":null
}

형식으로 요청을 보내면

이렇게 null이 있기 때문에 요청시에 직접 null로 업데이트를 원하는 것으로
구분할 수 있다.

하지만 이 방법은 또 MapStruct처럼, 엔티티에 새로운 필드가 추가되더라도
직접 수정해줘야하는 단점은 여전하다


위에 적어둔 방법 외에도 JsonNullable을 이용해서 의도한 null인지 아닌지 체크하는 방법도 있으니 이것도 찾아보는 것이 좋을 것 같다.


찾아본 바로는, 아직은 필드 존재여부 확인도 가능하고 타입안정성, 자동매핑도 모두 보장되는 방법은 따로 없는 것 같다.

0개의 댓글