
목차
들어가기 전에
REST 아키텍처에 대해 처음 공부할 때, 리소스의 수정을 나타내는 HTTP 메소드로 PUT과 PATCH가 언급됩니다. 흔히 PUT은 리소스의 전체 속성을 요청으로 보내진 데이터로 대체하고, 요청으로 전달되지 않은 속성은 null로 대체한다고 합니다. PATCH는 요청으로 전달된 속성만 수정한다고 이야기하죠.
하지만 막상 실무에 들어가보면 실제로 리소스의 일부 속성을 수정하는 API라도 PATCH로 호출하는 모습을 찾아보기 매우 어렵습니다. 오히려 리소스의 일부 속성만 수정하는 API라도 PUT으로 호출하는 경우가 많죠. 실무에서의 PUT과 PATCH에 대해 개인적인 생각을 정리해보려고 합니다.
어느 관점에서 볼 것인가?
PUT과 PATCH는 사실 관점의 차이로 볼 수 있지 않을까 싶습니다. 예를 들어 글 도메인을 나타내는 Article이라는 엔티티가 있다고 가정해봅시다.
@Entity
class Article(
var title: String?,
var content: String?
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
var viewCount: Long = 0
private set
fun incrementViewCount() = viewCount++
fun update(request: ArticleUpdateRequest) {
this.title = request.title
this.content = request.content
}
}
그리고 다음과 같은 요청 바디를 받아서 글의 제목과 내용을 모두 수정하는 API를 구현하는 경우를 생각해봅시다.
{
"title": "수정할 글 제목",
"content": "수정할 글 내용"
}
서버는 이 요청을 받고 글이라는 도메인의 제목을 요청 바디의 title값으로, 내용을 요청 바디의 content값으로 모두 변경합니다.
이 경우, API를 호출하기 위한 HTTP 메소드는 무엇으로 정의해야 할까요?
먼저 서버의 관점에서 본다면, 이 API는 PATCH로 받아야 한다고 생각할 수 있습니다. 요청 스펙에 정의된 속성을 모두 변경하지만, 어찌됐든 글이라는 도메인의 일부 속성만 수정하는 API니까요.
반대로 클라이언트의 관점에서 본다면, 이 API는 PUT으로 호출해야 한다고 생각할 수 있습니다. 클라이언트는 도메인의 전체 속성을 알 방법도, 알 필요도 없을 뿐더러 API의 동작 자체는 요청 스펙에 정의된 모든 속성을 변경하기 때문입니다.
PATCH를 사용하기엔 녹록치 않은 현실
앞의 예시에서 서버의 관점을 적용해서 API 호출 메소드로 PATCH를 사용했다고 가정해봅시다.
여기서 문제가 생깁니다. 앞서 이야기한대로, 서버가 의도한 동작은 요청 스펙에 정의된 title과 content를 모두 변경하는 동작입니다. 즉, title만 전달되면 content는 null로 덮어씌워지고, content만 전달되면 title은 null로 덮어 씌워지는거죠. 단지 글의 일부 속성만 변경하는 API이기 때문에 PATCH를 사용했을 뿐입니다.
하지만 클라이언트 입장에선 PATCH라고 적혀있으니, 요청 바디의 필드 중 하나만 전달하면 전달된 필드만 교체될 것이라고 생각하기 쉽습니다. 그래서 글 내용만 수정하려고 content만 보냈더니 글 제목이 사라지는 문제가 생기거나 반대로 글 제목만 수정하려고 했는데 내용이 사라져버릴 수 있죠.
이런 문제를 해결하기 위해, 서버에서 클라이언트의 피드백을 반영해서 클라이언트가 전달한 값만 변경하려고 비즈니스 로직을 수정하려는 경우를 생각해봅시다. 일반적으로 코틀린이나 자바에서는 요청 바디를 받을 때 다음과 같이 DTO로 받습니다.
data class ArticleUpdateRequest(
val title: String?,
val content: String?
)
클라이언트의 피드백을 반영하려면 둘 중 일부만 받을 수 있어야하니, 두 프로퍼티 모두 타입은 nullable로 설정하겠죠. 이렇게 되면, Article 엔티티의 update() 로직도 조금 수정해야 합니다.
@Entity
class Article(
var title: String?,
var content: String?
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
var viewCount: Long = 0
private set
fun incrementViewCount() = viewCount++
fun update(request: ArticleUpdateRequest) { // null 체크 추가
if (request.title != null) this.title = request.title
if (request.content != null) this.content = request.content
}
}
요청 바디의 각 필드에 대해 null 체크를 해서, null이 아닐 때만 엔티티의 프로퍼티를 변경하도록 수정했습니다.
하지만 큰 문제가 하나 있습니다. 코틀린이나 자바는 undefined라는 개념이 없어서 요청 바디를 DTO로 받게 되면 어떤 필드가 아예 없는건지, 아니면 null로 전달된 것인지 구분할 수가 없습니다.
{
"title": null,
"content": "수정할 글 내용"
}
{
"content": "수정할 글 내용"
}
다시 말해, 이렇게 두 가지의 요청 바디가 발생해도 DTO에서는 title: null으로 받습니다. 하지만 클라이언트 입장에서 첫 번째 요청은 글의 제목을 없애버리는 의도를 가진 요청이고, 두 번째 요청은 글의 내용만 변경하는 의도를 가진 요청이죠.
위에서 수정한 Article의 update()도 문제가 있는게, null체크를 해서 프로퍼티 값의 변경 여부를 따졌지만 이렇게 하면 값을 없애버리는 의도를 가진 동작을 수행할 수 없습니다.
결국 이런 경우까지 처리하려면 다음과 같이 요청 바디를 DTO로 받는 것을 포기할 수 밖에 없습니다.
@Entity
class Article(
var title: String?,
var content: String?
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
var viewCount: Long = 0L
protected set
fun increaseViewCount() = viewCount++
fun update(request: Map<String, Any?>) { // Map으로 변경
if (request.contains("title")) this.title = request["title"] as String?
if (request.contains("content")) this.content = request["content"] as String?
}
}
요청 바디를 Map으로 받으면 요청 바디에 아예 존재하지 않는 Key는 Map에 포함되지 않으니 원하는 동작을 모두 구현할 수 있습니다. 하지만 이 방식을 사용하면 DTO의 수많은 장점을 포기해야만 하는 문제가 있죠.
운영중에 도메인의 속성이 변경된다면?
위의 사례 말고도 다른 문제가 있습니다. 다음과 같이 사용자 도메인을 나타내는 User 엔티티가 있는 경우를 생각해봅시다.
@Entity
class User(
var name: String
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}
그리고 사용자의 이름을 변경하는 API가 있는 경우를 생각해봅시다. 이 경우, User에서 변경할 수 있는 프로퍼티는 이름 뿐이기 때문에 해당 API는 PUT을 사용하게 됩니다.
그러다가 버전이 올라가면서 사용자의 속성에 전화번호, 주소가 추가됩니다.
@Entity
class User(
var name: String,
var phone: String,
var address: String
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}
이렇게 되면 REST 이론을 지키기 위해 기존에 있던 사용자의 이름을 변경하는 API는 두 가지 선택을 해야 합니다.
- 이름을 변경하는 작업이
User의 일부 속성만 변경하게 되었기 때문에 요청 메소드를 PATCH로 변경합니다. - PUT을 유지하기 위해 API의 요청 스펙에 전화번호와 주소를 추가합니다.
문제는 두 가지 방법 모두 기존에 클라이언트가 요청하고 있던 내용을 수정해야 한다는 점입니다. 첫 번째 방법은 PUT으로 보내던 요청을 PATCH로 보내도록 수정하지 않으면 오류가 발생할 것이고, 두 번째 방법은 요청 바디에 전화번호와 주소를 추가하지 않으면 기존에 저장되어 있던 전화번호와 주소가 유실되어 버린다는 큰 문제가 발생하죠.
개인적인 생각 - 클라이언트의 관점을 적용하자!
그래서 개인적인 생각을 정리해보자면, 클라이언트의 관점을 적용해서 PUT이나 PATCH를 선택하는게 맞다는 생각이 듭니다.
사실 요청의 주체인 클라이언트의 입장에서는 서버의 리소스가 가진 모든 속성을 알 수도 없고 알 필요도 없습니다. 단지 서버에서 정의해준 API의 스펙대로 요청을 보냈을 때, 기대했던 응답을 받기만 하면 되죠.
즉, 클라이언트는 서버의 리소스가 가진 속성들이 어떤게 있는지 모르기 때문에 PUT으로 요청하는 API의 요청 바디에 누락된 필드는 null이 될 것이라 생각할테고, PATCH로 요청하는 API의 요청 바디에 누락된 필드는 수정되지 않을 것이라고 생각하게 될 것입니다. 실제로 서버의 리소스 속성과는 전혀 무관하게 말이죠.
실무에서 리소스를 수정하는 API의 대부분이 PUT을 사용하는 이유도 이런 이유가 아닐까 싶습니다. 단순히 리소스의 일부 속성만 변경한다는 이유로 PATCH를 사용하면 클라이언트에서 오해를 할 수도 있고 구현 상의 난이도 문제도 있기 때문이죠.
실제로 클라이언트의 관점을 적용하면 위에서 언급했던 모든 문제가 해결되기도 합니다.
마치며
리소스를 수정하는 API를 설계하면서 시작된 의문이었는데 생각해볼 점이 많았던 것 같습니다. "API 스펙에 정의된 속성을 전부 교체할거니까 PUT을 써야할 것 같은데, 리소스 입장에선 일부 속성만 수정하는거니까 PATCH가 맞지 않나?" 하는 의문에서 시작했지만 실무에서 PATCH를 사용하는 경우를 왜 찾아보기 힘든지도 생각해볼 수 있었죠.
사실 최종적인 결론은 클라이언트와 서버가 소통하면서 오해하지 않도록 규칙을 잘 정하는 것이라고 생각합니다. 이 글에서 작성한 내용과 다른 생각을 가진 분이 계실 수도 있고, 그러면 문제가 발생하지 않도록 서로 협의하고 규칙을 정해서 개발하면 될테니까요.
'Development > etc.' 카테고리의 다른 글
| [IntelliJ] 인텔리제이 2025.2 이후 Gradle 콘솔로 실행되는 문제 (0) | 2025.08.28 |
|---|---|
| [IntelliJ] Remote JVM Debug를 사용해서 서버를 원격으로 디버깅 하기 (0) | 2024.10.02 |
| [Gradle] 순수 자바/코틀린 Gradle 프로젝트를 실행 가능한 jar 파일로 빌드하기 (2) | 2023.11.28 |
| [IntelliJ] 스프링부트 프로젝트의 클래스 또는 리소스 수정 후 자동으로 재시작하기 (2) | 2023.11.02 |
| [IntelliJ] 인텔리제이의 Remote host를 사용하여 서버에 접속해보자! (0) | 2023.03.09 |
댓글