인증은 걸어놨는데, 새로 만든 API 주소만 로그인 없이 열려 있었습니다
어떤 외식 프랜차이즈 ERP SaaS의 매출 분석 화면에 채널별 매출 분해 기능을 붙이는 작업이었습니다. 배포를 마치고 인증 설정을 점검하던 중, 방금 만든 API가 로그인 없이 그대로 호출되고 있다는 사실을 확인했습니다. 고객사의 매출을 다루는 화면이었고, 그 뒤의 API는 인증 경계 바깥에 놓여 있었습니다.
로그인 없이 열려 있던 내부 API 하나는 그 자체로 고객 데이터 유출 리스크입니다. B2B SaaS에서 이런 경로가 하나 있다는 것은 계약과 규제 관점에서도 그냥 넘어갈 수 있는 사건이 아닙니다. 문제는 이게 실수라기보다 설정의 구조에서 예정된 결과였다는 점입니다.
기존 것은 건드리지 말자는 원칙이 만든 사각지대
이 작업에는 기존 API는 손대지 않는다는 원칙이 있었습니다. 그래서 매출 분해 기능은 기존 경로를 수정하는 대신 신규 /v2 API로 새로 만들었습니다. 안정성 측면에서는 합리적인 선택이었습니다. 이미 돌아가는 것을 흔들지 않으면서 새 기능만 별도 프리픽스로 분리하는 방식입니다.
배포 후 인증 설정을 열어 보니 인증 인터셉터가 등록된 경로가 /v1/api/** 한 패턴뿐이었습니다. 새로 만든 /v2/api/sales/**는 그 매칭 범위 밖이었습니다. 인증 인터셉터는 자기 패턴에 걸리는 요청만 검사하고, 걸리지 않는 요청은 그냥 통과시킵니다. 결과적으로 /v2/api/sales/**는 아무 인증 없이 응답을 내주고 있었습니다.
코드에 버그가 있었던 것이 아닙니다. 인증 인터셉터는 시킨 대로 정확히 /v1/api/**만 지키고 있었습니다. 새 경로가 그 밖에 생겼을 뿐입니다.

패턴은 화이트리스트가 아니라 블랙스팟을 만든다
경로 패턴으로 인증 범위를 정하면 그 패턴은 보호 목록처럼 보이지만 실제로는 그 반대로 동작합니다. 패턴에 걸리는 곳만 검사하고 나머지는 전부 무방비로 열어 둡니다. /v1/api/**를 지정하는 순간, 인증의 기본값은 '이 패턴에 맞는 것만 인증하고 나머지는 통과'가 됩니다.
건물에 비유하면 이렇습니다. 경비원에게 1층 로비만 지키라고 배치해 뒀는데, 나중에 2층에 새 출입문을 하나 뚫은 상황입니다. 경비원은 여전히 성실하게 1층을 지키고 있고 규정도 어기지 않았습니다. 다만 2층 문은 애초에 그의 관할이 아니었을 뿐입니다. 인증 인터셉터의 패턴 매칭이 정확히 이 경비원의 관할 범위입니다. 새 문을 뚫을 때마다 누군가 그 문을 관할에 추가하지 않으면 그 문은 그냥 열려 있습니다.

지혈을 먼저, 결정은 나중에
발견 즉시 인증 설정(WebMvcConfig 류)에 /v2/api/sales/**를 인증 대상으로 추가해 터진 곳을 막았습니다. 여기서 바로 갈린 선택지가 하나 있었습니다. 얼마나 넓게 막을 것인가입니다.
옵션 | 장점 | 위험 |
|---|---|---|
A. 좁게 (/v2/api/sales/**만) | 명시적이고 예외 관리가 쉬움 | 새 프리픽스가 생길 때마다 또 빠뜨릴 위험 반복 |
B. 넓게 (/v2/api/** 통째로) | 앞으로의 v2 경로가 자동으로 보호됨 | 헬스체크·웹훅처럼 의도적으로 공개해야 할 경로까지 막힐 위험 |
넓게 막으면 미래의 실수를 원천 차단할 수 있지만, 대신 공개해야 할 경로를 실수로 막아 가용성을 해칠 수 있습니다. 이 '얼마나 강하게 막을 것인가'는 취향이 아니라 위협 모델로 정해야 하는 문제입니다. 무엇을 지키려는지, 잘못 막혔을 때 비용이 얼마인지를 두고 강도를 조정한 사례는 아래 회고에 정리해 두었습니다.
이번에는 터진 곳인 sales만 좁게 막고, 넓은 패턴으로 갈지는 별도 결정으로 분리했습니다. 사고 대응과 구조 개선을 한 번에 섞으면 급하게 넓은 패턴을 걸었다가 공개 경로를 막는 2차 사고가 나기 쉽습니다. 우선 출혈을 멈추고, 경계를 다시 그리는 일은 충분히 살펴본 뒤에 하기로 했습니다.
버그 하나는 같은 버그 N개의 신호였다
한 곳을 찾자마자 같은 사유로 뚫린 곳이 더 있을 것이라 보고 전수조사를 했습니다. 예상대로였습니다. 이전에 만들어져 있던 다른 v2 컨트롤러인 /v2/api/crawling/**도 같은 이유로 인증 밖에 있었습니다. 처음 발견한 sales 하나만 막고 끝냈다면 crawling은 그대로 열린 채 남았을 것입니다.
특정 경로가 인증 밖에 있었다는 것은 개별 실수가 아니라 인증을 경로 패턴으로 스코프한 구조의 산물입니다. 원인이 구조에 있으면 같은 원인을 공유하는 경로는 하나가 아니라 여럿입니다. 그래서 이런 종류의 문제는 발견한 한 곳을 고치는 것으로 끝나지 않고, 같은 패턴을 전수로 훑는 것까지가 한 세트입니다.

정답은 기본값을 뒤집는 것
이 문제의 뿌리는 인증의 기본값이 '특정 경로만 인증'이라는 데 있습니다. 이 기본값에서는 새 경로가 생길 때마다 누군가 인증 목록에 그 경로를 추가해야 하고, 한 번이라도 빠뜨리면 그 경로는 조용히 열립니다. 반대로 기본값을 deny-by-default, 즉 '전부 인증하고 공개할 것만 예외로 뺀다'로 두면 새 경로는 만들어지는 순간 자동으로 인증 안쪽에 들어옵니다. 무언가를 열려면 그때 의식적으로 예외에 등록해야 하므로, 실수의 방향이 '뚫림'에서 '막힘'으로 바뀝니다.
다만 deny-by-default가 언제나 정답인 것은 아닙니다. 공개 API가 많은 서비스라면 예외 목록을 관리하는 비용이 오히려 더 커집니다. 넓은 패턴으로 전부 막아 두면 헬스체크나 웹훅처럼 의도적으로 열어야 하는 경로가 함께 막혀 가용성 사고로 이어질 수 있습니다. 이 방식에서는 예외 목록의 정확성이 곧 서비스의 가용성이 됩니다. 그래서 '전부 막고 예외를 관리'하는 쪽으로 갈수록 그 예외 목록을 누가 어떻게 관리하는지가 함께 설계돼 있어야 합니다.
정리하면, 버전 프리픽스를 하나 추가하는 일은 단순히 URL을 늘리는 작업이 아니라 보안 경계를 새로 긋는 작업입니다. /v2를 만들었다면 인증 필터가 그 프리픽스를 아는지부터 확인해야 사고를 막습니다.
새 API 경로를 열기 전 점검 목록
인증의 기본값이 '전부 인증'인가, '특정 경로만 인증'인가부터 확인한다. 후자라면 새 경로는 기본이 무방비다.
새 버전 프리픽스(/v2 등)를 추가했다면, 인증 필터·인터셉터가 그 프리픽스를 매칭 범위에 포함하는지 배포 전에 검증한다.
인증 밖 경로를 하나 발견하면 그 하나만 고치지 말고, 같은 원인을 공유하는 경로를 전수로 훑는다.
사고 대응(지혈)과 구조 개선(넓게 막기)을 한 번에 섞지 않는다. 좁게 막아 멈추고, 경계 재설계는 위협 모델로 따로 결정한다.
넓게 막는 정책을 택했다면 공개 경로 예외 목록의 소유자와 갱신 절차를 함께 정한다. 이 목록의 정확성이 곧 가용성이다.
인증 인터셉터는 이번에도 시킨 일을 정확히 했습니다. 문제는 시킨 범위가 경로 패턴이었고, 그 패턴은 지킬 곳이 아니라 지키지 않을 곳을 정의하고 있었다는 데 있었습니다. 기본값을 '열림'에서 '닫힘'으로 옮겨 두면, 다음에 누군가 새 경로를 급하게 붙이더라도 실수는 데이터 유출이 아니라 한 번의 접근 차단으로 끝납니다.