스프링 리팩토링 패턴 시리즈 (4/5)

이전 편: 예외 처리 전략 설계

다음 편: 코드 일관성과 쿼리 최적화

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의 값이 사용되는 혼란이 발생한다.

graph LR A["DELETE /api/users/{userId}"] --> B{"어떤 어노테이션?"} B -->|"@PathVariable"| C["경로에서 추출"] B -->|"@RequestParam"| D["쿼리에서 추출"] style C fill:#c8e6c9 style D fill:#ffcdd2

원칙: 경로에 변수가 있으면 반드시 @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 → 특정 채널의 메시지 목록

핵심 원칙

  1. 명사 사용 — URL에는 동사가 아닌 명사를 쓴다. /getUsers ❌ → /users
  2. 복수형 — 컬렉션은 복수형. /user ❌ → /users
  3. 계층 구조 — 리소스 간 관계를 URL 경로로 표현. /channels/{id}/messages
  4. 소문자 — 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을 쓰면, 이메일이나 비밀번호까지 함께 보내야 한다.