당신이 React로 화면을 만들다가 데이터가 필요해진 순간을 떠올려 보자. 손이 거의 반사적으로 이렇게 움직였을 것이다.
const res = await fetch("/api/tasks");
const tasks = await res.json();
이 두 줄을 우리는 수백 번, 어쩌면 수천 번 써왔다. 요청을 보내고, 응답을 받고, JSON으로 풀어서 화면에 뿌린다. 너무 익숙해서 이제는 거의 생각조차 하지 않는다. 그런데 잠시 멈추고 한 가지만 생각해 보자. 이 fetch가 떠난 요청은 도대체 어디로 가는 걸까? 그리고 그 반대편에서는 누가 무엇을 하고 있을까?
솔직히 고백하자면, 프런트만 다루던 시절의 나에게 서버는 일종의 블랙박스였다. fetch를 던지면 잠시 후 JSON이 돌아온다. 잘 돌아오면 다행이고, 빨간 에러가 뜨면 백엔드 담당자에게 슬랙을 보냈다. 그 안에서 무슨 일이 벌어지는지는 알 수도 없고, 알 필요도 없다고 여겼다. 어쩌면 당신도 비슷하지 않았는가?
이 책은 그 블랙박스를 여는 데서 출발한다. 그런데 처음부터 낯선 자바와 Spring 이야기를 쏟아내지는 않을 생각이다. 그건 너무 불친절하다. 대신 우리가 이미 아주 잘 아는 것 — fetch, 상태코드, 헤더, JSON — 에서 시작하자. 그리고 그것을 딱 한 번, 180도 뒤집어 보자. 클라이언트 입장에서 보던 HTTP를, 서버 입장에서 다시 보는 것이다. 이 장에서 할 일은 새로운 지식을 욱여넣는 게 아니라, 이미 가진 지식을 거울에 비춰 보는 일이다.
같은 HTTP, 반대편에서 보면
먼저 정리해 두자. 프런트 개발자라면 이미 HTTP의 절반을 알고 있다. 메서드(GET/POST/PUT/DELETE), 상태코드(200·201·400·401·404·500), 헤더, 그리고 JSON 바디. 모두 클라이언트가 “보내고” “받는” 것이다. 우리가 매일 다뤄온 자산이다. 이걸 버릴 이유는 전혀 없다. 오히려 출발점으로 삼자.
자, 이제 시점을 바꿔 보자. 방금 그 fetch("/api/tasks") 요청이 네트워크를 건너 서버 컴퓨터의 문 앞에 도착했다고 상상해 보자. 클라이언트 입장에서 이 요청은 “내가 보내는 것”이었다. 하지만 서버 입장에서는 “나에게 도착한 것”이다. 같은 HTTP 메시지인데, 보는 방향이 정반대다.
fetch가 받아 온 404. 이 숫자는 누가 정한 값일까?
이건 생각보다 묵직한 변화다. 200을 줄지 404를 줄지, 검증에 실패했을 때 400을 줄지 422를 줄지, 새 자원을 만들었을 때 200으로 퉁칠지 201을 제대로 줄지 — 이 모든 게 더 이상 “받는 것”이 아니라 “내가 결정하는 책임”이 된다. 프런트에서 서버가 주는 신호를 해석했다면, 백엔드에서는 그 신호를 설계하게 되는 셈이다.
서버가 요청 하나를 처리하는 5단계
그렇다면 서버는 도착한 요청 하나를 가지고 정확히 무슨 일을 할까? 막연히 “처리한다”고 뭉뚱그리지 말고, 단계를 쪼개서 들여다보자. 요청 하나가 들어와 응답 하나가 나가기까지, 서버 안에서는 대략 다섯 가지 일이 순서대로 벌어진다.
POST /api/tasks로 제목이 빈 할 일 {"title":""}이 왔다. 서버는 이걸 언제 걸러 낼까?
이 다섯 단계를 한 줄로 이으면 이렇게 된다. 라우팅 → 역직렬화 → 검증 → 처리 → 직렬화. 지금 이 흐름을 외울 필요는 없다. 다만 한 가지만 마음에 새겨 두자. 서버는 마법으로 JSON을 뱉어내는 신비한 존재가 아니라, 내가 들여다보고 통제할 수 있는 5단계 파이프라인이다. 블랙박스의 뚜껑이 살짝 열리는 느낌이 드는가? 앞으로의 장들은 이 다섯 단계를 하나씩 깊이 파고드는 여정이라고 봐도 좋다.
await res.json() / JSON.stringify(body)TaskRequest 객체) / 직렬화(반환 객체 → JSON)res.json()은 어떤 모양이든 일단 객체로 만들어 준다. 자바는 미리 정한 클래스 모양에 맞춰 풀기 때문에, 숫자 칸에 "abc"가 오는 식으로 모양이 어긋나면 검증까지 가지도 못하고 역직렬화 단계에서 400으로 끝난다.요청의 모양을 바꾸고 “다음 단계”를 눌러 보세요. 어느 단계에서 멈추는지, 어떤 상태코드가 누구의 결정으로 나가는지 보입니다.
- 1. 라우팅·
- 2. 역직렬화·
- 3. 검증·
- 4. 처리·
- 5. 직렬화·
- 응답·
- 해볼 것
- 라우팅 단계에서 멈추는 요청 보내기 (404 또는 405)
- 같은 400이 서로 다른 두 단계에서 나는 것 확인하기
201 Created받기- 라우팅은 통과했는데 404가 나는 요청 만들기
- 서버는 200을 보냈는데 브라우저가 막는 장면을 본 뒤, 서버 설정으로 풀기
메서드와 상태코드 — 받던 것에서 정하는 것으로
이제 우리가 이미 아는 메서드와 상태코드를, 서버의 눈으로 다시 보자.
먼저 메서드다. 프런트에서 우리는 “조회는 GET, 생성은 POST, 수정은 PUT, 삭제는 DELETE”라는 관습을 거의 몸으로 익혔다. 그런데 이 약속이 단지 우리끼리의 동네 규칙이 아니라는 점은 알아 둘 만하다. HTTP 메서드의 의미는 RFC 9110(HTTP Semantics)이라는 표준 문서에 정의되어 있다. 예컨대 GET은 자원을 가져오기만 할 뿐 서버의 상태를 바꾸지 않아야 한다는 식이다. 서버를 만드는 입장이 되면 이 약속을 지키는 쪽이 우리가 된다. “GET 요청인데 슬쩍 데이터를 지운다” 같은 설계는 표준을 어기는 일이고, 그런 API는 쓰는 사람을 두고두고 난감하게 만든다.
상태코드는 더 극적이다. 앞서 말했듯 이제 우리가 정하는 값이다. 그렇다면 어떤 상황에 어떤 코드를 줘야 할까? 입문 단계에서 손에 익혀 두면 좋은 것만 추려 보자.
- 200 OK — 잘 처리됐다. 조회가 성공했을 때의 기본값이다.
- 201 Created — 새 자원을 만들어냈다. 할 일을 새로 등록했을 때 그냥 200이 아니라 201을 주면, “새로 생겼다”는 의미가 한층 또렷해진다.
- 400 Bad Request — 클라이언트가 보낸 요청이 잘못됐다. 앞의 검증 단계에서 걸러낸 경우다.
- 401 Unauthorized — “당신이 누군지 모르겠다.” 인증이 필요한데 안 했거나 실패한 경우다.
- 404 Not Found — 찾는 자원이 없다.
- 500 Internal Server Error — 서버 안에서 뭔가 터졌다. 우리가 미처 처리 못 한 예외가 새어 나온 것이다.
Spring 컨트롤러의 @PostMapping 메서드가 할 일을 저장한 뒤 그 객체를 그냥 return 하면, 응답 상태코드는?
여기서 한 가지 마음가짐을 권하고 싶다. 상태코드를 “에러 났으니 대충 500” 식으로 던지지 말자. 클라이언트가 잘못 보낸 거면 400, 자원이 없으면 404, 서버 잘못이면 500 — 이렇게 의도를 담아 골라 주는 편이 낫다. 왜냐하면 그 코드를 받아서 분기 처리할 사람이 다름 아닌 과거의 당신, 즉 프런트 개발자이기 때문이다. 서버가 상태코드를 성의 없이 주면 프런트가 얼마나 답답한지, 우리는 이미 몸으로 안다. 그러니 그 답답함을 물려주지 말자. (다만 검증 실패를 어떻게 품위 있게 다룰지, 전역 예외 처리로 500을 어떻게 길들일지는 5장에서 본격적으로 다룬다. 지금은 “상태코드는 내가 의도해서 정하는 것”이라는 감각만 챙겨 두면 충분하다.)
if (!res.ok) { … } 받은 상태코드로 분기ResponseEntity.status(…) 보낼 상태코드를 고름fetch는 404나 500을 받아도 reject하지 않고 res.ok로만 실패를 알린다. 그래서 서버가 실패를 200에 {"error": …}로 담아 보내면 프런트의 res.ok 분기는 아무것도 걸러 내지 못한다. 서버에서 고른 숫자가 곧 프런트 분기의 계약이다.헤더, 그리고 서버에 남는 기억 — 쿠키와 세션의 씨앗
헤더 이야기로 넘어가자. 프런트에서 우리는 헤더에 익숙하다. Content-Type: application/json을 붙여 “이건 JSON이야”라고 알리고, Authorization 헤더에 토큰을 실어 보냈다. 서버 입장에서 헤더는 본문(바디)에 담기 애매한 부가 정보를 주고받는 통로다. 요청에도 붙고 응답에도 붙는, 일종의 메타데이터인 셈이다.
그런데 헤더 중에 우리가 특히 곱씹어 볼 만한 것이 하나 있다. 바로 쿠키다. 한 가지 상황을 그려 보자. 당신이 어떤 사이트에 로그인을 한다. 그다음부터는 페이지를 옮겨 다녀도 로그인이 풀리지 않는다. 어떻게 이게 가능할까?
로그인한 뒤 페이지를 옮겨 다녀도 로그인이 풀리지 않는 이유는?
지금은 딱 이 정도만 알아 두면 된다. “HTTP는 본래 기억이 없는데, 쿠키와 세션이라는 장치로 서버가 사용자를 기억하게 만든다.” 이 작은 씨앗이 나중에 9장, 10장, 11장에서 인증과 보안을 다룰 때 큰 나무로 자란다. 그때 우리는 “토큰을 어디에 저장하지?”라는, 프런트 개발자라면 한 번쯤 검색해 봤을 그 고민과 정면으로 마주하게 된다. 기억해 두자. 오늘 심은 이 씨앗이 그 이야기의 뿌리다.
REST는 규칙이 아니라 스타일이다
이쯤에서 우리가 입에 달고 살던 단어 하나를 짚고 넘어가자. “REST API.” 우리는 거의 모든 API를 그냥 “REST API”라고 불러왔다. 그런데 REST가 정확히 무엇인지 설명하라고 하면, 막상 말문이 막히지 않는가? 나도 그랬다.
흔한 오해부터 풀자. REST는 “URL을 자원 중심으로 짓고, GET·POST 같은 메서드를 잘 쓰자” 정도의 코딩 규칙이라고 여기기 쉽다. 물론 그것도 일부는 맞다. 하지만 본래 REST는 그보다 훨씬 큰 개념이다. REST는 로이 필딩(Roy Fielding)이 2000년 박사학위 논문에서 정리한 아키텍처 스타일이다. 쉽게 말해 “분산 시스템을 이렇게 설계하면 웹처럼 잘 확장되고 오래간다”는 일종의 설계 철학에 가깝다. 클라이언트와 서버를 분리하고, 서버가 요청 사이의 상태를 들고 있지 않고(stateless), 응답을 캐시할 수 있게 하고… 이런 제약들의 묶음이다.
여기서 재미있는 사실이 하나 있다. 업계에서 흔히 부르는 “REST API”는 필딩이 정의한 엄밀한 REST와 꽤 다르다. 우리가 만드는 대부분의 API는 “HTTP + JSON + 자원스러운 URL” 수준의 느슨한 차용에 가깝다. 이게 틀렸다는 말이 아니다. 다만 “REST”라는 단어 뒤에 생각보다 깊은 이론이 깔려 있다는 것 정도는 알아 두면, 나중에 누군가 “그건 진짜 REST가 아니야”라고 시비를 걸 때 당황하지 않을 수 있다.
지금은 여기까지만 해 두자. REST의 엄밀한 정의와 그 유명한 HATEOAS 이야기는 5장과 부록에서 더 깊이 파고들 기회가 있다. 입문 단계에서 이론에 너무 일찍 짓눌릴 필요는 없다. 그저 “REST는 단순한 규칙이 아니라 아키텍처 스타일”이라는 한 문장만 품고 가자.
그 빨간 콘솔 에러의 정체 — CORS라는 증상
프런트를 하다 보면 거의 통과의례처럼 마주치는 에러가 하나 있다. React 앱을 localhost:3000에서 띄우고, 백엔드 API를 다른 주소로 호출했을 때, 브라우저 콘솔에 시뻘겋게 뜨던 그 메시지 말이다.
Access to fetch at 'http://localhost:8080/api/tasks' from origin
'http://localhost:3000' has been blocked by CORS policy
이 에러를 처음 봤을 때의 그 막막함, 다들 기억할 것이다. 코드는 분명 멀쩡한데 요청이 막힌다. 검색해서 어딘가에서 복사한 설정을 붙여넣으면 신기하게 풀리긴 하는데, 왜 풀렸는지는 끝내 알 수 없었다. 솔직히 꽤나 찜찜한 경험이 아니었나?
fetch("http://localhost:8080/api/tasks")(평범한 GET)에서 이 에러가 났다. 그 순간 8080 서버에서는 무슨 일이 있었을까?
그렇다면 이 장벽으로 막는 것도, 또 정당하게 열어 주는 것도 누구의 일일까? 흥미롭게도 그건 프런트의 일이 아니다. 서버의 책임이다. 서버가 “이 출처에서 오는 요청은 받아 주겠다”고 명시적으로 허락해 줘야 비로소 그 빨간 에러가 사라진다. 다시 말해, 그동안 우리를 괴롭히던 에러를 풀 열쇠가 이제 백엔드를 배우는 당신 손에 넘어온다.
이 씨앗만 심어 두자. “CORS는 프런트가 보는 증상이고, 막는 것도 푸는 것도 서버의 책임이다.” 구체적으로 Spring에서 어떻게 출처를 허용하고, preflight라는 사전 요청은 무엇이며, 쿠키 인증과 엮이면 왜 더 까다로워지는지는 5장에서 제대로 다룬다. 오늘은 “아, 그게 원래 서버 쪽 이야기였구나”라는 깨달음 하나면 충분하다.
AI 페어코딩 학습 포인트: HTTP를 AI에게 묻되, 1차 근거로 되짚자
이 책은 처음부터 끝까지 AI와 함께 배우는 것을 전제로 한다. Cursor든 Claude Code든, 당신 곁에는 이미 든든한 페어 프로그래머가 있다. 그러니 이 장의 내용도 AI에게 적극적으로 물어보며 멘탈 모델을 단단히 다지자. 예를 들어 이렇게 물어볼 수 있다.
“내가 프런트에서
fetch('/api/tasks')로 POST 요청을 보내면, 서버는 이 요청을 받아서 어떤 단계로 처리하는지 설명해줘.”
AI는 이런 개념 설명에 강하다. 라우팅부터 직렬화까지, 우리가 앞서 본 흐름을 친절하게 풀어 줄 것이다. 백과사전을 옆에 둔 것처럼 든든하다.
그런데 여기서 한 가지 습관을 처음부터 들이자. AI의 설명을 그대로 믿고 끝내지 말자는 것이다. 특히 “GET은 멱등하다”, “이 상태코드는 이런 의미다” 같은 표준에 관한 주장은, 한 번쯤 1차 근거로 되짚어 보는 편이 낫다. HTTP의 의미는 RFC 9110 같은 표준 문서가 정한다. AI에게 “그 설명의 근거가 되는 RFC 조항을 알려줘”라고 한 번 더 물어보고, 정말 그런지 확인하는 것이다.
왜 이렇게까지 해야 할까? 한 가지 미리 귀띔하자면, AI는 때때로 자신 있게 틀린다. 특히 버전이 빠르게 바뀌는 기술에서는 한물간 코드를 천연덕스럽게 정답인 양 내놓곤 한다. 이 함정은 2장에서 본격적으로 다룬다. 지금은 그저 “AI에게 묻고, 1차 근거로 검증한다”는 작은 리듬을 손에 익혀 두자. 이 리듬이 앞으로 이 책 전체를 관통하는 우리의 무기가 된다.
마무리
이 장에서 우리는 코드를 거의 쓰지 않았다. 대신 시점 하나를 통째로 뒤집었다. 매일 쓰던 fetch의 반대편으로 건너가, 서버의 눈으로 HTTP를 다시 본 것이다.
돌아보면 핵심은 하나다. 서버는 마법 상자가 아니라 라우팅 → 역직렬화 → 검증 → 처리 → 직렬화라는 5단계 파이프라인이고, 상태코드·CORS처럼 우리가 받기만 하던 것들이 이제 우리가 정하는 책임으로 넘어왔다는 것.
블랙박스의 뚜껑이 조금 열린 느낌이 든다면, 이 장은 제 몫을 다한 셈이다. 다만 우리는 아직 코드 한 줄 본 적이 없다. 그러니 곧장 코드부터 짜고 싶겠지만, 그 전에 우리가 이 여정을 누구와 함께 걸을지부터 정하고 가자. 다음 장에서는 우리의 페어 프로그래머인 AI를 어떻게 동반자로 삼되, 왜 마냥 믿어서는 안 되는지를 이야기한다. 든든하지만 가끔은 자신 있게 틀리는 이 동료와의 관계 설정, 그것이 본격적인 코딩에 앞서 맺어 둘 학습 계약이다.
1. 서버가 요청 하나를 처리하는 순서로 맞는 것은?
2. GET /api/tasks/99가 왔는데 99번 할 일이 없다. 의도를 담은 응답은?
3. localhost:3000의 React 앱이 localhost:8080 API를 부르다 CORS 에러가 났다. 고칠 곳은?
4. HTTP가 stateless라는 말의 뜻은?
아직 서버 프로젝트는 없습니다. 오늘은 이미 가진 도구(브라우저 DevTools, AI)로 서버가 내린 결정을 거꾸로 읽어 봅니다.