들어가며
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는 일관성, 직관성, 확장성을 모두 갖추어야 합니다. 이 글에서 다룬 원칙들을 요약하면 다음과 같습니다.
- URI는 명사, 복수형, 소문자, 하이픈을 사용합니다.
- HTTP 메서드를 의미에 맞게 정확히 사용합니다.
- 응답 형식을 표준화하고, 에러 응답에는 RFC 9457을 활용합니다.
- 대용량 데이터는 커서 기반 페이징을 고려합니다.
- API 버전 관리를 통해 하위 호환성을 유지합니다.
- OpenAPI로 문서를 자동 생성하여 팀 협업을 원활하게 합니다.
이 원칙들을 프로젝트 초기부터 적용하면 유지보수가 쉽고 확장 가능한 API를 만들 수 있습니다.
'CS' 카테고리의 다른 글
| 백엔드 개발자 기술 면접 완벽 대비 - Spring/JPA/DB/인프라 핵심 질문 50선 (0) | 2026.04.14 |
|---|---|
| DNS와 CDN 동작 원리 - 웹 성능 최적화의 기초 (0) | 2026.04.08 |
| 실시간 통신 완벽 가이드 - WebSocket, SSE, gRPC 비교 분석 (0) | 2026.04.08 |
| OAuth 2.0 / OIDC 완벽 가이드 - 인증 프로토콜의 모든 것 (1) | 2026.04.08 |
| HTTP 완벽 가이드 - 백엔드 개발자가 알아야 할 모든 것 (0) | 2026.03.31 |