- 발행일
토큰을 만드는 길이 세 갈래였다 — 사용자당 유효 토큰 1개를 한 관문에서 지키기
토큰을 만드는 길이 세 갈래였다
교통 데이터 플랫폼의 Open API는 토큰으로 인증합니다. 10월 6일, "사용자 한 명에게 유효한 토큰은 하나만" 이라는 규칙을 넣었습니다. 이 규칙은 제가 정한 게 아니에요. 같은 회사에서 먼저 Open API를 운영하던 다른 시스템이 이미 이렇게 바꿔 두었고, 9월 말부터 그 시스템의 Open API 변경을 이쪽으로 옮겨 오던 흐름의 하나였습니다.
규칙 자체는 한 줄이지만 넣을 자리를 찾다 보니 문제가 보였습니다. 이 시스템에서 토큰을 만드는 길이 세 갈래였거든요(1e321333).
세 곳이 각자 토큰을 만들고 있었다
| 길 | 어디서 | 기존 토큰 확인 |
|---|---|---|
| 관리자 직접 발급 | 관리 화면 TokenCreateView.form_valid | 안 함 |
| 발급 신청 승인 | 관리 화면 TokenRequestApproveView | 안 함 |
| 관리용 REST API | POST /api/v1/tokens/ perform_create | 안 함 |
세 곳 모두 uuid.uuid4().hex로 키를 만들고 now + 유효기간으로 만료일을 넣는 코드를 각자 들고 있었습니다. 같은 일을 하는 코드가 세 벌이었던 거예요.
특히 승인 쪽은 이 일이 문서에 이미 알려진 한계로 적혀 있었습니다.
유효한 토큰이 있는 사용자의 대기 신청을 승인하면 토큰이 두 개가 된다(승인 때 기존 토큰을 보지 않는다).
승인 뷰는 신청 행을 select_for_update()로 잠그고 상태를 다시 확인하고 있었습니다. 그래서 같은 신청을 두 관리자가 동시에 승인하는 경우는 막혔어요. 하지만 그 잠금이 지키는 건 신청 한 건이었고, 그 사용자에게 다른 유효 토큰이 있는지는 아무도 보지 않았습니다.
세 곳에 검사를 넣지 않고 한 함수로 모았다
가장 짧은 길은 세 곳에 if 유효 토큰 있음: 거절을 하나씩 넣는 것이었습니다. 그렇게 하지 않은 이유는 두 가지예요.
- 네 번째 길이 생기면 그 검사를 또 기억해서 넣어야 합니다. 규칙이 한 곳에 있으면 새 길도 그 함수를 부르기만 하면 됩니다.
- 검사와 생성 사이에 틈이 있으면 검사가 소용없습니다. 이 틈을 닫는 잠금도 세 곳에 따로 걸어야 하는데, 잠금은 세 곳이 같은 것을 같은 순서로 잡아야 의미가 있습니다.
그래서 ApiTokenManager.issue() 하나를 만들고 세 길이 전부 여기를 지나게 했습니다.
@staticmethod
def issue(*, user, description, expiry_days):
"""관리자 직접 발급·신청 승인·관리 API 가 함께 쓰는 사용자당 1개 발급 관문."""
# 계정 삭제와 같은 잠금을 먼저 잡아, 동시 발급 두 건이 서로의 빈 조회를 보고 둘 다 만들지 못하게 한다.
with lifecycle_atomic():
if ApiTokenManager.has_active_token(user.pk):
raise ActiveTokenExistsError
return ApiToken.objects.create(
user=user,
key=uuid.uuid4().hex,
description=description,
expires_at=timezone.now() + timedelta(days=expiry_days),
)
거절은 하나의 예외(ActiveTokenExistsError)로 던지고, 각 길이 자기 방식대로 바꿔 보여 줍니다.
| 길 | 거절할 때 |
|---|---|
| 직접 발급 | 사용자 칸 아래 오류 문구 |
| 신청 승인 | 신청은 대기로 남고 오류 메시지 — 기존 토큰이 만료·회수된 뒤 다시 승인할 수 있다 |
| REST API | 400, user 필드 오류 |
승인에서 신청을 반려로 돌리지 않고 대기로 남긴 건, 신청자가 잘못한 게 아니기 때문입니다. 지금 가진 토큰이 끝나면 그 신청은 정상적으로 승인될 수 있는 신청이에요.
직접 발급 화면은 폼의 clean_user()에서도 같은 검사를 합니다. 화면에서 바로 알려 주려는 용도이고, 진짜 판정은 form_valid에서 잠금 안의 issue()가 다시 합니다. 폼 검증과 저장 사이에 다른 관리자가 먼저 발급할 수 있기 때문이에요. 사용자 칸 아래에는 "유효한 토큰이 없는 사용자에게만 발급할 수 있습니다"라는 안내도 붙였습니다.
DB 제약으로는 걸 수 없는 규칙이었다
"한 사용자에 하나"는 보통 유일 제약으로 겁니다. 그런데 여기서 '유효'는 회수되지 않았고 만료 시각이 지금보다 뒤라는 뜻이에요. 조건에 '지금'이 들어갑니다. 부분 유일 인덱스의 조건은 행이 저장될 때 정해지는 값으로만 쓸 수 있어서, 시간이 지나 만료되는 토큰을 인덱스가 스스로 빼 주지 않습니다. revoked_at IS NULL만으로 걸면 만료됐지만 회수하지 않은 토큰이 새 발급을 영영 막습니다. 그래서 이 규칙은 잠금 안에서 앱이 검사할 수밖에 없습니다.
잠금을 새로 만들지 않고 계정 삭제의 잠금을 빌렸다
잠금 없이 검사만 하면, 두 요청이 동시에 "유효 토큰 없음"을 보고 둘 다 토큰을 만듭니다. 토큰 행을 잠그는 방법은 처음 발급하는 사용자에게 쓸 수 없습니다. 잠글 토큰 행이 아직 없으니까요. 사용자 행을 잠그면 사용자 단위로 좁힐 수 있지만, 계정 삭제가 쓰는 잠금과 순서를 하나 더 맞춰야 합니다.
이 시스템에는 이미 사용자 단위의 변경을 한 줄로 세우는 잠금이 있었습니다. 팀원이 계정 영구 삭제를 만들면서 넣은 lifecycle_atomic()이에요. 잠금용 행 하나를 select_for_update()로 잡고, 계정 삭제와 사용자를 참조하는 변경이 이 잠금 안에서만 일어나게 합니다. 토큰도 사용자를 참조하는 행이니 이 잠금을 쓰기로 했습니다.
먼저 이 규칙을 넣은 쪽 시스템은 다른 잠금을 씁니다. 같은 방식을 따라가지 않은 이유는 잠금 순서 때문입니다.
- 승인은
select_for_update()에select_related("user")를 붙여 읽기 때문에 신청 행과 함께 신청자의 사용자 행까지 잠급니다. - 계정 삭제·역할 배정처럼
lifecycle_atomic()을 먼저 잡고 그 사용자를 참조하는 행을 쓰는 작업이 있습니다. 승인이 사용자 행을 쥔 채 발급 관문에서lifecycle_atomic()을 기다리고, 그쪽이lifecycle_atomic()을 쥔 채 그 사용자 행에 닿으면 서로를 기다리며 멈출 수 있습니다.
그래서 승인 뷰는 lifecycle_atomic()을 신청 행보다 먼저 잡도록 바꿨습니다.
# 발급 관문과 같은 잠금을 신청 행보다 먼저 잡는다 — 계정 삭제와 잠금 순서가 엇갈리면 교착된다.
try:
with lifecycle_atomic():
req = (
ApiTokenRequest.objects.select_for_update()
.select_related("user")
.get(pk=pk)
)
...
token = ApiTokenManager.issue(...)
lifecycle_atomic()은 이미 그 잠금 안에 있으면 다시 잡지 않고 지나가게 되어 있어서, 바깥에서 한 번 잡고 안의 issue()가 또 불러도 문제가 없습니다.
네 번째 길: 만료일 변경
세 길을 막고 나서 하나가 더 보였습니다. 관리자는 토큰 상세에서 만료일을 바꿀 수 있습니다. 이미 만료된 토큰 A가 있고 새로 받은 토큰 B가 유효할 때, A의 만료일을 미래로 옮기면 유효 토큰이 다시 둘이 됩니다. 발급은 안 했지만 결과는 같아요.
그래서 만료일 변경도 ApiTokenManager.change_expiry() 한 곳으로 모으고 같은 규칙을 걸었습니다. 상세 화면과 REST PATCH가 모두 이 함수를 씁니다.
with lifecycle_atomic():
token = ApiToken.objects.select_for_update().get(pk=token_id)
if (
token.revoked_at is None
and expires_at > timezone.now()
and ApiTokenManager.has_active_token(token.user_id, exclude_token_id=token.pk)
):
raise ActiveTokenExistsError
token.expires_at = expires_at
token.save(update_fields=["expires_at"])
여기서 문서의 다른 한계 하나도 같이 풀렸습니다.
관리자가 만료일을 저장하는 사이 다른 관리자가 회수하면 회수 기록이 지워질 수 있다(폼이 행 전체를 저장한다).
예전 상세 화면은 form.save()로 행 전체를 저장했습니다. 요청이 토큰을 읽은 뒤 저장하기 전에 다른 관리자가 회수하면, 회수 전 값을 들고 있던 인스턴스가 revoked_at을 다시 비워 버릴 수 있었어요. 이제는 행을 잠근 상태에서 다시 읽고 update_fields=["expires_at"]로 만료일만 씁니다.
거절했을 때 화면에도 하나 손볼 게 있었습니다. 폼의 is_valid()가 거절된 만료일을 인스턴스에 이미 입혀 놓기 때문에, 그대로 다시 그리면 상세 표에 저장되지 않은 날짜가 보입니다. 거절 뒤에 refresh_from_db()로 저장된 값을 다시 읽게 했습니다.
검증
회귀 테스트 7건을 붙였습니다(open_api/test_token_single_active.py).
- 두 번째 발급은 거절되고 토큰은 1개로 남는다
- 기존 토큰이 만료되거나 회수되면 다시 발급할 수 있다
- 직접 발급 화면은 사용자 칸 오류를 보인다
- 승인은 신청을 대기로 남긴다
- REST API는
400을 돌려준다 - 만료일 변경으로 만료 토큰을 되살려 유효 토큰이 둘이 되는 것을 막는다
- 유효 토큰이 하나뿐일 때는 만료일 변경이 그대로 된다
마지막 테스트는 막는 쪽만 테스트하면 정상적인 만료일 연장까지 막아 버리는 실수를 잡지 못해서 넣었습니다. 개발 문서의 알려진 한계 두 줄은 해소로 표시했습니다.
같은 날 밤: 토큰 API가 모델 이름을 내보내고 있었다
같은 날 밤에 토큰 관리 API를 하나 더 고쳤습니다(00512efa). 없는 토큰 ID로 부르면 응답이 이랬어요.
{"detail": "No ApiToken matches the given query."}
DRF 3.15는 Http404와 파서 오류의 원문을 detail에 그대로 싣습니다. 모델 이름과 파서 내부 문구(JSON parse error - …)가 바깥에 보이는 거예요. ApiTokenViewSet.handle_exception 한 곳에서 404는 "해당 항목을 찾을 수 없습니다", 본문을 해석할 수 없는 400은 JSON과 그 밖의 형식에 맞춘 고정 문구로 바꿨습니다. 상태 코드와 필드별 검증 오류는 그대로 둡니다. 클라이언트가 어느 필드가 틀렸는지는 알아야 하니까요. 테스트 4건이 404(GET·PATCH·DELETE), 깨진 JSON(발급이 안 일어나는지까지), 깨진 multipart, 필드 오류 유지를 확인합니다.
한계와 남은 것
- 이 커밋의 7건은 동시성을 재현하지 않습니다. 7건은 모두 순서대로 실행되는
TestCase라 "두 요청이 동시에 빈 조회를 본다"는 상황을 만들지 않습니다. 잠금이 그 틈을 닫는다는 건 코드를 읽어서 판단한 것이에요. 테스트 설정의 기본 DB는 SQLite 메모리 DB라select_for_update()가 실제로 걸리지도 않습니다. - 잠금 범위가 전역입니다.
lifecycle_atomic()은 잠금용 행 하나를 잡으므로 서로 다른 사용자의 발급도, 다른 사용자의 계정 삭제도 한 줄로 섭니다. 토큰 발급은 관리자 권한으로만 일어나는 드문 일이라 지금은 이걸로 충분하다고 봤지만, 발급이 잦아지면 사용자 단위 잠금이 필요해집니다. - 신청 버튼 두 번 누르기는 그대로입니다. 발급 신청을 빠르게 두 번 누르면 대기 신청이 두 건 생길 수 있다는 한계는 이번 범위가 아니었고 문서에 그대로 남아 있습니다. 승인은 한 건만 통과하니 토큰이 둘이 되지는 않지만, 관리자가 볼 신청은 두 건이 됩니다.
- 키를 평문으로 저장합니다.
ApiToken.key에 32자리 키가 그대로 들어 있고, 토큰 상세 화면은 만료 전까지 언제든 키 전체를 다시 보여 줍니다. DB나 백업이 새면 키를 그대로 쓸 수 있어요. 해시로 저장하면 키는 발급할 때 한 번만 보여 줄 수 있습니다. (이 글을 정리하며 덧붙임) 10월 28일에 이 항목을 검토했지만 보류로 적었습니다(9413dcea). 토큰 상세와 마이페이지의 키 [보기]·[복사]는 먼저 규칙을 정한 그 시스템과 화면을 맞춘 것이라, 이쪽만 해시로 바꾸면 두 시스템의 화면이 갈립니다. 그쪽과 어떻게 맞출지를 먼저 정해야 해서, 지금은 알고도 남겨 둔 부채입니다.
같은 토큰 화면의 앞선 이야기는 토큰 승인 오클릭이 무서워서 만든 가드2026-06-16토큰 승인 오클릭이 무서워서 만든 가드 — 위험한 액션 버튼 UX 회고토큰 신청 검토 화면에서 승인/거절을 우측 정렬 버튼 그룹으로 묶고, 거절 사유를 적어둔 채 승인을 눌러버리는 오클릭에 확인 가드를 넣은 작업입니다. 네 가지 마크업 방식 중 form 속성을 고른 이유와, 커스텀 모달 대신 네이티브 confirm을 남겨둔 판단을 정리해요.에 있습니다. 그때는 승인 버튼을 잘못 누르지 않게 하는 화면 쪽 가드였고, 이번에는 승인을 눌렀을 때 서버가 지켜야 할 규칙이었습니다.