개발일기/Spring

API versioning 방법론

ignuy 2024. 11. 20.

세상에 완벽한 소프트웨어는 존재하지 않는다.

애플리케이션은 요구 사항이 변경될 때마다 새로운 버전을 릴리즈합니다. 이때, 다양한 버전을 동시에 사용하는 유저들의 안정적인 활동을 보장하기 위해서 backend engineer는 하나의 API에서 다양한 버전을 관리해야 할 때가 있습니다. 이번에는 Spring과 Kotlin 환경을 기준으로 API version을 관리하는 몇 가지 방법론을 제시해보겠습니다.

들어가기 앞서 대부분의 문제가 그렇듯, 정답이란 없습니다. 본인의 팀 상황에 맞는 방법을 선택하시되 중요한 것은 팀 전체가 하나처럼 움직이는, 모든 코드가 공통성을 가지는 “일관성”이지 않을까 싶습니다.

API 버전 관리 전략

1. URL Versioning

가장 대중적으로 알려진 방식이다. 접근 방법도 단순하다. 그저 URL 경로에 버전을 명시하는 것이다. 현재까지도 그런지는 확실하지 않지만 Facebook, Twitter, AirBnB 등의 글로벌 공룡 기업들도 이 방식을 선택하여 사용하고 있다고 한다.

코드로 살펴보자.

@RestController
@RequestMapping("/api/v1/~~")
class ~~ControllerV1 {

    @GetMapping
    fun apiV1(): ResponseEntity<List<User>> {
        val users = listOf(User("Aiden", 27), User("Diana", 25))
        return ResponseEntity.ok(users)
    }
}

@RestController
@RequestMapping("/api/v2/~~")
class ~~ControllerV2 {

    @GetMapping
    fun apiV2(): ResponseEntity<List<UserDTO>> {
        val users = listOf(UserDTO("Aiden Hong", 27), UserDTO("Diana Son", 25))
        return ResponseEntity.ok(users)
    }
}

위처럼 “/api/v1/~~”, “/api/v2/~~” 과 같이 일반적으로 API의 가본 URL에 버전 번호를 슬래시로 구분하여 추가한다.

URL 버전 관리를 사용하면, 개발자는 API의 서로 다른 버전을 명확히 구분할 수 있어 구버전 클라이언트 애플리케이션과의 충돌을 방지할 수 있다. 또한, 각 버전이 고유한 URL을 가지고 있기 때문에 API의 다른 버전을 서로 다른 서버나 환경에 쉽게 배포할 수 있다.

그러나, API 버전이 많아질수록 URL이 점점 복잡해지고 가독성이 떨어질 수 있고, URL 구조에 변경이 생긴다면 기존 클라이언트 애플리케이션의 작동을 방해할 수 있다.

이 방법론에 관심이 있다면 더 구체적으로 정리된 아래 장단점을 확인해 보자.

URL Versioning 장점

  • 명확하고 간단: API 버전을 명확하게 표시할 수 있어, 개발자가 자신이 사용하는 버전을 쉽게 이해할 수 있음.
  • 쉬운 버전 관리: URL에 버전 번호를 추가하는 방식으로 간단하게 API 버전 관리 가능.
  • 독립적 배포: 서로 다른 버전을 서로 다른 서버나 환경에 독립적으로 배포 가능.
  • 호환성: URL만 수정하면 되므로 HTTP 클라이언트 라이브러리나 도구와 쉽게 연동 가능.

URL Versioning 단점

  • URL의 복잡성 증가: API 버전이 많아질수록 URL이 길어지고 복잡해질 수 있음.
  • 클라이언트 애플리케이션의 의존성: 클라이언트가 URL 구조에 의존하므로, URL 변경 시 클라이언트 애플리케이션이 작동하지 않을 위험이 있음.
  • 전환의 번거로움: 클라이언트가 새로운 API 버전을 사용하려면 URL을 새로 구성해야 하므로 추가 작업이 필요.
  • 혼란 가능성: URL이 명확히 문서화되지 않으면 혼란이나 실수가 발생할 가능성이 있음.

URL Versioning Best Practice

URL Versioning은 그 진입 문턱이 낮고 API 버전 관리를 위한 효과적인 방법이 될 수 있지만, 몇 가지 BP를 따라야 한다.

  • 일관된 URL 구조 사용
    • API 내 모든 리소스에 대해 일관된 URL 구조와 명확한 명명 규칙을 사용
  • 깔끔한 URL 유지
    • 복잡하거나 중첩된 URL을 피하고 간결하고 읽기 쉬운 URL 유지.
  • 버전 관리 방식 문서화
    • 버전 관리 방식과 작동 방식을 명확히 문서화하여, 개발자(또는 팀)가 이를 이해하고 사용
  • 전환 경로 제공
    • 이전 API 버전을 사용하는 개발자에게 새로운 버전으로 쉽게 전환할 수 있는 명확한 경로를 제공. 이를 통해 변경으로 인한 위험을 줄이고, API의 안정성을 유지
  • 주요 변경 사항에만 버전 관리 적용
    • 이전 버전과 호환되지 않는 주요 변경 사항에만 URL 버전 관리를 적용하고, 사소한 변경 사항에는 헤더나 미디어 타입 버전 관리와 같은 다른 방법을 함께 사용하는 것을 고려. 이는 유지해야 할 API 버전 수를 줄이고 API를 단순화하는 데 도움이 됩니다.

2. Query Parameter Versioning

이 방식은 요청 URL에 Query paramter로 version 또는 v 등의 버전 명시 파라미터를 추가하여 API 버전을 지정한다. 서버는 쿼리 파라미터 값을 읽고, 해당 버전에 따라 요청을 처리하게 된다.

@RestController
@RequestMapping("/api/resource")
class ResourceController {

    @GetMapping
    fun getResource(@RequestParam version: Int): ResponseEntity<Any> {
        return when (version) {
            1 -> ResponseEntity.ok("Response for version 1")
            2 -> ResponseEntity.ok("Response for version 2")
            else -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid API version")
        }
    }
}

1번 방식(URL Versioning)과는 다르게 URL 경로에 버전 정보를 포함하지 않으므로, 리소스의 기본 구조를 유지할 수 있다. 클라이언트 측에서도 요청 시 쿼리 파라미터만 변경하면 원하는 버전으로 손쉽게 변경이 가능하다.

하지만, 서버의 캐싱 시스템이 Query Parameter를 고려하지 않도록 설정되어 있다면 다른 버전의 API가 동일한 URL로 캐싱될 위험이 있다. 또한, Query Parameter를 통해서 버전을 명시하는 것이 RESTful 디자인 원칙에 위배된다고 보는 견해도 있다.

이 방식도 더 구체적인 정보를 원한다면 아래를 확인하자.

Query Parameter Versioning 장점

  • RESTful URL 유지 : URL 경로에 버전 정보를 포함하지 않으므로, 리소스의 기본 구조를 유지
  • 유연성 : 클라이언트가 요청 시 쿼리 파라미터만 변경하면 버전을 쉽게 전환 가능
  • 단일 컨트롤러 : 여러 버전을 처리하기 위해 단일 컨트롤러에서 로직을 관리(필요시 코드 분리 가능).
  • 도구 및 HTTP 클라이언트 호환성 : Query Parameter는 표준 형식이기 때문에 모든 HTTP 클라이언트와 쉽게 호환.

Query Parameter Versioning 단점

  • 가독성 감소 : 쿼리 매개변수가 많아지면 URL의 가독성이 떨어질 수 있음.
  • 캐싱 문제 : 일부 캐싱 시스템은 URL의 쿼리 매개변수를 무시하거나 제대로 처리하지 못할 수 있음
  • 표준화 부족 : 쿼리 매개변수를 이용한 버전 관리는 일부 개발자 커뮤니티에서 RESTful 디자인 원칙에 완전히 부합하지 않는다는 견해가 존재

Query Parameter Versioning Best Practice

  • 명확하고 일관된 매개변수 이름 사용
    • 가독성 문제를 해결하기 위해 쿼리 매개변수 이름은 version, v 등 간단하고 직관적인 이름을 사용하는 것이 좋음
  • 기본 버전 제공
    • 클라이언트가 쿼리 매개변수를 생략할 경우, 기본값으로 최신 또는 특정 버전을 제공하는 로직을 추가
@GetMapping
fun getResource(@RequestParam(required = false, defaultValue = "1") version: Int): ResponseEntity<Any> {
    return when (version) {
        1 -> ResponseEntity.ok("Response for version 1")
        2 -> ResponseEntity.ok("Response for version 2")
        else -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid API version")
    }
}
  • 캐싱을 고려한 설계
    • URL 캐싱을 사용하는 시스템에서는 쿼리 매개변수 기반 캐싱을 올바르게 처리할 수 있도록 설정(예: CDN이나 Reverse Proxy에서 version을 캐싱 키로 포함하도록 설정)
  • 클라이언트와 명확한 계약
    • API 문서에 버전 지정 방법과 매개변수의 의미를 명확히 설명한 정책을 문서화하고 팀 전체에 공유. 일관성이 굉장히 중요한 방법.
  • 주요 변경 사항에만 버전 관리
    • 마이너 업데이트는 기존 버전 내에서 처리하고, API 버전을 쿼리 매개변수로 관리하는 것은 주요 변경 사항에만 사용

3. Header Versioning

세 번째 방식은 API 요청 시 HTTP 헤더를 사용하여 API 버전을 지정한다. URL에 버전 정보가 포함되던 앞에 두 방식과는 다르게 요청 헤더에 버전 정보를 담아서 서버가 이를 기반으로 적절한 버전의 API를 처리하도록 한다. 이를 위해서 주로 Accept header나 X-API-Version과 같은 custom header를 활용한다.

@RestController
@RequestMapping("/api/resource")
class ResourceController {

    // Version 1 API
    @GetMapping(produces = ["application/vnd.example.v1+json"])
    fun getResourceV1(): ResponseEntity<Any> {
        return ResponseEntity.ok("Response for version 1")
    }

    // Version 2 API
    @GetMapping(produces = ["application/vnd.example.v2+json"])
    fun getResourceV2(): ResponseEntity<Any> {
        return ResponseEntity.ok("Response for version 2")
    }
}

@RestController
@RequestMapping("/api/resource")
class ResourceController {

    @GetMapping
    fun getResource(@RequestHeader("X-API-Version", defaultValue = "1") version: Int): ResponseEntity<Any> {
        return when (version) {
            1 -> ResponseEntity.ok("Response for version 1")
            2 -> ResponseEntity.ok("Response for version 2")
            else -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid API version")
        }
    }
}

이 방식은 URL 경로를 변경하거나 추가할 필요가 없으므로 깔끔한 URL을 유지할 수 있으면서도 API 버전을 관리할 수 있는 장점이 있다.

예시

  • Accept Header를 사용하는 경우
    • 요청: GET /api/resource
    • 헤더: Accept: application/vnd.example.v1+json
  • 커스텀 헤더를 사용하는 경우
    • 요청: GET /api/resource
    • 헤더: X-API-Version: 1

반면, 클라이언트가 API 버전을 헤더로 지정해야 하므로, 버전 관리 방법을 이해하고 구현하는데 다소 복잡할 수 있다.

Header Versioning 장점

  • 깔끔한 URL 구조 유지 : API 버전 정보를 URL 경로에 포함하지 않으므로, URL 구조를 깔끔하게 유지. 클라이언트는 API 버전 변경에 따른 URL 변경을 걱정할 필요가 없음
  • 명확한 버전 관리 : 헤더를 사용하여 버전을 명확하게 구분. 버전 정보가 URL 경로와는 독립적으로 관리되므로 더 유연한 버전 관리가 가능
  • 더 나은 문서화와 유지 관리 : API 버전이 헤더에 명시되면, 버전별로 세부 동작이나 변경 사항을 문서화하기 용이.
  • API 변경에 따른 간섭 최소화 : API의 주요 리소스 경로(URL)는 변경되지 않으므로 기존 클라이언트에 미치는 영향을 최소화

Header Versioning 단점

  • 라이언트 구현이 복잡해질 수 있음 : 클라이언트가 API 버전을 헤더로 지정해줘야 하니 버전 관리 방법을 이해하고 구현하는 게 복잡함
  • 호환성 문제 : 일부 HTTP 클라이언트나 도구에서는 커스텀 헤더를 지원하지 않거나 잘못 처리할 수 있음.
  • 문서화 필요성 : Accept 헤더나 커스텀 헤더를 사용한 버전 관리는 클라이언트 개발자에게 혼동을 일으킬 수 있음
  • 서버에서 버전 처리 로직 추가 : 서버는 요청 헤더에서 버전 정보를 추출하고 적절한 처리를 해야 하므로, 코드에서 버전 관리 로직을 추가하는 데 노력이 필요할 수 있습니다.

Header Versioning Best Practice

  • 헤더 이름의 일관성
    • Accept 헤더를 사용하든, X-API-VERSION과 같은 커스텀 헤더를 사용하든 설계상의 자유이지만, 중요한 것은 일관성 있는 규칙이다. 이 규칙이 Header Versioning의 단점 대부분을 극복하는데 중요한 열쇠이다.
  • 기본 버전 제공
    • 클라이언트가 헤더를 생략하는 경우에도 서비스는 정상적으로 동작해야 하므로 기본 API 버전을 설정하는 것이 좋다. 이를 통해 헤더를 제공하지 않은 요청도 처리할 수 있다.
    @GetMapping
    fun getResource(@RequestHeader(name = "X-API-Version", defaultValue = "1") version: Int): ResponseEntity<Any> {
        return when (version) {
            1 -> ResponseEntity.ok("Response for version 1")
            2 -> ResponseEntity.ok("Response for version 2")
            else -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid API version")
        }
    }
    
  • 미들웨어 또는 인터셉터 사용
    • 요청 처리 전에 버전 정보를 검증하고 적절한 버전으로 요청을 처리하도록 인터셉터필터를 사용하는 방법도 있다. Spring에서는 HandlerInterceptor나 Filter를 활용하여 헤더에서 버전 정보를 검사하고 요청 처리 흐름을 제어할 수 있다.

역호환성 유지 전략

API Versioning은 단순히 서버와 클라이언트만의 약속이 아니다. 구버전의 API를 사용하고 있는 사용자에게도 API 버전을 변경하더라도 기존 클라이언트가 문제없이 작동하도록 해야 한다. 이를 위해서 다양한 전략을 취할 수 있으니 아래에서 살펴보자.

1. 데이터 변환 계층

API 버전 별로 사용하는 데이터 형식이 다르더라도 기존 클라이언트와 호환되도록 변환 계층을 구현해야 한다.

data class User(val name: String, val age: Int)

data class UserDTO(val fullName: String, val age: Int)

object UserMapper {
    fun toV2(user: User): UserDTO = UserDTO(fullName = user.name, age = user.age)
}

2. Deprecated 필드 유지

여러 API를 사용하다 보면 Deprecated 된 API에 대한 경고문도 자주 봤을 것이다. 더이상 지원하지 않는 필드라도 기존 유저의 호환성을 위하여 유지하고, 새로운 클라이언트로 전환을 유도하는 방식이다.

data class User(
    val name: String,
    @Deprecated("This field is deprecated and will be removed in future versions")
    val oldField: String? = null
)

3. Phase-out

Deprecated된 API에 대하여, 또는 종료된 버전에 대하여 사전에 안내하고 단계적으로 폐기하는 절차를 설계해야 한다. 이를 위해, 종료된 버전 요청 시 에러 응답을 처리하는 코드를 구현해야 한다.

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

    @GetMapping
    fun getUsers(@RequestHeader("X-API-Version") version: String): ResponseEntity<Any> {
        return when (version) {
            "1" -> ResponseEntity.status(HttpStatus.GONE).body("Version 1 is no longer supported")
            "2" -> ResponseEntity.ok(listOf(UserDTO("Aiden Hong", 27), UserDTO("Diana Son", 25)))
            else -> ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid API version")
        }
    }
}

댓글