REST API는 클라이언트와 서버 사이의 계약이다. 일관성 없는 API는 프론트엔드 개발자를 혼란에 빠뜨리고, 유지보수 비용을 기하급수적으로 늘린다. 스프링 MVC에서 REST API를 설계할 때 지켜야 할 원칙들을 정리한다.
HTTP 메서드와 매핑 어노테이션
스프링은 HTTP 메서드별로 전용 어노테이션을 제공한다.
@GetMapping("/users") // GET
@PostMapping("/users") // POST
@PutMapping("/users/{id}") // PUT
@PatchMapping("/users/{id}")// PATCH
@DeleteMapping("/users/{id}")// DELETE
이것들은 @RequestMapping(method = RequestMethod.GET)의 축약형이다.
왜 축약형을 써야 하는가
// Before: 장황하고 실수하기 쉬움
@RequestMapping(value = "/public", method = RequestMethod.POST)
public ResponseEntity<ChannelDto> createPublic(...) { }
@RequestMapping(value = "/{channelId}", method = RequestMethod.DELETE)
public ResponseEntity<Void> removeChannel(...) { }
// After: 의도가 명확함
@PostMapping("/public")
public ResponseEntity<ChannelDto> createPublic(...) { }
@DeleteMapping("/{channelId}")
public ResponseEntity<Void> removeChannel(...) { }
- 가독성 — 메서드의 HTTP 동사가 어노테이션 이름에 바로 드러난다.
- 일관성 — 팀 전체가 동일한 스타일을 유지하기 쉽다.
- 실수 방지 —
method = RequestMethod.GET을 빼먹으면 모든 HTTP 메서드에 매핑되는 사고가 발생할 수 있다.
한 프로젝트에서 @RequestMapping(method = ...)과 @GetMapping 등을 섞어 쓰면 코드의 일관성이 무너진다. 팀에서 하나의 스타일을 정하고 전체에 적용해야 한다.
@PathVariable vs @RequestParam
이 두 어노테이션의 혼용은 REST API에서 가장 흔한 설계 실수 중 하나다.
@PathVariable
리소스를 식별할 때 사용한다. URL 경로의 일부로 포함된다.
@DeleteMapping("/{userId}")
public ResponseEntity<Void> delete(@PathVariable UUID userId) { }
// DELETE /api/users/550e8400-e29b-41d4-a716-446655440000
/users/{userId}— "이 사용자"를 지칭하는 고유 식별자
@RequestParam
필터링, 검색, 페이징 등 리소스를 어떻게 조회할지를 지정할 때 사용한다.
@GetMapping
public ResponseEntity<List<MessageDto>> getByChannelId(
@RequestParam UUID channelId,
@RequestParam(defaultValue = "0") int page) { }
// GET /api/messages?channelId=...&page=0
?channelId=...— 메시지 목록을 필터링하는 조건
잘못된 사용 예시
// 문제: 경로에 {userId}가 있는데 @RequestParam으로 받음
@DeleteMapping("/{userId}")
public ResponseEntity<Void> delete(@RequestParam UUID userId) { }
// DELETE /api/users/550e...?userId=550e... ← URL에 ID가 두 번!
경로 변수(/{userId})와 @RequestParam이 동시에 존재하면, 경로의 값은 무시되고 쿼리 파라미터에서만 값을 읽는다. 클라이언트는 /users/abc로 요청했는데 실제로는 ?userId=xyz의 값이 사용되는 혼란이 발생한다.
원칙: 경로에 변수가 있으면 반드시 @PathVariable, 쿼리 스트링이면 @RequestParam.
HTTP 상태 코드
적절한 HTTP 상태 코드는 API의 자기 설명성을 높인다.
성공 응답
- 200 OK — 일반적인 성공 응답.
GET,PUT,PATCH요청의 성공. - 201 Created — 새 리소스가 생성됨.
POST요청의 성공. - 204 No Content — 성공했지만 응답 본문이 없음.
DELETE요청의 성공.
클라이언트 오류
- 400 Bad Request — 잘못된 요청. 유효성 검증 실패.
- 401 Unauthorized — 인증되지 않음. 로그인이 필요.
- 403 Forbidden — 권한 없음. 인증은 되었지만 권한이 부족.
- 404 Not Found — 리소스가 존재하지 않음.
- 409 Conflict — 충돌. 이미 존재하는 리소스 생성 시도.
비표준 상태 코드 주의
// 안티패턴: 비표준 상태 코드
return ResponseEntity.status(HttpStatus.valueOf(203)).build();
203은 Non-Authoritative Information으로, 프록시 서버가 응답을 변환했을 때 사용하는 코드다. CSRF 토큰 반환에 203을 사용하는 것은 HTTP 스펙에 맞지 않다.
// 올바른 사용
return ResponseEntity.ok().build(); // 200
return ResponseEntity.noContent().build(); // 204
RESTful URL 설계
# 리소스 컬렉션
GET /api/users → 전체 사용자 목록
POST /api/users → 사용자 생성
# 개별 리소스
GET /api/users/{id} → 특정 사용자 조회
PUT /api/users/{id} → 전체 수정
PATCH /api/users/{id} → 부분 수정
DELETE /api/users/{id} → 삭제
# 하위 리소스
GET /api/channels/{id}/messages → 특정 채널의 메시지 목록
핵심 원칙
- 명사 사용 — URL에는 동사가 아닌 명사를 쓴다.
/getUsers❌ →/users✅ - 복수형 — 컬렉션은 복수형.
/user❌ →/users✅ - 계층 구조 — 리소스 간 관계를 URL 경로로 표현.
/channels/{id}/messages - 소문자 — URL은 대소문자를 구분하므로 소문자로 통일.
심화 분석
컨트롤러 레이어의 책임
컨트롤러는 HTTP 요청/응답 변환만 담당해야 한다. 비즈니스 로직이 컨트롤러에 들어가면 테스트가 어려워지고, 로직이 서비스와 컨트롤러에 분산된다.
// 안티패턴: 컨트롤러에 비즈니스 로직
@GetMapping
public ResponseEntity<List<ChannelDto>> getVisibleChannels(@RequestParam UUID userId) {
User user = userRepository.findById(userId).orElseThrow();
List<Channel> channels = channelRepository.findAllPublic();
channels.addAll(channelRepository.findAllPrivateByUser(user));
return ResponseEntity.ok(channels.stream().map(ChannelMapper::toDto).toList());
}
// 모범 사례: 서비스에 위임
@GetMapping
public ResponseEntity<List<ChannelDto>> getVisibleChannels(@RequestParam UUID userId) {
return ResponseEntity.ok(channelService.getAllVisibleByUser(userId));
}
ResponseEntity 일관성
// 일관성 없는 반환 스타일
@GetMapping("/me")
public UserDto getMe(...) { } // ResponseEntity 없음
@GetMapping
public ResponseEntity<List<UserDto>> getAll() { } // ResponseEntity 있음
같은 컨트롤러 내에서 ResponseEntity를 쓸지 말지 통일해야 한다. 일반적으로 ResponseEntity를 사용하는 것이 상태 코드와 헤더를 명시적으로 제어할 수 있어 권장된다.
자주 하는 실수
@RequestMapping을 클래스와 메서드에 혼용
@RestController
@RequestMapping("/api/channels")
public class ChannelController {
@RequestMapping(value = "/public", method = RequestMethod.POST) // 구식
public ResponseEntity<ChannelDto> createPublic(...) { }
@GetMapping // 현대적
public ResponseEntity<List<ChannelDto>> getAll(...) { }
}
한 컨트롤러 안에서 @RequestMapping(method=...)과 @GetMapping이 혼재하면, 코드 리뷰 시 "이 팀의 컨벤션이 뭔가?"라는 의문이 생긴다. 전부 축약형으로 통일하는 것이 바람직하다.
경로 변수와 파라미터 불일치
@DeleteMapping("/{messageId}")
public ResponseEntity<Void> remove(@RequestParam UUID messageId) { }
경로에 {messageId}가 있지만 @RequestParam으로 받고 있다. 이러면 클라이언트가 DELETE /api/messages/123으로 요청해도, 실제로는 ?messageId=123이 없으면 400 에러가 발생한다.
PATCH와 PUT 혼동
- PUT — 리소스 전체를 교체. 보내지 않은 필드는 null이 된다.
- PATCH — 리소스 일부만 수정. 보낸 필드만 변경된다.
사용자 이름만 바꾸고 싶은데 PUT을 쓰면, 이메일이나 비밀번호까지 함께 보내야 한다.