Spring Boot : 명시적인 Null 값으로 부분 업데이트(PATCH) 수행하기

급식·2023년 7월 21일
2
post-thumbnail

발단

프로젝트를 진행하다가, 특정 엔티티의 일부 필드만을 업데이트 해야 하는 요구사항이 생겨 PATCH API를 구현을 맡게 되었다.

'PUT을 써도 되지 않나?' 싶은 분들을 위해 짧게 첨언하자면, PUT은 전달 받은 데이터 그대로 원본 데이터를 대체한다. 반면 PATCH의 경우 딱 정직하게 전달 받은 데이터만 입력 받아 일부(다 전달 받았으면 갱신이겠지?)를 갱신하는 방식이다. 너무 자세하게 설명해주신 분들이 많으셔서 더 자세히는 설명 않겠다. 개인적으로 이 분 설명이 좋았음!

암튼,, 잘못 설계되어서 그런지 한 엔티티의 크기가 꽤 클 것으로 생각되어서 일부를 갱신하는 PATCH 방식을 고려한 면도 있고, 혹시 클라이언트에서 값을 누락해서 원치 않는 Null이 생기는 문제가 발생할 수 있을 것 같아 우선 PATCH 메서드를 사용하는게 좋겠다고 생각했다.

그런데 기존 방식대로 controller dto를 설계하기에는 아래와 같은 문제가 있었다.


문제 발생

아래와 같은 DTO를 controller에서 사용자의 입력을 받기 위해 사용한다고 생각해보자.

@Getter
@AllArgsConstructor
public class MemberUpdateDto {
	private String name;
    private Integer age;
}

그럼 컨트롤러에서 아래와 같은 방식으로 업데이트를 수행할 것이다.

@PatchMapping("/{id}")
public ResponseEntity<Member> updateMember(@PathVariable Long id, @RequestBody MemberUpdateDto updateDto) {
    if (!members.containsKey(id)) {
        return ResponseEntity.notFound().build();
    }

    Member existingMember = members.get(id);

    if (updateDto.getName() != null) {
        existingMember.setName(updateDto.getName());
    }

    if (updateDto.getAge() != null) {
        existingMember.setAge(updateDto.getAge());
    }

    memberService.updateMember(id, existingMember);

    return ResponseEntity.ok(existingMember);
}

위 방식에는 문제가 있는데, 서버 입장에서는 클라이언트가 정말 의도해서 값을 null로 준건지, 아니면 그냥 값을 아래와 같이 안 줘서 null로 온건지 확인할 방법이 없다.

즉 아래의 이 요청과

{
    "age": 27
}

이 요청을 구분할 방법이 없다는 말이다.

{
    "name": null,
    "age": 27
}

혼자 끙끙거리다가 chatGPT한테 슬쩍 물어봤는데, 아래와 같은 답변을 해줬다.


부족한 해결 방안

요 멍충이가 내놓은 답변은, 저런 식으로 미리 약속된 'null을 의미하는 값'을 정해서

{
    "name": "__NULL__",
    "age": 27
}

아래와 같이 수정해 의도된 null값을 구분한다는 얘기였다.

@Getter
@AllArgsConstructor
public class MemberUpdateDto {
	public static final String NULL_VALUE = "__NULL__";
	private String name;
    private Integer age;
}

그럼 컨트롤러에서 아래와 같은 방식으로 업데이트를 수행할 것이다.

@PatchMapping("/{id}")
public ResponseEntity<Member> updateMember(@PathVariable Long id, @RequestBody MemberUpdateDto updateDto) {
    // .. 중략 ..
    if (patchDto.getName() != null) {
        if (patchDto.getName().equals(PatchDto.NULL_VALUE)) {
            entity.setName(null);
        } else {
            entity.setName(patchDto.getName());
        }
    }

    if (patchDto.getAge() != null) {
        if (patchDto.getAge().equals(PatchDto.NULL_VALUE)) {
            entity.setAge(null);
        } else {
            entity.setAge(patchDto.getField2());
        }
    }
    // .. 중략 ..
}

척 보기에도 문제 있는 방법인게, Age의 타입은 Integer이고, NULL_VALUE는 String이다. 당연히 기각.


해답

그래서 다른 방법을 찾아보다가 JsonNullable, MapStruct를 사용해 이 문제를 해결하는 과정을 소개한 좋은 글을 발견했다. 개요 부분은 내가 적은 내용과 비슷하므로 생략하고, 직접적인 해결 방법을 명시한 부분만 따로 번역해서 남겨 놓으려고 한다.

영어가 좀 짧아서(...) 오역이 있을 수도 있고, 부분부분 생략할 예정이니 되도록 직접 들어가서 보는게 좋다.

출처는 여기! : https://kdrozd.pl/how-to-perform-a-partial-update-patch-with-explicit-null/

JsonNullable과 MapStruct로 부분 업데이트를 구현하는 방법

왜 JsonNullable인가?

간단하기도 하고, 정확히 이 목적을 위해 만들어진 라이브러리이기 때문에 선택했다. 어떤 사람들은 Optional을 사용하고, 실제로 작동하긴 하지만 이건 그 목적을 위해 만들어진게 아니다. 이런 관점에서, JsonNullable의 소개 페이지에 일부 내용에 동의한다.

많은 사람들이 이 작업을 수행하기 위해 Optional을 사용하곤 한다. 이렇게 해도 작동하긴 하겠지만, 아래와 같은 이유로 그다지 좋은 생각은 아니다.
1. Bean은 Optional 필드를 가질 수 없다. Optional은 오로지 메서드의 반환 값으로 사용되기 위해 설계된 것이다.
2. Optional은 절대로 null이 될 수 없다. Optional의 목적은 null을 감싸 NPE(Null Pointer Exception)을 방지하기 위한 것이므로, 코드 내부에서 Optional이 Null이 되도록 설계해서는 안된다. 따라서 Optional을 반환하는 메서드를 호출하는 코드는 Optional이 null이 아님을 항상 확신할 수 있어야 한다.
~ OpenAPITools/jackson-databind-nullable

왜 mapstruct인가?

  • mapstruct는 매우 유연하고, 자동으로 뭔가를 수행하지 않더라도 매핑 과정을 커스터마이징하는데 어떠한 문제도 없다.
  • mapstruct는 compile-time code generator이다. 즉, mapper class는 애플리케이션이 시작되기 전에 생성되기 때문에, 코드를 이해하기 용이하여 디버깅에 도움이 된다.
  • 런타임에 실행되지 않는 특성 덕분에 복잡한 매커니즘(리플렉션 등)을 전혀 사용하지 않으므로 다른 라이브러리보다 빠르다.

이전에 런타임에 실행되는 modelmapper를 사용해본 적이 있는데, mapper의 오작동을 찾아내는 작업이 정말 어려웠다. 물론 정교한 커스터마이징이 필요하지 않았던 점은 좋았지만, 라이브러리의 복잡한 매커니즘을 알아내는데 많은 시간이 소요되었다. 운좋게도 mapstruct 라이브러리에 대해 알게 되었고, modelmapper를 디버깅하는 것보다 원래 코드를 mapstruct로 바꾸는 작업에 훨씬 적은 시간이 들었다.

설정

딱 종속성만 설정해주면 된다. 종속성 관리자로 나는 Maven을 사용했지만, Gradle에서도 잘 작동할 것이다. pom.xml 파일은 아래와 같다.

<dependencies>

    <!-- 다른 종속성들은 생략됨 (spring, h2, tests...) -->

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-json</artifactId>
    </dependency>

    <dependency>
        <groupId>org.openapitools</groupId>
        <artifactId>jackson-databind-nullable</artifactId>
        <version>0.2.1</version>
    </dependency>

    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>1.5.2.Final</version>
    </dependency>

</dependencies>

<build>
    <plugins>

        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>11</source>
                <target>11</target>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>1.4.1.Final</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>

    </plugins>
</build>

추가로, Jackson 라이브러리에 JasonNullable을 처리하는 방법을 알리기 위해 추가적인 모듈을 등록해야 된다.

import com.fasterxml.jackson.annotation.JsonInclude;
import org.openapitools.jackson.nullable.JsonNullableModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

@Configuration
public class JacksonConfig {
    @Bean
    Jackson2ObjectMapperBuilder objectMapperBuilder() {
        Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
        builder.serializationInclusion(JsonInclude.Include.ALWAYS)
                .modulesToInstall(new JsonNullableModule());
        return builder;
    }

}

이 예제는 Product라는 이름의 엔티티를 사용한다. 간단하게 구현하기 위해, ProductDTO 클래스는 엔티티와 동일한 필드를 가진다. 차이점이 있다면, 특정 필드를 JSON Merge Patch (RFC 7386) 처리하기 위해 JsonNullable<>로 감쌌다.

package pl.kdrozd.examples.model;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.Objects;

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String name;
    private Integer quantity;
    private String description;
    private String manufacturer;

    // constructors, getters, setters...
package pl.kdrozd.examples.dto;

import org.openapitools.jackson.nullable.JsonNullable;

import java.util.Objects;

public class ProductDTO {

    private Long id;
    private String name;
    private Integer quantity;
    private JsonNullable<String> description;
    private JsonNullable<String> manufacturer;

    // constructor, getters, setters...

이제 Jackson이 제대로 엔티티를 역직렬화(deserialize)하는지 검증해보자.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.openapitools.jackson.nullable.JsonNullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import pl.kdrozd.examples.dto.ProductDTO;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

@SpringBootTest
class JacksonConfigTest {

    @Autowired
    private ObjectMapper mapper;

    @Test
    void should_use_json_nullable_module() throws JsonProcessingException {
        assertEquals(JsonNullable.of("some description"),
        			mapper.readValue("{\"description\":\"some description\"}",
                    ProductDTO.class).getDescription());
                    
        assertEquals(JsonNullable.of(null),
        			mapper.readValue("{\"description\":null}",
                    ProductDTO.class).getDescription());
                    
        assertNull(mapper.readValue("{}", ProductDTO.class).getDescription());
    }
}

DTO를 모델 클래스와 mapping하기

모델과 DTO 사이의 필드를 직접 다시 쓰는 대신, mapstruct 라이브러리를 사용할 것이다. 이 예제에서 만약 JsonNullable을 사용하지 않았다면, 아래 코드로 충분했을 것이다.

package pl.kdrozd.examples.mapper;

import org.mapstruct.*;
import pl.kdrozd.examples.dto.ProductDTO;
import pl.kdrozd.examples.model.Product;

@Mapper(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
        componentModel = "spring")
public interface ProductMapper {

    @Mapping(target = "id", ignore = true)
    Product map(ProductDTO entity);

    ProductDTO map(Product entity);

    @InheritConfiguration
    void update(ProductDTO update, @MappingTarget Product destination);
}

불행히도 mapstruct는 JsonNullable 클래스에 대해 기본적으로 지원되지 않기 떄문에 컴파일을 시도할 경우 아래와 같은 에러가 발생한다.

java: Can't map property "JsonNullable<string> description" to "String description". Consider to declare/implement a mapping method: "String map(JsonNullable<string> value)".

이 에러를 해결하려면, JsonNullable 클래스를 위해 Mapper를 아래와 같이 분리해야 한다.

package pl.kdrozd.examples.mapper;

import org.mapstruct.Condition;
import org.mapstruct.Mapper;
import org.mapstruct.Named;
import org.openapitools.jackson.nullable.JsonNullable;

@Mapper(componentModel = "spring")
public interface JsonNullableMapper {

    default <T> JsonNullable<T> wrap(T entity) {
        return JsonNullable.of(entity);
    }

    default <T> T unwrap(JsonNullable<T> jsonNullable) {
        return jsonNullable == null ? null : jsonNullable.orElse(null);
    }

}

그 다음으로는 ProductMapperJsonNullableMapper를 사용하도록 코드를 고쳐주면 된다.

package pl.kdrozd.examples.mapper;

import org.mapstruct.*;
import pl.kdrozd.examples.dto.ProductDTO;
import pl.kdrozd.examples.model.Product;

@Mapper(uses = JsonNullableMapper.class,
        nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
        componentModel = "spring")
public interface ProductMapper {

    @Mapping(target = "id", ignore = true)
    Product map(ProductDTO entity);

    ProductDTO map(Product entity);

    @InheritConfiguration
    void update(ProductDTO update, @MappingTarget Product destination);
}

이제 프로젝트가 정상적으로 컴파일 되며, mapper 소스 코드가 target\generated-sources\annotations 디렉토리에 아래와 같이 정상적으로 저장된다.

package pl.kdrozd.examples.mapper;

import javax.annotation.processing.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import pl.kdrozd.examples.dto.ProductDTO;
import pl.kdrozd.examples.model.Product;

@Generated(
        value = "org.mapstruct.ap.MappingProcessor",
        date = "2022-08-05T17:54:15+0000",
        comments = "version: 1.5.2.Final, compiler: javac, environment: Java 11.0.15 (Private Build)"
)
@Component
public class ProductMapperImpl implements ProductMapper {

    @Autowired
    private JsonNullableMapper jsonNullableMapper;

    @Override
    public Product map(ProductDTO entity) {
        if ( entity == null ) {
            return null;
        }

        Product product = new Product();

        product.setName( entity.getName() );
        product.setQuantity( entity.getQuantity() );
        product.setDescription( jsonNullableMapper.unwrap( entity.getDescription() ) );
        product.setManufacturer( jsonNullableMapper.unwrap( entity.getManufacturer() ) );

        return product;
    }

    @Override
    public ProductDTO map(Product entity) {
        if ( entity == null ) {
            return null;
        }

        ProductDTO productDTO = new ProductDTO();

        productDTO.setId( entity.getId() );
        productDTO.setName( entity.getName() );
        productDTO.setQuantity( entity.getQuantity() );
        productDTO.setDescription( jsonNullableMapper.wrap( entity.getDescription() ) );
        productDTO.setManufacturer( jsonNullableMapper.wrap( entity.getManufacturer() ) );

        return productDTO;
    }

    @Override
    public void update(ProductDTO update, Product destination) {
        if ( update == null ) {
            return;
        }

        if ( update.getName() != null ) {
            destination.setName( update.getName() );
        }
        if ( update.getQuantity() != null ) {
            destination.setQuantity( update.getQuantity() );
        }
        if ( update.getDescription() != null ) {
            destination.setDescription( jsonNullableMapper.unwrap( update.getDescription() ) );
        }
        if ( update.getManufacturer() != null ) {
            destination.setManufacturer( jsonNullableMapper.unwrap( update.getManufacturer() ) );
        }
    }
}

이제 mapper.update(...)가 의도한대로 작동하는지 확인하기 위한 테스트를 작성해보자.

@Test // 성공 : 모든 필드가 존재하는 경우, id를 제외한 모든 것들을 업데이트 해야 한다.
void should_update_all_entities_in_product_except_id() {
    ProductDTO update = new ProductDTO(3L, "Updated name", 2,
    								  JsonNullable.of("Updated description"),
                                      JsonNullable.of("UpdateCompany"));
    Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
    Product expected = new Product(1L, "Updated name", 2,
    							  "Updated description", "UpdateCompany");
                                  
    mapper.update(update, destination);
    
    assertEquals(expected.getId(), destination.getId());
    assertEquals(expected.getDescription(), destination.getDescription());
    assertEquals(expected.getManufacturer(), destination.getManufacturer());
    assertEquals(expected.getName(), destination.getName());
    assertEquals(expected.getQuantity(), destination.getQuantity());
}

// 성공 : 명시적으로 nullable한 필드(JsonNullable.of(null))에 명시적으로 null이 전달된 경우,
// 해당 필드는 null로 업데이트 되어야 한다.
@Test
void should_update_only_nullable_fields_in_product() {
    ProductDTO update = new ProductDTO(1L, null, null,
    								   JsonNullable.of(null), JsonNullable.of(null));
    Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
    Product expected = new Product(1L, "RTX3080", 0, null, null);
    
    mapper.update(update, destination);
    
    assertEquals(expected.getId(), destination.getId());
    assertEquals(expected.getDescription(), destination.getDescription());
    assertEquals(expected.getManufacturer(), destination.getManufacturer());
    assertEquals(expected.getName(), destination.getName());
    assertEquals(expected.getQuantity(), destination.getQuantity());
}

@Test // 성공 : 필드가 Null이지만 JsonNullable로 싸여 있지 않은 경우, 필드를 업데이트 하지 않는다.
void should_not_update_any_field_in_product_2() {
    ProductDTO update = new ProductDTO(null, null, null, null, null);
    Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
    Product expected = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
    
    mapper.update(update, destination);
    
    assertEquals(expected.getId(), destination.getId());
    assertEquals(expected.getDescription(), destination.getDescription());
    assertEquals(expected.getManufacturer(), destination.getManufacturer());
    assertEquals(expected.getName(), destination.getName());
    assertEquals(expected.getQuantity(), destination.getQuantity());
}

@Test // 실패 : Nullable 필드가 존재하지 않는 경우(JsonNullable.undefined()), 업데이트 되면 안된다.
void should_not_update_any_field_in_product() {
    ProductDTO update = new ProductDTO(null, null, null,
    								  JsonNullable.undefined(),
                                      JsonNullable.undefined());
    Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
    Product expected = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
    
    mapper.update(update, destination);
    
    // 아래 행에서 오류 발생
    // : org.opentest4j.AssertionFailedError: Expected:"Great GPU" Actual:null
    assertEquals(expected.getId(), destination.getId()); 
    assertEquals(expected.getDescription(), destination.getDescription());
    assertEquals(expected.getManufacturer(), destination.getManufacturer());
    assertEquals(expected.getName(), destination.getName());
    assertEquals(expected.getQuantity(), destination.getQuantity());
}

보다시피 4번 테스트 빼고 모두 통과되었는데, 이는 JsonNullable.undefined() 프로퍼티를 제대로 처리하지 않는 것으로 생각된다. 여기서 문제는 mapper가 필드가 null이 아닌 경우 필드를 무조건 업데이트하도록 작동하기 때문에 발생한다. 위의 테스트 코드에서 JsonNullable.undefined()은 null이 아니지만 존재하는 값이 있는 것도 아니므로, 어쨌든 nullf로 처리되어 버린다. 따라서 JsonNullable 필드에 대해, mapper는 값을 isPresent()로 체크해 주어야 한다.

따라서 아래 코드를

if ( update.getDescription() != null ) {
    destination.setDescription(jsonNullableMapper.unwrap( update.getDescription()));
}

이렇게 바꿔줘야 한다.

if (update.getDescription() != null && update.getDescription().isPresent()) {
    destination.setDescription(jsonNullableMapper.unwrap( update.getDescription()));
}

운좋게도, mapstruct는 이 작업을 매우 간편하게 처리할 수 있다. 그냥 JsonNullableMapper@Condition 어노테이션이 붙은 새 메서드를 만들면 된다.

package pl.kdrozd.examples.mapper;

import org.mapstruct.Condition;
import org.mapstruct.Mapper;
import org.mapstruct.Named;
import org.openapitools.jackson.nullable.JsonNullable;

@Mapper(componentModel = "spring")
public interface JsonNullableMapper {

    default <T> JsonNullable<T> wrap(T entity) {
        return JsonNullable.of(entity);
    }

    default <T> T unwrap(JsonNullable<T> jsonNullable) {
        return jsonNullable == null ? null : jsonNullable.orElse(null);
    }

    /**
     * nullable한 파라미터가 명시적으로 전달되었는지 확인한다.
     * @return 명시적으로 전달된 경우 true를, 그렇지 않은 경우 false를 반환
     */
    @Condition
    default <T> boolean isPresent(JsonNullable<T> nullable) {
        return nullable != null && nullable.isPresent();
    }
}

Mapstruct는 아래와 같이 우리가 원하는 코드를 생성했고, 이제 4번째 테스트도 제대로 통과된다.

@Override // generated by mapstruct
public void update(ProductDTO update, Product destination) {
    if ( update == null ) {
        return;
    }
    if ( update.getName() != null ) {
        destination.setName( update.getName() );
    }
    if ( update.getQuantity() != null ) {
        destination.setQuantity( update.getQuantity() );
    }
    if ( jsonNullableMapper.isPresent( update.getDescription() ) ) {
        destination.setDescription( jsonNullableMapper.unwrap( update.getDescription() ) );
    }
    if ( jsonNullableMapper.isPresent( update.getManufacturer() ) ) {
        destination.setManufacturer( jsonNullableMapper.unwrap( update.getManufacturer() ) );
    }
}

정리

이번 게시물에서는 JsonNullableMapstruct을 사용해서 부분 업데이트를 구현하는 방법을 소개했다.

소스 코드는 내 깃헙에서 확인할 수 있다.


,,이제 점심 먹고 구현 시작하면 되겠다!

profile
뭐 먹고 살지.

0개의 댓글