Code › tail-villain
v1 출시 전 보안 경계
v1 출시 전에 인증과 요청 제한, 배포 경계를 점검한 과정
v1을 앞두고 가장 신경 쓰였던 건 기능이 하나 더 있는지가 아니었다. 사용자가 들어왔을 때, 시스템이 어디까지 버틸 수 있는지였다.
tail-villain은 AI를 많이 호출하는 제품이다. 로드맵을 만들고, 인터뷰를 시작하고, 답변마다 follow-up과 평가를 만든다. 한 번의 요청이 단순 조회보다 비싸고, 잘못 열려 있으면 비용과 보안 문제가 같이 커진다.
그래서 4월 말에서 5월 1일 사이의 작업은 새 기능 추가보다 접근 권한과 요청 한도를 서버에서 강제하는 작업이었다. 누가 얼마나 요청할 수 있는지, 어떤 사용자가 민감한 기능에 접근할 수 있는지, 배포 환경에서 빌드와 런타임이 같은 규칙을 보고 있는지 확인해야 했다.
먼저 throttling을 다시 봤다. 단순한 IP 기준 제한만 두면 구현은 쉽지만, 실제 사용 환경에서는 거칠다. 회사나 학교처럼 같은 네트워크를 공유하는 사용자가 많으면 한 사람의 사용량 때문에 다른 사람이 같이 막힐 수 있고, 로그인한 사용자의 행동과 익명 요청을 같은 기준으로 묶는 것도 어색하다.
그래서 요청 제한의 기준을 사용자 중심으로 바꿨다. 인증된 사용자는 userId로 추적하고, 익명 요청은 IP로 추적한다. 정책도 한 가지 숫자로 끝내지 않고, 짧은 요청, 중간 요청, 긴 요청, 무거운 요청처럼 비용과 위험도에 맞춰 나눴다.
특히 AI 관련 endpoint는 별도로 다뤄야 했다. 로드맵 생성이나 인터뷰 메시지는 서버 비용을 직접 발생시키고, 무제한으로 열어두면 제품 전체가 흔들린다. 인증 요청도 마찬가지다. 로그인과 가입은 사용자에게는 입구지만, 시스템 입장에서는 brute-force와 abuse를 가장 먼저 만나는 지점이다.
처음에는 guard 순서 문제도 있었다. 사용자 기준 throttling을 하려면 req.user가 있어야 하는데, global guard가 auth보다 먼저 돌면 user context가 비어 있다. 보안 코드는 한 줄의 조건보다 실행 순서가 더 중요할 때가 많다. 그래서 IP, auth, email verification, user와 credit 같은 층을 한 번에 보면서 요청 흐름을 다시 잡았다.
아이디 경계도 같이 정리했다. 내부 데이터베이스 ID가 그대로 밖으로 새지 않게 public ID 체계를 만들고, URL과 API에서 보이는 값은 prefix가 붙은 짧은 ID로 다루게 했다. 사용자에게 user, roadmap 같은 리소스의 성격은 보이되, 내부 primary key는 숨기는 구조다.
이건 보안만의 문제는 아니었다. 공개 ID가 일관되면 로그를 읽을 때도, 고객 지원을 할 때도, 외부 시스템과 붙일 때도 훨씬 덜 헷갈린다. 내부 ID는 저장소의 사정이고, 사용자가 보는 ID는 제품의 언어다.
LLM 사용 로그도 그 연장선에 있었다. 어떤 요청이 어떤 모델을 얼마나 썼는지 남겨야 비용을 볼 수 있고, 비용을 봐야 제한 정책을 제대로 잡을 수 있다. 다만 블로그에 적을 만큼 중요한 건 숫자 자체가 아니라, 비싼 동작을 추적 가능한 동작으로 바꿨다는 점이었다.
비용이 보이지 않으면 보안 정책도 감으로 만든다.
다음 경계는 identity였다. 이메일 인증 없이 민감한 endpoint를 열어두면, 계정은 만들어졌지만 실제 사용자를 믿을 수 없는 상태가 된다. 그래서 OTP 기반 email verification을 넣고, 필요한 경우 전역 설정으로 verification 요구 여부를 켜고 끌 수 있게 했다.
미인증 사용자는 민감한 API로 바로 들어가지 못하게 막고, 화면에서는 verification banner와 modal로 다음 행동을 알려줬다. 여기서 중요한 건 미인증 계정의 접근을 차단하면서도 인증이 필요한 이유와 다음 행동을 화면에서 설명했다.
관리 화면도 필요했다. 운영 중에는 모든 정책을 코드 배포로만 바꾸기 어렵다. email verification을 강제할지, 어떤 global flag를 켤지 같은 값은 상황에 따라 조정할 수 있어야 한다. 그래서 admin settings 쪽에 시스템 설정을 모았다.
배포 쪽에서는 monorepo의 민감함이 그대로 드러났다. 로컬에서는 돌아가는데 Vercel에서는 entrypoint를 찾지 못하거나, build artifact 위치가 달라지거나, CommonJS 환경에서 ESM-only 패키지가 터지는 식의 문제가 이어졌다.
이런 문제는 제품 기능과 멀어 보이지만, v1에서는 기능만큼 중요하다. 사용자가 가입하고 인터뷰를 시작하려면, 서버가 같은 방식으로 빌드되고 같은 방식으로 실행되어야 한다. root package script에서 shared types와 Prisma client 생성 순서를 잡고, backend build output이 Vercel이 기대하는 위치에 나오도록 tsconfig를 정리했다.
로컬 PostgreSQL 버전도 production target과 맞췄다. 데이터베이스 버전이 다르면 개발 중에는 보이지 않던 차이가 배포 후에 튀어나올 수 있다. 특히 인증, credit, session 같은 핵심 데이터가 붙는 제품에서는 그런 차이를 줄이는 편이 낫다.
이틀 동안 한 일들을 launch checklist처럼 나열하면 많아 보인다. ID 체계, LLM usage log, user-aware throttling, email verification, admin settings, Vercel build, module format, database version까지 전부 다른 항목처럼 보인다.
하지만 실제로는 같은 질문이었다.
사용자가 제품에 들어왔을 때, 이 요청을 누가 보냈는지 알 수 있는가. 이 사용자가 이 기능을 써도 되는 상태인가. 요청이 너무 비싸거나 위험하면 막을 수 있는가. 배포된 서버는 로컬에서 검증한 규칙과 같은 방식으로 움직이는가.
v1은 축하할 이름이라기보다, 이런 질문에 최소한의 답을 갖춘 상태였다. 기능이 완성됐다고 말하기 전에, 먼저 제품이 스스로를 지킬 수 있어야 했다.