이번 글에서는 레브잇(Levit)의 사내 보안 플랫폼 이야기를 해보려 한다.
먼저 전제를 하나 짚고 가자. 레브잇에는 별도의 보안 전담팀이 없다. 보안 운영은 코어플랫폼팀의 DevSecOps 한 명이 플랫폼 개발과 함께 맡고 있다.
문제는 보안 요청이 모두 사람에게 몰린다는 점이었다. 권한 부여, 정책 안내, GitHub 설정, 온보딩 지원, 취약점 대응까지 대부분의 요청이 Slack DM이나 수동 처리로 이어졌다. 담당자가 다른 일을 하고 있거나 자리를 비우면 처리가 늦어졌고, 요청이 쌓일수록 중요한 판단보다 반복 작업에 더 많은 시간을 쓰게 됐다.
우리는 이 문제를 사람을 더 투입해서 해결하기보다, 반복 가능한 보안 업무를 플랫폼으로 옮기는 방식으로 풀기로 했다. 임직원은 필요한 권한과 정보를 직접 신청하고, AI는 요청을 먼저 검토하고, 담당자는 정말 판단이 필요한 예외 케이스만 처리하도록 구조를 바꿨다.
이 글에서는 레브잇의 사내 보안 플랫폼을 만들면서 어떤 문제를 자동화했고, 어디에 안전장치를 뒀으며, 실제 운영에서 무엇을 배웠는지 정리한다.

1. 보안 요청은 왜 병목이 되었나
대부분의 회사에서 보안 담당자와 개발팀의 관계는 비슷한 방식으로 굳어지기 쉽다.
예를 들어 개발자가 새 레포지토리에 배포용 시크릿을 등록해야 한다고 해보자. 개발자는 Slack으로 보안 담당자에게 DM을 보낸다. 담당자는 다른 일을 하다가 한참 뒤에 메시지를 확인한다. 요청이 타당한지 보고, 누가 승인했는지 확인하고, 콘솔에 로그인해 설정을 바꾼 뒤 “처리했습니다”라고 답한다. 개발자는 그사이 30분에서 반나절을 기다린다. 담당자가 휴가라면 작업은 다음 날로 밀린다.
문제는 단순히 느리다는 것이 아니다. 더 근본적인 문제는 구조에 있다. 모든 요청이 한 사람을 거치면, 그 사람은 곧 병목이 된다. 그 사람이 자리를 비우면 처리가 멈추고, 요청이 많아지면 중요한 판단보다 단순 처리에 시간을 쓰게 된다.
바쁠수록 위험한 선택을 하기 쉬운 것도 문제다.
“일단 통과시키고 나중에 보자.”
인원이 적을수록 이런 상황은 더 빨리 온다. 이 문제를 푸는 방법은 크게 두 가지다.
첫 번째는 사람을 더 뽑아 승인 담당자를 늘리는 것이다. 당장은 병목이 줄어들 수 있다. 하지만 조직이 커지면 같은 문제가 다시 생긴다. 구조는 그대로이기 때문이다.
두 번째는 승인 담당자가 모든 요청을 직접 처리하는 구조 자체를 바꾸는 것이다. 사람이 직접 판단해야 하는 일과 시스템이 처리해도 되는 일을 나누고, 반복 가능한 업무는 셀프서비스와 자동화로 넘긴다.
우리는 두 번째 방법을 선택했다. 이 결정이 플랫폼의 전체 설계를 관통한다.
2. 반복 업무는 자동화하고, 판단이 필요한 일만 사람이 본다
보안 담당자의 일을 없애는 것이 목표는 아니었다. 오히려 반대였다. 사람이 해야 할 일을 제대로 할 수 있게 만드는 것이 목표였다.
먼저 DevSecOps가 실제로 처리하던 일을 성격별로 나눠봤다. 크게 세 가지가 섞여 있었다.
(A) 반복 처리
“이 요청자가 이 권한을 받아도 되는가?”, “콘솔에서 그룹 하나 추가하기”처럼 규칙으로 정리할 수 있고 판단이 거의 필요 없는 일이다.
(B) 지식 전달
“VPN은 어떻게 설치하나요?”, “이 정책은 왜 필요한가요?”처럼 답이 이미 정해져 있는데 매번 사람에게 물어보는 일이다.
© 판단이 필요한 일
“정책상 기본적으로는 안 되지만, 이 사업적 맥락에서는 예외를 줄 수 있는가?”처럼 사람의 맥락 이해와 책임이 필요한 일이다.
기존 구조의 가장 큰 문제는 (A)와 (B)가 DevSecOps의 시간을 대부분 차지한다는 점이었다. 정작 사람이 가장 잘해야 하는 ©에 쓸 시간이 부족했다.
그래서 목표를 이렇게 정했다.
반복 처리는 셀프서비스로 처리한다.
지식 전달은 가이드와 AI 챗봇이 맡는다.
판단이 필요한 요청은 AI가 먼저 정리한 뒤 사람에게 넘긴다.
다만 절대 양보할 수 없는 전제가 있었다. 안전장치 없는 셀프서비스는 자동화가 아니라 사고다.
권한을 직접 신청할 수 있게 하면서 자기 자신을 승인하게 두거나, 존재하지 않는 그룹을 부여하거나, 위험한 작업을 무심코 통과시키면 보안 플랫폼이 아니라 보안 구멍이 된다. 그래서 모든 자동화 경로에는 처음부터 “여기서는 반드시 막아야 한다”는 조건을 코드로 넣었다.
이 철학은 네 가지 핵심 기능과, 그 기능들이 모이는 어드민 콘솔로 구현됐다.
Press enter or click to view image in full size

이제 각 기능을 하나씩 살펴보자.
3. 하나의 기준 문서로 보안 지식을 관리한다
기존 방식
보안 지식은 여러 곳에 흩어져 있었다. 위키 한쪽, Notion 페이지, 누군가의 Slack 스레드, 그리고 가장 위험하게는 담당자의 기억 속에 있었다.
같은 질문이 분기마다 새 입사자에게서 반복됐고, 답은 누가 응대하느냐에 따라 조금씩 달라졌다. 지식이 사람에게 묶여 있으니, 그 사람이 자리를 비우면 지식도 함께 사라졌다.
개선 후
핵심은 보안 지식을 하나의 기준 문서로 관리하는 것이었다. 정책, 도구 사용법, 플랫폼 설명을 knowledge/의 Markdown과 app/guide/의 MDX로 정리하고, 모두 Git에 커밋해 PR 리뷰를 거치게 했다.
보안 문서가 “수정 이력이 남고 리뷰되는 코드”가 된 것이다.
그리고 이 문서를 사람이 읽는 가이드와 AI 챗봇이 함께 사용한다.
보안 문서 (knowledge/*.md, app/guide/*.mdx — Git 커밋, PR 리뷰 대상)
│
├─► 가이드 페이지 : @next/mdx + remarkGfm 로 렌더 → 브라우저 열람
│
└─► 버디 챗봇 : 가이드 컨텍스트를 프롬프트에 주입 → Q&A + 도구 호출같은 문서가 사람이 읽는 가이드이자 AI가 답하는 근거가 된다. 문서를 한 번 고치면 가이드와 챗봇의 답변이 함께 바뀐다. 챗봇은 예전 정보를 말하고 가이드는 최신 정보를 말하는 식의 불일치를 구조적으로 줄일 수 있었다.

여기서 한 단계 더 나아간 부분이 있다. 버디는 단순히 답변만 하는 챗봇이 아니라, 필요한 작업을 직접 처리하는 창구이기도 하다.
버디와 온보딩 챗봇은 최대 4턴의 에이전트 루프로 동작한다. 필요할 때는 도구를 호출한다. 모르는 질문은 관리자에게 넘기고(escalate_to_admin), 사내 레포지토리 정보를 물으면 조회 도구(query_repo_info)를 호출한다.
특히 온보딩 챗봇은 대화 중에 역할 설정, 모듈 완료, GitHub 계정 연결, 권한 신청(request_permission)까지 직접 수행한다.
신입 구성원이 “Datadog 읽기 권한이 필요해요”라고 말하면, 챗봇이 그 자리에서 권한 신청을 만든다. 안내문만 보여주는 챗봇이 아니라, 대화하면서 실제 업무를 처리하는 창구다.

긴 대화의 맥락을 잃지 않기 위해 간단한 벡터 검색도 붙였다. 과거 대화를 임베딩해 PostgreSQL의 pgvector에 저장하고, 새 질문이 들어오면 비슷한 과거 메시지를 찾아 컨텍스트에 넣는다.
임베딩은 Voyage AI의 voyage-3 모델을 사용한다. 여기서 작지만 중요한 운영 원칙이 코드에 드러난다.
부가 기능이 실패해도 전체 서비스 장애로 번지면 안 된다.
// lib/chat/embeddings.ts — voyage-3, 1024차원.
// 키가 없으면 '검색만' 끄고 서비스는 계속 산다.
export async function embedText(text: string): Promise<number[] | null> {
const apiKey = process.env.VOYAGE_API_KEY;
if (!apiKey) { /* 1회 경고 후 */ return null; } // ← 임베딩이 죽어도 챗봇은 답한다
const trimmed = text.length > 6000 ? text.slice(0, 6000) : text;
const resp = await fetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ input: [trimmed], model: 'voyage-3', input_type: 'document' }),
});
const data = await resp.json();
const vec = data?.data?.[0]?.embedding;
return vec && vec.length === 1024 ? vec : null;
}API 키가 없거나 호출이 실패하면 null을 반환하고 끝낸다. 시맨틱 검색은 있으면 좋은 기능이지만, 이 기능 때문에 챗봇 전체가 죽어서는 안 된다.
AI 기능을 운영 시스템에 붙일 때 먼저 정해야 하는 것은 “이 기능이 실패하면 무엇이 계속 동작해야 하는가”다.
무엇이 좋아졌나
자정에 입사 준비를 하는 사람도, 주말에 급한 작업을 하는 사람도 담당자를 기다리지 않아도 된다. 챗봇에게 묻고, 같은 기준의 답을 받을 수 있다.
보안 지식이 사람에게서 분리되어 시스템에 담겼고, 담당자의 부재가 곧 지식의 부재가 되는 상황이 줄었다.
배운 점
챗봇 답변에 Mermaid 다이어그램을 그릴 때 한글 라벨이 잘리는 문제가 있었다. 렌더링 엔진이 한글(CJK) 글자 폭을 실제보다 좁게 계산했기 때문이다.
CJK 처리 플러그인을 붙이고 htmlLabels: false로 우회해 해결했다. 영어권 도구를 한국어 환경에 그대로 가져오면 이런 작은 문제가 곳곳에서 생긴다는 점을 다시 확인했다.
앞으로 하고 싶은 일
지금은 사람이 지식 베이스를 채우고 정합성을 확인한다. 다음 단계는 챗봇이 답하지 못한 질문을 모아 “이 주제의 문서가 비어 있다”고 알려주는 것이다.
실제로 처리된 요청과 예외 사례를 바탕으로 새 문서 초안을 제안하고, 사람은 검수만 하는 흐름을 만들고 싶다. 보안 문서가 운영을 따라 자연스럽게 업데이트되는 구조가 목표다.
4. 보안 플랫폼에 들어오게 만드는 작은 장치: 버디 가챠
좋은 가이드와 똑똑한 챗봇을 만들어도 사람들이 들어와 보지 않으면 소용이 없다.
솔직히 말해 보안은 대부분의 사람에게 재미있는 주제가 아니다. 중요하다는 것은 알지만, 급하지 않으면 미루게 되는 일에 가깝다. 셀프서비스로 처리 시간을 줄이는 것과 별개로, 사람들이 플랫폼에 처음 들어오게 만드는 동기도 필요했다.
그래서 만든 것이 버디 가챠다.
보안 챗봇 “버디”는 하나의 고정된 봇이 아니라, 종마다 말투(persona)와 첫인사(greeting)가 다른 여러 캐릭터를 가진다. 사용자는 버디를 처음 만날 때 이 캐릭터를 뽑는다. 규칙은 코드에 그대로 들어 있다.
// lib/buddy/characters.ts
export const HIDDEN_PROBABILITY = 0.1; // 히든 캐릭터 등장 확률 10%
export const MAX_GACHA_ATTEMPTS = 5; // 1인당 뽑기 5회export function pickRandomBuddy(): BuddyCharacter {
const hiddens = BUDDY_CHARACTERS.filter((c) => c.hidden);
const normals = BUDDY_CHARACTERS.filter((c) => !c.hidden);
if (hiddens.length > 0 && Math.random() < HIDDEN_PROBABILITY) {
return hiddens[Math.floor(Math.random() * hiddens.length)]; // ✨ 히든 풀
}
return normals[Math.floor(Math.random() * normals.length)];
}일반 캐릭터에는 친근한 안내견형 버디, 깐깐하지만 통찰력 있는 정책 전문가형 버디 등이 있고, 10% 확률로만 등장하는 히든 캐릭터도 있다. 히든 캐릭터에는 회사 경영진을 본뜬 이스터에그를 넣었다.
“보안 가이드를 보러 들어왔다가 히든 캐릭터가 떠서 단톡방에 자랑하는 장면”을 의도한 것이다.
뽑은 버디는 도감에 모이고, 마음에 드는 캐릭터를 내 챗봇으로 설정할 수 있다.
이 장치는 단순한 장난만은 아니었다. 보안의 가장 큰 적은 무관심이다. 사람들이 플랫폼에 들어오지 않으면 잘 만든 가이드도 챗봇도 의미가 없다.
버디 가챠는 “일단 한 번 들어와 보게 만드는” 가장 가벼운 장치였다. 들어와서 버디를 뽑고, 그 버디에게 첫 질문을 던지는 순간 플랫폼은 낯선 사내 도구가 아니라 “내 버디가 있는 곳”이 된다.

물론 선은 분명히 지켰다. 현금 결제도 없고, 더 뽑게 만들기 위한 확률 조작도 없다. 가챠에 관심 없는 사람도 모든 기능을 똑같이 사용할 수 있다. 캐릭터 말투가 바뀌어도 버디가 답하는 보안 지식의 내용과 품질은 동일하다.
가챠는 입구의 환영 인사지, 기능 사용을 막는 통행료가 아니다.
5. 권한 신청과 부여를 셀프서비스로 처리한다
지식과 가이드가 사람을 플랫폼으로 데려왔다면, 실제로 사람들이 가장 많이 원하는 것은 권한이다.
권한 자동화는 효과가 크다. 동시에 가장 위험한 영역이기도 하다.
기존 방식
새 입사자가 들어오면 수십 개 시스템에 계정과 권한이 필요하다. 예전에는 DevSecOps가 콘솔을 하나하나 열어 손으로 권한을 부여했다.
입사 첫날 권한 세팅만으로도 담당자의 반나절이 사라졌다. 이후 발생하는 일상적인 권한 요청도 대부분 같은 방식으로 처리됐다.
개선 후
관리자가 권한 그룹을 승인하면, 시스템은 권한 문자열의 prefix를 보고 알맞은 외부 시스템 API를 호출한다.
어드민 전용 엔드포인트는 isCorePlatform 가드 뒤에 있고, 내부 분기는 단순하다.
// api/admin/onboarding/permissions/grant/route.ts
if (permission.startsWith('okta:group:')) {
const groupName = permission.slice('okta:group:'.length);
const r = await addUserToOktaGroup(target, groupName); // Okta API
externalOk = r.ok; externalError = r.error;
} else if (permission.startsWith('github:team:')) {
const teamFullName = permission.slice('github:team:'.length); // 예: wetripod/backend
const r = await inviteAndAddToTeam(username, teamFullName); // GitHub 초대 + 팀 추가
externalOk = r.ok; externalError = r.error;
}
// onepassword:vault:* / slack:channel:* 등은 외부 자동화 없이 상태만 granted 로 이동if (externalOk) await movePendingToGranted(target, permission);
console.log(JSON.stringify({ audit: true, action: 'admin_onboarding_grant', actor, target, permission }));외부 부여가 실패하면 상태를 옮기지 않는다. 모든 권한 부여에는 audit: true 로그가 남는다.
여기서 가장 큰 지렛대는 Okta 그룹이었다.
데이터 분석, 모니터링, 클라우드 같은 여러 SaaS의 권한 API를 각각 직접 호출하는 대신, Okta 그룹에 사람을 추가하는 것으로 처리한다. 그러면 Okta의 SCIM 프로비저닝이 해당 그룹에 연결된 SaaS로 사용자를 자동 동기화한다.
DevSecOps는 “누구에게 어떤 그룹을 줄 것인가”만 결정하고, 그 뒤의 전파는 표준 프로토콜이 맡는다.
다만 SCIM은 만능이 아니었다. 자동화에서 시간을 많이 쓴 부분은 깔끔한 성공 경로가 아니라, 외부 시스템마다 다른 예외 동작이었다.
Group Push의 ADD-only 문제
일부 Group Push 링크 모드는 멤버 추가만 동기화하고 제거는 전파하지 않았다. Okta 그룹에서 사용자를 제거했는데, 대상 시스템에는 여전히 멤버로 남는 것이다.
깔끔히 정리하려면 대상 SCIM 엔드포인트에 op=remove PATCH를 직접 호출해야 했다.
멱등성은 HTTP status만으로 판단할 수 없었다
GitHub 초대 API는 이미 멤버인 사람에게 422를 돌려준다. 일반적으로는 에러처럼 보이지만, 이 경우에는 "이미 처리됨"에 가깝다.
그래서 status code만 보지 않고 응답 본문을 정규식(/already/i)으로 확인해 멱등 처리했다.
셀프서비스의 핵심은 안전장치다
권한 셀프서비스를 만들면서 가장 먼저 세운 원칙은 이것이다.
> 승인 = 자동 처리
승인해 놓고 사람이 다시 콘솔에 들어가 수동 처리해야 한다면, 그것은 셀프서비스가 아니다.
초기에는 일부 요청을 “시스템에서 승인하고, 실제 처리는 사람이 수동으로 하는 방식”으로 흘려보낸 적이 있다. 결과는 좋지 않았다. 승인 기록은 남았는데 실제 처리가 누락된 건들이 생겼고, 십수 건을 나중에 백필해야 했다. 어떤 경로에서는 수동 처리가 조용히 실패했는데 아무도 몰랐다.
그 뒤로 원칙을 명확히 했다.
근본적으로 자동화할 수 없는 작업이라면, 애초에 셀프서비스 대상으로 삼지 않는다. 반만 자동화한 흐름이 가장 위험하다.
GitHub 셀프서비스
GitHub 관련 작업도 셀프서비스로 옮겼다.
레포지토리 생성, 시크릿 등록, 러너 토큰 발급 같은 작업을 폼에서 요청하면 GitHub App 권한으로 끝까지 자동 처리한다.
가장 신경 쓴 부분은 GitHub Actions 시크릿 등록이었다.
GitHub Actions 시크릿은 저장 전에 레포지토리의 공개키로 암호화해야 한다. 이 암호화를 서버가 아니라 브라우저에서 수행하게 했다. 평문 시크릿이 우리 서버로 전송되지 않도록 하기 위해서다.
서버는 공개키 조회만 프록시한다. 브라우저는 GitHub 토큰을 갖지 않으므로 공개키는 서버를 거쳐 가져오되, 암호화 자체는 클라이언트 메모리에서 끝낸다.
// lib/github-secret-seal.ts — 평문은 폼 메모리에만 존재, 서버로 절대 전송 안 됨
const { key, key_id } = await fetchPublicKeyViaServer(repoFullName); // 공개키만 서버 프록시
const sodium = (await import('libsodium-wrappers')).default; // 폼 진입 시점에만 번들 로드
await sodium.ready;
const encBytes = sodium.crypto_box_seal(
sodium.from_string(plaintext),
sodium.from_base64(key, sodium.base64_variants.ORIGINAL),
);
return { encryptedValue: sodium.to_base64(encBytes, ...), keyId: key_id };여기서도 의존성 이슈가 있었다. libsodium-wrappers 0.7.16이 Next.js 14 환경에서 ESM 모듈 해석에 실패했고, CJS-only인 0.7.15로 버전을 고정해야 했다. 최신 버전이 항상 정답은 아니라는 것을 다시 확인한 사례였다.
처리가 끝난 뒤 감사 로그에는 시크릿 이름, repo, 시각만 남긴다. 시크릿 값은 시스템 어디에도 저장하지 않는다.
GitHub App 권한에도 주의할 점이 있었다. GitHub App 권한은 2단계 모델이다. App에 정의된 권한과 설치(installation)에 실제로 부여된 권한은 별개다.
App manifest에 권한을 추가해도, org admin이 새 권한을 수락하기 전까지는 installation에 반영되지 않는다. 권한을 늘릴 때마다 이 두 단계를 모두 의식해야 했다.
무엇이 좋아졌나
읽기 권한처럼 명확한 요청은 자정에도 즉시 처리된다.
DevSecOps의 일과에서 권한 부여 콘솔 작업이 크게 줄었다. 모든 처리에는 신청자, 사유, 판단 결과, 실행 결과가 기록된다. 권한 부여가 “부탁하고 기다리기”에서 “신청하면 자동 처리되는 흐름”으로 바뀌었다.
앞으로 하고 싶은 일
지금은 사람이 요청해야 시스템이 움직인다. 다음 단계는 시스템이 먼저 제안하는 것이다.
직무와 팀을 보고 필요한 권한 묶음을 미리 제안하고, 일정 기간 사용하지 않은 권한은 회수를 제안하고 싶다. 권한의 생애주기, 즉 부여 → 사용 → 미사용 감지 → 회수가 사람의 기억이 아니라 시스템으로 돌아가는 상태가 목표다.
최소 권한 원칙이 정책 문서 속 문장이 아니라 시스템의 기본 동작이 되게 만들고 싶다.
