## 1. "로그인했는데 왜 나를 모르니?" (문제의 시작) {#sec-ffb74987f051} OAuth2, JWT, 세션 인증… 인증 방식은 정말 많고, 대부분의 경우엔 그걸로 충분합니다. 저도 그랬고요. * 직접 만든 이메일 클라이언트나 ChatGPT의 MyGPT에서 **OAuth2**를 붙였을 때는 “아, 이게 진짜 사용자 경험이다” 싶었고 * 단독 Django 서버로 끝나는 웹 앱은 **세션 인증**이 최고! * 프론트와 협업하는 구조(프론트/백 분리)에서는 **JWT**가 가장 깔끔했습니다. 그런데, 이 조합이 어느 순간 한 번에 무너지는 구간이 있었습니다. 바로 **비동기 작업(Celery)** 이었습니다. 사용자가 버튼을 누르면, 백엔드가 직접 일을 하는 게 아니라 저 멀리 있는 AI 연산 서버나 워커에게 일을 시킵니다. 이때 워커는 말합니다. > "저기... 요청은 받았는데, 이거 누구 대신해서 하는 일이죠? request.user가 없는데요?" ![로봇 워커가 apikey를 들고 편지를 전달하는 이미지](/media/editor_temp/6/b63803ff-21ba-4f1b-a5af-47c8ca0fdd25.png) ## 2. 문제는 “백엔드 ↔ 백엔드” + “비동기 워커(Celery)” {#sec-a7eab2678145} 제가 API Key를 도입하게 된 결정적 계기는 **백엔드 서버끼리 통신**하는데, 그 사이에 **Celery worker가 개입**하는 구조때문이었습니다. 사용자가 버튼을 눌렀다고 해서 요청이 곧장 AI 연산 서버로 가는 게 아니라, 1. 사용자가 웹 요청을 보냄 2. 백엔드가 “작업”을 큐에 넣음 3. Celery worker가 큐를 소비하면서 **어딘가의 백엔드/연산 서버로 비동기 요청을 보냄** 이 상황에서 제일 아픈 지점이 뭐였냐면: * worker에는 **request.user가 없습니다** * 세션도 없습니다 (브라우저가 아니니까요) * JWT도 애매합니다 (토큰을 “누가” “어디서” “어떻게” 관리하고 전달할지부터 복잡해짐) * OAuth2는 더더욱 “사용자 상호작용”이 전제인 흐름이지요. 애당초 적용 불가입니다. 결국 JWT와 세션이 무력해지는 순간에 남는 질문이 하나로 압축됩니다. > “연산서버에서 이 작업을 실행하는 주체가 어떤 고객(테넌트/유저)인지 특정할 수 있도록, 워커가 어떻게 알려주게 하지?” ## 3. 워커 세계에는 ‘인증’보다 ‘식별’이 먼저 필요했다 {#sec-77f5863a0a93} 웹 요청에서는 “인증=로그인”이고 “로그인=유저”가 자연스럽게 연결됩니다. 하지만 워커는 **사람이 아니라 애플리케이션이 스스로 CPU를 빌려 움직이는 존재**라서, 인증이라는 말보다 먼저 “식별”이 필요했습니다. 인증은 서버간의 HMAC 나 seceryKey등을 사용하면 되는 문제니까요. * 이 작업은 A 고객의 데이터로 실행돼야 함 * 결과는 A 고객의 리소스에 저장돼야 함 * 요금제/권한/쿼터는 A 고객 기준으로 차감돼야 함 이걸 JWT나 세션으로 억지로 끼워 맞추려고 하면, 토큰 발급/보관/전달/만료/재발급 설계가 커지고, 무엇보다도 **"아무리 내 서버의 고객이지만 고객이 브라우저를 직접 사용하고 있는 중이 아닌데, 백엔드에서 고객 정보를 가져다가 JWT를 발급받는다?"** 라는 왠지 모를 엄청난 거부감이 듭니다. 할 수는 있지만 절대 해서는 안되는 그런 것을 떠올리는 기분이 들었습니다. 너무 찝찝해서 그 아이디어는 바로 폐기 했습니다. ## 4. 해결: API Key는 이 구간에서 너무 단순하고 강력했다 {#sec-2110738d8481} 그래서 도입한 게 API Key였고, 이 문제를 한 번에 정리해줬습니다. * worker가 내부 요청을 보낼 때 **헤더 하나로 인증/식별을 동시에 처리** * 서버는 그 키를 보고 **어떤 유저/고객의 요청인지 즉시 매핑** * 키를 회수/교체(롤링)하는 것도 훨씬 명확함 예를 들면 이런 느낌으로 서버는 연산서버에 비동기 요청을 보냅니다. ```http POST /v1/ai/jobs Authorization: Api-Key Content-Type: application/json { "job_id": "...", "payload": {...} } ``` 워커는 `request.user`가 없어도 상관없습니다. 그냥 이대로 보내면 요청을 받는 백엔드에서 아래에 설명할 방법으로 저 Api-Key를 이용해서 사용자를 특정할 수 있습니다. ## 5. 결정적 개선: API Key를 USER와 묶어버리니 운영이 편해졌다 {#sec-762242363071} 제가 특히 만족했던 포인트는 여기입니다. 기존 `rest_framework_api_key` 같은 라이브러리는 API Key 자체는 잘 제공하지만, 제 케이스에서는 **“키 ↔ 사용자(고객) 결합”이 핵심**이었습니다. 그래서 `AbstractAPIKey`를 상속해서 `CustomAPIKey`로 확장하고, **AUTH_USER 모델과 FK로 연결**했습니다. 결과는... 대만족 이었습니다. 대략 구조는 이런 식의 개념이에요. ```python from django.conf import settings from rest_framework_api_key.models import AbstractAPIKey from django.db import models class CustomAPIKey(AbstractAPIKey): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_keys" ) is_test = models.BooleanField(default=False) # 스테이지/테스트 키 구분 ``` 이렇게 묶어두면 단순한 “인증”을 넘어 **운영 기능**이 쭉 열립니다. ## 6. 유저당 키 자동 발급이 만들어준 운영 이득들 {#sec-d180d12a94f2} 회원가입 시 유저에게 키를 하나 자동으로 심어두는 방식으로 운영하니, 다음이 아주 편해졌습니다. ### 1) 유효성 관리가 쉬워짐 {#sec-bfe883fb0c50} * 특정 유저의 키를 조회/비활성화/삭제가 단순해집니다. * 유저 탈퇴 시 연결된 키 정리도 자연스럽습니다(FK cascade). ### 2) 키 롤링이 쉬워짐 {#sec-7f4bccda836d} * “키 유출 의심” 같은 상황에서 새 키 발급 → 구 키 폐기 흐름이 명확합니다. * 다중 키를 허용하면 **무중단 교체**도 가능합니다(새 키 배포 후 구 키 종료). ### 3) 요금제/쿼터/권한 관리가 유저 중심으로 붙음 {#sec-1081bea2890b} * 키 단위가 아니라 **유저 단위로 과금/제한**을 걸 수 있어요. * “이 API Key는 누구의 요청인가?”를 매번 따로 추론할 필요가 없습니다. ### 4) 한 유저에 여러 키를 발급할 수 있음 {#sec-1bc8fa524379} 여기서 `is_test` 같은 플래그가 빛을 발합니다. APIKey 인증방식이라는 개념이 OneToOne 모델 연결이 아니라 FK이기 때문에 하나의 유저에 여러개의 key를 용도별로 달아줄 수 있습니다. * 같은 유저가 **스테이지 모드용 키**와 **프로덕션용 키**를 동시에 가질 수 있음 * 하나의 계정으로 개발/운영 흐름을 분리하기가 쉬움 * 로그/모니터링에서도 “테스트 트래픽 vs 실제 트래픽”을 깔끔히 나눌 수 있음 ## 7. 인증 방식은 우열이 아니라 “상황별 무기 선택”이다 {#sec-f73d5faf76f6} 정리하면, 제가 지금도 느끼는 “상황별 최적 조합”은 대략 이렇습니다. * **OAuth2**: 외부 서비스/클라이언트 연동, 사용자 동의가 중요한 플로우에 강함 * **세션 인증**: Django 단독 웹 앱에서 개발 속도와 단순함이 최고 * **JWT**: 프론트/백 분리, 모바일/SPA 등 다양한 클라이언트에서 균형이 좋음 * **API Key**: 백엔드-백엔드, 자동화/워커/배치처럼 “사용자 요청이 아닌 요청”에서 압도적으로 편함 특히 Celery worker가 끼는 순간, “로그인 기반 인증”으로 세계를 통일하려는 욕심이 오히려 복잡도를 키웁니다. 그때 API Key는 정말 깔끔한 탈출구였습니다. ## 8. 마무리 {#sec-d746e56908e8} 사람(브라우저/앱)은 세션/JWT/OAuth2로 다루는 게 자연스럽습니다. 하지만 워커는 사람이 아니라 프로세스고, 프로세스는 “누구의 일인지”를 식별할 수 있어야 합니다. 제가 API Key로 넘어간 이유는 거창한 보안 담론이 아니라, **그 구간에서 가장 단순하게 문제를 해결했기 때문**이었습니다. 그리고 USER와 묶어서 운영 단위를 “키”가 아니라 “사용자”로 맞추자, 키 관리는 도구가 아니라 **운영 레버**가 되었습니다. 여러분도 API-Key를 자주 사용하시나요? 제가 위에 사용한 방식은 APIKey의 편리함의 일부라고 생각합니다만, 이러한 작은 경험을 통해 이 글을 읽으시는 분들에게 좋은 영감을 전해드렸으면 그걸로 충분하다고 생각합니다. --- **관련글** - [Django/DRF에서 HMAC 서명으로 서버-서버 요청 무결성 지키기](/ko/whitedec/2025/12/9/django-drf-hmac-signature-server-to-server-integrity/) - [React RCE 사건이 남긴 교훈: HMAC 서명·키 로테이션·제로 트러스트의 필요성](/ko/whitedec/2025/12/8/react-rce-lesson-hmac-key-rotation-zero-trust/)