PHpullh
개발자 면접 준비/시스템 디자인

시스템 디자인

API 설계 면접: 요구사항을 구조로 답하는 법

API 설계 면접에서 요구사항, 데이터 모델, 오류 처리, 확장성을 순서대로 설명하는 실전 답변 프레임입니다.

이 면접이 실제로 보는 것

API 설계 면접에서 좋은 평가를 못 받는 답변은 대부분 아는 게 없어서 그런 것이 아닙니다. REST도 알고 상태 코드도 알고 인증도 아는 사람이 첫 마디를 “일단 POST /urls를 만들고요”로 시작하는 순간, 면접관 쪽에서는 요구사항을 확인하지 않고 코드부터 쓰는 사람이라는 인상이 남습니다. 반대로 아직 엔드포인트를 하나도 그리지 않았어도 “이 API를 쓰는 쪽이 누구인지, 실패했을 때 무엇이 곤란해지는지부터 확인하겠습니다”라고 말하면 그 다음 20분이 훨씬 편해집니다.

그래서 이 페이지는 개념 나열 대신 문제 하나를 끝까지 따라갑니다. 계약을 어떻게 좁히고, 어떤 순서로 적고, 실패가 났을 때 무엇을 약속하고, 나중에 바꿀 때 어떻게 빠져나오는지를 차례대로 봅니다. HTTP 요청과 응답이 어떤 구조인지 아직 흐릿하다면 API 요청과 응답 이해하기를 먼저 읽고 오는 편이 낫습니다.

연습 문제: 단축 URL 서비스의 API를 설계하세요

이 문제를 고른 이유는 도메인이 한 문장으로 설명되기 때문입니다. 도메인 설명에 시간을 쓸 필요가 없으니 온전히 계약 설계에만 집중할 수 있고, 그러면서도 멱등성, 캐시, 권한, 레이트 리밋, 만료 같은 실무 쟁점이 전부 등장합니다. 면접에서 이 문제를 받았다고 가정하고 아래 순서대로 따라가 보시기 바랍니다.

1단계 · 요구사항 좁히기

“단축 URL 서비스”라는 다섯 글자 안에는 서로 다른 제품이 최소 세 개쯤 들어 있습니다. 사내 문서 링크를 줄이는 도구인지, 마케팅 캠페인 클릭을 집계하는 도구인지, 누구나 가입 없이 쓰는 공개 서비스인지에 따라 계약이 달라집니다. 그래서 설계 전에 다음 네 가지는 반드시 물어야 합니다.

  1. 누가 호출합니까. 로그인한 사용자가 웹 화면에서 호출하는지, 서버 대 서버로 API 키를 들고 호출하는지에 따라 인증 방식과 레이트 리밋 단위가 달라집니다.
  2. 코드는 사용자가 정할 수 있습니까. 사용자가 원하는 별칭을 지정할 수 있으면 중복 처리와 예약어 차단이 필요하고, 서버가 생성하면 그 문제는 사라집니다.
  3. 수정과 삭제가 있습니까. 한 번 만들면 끝인 서비스와 원본 URL을 나중에 바꿀 수 있는 서비스는 캐시 전략이 완전히 달라집니다.
  4. 클릭 통계가 필요합니까. 필요하다면 리다이렉트 경로에 쓰기가 붙게 되고, 그 쓰기를 동기로 할지 비동기로 할지가 곧바로 논점이 됩니다.

여기서는 답을 이렇게 받았다고 두겠습니다. 로그인한 사용자가 웹과 API로 링크를 만들고, 코드는 서버가 생성하며, 삭제와 만료는 있지만 원본 URL 수정은 없고, 클릭 수는 대략적인 집계로 충분하다고 합니다. 트래픽은 가정으로 읽기가 쓰기보다 훨씬 많은 형태라고 두겠습니다. 실제 숫자는 면접관이 주지 않으면 스스로 가정을 말하고 넘어가면 됩니다. 중요한 것은 숫자 자체가 아니라 그 가정이 설계 결정을 어떻게 바꾸는지 설명하는 부분입니다.

TIP

질문은 네 개를 넘기지 않는 편이 좋습니다. 요구사항 확인이 길어지면 설계를 회피한다는 인상을 줍니다. 답이 안 나오는 항목은 “일단 이렇게 가정하고 진행하겠습니다”라고 말하고 넘어간 뒤, 나중에 그 가정이 틀렸을 때 어디가 바뀌는지 한 줄로 짚어 주면 됩니다.

2단계 · 계약 스케치

요구사항이 좁혀졌으면 곧바로 엔드포인트 목록을 적습니다. 이때 이름만 나열하지 말고 대표 요청 하나에 대해 실제 본문과 응답을 적어야 합니다. 본문을 적는 순간 “원본 URL 길이 제한은 얼마인가”, “만료를 절대 시각으로 받나 상대 시간으로 받나” 같은 결정이 강제로 드러나기 때문입니다.

ENDPOINT CONTRACT

POST /v1/links            링크 생성 (인증 필요)
GET  /v1/links/{code}     메타데이터 조회 (소유자만)
DELETE /v1/links/{code}   삭제 (소유자만)
GET  /{code}              리다이렉트 (공개)

--- 요청 ---
POST /v1/links
Authorization: Bearer <token>
Idempotency-Key: 6f1c2a94-3b0e-4f2b-9a77-1f2c9d5e0b21
Content-Type: application/json

{
  "target_url": "https://example.com/a/very/long/path?ref=news",
  "expires_at": "2026-12-31T23:59:59Z"
}

--- 201 Created ---
Location: https://sho.rt/v1/links/aK9dZq
Content-Type: application/json

{
  "code": "aK9dZq",
  "short_url": "https://sho.rt/aK9dZq",
  "target_url": "https://example.com/a/very/long/path?ref=news",
  "expires_at": "2026-12-31T23:59:59Z",
  "created_at": "2026-08-30T04:11:07Z"
}

--- 422 Unprocessable Entity ---
{
  "error": {
    "code": "invalid_target_url",
    "message": "target_url must be an absolute http or https URL",
    "field": "target_url"
  }
}

이 스케치 한 장에 이미 여러 결정이 담겨 있습니다. 리다이렉트만 /{code}로 짧게 두고 관리용 API는 /v1 아래로 넣은 것은, 짧은 경로가 제품의 핵심 자산이라 관리 경로와 섞이면 안 되기 때문입니다. 생성 응답에 Location 헤더와 본문을 함께 준 것은 클라이언트가 한 번 더 조회하지 않도록 하기 위해서입니다. 오류 본문에 사람이 읽는 message와 기계가 분기하는 code를 분리한 것도 의도된 선택입니다. 클라이언트가 문자열 메시지로 분기하기 시작하면 메시지를 다듬는 순간 남의 코드가 깨집니다. 이 부분은 언어별 오류 처리 비교와 JSON 다루기 비교를 같이 보면 감각이 잡힙니다.

상태 코드도 미리 정해 두면 좋습니다. 입력 형식 자체가 깨졌으면 400, 형식은 맞지만 값이 규칙을 어겼으면 422, 토큰이 없거나 만료면 401, 남의 리소스면 403, 존재하지 않으면 404, 한도 초과면 429입니다. 여기서 “403과 404 중 무엇을 줄 것인가”는 단순한 취향 문제가 아니라 정보 노출 문제입니다. 남의 코드에 403을 주면 그 코드가 존재한다는 사실이 새 나갑니다.

3단계 · 실패와 재시도

계약을 적고 나면 반드시 나오는 질문이 있습니다. 클라이언트가 POST /v1/links를 보냈는데 응답을 못 받고 타임아웃이 났다면, 그 요청은 성공한 걸까요 실패한 걸까요. 클라이언트는 알 수 없습니다. 그래서 재시도합니다. 그러면 링크가 두 개 생깁니다.

위 계약에 Idempotency-Key 헤더가 들어간 이유가 이것입니다. 서버는 이 키와 요청 본문의 해시를 함께 저장해 두고, 같은 키로 같은 본문이 다시 오면 저장해 둔 응답을 그대로 돌려줍니다. 같은 키인데 본문이 다르면 409로 거절합니다. 클라이언트가 키를 재사용하면서 다른 것을 만들려 하는 상황은 버그이지 정상 흐름이 아니기 때문입니다.

WARNING

멱등성 키를 애플리케이션 메모리에만 두면 인스턴스가 여러 대인 순간 무용지물이 됩니다. 공유 저장소에 두고, 키에는 보관 기간을 정해야 합니다. 그리고 마지막 방어선은 언제나 데이터베이스의 유니크 제약입니다. 애플리케이션 레벨 검사는 두 요청이 동시에 통과할 수 있지만 유니크 제약은 통과하지 못합니다.

부분 실패도 계약에 적어야 합니다. 링크는 만들어졌는데 통계 초기화가 실패했다면 사용자에게 실패를 돌려줄 것인지, 아니면 201을 주고 통계는 나중에 채울 것인지 정해야 합니다. 이 문제에서는 클릭 수가 대략적인 값으로 충분하다고 했으므로 후자가 맞습니다. 이렇게 “요구사항에서 받은 답이 이 결정을 이렇게 바꿉니다”라고 연결해 주는 것이 면접에서 가장 점수가 높은 구간입니다.

4단계 · 진화와 버전

API는 배포한 다음 날부터 바꾸고 싶어집니다. 그런데 버전을 올리는 일은 생각보다 비쌉니다. 두 벌의 코드를 동시에 운영해야 하고, 클라이언트를 옮기도록 설득해야 하고, 언제 끌지 계속 신경 써야 합니다. 그래서 순서는 항상 이렇습니다. 먼저 호환되는 변경으로 해결할 수 있는지 보고, 안 되면 그때 버전을 올립니다.

  • 선택 필드를 추가하는 것은 호환됩니다. 기존 클라이언트는 모르는 필드를 무시합니다.
  • 필드를 지우거나 이름을 바꾸는 것은 깨집니다. 지우고 싶으면 먼저 사용량을 관측하고, 쓰는 곳이 없다는 것을 확인한 뒤 지웁니다.
  • 필드 타입을 바꾸는 것은 이름을 바꾸는 것보다 나쁩니다. 조용히 깨지기 때문입니다. 새 필드를 추가하고 옛 필드를 남겨 두는 편이 안전합니다.
  • 기본값을 바꾸는 것도 호환성 파괴입니다. 요청에 없던 필드가 갑자기 다른 동작을 하게 됩니다.
  • 오류 코드 문자열을 바꾸는 것 역시 파괴입니다. 클라이언트가 그 문자열로 분기하고 있기 때문입니다.

버전을 정말 올려야 한다면 경로에 넣는 방식이 로그와 라우팅에서 가장 다루기 쉽습니다. 그리고 새 버전을 여는 날 반드시 옛 버전의 종료 조건을 같이 정해야 합니다. “호출량이 하루 0이 되면 끈다” 같은 관측 가능한 조건이면 충분합니다. 조건 없이 열어 둔 버전은 영원히 남습니다.

약한 답과 강한 답

같은 문제에 대한 두 답을 나란히 놓고 보겠습니다.

약한 답 — “POST /shorten으로 URL을 받아서 단축 코드를 만들어 DB에 저장하고, GET /{code}로 조회해서 302 리다이렉트를 합니다. 코드는 랜덤 문자열로 만들고, 트래픽이 많아지면 캐시를 붙이면 됩니다.”

강한 답 — “호출자는 로그인 사용자이고 코드는 서버가 만든다고 확인했으니, 생성은 POST /v1/links로 두고 요청에 target_url과 expires_at을 받겠습니다. 응답은 201에 Location과 본문을 함께 주고, 잘못된 URL은 422에 error.code=invalid_target_url로 돌려줍니다. 생성은 재시도로 중복될 수 있으니 Idempotency-Key 헤더를 받아 같은 키에는 저장된 응답을 반환하고, 최종 방어선으로 code에 유니크 제약을 두겠습니다. 리다이렉트는 공개 경로 GET /{code}로 분리하고, 삭제와 만료가 있으므로 영구 리다이렉트 대신 302에 짧은 캐시를 두겠습니다. 클릭 집계는 정확도 요구가 낮으니 리다이렉트 응답을 막지 않도록 비동기로 처리하겠습니다.”

약한 답이 부족한 지점은 “깊이가 얕다”는 막연한 이유가 아닙니다. 정확히 세 가지를 하지 않았습니다. 첫째, 자기가 세운 가정을 한 번도 말하지 않았습니다. 호출자가 누구인지, 수정이 있는지 없는지가 답에 없으니 뒤따르는 결정들이 왜 그렇게 되는지 검증할 방법이 없습니다. 둘째, 실패 경로가 통째로 빠졌습니다. 잘못된 입력, 중복 요청, 존재하지 않는 코드에 대해 무엇을 돌려주는지가 없으면 그건 계약이 아니라 스케치입니다. 셋째, 302를 고른 이유를 말하지 않았습니다. 삭제 가능한 리소스에 영구 리다이렉트를 쓰면 브라우저 캐시에 남은 매핑을 회수할 수 없는데, 약한 답은 이 선택을 했다는 자각조차 없습니다. 강한 답이 좋은 이유는 어려운 기술을 써서가 아니라, 모든 결정에 그 결정을 강제한 요구사항이 하나씩 붙어 있어서입니다.

면접관이 이어서 묻는 것들

위 답을 하고 나면 대화가 끝나지 않고 이어집니다. 자주 나오는 후속 질문과, 그 질문이 실제로 무엇을 확인하려는 것인지 정리했습니다.

단축 코드는 어떻게 만들 생각인가요?

난수 기반 코드와 순번 인코딩 중 하나를 고르고 이유를 말합니다. 난수는 추측이 어렵지만 충돌 검사가 필요하고, 순번 인코딩은 충돌이 없지만 발급량이 노출됩니다. 어느 쪽이든 유니크 제약으로 최종 방어선을 둡니다. 면접관이 보는 것은 어느 쪽을 골랐느냐가 아니라 두 방식의 비용을 알고 골랐느냐입니다.

같은 URL을 두 번 등록하면 어떻게 되나요?

기본은 매번 새 코드를 발급하는 것이 안전합니다. 만료나 삭제가 서로 다른 주체에게 영향을 주기 때문입니다. 재사용이 요구사항이면 소유자와 원본 URL 조합에 유니크 제약을 걸고 기존 코드를 돌려줍니다. 이 질문은 “저장 공간 절약”이라는 얕은 이유로 코드를 공유했다가 한 사용자의 삭제가 다른 사용자의 링크를 죽이는 상황을 알아차리는지를 봅니다.

조회 응답을 캐시해도 되나요?

코드에서 원본 URL로의 매핑은 거의 변하지 않으므로 캐시하기 좋습니다. 다만 삭제와 만료가 있으므로 TTL을 짧게 두거나 삭제 시 캐시를 명시적으로 무효화해야 합니다. 영구 리다이렉트를 쓰면 브라우저 캐시는 회수할 수 없습니다. 캐시를 성능 도구로만 보는지, 무효화까지 포함한 계약으로 보는지를 가르는 질문입니다.

인증은 어디에 붙이나요?

생성·수정·삭제는 인증이 필요하고 리다이렉트는 공개입니다. 토큰에서 소유자를 꺼내 리소스 소유자와 비교하며, 남의 코드에 접근할 때 403과 404 중 무엇을 줄지 정보 노출 관점에서 정합니다. 인증과 인가를 구분해서 말하는지가 관전 포인트입니다. 기초가 흔들린다면 첫 프로젝트를 위한 보안 기초를 훑어보시기 바랍니다.

요청이 몰리면 어떻게 막나요?

생성 API에 소유자 단위 레이트 리밋을 걸고 429와 Retry-After를 함께 반환합니다. 리다이렉트는 IP 단위로 훨씬 느슨하게 두고, 한도를 계약 문서에 숫자로 적어 클라이언트가 대비할 수 있게 합니다. 쓰기와 읽기에 같은 한도를 적용하겠다고 답하면 두 경로의 성격 차이를 못 본 것으로 읽힙니다.

혼자 연습하는 방법

이 페이지를 읽는 것만으로는 늘지 않습니다. 타이머를 5분에 맞추고, 위 문제를 소리 내어 답해 보시기 바랍니다. 요구사항 질문 4개, 엔드포인트 목록, 대표 요청·응답 한 쌍, 실패 처리, 버전 전략 순서로 막힘없이 나오는지 보면 됩니다. 그다음 문제를 바꿔서 같은 순서를 다시 밟습니다. 파일 업로드 API, 댓글 API, 결제 웹훅 수신 API 정도가 연습하기 좋습니다. 특히 웹훅은 재시도가 기본 동작이라 멱등성을 설명할 자리가 자연스럽게 생깁니다.

말로 답한 뒤에는 반드시 적어 두세요. 어떤 가정을 빠뜨렸는지, 어떤 트레이드오프를 언급하지 않았는지, 그 결정이 맞는지 어떻게 검증할 것인지 세 줄이면 충분합니다. 다른 주제로 넓히고 싶다면 면접 준비 목록과 백엔드 개발 로드맵을 이어서 보시기 바랍니다.

관련 언어 학습으로 복습하기