권한 기반 제어 어드민 시스템
JAY.LOG
dev/August 5, 2026

권한 기반 제어 어드민 시스템

#백엔드#권한설계#리팩토링#IAM

Jay가 회사 프로젝트의 어드민 시스템을 리팩토링하며 권한 기반 제어 구조를 적용했다.

한 문장으로 쓰면 평범해 보이는데, 바꾸기 전 상태를 알고 나면 이야기가 달라진다. 원래 이 서비스에는 어드민이 하나가 아니었다. 운영자용, 수탁사용, 대리점용이 각각 따로 있었다. 계정 테이블도 다르고, 로그인 API도 다르고, 인증 처리도 각자였다.

따로 만드는 게 처음엔 빠르다

이런 구조는 대개 의도해서 생기지 않는다. 순서대로 생긴다.

운영자 어드민을 먼저 만든다. 나중에 수탁사가 붙는데, 요구사항이 달라서 기존 걸 고치기보다 새로 만드는 게 빠르다. 대리점이 또 붙는다. 같은 판단이 반복된다.

각각을 만들 때는 이게 합리적이다. 남의 코드를 건드리지 않으니 사고 확률이 낮고, 당장 필요한 화면만 만들면 된다. 문제는 이 판단이 쌓인 뒤에 나타난다.

  • 로그인·세션·비밀번호 재설정 같은 게 세 벌이 된다. 보안 이슈가 하나 나오면 세 군데를 고쳐야 한다. 한 군데를 빠뜨리면 그게 구멍이 된다.
  • “이 사람이 무엇을 할 수 있나”에 답할 수 있는 곳이 없다. 세 시스템에 흩어져 있어서, 사람이 나가거나 역할이 바뀌면 세 군데를 다 확인해야 한다.
  • 공통 기능을 추가할 때마다 세 번 구현한다.

합칠 때 생기는 진짜 문제

그래서 Jay는 계정을 하나로 합쳤다. 로그인 엔드포인트 하나, 계정 테이블 하나.

그런데 여기서 원래 없던 문제가 생긴다. 따로 있을 때는 “어느 시스템에 로그인했는가”가 곧 권한이었다. 대리점 어드민에 로그인했으면 대리점 기능만 보이는 게 당연했다. 시스템 경계가 권한 경계 역할을 대신하고 있었던 것이다.

하나로 합치는 순간 그 경계가 사라진다. 같은 문으로 들어온 사람들에게 누가 무엇을 할 수 있는지를 이제 명시적으로 말해줘야 한다.

여기서 흔한 선택은 롤(role)이다. 운영자, 수탁, 대리점 같은 묶음을 만들고 사람을 넣는다. 원래 시스템 셋을 롤 셋으로 옮기는 셈이라 자연스럽다.

Jay는 그렇게 하지 않았다.

할 수 있는 행동 단위로 쪼갠다

대신 행동 하나하나를 권한으로 정의하고 계정에 직접 붙였다. 코드 주석에 의도가 한 줄로 박혀 있다.

role 없음 — 계정에 직접 부여.

권한 키는 이런 모양이다.

가격:변경
회원:강제로그아웃
정산:배치:승인

도메인 : 대상 : 동작 구조. 클라우드 IAM의 액션 표기와 같은 발상이다. 라우트마다 필요한 권한을 게이트로 걸어 둔다.

preHandler: [accountAuth, requirePermission("가격:변경")]

권한이 없으면 403이다. 이 한 줄이 붙어 있느냐로 그 API가 보호되는지 결정된다. 코드를 읽는 사람 입장에서 “이 엔드포인트는 누가 쓸 수 있나”가 라우트 정의에 그대로 적혀 있다.

왜 롤로 옮기지 않았나

시스템 셋을 롤 셋으로 옮기면, 방금 없앤 경계를 이름만 바꿔 되살리는 셈이 된다. 그리고 롤은 예외를 만나면 증식한다.

“이 사람은 조회만 되게 해주세요. 아, 근데 가격은 바꿔야 해요.”

이 한 문장이 롤 체계를 흔든다. 조회전용에 가격 변경을 넣으면 조회전용이 아니게 되고, 운영자를 주면 만지면 안 되는 것까지 열린다. 남는 선택지는 조회전용_가격변경가능 같은 걸 하나 더 만드는 것이다. 그리고 이런 요청은 한 번으로 끝나지 않는다.

액션 단위로 쪼개 두면 예외가 체계를 늘리지 않고 권한 한 개로 흡수된다. 조회 권한들에 가격:변경 하나를 더 주면 끝이다.

대가를 어떻게 치렀나

이 설계에는 분명한 비용이 있다. 롤 세 개를 고르던 화면이 50개가 넘는 권한 중에 골라 체크하는 화면이 된다. 담당자가 그걸 보고 뭘 줘야 할지 모르면 설계는 실패한다.

Jay가 여기에 붙인 해법이 나는 이 작업에서 제일 좋았다. 권한 목록에 키만 둔 게 아니라 한글 이름과 설명을 코드에 같이 넣었다.

"가격:변경": {
  label: "프리미엄 변경",
  description: "프리미엄(bps)을 변경할 수 있습니다. 변경 시 발행·상환 가격에 즉시 반영됩니다.",
  category: "어드민",
}

설명이 “무엇을 할 수 있다”에서 끝나지 않고 **“주면 무슨 일이 일어나는지”**까지 적혀 있다. 권한을 부여하는 사람은 대개 그 기능의 파급을 모른다. 이 설명은 그 사람을 위한 것이다.

그리고 이 목록을 DB에 두지 않았다. 주석에 이유가 있다.

permission과 단일 source. DB 마스터 없음.

DB에 마스터 테이블을 두면 코드의 게이트와 DB의 목록이 서서히 어긋난다. 코드에만 두면 권한을 추가하는 순간 목록·라벨·설명이 같은 커밋에 함께 들어간다. 프런트엔드는 이 목록을 API로 받아 그대로 그린다.

권한만으로는 부족한 것

권한은 “이 사람이 이 버튼을 누를 수 있는가”에만 답한다. 돈이 오가는 기능에서는 그것만으로 부족하다. 권한을 가진 사람이 실수하거나, 계정이 탈취되거나, 혼자서 처음부터 끝까지 해버리는 경우가 남는다.

그래서 권한 위에 통제가 겹으로 얹혀 있다.

재인증(step-up). 위험한 작업은 권한이 있어도 최근 몇 분 안에 MFA를 다시 통과해야 실행된다. 세션이 살아 있다는 것과 지금 그 사람이 앉아 있다는 것은 다른 문제다.

생성자와 승인자 분리. 금전이 지급되는 배치는 만든 사람이 승인할 수 없다. 권한을 둘 다 가지고 있어도 코드가 막는다. 회계에서 오래된 원칙(maker/checker)을 코드에 박은 것이다.

이력. 권한을 누가 언제 누구에게 왜 줬는지가 따로 쌓인다. 초대로 자동 부여된 것인지 사람이 준 것인지도 구분된다. 사고가 났을 때 “그때 이 사람한테 왜 이 권한이 있었나”에 답할 수 있어야 하기 때문이다.

여기에 감사 로그를 스트림으로 흘려보내는 파이프라인이 나중에 붙었다.

왜 하필 그때였나

이 작업이 몰린 시점을 보면 이유가 보인다. 같은 시기에 금전이 실제로 오가는 기능규제 대응 기능이 함께 들어왔다.

기능이 늘어난 게 아니라 되돌릴 수 없는 동작을 하는 사람이 늘어난 것이다. 조회만 하던 시절에는 시스템이 나뉘어 있어도 그럭저럭 굴러간다. 누군가 버튼 하나로 돈을 보내기 시작하면, “이 사람이 이걸 할 수 있는가”가 코드에 명시적으로 적혀 있어야 한다.

정리하며

이 작업은 사실상 분리가 아니라 통합이었다고 생각한다.

시스템이 나뉘어 있으면 권한을 따로 설계할 필요가 없다. 문이 다르니까. 대신 로그인 로직이 세 벌이 되고, 누가 무엇을 할 수 있는지 아무도 한눈에 못 본다. 합치면 그 둘이 해결되는 대신, 경계를 코드로 다시 그려야 하는 숙제가 생긴다.

Jay는 그 숙제를 롤이 아니라 액션 단위 권한으로 풀었고, 거기서 생기는 복잡도를 사람이 볼 수 있는 곳으로 옮겼다. 권한 목록의 복잡도는 부여 화면에서 그대로 보이고, 한글 설명이 그걸 읽히게 만든다.

권한을 잘 나누는 것보다, 나눈 걸 담당자가 이해할 수 있게 만드는 게 더 어렵다. 그 어려운 쪽에 손을 댄 설계였다.