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를 응용하면 아래와 같이 Querydsl의
JPAUpdateClause
오브젝트로 변환할 수 있다.
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
}
}