SW 개발

Spring Boot, JsonNullable을 이용하여 HTTP PATCH 구현하기

지단로보트 2022. 10. 23. 22:46

개요

  • RFC 7386에 해당하는 JSON Merge Patch 스펙에 따르면 HTTP PATCH 요청시 Partial Update의 대상이 되는 필드들을 가변적으로 전달할 수 있어야 한다. 문제는 Spring Boot 기반의 기본 프로젝트 구성으로는 특정 필드를 null로 교체하라는 것인지, 교체 대상이 아니라는 것인지 식별할 수 있는 방법이 없다는 것이다. 이번 글에서는 이를 해결하기 위해 등장한 Jackson 기반의 JsonNullableModule 모듈을 소개하고 사용법을 소개하고자 한다.

build.gradle.kts

  • 아래와 같이 jackson-databind-nullable 라이브러리를 프로젝트에 추가한다.
dependencies {
    implementation("org.openapitools:jackson-databind-nullable:0.2.4")
}

ObjectMapper 빈 생성

  • 아래와 같이 JsonNullableModule 모듈을 포함된 ObjectMapper 커스텀 빈을 생성해야 JsonNullable<Any?> 랩퍼 타입의 작동이 활성화된다.
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.openapitools.jackson.nullable.JsonNullableModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration


@Configuration
class JsonConfig {

    @Bean("objectMapper")
    fun objectMapper(): ObjectMapper {

        return jacksonObjectMapper().apply {
            setSerializationInclusion(JsonInclude.Include.ALWAYS)
            configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            disable(SerializationFeature.FAIL_ON_EMPTY_BEANS, SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            registerModules(JsonNullableModule(), JavaTimeModule())
        }
    }
}

JsonNullable 사용법

  • 본격적인 예제 작성에 앞서 JsonNullable 랩퍼 클래스의 사용법을 간단히 정리했다.
// 값 미지정, Partial Update 대상에서 제외
val unset: JsonNullable<String?> = JsonNullable.undefined()

// 값으로 null 지정, Partial Update 대상에 포함
val toNull: JsonNullable<String?> = JsonNullable.of(null)

// 값으로 value 지정, Partial Update 대상에 포함
val toValue: JsonNullable<String?> = JsonNullable.of("value")

UpdateFooRequestDTO 생성

  • foo, bar 2개 필드를 가지는 DTO를 생성한다.
import org.openapitools.jackson.nullable.JsonNullable

data class UpdateFooRequestDTO(

    // JsonNullable 적용
    var foo: JsonNullable<String?> = JsonNullable.undefined(),
    
    // JsonNullable 미적용
    var bar: String? = null
)

@RestController 작성

  • 마지막으로 PATCH 요청을 받는 API를 아래와 같이 작성한다.
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("foos")
class FooController {

    @PatchMapping
    fun updateFoo(@RequestBody request: UpdateFooRequestDTO): ResponseEntity<Void> {

        println("foo?.isPresent(): ${request.foo.isPresent}")
        if (request.foo.isPresent) {
            println("foo?.get(): ${request.foo.get()}")
        }
        println("bar?: ${request.bar}")

        return ResponseEntity.noContent().build()
    }
}

JsonNullable<Any?> 동작 확인

  • 동작 확인 결과는 아래와 같다. 첫번째 사례로 foo, bar 모두 null로 교체하는 상황이다. 즉, 두 필드 모두 Partial Update의 대상이 되어야 한다.
{
  "foo": null, // foo를 null로 교체
  "bar": null // bar를 null로 교체
}

foo?.isPresent(): true
foo?.get(): null
bar?: null
  • 두번째 사례는 foo를 생략하는 것이다. 이 경우, foo는 주어지지 않으므로 Partial Update의 대상에서 제외되어야 한다.
  • 이번 글을 작성한 이유인 isPresent(): false를 통해서 Parial Update의 대상에서 제외된 것을 확인할 수 있다. 이러한 특성을 이용하면 @Repository 레벨까지 DTO를 그대로 전달하여 정확한 Partial Update를 수행할 수 있다.
{
  // foo를 생략하여 Partial Update 대상에서 제외
  "bar": null // bar를 null로 교체
}

foo?.isPresent(): false
bar?: null
  • 세번째 사례는 foo, bar 모두 특정 값으로 교체하는 상황이다. 두 필드 모두 Partial Update의 대상이 되어야 한다.
{
  "foo": "value1", // foo를 value1로 교체
  "bar": "value2" // bar를 value2로 교체
}

foo?.isPresent(): true
foo?.get(): value1
bar?: value2

DTO를 Querydsl의 JPAUpdateClause로 변환

  • 앞서 예제로 소개한 DTO를 응용하면 아래와 같이 QuerydslJPAUpdateClause 오브젝트로 변환할 수 있다.
package com.jocoos.flipflop.api.common.dto

import com.querydsl.jpa.impl.JPAQueryFactory
import com.querydsl.jpa.impl.JPAUpdateClause
import java.time.Instant

data class UpdateFooRequestDTO(
    var id: Long? = null,
    var updateUserId: Long? = null,

    var foo: JsonNullable<String?> = JsonNullable.undefined(),
    var bar: JsonNullable<String?> = JsonNullable.undefined()
) {
    fun toUpdateClause(query: JPAQueryFactory): JPAUpdateClause {

        val foo = QFoo("foo")
        val update = query.update(foo)

        update.where(this.foo.id.eq(id))

        if (this.foo.isPresent) {
            update.set(foo.foo, this.foo.get())
        }
        if (this.bar.isPresent) {
            update.set(foo.bar, this.bar.get())
        }
        update.set(foo.lastModifiedBy, this.updateUserId)
        update.set(foo.lastModifiedAt, Instant.now())

        return update
    }
}
  • 이렇게 만들어진 JPAUpdateClause는 아래와 같이 Partial Update에 만능으로 대응 가능한 update() 함수를 작성하는데 사용할 수 있다.
package com.jocoos.flipflop.api.common.repository

import com.jocoos.flipflop.api.common.dto.FooUpdateRequestDTO
import com.jocoos.flipflop.api.common.entity.Foo
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
import javax.annotation.Resource
import javax.persistence.EntityManager

@Repository
class FooRepositorySupport(

    @Autowired
    @Resource(name = "jpaQueryFactory")
    val query: JPAQueryFactory,
    val em: EntityManager

) : QuerydslRepositorySupport(Foo::class.java) {

    @Transactional(readOnly = false)
    fun update(request: FooUpdateRequestDTO): Long {

        request.id ?: return 0

        val result = request.toUpdateClause(query).execute()
        em.refresh(em.find(Foo::class.java, request.id))

        return result
    }
}

참고 글