티스토리 뷰

배경

러닝 관련 API를 구현하던 중 러닝을 기록하는 API를 설계했었습니다. 이 과정에서 러닝 상태 전이에 따라 러닝 시작, 중단, 재개, 완료 등의 기능을 구현해야했습니다. 그리고 각 API를 어떻게 설계하는게 좋을지에 대한 고민으로 이 문서를 작성하게 되었습니다.

필요한 API는 다음과 같습니다.

  • 러닝 시작 API
  • 러닝 기록 업데이트 API
  • 러닝 중단 API
  • 러닝 재개 API
  • 러닝 완료 API

고민

크게 고려하고 있는 방식은 두 가지입니다.

방식 1. 액션별 엔드포인트 + DTO로 recordId 전달

기본적으로 /api/v1/running 엔드포인트에 이후 액션을 엔드포인트로 사용하는 방식입니다.

@RestController
@RequestMapping("/api/v1/running")
class RunningController(
    private val runningService: RunningService,
) {
    @PostMapping("/start")
    fun start(@RequestBody req: RunningStartRequest): ApiResponse<...> = …

    @PostMapping("/update")
    fun update(@RequestBody req: RunningUpdateRequest): ApiResponse<...> = …

    @PatchMapping("/stop")
    fun stop(@RequestBody req: RunningStopRequest): ApiResponse<...> = …

    @PostMapping("/resume")
    fun resume(@RequestBody req: RunningResumeRequest): ApiResponse<...> = …

    @PostMapping("/done")
    fun done(@RequestBody req: RunningDoneRequest): ApiResponse<...> = …
}

위 예시를 통해 알 수 있듯이 모든 행위가 엔드포인트로 붙었습니다.

  • /api/v1/running/start
  • /api/v1/running/update
  • /api/v1/running/stop

방식 2. 자원(Resource) + 액션별 하위 경로

자원을 URI에 나타내고 HTTP 메서드로 행위를 나타내는 RESTful한 방식입니다.

@RestController
@RequestMapping("/api/v1/running")
class RunningRecordController(
    private val runningService: RunningService,
) {
    @PostMapping()
    fun start(@RequestBody req: RunningStartRequest):  ApiResponse<...> = …
    
    @PostMapping("/{recordId}/points")
    fun addPoint(@PathVariable recordId: Long,
                 @RequestBody point: RunningPointDto): ApiResponse<...> = …

    @PatchMapping("/{recordId}/stop")
    fun stop(@PathVariable recordId: Long): ApiResponse<...> = …

    @PatchMapping("/{recordId}/resume")
    fun resume(@PathVariable recordId: Long,
               @RequestBody req: ResumeRequest): ApiResponse<...> = …

    @PatchMapping("/{recordId}/done")
    fun complete(@PathVariable recordId: Long,
                 @RequestBody req: CompleteRequest): ApiResponse<...> = …
}

아마 RESTful API에 익숙한 개발자는 기본적으로 생각하는 방식은 위 방식을 채택할 것 같습니다.

 

저는 방식 1을 선택했었습니다.

이 방식1 은 URI는 자원으로 나타내고 HTTP 메서드를 통해 행위를 나타내는 RESTful API의 원칙에 일치하지 않습니다. 행위를 URI를 통해 나타내고 있고 HTTP 메서드를 통해 작업을 구분하지 않기 때문입니다. 그렇기에 대부분의 개발자는 방식 2를 선택하실 것 같습니다.

그럼에도 제가 방식 1을 선택한 이유는 다음과 같습니다.

 

[이유 1. RESTful 하게 나타내는 것의 한계점이 있다.]

RESTful 하게 API를 설계하기 위해선 자원을 통해 URI를 나타내고 HTTP 메서드를 통해 행위를 정의해야합니다. 하지만 저는 이 방식의 한계점이 존재하다고 생각했습니다.

이러한 한계점은 위 방식 2의 예시를 통해서도 확인할 수 있습니다. 러닝을 시작하고 기록을 추가하는 API는 자원과 메서드를 통해 행동을 정의할 수 있습니다.

  • 러닝 시작: POST /api/v1/running
  • 러닝 기록 추가: POST /api/v1/running/{runningId}

하지만 그 외의 행위에 대해선 URI에 자원을 나타내기에도, HTTP 메서드로 행동을 정의하기에도 한계점이 있습니다.

  • 러닝 중단: PATCH /api/v1/running/{runningId}/stop
  • 러닝 재개: PATCH /api/v1/running/{runningId}/resume
  • 러닝 완료: PATCH /api/v1/running/{runningId}/done

URI에 ‘행위’가 포함되고 HTTP 메서드로 행위를 구분하지 않죠. 이러한 한계점은 실제로 종종 경험하게 됩니다. 모든 API가 자원 + HTTP 메서드로 깔끔하게 나타낼 수 없는 것을 종종 경험하셨을 겁니다. 저 또한 이런 경험이 있었고 위 API를 설계했을 때도 동일하게 경험했습니다.

 

[이유 2. Depth를 줄일 수 있다]

방식 1을 선택하게 되면 방식 2에 비해 Depth를 하나 더 줄일 수 있습니다.

러닝 기록 중단 API를 예시로 살펴봅시다.

  • 방식 1 : POST /api/v1/running/stop
  • 방식 2 : PATCH /api/v1/running/{runningId}/stop

방식 1의 경우 러닝 기록과 관련된 API가 동일한 Depth를 사용하게 됩니다. 따라서 가시성에도 좋고 라우팅 로직이 단순합니다. 또한 방식 1의 경우 모든 정보를 DTO로 관리하기에 runningId 를 URI에 포함하는 방식 2의 비해 통일성도 있다고 생각했습니다.

다음과 같은 이유로 저는 방식 1을 선택했습니다.

 

방식 2로 리팩터링하려고 합니다.

지금은 방식 1을 선택하긴 했지만 어떤게 더 나은 방식인지에 대해 계속 고민하게 되었습니다. 지금 단계에서는 API가 복잡하지 않기에 방식 1이 더 간단하게 느껴지지만, 만약 행위가 더 늘어나게 된다면 동일한 Depth를 유지하는 방식이 과연 유지보수에 좋을지는 의문입니다. (Depth를 유지하는게 큰 장점인지도 느껴지지 않았습니다)

그리고 RESTful 하게 나타낼 수 있는 자원도 RESTful하지 않게 표현하게 된다는 점이 단점으로 느껴집니다. 한계점이 있다고 장점을 모두 포기하는것이 과연 맞을지 의문이죠.

그래서 방식 2로 리팩터링하고자 합니다. 방식 1의 장점이 크게 느껴지지 않는다면 대부분의 개발자가 익숙한 RESTful 방식을 선택하는게 추후 유지보수에서 장점을 챙길 수 있죠.

만약 방식 2로 개선한 이후 단점이 있다면 또 글로 남겨보겠습니다!