← Posts

API · Design · Backend

API는 약속이다

내부 코드는 언제든 고치지만 공개한 API는 남이 이미 그 모양에 맞춰 코드를 짜뒀다. API를 계약으로 보는 관점을 잡는다.

목차
  1. 필드 이름 하나를 바꿨더니 앱이 죽었다
  2. 내부 코드와 공개 API는 고치는 비용이 다르다
  3. 계약에 들어 있는 것
  4. 소비자는 생각보다 많고, 마음대로 못 고친다
  5. 문서가 아니라 실제 응답이 계약이다
  6. 저장 구조가 새어 나가면 계약이 굳는다
  7. 더하기는 대체로 되고, 빼기와 이름 바꾸기는 안 된다
  8. 그래서 실무에선 어떻게 지키나
  9. 정리

API 설계의 결정들은 전부 “나중에 못 바꾼다”는 전제 위에 있다. 그 전제부터 확인하고 시작한다.

필드 이름 하나를 바꿨더니 앱이 죽었다

사용자 정보를 주는 API가 있다. 응답은 이렇게 생겼다.

json
{
  "id": 1024,
  "userName": "김개발",
  "email": "dev@example.com"
}

코드를 정리하다 보니 userName이 거슬린다. 자원이 이미 user인데 필드에 또 user가 붙어 있다. 그래서 name으로 바꾼다.

json
{
  "id": 1024,
  "name": "김개발",
  "email": "dev@example.com"
}

서버는 잘 뜬다. 테스트도 다 통과한다. 그런데 배포 30분 뒤에 모바일 앱에서 사용자 이름이 전부 빈칸으로 나온다는 제보가 들어온다.

앱은 userName을 읽고 있었다. 그 필드가 사라졌으니 빈 값이 됐다. 서버 코드 어디에도 에러는 없다. 깨진 건 서버가 아니라 서버와 앱 사이의 약속이다.

내부 코드와 공개 API는 고치는 비용이 다르다

서버 안에서 변수 이름을 바꾸는 일이라면 이렇게 겁낼 필요가 없다. IDE에서 이름 바꾸기를 누르면 참조가 전부 따라오고, 하나라도 놓치면 컴파일이 실패한다. 못 고친 자리를 컴파일러가 알려준다.

API는 그 안전망이 없다.

diagramdiagram

응답에서 필드를 지워도 서버는 아무 불평 없이 빌드되고 배포된다. 그 필드를 읽던 코드는 다른 회사, 다른 저장소, 다른 언어에 있기 때문이다. 문제는 그 코드가 실제로 돌아갈 때, 즉 사용자가 화면을 열 때 드러난다.

그래서 이 시리즈는 한 문장 위에 서 있다.

API는 코드가 아니라 계약이다. 내부 구현은 언제든 고치지만, 공개한 API는 남이 이미 그 모양에 맞춰 코드를 짜뒀다.

계약에 들어 있는 것

“API”라고 하면 보통 URL 목록을 떠올리는데, 약속된 것은 훨씬 많다. 아래가 전부 계약이다.

계약 항목어기면
경로와 메서드GET /users/{id}404가 난다
요청 파라미터?page=2&size=20요청이 거부된다
요청 본문 모양{"email": "...", "password": "..."}400이 난다
응답 필드 이름과 타입"id": 1024 (숫자)파싱이 깨지거나 값이 빈다
상태 코드생성 성공은 201클라이언트 분기가 틀어진다
에러 응답 형식{"code": "...", "message": "..."}에러 화면이 안 뜬다

여기서 자주 놓치는 게 아래 세 줄이다. 응답 필드의 타입, 상태 코드, 에러 형식도 똑같이 약속이다. 숫자로 주던 id를 어느 날 문자열로 바꾸면, 필드 이름을 바꾼 것과 똑같이 앱이 깨진다.

참고

반대로 계약이 아닌 것도 분명히 해두면 좋다. 응답을 만드는 데 쿼리를 몇 번 날렸는지, 어떤 프레임워크를 쓰는지, 내부 클래스 이름이 무엇인지는 계약 밖이다. 그건 마음껏 바꿔도 된다. 계약의 경계는 “밖에서 보이느냐”에서 갈린다.

소비자는 생각보다 많고, 마음대로 못 고친다

“앱 팀에 말해서 같이 배포하면 되지 않나” 싶어진다. 소비자가 하나라면 그렇다. 그런데 API 하나에 매달린 소비자는 대개 하나가 아니다.

diagramdiagram

웹은 그나마 낫다. 새로고침하면 새 코드가 내려간다. 문제는 나머지다.

  • 모바일 앱은 스토어 심사를 거쳐야 하고, 심사를 통과해도 사용자가 업데이트를 안 한다. 구버전 앱이 몇 달씩 남는다.
  • 파트너사 연동은 남의 회사 일정이다. 우리가 언제 고치라고 정할 수 없다.
  • 배치는 새벽에만 돌아서, 깨져도 다음 날 아침에야 안다.

그래서 서버와 클라이언트를 동시에 배포해서 맞추는 방법은 대체로 쓸 수 없다. 서버가 먼저 바뀌어도 옛 클라이언트가 계속 돌아가야 한다. 이게 API 설계를 어렵게 만드는 진짜 제약이다.

문서가 아니라 실제 응답이 계약이다

계약서를 문서로 써뒀다고 안심하기 쉬운데, 클라이언트 개발자가 보는 건 문서가 아니라 실제로 내려온 응답이다.

문서에 안 적힌 필드가 응답에 섞여 있으면 누군가는 그걸 쓴다. 문서에 “정렬 순서는 보장하지 않는다”고 적어놨어도, 실제로 늘 최신순으로 내려왔다면 클라이언트는 그걸 믿고 정렬 코드를 빼버린다. 그리고 우리가 쿼리를 최적화하며 정렬을 바꾸는 날 화면이 뒤집힌다.

주의

“문서에 없으니 바꿔도 된다”는 방어가 운영에서는 잘 통하지 않는다. 이미 그 동작에 의존하는 코드가 돌아가고 있으면, 화면이 깨진 사실 자체는 그대로다. 문서는 책임 소재를 정리해줄 뿐 장애를 막아주지는 않는다. 관찰 가능한 동작은 웬만하면 계약으로 취급하는 편이 안전하다.

그래서 실무의 규칙은 이렇게 뒤집힌다. 내보내지 않을 것은 애초에 내보내지 않는다. 나중에 빼는 것보다 처음부터 안 넣는 게 압도적으로 싸다.

저장 구조가 새어 나가면 계약이 굳는다

앞의 규칙을 가장 크게 어기는 방식이 하나 있다. DB 엔티티를 그대로 응답으로 내보내는 것이다.

java
@GetMapping("/users/{id}")
public User get(@PathVariable Long id) {
    return userService.findById(id);   // 엔티티를 그대로
}

이 한 줄이 하는 일은 “사용자 정보를 준다”가 아니다. 테이블 구조를 공개 계약으로 승격시키는 것이다. 칼럼을 하나 추가하면 응답이 바뀌고, 칼럼 이름을 리팩터링하면 클라이언트가 깨진다. DB 사정으로 시작한 변경이 앱 장애로 끝난다.

DTO 글이 이 문제를 정면으로 다룬다. 특히 테이블이 바뀌면 API 계약도 바뀐다 대목이 지금 이야기와 같은 말이다. 거기서는 계층 분리의 관점으로 봤고, 여기서는 계약의 관점으로 본다.

diagramdiagram

DTO를 두면 계약이 명시적인 자리를 갖는다. 그 클래스를 고칠 때만 계약이 바뀌고, 고치는 사람은 자기가 계약을 건드리는 중이라는 걸 안다. 엔티티를 그대로 내보내면 계약을 바꾸는 순간을 아무도 인지하지 못한다.

더하기는 대체로 되고, 빼기와 이름 바꾸기는 안 된다

그럼 API는 영원히 못 바꾸나. 그렇지는 않다. 변경에는 깨는 것과 안 깨는 것이 있고, 그 선을 알면 꽤 많은 걸 바꿀 수 있다.

diagramdiagram

기준은 옛 클라이언트가 계속 돌아가느냐 하나다.

  • 응답에 필드를 추가하면, 그 필드를 모르는 옛 클라이언트는 그냥 무시하고 하던 대로 동작한다.
  • 필드를 제거하거나 이름을 바꾸면, 읽던 자리가 사라져서 깨진다.
  • 타입을 바꾸면 파싱 단계에서 깨진다. 숫자 1024와 문자열 "1024"는 클라이언트에게 완전히 다른 값이다.
참고

“추가는 안전하다”에는 조건이 하나 붙는다. 클라이언트가 모르는 필드를 무시하도록 만들어져 있어야 한다. 대부분의 JSON 라이브러리가 기본으로 그렇게 동작하지만, 엄격 모드를 켜둔 클라이언트라면 새 필드 하나에도 파싱이 실패한다. 그래서 “추가는 늘 안전”이 아니라 “대체로 안전”이다. 이 조건은 JSON 편에서 다시 본다.

이 규칙 하나로 커버가 안 되는 변경이 남는데, 그걸 다루는 게 버저닝 편이다.

그래서 실무에선 어떻게 지키나

계약을 지키자는 다짐만으로는 안 지켜진다. 실무에서는 계약을 눈에 보이게 만들어 놓는다.

계약을 파일로 적어둔다. OpenAPI 같은 명세 포맷으로 경로·파라미터·응답 스키마를 문서 파일에 적고, 저장소에 같이 커밋한다. 그러면 계약 변경이 코드 리뷰에 diff로 올라온다. “이 PR이 응답 필드를 지우고 있다”가 리뷰어 눈에 보이는 것과 안 보이는 것은 차이가 크다.

응답 모양을 테스트로 고정한다. 컨트롤러 테스트에서 응답 JSON의 필드 이름과 타입을 직접 확인해두면, 필드를 지우는 순간 빌드가 실패한다. 처음에 없던 안전망을 여기서 다시 만드는 것이다.

java
mockMvc.perform(get("/users/1024"))
       .andExpect(status().isOk())
       .andExpect(jsonPath("$.userName").exists())   // 계약을 테스트가 붙잡는다
       .andExpect(jsonPath("$.id").isNumber());

누가 쓰는지 센다. 없앨 후보인 필드나 엔드포인트가 있으면, 지우기 전에 호출 로그부터 본다. 호출량이 0인 걸 확인하고 지우는 것과 짐작으로 지우는 것은 다른 일이다. 이 절차는 버저닝 편에서 자세히 본다.

사내에서만 쓰는 API와 외부에 공개한 API는 계약의 무게가 다르다. 소비자가 옆 팀 하나뿐이고 배포를 같이 맞출 수 있다면 훨씬 자유롭게 바꿔도 된다. 계약을 얼마나 엄하게 지킬지는 소비자가 누구인지로 정해진다. 다만 “사내용”이라고 시작한 API가 어느새 파트너에게 열리는 일이 흔하다는 것도 같이 기억할 만하다.

정리

API는 무엇인가구현이 아니라 계약. 남이 이미 그 모양에 맞춰 코드를 짜뒀다
왜 못 바꾸나컴파일러가 안 잡아준다. 깨진 걸 운영에서 사용자가 먼저 안다
계약의 범위경로·메서드·파라미터·응답 필드와 타입·상태 코드·에러 형식
소비자 사정앱은 구버전이 남고 파트너는 우리 일정을 안 따른다
관찰된 동작문서에 없어도 이미 의존하는 코드가 있으면 계약처럼 굳는다
엔티티 노출테이블 구조가 곧 계약이 된다. DTO로 계약이 사는 자리를 만든다
안전한 변경추가는 대체로 안전, 제거·이름 변경·타입 변경은 깨진다

이 전제를 깔고 나면 나머지가 전부 이 위에서 결정된다. URL을 어떻게 짓느냐, 상태 코드를 무엇으로 고르느냐, JSON 필드를 어떤 이름으로 두느냐가 전부 “한 번 정하면 되돌리기 어렵다”는 조건 아래의 선택이다.

다음 글은 그 첫 결정을 다룬다. URL에 동사를 쓰던 습관을 걷어내고 자원으로 말하는 방식, REST다.

관련 글