이 글에서 얻는 것

  • REST 아키텍처 스타일의 핵심 원칙을 이해합니다.
  • 리소스 중심 URL 설계와 HTTP 메서드의 올바른 사용법을 익힙니다.
  • HTTP 상태코드(2xx/3xx/4xx/5xx)를 상황에 맞게 선택할 수 있습니다.
  • API 버저닝, 페이징, 필터링 같은 실전 패턴을 적용할 수 있습니다.

0) REST는 “리소스 중심"의 아키텍처 스타일

REST (Representational State Transfer)는 HTTP를 기반으로 한 아키텍처 스타일입니다.

핵심 개념:

  • 리소스(Resource): 데이터의 단위 (예: 사용자, 주문, 상품)
  • 표현(Representation): 리소스의 형태 (JSON, XML 등)
  • 상태 전이(State Transfer): HTTP 메서드로 리소스 상태 변경

1) REST 설계 원칙

1-1) 리소스 중심 설계

❌ 잘못된 설계 (동사 중심)

POST /createUser
GET /getAllUsers
POST /updateUser
POST /deleteUser

✅ 올바른 설계 (명사 중심)

POST   /users          # 사용자 생성
GET    /users          # 사용자 목록 조회
GET    /users/{id}     # 특정 사용자 조회
PUT    /users/{id}     # 사용자 전체 수정
PATCH  /users/{id}     # 사용자 부분 수정
DELETE /users/{id}     # 사용자 삭제

핵심:

  • URL은 리소스를 나타냄 (명사 사용)
  • 동작은 HTTP 메서드로 표현 (GET/POST/PUT/DELETE)

1-2) 계층 구조 표현

GET /users/{userId}/orders              # 특정 사용자의 주문 목록
GET /users/{userId}/orders/{orderId}    # 특정 주문 상세
GET /orders/{orderId}/items             # 주문의 아이템 목록

주의:

  • 너무 깊은 중첩은 피함 (3단계 이내 권장)
  • 독립적인 리소스는 최상위로

2) HTTP 메서드

2-1) GET: 리소스 조회

# 목록 조회
GET /users HTTP/1.1
Host: api.example.com

# 응답
HTTP/1.1 200 OK
Content-Type: application/json

[
  {"id": 1, "name": "Alice", "email": "alice@example.com"},
  {"id": 2, "name": "Bob", "email": "bob@example.com"}
]
# 단일 조회
GET /users/1 HTTP/1.1

# 응답
HTTP/1.1 200 OK
Content-Type: application/json

{"id": 1, "name": "Alice", "email": "alice@example.com"}

특징:

  • 안전(Safe): 서버 상태 변경 없음
  • 멱등성(Idempotent): 여러 번 호출해도 같은 결과
  • Body 없음 (쿼리 파라미터로 조건 전달)

2-2) POST: 리소스 생성

POST /users HTTP/1.1
Content-Type: application/json

{
  "name": "Charlie",
  "email": "charlie@example.com"
}

# 응답
HTTP/1.1 201 Created
Location: /users/3
Content-Type: application/json

{
  "id": 3,
  "name": "Charlie",
  "email": "charlie@example.com",
  "createdAt": "2025-12-16T10:00:00Z"
}

특징:

  • 비멱등성: 여러 번 호출 시 여러 리소스 생성
  • 201 Created + Location 헤더 반환
  • 생성된 리소스의 URI 제공

2-3) PUT: 리소스 전체 교체

PUT /users/1 HTTP/1.1
Content-Type: application/json

{
  "name": "Alice Updated",
  "email": "alice.new@example.com"
}

# 응답
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 1,
  "name": "Alice Updated",
  "email": "alice.new@example.com"
}

특징:

  • 멱등성: 같은 요청 반복 시 같은 결과
  • 전체 필드 교체 (일부 누락 시 null로 처리 가능)
  • 리소스가 없으면 생성 가능 (선택적)

2-4) PATCH: 리소스 부분 수정

PATCH /users/1 HTTP/1.1
Content-Type: application/json

{
  "name": "Alice Renamed"
}

# 응답
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 1,
  "name": "Alice Renamed",
  "email": "alice@example.com"  # 기존  유지
}

특징:

  • 일부 필드만 수정
  • PUT보다 유연함

2-5) DELETE: 리소스 삭제

DELETE /users/1 HTTP/1.1

# 응답
HTTP/1.1 204 No Content

특징:

  • 멱등성: 여러 번 삭제해도 결과 동일
  • 204 No Content (본문 없음) 또는 200 OK (삭제된 리소스 반환)

3) HTTP 상태코드

3-1) 2xx: 성공

200 OK               # 요청 성공 (GET, PUT, PATCH)
201 Created          # 생성 성공 (POST)
202 Accepted         # 요청 수락 (비동기 처리)
204 No Content       # 성공, 응답 본문 없음 (DELETE)

3-2) 3xx: 리다이렉션

301 Moved Permanently   # 영구 이동
302 Found               # 임시 이동
304 Not Modified        # 캐시된 리소스 사용

3-3) 4xx: 클라이언트 오류

400 Bad Request          # 잘못된 요청 (유효성 검증 실패)
401 Unauthorized         # 인증 필요
403 Forbidden            # 권한 없음
404 Not Found            # 리소스 없음
405 Method Not Allowed   # 지원하지 않는 HTTP 메서드
409 Conflict             # 리소스 충돌 (중복 등)
422 Unprocessable Entity # 유효성 검증 실패 (상세)
429 Too Many Requests    # 요청 횟수 초과

3-4) 5xx: 서버 오류

500 Internal Server Error  # 서버 내부 오류
502 Bad Gateway            # 게이트웨이 오류
503 Service Unavailable    # 서비스 이용 불가
504 Gateway Timeout        # 게이트웨이 타임아웃

3-5) 상태코드 선택 가이드

// ✅ 올바른 상태코드 사용
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    User user = userService.findById(id);
    if (user == null) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).build();  // 404
    }
    return ResponseEntity.ok(user);  // 200
}

@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody User user) {
    User created = userService.create(user);
    return ResponseEntity
        .status(HttpStatus.CREATED)  // 201
        .header("Location", "/users/" + created.getId())
        .body(created);
}

@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build();  // 204
}

4) URL 설계 패턴

4-1) 복수형 명사 사용

✅ /users
✅ /orders
✅ /products

❌ /user
❌ /order
❌ /getUsers

4-2) 케밥 케이스 (kebab-case)

✅ /order-items
✅ /user-profiles

❌ /orderItems  (camelCase)
❌ /Order_Items (snake_case)

4-3) 쿼리 파라미터 활용

# 필터링
GET /users?status=active&age=25

# 정렬
GET /users?sort=createdAt,desc

# 페이징
GET /users?page=1&size=20

# 검색
GET /users?search=alice

# 필드 선택
GET /users?fields=id,name,email

4-4) 하위 리소스

# 특정 사용자의 주문
GET /users/{userId}/orders

# 특정 주문의 아이템
GET /orders/{orderId}/items

# ⚠️ 너무 깊은 중첩 피하기
❌ /users/{userId}/orders/{orderId}/items/{itemId}/reviews
✅ /reviews?itemId={itemId}

5) 실전 패턴

5-1) 페이징

GET /users?page=1&size=20 HTTP/1.1

# 응답
HTTP/1.1 200 OK
Content-Type: application/json

{
  "content": [
    {"id": 1, "name": "Alice"},
    {"id": 2, "name": "Bob"}
  ],
  "page": 1,
  "size": 20,
  "totalElements": 100,
  "totalPages": 5
}

5-2) 필터링 & 정렬

# 필터링
GET /products?category=electronics&minPrice=10000&maxPrice=50000

# 정렬
GET /products?sort=price,asc&sort=createdAt,desc

# 복합
GET /products?category=electronics&sort=price,asc&page=1&size=10

5-3) 부분 응답 (Field Selection)

GET /users?fields=id,name,email HTTP/1.1

# 응답 (필요한 필드만)
[
  {"id": 1, "name": "Alice", "email": "alice@example.com"},
  {"id": 2, "name": "Bob", "email": "bob@example.com"}
]

5-4) API 버저닝

# URL 버전
GET /v1/users
GET /v2/users

# 헤더 버전
GET /users HTTP/1.1
Accept: application/vnd.myapi.v1+json

# 쿼리 파라미터 버전 (비권장)
GET /users?version=1

5-5) 에러 응답 포맷

POST /users HTTP/1.1
Content-Type: application/json

{"email": "invalid-email"}

# 응답
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "timestamp": "2025-12-16T10:00:00Z",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed",
  "path": "/users",
  "errors": [
    {
      "field": "email",
      "message": "Invalid email format",
      "rejectedValue": "invalid-email"
    }
  ]
}

5-6) HATEOAS (Hypermedia)

{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com",
  "_links": {
    "self": {"href": "/users/1"},
    "orders": {"href": "/users/1/orders"},
    "update": {"href": "/users/1", "method": "PUT"},
    "delete": {"href": "/users/1", "method": "DELETE"}
  }
}

6) 실전 예제: RESTful API 설계

블로그 시스템 API

# 게시글 (Posts)
GET    /posts                    # 목록 조회
GET    /posts/{id}               # 상세 조회
POST   /posts                    # 생성
PUT    /posts/{id}               # 수정
DELETE /posts/{id}               # 삭제

# 댓글 (Comments)
GET    /posts/{postId}/comments  # 특정 게시글의 댓글
POST   /posts/{postId}/comments  # 댓글 작성
DELETE /comments/{id}            # 댓글 삭제 (독립적)

# 좋아요
POST   /posts/{id}/like          # 좋아요
DELETE /posts/{id}/like          # 좋아요 취소

# 검색
GET    /posts?search=keyword&sort=createdAt,desc&page=1&size=10

Spring Boot 구현 예제

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    // 목록 조회
    @GetMapping
    public ResponseEntity<Page<UserDTO>> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(required = false) String status) {
        Page<UserDTO> users = userService.findAll(page, size, status);
        return ResponseEntity.ok(users);
    }

    // 단일 조회
    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
        UserDTO user = userService.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
        return ResponseEntity.ok(user);
    }

    // 생성
    @PostMapping
    public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserDTO created = userService.create(request);
        URI location = URI.create("/api/v1/users/" + created.getId());
        return ResponseEntity.created(location).body(created);
    }

    // 수정
    @PutMapping("/{id}")
    public ResponseEntity<UserDTO> updateUser(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request) {
        UserDTO updated = userService.update(id, request);
        return ResponseEntity.ok(updated);
    }

    // 부분 수정
    @PatchMapping("/{id}")
    public ResponseEntity<UserDTO> patchUser(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {
        UserDTO patched = userService.patch(id, updates);
        return ResponseEntity.ok(patched);
    }

    // 삭제
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

7) 자주 하는 실수

❌ 실수 1: 동사 사용

POST /createUser
GET /getUserById?id=1

✅ 수정

POST /users
GET /users/1

❌ 실수 2: 잘못된 HTTP 메서드

GET /users/delete?id=1    # GET으로 삭제
POST /users/search        # POST로 조회

✅ 수정

DELETE /users/1
GET /users?search=keyword

❌ 실수 3: 부적절한 상태코드

// 리소스 없을 때 200 반환
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userService.findById(id).orElse(null);  // null 반환 시 200
}

✅ 수정

@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    return userService.findById(id)
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());  // 404
}

연습 (추천)

  1. 간단한 REST API 설계

    • 도메인 선택 (예: 도서관, 쇼핑몰)
    • 리소스 정의
    • URL/메서드 매핑
  2. Spring Boot로 구현

    • Controller 작성
    • 상태코드 올바르게 반환
    • 예외 처리 (@ControllerAdvice)
  3. Postman으로 테스트

    • 각 엔드포인트 호출
    • 상태코드 확인
    • 에러 케이스 테스트

요약: 스스로 점검할 것

  • REST는 리소스 중심 설계 (명사 사용)
  • HTTP 메서드로 동작 표현 (GET/POST/PUT/PATCH/DELETE)
  • 상태코드를 상황에 맞게 선택 (2xx/4xx/5xx)
  • 쿼리 파라미터로 필터링/페이징/정렬
  • 에러 응답은 일관된 포맷으로 제공
  • URL은 계층 구조 표현 (3단계 이내)

다음 단계

  • Spring MVC 요청 라이프사이클: /learning/deep-dive/deep-dive-spring-mvc-request-lifecycle/
  • Spring Validation: /learning/deep-dive/deep-dive-spring-validation-response/
  • API 문서화 (Swagger): /learning/deep-dive/deep-dive-spring-restdocs-swagger/