Backend 24
성공의 그림자: HTTP 응답 코드로 API 의도 밝히기
요청이 성공했으면 항상 200 OK면 되는 것 아닌가?
근거 · 교안 p155-p163
1장: 성공은 하나가 아니야: GET과 POST의 다른 성공
솔라가 노트북을 탁 소리 나게 닫으며 소파에 털썩 주저앉았다. 화면에 가득했던 코드 대신, 미간에 잡힌 주름이 동생의 고민을 고스란히 보여주고 있었다. 방에서 조용히 책을 읽던 루나는 말없이 고개만 들어 솔라를 바라봤다.
“언니, 나 스터디에서 이상한 말을 들었어.”
“이상한 말?”
“응. ‘성공해도 어떤 성공인지 HTTP 응답 코드로 표현해야 한다’는 거야. 이게 무슨 소리람. 성공은 그냥 성공이지, 종류가 따로 있나?”
솔라는 소파 등받이에 머리를 기댄 채 허공에 대고 말했다. 그녀의 목소리에는 순수한 의문과 약간의 불만이 섞여 있었다.
“나는 지금까지 API 만들 때 요청이 성공적으로 처리되면 무조건 ‘200 OK’를 보냈거든. 사용자를 조회하는 GET 요청이 성공해도 200, 새로운 사용자를 등록하는 POST 요청이 성공해도 200. 일단 서버가 제 할 일을 무사히 마쳤으니까. 그런데 이게 잘못됐다는 듯이 말하잖아. 대체 뭐가 문제라는 건지 모르겠어.”
솔라의 말은 명쾌했다. 성공의 증표로 ‘200 OK’ 하나면 충분하지 않냐는, 지극히 상식적인 주장처럼 들렸다. 루나는 책을 덮고 솔라 쪽으로 몸을 돌렸다.
“네 말대로 둘 다 성공은 맞지. 그런데 솔라, 네가 어제 읽던 책 정보를 그냥 확인하는 거랑, 세상에 없던 새 책을 도서관 서가에 직접 꽂아 넣는 거랑, 그 둘이 정말 똑같은 ‘성공’일까?”
“음… 그건 다르지. 하나는 그냥 보는 거고, 하나는 뭔가를 만들어내는 거니까.”
“바로 그거야.” 루나는 자신의 노트북을 열어 간단한 API 테스트 도구를 화면에 띄웠다. “말로 하는 대신, 직접 눈으로 확인해 보자. 우리가 만든 작은 도서관 API가 있잖아. 그걸로 한번 시험해 볼게.”
루나는 먼저 특정 책의 정보를 조회하는 GET 요청을 보냈다.
GET /books/1
화면에 즉시 응답이 나타났다. 솔라는 자기도 모르게 화면으로 고개를 불쑥 내밀었다.
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 1,
"title": "클린 코드",
"author": "로버트 C. 마틴"
}
“이것 봐. 내가 말했지? ‘200 OK’잖아. 요청이 성공했고, 내가 원하던 책 정보를 깔끔하게 받았어.”
솔라의 목소리에 확신이 찼다. 그녀의 생각이 옳았다는 증거가 눈앞에 있었다. 루나는 고개를 끄덕이며 다음 요청을 준비했다.
“맞아. 이건 ‘네가 요청한 자원을 성공적으로 찾아서 전달했어’라는 의미의 성공이야. 그럼 이제 새 책을 하나 등록해 볼까?”
루나는 요청 본문에 새로운 책 정보를 JSON 형식으로 입력하고 POST 요청을 보냈다.
POST /books
{
"title": "오브젝트",
"author": "조영호"
}
잠시 후, 새로운 응답이 화면에 표시되었다. 솔라는 응답의 첫 줄을 보고 눈을 동그랗게 떴다.
HTTP/1.1 201 Created
Location: /books/5
Content-Type: application/json
{
"id": 5,
"title": "오브젝트",
"author": "조영호"
}
“어? 이건… ‘200 OK’가 아니네? ‘201 Created’라고 나왔어. 이것도 성공한 거 맞아?”
“응, 성공이야. 200번대 코드는 모두 성공을 의미하니까. 하지만 그냥 성공이 아니지. 뭐라고 적혀있어?”
“‘Created’. 만들어졌다고?”
솔라는 두 개의 응답을 번갈아 쳐다보았다. 하나는 익숙한 200 OK, 다른 하나는 낯선 201 Created. 똑같이 성공인데, 서버는 다른 숫자로 대답하고 있었다. 마치 다른 종류의 미소를 짓는 것처럼.
“아…!” 솔라는 무언가 깨달은 듯 작은 탄성을 뱉었다. “GET 요청은 이미 있던 책의 정보를 그냥 확인하는 거였어. 신분증을 보여주고 ‘나 여기 있어요’하는 것처럼. 하지만 POST 요청은 이 세상에 없던 ‘5번 책’이라는 새로운 존재를 만들어낸 거네. 완전히 새로운 신분증을 발급받은 것처럼 말이야.”
그녀는 이제 ‘성공’이라는 단어를 다른 무게로 느끼기 시작했다.
“그래서 200 OK는 ‘네 요청 잘 들었고, 결과 여기 있어’라는 느낌이라면, 201 Created는 ‘네 요청 덕분에 새로운 게 성공적으로 태어났어!’라고 선언하는 거구나. 그냥 상태가 바뀐 게 아니라, 새로운 정체성이 생긴 거니까.”
솔라는 방금 전 자신이 내뱉었던 “성공은 그냥 성공이지”라는 말을 떠올렸다. 이제 그 말은 어딘가 부족하게 느껴졌다. ‘조회 성공’과 ‘생성 성공’은 분명히 다른 결의 성공이었다.
“이제 ‘성공해도 어떤 성공인지 표현해야 한다’는 말이 조금은 이해가 돼. 서버가 어떤 종류의 성공을 이뤘는지 명확하게 알려주면, 클라이언트는 그걸 보고 다음에 뭘 해야 할지 더 똑똑하게 판단할 수 있겠네.”
솔라는 고개를 끄덕였지만, 이내 새로운 의문이 고개를 들었다. 그녀의 시선은 201 Created 응답에 다시 머물렀다.
“그런데 언니, 그냥 200 OK를 보내고 응답 본문에 ‘성공적으로 생성되었습니다’라고 메시지를 넣어주는 거랑 무슨 차이야? 왜 굳이 ‘201’이라는 별도의 코드를 만들어서까지 구별하는 거지? 이게 단순히 200의 다른 이름 같은 거 아니야? 혹시 저 Location 헤더랑 뭔가 관련이 있는 건가?“
2장: 201 Created, 그냥 숫자가 아니야: 생성된 자원의 Location
솔라의 질문이 채 공중에 흩어지기도 전에, 루나는 노트북 화면의 코드를 수정하기 시작했다. 방금 전까지 새 책을 성공적으로 등록했던 @PostMapping 메서드가 그녀의 손길 아래 모습을 바꾸고 있었다. 솔라는 어깨너머로 화면을 들여다보았다.
원래 코드는 생성된 Book 객체를 그대로 반환하고 있었다. 스프링이 마법처럼 201 Created를 붙여주긴 했지만, 코드상으로는 그저 객체를 돌려주는 게 전부였다. 하지만 루나는 그 부분을 지우고 새로운 코드를 타이핑했다. ResponseEntity라는 낯선 이름이 화면에 나타났다.
// 기존 코드
// @PostMapping("/books")
// @ResponseStatus(HttpStatus.CREATED)
// public Book createBook(@RequestBody Book book) {
// Book savedBook = bookService.save(book);
// return savedBook;
// }
// 수정된 코드
@PostMapping("/books")
public ResponseEntity<Book> createBook(@RequestBody Book book) {
Book savedBook = bookService.save(book);
URI location = URI.create("/books/" + savedBook.getId());
return ResponseEntity.created(location).body(savedBook);
}
솔라가 미간을 찌푸렸다. “언니, 지금 뭐 하는 거야? 아까 잘 동작했잖아. 201 Created도 잘 나왔고. 그런데 왜 코드를 더 복잡하게 바꿔? ResponseEntity? 이건 또 뭐야?”
솔라의 목소리에는 코드가 더 길고 복잡해지는 것에 대한 순수한 불만이 담겨 있었다. 그녀의 눈에는 ResponseEntity.created(location).body(savedBook)라는 구문이 단순히 return savedBook이라는 한 줄을 불필요하게 늘려놓은 것처럼 보였다.
“네 말대로 결과는 같아 보일 수 있지.” 루나는 키보드에서 손을 떼고, 아까 POST 요청 후 받았던 응답 창을 다시 화면에 띄웠다.
HTTP/1.1 201 Created
Location: /books/5
Content-Type: application/json
{
"id": 5,
"title": "오브젝트",
"author": "조영호"
}
루나는 응답의 두 번째 줄, Location: /books/5를 손가락으로 가리켰다. “솔라, 네가 아까 그냥 지나쳤던 이 한 줄이, 201 Created의 진짜 의미를 담고 있어.”
“Location 헤더? 그게 뭐? 그냥 새로 만들어진 책의 주소 같은 거 아냐?”
“맞아. 정확히는 ‘새로 생성된 자원을 어디서 조회할 수 있는지 알려주는 공식적인 주소’야. 클라이언트는 이 주소로 바로 GET 요청을 보내서 방금 자기가 만든 자원이 잘 있는지 확인할 수 있어. 마치 온라인으로 물건을 주문하고 받은 영수증에 ‘고객님의 상품은 A-3 구역 5번 선반에 보관되었습니다’라고 적어주는 것과 같아.”
루나는 다시 코드로 시선을 옮겼다. “그럼 이 영수증은 누가, 어떻게 발급할까? 내가 방금 바꾼 코드를 다시 봐. ResponseEntity.created() 메서드에 우리가 뭘 전달했지?”
솔라의 눈이 코드와 응답 창 사이를 바쁘게 오갔다. 그녀의 시선은 URI location = URI.create("/books/" + savedBook.getId());라는 줄과 ResponseEntity.created(location)에 꽂혔다. 그리고 응답 창의 Location: /books/5에 다시 멈췄다.
순간, 흩어져 있던 퍼즐 조각들이 맞춰지는 느낌이 들었다.
“아…! created() 메서드에 넘겨준 location 변수가… 저 응답 헤더의 Location 값이 되는 거구나! 그냥 ‘201’이라는 숫자만 덜렁 보내는 게 아니었어. ‘생성되었음’을 알리는 동시에, ‘생성된 것은 바로 여기에 있으니 찾아가 보시오’라는 안내판까지 함께 주는 거였네.”
솔라는 이제야 200 OK에 ‘생성 성공’ 메시지를 담아 보내는 것과 201 Created를 보내는 것의 결정적인 차이를 깨달았다. 전자는 서버와 클라이언트 간의 사적인 약속이다. 클라이언트는 응답 본문을 열어보고 ‘생성 성공’이라는 문자열이 있는지 직접 확인해야 한다. 하지만 후자는 HTTP라는 공공의 약속, 즉 프로토콜을 따르는 것이다. 어떤 클라이언트든 201 Created 응답을 받으면 Location 헤더를 찾아 다음 행동을 준비할 수 있다. 그것이 바로 잘 설계된 API의 힘이었다.
“이제 알겠어. ResponseEntity는 단순히 응답을 포장하는 상자가 아니었어. 상태 코드, 헤더, 본문을 내 의도대로 정교하게 조립해서 ‘자원 생성’이라는 특별한 성공의 의미를 온전히 전달하는 도구였구나. ‘성공해도 어떤 성공인지 표현해야 한다’는 말이 이런 뜻이었어.”
스스로의 깨달음에 만족한 솔라는 의기양양하게 노트북 화면을 바라봤다. 이제 명확한 성공 응답을 만들 수 있을 것 같았다. 하지만 그녀의 시선이 코드의 한 부분에 머물면서 새로운 그림자가 드리워졌다. 데이터를 받아들이는 @RequestBody Book book 부분이었다.
“그런데 언니, 만약에… 만약에 누가 장난으로 제목도 없는 책을 만들어달라고 요청하면 어떡해? title을 빈칸으로 보내거나, 아예 title 필드를 빼고 요청을 보내는 거야. 그래도 서버는 일단 책을 만들고 ‘201 Created’를 보내주나? 내용도 없는 유령 같은 책의 위치를 알려주는 게 무슨 의미가 있지?“
3장: 좋은 응답은 유효한 요청에서: @RequestBody와 @Valid
솔라는 이전의 깨달음으로 인한 만족감 대신, 새로운 불안감에 사로잡혔다. 그녀는 언니의 노트북을 자기 쪽으로 끌어당겼다. 방금 전까지 ‘새 책 등록’이라는 성공적인 결과를 보여주던 API 테스트 도구가 이제는 잠재적인 문제를 실험할 도구가 되었다.
“말 나온 김에, 내가 직접 해봐야겠어. 유령 책을 한번 만들어보자고.”
솔라는 망설임 없이 POST 요청의 본문을 수정했다. title 필드의 값을 빈 문자열("")로 바꾼 것이다. 그녀는 이것이 서버를 속이는 일종의 장난처럼 느껴졌지만, 동시에 반드시 확인해야 할 구멍이라고 생각했다. 그녀는 숨을 참고 ‘전송’ 버튼을 눌렀다.
POST /books
{
"title": "",
"author": "작가 미상"
}
결과는 솔라의 우려대로였다. 화면에는 선명하게 201 Created가 찍혔고, Location 헤더는 /books/6이라는 새로운 주소를 가리키고 있었다. 본문에는 제목이 텅 빈, 정체불명의 책 데이터가 담겨 있었다.
“이것 봐, 언니. 서버는 아무런 의심 없이 유령 책을 만들고는 ‘성공적으로 생성했어!’라고 태연하게 거짓말을 하고 있잖아. 심지어 친절하게 ‘이쪽으로 가면 그 유령을 만날 수 있어’라고 안내까지 해주고. 이건 올바른 응답이 아니야. 성공의 의미를 더럽히는 거라고.”
솔라의 목소리에는 실망감이 가득했다. 잘 설계된 줄 알았던 시스템의 허점을 발견한 기분이었다.
루나는 솔라의 불평을 잠자코 듣고 있었다. 그녀는 대답 대신, 조용히 키보드를 가져가 코드를 수정하기 시작했다. 먼저 책 데이터를 담는 Book 클래스를 열었다. 그리고 title 필드 위에 @NotBlank라는 생소한 어노테이션을 한 줄 추가했다.
public class Book {
// ... id, author 필드
@NotBlank
private String title;
// ...
}
다음으로, 요청을 처리하는 컨트롤러의 @PostMapping 메서드로 돌아가, @RequestBody 앞에 @Valid라는 또 다른 어노테이션을 붙였다. 코드는 아주 조금 바뀌었을 뿐이지만, 무언가 중요한 방어막이 추가된 듯한 인상을 주었다.
@PostMapping("/books")
public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
// ... 이전과 동일한 로직
}
“자, 다시 해봐.”
루나는 짧게 말하고는 다시 솔라에게 노트북을 밀어주었다. 솔라는 떨떠름한 표정으로 화면을 봤다. 고작 어노테이션 두 개를 추가했을 뿐이었다. 그녀는 이것이 그저 형식적인 절차일 뿐, 근본적인 문제를 해결하지는 못할 것이라고 속으로 생각했다. ‘저런다고 뭐가 달라지겠어.’
그녀는 아까와 똑같은, title이 비어있는 요청을 다시 전송했다. 그러나 이번에 화면에 나타난 결과는 완전히 달랐다.
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"timestamp": "...",
"status": 400,
"error": "Bad Request",
"errors": [
{
"field": "title",
"defaultMessage": "must not be blank"
}
],
"path": "/books"
}
201 Created는 온데간데없었다. 대신 차가운 400 Bad Request라는 응답 코드가 솔라를 맞았다. 응답 본문에는 ‘title 필드가 비어 있으면 안 됩니다’라는 명확한 실패 이유까지 담겨 있었다. 유령 책은 생성되지 않았다. 서버는 요청을 아예 문전박대해 버린 것이다.
솔라는 순간 말을 잃고 두 개의 응답 결과를 번갈아 쳐다봤다. 하나는 거짓된 성공을 알리는 201 Created. 다른 하나는 단호한 거절을 표하는 400 Bad Request.
“아…!”
탄성과 함께 솔라의 머릿속에서 모든 것이 연결되었다.
“이 어노테이션들이 문지기 역할을 한 거구나! @RequestBody가 손님(요청 데이터)을 안으로 들여보내기 전에, @Valid라는 문지기가 @NotBlank 규칙에 따라 손님의 신분증(데이터의 유효성)을 검사한 거야. 제목이 없으니까 ‘당신은 들어올 수 없습니다’ 하고 돌려보낸 거네.”
그녀는 이제 ‘성공’ 응답의 무게를 다시 한번 깨달았다. 성공 응답은 아무에게나 주어지는 것이 아니었다. 유효한 요청, 즉 ‘자격 있는’ 요청만이 받을 수 있는 명예로운 배지 같은 것이었다.
“그러니까 ‘좋은 응답’은 ‘좋은 요청’에서 시작되는 거였어. 서버는 무턱대고 모든 요청을 처리하고 보는 게 아니라, 먼저 요청이 올바른지부터 따져봐야 해. 만약 요청이 엉망이라면, ‘201 Created’ 같은 성공 코드를 보내는 게 아니라, ‘400 Bad Request’처럼 무엇이 잘못됐는지 명확히 알려주는 게 훨씬 더 정직하고 유용한 소통 방식이구나.”
솔라는 더 이상 스터디에서 들었던 “성공해도 어떤 성공인지 표현해야 한다”는 말이 어렵게 느껴지지 않았다. 그 말은 단순히 성공의 종류(200, 201)를 구분하는 것을 넘어, 성공의 전제 조건인 ‘요청의 유효성’까지 포함하는 더 깊은 의미를 가지고 있었다.
그녀는 새 메모장을 열어 키보드를 두드리기 시작했다. 자신의 깨달음을 스터디 그룹원들과 나눌 가이드라인 초안을 만들기 위해서였다. 화면에 그녀의 생각이 정리되어 나타났다.
API 응답 코드 활용 가이드라인 (초안)
-
자원 조회 (GET) 성공 시:
200 OK- 요청한 데이터를 찾아서 성공적으로 전달했다는 의미.
-
자원 생성 (POST) 성공 시:
201 Created- 요청으로 인해 새로운 자원이 ‘성공적으로 생성되었음’을 의미.
- 필수:
Location헤더에 생성된 자원의 URI를 포함해야 함.
-
요청 자체가 유효하지 않을 때:
400 Bad Request- 성공/실패를 논하기 전에, 요청 내용에 문제가 있음을 의미. (예: 필수 필드 누락)
- 중요:
201 Created같은 성공 코드를 절대 반환하면 안 됨. 응답 본문에 무엇이 잘못되었는지 명시할 것.
솔라는 자신이 작성한 짧은 가이드라인을 보며 만족스럽게 고개를 끄덕였다. 이제 성공의 그림자에 가려져 있던 여러 얼굴들이 선명하게 보이는 듯했다. 성공은 하나의 모습이 아니었고, 좋은 성공은 언제나 좋은 시작을 필요로 했다.