홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

메뉴를 감췄으니 권한이라고 믿었습니다 — 검사는 처음부터 없었습니다

정기창·2026년 8월 25일

관리자 페이지에는 도메인별로 메뉴가 나뉘어 있고, 계정마다 볼 수 있는 메뉴가 다릅니다. 저는 그것을 권한이라고 불러왔습니다. 그런데 백엔드를 열어보니 그 메뉴 뒤의 API에는 검사가 아예 없었습니다. 감춰져 있었을 뿐입니다.

구조가 fail-open이었습니다

문제의 컨트롤러는 클래스 레벨에 역할 데코레이터 하나만 걸려 있었습니다.

@Roles('admin')          // 클래스 전체에 걸린 유일한 검사
@Controller('admin')
export class AdminController {
  @Roles('super_admin')  // 붙인 것만 좁혀진다
  @Delete('users/:id')
  removeUser() { /* ... */ }

  @Get('saas-users')     // 안 붙이면 admin 전원에게 열린다
  listSaasUsers() { /* ... */ }
}

메서드에 더 좁은 역할을 붙인 엔드포인트만 좁아지고, 안 붙인 엔드포인트는 자동으로 admin 전체에게 열립니다. 그러니까 이 구조에서 데코레이터를 빠뜨리면 에러가 나는 게 아니라 조용히 개방됩니다. 실수의 방향이 위험한 쪽으로 정렬되어 있었습니다.

실제로 무엇이 열려 있었냐면, 광고 도메인 권한만 가진 관리자 계정으로 다른 서비스의 사용자 목록을 조회하고 삭제까지 할 수 있었습니다. 화면에는 그 메뉴가 안 보이지만, API는 주소만 알면 그대로 응답합니다.

메뉴 숨김은 표시이지 인가가 아닙니다

돌이켜 생각해보면 이 착각이 생긴 경로가 분명합니다. 메뉴 권한이라는 개념을 프론트에 먼저 만들었고, 그걸 만들 때 이미 "권한"이라는 단어를 썼습니다. 이후로는 그 단어가 백엔드에도 있는 것처럼 느껴졌습니다. 이름을 먼저 지으면 구현이 있는 것처럼 착각하기 쉽다는 생각이 들었습니다.

프론트의 메뉴 숨김은 사용자 경험입니다. 볼 필요 없는 것을 안 보여주는 일이고, 그 자체로는 옳습니다. 다만 그것이 막아주는 대상은 실수로 잘못된 화면에 들어가는 사용자이지, 주소를 직접 부르는 요청이 아닙니다.

고치는 김에 만난 함정 하나

권한 가드를 붙이면서 전역 가드로 한 번에 걸까 잠깐 고민했습니다. 컨트롤러마다 붙이는 것보다 빠뜨릴 일이 없어 보였기 때문입니다. 그런데 이 경우에는 쓸 수 없었습니다.

전역 가드는 컨트롤러에 붙인 가드보다 먼저 실행됩니다. 그래서 인증 가드보다 앞서 돌고, 그 시점에는 req.user가 아직 채워지지 않았습니다. 누구인지 모르는 상태에서 이 사람이 무엇을 할 수 있는지 물어보는 셈이라 성립하지 않습니다. 결국 컨트롤러마다 인증 가드와 권한 가드를 순서대로 붙였습니다.

화면이 아예 없는 API 전용 컨트롤러는 반대로 잠갔습니다. 클래스 레벨에 시스템 권한을 요구하게 두면, 그 권한을 가진 계정이 현재 없으므로 사실상 최고 관리자 전용이 됩니다. 빠뜨리면 닫히는 쪽으로 기본값을 뒤집은 것입니다.

검사를 켜는 순간 기존 호출이 403이 됩니다

이번 작업에서 가장 오래 붙잡고 있었던 것은 코드가 아니라 이 문제였습니다. 지금까지 통과하던 요청들은 권한이 있어서 통과한 게 아니라 검사가 없어서 통과했습니다. 검사를 붙이는 순간, 권한 맵이 비어 있는 기존 계정들은 전부 403을 받습니다.

그래서 코드 변경과 함께 마이그레이션을 하나 넣었습니다. 지금 실질적으로 행사되고 있던 접근 범위를 권한 맵에 먼저 기록하는 작업입니다. 검사를 켜기 전에 현재 상태를 원장에 옮겨두는 것이죠.

여기서 불편한 질문이 하나 생깁니다. 실태를 그대로 옮기면 부당했던 접근까지 정당화되는 것 아닌가. 맞습니다. 그래서 이 마이그레이션은 종착점이 아니라 기준선입니다. 옮긴 다음에 줄이는 것은 별도의 결정이고, 옮기지 않으면 그 결정을 할 기회 자체가 없어집니다. 운영이 멈추면 사람은 판단하지 않고 롤백하기 때문입니다.

같은 실수가 반복되지 않게

구멍을 메우는 것보다 중요한 것은 다음번에 같은 자리가 다시 열리지 않게 하는 일이었습니다. 이번 구조에서 근본 문제는 "데코레이터를 안 붙였다"가 아무 신호도 만들지 않는다는 점입니다. 그래서 새 엔드포인트가 권한 선언 없이 추가되면 드러나도록 불변식을 걸어두었습니다.

정리하면 이렇습니다.

구분fail-openfail-closed
데코레이터를 빠뜨리면모두에게 열린다아무도 못 쓴다
실수가 드러나는 시점사고가 난 뒤기능을 쓰려는 즉시
비용0 (조용함)개발 중 한 번 막힘

fail-closed가 불편한 것은 사실입니다. 새 엔드포인트를 만들 때마다 한 번씩 막히니까요. 다만 그 불편은 개발 중에 몇 분이고, 반대쪽의 비용은 사고가 나야 청구됩니다.

남는 것

보안 점검을 할 때 저는 주로 "무엇을 막고 있나"를 봤습니다. 이번에 배운 것은 질문을 하나 더 해야 한다는 점입니다. 빠뜨렸을 때 무슨 일이 일어나는가. 그 답이 "열린다"면 지금 잘 막고 있다는 사실은 운에 가깝습니다. 지금까지 붙여둔 데코레이터가 우연히 다 맞았을 뿐이니까요.

혹시 비슷한 관리자 페이지를 운영하고 계시다면, 화면에 안 보이는 메뉴의 API 주소를 하나 골라 권한이 낮은 계정의 토큰으로 직접 호출해보시길 권합니다. 확인은 1분이면 끝나고, 결과가 200이면 그 메뉴는 감춰져 있을 뿐입니다.

NestJS인가보안OWASP권한 관리백엔드

관련 글

프론트 컨테이너가 DB 비밀번호를 들고 있었습니다 — compose 리소스의 env 경계

배포 플랫폼이 compose 리소스의 환경변수를 그 안 모든 서비스 컨테이너에 주입하고 있었습니다. mysql 드라이버가 0개인 프론트가 DB 비밀번호와 토큰 키를 통째로 들고 있었고, 범위를 좁힐 방법은 하나뿐이었습니다.

관련도 92%

주석이 거짓말을 하고 있었습니다 — 이스케이프가 실제로 막는 범위

경로를 감싸는 함수가 콜론을 이스케이프하고 있었습니다. 그래서 윈도우 경로를 막아준다고 주석에 적혀 있었는데, 재보니 콜론만 여전히 실패했습니다. 이스케이프하는 것과 지켜주는 것은 다른 말이었습니다.

관련도 92%

삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다

서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.

관련도 92%