기술

이렇게까지 복잡할 필요 있냐는 한마디에, 토큰 계층을 통째로 걷어냈습니다

2026.09.128분 읽기

내부 프로젝트 관리 도구에 고객사 안내 문서를 공유하는 기능을 붙이는 작업이었습니다. 링크 하나를 보내면 로그인 없이 열리는 형태입니다. 여기에 고객사별로 문서를 잠그자는 요구가 들어왔습니다. 링크에 붙은 토큰만으로는 부족하니 비밀번호를 하나 더 두자는 이야기였습니다.

비밀번호를 도입하기로 정한 뒤 남은 문제는 그 비밀번호를 어떻게 유지하느냐였습니다. 한 번 인증한 사람에게 페이지를 넘길 때마다 다시 입력하게 만들 수는 없었습니다.

그래서 서명된 접근 토큰을 발급하는 안을 설계했습니다. 비밀번호로 한 번 인증하면 유효기간이 붙은 토큰을 내주고, 이후 요청은 그 토큰으로만 처리하는 구조였습니다. 인증과 열람이 분리되니 그림으로는 깔끔했습니다.


이렇게까지 복잡할 필요 있나

구현에 들어가려던 참에 이 질문을 받았습니다. 처음에는 방어할 생각이었습니다. 토큰 기반 인증은 흔한 패턴이고 저도 여러 번 써봤습니다. 그런데 요구를 다시 읽어보니 문장 하나였습니다. 고객사별로 문서를 잠그자.

요구는 한 줄인데 설계에는 계층이 하나 더 있었습니다. 그 계층을 왜 넣었는지 근거를 종이에 적어봤습니다. 근거는 하나뿐이었습니다.

비밀번호를 클라이언트에 저장하면, 비밀번호를 바꿔도 저장된 값으로 계속 볼 수 있다.


계층을 정당화한 전제가 틀렸습니다

적어놓고 보니 이 문장이 틀렸습니다. 비밀번호를 클라이언트에 두느냐 마느냐는 검증 시점과 아무 관계가 없습니다. 매 요청에 비밀번호를 실어 보내고 서버가 그때마다 현재 저장된 해시와 대조하면, 비밀번호가 바뀐 다음 요청부터 바로 막힙니다.

제가 뒤섞은 것은 저장과 검증이었습니다. 브라우저에 값이 남아 있다는 사실과, 그 값이 지금도 유효한지 누가 언제 판단하느냐는 별개입니다. 판단을 서버가 매 요청 시점에 하면 저장된 값이 낡았을 때 그냥 실패합니다.

오히려 토큰 쪽이 이 문제에 약합니다. 발급 시점에 한 번 검증하고 만료까지 그 판단을 재사용하니, 비밀번호를 바꿔도 이미 나간 토큰은 유효기간 동안 살아 있습니다. 막으려던 상황을 계층 자체가 만들고 있던 셈입니다.

계층을 세운 근거가 계층을 세우지 말아야 할 이유를 가리키고 있었습니다.

144-1.jpg


남은 이점이 원래 문제와 무관했습니다

전제가 무너지자 토큰 계층에 남은 이점은 하나였습니다. 브라우저 저장소에 비밀번호 평문이 남지 않는다는 것입니다. 그 자체로는 맞는 말입니다. 그래서 한 번 더 물었습니다. 이번에 막으려던 것이 그것이었나.

아니었습니다. 이 작업의 출발점은 링크가 우연히 새는 상황이었습니다. 메신저로 전달되고, 화면이 캡처되고, 브라우저 히스토리에 남는 경로입니다. 링크를 받은 사람의 브라우저 안쪽은 애초에 위협 목록에 없었습니다. 그 사람은 이미 문서를 볼 자격이 있는 사람입니다.

주소 자체가 열쇠일 때 무엇이 문제였는지는 같은 작업의 앞 단계에 정리해뒀습니다.

남는 이점이 원래 문제와 무관하면 그것은 계층을 지킬 근거가 되지 않습니다. 이 자리에서 그래도 이런 장점이 있다로 버티기 시작하면 설계는 계속 부풉니다. 그래서 토큰 발급 계층을 통째로 삭제했습니다.


시도 제한도 같이 걷어냈습니다

같은 자리에서 시도 제한도 뺐습니다. 비밀번호를 붙이면 무차별 대입을 막는다는 것이 저의 기본 반응이었는데, 이번 구조에서는 순서가 달랐습니다. 비밀번호를 시도하려면 43자짜리 토큰이 박힌 주소를 먼저 알아야 합니다. 주소를 모르면 시도 자체가 시작되지 않습니다.

그리고 주소를 알아냈다면 그것은 이미 링크가 샌 상황입니다. 실제 위협은 대입이 아니라 유출이었고, 시도 제한은 유출을 막지 못합니다. 대신 비밀번호 최소 길이 규칙을 뒀습니다. 넣으려던 장치 중 실제 위협과 이어지는 것만 남긴 셈입니다.

장치

막는다고 본 것

실제 위협과의 관계

판단

토큰 발급 계층

비밀번호 변경 후에도 계속 열람

매 요청 대조로 이미 해결됨

삭제

브라우저에 평문 미저장

로컬 저장소 노출

위협 목록 밖

근거 아님

시도 제한

무차별 대입

주소를 먼저 알아야 시도 가능

삭제

비밀번호 최소 길이

짧은 비밀번호

주소가 샌 뒤 남는 마지막 방어

유지

144-2.jpg


같은 날, 반대 방향으로 판단한 일

구현에 들어가고 나서 테스트 두 개가 깨졌습니다. 만료를 두지 않는다를 못박아둔 테스트였습니다. 이번 작업에서 문서별로 만료를 설정할 수 있게 하면서 그 테스트가 걸렸습니다.

지우면 몇 분이면 끝나는 일이었습니다. 지우지 않은 이유는 그 테스트가 며칠 전 실제 사고로 세워진 것이었기 때문입니다. 공유 링크 하나가 조회수 18회와 31회로 한창 읽히던 중에 만료로 죽어 있었습니다. 열리지 않는다는 연락을 받고서야 알았습니다.

사고로 세운 테스트에는 지식이 압축돼 있습니다. 다만 문장만 남고 이유는 사라지기 때문에, 지금 요구와 충돌할 때는 지우는 쪽이 늘 빠릅니다. 그래서 지우기 전에 두 가지를 갈랐습니다. 그때 죽인 것과 지금 넣는 것이 같은 것인가, 그리고 그 사고가 이 형태에서 재현되는가.

그때 죽인 것은 자동으로 붙는 기본 만료였습니다. 아무도 설정하지 않았는데 링크가 조용히 죽는 구조였습니다. 이번에 넣은 것은 선택적 만료입니다. 기본값은 무제한이고 필요할 때만 사람이 날짜를 지정합니다. 같은 사고가 재현되는 형태가 아니었습니다.

그래서 테스트를 삭제하는 대신 불변식을 다시 썼습니다. 앞 문장은 구현을 못박은 것이고 뒤 문장은 의도를 못박은 것입니다. 요구가 자라면 앞 문장은 금방 낡지만 뒤 문장은 그대로 삽니다.

before  만료 필드가 없다            → 구현을 못박은 문장
after   만료의 기본값이 없음이다   → 의도를 못박은 문장

144-3.jpg


두 판단을 가른 기준은 하나였습니다

설계는 지웠고 테스트는 살렸습니다. 방향은 반대인데 물어본 것은 같았습니다. 이것이 지금 무엇을 막고 있는지 확인했는가.

계층을 정당화한 문장을 한 줄로 적습니다. 적히지 않으면 근거가 없는 것입니다.

그 문장이 지금도 사실인지 검증합니다. 계층의 일반적인 장점이 아니라 그 한 문장만 봅니다.

전제가 무너진 뒤 남는 이점을 셉니다. 원래 문제와 무관한 이점은 근거로 세지 않습니다.

깨진 테스트는 왜 세워졌는지부터 읽습니다. 사고에서 나온 것이면 그 사고가 재현되는 형태인지 먼저 가릅니다.

요구가 달라진 것이면 삭제가 아니라 재정의입니다. 구현을 적은 문장을 의도를 적은 문장으로 바꿉니다.


설계 리뷰에서 물어볼 것들

이 계층이 없으면 무엇이 깨지는지 한 문장으로 답할 수 있는가

그 문장이 지금 시스템에서도 사실인지 직접 확인했는가

남은 이점이 처음에 풀려던 문제와 이어지는가

테스트를 고치는 이유가 요구가 달라져서인가, 통과시키려는 것인가

사고로 세워진 규칙이 어느 것인지 팀이 구분할 수 있는가


마무리

설계는 한 층 줄었고 기능은 그대로입니다. 만료 관련 불변식은 전보다 정확한 문장으로 남았습니다. 들인 시간은 질문 하나와 근거를 종이에 적어본 10분 남짓입니다.

전제가 틀렸다는 것을 구현 전에 알아챈 것은 운이 좋은 쪽이었습니다. 이미 만든 뒤였다면 들인 시간 때문에 같은 판단을 못 했을 가능성이 큽니다. 이렇게까지 복잡할 필요 있나는 좋은 질문이지만 그 자체가 답은 아닙니다. 정말 필요한 복잡성까지 깎을 수 있어서, 질문은 계기로 두고 판단은 전제를 다시 읽는 데서 냅니다.

묘비처럼 세워둔 테스트도 무한정 쌓이면 부채입니다. 재정의로 살릴지 정말 수명이 끝났는지는 매번 사람이 봐야 하고, 그 비용은 줄지 않습니다. 다만 지우기 전에 왜 세웠는지 한 번 읽는 비용은 몇 분이었습니다.

#설계철회#틀린전제#묘비테스트#불변식#과설계