사람은 떠났는데 권한은 남았다

김팀장은 여전히 급여를 볼 수 있다
먼저 한 장면부터 말씀드리겠습니다. 김팀장이 마케팅 부서에서 개발 부서로 자리를 옮겼습니다. 인사 시스템에는 이동이 반영됐고, 조직도도 다시 그려졌습니다. 그런데 인가 시스템 안에서는 김팀장이 아직 마케팅 부서의 매니저로 남아 있습니다. 그 상태가 몇 분이든 며칠이든 이어지는 동안, 김팀장은 이제 자신과 무관해진 마케팅 부서 구성원들의 급여를 계속 열어볼 수 있습니다.
퇴사도 같은 얼굴을 하고 있습니다. 사람은 나갔는데 관계는 남고, 관계가 남아 있는 한 접근은 살아 있습니다. 조직이 개편되면 반대편 사고도 함께 옵니다. 새로 부임한 매니저가 정작 자기 팀원의 정보를 못 보는 상황이죠. 둘 다 뿌리는 하나입니다. 원천에서 관계가 바뀌었는데, 그 변경을 인가 쪽이 제때 따라잡지 못한 것.
이 글은 보안 사고 이야기로 문을 열지만, 정작 다루려는 건 사고 그 자체가 아닙니다. 중심에 두려는 질문은 이겁니다. 관계 데이터의 원천은 여러 도메인 서비스에 흩어져 있는데, 그 관계로 인가 판정을 내리는 쪽의 상태를 어떻게 원천과 계속 일치시키는가. 사고는 이 일치가 늦거나 부분적으로 실패할 때 그 틈으로 새어 나옵니다.
원천은 도메인에, 판정은 인가 엔진에
관계 데이터의 진짜 주인은 각 도메인 서비스입니다. 조직 구조는 조직 서비스가, 구성원 소속은 구성원 서비스가, 매니저 지정은 인사 서비스가, 프로젝트 참여는 프로젝트 서비스가 관리합니다. "누가 무엇에 접근할 수 있는가"는 결국 이 관계들의 조합으로 결정됩니다. 저희는 인가를 역할 목록이 아니라 관계로 표현하는 방식(ReBAC)을 쓰고, 그 판정은 OpenFGA를 엔진으로 두고 그 위에 얹은 인가 서비스가 맡습니다. 관계 하나하나는 이 엔진 안에서 튜플로 저장됩니다. 표준 모델은 그대로 두고 실행부를 저희 손으로 다시 만드는 이야기는 인가 엔진을 다루는 별도 편으로 미룹니다. 이 글에서 엔진은 관계 데이터를 받아 판정을 내리는 쪽으로만 등장합니다.
문제의 구조는 여기서 분명해집니다. 관계의 원천은 도메인 서비스에 흩어져 있고, 판정에 쓰이는 상태는 인가 엔진 안에 따로 있습니다. 원천이 바뀌면 판정용 상태도 따라 바뀌어야 하는데, 이 둘은 서로 다른 저장소입니다. 원천에서 관계가 변할 때마다 인가 엔진의 튜플이 갱신되어야 하고, 그 갱신은 되도록 실시간이어야 합니다. 늦어지는 만큼 앞서 본 김팀장의 창이 열립니다.

정리하면 이 글은 하나의 수렴 문제입니다. 받는 쪽(인가 엔진의 튜플 저장소)을 원천(도메인 서비스)과 계속 같은 상태로 유지하는 것. 이제부터 이 수렴을 어떻게 세웠고, 어디가 어려웠는지를 세 가지로 나눠 이야기하겠습니다.
첫째, 새 인프라를 짓지 않았다
가장 먼저 짚고 싶은 건, 이 수렴을 위해 저희가 새 메시징 인프라를 하나도 만들지 않았다는 점입니다.
배경은 짧게만 말씀드립니다. 저희에게는 이미 도메인 변경을 안전하게 밖으로 흘려보내는 레일이 있었습니다. 도메인 데이터가 바뀌면 같은 트랜잭션 안에서 변경 사실을 Outbox 테이블에 함께 기록하고, Debezium이 그 테이블 변경을 CDC로 잡아 Kafka로 실어 나릅니다. 애플리케이션이 DB 쓰기와 메시지 발행을 따로 하다 한쪽만 성공하는 문제를 구조적으로 없애려고 택한 방식이고, 자세한 배경은 본편 2화에서 다뤘습니다. 중요한 건 이 레일이 이미 모든 도메인이 쓰고 있고 수개월간 프로덕션에서 검증됐다는 사실입니다.
그래서 인가 동기화를 붙일 때 저희가 새로 만든 건 단 둘이었습니다. 기존 컨슈머 프레임워크 위에 올린 인가 컨슈머, 그리고 튜플을 실제로 쓰고 지우는 튜플 쓰기 서비스. 파이프라인을 이루는 여섯 개 구성요소 중 넷(Outbox, CDC, Kafka 클러스터, 컨슈머 프레임워크)이 이미 있었으니, 새로 얹은 건 그 위의 얇은 두 층뿐입니다.

인가 컨슈머가 하는 일은 번역입니다. 도메인 이벤트는 "무엇이 바뀌었나"를 말합니다. 구성원이 배정됐다, 매니저 지정이 해제됐다, 조직이 개편됐다 같은 식으로요. 컨슈머는 이걸 "어떤 관계가 새로 생겼고 어떤 관계가 사라졌나"로 옮겨, 튜플을 쓰거나 지우는 명령으로 바꿉니다. 김팀장의 부서 이동 이벤트가 들어오면, 옛 관계를 지우는 삭제 명령과 새 관계를 만드는 쓰기 명령으로 번역되는 식입니다.
이 대목이 흔히 조직에서 막히는 자리입니다. 전사 인가를 한 번에 파악하고 동기화하는 시도가 어려운 건, 대개 도메인마다 데이터가 드나드는 구조가 제각각이기 때문입니다. 저희 도메인들은 대체로 같은 모듈 구조를 따릅니다(이전화). 어떤 데이터가 어떤 경로로 밖에 나가는지를 도메인마다 새로 찾아 헤맬 필요가 없었고, 컨슈머도 도메인마다 같은 모양으로 반복해 짤 수 있었습니다. 다만 어떤 이벤트를 어떤 튜플로 옮길지는 여전히 도메인마다 사람이 읽고 하나씩 정했습니다. 구조가 같아서 인가가 저절로 따라온 게 아니라, 구조까지 제각각인 열 개 서비스였다면 그 하나씩이 훨씬 비쌌을 거라는 이야기입니다.
조직 개편은 이 구조의 이점이 가장 크게 드러나는 사례입니다. 개편 이벤트 하나가 수십에서 수백 개의 튜플 변경을 한꺼번에 일으킵니다. 기존 조직 관계를 지우고, 새 조직 관계를 만들고, 구성원 데이터의 조직 연결을 다시 잇는 일이 함께 벌어지죠. 역할 목록 기반이었다면 수백 개 역할을 지웠다 다시 만들어야 했을 작업이, 관계 기반에서는 튜플의 삭제와 생성으로 처리되고, 그 방아쇠는 Kafka 이벤트 하나로 당겨집니다.
둘째, 늦음을 두 방향으로 나눠 본다
받는 쪽을 원천과 일치시키는 이 수렴은 즉시 일어나지 않습니다. 이벤트가 발행되고, 레일을 타고, 컨슈머가 처리해 튜플을 쓰기까지 시간이 걸립니다. 그 사이에는 원천과 인가 상태가 어긋나 있는 구간이 반드시 존재합니다. 저희가 배운 건, 이 어긋남을 하나의 위험으로 뭉뚱그리지 않고 방향을 나눠서 봐야 한다는 것이었습니다.
한쪽은 옛 권한이 잠시 더 남는 경우입니다. 김팀장이 부서를 옮겼는데 옛 매니저 관계가 아직 안 지워져서, 잠깐 동안 마케팅 급여를 더 볼 수 있는 상태. 이건 허용 방향의 불일치입니다. 있어서는 안 될 접근이 잠시 살아 있으니, 곧바로 보안 노출로 이어집니다.
다른 쪽은 새 권한이 잠시 늦게 오는 경우입니다. 새로 부임한 매니저의 관계가 아직 안 만들어져서, 잠깐 동안 자기 팀원 정보를 못 보는 상태. 이건 제한 방향의 불일치입니다. 있어야 할 접근이 잠시 없는 것이라, 결과는 업무 불편에 가깝습니다.

저희는 이 둘 중 허용 방향의 불일치를 더 위험한 것으로 다룹니다. 잠깐 못 보는 불편은 되돌릴 수 있지만, 잠깐 더 본 급여 데이터는 되돌릴 수 없으니까요. 다만 이건 이 맥락에서의 판단이지, 모든 상황에 통하는 보안 원칙으로 일반화할 생각은 없습니다. 어느 쪽이 더 아픈지는 결국 그 데이터가 무엇이냐에 달려 있습니다.
여기서 "그 늦음이 얼마나 되느냐"가 남습니다. 저희 경험으로 컨슈머 처리 지연은 대체로 초에서 분 단위로 관측됩니다. 이 값은 측정된 SLA라기보다 운영하며 얻은 경험적 서술에 가깝습니다. 그래서 절대치로 단정하지는 않으려 합니다. 다만 이 지연이 추상이 아니라 실제로 감시되는 대상이라는 점은 분명합니다. 컨슈머 랙, 처리 시간, Outbox에서 Kafka까지의 지연 같은 지표를 대시보드에 두고 자체 기준으로 지켜봅니다. 이 수치들은 외부 벤치마크가 아니라 저희 운영 기준이자 목표치입니다.
그래서 "초에서 분 단위의 창을 감내할 수 있는가"는 기술이 답하는 질문이 아닙니다. 그건 이 데이터의 민감도에서 그 정도 노출을 받아들일 수 있느냐는 조직의 결정입니다. 급여나 평가처럼 민감도가 극도로 높은 데이터라면 감내 창을 훨씬 짧게 잡거나, 뒤에서 말할 캐시 배제 같은 더 보수적인 선택으로 옮겨 갑니다. 위험을 어디까지 수용할지는 데이터마다 조직이 정하는 문제입니다.
셋째, 어디까지 견디고 어디서부터 못 견디는가
조직 개편 이벤트 하나가 수백 개 튜플을 건드린다고 했습니다. 여기서 가장 무서운 실패는 전부 실패하는 게 아닙니다. 일부만 성공하는 것입니다.
저희가 쓰는 튜플 쓰기는 여러 튜플을 한 번에 넘겨도 원자적이지 않습니다. 오히려 저희가 트랜잭션을 끄고 여러 덩어리로 나눠 보내는 쪽을 택했습니다. 수백 개를 한 트랜잭션에 묶으면 인가 엔진 쪽에서 감당하기 어렵기 때문인데, 그 대가로 열 개를 쓰다 다섯 번째에서 실패하면 앞의 넷은 저장되고 뒤의 다섯은 저장되지 않은 채 끝나는 상황을 받아들인 셈입니다. 개편 상황에서 이게 벌어지면, 옛 관계 일부만 지워지고 일부는 남는 상태가 만들어집니다. 앞 절에서 말한 허용 방향 불일치의 가장 나쁜 형태, 곧 지워졌어야 할 잔존 권한이 정확히 이 틈에서 생깁니다.
여기까지는 저희가 견디는 쪽입니다. 실패한 쓰기는 곧바로 포기되지 않습니다. 컨슈머는 처리에 실패한 이벤트를 몇 초 동안 간격을 늘려 가며 다시 시도합니다. 순간적인 네트워크 장애나 인가 엔진의 일시적인 과부하처럼 잠깐 지나가는 실패는 이 재시도 안에서 대부분 흡수됩니다.
재시도가 안전한 건 같은 이벤트를 다시 처리해도 결과가 달라지지 않기 때문입니다. 다만 그 이유는 튜플 쓰기 자체가 덮어쓰기여서가 아닙니다. 저희가 쓰는 관계 갱신은 먼저 현재 저장된 튜플을 읽어 오고, 들어온 관계 목록과 비교해 차이분만 쓰거나 지웁니다. 첫 시도에서 이미 반영된 관계는 두 번째 시도의 비교에서 차이로 잡히지 않으니, 다시 쓸 대상 자체가 없습니다. 재처리가 무해한 건 쓰기가 관대해서가 아니라 쓸 것이 남아 있지 않아서입니다. 여기에 더해 이 레일 위에서는 이벤트마다 고유 id가 붙어, 컨슈머가 이미 처리한 이벤트를 알아보고 중복 수신을 걸러 냅니다.
문제는 재시도 시간이 지나도록 실패가 이어질 때입니다. 정해진 시간을 넘기면 컨슈머는 실패를 기록으로 남기고 다음 이벤트로 넘어갑니다. 오프셋은 전진하고, 그 이벤트가 만들려던 튜플 변경은 반영되지 않은 채로 남습니다. 즉 저희 파이프라인은 잠깐 지나가는 실패는 견디지만, 재시도 시간을 넘겨 이어지는 실패까지 스스로 회복하지는 못합니다. 앞 절에서 말한 허용 방향 불일치가 이 경우에는 잠깐의 창이 아니라 그대로 남는 상태가 됩니다.
이걸 감춰 두고 "부분 실패를 견딘다"고 쓸 수는 없었습니다. 지금 저희에게 있는 건 실패를 알아차리는 기록이지, 실패한 이벤트를 자동으로 다시 태우는 경로가 아닙니다. 그래서 처리에 실패한 이벤트를 따로 모아 관측하고 다시 태울 수 있게 하는 재처리 경로를 별도 과제로 만들고 있습니다. 이 글을 쓰는 시점에는 아직 작업 중입니다.
캐시도 같은 종류의 정직함을 요구합니다. 인가 판정을 매번 엔진까지 묻지 않으려고 결과를 캐시해 두는데, 튜플이 바뀌어도 캐시에 옛 판정이 남으면 또 다른 잔존이 생깁니다. 흔한 해법은 튜플이 바뀔 때 관련 캐시를 지우는 것입니다. 저희는 그 반대쪽을 택했습니다. 지우는 신호가 제때 도착하기를 기대하는 대신, 묻는 쪽이 호출마다 얼마나 최신인 답을 받을지를 고릅니다. 관계가 방금 바뀐 걸 아는 호출이면 캐시를 우회해 읽고, 그럴 이유가 없으면 캐시된 답을 받아 지연을 아낍니다. 무효화가 도착했는지를 믿는 대신, 이 답이 그 변경보다 최신인지를 물어보는 자리에서 따지는 방식입니다.
이 선택에는 대가가 있습니다. 변경 직후에는 캐시가 거의 쓰이지 못해 그만큼 엔진 호출이 늘고, 관계가 언제 바뀌었는지를 모르는 호출 경로에서는 이 판단 자체가 서지 않습니다. 그리고 급여나 평가처럼 보안이 극도로 중요한 데이터는 아예 캐시를 쓰지 않는 선택도 열어 둡니다. 이 경우엔 조금 느려도 판정을 매번 원천 상태에서 새로 받는 편이, 옛 결과가 잠시라도 남는 위험보다 낫다고 봤습니다.
남는 것은 감사 추적이다
지금까지의 장치들은 잔존 권한의 창을 좁히고, 잠깐 지나가는 실패를 흡수하는 데까지입니다. 창을 완전히 0으로 만들었다고 말하지는 않겠습니다. 그건 데이터 민감도에 따라 조직이 감내 수준을 정하는 문제라고 앞서 말씀드렸으니까요. 그렇다면 창이 존재한다는 걸 인정한 뒤에 남는 질문은 이겁니다. 나중에 그 창을 어떻게 되짚어 볼 것인가.
인가 엔진은 튜플이 생기고 사라진 내역을 시간순 목록으로 쌓아 둡니다. 저희는 별도 스케줄러를 두고 이 목록을 짧은 주기로 훑는데, 원래 목적은 변경 전파였습니다. 바뀐 관계가 어떤 리소스와 어떤 사용자에게 영향을 주는지를 풀어 다른 서비스로 흘려보내는 일이죠. 다만 전파는 미리 등록해 둔 일부 권한에 대해서만 일어납니다. 목록을 읽는 첫 번째 용도가 이 전파라면, 두 번째 용도는 목록 그 자체입니다. "언제, 어떤 튜플이, 생기거나 사라졌는가"가 시간순으로 남습니다. 전파하려고 읽던 것이 그대로 변경 이력이 되는 셈입니다. 전파 대상이 아닌 권한이라도 목록에는 남으니, 이력으로서의 쓸모는 전파 범위에 매이지 않습니다.
이 이력이 규제 대응과 사고 조사의 출발점이 됩니다. 보안 감사에서 던져지는 질문은 대개 이런 모양입니다. "이 사람이, 이 시점에, 이 데이터에 접근할 수 있었는가." 김팀장의 부서 이동이 인가에 반영되기까지 얼마의 창이 있었는지, 그 창 동안 마케팅 급여에 접근 가능한 상태였는지를 이 목록으로 되짚을 수 있습니다.
다만 이건 완성된 감사 로그가 아닙니다. 목록의 원본은 인가 엔진이 들고 있고, 저희가 따로 영속화해 두는 것은 어디까지 읽었는지를 가리키는 표식 하나뿐입니다. 그러니 엔진이 오래된 내역을 정리하면 그만큼 되짚을 수 있는 범위가 줄고, 표식이 어긋나면 그 사이 구간을 놓칠 수 있습니다. 감사 자산으로 제대로 쓰려면 이 목록을 받아 별도로 보관하는 단계가 한 겹 더 필요하고, 그건 아직 저희가 하지 않은 일입니다.
돌아보면 이 글의 세 가지는 결국 하나를 향합니다. 받는 쪽을 원천과 일치시키는 수렴을, 새 인프라 없이 기존 레일 위에 얹고(첫째), 그 수렴이 늦어지는 창을 방향별로 나눠 다루고(둘째), 부분 실패에서 어디까지 견디고 어디서부터 못 견디는지를 분명히 하는 것(셋째). 그리고 그 위에 변경 이력을 놓아, 완벽히 지우지 못하는 창을 나중에 되짚을 수 있게 했습니다.
실시간으로 다 지우지 못한다는 걸 인정하는 대신, 지우지 못한 것이 어디에 남는지를 알아 두는 쪽을 택한 셈입니다. 그 기록을 오래 붙잡아 두는 일은 아직 남아 있습니다.
김팀장 이야기로 돌아가면, 부서를 옮긴 그가 옛 급여 화면에서 밀려나기까지는 여전히 얼마간의 시간이 걸립니다. 저희가 지금 말할 수 있는 건 그 시간이 0이라는 게 아니라, 그 시간이 얼마였고 그동안 무엇이 열려 있었는지를 나중에 짚어 낼 수 있다는 것입니다.
🚀플렉스팀 채용페이지 바로가기
☕flex Private Talk 신청하기