Big Project · AI's EYE 22

Firebase 클레임 기반 다매장 권한 분리: 본사/점주

Firebase로 로그인만 성공하면 인증 문제가 해결된 것처럼 보인다. 같은 사용자가 어떤 매장 데이터와 본사 기능에 접근할 수 있는지는 왜 서버에서 다시 확인해야 하는지 모호하다.

근거 · 프로젝트 문서 · 코드 · 테스트

1장: 로그인 성공이 전부가 아니다: 인증과 인가의 분리

솔라의 손끝이 모니터 화면의 한 문장을 가리키고 있었다. 다매장 관리 시스템을 구축한 어느 개발팀의 회고록이었는데, 유독 그 한 문장이 솔라의 빠른 읽기 흐름에 제동을 걸었다.

“Firebase로 로그인만 성공하면 인증 문제가 해결된 것처럼 보인다. 같은 사용자가 어떤 매장 데이터와 본사 기능에 접근할 수 있는지는 왜 서버에서 다시 확인해야 하는지 모호하다.”

솔라는 이 문장이 꼭 자신의 마음속 의문을 그대로 옮겨 적은 것 같다고 생각했다. 분명히 프로젝트의 성공 사례를 정리한 글인데, 저자들 역시 처음에는 자신과 똑같은 지점에서 혼란을 느꼈다는 뜻이었다.

“언니, 나 이것 좀 봐봐.”

노트북 화면을 돌려 루나에게 보여주며 솔라가 말했다.

“Firebase로 로그인에 성공했다는 건, 우리 시스템이 사용자가 누구인지 알아봤다는 뜻이잖아. ‘아, 이 사람은 A 매장 점주 김솔라구나’ 하고. 그런데 왜 서버에서 또 확인을 한다는 걸까? 이미 신원 확인은 끝났는데.”

솔라의 말에는 당연한 것을 왜 복잡하게 만드냐는 듯한 억울함이 살짝 묻어났다. 마치 놀이공원 자유이용권을 끊고 입장했는데, 놀이기구를 탈 때마다 표를 다시 검사하는 기분이었다.

루나는 화면의 문장을 잠시 들여다본 뒤, 솔라에게서 노트북을 받아 옆에 내려놓았다. 대신 깨끗한 메모지와 펜을 가져왔다.

“그 질문, 아주 좋은 출발점이야. 우리만의 작은 놀이공원을 한번 만들어보자.”

루나는 메모지에 커다란 네모를 하나 그리고, 그 옆에 작은 네모 여러 개를 그렸다. 큰 네모에는 ‘정문 매표소’라고 썼고, 작은 네모들에는 각각 ‘자이로드롭’, ‘롤러코스터’, ‘회전목마’라고 적었다.

“솔라 네가 이 놀이공원에 왔어. 정문 매표소에 가서 신분증을 보여주고 본인 확인을 마친 뒤, ‘놀이공원 출입증’을 받았다고 해보자. 이게 바로 Firebase 로그인 성공이야. 시스템이 ‘응, 너는 우리 회원 맞아’ 하고 증표를 내준 거지.”

루나는 ‘정문 매표소’에서 화살표를 그려 솔라를 상징하는 동그라미 옆에 ‘출입증’이라는 작은 표식을 그려 넣었다.

“자, 이제 넌 이 출입증을 목에 걸고 놀이공원 안으로 들어왔어. 신나게 ‘롤러코스터’ 앞으로 달려갔지. 그런데 여기서 문제가 하나 있어. 이 놀이공원 규칙상, 롤러코스터는 키가 150cm 이상인 사람만 탈 수 있어.”

“아…”

솔라의 입에서 짧은 탄성이 터져 나왔다.

“만약 롤러코스터 탑승 도우미가 네 목에 걸린 출입증만 보고 ‘어, 우리 공원 손님 맞네요. 통과!’라고 하면 어떻게 될까? 출입증이 진짜인지, 위조된 건 아닌지만 확인하고 그냥 들여보내 주는 거야.”

“키가 작은 어린이도 롤러코스터를 타게 되겠지. 위험하네.” 솔라가 대답했다.

“바로 그거야.”

루나가 펜으로 ‘롤러코스터’를 톡톡 쳤다.

“정문 매표소의 역할과 롤러코스터 탑승 도우미의 역할은 달라. 매표소는 네가 ‘누구인지’ 확인해서 출입증을 발급해. 이걸 **인증(Authentication)**이라고 해. ‘이 사람은 솔라가 맞다’고 증명하는 거지.”

루나는 ‘정문 매표소’ 밑에 ‘인증’이라고 적었다.

“반면, 롤러코스터 탑승 도우미는 네가 ‘무엇을 할 수 있는지’를 확인해. 키가 150cm가 넘는지, 즉 ‘롤러코스터를 탈 자격이 있는지’를 검사하지. 이걸 **인가(Authorization)**라고 불러. 출입증이 있다고 해서 모든 놀이기구를 탈 권리가 자동으로 생기는 건 아니잖아.”

루나는 ‘롤러코스터’ 밑에 ‘인가’라고 썼다.

솔라는 잠시 루나가 그린 그림을 말없이 들여다보았다. 정문 매표소와 롤러코스터. 인증과 인가. 분리된 두 개의 검문소.

“아… 그럼 Firebase 로그인은 정문 매표소에서 출입증을 받는 것까지구나. ‘이 사람은 우리 서비스에 가입한 사용자’라는 사실만 확인해 주는 거네. 진짜 중요한 건 그 출입증을 가지고 각 매장 데이터라는 놀이기구 앞에 갔을 때 벌어지는 일이구나.”

솔라는 아까 자신이 읽었던 문장을 다시 떠올렸다. 로그인 성공과 자원 접근은 별개의 문제. 이제야 그 문장이 왜 거기에 쓰여 있었는지 명확하게 이해되었다. 로그인 성공은 이야기의 끝이 아니라 시작이었던 것이다.

“맞아. 로그인에 성공한 A 매장 점주가 B 매장의 매출 데이터에 접근하려고 할 때, 우리 서버는 롤러코스터 탑승 도우미처럼 행동해야 해. ‘출입증은 확인됐습니다. 하지만 이 놀이기구는 당신의 키 제한에 맞지 않네요.’ 하고 정중히 막아서야 하는 거지.”

솔라는 고개를 끄덕였다. 머릿속에 뒤엉켜 있던 실타래가 풀리는 기분이었다. 신원 확인과 권한 부여는 완전히 다른 차원의 문제였다.

“그럼… 그 롤러코스터 탑승 도우미는 뭘 보고 키를 판단하는 거야? 출입증에 뭔가 특별한 게 적혀 있기라도 한 건가? ‘이 사람은 키 160cm’ 이런 식으로?”

솔라의 질문은 자연스럽게 다음 단계로 넘어갔다. 인증과 인가가 분리되어야 한다는 원칙을 받아들이자, 이제 그 둘을 어떻게 연결하는지에 대한 구체적인 방법이 궁금해졌다. 서버는 사용자가 어떤 매장의 점주인지, 혹은 본사 관리자인지를 대체 무엇을 보고 판단하는 걸까? 그 정보는 어디에 담겨서 전달되는 걸까?

2장: ID 토큰 속 클레임: ‘role’과 ‘store_id’가 말하는 것

솔라의 질문에 루나는 말없이 펜을 다시 들었다. 어제 그렸던 ‘놀이공원’ 그림 위, ‘출입증’이라고 적었던 작은 표식 옆에 새로운 네모 상자를 그리기 시작했다. 이전보다 훨씬 크고 구조적인 상자였다.

루나는 상자 안에 가로선을 그어 세 칸으로 나누고, 위에서부터 차례로 ‘헤더(Header)’, ‘페이로드(Payload)’, ‘서명(Signature)’이라고 적었다. 그리고 가장 중요한 중간 칸, ‘페이로드’ 안에 다시 작은 상자를 하나 더 그렸다. 마치 출입증 안에 숨겨진 비밀 주머니 같았다. 루나는 그 비밀 주머니에 custom claims라고 이름을 붙였다.

“솔라 네가 어제 물었지. 출입증에 뭔가 특별한 게 적혀 있냐고. 바로 이거야.”

루나가 custom claims 상자를 펜 끝으로 가리켰다.

“이게 Firebase가 로그인 성공 후 발행하는 진짜 ‘출입증’, 즉 ID 토큰의 구조를 간단하게 그린 거야. 그리고 이 custom claims라는 비밀 주머니 안에, 우리 서버가 가장 궁금해하는 정보가 들어있어.”

루나는 비밀 주머니 안에 두 줄의 글씨를 채워 넣었다.

{
  "role": "store_manager",
  "store_id": "store-A"
}

솔라는 새로운 그림을 유심히 들여다보았다. 어제는 막연하게 ‘출입증’이라고만 생각했던 것이, 이제는 구체적인 정보를 담은 문서처럼 보였다.

“아, 출입증에 내 명찰을 붙여주는 거랑 비슷하네. ‘이 사람은 A매장 관리자 김솔라’ 라고 알려주는 거지.”

솔라가 말했다. 드디어 서버가 사용자를 식별할 구체적인 단서를 찾았다는 생각에 목소리가 약간 들떴다. 이제 서버는 이 명찰을 보고 A매장 문을 열어주면 되는 것이다. 간단한 문제였다.

“명찰이라… 반은 맞고 반은 틀려.”

루나가 대답했다.

“이건 단순히 ‘나는 누구다’라고 소개하는 명찰이라기보다는, ‘나는 이런 권한이 있다고 주장한다’고 외치는 서류에 가까워. 그래서 이름도 ‘클레임(Claim, 주장)’이야.”

“주장?”

“응. 이 토큰은 ‘이 토큰을 들고 온 사람은 store_manager라는 역할을 가졌으며, store-A라는 매장에 대한 권한이 있음을 주장합니다’라고 말하는 것과 같아. 그냥 정보가 아니라, 권한의 근거가 되는 거지.”

루나는 ‘롤러코스터’ 그림을 다시 톡톡 쳤다.

“롤러코스터 탑승 도우미는 네 이름이 솔라인지, 어디 사는지 관심 없어. 오직 ‘키가 150cm 이상’이라는 조건에만 관심 있지. 마찬가지로 우리 서버도 이 토큰을 받으면, 다른 정보보다 custom claims에 담긴 이 ‘주장’들을 먼저 확인하는 거야. ‘이 요청을 한 사용자의 역할은 무엇인가? 그리고 어느 매장에 대한 권한을 주장하고 있는가?’”

그제야 솔라는 자신이 놓치고 있던 핵심을 깨달았다. rolestore_id는 사용자의 프로필 정보 같은 단순한 꼬리표가 아니었다. 그것은 서버의 인가(Authorization) 시스템이 작동하는 데 필요한 유일하고 결정적인 ‘입력값’이었다. 서버는 이 클레임들을 기준으로 접근 허용 여부를 판단하는 기계였다.

“아…! 그럼 서버는 이 토큰을 받으면, 데이터베이스를 뒤져서 이 사람이 진짜 A매장 점주인지 다시 확인할 필요가 없는 거구나. 그냥 이 토큰의 custom claims를 보고 ‘음, store_managerstore_idstore-A네. 그럼 A매장 데이터에 대한 요청은 허락!’ 이렇게 판단하는 거구나.”

“정확해. 일단 토큰 자체가 위조되지 않았다는 것만 확인되면, 그 안에 담긴 주장은 신뢰하기로 약속하는 거야. 덕분에 서버는 매번 요청이 올 때마다 사용자 정보를 조회하는 복잡한 과정을 생략할 수 있지.”

솔라는 고개를 끄덕였다. 머릿속에서 API 서버가 일하는 모습이 한층 더 선명하게 그려졌다. API 서버는 Firebase라는 신원 보증 기관이 발급한 ‘권한 주장 서류’를 검토하는 입국 심사관과 같았다. 서류의 진위 여부를 확인하고, 명시된 방문 목적과 체류 자격에 따라서만 문을 열어준다.

문득 솔라의 머리에 새로운 의문이 꼬리를 물었다.

“언니, 그런데 방금 ‘토큰 자체가 위조되지 않았다는 것만 확인되면’이라고 했잖아. 그럼 누군가 이 토큰을 위조해서 roleadmin으로 바꾼다거나, 다른 매장 ID를 넣어서 보내면 어떡해? 그리고 내가 A매장 점주인 건 맞는데, 실수로 B매장 데이터를 요청하면 서버는 어떻게 알아채고 막아주는 거지? 그냥 ‘접근 실패’라고만 알려주나?”

인증과 인가가 분리된다는 것을 이해했고, 인가에 필요한 정보가 토큰의 클레임에 담겨 전달된다는 것도 알게 되었다. 하지만 그 클레임을 검증하고, 정책에 따라 접근을 허용하거나 거부하는 서버의 구체적인 행동 방식은 여전히 안개 속에 있었다. 그것이 바로 ‘401’과 ‘403’이라는 숫자가 등장하는 지점일까?

3장: 401 vs 403: 서버는 어떻게 접근을 거부하는가

이튿날, 솔라와 루나가 마주 앉은 테이블 위에는 어제 그렸던 ID 토큰 그림 대신, 새로운 다이어그램이 놓여 있었다. 루나가 방금 그린 간단한 순서도였다. ‘API 요청’이라는 시작점에서 뻗어 나온 화살표는 두 개의 마름모꼴 검문소를 차례로 거치고 있었다.

첫 번째 검문소에는 ‘토큰 유효성 검증’이라고 적혀 있었다. 여기서 ‘실패’로 빠져나온 화살표는 텅 빈 네모 상자로 이어졌다. ‘성공’ 화살표는 두 번째 검문소로 향했다. 두 번째 검문소의 이름은 ‘권한 정책 검증’이었고, 여기서도 ‘성공’과 ‘실패’ 화살표가 각각 다른 네모 상자로 뻗어 나갔다. 어제 솔라가 던졌던 두 가지 질문—토큰 위조와 권한 없는 접근—에 대한 답을 찾는 지도처럼 보였다.

“어제 네가 궁금해했던 것들. 서버가 어떻게 알아채고 막아주는지. 그 과정을 이 지도 위에서 직접 따라가 보자.”

루나가 말했다. 솔라는 어제의 질문을 떠올렸다. 누군가 토큰을 위조해서 roleadmin으로 바꾸거나, 내가 실수로 B매장 데이터를 요청하는 경우.

“좋아. 첫 번째 시나리오. 악의적인 해커가 나타나서, 자기 마음대로 roleadmin으로 바꾼 가짜 토큰을 만들어서 우리 서버에 보냈다고 쳐보자.”

솔라는 해커가 된 것처럼 목소리를 살짝 낮췄다.

“그럼 그 요청은 이 지도의 시작점으로 들어오겠지.”

루나의 펜 끝이 ‘API 요청’ 상자를 가리켰다가, 첫 번째 검문소인 ‘토큰 유효성 검증’으로 옮겨갔다.

“여기서 서버는 뭘 할까? 토큰에 admin이라고 쓰여있는지 먼저 볼까?”

“아니.” 솔라는 즉시 대답했다. 어제 루나가 했던 말을 기억해냈다. “‘토큰 자체가 위조되지 않았다는 것만 확인되면’. 그럼 제일 먼저 하는 일은 토큰의 서명을 확인하는 거겠네. Firebase라는 보증 기관이 발급한 게 맞는지, 중간에 누가 내용을 바꾸진 않았는지.”

“맞아. 해커가 만든 가짜 토큰은 Firebase의 비밀 키로 서명되지 않았을 테니, 이 첫 번째 검문소에서 바로 ‘실패’ 딱지를 받게 돼.”

루나의 펜이 ‘실패’ 화살표를 따라 첫 번째 빈 상자로 향했다. 그리고 그 안에 ‘401 Unauthorized’라고 적었다.

“401… 어디서 본 숫자인데. 인증이 필요하다는 뜻이었나?”

“정확해. ‘당신이 누군지 증명할 수 없군요. 유효한 출입증을 가져오세요.’라는 의미야. 토큰이 없거나, 만료되었거나, 이렇게 위조된 경우 모두 여기에 해당하지. 우리 놀이공원 비유로 치면 정문 매표소에서 입장을 거부당한 거야.”

솔라는 고개를 끄덕였다. 첫 번째 시나리오는 명쾌했다. 신원 증명 자체가 실패한 경우다. 이제 두 번째 시나리오가 남았다.

“그럼 이번엔 나야. 나는 A매장 점주라서, Firebase로부터 아주 유효한 토큰을 받았어. rolestore_manager, store_idstore-A라고 정확히 적혀 있지. 그런데 내가 실수로 B매장의 매출 데이터를 요청하는 API를 호출한 거야.”

솔라는 펜을 들어 직접 순서도를 따라가기 시작했다.

“내 토큰은 진짜니까, 첫 번째 검문소 ‘토큰 유효성 검증’은 ‘성공’으로 통과.”

펜이 두 번째 검문소인 ‘권한 정책 검증’으로 이동했다.

“자, 이제 서버는 내 토큰이 진짜라는 걸 알아. 그리고 내 토큰에 담긴 클레임, 즉 ‘나는 A매장 관리자다’라는 주장도 읽었어. 그런데 내가 요청한 건 B매장 데이터야. 여기서 무슨 일이 벌어질까?”

“음… 서버는 두 가지를 비교하겠네. 내 토큰 안의 store_id 클레임(store-A)과 내가 요청한 데이터의 주인(store-B). 둘이 다르니까… ‘실패’!”

솔라의 펜이 두 번째 검문소의 ‘실패’ 화살표를 따라 마지막 빈 상자로 향했다. 루나는 그 상자 안에 ‘403 Forbidden’이라고 적어주었다.

“403 Forbidden… 금지되었다는 뜻이네. 401이랑은 다른 숫자야.”

솔라는 잠시 다이어그램 위에 그려진 두 개의 다른 실패 경로를 말없이 응시했다. 하나는 401 Unauthorized, 다른 하나는 403 Forbidden. 똑같은 ‘접근 실패’처럼 보였지만, 서버가 실패를 선언한 지점과 이유가 완전히 달랐다.

그 순간, 솔라의 머릿속에서 모든 조각이 맞춰졌다.

“아! 알겠다! 401은 ‘넌 누구야? 증명해봐!’이고, 403은 ‘네가 누군진 알겠는데, 여긴 들어오면 안 돼!’ 구나!”

솔라의 목소리에 유레카의 기쁨이 실렸다. 로그인 실패와 권한 없음이 그냥 뭉뚱그려진 ‘에러’가 아니었다. 서버는 인증과 인가를 철저히 분리해서 검사하고, 실패했을 때 그 이유까지 명확히 구분해서 알려주고 있었던 것이다. 토큰의 진위를 가리는 인증 정책과, 토큰의 내용과 요청을 비교하는 인가 정책이 서버 안에서 별개의 검문소로 작동하고 있었다.

“A매장 점주가 B매장 데이터에 접근하는 건 인증 실패가 아니라, 인가 정책 위반이었던 거야.”

솔라가 순서도를 보며 스스로 정리했다. 이제 개발팀 회고록에 적혀 있던 ‘서버는 401과 403을 구분한다’는 문장은 더 이상 모호한 기술 용어가 아니었다. 그것은 인증된 신원과 허용된 자원을 구분하여 시스템의 경계를 세우는, 명확한 설계 원칙의 증거였다.

자신감이 붙은 솔라는 순서도의 ‘권한 정책 검증’ 부분을 다시 짚었다.

“이 검증 로직은 store_manager한테는 잘 통하겠네. 그런데 만약 본사 관리자, 그러니까 roleadmin인 사람은 어떡하지? admin은 특정 store_id에 묶여있지 않을 텐데. 이 검문소는 admin을 어떻게 통과시켜 주는 거야? 그리고… 만약 요청하는 게 이런 데이터가 아니라, Vision 이미지 같은 거면 그때도 이 정책이 그대로 적용되나?”

4장: 역할 기반 인가 정책과 예외 처리: Vision 이미지의 경우

다음 날 아침, 솔라는 어제 루나와 함께 그렸던 순서도를 물끄러미 바라보고 있었다. ‘권한 정책 검증’이라고 쓰인 두 번째 마름모꼴 검문소. 어제는 그저 하나의 관문처럼 보였지만, 밤사이 그 안에서 수많은 갈림길이 뻗어 나가는 상상을 했다. 본사 관리자(admin)는 통과시키고, 엉뚱한 매니저(store_manager)는 막아서는, 보이지 않는 갈림길들.

루나가 다가와 그 순서도를 보더니, 말없이 빨간 펜을 들었다. 그리고는 ‘권한 정책 검증’ 마름모를 지우는 대신, 그 안을 파고들며 더 복잡한 그림을 그려 넣기 시작했다. 마름모 안에서 새로운 질문들이 꼬리를 물며 작은 의사결정 나무가 자라났다.

if (token.claims.role == 'admin') -> success else if (token.claims.role == 'store_manager') -> if (token.claims.store_id == request.resource.store_id) -> success

“어… 잠깐만. 어제 내가 상상했던 것보다 훨씬 복잡한데?”

솔라는 루나가 그린 그림을 보며 중얼거렸다.

“나는 그냥 단순하게 생각했어. 역할이 admin이면 무조건 통과. store_manager면 토큰의 store_id랑 요청하는 자원의 store_id가 같은지만 보면 끝나는 거 아냐?”

솔라의 말은 합리적이었다. 이미 토큰에서 역할과 담당 매장 정보를 얻었으니, 그 두 가지만으로 모든 인가 규칙을 만들 수 있을 것 같았다. 하나의 규칙으로 모든 상황을 깔끔하게 처리하는 것. 그것이 솔라가 생각한 이상적인 설계였다.

“그게 기본 골격은 맞아. 하지만 그 규칙만으로는 모든 문을 지킬 수 없어.”

루나는 순서도를 옆으로 밀어두고 새 메모지를 꺼냈다. 그리고 그 위에 표를 그리기 시작했다. 마치 서버의 인가 정책을 심문하는 조사관처럼, 여러 가지 시나리오를 표에 채워 넣었다.

요청자 (토큰 클레임)요청 자원 (API 경로)결과
role: store_manager, store_id: store-A/stores/store-A/sales
role: store_manager, store_id: store-A/reports/all-stores
role: admin/reports/all-stores
role: admin, store_id: null/stores/store-A/sales

“자, 네가 서버의 ‘권한 정책 검증’ 검문소 담당자라고 생각해 봐. 이 표를 하나씩 채워보자.”

솔라는 첫 번째 줄을 자신 있게 짚었다. “이건 쉽네. A매장 점주가 A매장 매출 데이터를 요청했어. 토큰의 store_id와 요청 경로의 store_id가 일치하니까, 통과. 허용(Allow).”

“좋아. 다음은?”

루나의 펜이 두 번째 줄로 향했다. A매장 점주가 모든 매장의 데이터를 집계해서 보여주는, 본사용 리포트 페이지를 요청한 경우였다. 솔라는 잠시 망설였다.

“음… 이 요청 경로에는 특정 store_id가 없네. 비교할 대상이 없으니까… 일단 **거부(Deny)**해야 하나?”

“맞아. 거부야. 여기서 중요한 건, 이 /reports/** 라는 자원 자체가 ‘오직 admin 역할만 접근 가능’이라는 별도의 규칙을 갖고 있다는 점이야. store_id를 비교하기도 전에 역할(role)에서 먼저 걸리는 거지.”

그제야 솔라는 자신이 놓친 부분을 깨달았다. 정책이란 단순히 토큰 정보와 요청 정보를 비교하는 것만이 아니었다. ‘자원’ 그 자체에도 접근 조건이 붙어 있을 수 있다는 사실을 말이다.

“그럼 세 번째는 더 쉽네. admin이 전체 리포트를 요청했으니, 당연히 허용(Allow). 네 번째도… admin은 슈퍼 유저니까 특정 매장 데이터도 볼 수 있어야지. 허용(Allow).”

솔라가 빠르게 답하자, 루나는 admin이 특정 매장 데이터에 접근하는 네 번째 시나리오를 펜으로 가리켰다.

“맞아. 허용이야. 하지만 중요한 건 이게 ‘슈퍼 유저니까 당연히’가 아니라는 점이야. 서버의 인가 로직에 ‘요청자의 역할이 admin이면, store_id를 검사하지 않고 통과시킨다’라는 규칙이 명시적으로 작성되어 있기 때문에 가능한 거야. 모든 허용에는 그에 맞는 규칙이 있어. 마법은 없어.”

솔라는 고개를 끄덕였다. 역할과 자원의 관계를 하나하나 정의하는 것. 그것이 바로 인가 정책의 핵심이었다. 하나의 만능 규칙이 아니라, 여러 개의 구체적인 규칙들의 집합이었다.

“좋아, 이제 마지막 질문. 어제 내가 물었던 것 중 남은 거.”

솔라는 표 아래에 새로운 줄을 상상하며 말했다.

“A매장 점주가 A매장의 Vision 분석 이미지를 웹페이지에서 본다고 해보자. <img> 태그로 이미지를 불러와야 할 텐데, 이미지 태그는 인증 토큰을 헤더에 담아서 보낼 수 없잖아. 그럼 서버는 토큰이 없으니 401 에러를 보내야 하는 거 아냐? 이미지는 어떻게 보여주지?”

그것은 솔라의 생각에 남아있는 마지막 모순이었다. 모든 자원 접근은 토큰 기반으로 이루어져야 한다는 대원칙이, 가장 흔한 HTML 태그 앞에서 무너지는 것처럼 보였다.

루나는 미소를 지으며 표의 빈 공간에 새로운 흐름을 그렸다.

  1. 클라이언트 (JS): GET /api/stores/store-A/image-url/img123 (✅인증 토큰 포함)
  2. 서버: 토큰 검증, 인가 정책 검증 (store_managerstore-A 리소스 요청 -> OK)
  3. 서버: 이미지 파일 자체가 아닌, 임시 접근용 URL을 생성하여 응답. {"signed_url": "https://storage.server/vision-images/...?signature=..."}
  4. 클라이언트 (JS): 응답으로 받은 임시 URL을 <img> 태그의 src 속성에 넣어줌. <img src="https://storage.server/vision-images/...?signature=...">

“아…!”

솔라의 입에서 탄성이 터져 나왔다.

“두 단계로 나누는 거구나! 먼저 API로 정식으로 권한을 확인하고, 통과하면 그 때만 쓸 수 있는 비밀 통행증(임시 URL)을 발급받는 거네. 이미지 자체는 그 통행증으로 가져오는 거고.”

“바로 그거야. 모든 자원이 똑같은 방식으로 문을 통과할 필요는 없어. 중요한 건 문을 통과하기 전에 ‘반드시 유효한 검문소를 거친다’는 원칙을 지키는 거지. Vision 이미지의 경우, API 호출이 그 검문소 역할을 하고, 임시 URL이 검문소를 통과했다는 증표가 되는 거야.”

이제 솔라는 개발팀 회고록의 문장을 완전히 새로운 눈으로 볼 수 있었다. ‘admin은 본사와 전체 매장 집계에, store_manager는 claims에 지정된 담당 매장에 접근한다.’ 이 문장은 단순한 기능 설명이 아니었다. 어떤 역할이 어떤 자원에 접근할 수 있는지, 그리고 때로는 자원의 특성에 맞춰 접근 방식을 어떻게 바꿀 것인지까지 고려한, 잘 짜인 인가 정책 모델링의 결과물이었던 것이다.

솔라는 잘 짜인 규칙들로 채워진 표와 순서도를 보았다. 퍼즐 조각들이 제자리를 찾은 듯 명쾌했지만, 동시에 새로운 질문이 고개를 들었다.

“이렇게 역할과 자원마다 규칙을 촘촘하게 만들어서 경계를 세우는 건 알겠어. 그런데 만약 매장이 수백 개로 늘어나거나, ‘지역 매니저’ 같은 새로운 역할이 생긴다면? 이 모든 규칙들을 일일이 관리하는 게 너무 복잡하고 힘들어지지 않을까? 이 방식의 장점과 단점은 뭘까? 어떻게 하면 이 경계를 무너뜨리지 않으면서도 유연하게 확장할 수 있지?”

5장: 다매장 권한 경계 설계: 확장성과 보안

솔라의 손이 바쁘게 움직였다. 어제 루나와 함께 완성했던 인가 정책 표 옆에, 솔라는 새로운 시나리오들을 마구잡이로 추가하고 있었다. ‘store-C 추가’, ‘store-D 추가’, 그리고 새로운 역할인 ‘regional_manager’까지. 처음에는 몇 줄로 깔끔했던 표가 금세 어지러운 낙서장처럼 변해갔다. 규칙 하나가 생길 때마다 확인해야 할 조건들이 거미줄처럼 얽히는 것 같았다.

“언니, 이거 봐봐. 우리가 만든 규칙이 처음엔 명쾌해 보였는데….”

솔라는 펜을 내려놓으며 한숨을 쉬었다.

“매장이 겨우 두세 개 늘어나고, 역할 하나가 추가됐을 뿐인데 벌써부터 복잡해. 매장이 수백 개가 되면, 이 규칙들은 누가 다 관리해? 본사 관리자가 A매장 점주에게 실수로 B매장 권한을 주면 어떡하고? 뭔가… 이 시스템, 되게 꼼꼼하게 관리하지 않으면 쉽게 무너질 것 같아.”

솔라의 눈에는 지금까지 쌓아 올린 이해가 모래성처럼 느껴지는 듯한 불안감이 서려 있었다. 인증과 인가를 분리하고, 클레임으로 권한을 전달하며, 401과 403을 구분하는 개별 조각들은 모두 이해했지만, 이것들이 모여 하나의 튼튼한 시스템을 이룬다는 확신이 들지 않았다. 파편적인 기술들의 집합일 뿐, 유연하고 확장 가능한 설계라는 느낌이 아니었다.

루나는 솔라가 어지럽게 그려놓은 표를 잠시 들여다보았다. 그리고는 솔라가 추가한 ‘store-C’라는 글자를 손가락으로 가리켰다.

“좋아. 그 불안감을 가지고 작은 시뮬레이션을 하나 해보자. 솔라 네가 방금 우리 회사에 새로 생긴 ‘C 매장’의 존재를 데이터베이스에 등록했다고 상상해 봐. 아직 점장은 정해지지 않았어.”

루나는 어지러운 표 대신, 깨끗한 메모지를 가져와 간단한 그림을 그렸다. 왼쪽에는 토큰: { role: store_manager, store_id: store-A } 라고 적힌 솔라의 ‘출입증’을, 오른쪽에는 /stores/store-C/sales 라는 ‘자원’을 그렸다.

“자, 넌 여전히 A매장 점주야. 그리고 방금 생긴 따끈따끈한 C매장의 매출이 궁금해졌어. 그래서 C매장 데이터 API를 요청하는 거야. 어떻게 될까?”

솔라는 루나가 만든 단순한 실험대에 집중했다. 복잡한 규칙 표를 떠나, 구체적인 요청 하나를 따라가 보기로 했다.

“일단 내 토큰은 유효하니까, 첫 번째 검문소인 ‘인증’은 통과. 401 에러는 아니겠지.”

“그다음은?”

“두 번째 검문소, ‘인가 정책’으로 가겠지. 서버는 내 토큰의 클레임을 읽을 거야. rolestore_manager, store_idstore-A. 그리고 내가 요청한 자원은 store-C의 데이터… 어?”

솔라의 말이 잠시 멈췄다.

“서버의 인가 규칙은 ‘토큰의 store_id와 요청한 자원의 store_id가 같은가?’를 검사하잖아. 내 토큰은 store-A고, 요청은 store-C니까… 당연히 다르네. 그럼 403 Forbidden. 접근 거부.”

솔라는 스스로 답을 내놓고는 잠시 침묵했다. 너무나 당연한 결론이었다. 그런데 그 당연함 속에, 자신이 놓치고 있던 거대한 무언가가 숨어 있었다.

“잠깐만… 이상하다. 우리는 C매장을 위한 어떤 규칙도 추가한 적이 없어. 서버 코드에 if (request.store == 'store-C') 같은 걸 넣지도 않았고. 그런데 서버는 어떻게 C매장을 ‘보호’해야 하는지 알고 있었지?”

그 순간, 솔라의 머릿속을 맴돌던 불안감이 안개처럼 걷히기 시작했다. 그녀가 ‘규칙의 폭발’이라고 걱정했던 것은 애초에 존재하지 않는 문제였다.

“아…! 규칙은 ‘A매장 점주는 A매장만 접근 가능’이라거나 ‘B매장 점주는 B매장만 접근 가능’처럼 자원 하나하나에 개별적으로 설정된 게 아니었어. 규칙은 단 하나, ‘점주 역할은 자신의 store_id 클레임과 일치하는 매장 자원에만 접근할 수 있다’는 원칙이었던 거야!”

새로운 매장이 생겨도 기존의 원칙이 알아서 그 경계를 지켜주고 있었다. 시스템은 기본적으로 모든 접근을 거부하고(Deny by Default), 토큰의 클레임이 명시적으로 허용한 경우에만 문을 열어주는 구조였던 것이다. 확장성은 규칙을 추가해서 얻는 게 아니라, 단단한 경계 원칙이 새로운 자원에도 자동으로 적용되기에 확보되는 것이었다.

“바로 그거야.” 루나가 말했다. “우리가 세운 것은 개별 자물쇠의 목록이 아니야. ‘자신의 열쇠와 맞는 자물쇠만 열 수 있다’는, 시스템 전체를 관통하는 단 하나의 경계 원칙이지. 통제 지점은 API 서버의 코드가 아니라, 사용자에게 어떤 열쇠(클레임)를 쥐여줄지를 결정하는 곳에 있어. C매장 점주가 새로 오면, 관리자는 그 사람의 사용자 계정에 store_id: store-C라는 클레임을 설정해주기만 하면 돼. 그걸로 끝이야.”

솔라는 고개를 끄덕였다. 이제야 모든 조각이 하나의 그림으로 맞춰졌다. 인증, 클레임, 인가 정책은 따로 노는 기술이 아니었다. 로그인 성공과 자원 권한을 의도적으로 분리하고, 그 사이를 신뢰할 수 있는 정보(클레임)로 연결하여, 확장 가능하고 안전한 경계를 설계하는 하나의 철학이었다.

솔라는 처음 이 주제에 의문을 품게 했던 개발팀의 회고록 문장을 떠올렸다. ‘로그인 성공과 자원 권한을 별개의 검증으로 만든 사례다.’

그 문장은 더 이상 기술적인 사실의 나열이 아니었다. 솔라는 펜을 들어 그 문장 아래에, 며칠간의 여정을 통해 자신이 얻은 설계 원칙들을 정리하기 시작했다. 미래의 자신이 또다시 길을 잃지 않도록, 단단한 이정표를 세우는 작업이었다.

다매장 권한 경계 설계 원칙

  1. 인증과 인가를 분리하라: ‘누구인가(Authentication)’와 ‘무엇을 할 수 있는가(Authorization)’는 완전히 다른 문제다. 둘을 분리하는 것이 경계의 시작이다.
  2. 권한의 근거는 토큰에 담아라: 서버가 모든 사용자의 권한을 기억할 필요 없이, 상태 비저장(stateless)으로 정책을 검사할 수 있게 한다. 권한은 이식성을 갖는다.
  3. 서버는 원칙을 강제하고, 기본적으로 거부하라: 서버는 ‘요청된 자원’과 ‘토큰의 주장’을 비교하는 단일 원칙을 강제한다. 새로운 자원은 명시적으로 허용되기 전까지 자동으로 보호된다.

솔라는 자신이 적은 세 가지 원칙을 바라보았다. 이것은 단순히 Firebase나 특정 시스템에 국한된 이야기가 아니었다. 여러 사용자 그룹이 각기 다른 자원에 접근해야 하는 복잡한 시스템이라면 어디에나 적용할 수 있는, 견고한 권한 시스템을 세우는 보편적인 청사진이었다. 더 이상 솔라에게 인증과 인가는 풀어야 할 문제가 아니라, 시스템의 보안과 확장성을 지탱하는 든든한 설계 도구로 보였다.