Insight
home
News
home

"배포해줘" 한마디로 끝나게 만들기

날짜
2026/09/02
안녕하세요, 프리윌린 프론트엔드 엔지니어링 파트입니다. 오늘은 개발자가 아닌 구성원도 AI로 만든 웹앱을 명령어 한 번으로 배포할 수 있게 만든 사내 배포 포털 이야기를 공유하려 합니다. 왜 만들었는지부터, 만들면서 빠진 함정들과 거기서 건진 교훈까지 순서대로 담았습니다. 시작은 이렇습니다. 사내에 AI 코딩 도구가 확산되면서 흥미로운 풍경이 펼쳐졌습니다. 기획자가 직접 아이디어를 프로토타입으로 구현해 오고, 콘텐츠팀은 부서용 대시보드를 뚝딱 구축해 냈습니다. 운영 담당자 역시 반복적인 업무를 웹 도구로 바꿔버렸죠. 사내에 '직접 만들 줄 아는 사람'이 순식간에 늘었습니다. 반가운 변화였습니다. 그런데 곧바로 질문이 따라왔습니다.
"만들었는데… 이거 어디에 올리죠?"
로컬에서는 잘 돌아가는 웹앱이 있습니다. 팀에 공유해서 사용하고 싶습니다. 그런데 다음이 벽입니다. 호스팅 계정을 만들고, CLI를 설치하고, 빌드 설정을 맞추고, 환경변수를 넣고, 도메인을 연결해야 합니다. 개발자에게는 익숙한 루틴이지만 방금 처음으로 웹앱을 완성한 사람에게는 앱을 만드는 것보다 배포가 더 어렵게 느껴집니다. 이 벽을 방치하면 사람들은 각자 우회로를 찾습니다. 개인 무료 호스팅 계정에 사내 도구를 올리면 그 도구는 회사 통제 밖에 존재하게 되고 퇴사나 이동 시 회수할 방법이 없습니다. 공개 URL로 팀에 공유하면 사내 데이터가 담긴 화면이 인터넷 전체에 노출됩니다. AI가 생성한 코드에 API 키가 하드코딩된 채 프론트엔드 번들로 나가면 시크릿이 그대로 새어 나갑니다. 정식 경로가 없어서 생기는 문제입니다. 그래서 정식 경로를 만들기로 했습니다. 사내 배포 포털, 이름은 올림입니다. 앱을 '올린다'는 뜻입니다. 사용자 쪽에서는 에이전트한테 "배포해줘" 한마디만 하면 끝나야 하고, 회사 쪽에서는 인증, 시크릿 통제, 접근 제어, 회수 가능성까지 다 보장돼야 합니다. 언뜻 보면 안 맞는 요구 같습니다. 그런데 배포 경로를 하나로 고정해버리니 신기하게도 둘 다 잡혔습니다. 이번 설계는 가장 쉬운 길이 곧 가장 안전한 길이 되도록 경로 자체를 하나로 좁히는 데 집중했습니다.

어떤 시스템을 만들었나

사용자가 겪는 흐름은 이렇습니다.
구성 요소는 셋입니다. 첫째, 템플릿. 사용자는 빈 폴더가 아니라 사내 표준 템플릿에서 시작합니다. 템플릿에는 빌드 설정과 AI 게이트웨이 연동 가이드, 그리고 배포 스크립트가 동봉되어 있습니다. 사용자가 에이전트에게 "배포해줘"라고 말하면 에이전트가 이 스크립트를 실행해 빌드 산출물을 포털로 전달합니다. 사용자는 배포 과정을 몰라도 되고, 알 필요도 없습니다. 둘째, 포털. 신뢰 경계는 전부 여기에 있습니다. 인증, 보안 스캔, 실제 호스팅 API 호출, 버전 기록, 접근 제어까지. 사용자와 에이전트는 그 뒤에 어떤 호스팅 플랫폼이 있는지 몰라도 됩니다. 저희는 호스팅으로 Vercel의 배포 REST API를, 인증·DB·스토리지로 Supabase를 사용했습니다. 셋째, 방화벽. 배포된 앱은 회사 네트워크 대역에서만 접근되도록 WAF로 IP를 제한합니다. "공개 URL에 사내 데이터" 문제를 앱마다 챙기는 대신 인프라 단에서 한 번에 차단합니다. 설계 원칙도 처음부터 세 가지를 못 박았습니다. 빌드는 로컬에서 합니다. 원격 빌드를 하면 사내 프라이빗 패키지 인증을 포털에 넘겨야 하고 빌드타임 코드 실행이라는 공격면이 생기는데 로컬 빌드는 둘 다 피할 수 있습니다. 보안 스캔을 통과하기 전에는 아무것도 하지 않습니다 — 버전 기록도, 저장소 승격도, 배포도 없습니다. 그리고 서버 시크릿은 저장하지 않습니다 — 배포 요청에 실려 와 호스팅 함수의 환경변수로 주입될 뿐, 포털의 DB에도 스토리지에도 남지 않습니다. 인증은 처음부터 두 갈래로 나눴습니다. 사람은 사내 구글 계정 OAuth로 로그인하고(허용 도메인 제한), 에이전트는 포털이 발급한 배포 전용 API 키를 씁니다. 에이전트에게 사람 계정의 세션을 쥐여주는 대신 용도가 배포뿐인 별도 키를 주면, 유출돼도 할 수 있는 일이 '그 사람 이름으로 배포' 하나로 제한되고 회수도 키 하나 삭제로 끝납니다.

만들면서 풀어야 했던 것들

"배포해줘" 한마디는 어떻게 진짜 배포가 될까?

답은 템플릿에 있습니다. 개발자가 배포 때마다 손으로 하던 일 (빌드하고, 산출물을 정리하고, 환경변수를 챙기고, 서버 함수를 준비하는 일) 을 배포 스크립트 하나에 담아 템플릿에 동봉했습니다. 사용자가 에이전트에게 "배포해줘"라고 말하면 에이전트가 이 스크립트를 실행해 산출물을 만들고, 포털이 그것을 받아 배포합니다. 말하자면 프로젝트마다 '배포를 아는 개발자' 한 명이 스크립트 형태로 함께 들어 있는 셈입니다. 스크립트가 배포 한 번에 챙기는 일은 이렇습니다.
가장 공들인 부분은 환경변수입니다. AI 게이트웨이 키 같은 서버 시크릿은 프론트엔드 번들에 들어가는 순간 그대로 노출됩니다. 사내 가이드가 권장하는 패턴은 시크릿을 번들에 넣지 않고 서버리스 프록시 함수의 환경변수로만 읽는 것인데, 올림의 사용자에게는 "프록시 함수를 만들어 시크릿을 분리하세요"라는 요구 자체가 이미 벽입니다. 그래서 이 패턴이 알아야 하는 지식이 아니라 그냥 따라오는 기본값이 되도록 템플릿을 설계했습니다. 템플릿에는 표준 AI 게이트웨이 프록시 함수가 미리 들어 있습니다. 배포 스크립트가 zip 루트에 /api 함수 소스를 함께 얹으면 호스팅 플랫폼의 zero-config 빌드가 이를 서버리스 함수로 만들어 주고, 환경변수는 배포 요청 본문에 실려 함수 런타임에만 주입됩니다. 앱 빌드는 여전히 로컬에서 하므로 원격 빌드의 리스크도 피합니다. 사용자는 "AI 기능이 되는 앱"을 만들 뿐, 프록시의 존재도 시크릿의 행방도 몰라도 됩니다. 환경변수 분류도 스크립트의 몫입니다. 어떤 키는 번들에 노출돼도 되는 클라이언트용이고 어떤 키는 절대 노출되면 안 되는 서버용인지, 사용자는 구분하기 어렵습니다. 스크립트가 이름 규칙으로 자동 분류해 서버용 키만 전송하고, 로그에는 키 이름만 남기고 값은 찍지 않습니다. 올림 쪽에서도 받은 환경변수를 DB나 스토리지에 저장하지 않고 호스팅 API로 흘려보내기만 합니다. 시크릿이 머무는 곳은 사용자의 로컬과 함수 런타임, 둘뿐입니다. 그리고 이 흐름 한가운데에 보안 스캔이 있습니다. 올림의 사용자는 보안 교육을 받은 개발자가 아닙니다. AI가 생성한 코드에 API 키가 하드코딩되어 있어도 알아차리지 못할 수 있습니다. 그래서 검사를 사람의 주의력에 맡기는 대신 배포 경로 자체에 심었습니다. 템플릿에는 커밋 단계에서 시크릿을 잡는 로컬 스캔 훅이 들어 있고, 포털은 업로드된 산출물 전체를 서버에서 한 번 더 스캔합니다. 로컬 도구는 사용자가 끄거나 우회할 수 있으므로 강제할 수 있는 최종 관문은 서버뿐입니다. 클라우드 키 접두사나 프라이빗 키 블록 같은 고신뢰 패턴부터 제네릭한 키=값 대입까지 검사하고, /api 함수가 외부 패키지 의존성을 동반하는 경우도 — 검증되지 않은 코드가 서버에서 실행되는 셈이라 — 여기서 차단합니다. 스캔을 통과하기 전에는 버전 기록도 배포도 일어나지 않습니다. 스캔에 걸리면 배포는 차단되고, 무엇이 어느 파일에서 걸렸는지가 에러 응답으로 돌아갑니다. 여기서 이 구조의 자연스러움이 나옵니다. 그 에러를 읽는 것은 사람이 아니라 에이전트입니다. 에이전트는 메시지를 보고 하드코딩된 키를 .env.local로 옮기고 다시 배포합니다. 옮겨진 키는 앞서 설명한 환경변수 경로를 타고 서버 함수에만 주입됩니다. 사용자에게는 "키가 노출될 뻔해서 안전한 위치로 옮기고 다시 배포했어요" 한 줄이 보일 뿐입니다. 차단이 막다른 길이 되지 않는 이유는 시크릿이 가야 할 올바른 경로가 같은 흐름 안에 이미 준비되어 있기 때문입니다. "하지 마세요"로 끝나는 검사가 아니라 "이 길로 가세요"까지가 한 세트인 것입니다. 이 밖의 디테일도 몇 가지 챙겨야 했습니다. SPA는 새로고침하면 404가 나므로 배포 시 폴백 rewrite를 주입하되, /api 경로는 가로채지 않도록 제외했습니다. 윈도우에서 배포 후 브라우저가 자동으로 안 열리던 문제처럼, 개발 도구에 익숙한 사람이라면 그냥 넘겼을 문제도 여기서는 고쳐야 할 버그였습니다 — 사용자 다수가 윈도우를 쓰는 비개발자니까요. 이 구조의 약점은 하나입니다. 스크립트가 사용자의 로컬에 복사되는 순간 그 버전으로 굳어버린다는 것. 그 이야기가 바로 다음 함정입니다.

"업로드가 용량제한에서 잘린다면?"

어느 날부터 "배포가 안 된다"는 제보가 들어왔습니다. 원인은 서버리스 함수의 요청 본문 하드리밋(4.5MB)이었습니다. 재미있는 건, 앱 코드에는 50MB 크기 체크가 이미 있었다는 점입니다. 4.5MB를 넘는 요청은 그 체크에 도달조차 못 하고 엣지에서 먼저 잘렸습니다. 상수를 아무리 올려도 고쳐지지 않는, 처음부터 죽은 코드였던 셈입니다. 해결은 업로드 경로를 함수 본문 밖으로 빼는 것이었습니다.
클라이언트가 스토리지에 직접 올리고 포털은 경로만 받습니다. 스캔을 통과하기 전에는 스테이징 영역에만 존재하고, 통과하면 move 연산으로 정식 경로에 승격됩니다. 경로 소유권을 검증해서 다른 사용자의 스테이징 객체를 지정하는 변조도 막았습니다. 그런데 진짜 문제는 따로 있었습니다. 이미 배포해 본 사용자들의 로컬에는 옛 방식의 스크립트가 있고, 자동 업데이트 수단이 없습니다. 한 번 배포되어 프리즈된 구버전 클라이언트에게 메시지를 전달할 수 있는 유일한 채널은 HTTP 응답뿐입니다. 그래서 옛 형식의 요청이 오면 배포하지 않고 426 Upgrade Required와 함께 한 줄짜리 갱신 명령을 응답에 담아 보내도록 했습니다. 에이전트가 그 명령을 실행하면 포털이 서빙하는 최신 스크립트로 파일 하나만 교체되고, 재시도하면 새 경로로 배포됩니다. 사람이라면 에러 메시지를 읽고 문서를 찾아갔겠지만 에이전트는 응답에 적힌 명령을 바로 실행합니다. 클라이언트를 배포한 순간부터 API 에러 메시지가 곧 업데이트 채널이라는 것을 이때 배웠습니다.

"어제 공유한 링크가 낡은 버전을 가리킨다면?"

초기에는 배포할 때마다 카드에 표시되는 공유 URL이 바뀌었습니다. 호스팅 플랫폼의 deployment URL(배포마다 해시가 달라지는 주소)을 저장하고 있었기 때문입니다. 메신저에 공유한 링크가 다음 배포에서 낡은 버전을 가리키게 되는 구조입니다. 먼저 프로젝트의 고정 alias(배포가 바뀌어도 항상 최신을 가리키는 영구 도메인)를 공유 URL로 저장하고 버전별 상세에만 각 배포의 직링크를 남기도록 고쳤습니다. 이후에는 한 발 더 나가 {앱이름}.<사내 배포 도메인> 형태의 커스텀 서브도메인을 배포 시 자동 연결했습니다. 외부 플랫폼 냄새가 나는 주소 대신 회사 도메인으로 공유되도록요. 작지만 아낀 설계가 두 가지 있습니다. 도메인 연결 API가 409(이미 연결됨)를 돌려주면 성공으로 취급해 재배포를 멱등하게 만들었고, 도메인 연결 실패가 배포 실패로 번지지 않도록 best-effort로 격리했습니다. 핵심 경로(배포)와 부가 경로(도메인, 썸네일)를 구분하고 부가 경로의 실패가 핵심을 막지 않게 하는 이 원칙은, 바로 다음 이야기에서 진가를 발휘합니다.

"방화벽 안에 있는 앱의 스크린샷을 어떻게 찍지?"

프로젝트가 쌓이니 목록이 텍스트 카드의 나열이 됐습니다. 배포가 완료되면 앱의 첫 화면을 자동으로 캡처해 카드에 띄우고 싶었습니다. 처음 설계에서 가장 먼저 부딪힌 건 방화벽이었습니다. 배포된 앱은 사내 IP만 허용하므로 서버리스 함수에서 띄운 headless 브라우저는 접근이 차단됩니다. 상시 통행용 시크릿 헤더를 WAF 예외로 박아두는 방법이 있지만 그 시크릿이 유출되면 IP 제한 전체가 무력화됩니다. 대신 캡처 1회용 토큰을 설계했습니다. 캡처 직전에 토큰을 발급해 WAF 예외 규칙에 넣고, headless 브라우저가 그 토큰을 헤더로 실어 접근하고, 캡처가 끝나면 성공이든 실패든 규칙을 복원해 토큰을 폐기합니다. 토큰 헤더는 same-origin 요청에만 주입되도록 스코핑해서 앱이 로드하는 서드파티 리소스로 토큰이 새는 것도 막았습니다. 상시 시크릿은 존재하지 않고, 유출돼도 수십 초짜리 토큰입니다. 설계는 깔끔했는데 서버리스 환경의 함정이 하나씩 나타났습니다. 배포하자 캡처 라우트가 인증 검증에 도달하기도 전에 500을 뱉었습니다. 로컬에서는 멀쩡했습니다. 서버리스용 chromium 패키지가 ESM 전용인데 번들 외부화 설정 때문에 CJS로 컴파일된 라우트가 런타임에 require(ESM)을 수행했습니다. 로컬 Node 버전은 이를 지원하지만 함수 런타임의 Node 버전은 아니었습니다. 정적 import가 모듈 최상단에 있어 try/catch가 잡을 기회도 없이 라우트가 즉사했습니다. 무거운 네이티브 의존성은 핸들러 안에서 동적 import로 지연 로드하도록 바꿨습니다. 로드가 실패해도 500이 아니라 관측 가능한 에러 응답으로 강등됩니다. 고쳤는데도 500이었습니다. 번들 외부화 설정은 "번들에서 빼라"는 뜻이지 "함수 산출물에 파일을 넣어준다"는 보장이 아니었습니다. 파일 추적(tracing) 설정에 패키지를 명시해 강제로 포함시켰습니다. 그랬더니 이번엔 빌드가 터졌습니다. pnpm의 심링크 경로와 실체 경로를 둘 다 넣었더니 글롭이 심링크를 따라가면서 100MB짜리 chromium 바이너리가 함수마다 이중 포함됐고, 캡처 라우트 세 개에 곱해져 산출물이 수백 MB로 부풀었던 것입니다. 심링크 경로 하나만 남기면 충분했습니다. 드디어 캡처가 됐는데 썸네일 속 한글이 모두 공백이었습니다. 서버리스 chromium의 번들 폰트에 한글 글리프가 없었기 때문입니다. 구버전에 있던 폰트 추가 API는 최신 버전에서 제거된 상태라, Noto Sans KR을 번들해 브라우저 실행 전에 fontconfig가 읽는 디렉터리에 복사하는 방식으로 해결했습니다. 마지막 함정은 운영 중에 나왔습니다. 일부 썸네일에 방화벽 차단 페이지가 저장된 것입니다. 배포 상태 폴링이 READY를 관측할 때마다 캡처를 발사하고 있었는데 캡처는 수십 초 걸리고 폴링은 수 초 간격이라 한 배포에 캡처 여러 개가 동시에 돌았습니다. 각 캡처가 프로젝트당 하나뿐인 WAF 토큰 규칙을 자기 토큰으로 덮어쓰니 앞선 캡처의 브라우저가 403으로 차단되고 그 차단 화면이 썸네일로 박제된 겁니다. 수정은 두 겹입니다. DB의 조건부 update(READY가 아닐 때만 READY로)를 이용해 실제 상태 전이를 일으킨 폴링 하나만 캡처를 발사하도록 했고, 응답이 2xx가 아니면 저장하지 않고 재시도를 주기 작업에 맡겼습니다. 폴링 기반 트리거는 '관측'과 '전이'를 구분하지 않으면 반드시 중복 발사됩니다. 이 여정 내내 지킨 것이 앞서 말한 원칙입니다. 썸네일 캡처는 어떤 단계에서 실패해도 배포를 막지 않습니다. 실패는 관측 가능하게 남기고, 재시도는 주기 작업이 줍습니다. 이 외에도 자잘한 다듬기가 이어졌습니다. 함수 실행 리전을 서울로 고정하고 콜드 스타트를 완화해 체감 속도를 올리고, 온보딩 페이지를 자연어 중심으로 고치고, 즐겨찾기·공개 범위·검색 같은 포털다운 기능들을 붙였습니다. 하나하나는 작지만, "만들어진 것들이 모이는 장소"는 이런 디테일이 쌓여야 사람들이 실제로 머무는 곳이 됩니다.

만들고 나서 정리한 것들

사용자의 인터페이스는 사람이 아니라 에이전트입니다. 온보딩 가이드도 CLI 플래그 나열이 아니라 "에이전트에게 이렇게 말하세요" 중심의 자연어로 다시 썼습니다. 에러 메시지는 사람보다 에이전트가 먼저 읽으므로 에러 응답에 다음 행동을 담는 것이 문서보다 강력한 전달 수단이었습니다. 배포 지식은 문서가 아니라 실행 파일로 전달해야 합니다. 가이드를 아무리 잘 써도 비개발자는 따라 하다 막힙니다. 빌드, 환경변수 분류, 서버 함수 준비 같은 지식을 템플릿의 스크립트에 담아 에이전트가 실행하게 하면 지식은 전달되는 게 아니라 그냥 동작합니다. 로컬과 프로덕션의 런타임 차이는 인증보다 먼저 터집니다. ESM/CJS, Node 버전, 번들 파일 추적, 폰트까지 — 전부 로컬에서는 재현되지 않았습니다. "로컬에서 되는데요"가 서버리스에서는 유난히 자주 거짓말이 됩니다. 핵심 경로와 부가 경로를 처음부터 구분하면 장애가 번지지 않습니다. 도메인 연결도 썸네일 캡처도 실패할 수 있지만, 배포는 성공합니다. 반대로 보안 스캔처럼 타협하면 안 되는 것은 어떤 편의와도 바꾸지 않았습니다.

앞으로 보고 있는 것들

지금의 올림은 "올리는 길"을 만들었지만, 올라간 다음이 남아 있습니다. 배포 후 앱이 실제로 정상 동작하는지까지 자동으로 확인해주는 배포 후 검증, 썸네일을 직접 올리고 싶은 사용자를 위한 수동 업로드 화면, 그리고 어떤 앱이 얼마나 쓰이고 있는지 보이게 하는 일입니다. 만든 사람에게는 "내 도구를 누가 쓰고 있다"는 신호만큼 강한 동기부여가 없으니까요.

마치며

지금은 만든 앱을 공유하고 싶은 구성원이 에이전트에게 "배포해줘"라고 말하면 끝납니다. 앱은 회사 계정 아래 사내 도메인으로 배포되고, 사내 네트워크에서만 열리며, 시크릿은 배포 전에 걸러지고, 버전과 썸네일이 포털에 쌓입니다. 만든 사람의 노트북 밖으로 나오지 못하고 사장되던 도구들이 이제 한곳에 모입니다. "만들 줄 아는 사람"이 늘어나는 속도는 앞으로 더 빨라질 겁니다. 그 속도를 조직의 자산으로 바꾸려면 만들어진 것들이 안전하게 모이는 장소가 필요하고, 올림은 그 장소를 만드는 작업이었습니다. 돌아보면 핵심은 기술보다 방향이었습니다. 가장 쉬운 길을 가장 안전한 길로 만들 것. 배포가 명령어 한 번이 된 순간부터, 보안 정책은 사용자를 막는 규칙이 아니라 그 길 위에 이미 깔려 있는 바닥이 됐습니다. 비슷한 고민을 하고 계신 조직에 이 기록이 작은 참고가 되면 좋겠습니다. 긴 글 읽어주셔서 감사합니다.