REST 통신은 요청 한 줄과 응답 한 줄로 요약됩니다. 각 부분이 하는 역할이 다릅니다.
| 부분 | 요청 예 | 역할 |
|---|---|---|
| 메서드 | POST |
하려는 동작(조회/생성/치환/부분수정/삭제) |
| 경로 | /members/42 |
어떤 자원인지(무엇을 하는지가 아니라) |
| 쿼리 | ?page=0&size=10 |
필터·페이징·정렬 같은 부가 조건 |
| 헤더 | Content-Type: application/json |
본문 형식, 인증 토큰, 캐시 조건 등 메타데이터 |
| 본문 | {"name":"..."} |
실제 데이터(GET/DELETE 에는 보통 없음) |
응답도 구조가 같습니다. 상태 코드(성공/실패의 종류), 헤더(Content-Type, Location, ETag), 본문(데이터 또는 에러) 세 가지입니다. Content-Type 은 "내가 보내는 형식"을, Accept 는 "내가 받고 싶은 형식"을 뜻하며 둘은 반대 방향입니다.
요청 POST /members HTTP/1.1
Content-Type: application/json; charset=UTF-8
Accept: application/json
{"name":"홍길동","email":"hong@test.com","age":30}
응답 HTTP/1.1 201 Created
Location: /members/1
Content-Type: application/json; charset=UTF-8
{"id":"1","name":"홍길동", ...}경로는 "무엇"을 나타내고, 메서드가 "무엇을 한다"를 나타냅니다. /getMember, /createMember 처럼 경로에 동사를 넣으면 메서드와 의미가 겹칩니다.
| 나쁜 예 | 좋은 예 | 이유 |
|---|---|---|
GET /getMember?id=1 |
GET /members/1 |
동사 중복. 메서드가 이미 "조회"를 말한다 |
POST /deleteMember |
DELETE /members/1 |
삭제는 DELETE 메서드로 |
GET /member |
GET /members |
컬렉션은 복수형. 단건은 /members/{id} |
GET /members/1/getOrders |
GET /members/1/orders |
하위 자원은 경로 계층으로 |
멱등성은 "같은 요청을 여러 번 보내도 서버 상태가 한 번 보낸 것과 같은가"입니다. 재시도 설계에서 결정적입니다.
| 메서드 | 멱등 | 설명 |
|---|---|---|
GET |
O | 조회는 몇 번을 해도 상태가 안 바뀐다 |
PUT |
O | 같은 값으로 덮어쓰기를 반복해도 결과가 같다 |
DELETE |
O | 이미 지운 것을 또 지워도 "없음" 상태는 같다(응답 코드는 다를 수 있음) |
POST |
X | 같은 요청을 두 번 보내면 자원이 두 번 생긴다(중복 생성) |
PATCH |
보통 X | "age": "+1" 같은 상대적 수정이면 X, 절대값 대입이면 O |
네트워크가 끊겨 응답을 못 받았을 때, 멱등 메서드는 재시도가 안전하지만 POST 재시도는 중복 주문 같은 사고로 이어집니다. 그래서 결제 POST 에는 멱등키(idempotency key)를 따로 둡니다.
상태 코드는 클라이언트가 재시도할지, 사용자에게 폼 오류를 보여줄지, 로그인 화면으로 보낼지를 가르는 신호입니다.
| 코드 | 이름 | 언제 |
|---|---|---|
| 200 | OK | 조회·수정 성공, 본문 있음 |
| 201 | Created | 생성 성공. Location 헤더에 새 자원 경로 |
| 204 | No Content | 성공했지만 돌려줄 본문이 없음(삭제 등) |
| 400 | Bad Request | 요청 형식·필수값이 잘못됨(클라이언트 책임) |
| 401 | Unauthorized | 누구인지 모름(로그인 안 됨) |
| 403 | Forbidden | 누군지는 알지만 권한 없음 |
| 404 | Not Found | 자원이 없음(경로의 id 가 없거나 경로 자체가 없음) |
| 405 | Method Not Allowed | 경로는 있지만 그 메서드는 안 받음 |
| 409 | Conflict | 상태 충돌(중복 생성, 낙관적 잠금 버전 불일치) |
| 415 | Unsupported Media Type | Content-Type 이 기대와 다름(JSON 기대인데 form) |
| 422 | Unprocessable Entity | 형식은 맞지만 업무 규칙 위반(잔액 부족 등) |
| 500 | Internal Server Error | 서버 버그·예외. 메시지를 그대로 노출하면 안 됨 |
400 과 422 를 실무에서 섞어 쓰는 팀이 많은데, 이 레슨은 "필수값 누락·형식 오류(이메일 아님, 길이 초과)"는 400, "형식은 맞지만 업무 규칙 위반(잔액 부족, 이미 마감된 주문 수정)"은 422 로 나눕니다. 409 는 "지금 상태와 충돌"(버전 불일치, 중복 키)에만 씁니다.
자바 객체와 JSON 은 타입 체계가 다릅니다. Jackson(Spring Boot 기본 JSON 라이브러리)이 이 변환을 자동으로 하지만, 표를 모르면 프런트와 타입 문제로 다툽니다.
| 자바 타입 | JSON | 함정 |
|---|---|---|
null |
null |
Jackson 기본은 null 필드도 그대로 내려보낸다(@JsonInclude 로 생략 가능) |
int, Integer |
숫자 | 문제 없음 |
long, Long |
숫자 | JS Number 는 2^53 이상에서 정밀도를 잃는다. id·계좌번호는 문자열로 내보낸다 |
double, BigDecimal |
숫자 | 금액은 double 대신 BigDecimal. JSON 도 문자열로 내보내는 편이 안전 |
LocalDate, Instant |
문자열 | ISO-8601(2026-09-09, 2026-09-09T09:00:00Z). 시간대 없는 문자열은 재앙 |
enum |
문자열(기본) | @JsonValue 로 다른 표현(코드값)으로 바꿀 수 있다 |
List, 배열 |
배열 [...] |
순서 보장 |
| 중첩 객체 | 객체 {...} |
깊은 중첩은 화면에 안 쓰는 필드까지 끌고 온다(DTO 로 평탄화 권장) |
이름 표기도 언어마다 관례가 다릅니다. 자바는 memberName(카멜케이스), DB 컬럼은 member_name(스네이크케이스)이 흔합니다. Spring Boot 는 application.yml 에 아래처럼 설정하면 Jackson 이 자동 변환합니다.
spring:
jackson:
property-naming-strategy: SNAKE_CASE # 자바 memberName ↔ JSON member_name이 레슨의 MiniJson 은 Map<String,Object> 를 그대로 직렬화하는 최소 구현이라 이런 변환이 없습니다. "Jackson 이 리플렉션으로 getter 를 찾아 필드 이름을 JSON 키로 바꾸는 그 작업"을 우리는 Member.toJson() 에서 수동으로 하는 셈입니다.
Accept 헤더로 XML 도 요청할 수 있다는 점(콘텐츠 협상)도 알아두면 좋습니다. 이 레슨은 JSON 만 지원하므로 Accept 를 따로 검사하지 않지만, 실무 API 가 여러 형식을 지원한다면 Accept 값에 따라 Content-Type 을 다르게 응답합니다.
에러도 데이터입니다. 클라이언트가 코드로 분기하고, 폼에 필드별 오류를 표시하고, 로그를 추적할 수 있어야 합니다. 이 레슨은 다음 규격을 씁니다.
{
"code": "VALIDATION_ERROR", // 클라이언트가 분기할 안정적인 문자열 코드
"message": "입력 값을 확인하세요", // 사람이 읽을 요약(로그·토스트에 표시)
"errors": [ // 필드별 상세(폼 오류 표시용). 없으면 빈 배열
{ "field": "email", "reason": "이메일 형식이 아닙니다" }
],
"traceId": "b93714dc", // 서버 로그와 대조할 추적 ID
"timestamp": "2026-09-09T09:02:20Z"
}표준화된 대안으로 RFC 9457 application/problem+json 이 있습니다. {type, title, status, detail, instance} 필드를 쓰고 Content-Type: application/problem+json 을 붙입니다. Spring Boot 3.x 는 ProblemDetail 클래스로 이를 기본 지원합니다.
필드 이름만 다를 뿐 목적은 이 레슨의 규격과 같습니다. 코드로 분기하고, 사람이 읽을 설명을 담고, 상세를 붙이고, 추적 ID 로 로그를 찾는 4가지 역할입니다.
검증은 순서가 있습니다. 싼 검사를 먼저 해서 비싼 검사(DB 조회 등)를 아끼기 때문입니다.
① 형식 검증 "age" 가 숫자인가? → 아니면 400
② 필수 검증 "name" 이 비어있지 않은가? → 아니면 400
③ 업무 규칙 나이가 0~150 인가? 이메일이 중복 아닌가? → 아니면 400/409/422①②는 요청 자체의 문제(400), ③ 중 "이미 존재하는 상태와 충돌"은 409, "형식은 맞지만 처리할 수 없는 값"(예: 탈퇴 회원 재가입 제한, 정책상 불가)은 422 로 나눕니다. 이 레슨의 Validation 은 ①②를 한 번에 모아 errors 배열로 반환하고, ③(버전 충돌)은 MemberStore 가 별도로 409 로 던집니다.
DB 유니크 제약(이메일 중복) 위반도 업무 규칙 검증의 일종입니다. 애플리케이션에서 미리 조회해 409 로 막거나, DB 예외를 잡아 409 로 변환합니다. 어느 쪽이든 예외 메시지 원문은 그대로 노출하지 않습니다(5절 실수 3).
목록 API 는 전체를 한 번에 안 돌려주고 페이지 단위로 나눕니다. 응답에 "몇 번째 페이지인지", "총 몇 건인지"를 함께 담아야 프런트가 페이지 버튼을 그릴 수 있습니다.
| 필드 | 의미 |
|---|---|
page |
요청한 페이지 번호(0부터) |
size |
페이지당 개수 |
totalElements |
전체 개수 |
totalPages |
전체 페이지 수 |
content |
이 페이지의 실제 데이터 배열 |
이 오프셋 페이징(OFFSET size*page LIMIT size)은 뒤 페이지로 갈수록 DB 가 앞부분을 건너뛰느라 느려집니다. 대용량·무한 스크롤에는 키셋(seek) 페이징을 씁니다. "이전 페이지 마지막 id 보다 큰 것 중 상위 N개"(WHERE id > :lastId LIMIT :size)로 항상 일정한 속도를 냅니다.
두 사용자가 같은 회원을 동시에 수정하면 나중에 저장한 쪽이 먼저 저장한 쪽을 덮어씁니다(lost update). 막는 방법은 두 가지입니다.
| 방식 | 흐름 |
|---|---|
| 버전 필드 | 자원에 version 정수 보관, 요청에 읽은 버전 동봉, 불일치면 409 |
| ETag/If-Match | 응답 ETag: "3", 요청 If-Match: "3", HTTP 표준이라 캐시·프록시와 호환 |
이 레슨은 PUT 에 version 필드를, GET 에 ETag/If-None-Match(캐시 재검증, 304)를 함께 씁니다. 실무에서는 보통 둘 중 하나로 통일합니다.
브라우저가 자신과 다른 출처(도메인·포트·프로토콜)로 요청을 보내면, 브라우저는 먼저 OPTIONS 프리플라이트 요청을 보내 "이 실제 요청을 보내도 되는지" 서버에 묻습니다. 서버가 Access-Control-Allow-* 헤더로 허락해야 실제 요청이 나갑니다.
프리플라이트가 필요한 경우: PUT/PATCH/DELETE, 커스텀 헤더(Authorization 등), Content-Type: application/json
프리플라이트가 필요 없는 경우: 단순 GET, Content-Type: text/plain 인 단순 POST서버는 실제 요청과 프리플라이트 응답 모두에 Access-Control-Allow-Origin(허용 출처), Access-Control-Allow-Methods(허용 메서드), Access-Control-Allow-Headers(허용 헤더)를 붙여야 합니다. CORS 는 서버가 브라우저에게 주는 허가일 뿐이라, curl 이나 서버 간 호출에는 애초에 적용되지 않습니다.
데모 [10] 실제 결과
OPTIONS /members → 204 Allow-Methods=GET,POST,PUT,PATCH,DELETE,OPTIONS이 레슨의 CorsFilter 는 OPTIONS 를 라우터까지 보내지 않고 필터 단계에서 204 로 끝냅니다. 실제 GET/POST 요청은 이 필터를 그대로 통과해 라우터로 넘어가되, 같은 CORS 헤더가 응답에도 붙습니다.
Spring 의 @GetMapping("/members/{id}") 는 내부적으로 경로 패턴을 정규식으로 컴파일해 두고, 요청이 오면 등록된 패턴들과 순서대로 매칭해 첫 일치를 찾습니다. 이 레슨의 Router 가 이 과정을 그대로 구현합니다.
"/members/{id}" → 정규식 "/members/([^/]+)" → "id" 라는 이름을 그룹 순서와 매핑
요청 "/members/42" 가 이 정규식에 매칭 → group(1) = "42" → pathVars.put("id", "42")메서드가 다르면 경로가 매칭돼도 넘어가고(405 Method Not Allowed 후보), 경로 자체가 안 맞으면 404 후보로 남습니다. 순서대로 다 돌아본 뒤에도 후보가 있으면 405, 아예 없으면 404 를 최종 응답합니다.
| 이 레슨 | Spring Boot 3.5 |
|---|---|
Router.add("GET", "/members/{id}", ...) |
@RestController + @GetMapping("/members/{id}") |
pathVars.get("id") |
@PathVariable Long id |
MiniJson.read(body) |
@RequestBody MemberRequest req(Jackson 이 자동 역직렬화) |
Validation.validateCreate(body) |
@Valid @RequestBody + @NotBlank, @Email, @Min/@Max |
Router.writeJson(ex, 201, ...) |
ResponseEntity.status(201).location(uri).body(dto) |
Router 의 catch(RuntimeException) |
@RestControllerAdvice + @ExceptionHandler |
Page.of(all, page, size) |
메서드 인자 Pageable pageable → repository.findAll(pageable) |
CorsFilter |
@CrossOrigin 또는 WebMvcConfigurer.addCorsMappings |
MemberStore.VersionConflict → 409 |
JPA @Version → OptimisticLockingFailureException → 409 |
Vue(axios) 클라이언트는 응답 인터셉터에서 이 레슨의 에러 규격을 한곳에서 처리합니다.
axios.interceptors.response.use(
res => res,
err => {
const body = err.response?.data; // {code, message, errors, traceId}
if (body?.errors?.length) showFieldErrors(body.errors); // 폼에 필드별 표시
else toast.error(body?.message ?? '알 수 없는 오류');
console.error(`traceId=${body?.traceId}`); // 문의 시 이 값으로 서버 로그 검색
return Promise.reject(err);
}
);