CS

REST API 설계 원칙 - 실무에서 바로 쓰는 베스트 프랙티스

백엔드 개발자 김승원 2026. 3. 31. 15:50

들어가며

REST(Representational State Transfer)는 웹 API 설계의 사실상 표준으로 자리 잡았습니다. 하지만 단순히 JSON을 반환한다고 RESTful API가 되는 것은 아닙니다. 일관성 있고 직관적인 API를 설계하려면 명확한 원칙과 규칙을 따라야 합니다. 이 글에서는 URI 설계, HTTP 메서드 활용, 상태 코드, 페이징, 버전 관리, OpenAPI 문서화까지 실무에서 바로 적용할 수 있는 베스트 프랙티스를 다룹니다.

1. URI 설계 원칙

URI는 API의 얼굴입니다. 잘 설계된 URI는 문서 없이도 API의 의도를 파악할 수 있게 합니다.

기본 규칙

  • 명사 사용: 리소스를 나타내므로 동사가 아닌 명사를 사용합니다.
  • 복수형 사용: 컬렉션은 복수형으로 표현합니다.
  • 소문자와 하이픈: 소문자를 사용하고, 단어 구분은 하이픈(-)으로 합니다.
  • 계층 구조: 리소스 간의 관계를 경로로 표현합니다.
# 좋은 예시
GET    /api/users                    # 사용자 목록 조회
GET    /api/users/123                # 특정 사용자 조회
GET    /api/users/123/orders         # 특정 사용자의 주문 목록
POST   /api/users                    # 사용자 생성
GET    /api/order-items              # 하이픈으로 단어 구분

# 나쁜 예시
GET    /api/getUsers                 # 동사 사용 금지
GET    /api/user                     # 단수형 사용 금지
GET    /api/User/123                 # 대문자 사용 금지
GET    /api/order_items              # 언더스코어 사용 지양
POST   /api/users/create             # URI에 동작을 넣지 않음

필터링, 정렬, 검색

컬렉션 조회 시 필터링, 정렬, 검색은 쿼리 파라미터로 처리합니다.

# 필터링
GET /api/products?category=electronics&min-price=10000

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

# 검색
GET /api/products?q=스마트폰

# 필드 선택 (Sparse Fieldsets)
GET /api/users/123?fields=name,email,phone

2. HTTP 메서드 올바르게 사용하기

각 HTTP 메서드는 명확한 의미를 가지며, 이를 정확히 사용해야 합니다.

메서드 용도 멱등성 안전성 요청 본문
GET 리소스 조회 O O 없음
POST 리소스 생성 X X 있음
PUT 리소스 전체 교체 O X 있음
PATCH 리소스 부분 수정 X X 있음
DELETE 리소스 삭제 O X 없음

멱등성(Idempotency)이란 같은 요청을 여러 번 보내도 결과가 동일한 성질입니다. GET, PUT, DELETE는 멱등이지만, POST와 PATCH는 멱등이 아닙니다. 네트워크 장애로 인한 재시도 시 멱등성이 중요하게 작용합니다.

Spring Boot 구현 예시

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

    private final UserService userService;

    @GetMapping
    public ResponseEntity<Page<UserResponse>> getUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "createdAt,desc") String sort) {
        
        Page<UserResponse> users = userService.findAll(
                PageRequest.of(page, size, Sort.by(parseSortOrders(sort))));
        return ResponseEntity.ok(users);
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

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

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

    @PatchMapping("/{id}")
    public ResponseEntity<UserResponse> patchUser(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {
        return ResponseEntity.ok(userService.patch(id, updates));
    }

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

3. 응답 형식 표준화

일관된 응답 형식은 클라이언트 개발자의 생산성을 높입니다.

성공 응답

// 단일 리소스 조회
{
  "data": {
    "id": 123,
    "name": "김승원",
    "email": "seungwon@example.com",
    "createdAt": "2026-03-15T09:30:00Z"
  }
}

// 컬렉션 조회 (페이징)
{
  "data": [
    { "id": 1, "name": "김승원" },
    { "id": 2, "name": "이영희" }
  ],
  "pagination": {
    "page": 0,
    "size": 20,
    "totalElements": 150,
    "totalPages": 8
  }
}

에러 응답

// RFC 9457 (Problem Details) 형식 권장
{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "유효성 검증 실패",
  "status": 400,
  "detail": "요청 본문에 유효하지 않은 필드가 있습니다.",
  "instance": "/api/users",
  "errors": [
    {
      "field": "email",
      "message": "올바른 이메일 형식이 아닙니다.",
      "rejectedValue": "invalid-email"
    }
  ]
}

Spring Boot 글로벌 예외 처리

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleNotFound(
            ResourceNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("리소스를 찾을 수 없습니다");
        problem.setType(URI.create("https://api.example.com/errors/not-found"));
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ProblemDetail> handleValidation(
            MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "유효성 검증 실패");
        
        List<Map<String, String>> errors = ex.getBindingResult()
                .getFieldErrors().stream()
                .map(e -> Map.of(
                        "field", e.getField(),
                        "message", e.getDefaultMessage()))
                .toList();
        problem.setProperty("errors", errors);
        return ResponseEntity.badRequest().body(problem);
    }
}

4. 페이징 전략

오프셋 기반 페이징

가장 일반적인 방식이지만, 대용량 데이터에서 성능 문제가 있습니다. OFFSET 100000이면 DB가 100,000개의 행을 스캔한 후 버려야 합니다.

커서 기반 페이징

대용량 데이터에 적합한 방식입니다. 마지막으로 조회한 항목의 식별자를 기준으로 다음 페이지를 조회합니다.

// 요청
GET /api/posts?cursor=eyJpZCI6MTAwfQ==&size=20

// 응답
{
  "data": [...],
  "pagination": {
    "nextCursor": "eyJpZCI6MTIwfQ==",
    "hasNext": true
  }
}
// Spring Boot 커서 기반 페이징 구현
@GetMapping("/api/posts")
public ResponseEntity<CursorPage<PostResponse>> getPosts(
        @RequestParam(required = false) String cursor,
        @RequestParam(defaultValue = "20") int size) {
    
    Long lastId = cursor != null ? decodeCursor(cursor) : Long.MAX_VALUE;
    
    List<Post> posts = postRepository
            .findByIdLessThanOrderByIdDesc(lastId, PageRequest.of(0, size + 1));
    
    boolean hasNext = posts.size() > size;
    if (hasNext) posts = posts.subList(0, size);
    
    String nextCursor = hasNext 
            ? encodeCursor(posts.get(posts.size() - 1).getId()) 
            : null;
    
    return ResponseEntity.ok(new CursorPage<>(
            posts.stream().map(PostResponse::from).toList(),
            nextCursor, hasNext));
}

5. API 버전 관리

API는 시간이 지남에 따라 변경됩니다. 기존 클라이언트의 호환성을 유지하면서 새 기능을 추가하려면 버전 관리가 필수입니다.

전략 비교

방식 예시 장점 단점
URI 경로 /api/v1/users 직관적, 캐싱 용이 URI 변경이 큼
쿼리 파라미터 /api/users?version=1 선택적 적용 가능 누락 시 혼란
헤더 Accept: application/vnd.api.v1+json URI가 깔끔 테스트가 불편

실무에서는 URI 경로 방식이 가장 널리 사용됩니다. 직관적이고 브라우저에서 바로 테스트할 수 있기 때문입니다.

6. OpenAPI(Swagger) 문서화

API 문서는 프론트엔드 개발자와의 소통 도구입니다. springdoc-openapi를 활용하면 코드에서 자동으로 문서를 생성할 수 있습니다.

// build.gradle
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0'
// application.yml
springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: method
@Tag(name = "User", description = "사용자 관리 API")
@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    @Operation(
        summary = "사용자 목록 조회",
        description = "페이징을 지원하는 사용자 목록 조회 API입니다."
    )
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "조회 성공"),
        @ApiResponse(responseCode = "401", description = "인증 필요")
    })
    @GetMapping
    public ResponseEntity<Page<UserResponse>> getUsers(
            @Parameter(description = "페이지 번호 (0부터 시작)")
            @RequestParam(defaultValue = "0") int page,
            @Parameter(description = "페이지 크기")
            @RequestParam(defaultValue = "20") int size) {
        // ...
    }
}

설정 후 http://localhost:8080/swagger-ui.html에 접속하면 인터랙티브한 API 문서를 확인할 수 있습니다.

마치며

좋은 REST API는 일관성, 직관성, 확장성을 모두 갖추어야 합니다. 이 글에서 다룬 원칙들을 요약하면 다음과 같습니다.

  1. URI는 명사, 복수형, 소문자, 하이픈을 사용합니다.
  2. HTTP 메서드를 의미에 맞게 정확히 사용합니다.
  3. 응답 형식을 표준화하고, 에러 응답에는 RFC 9457을 활용합니다.
  4. 대용량 데이터는 커서 기반 페이징을 고려합니다.
  5. API 버전 관리를 통해 하위 호환성을 유지합니다.
  6. OpenAPI로 문서를 자동 생성하여 팀 협업을 원활하게 합니다.

이 원칙들을 프로젝트 초기부터 적용하면 유지보수가 쉽고 확장 가능한 API를 만들 수 있습니다.