RBAC 를 코드에 박지 않는 이유
결제·정산 시스템에서 `if (user.role === 'ADMIN')` 패턴이 왜 빠르게 위험해지는지, HEDVION 팀이 권한을 데이터로 관리하며 얻은 구체적 교훈과 트레이드오프를 정리한다.
결제·정산 시스템에서 권한 실수가 유독 무거운 이유
대부분의 웹 서비스에서 권한 버그는 UX 불편으로 끝난다. 결제·정산 시스템에서는 결과가 다르다. 잘못된 권한 설정 하나가 실제 돈이 오가는 트랜잭션에 무단 접근하거나, 파트너사 정산 데이터를 외부에 노출하거나, 환불 API를 의도하지 않은 역할이 호출하는 결과로 이어진다. 규제 측면에서도 마찬가지다. 전자금융업 관련 내부 통제 요건은 "누가 어떤 데이터에 접근할 수 있는가"를 명확히 추적 가능하도록 요구한다. 권한이 코드에 흩어져 있으면 감사 시점에 이걸 증명하는 것 자체가 별도의 고통이 된다.
HEDVION 팀이 처음 서비스를 만들 때 if (user.role === 'ADMIN') 패턴으로 시작한 건 당연한 선택이었다. 역할이 ADMIN과 VIEWER 두 개뿐이었고, 빠르게 돌아가는 게 먼저였다. 문제는 서비스가 성장하면서 역할이 하나씩 추가될 때마다 드러났다. PG사 정산 데이터를 검토하는 "정산 담당자", 특정 가맹점 거래 내역만 볼 수 있는 "파트너 어드민", 자동화 배치를 실행하는 "시스템 봇" — 역할이 늘어날 때마다 코드베이스 전체를 grep으로 뒤지는 작업이 반복됐다.
if (user.role === 'ADMIN')이 퍼지는 속도
우리가 겪은 가장 현실적인 문제는 역할 체크가 얼마나 빠르게 흩어지는가였다. 초기 코드베이스에서 grep -r "user.role"을 돌려보니 API 컨트롤러 12곳, 미들웨어 3곳, 배치 스크립트 5곳, 프론트엔드 렌더링 조건 8곳, 총 28곳이 나왔다. 역할이 2개일 때는 관리 가능했다. 역할이 5개가 되고 역할별 예외 케이스가 붙기 시작하면, 변경 하나마다 28곳 이상을 찾아 일관되게 수정해야 한다. 한 군데라도 빠지면 권한이 열리거나 막히고, 이건 보통 배포 후에야 발견된다.
결제 팀 특유의 맥락이 있다. 정산 데이터는 단순한 읽기(READ)/쓰기(WRITE)가 아니라 "내보내기(EXPORT)", "환불 실행(REFUND_EXECUTE)", "정산 마감(CLOSE_PERIOD)" 같은 세밀한 액션 단위로 제어해야 한다. if (user.role === 'ADMIN')으로는 이 세분화를 표현하기가 점점 어려워지고, 결국 SUPER_ADMIN을 만들거나 canExport 같은 Boolean 플래그를 유저 테이블에 직접 붙이는 임시방편이 생긴다. 이 순간부터 권한 모델이 코드와 DB 양쪽에 반쪽씩 흩어지는 최악의 상태가 된다.
권한을 데이터로 관리한다는 것: 구조와 실제 사례
우리가 선택한 구조는 의도적으로 단순하다. roles 테이블에 역할을 정의하고, permissions 테이블에 (role_id, resource, action) 조합과 허용 여부를 저장한다. 서비스 레이어는 hasPermission(userId, resource, action) 단 하나의 함수만 호출한다. 역할이 새로 생기거나 기존 역할의 권한 범위가 바뀌어도 코드 배포 없이 DB 레코드 변경만으로 반영된다.
이 구조가 실제로 효과를 발휘한 순간이 있었다. 새 파트너사 온보딩 때, 파트너 어드민이 자신의 가맹점 정산 내역은 조회하되 전체 정산 보고서는 못 보고, 환불 실행은 50만 원 이하만 가능하도록 요구했다. 코드에 박힌 모델이었다면 새 역할 PARTNER_ADMIN을 코드 전체에 추가해야 했다. 데이터 기반 모델에서는 permissions 테이블에 두 레코드를 insert하고, 금액 조건 체크 로직 한 줄을 미들웨어에 추가하는 것으로 끝났다. 코드 배포는 없었고, QA 시간도 대폭 줄었다.
코드에 남겨야 하는 것과 빼야 하는 것
모든 것을 DB로 빼는 게 맞는 건 아니다. 인증(Authentication) 자체—토큰 유효성 검증, 세션 만료—는 코드 미들웨어에서 다룬다. 이건 권한(Authorization)과 레이어가 다른 문제다. 혼용하면 두 레이어가 서로의 책임을 침범하기 시작한다.
더 중요한 구분이 있다. "다른 가맹점의 정산 데이터에는 절대 접근 불가", "본인 계정 비밀번호 변경은 본인만 가능"처럼 어떤 역할이더라도 절대 뚫려서는 안 되는 불변 규칙은 코드에 명시적으로 남긴다. 이걸 DB에서 관리하면 실수로 레코드를 삭제하거나 잘못 수정했을 때 보안 구멍이 뚫린다. "유연한 권한 관리"가 "보안 홀 양산기"가 되는 역전이 여기서 일어난다. 유연성이 필요한 비즈니스 규칙은 데이터로, 시스템 불변 조건은 코드로—이 선을 명확히 긋는 것이 설계의 핵심이다.
실제 운영에서 부딪힌 세 가지 트레이드오프
성능: 매 요청마다 DB를 조회하면 지연이 생긴다. 우리는 권한 테이블을 Redis 캐시에 올리고 TTL을 60초로 설정했다. 실측 결과, 캐시 미스 시 PostgreSQL 쿼리 응답 시간은 평균 3~5ms, 캐시 히트 시 0.3ms 이하였다. 초당 수백 건의 결제 요청이 들어오는 상황에서도 권한 체크 자체가 병목이 된 적은 없었다. 단, 역할 변경 후 최대 60초간 구 권한이 유지된다는 점은 운영 정책으로 명확히 공지해야 한다. 즉각 반영이 필요한 긴급 권한 박탈이라면 캐시 강제 무효화 API를 별도로 만들어두는 것이 안전하다.
감사 로그: 권한 변경 이력이 없으면 사고 추적이 불가능하다. 우리는 permission_change_log 테이블에 변경자 ID, 변경 시각, 변경 전/후 값을 모두 기록한다. 실제로 파트너사 담당자가 "환불이 왜 안 되냐"고 문의했을 때, 로그를 보니 3일 전 다른 관리자가 해당 역할의 REFUND_EXECUTE 권한을 잘못 제거한 것이 원인이었다. 로그가 없었다면 원인 파악에 훨씬 오래 걸렸을 것이고, 파트너사와의 신뢰 문제로 번졌을 수 있다.
테스트 복잡도: 코드에 박혀 있으면 유닛 테스트가 단순하다—역할 문자열을 mock에 넣으면 된다. 데이터 기반 모델에서는 테스트 환경에서도 권한 픽스처 데이터를 세팅해야 하고, 통합 테스트마다 DB 상태 관리가 필요하다. 우리는 permissionFixtures.ts로 표준 권한 세트를 정의하고 각 통합 테스트 시작 시 주입하는 방식으로 불편함을 줄였다. 초기 셋업 비용 약 이틀은 이후 역할 변경마다 아끼는 디버깅 시간으로 충분히 회수됐다.
작은 팀에서 지금 당장 쓸 수 있는 실행 시사점
현재 코드베이스에서 grep -r "user.role\|user.is_admin\|req.user.type" 을 돌려 역할 체크가 몇 곳에 흩어져 있는지부터 세어라. 10곳 이상이면 이미 리팩토링 시점이 지났다. 마이그레이션은 한 번에 전부 할 필요 없다—신규 역할이 추가되는 시점에 맞춰 해당 리소스부터 hasPermission() 패턴으로 전환하고, 기존 체크는 점진적으로 교체한다.
구현 순서는 이렇게 권장한다. ① roles, permissions, permission_change_log 세 테이블을 먼저 만든다. ② hasPermission(userId, resource, action) 함수를 캐시 레이어 포함해 구현한다. ③ 불변 규칙(절대 DB로 빼면 안 되는 것)을 팀 문서에 명시적으로 정의한다. ④ 테스트 픽스처를 표준화한다. 엔터프라이즈 RBAC 프레임워크(Casbin 등)나 ABAC까지 갈 필요는 없다—(role, resource, action) → allow/deny 플랫 테이블만으로 웬만한 요구사항은 커버된다. 결제·정산 시스템은 외부 파트너, 감사 기관, 규제 요건에 따라 역할 구조가 예상보다 빠르게 바뀐다. 그 변화를 코드 배포 없이 DB에서 흡수할 수 있는 구조를 처음부터 잡아두는 것—이게 나중에 가장 값싼 선택이었다는 것을 직접 경험으로 확인했다.
— by slecs
* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.