← 전체 글

외부 협업자에게 무엇까지 열어줄 것인가 — BFF 구조에서 격리된 개발 환경 설계하기

· 8 분 분량
caddy 만 외부에 열려 있고 api·db·redis 는 내부망에만 있는 서버 구성도
인터넷에서 들어오는 문은 caddy 하나뿐이다. ● 이 붙은 곳만 DB 자격증명을 갖는다 — admin 에는 없다.

디자이너가 로컬에서 화면을 띄워놓고 고치고 싶다고 했습니다. 흔한 요청인데, 제가 담당하는 플랫폼에서는 간단하지 않았습니다.

api는 내부망에만 있습니다. caddy가 프록시하지 않으니 외부에서 부를 URL 자체가 없고, db·redis는 포트도 안 열려 있습니다. 반대로 admin(Next.js)은 BFF라서 서버 컴포넌트가 렌더 시점에 api를 호출합니다. 로컬에 admin만 띄우면 그 fetch가 전부 실패해서 화면이 500으로 뜹니다. 프론트만 고칠 사람에게도 api 연결이 필요하다는 뜻입니다.

닿게 하려면 SSH 터널밖에 없는데, SSH 접속을 열어주면 docker compose down도 같이 열립니다. 결국 터널만 되고 나머지는 안 되는 접근을 어떻게 만드느냐가 문제였습니다.

authorized_keys 옵션으로 셸을 막고 포트포워딩만 남긴 키를 발급했습니다. 터널 목적지도 포트 하나로 못 박았습니다.


1. 이 플랫폼은 이렇게 생겼습니다

서버 한 대 위에 docker compose로 전체 스택이 올라갑니다.

브라우저
│ https
┌───────────────────┐
│ Cloudflare │
└─────────┬─────────┘
══════════════════════════════│═══════════ 서버 한 대 ═══════════
┌───────────────────┐
│ caddy 80/443 │ ◀── 외부에 열린 유일한 문
└───┬───────────┬───┘
admin.example.com │ {slug}.example.com
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ admin (Next.js) │ │ file_server │
│ :3000 blue⇄green │ │ dist/{slug}/*.html│
│ DB 자격증명 없음 │ │ (앱이 아니다) │
└─────────┬─────────┘ └─────────▲─────────┘
│ fetch(API_BASE) │ 읽기
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ─ ─ ─ backend (외부 미노출) ─ ─ ─ ─ ─ ─ ─
▼ │
┌───────────────────┐ │
│ api :8787 │ │
│ ● DATABASE_URL │ │
└────┬─────────┬────┘ │
▼ ▼ │ dist 볼륨
┌────────────┐ ┌────────────┐ │
│ db ● │ │ redis │ │
└─────▲──────┘ └─────▲──────┘ │
┌──┴──────────────┴──┐ │
│ worker ● ├────────────┘
└────────────────────┘
● = DB 자격증명을 가진 곳. admin 에는 없다.

네트워크는 둘입니다. web은 caddy가 붙는 외부 노출망이고, backend는 db·redis가 사는 내부망입니다. db와 redis는 backend에만 있고 포트를 열지 않습니다. 비밀번호를 알아도 부를 주소가 없습니다.

api도 마찬가지입니다. caddy가 프록시하지 않아서 인터넷에서 api로 가는 URL이 존재하지 않습니다. compose 파일에 그 의도를 그대로 적어뒀습니다.

# API: 내부 전용(backend). caddy가 직접 프록시하지 않음(admin이 API_BASE로 호출)

원칙 세 개로 짰습니다

원칙구현
지킬 문은 하나만 둔다외부 노출은 caddy 80/443뿐
강한 자격증명은 안쪽에 가둔다토큰은 컨테이너 안 파일에서 읽는다
화면 계층은 데이터를 소유하지 않는다admin에 DATABASE_URL없다

세 번째가 admin을 BFF로 만든 이유입니다. 화면 서버에 DB 자격증명이 없으면, 그 계층이 뚫려도 거기서 얻을 수 있는 DB 접속 정보가 존재하지 않습니다. 덤으로 “admin에 DB 비밀번호가 제대로 설정됐나”는 애초에 점검 항목이 아닙니다. 환경변수 목록에서 처음부터 빠집니다.

대가는 있습니다. 화면 하나 그리는 데 네트워크 홉이 하나 더 붙습니다. 그 값은 치를 만하다고 판단했습니다.

BFF가 실제로 하는 일

이름이 헷갈리기 좋게 생겼는데, BFF는 api가 아니라 admin입니다. Backend For Frontend — 브라우저 입장에서 admin이 백엔드이고(Next.js 서버가 돌고 있으니까), 그 백엔드는 오직 이 화면 하나를 위해 존재합니다. api는 BFF가 호출하는 진짜 백엔드, 데이터의 주인입니다.

브라우저 ──▶ admin (서버 코드) ──▶ api ──▶ db
쿠키만 │ 토큰
◀── HTML ───────┘ ◀── JSON ── ← 여기서 모양이 바뀐다

경계는 파일 하나에 있습니다. admin/app/_lib/api.ts의 첫 줄이 스스로 그렇게 선언합니다.

admin/app/_lib/api.ts
// BFF 프록시. 브라우저에 토큰 노출 금지.
...
const API_BASE = process.env.API_BASE ?? 'http://api:8787';

여기서 두 가지가 일어납니다.

첫째, JSON을 받아 화면으로 만들어 내려줍니다. api가 준 JSON이 브라우저까지 흘러가는 게 아닙니다. 서버 컴포넌트가 렌더 전에 api 응답을 기다렸다가, 완성된 HTML을 내려보냅니다. 브라우저는 JSON을 구경도 못 합니다. 같은 패턴을 쓰는 페이지가 admin/app 아래 39개 있습니다.

둘째, 자격증명을 갈아 끼웁니다. 브라우저가 보낸 로그인 쿠키가 있으면 그대로 api에 넘기고(사용자 자격), 없으면 서비스 토큰을 붙입니다. 그 토큰은 컨테이너 안 파일에서 읽습니다. 브라우저는 이 토큰의 존재조차 모릅니다.

경계선은 'use client'입니다. page.tsx에 그 줄이 없다는 게 이 파일이 서버에서 돈다는 표시이고, 그래서 api:8787 같은 내부망 주소를 부를 수 있습니다.

┌─ page.tsx (서버) ────────────┐
│ api 호출 → JSON → 화면 반환 │ admin 컨테이너
│ <CategoryChat initial={…}/>│
└──────────┬───────────────────┘
│ 데이터를 props 로 넘긴다
┌─ 'use client' ───────────────┐
│ useState · onClick · 상호작용│ 브라우저
└──────────────────────────────┘

2. 요청이 이 설계와 부딪혔습니다

디자이너의 요구는 단순했습니다. 로컬 :3000으로 화면을 띄우고, 데이터는 실제 운영 중인 것을 보고, 프론트엔드 코드만 고치겠다는 것.

화면만 고치는데 서버가 필요한 이유

화면 대부분이 서버에서 그려지고, 그리는 시점에 api를 부릅니다. api에 못 닿으면 HTML이 아예 안 만들어집니다. 프론트 코드를 아무리 고쳐도 화면 자리에 500이 뜹니다. “프론트만 고치는데 왜 서버가 필요하냐”의 답이 여기 있습니다.

로컬에 DB째로 띄우는 방법도 생각해봤습니다. 그런데 시드 데이터가 사실상 없어서 목록·통계·카드가 전부 빈 화면이 됩니다.

정리하면 선택지는 하나뿐이었습니다. SSH 포트포워딩으로 터널을 뚫어 로컬 화면 서버가 운영 api를 보게 하는 것.

문제는 그다음입니다. 터널을 뚫으려면 SSH 접속 권한이 필요하고, 그 권한이면 서버에 들어가 뭐든 할 수 있습니다. docker compose down 한 줄이면 서비스가 내려갑니다. 파이프만 주고 열쇠는 안 주는 방법이 필요했습니다.

열기 전에 기준을 정했습니다

바로 구현하지 않고 조건을 먼저 적었습니다. 이후의 모든 결정은 이 표를 통과했는지로 갈렸습니다.

기준
서버를 조작할 수 없어야 한다실수든 고의든 운영은 못 건드리게
내(개발자) 노트북이 개입하지 않아야 한다내가 켜둬야 도는 구조는 실패다
터널 목적지는 하나여야 한다다른 내부 포트로 새면 안 된다
회수가 한 줄이어야 한다어려우면 아무도 회수하지 않는다

이 기준으로 후보들이 정리됐습니다.

  • 내 SSH 키 공유 — 첫 번째 기준에서 탈락. 셸·sudo·docker가 통째로 따라갑니다.
  • 내 노트북에서 터널을 띄워 중계 — 두 번째 기준에서 탈락. 제 노트북이 꺼지면 상대 작업이 멈춥니다.

남은 방향은 디자이너 전용 키를 따로 발급하되, 그 키로는 터널만 되게 만드는 것이었습니다.


3. 격리 — 셸은 막고 터널만

먼저, 인증과 인가는 다른 층입니다

“터널을 열어준다”고 하면 아무나 뚫을 수 있는 것처럼 들리는데, 그 전에 관문이 하나 더 있습니다.

누군가 서버 22번을 두드림
├─ 등록된 개인키 없음 → Permission denied (publickey) ← 외부인은 여기서 끝
└─ 디자이너 개인키 있음 → 인증 통과
└─ 그 키 줄의 옵션 적용 → 터널 하나만 허용

이 서버는 비밀번호 인증을 꺼두고 공개키만 받습니다.

pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no

22번은 인터넷에 열려 있어서 봇이 끊임없이 두드리지만, 비밀번호를 아무리 대입해도 통과할 경로가 없습니다. 키 인증은 서버가 던진 값에 클라이언트가 개인키로 서명해 답하고 서버가 공개키로 검증하는 방식이라, 개인키 자체는 네트워크로 오가지 않습니다. 통신을 전부 엿들어도 훔칠 게 없습니다.

그래서 층이 이렇게 나뉩니다.

질문막는 대상
인증너 누구냐외부인 전부
인가뭘 할 수 있냐셸·명령·다른 포트

이 절에서 다루는 restrict·permitopen외부인을 막는 장치가 아닙니다. 외부인은 이미 인증에서 걸립니다. 이 옵션들은 인증을 통과한 디자이너가 할 수 있는 일을 좁히는 용도입니다.

명령 통로와 포트 통로는 따로 돕니다

여기서 설계가 갈렸습니다. SSH 접속 하나 안에서 두 가지 일이 서로 독립적으로 돕니다.

SSH 연결 하나
├── session 채널 → 셸 실행, 명령 실행
└── direct-tcpip 채널 → 포트포워딩(-L)

포트포워딩은 서버에 로그인해서 하는 일이 아닙니다. 별도 채널이라 셸을 완전히 막아도 터널은 살아남습니다. 이 사실 하나로 “파이프만 주고 열쇠는 안 준다”가 가능해집니다.

구현은 계정을 새로 만드는 게 아니라, authorized_keys에서 키마다 다른 제약을 거는 방식입니다. 같은 계정으로 로그인하지만 허용되는 일이 키별로 다릅니다.

┌─ ~/.ssh/authorized_keys ──────────────────────────────────┐
│ │
│ ssh-ed25519 AAAA... me@laptop ← 옵션 없음 │
│ ↑ 제약 0. 셸·sudo 전부 허용 │
│ │
│ restrict,port-forwarding,permitopen="127.0.0.1:8987", │
│ command="/bin/false" ssh-ed25519 AAAA... designer │
│ ↑ 이 키로 붙으면 sshd 가 이 제약을 강제 │
│ │
└────────────────────────────────────────────────────────────┘

각 조각이 하는 일은 이렇습니다.

옵션하는 일
restrict전부 끈다 — pty, X11, agent forwarding, 포트포워딩, user-rc
port-forwarding그중 포워딩 하나만 되살린다
permitopen터널 목적지를 이 주소 하나로 못 박는다
command="..."session 채널로 뭐가 오든 지정된 것만 실행한다

셸이 열렸다가 막힌 게 아니라 애초에 다른 프로그램이 실행된 것입니다. 그리고 터널을 띄울 때 쓰는 ssh -N은 “session 채널을 아예 열지 않겠다”는 선언이라, forced command조차 돌지 않고 포워딩 채널만 남습니다.

목적지를 고정하려고 포트를 열었습니다

첫 구현에는 구멍이 하나 남아 있었습니다. permitopen을 걸지 못한 것입니다.

api 컨테이너는 도커 내부 네트워크에만 있어서, 서버 호스트에서조차 127.0.0.1:어떤포트로 부를 수 없었습니다. 닿으려면 컨테이너 IP를 알아야 합니다.

Terminal window
ssh -L 8787:172.20.0.5:8787 서버
# ^^^^^^^^^^ 배포될 때마다 바뀐다

그래서 터널을 띄우기 전에 컨테이너 IP를 조회해 붙이는 스크립트까지 만들었습니다. 문제는 목적지가 매번 달라지면 “이 주소로만 허용”을 걸 수 없다는 것입니다. 2절 표의 세 번째 기준을 못 지킨 상태였고, 그 말은 이 키로 db(5432) 같은 다른 내부 포트에도 터널을 뚫을 수 있다는 뜻입니다.

방향을 뒤집었습니다. 컨테이너를 쫓아다니는 대신, 호스트에 안 움직이는 주소를 하나 만들었습니다.

ports: ["127.0.0.1:8987:8787"]
# └ 호스트의 이 주소로 온 연결을 → 컨테이너의 8787로 넘긴다

이 한 줄이 하는 일은 셋입니다.

  • 호스트에 리스너가 생깁니다. 이제 서버 안에서 127.0.0.1:8987로 api를 부를 수 있습니다.
  • 인터넷에는 여전히 안 보입니다. 127.0.0.1은 그 기계 자신만 쓰는 주소라 외부 랜카드에 붙지 않습니다.
  • 주소가 안 바뀝니다. 배포로 컨테이너가 새로 떠도 호스트 쪽은 늘 127.0.0.1:8987입니다.

터널 명령도 따라서 고정됩니다.

Terminal window
ssh -L 8787:127.0.0.1:8987 서버
# ^^^^^^^^^^^^^^ 늘 같다

여기서 한 번 헷갈리기 쉬운데, -L의 목적지 주소는 서버가 해석합니다.127.0.0.1은 내 노트북이 아니라 서버 자신을 가리킵니다. 그래서 permitopen="127.0.0.1:8987"도 “이 키는 서버의 이 포트에만 연결할 수 있다”는 뜻이 됩니다.

목적지가 고정되니 permitopen을 걸 수 있게 됐고, IP 조회 스크립트는 통째로 사라졌습니다.

디자이너 노트북 서버
┌──────────────────────┐ ┌──────────────────────────┐
│ admin (next dev) │ SSH 터널 │ 127.0.0.1:8987 ─▶ api │
│ :3000 ─▶ 127.0.0.1:8787 ═══════════▶ │ (루프백에만 바인딩) │
└──────────────────────┘ └──────────────────────────┘
이 키로 되는 것 : 이 포트 하나
안 되는 것 : 셸 ✗ 명령 실행 ✗ db(5432) 등 다른 포트 ✗

포트를 여는 쪽이 더 안전해지는 지점입니다. 직관과 반대라 근거를 정리해뒀습니다.

주체포트 없을 때루프백에 열었을 때
인터넷닿지 않음닿지 않음
서버 셸 보유자컨테이너 주소로 닿음루프백으로 닿음
포워딩 허용 키컨테이너 주소로 닿음루프백으로 닿음

도달 가능한 주체가 늘지 않습니다. 경로만 바뀝니다. 대신 목적지가 고정되니 제한을 걸 수 있고, 그래서 순수하게 좁아집니다.

WARNING

127.0.0.1 접두어를 빼면 정반대가 됩니다. 0.0.0.0에 붙어 인터넷에 그대로 노출되고, 더 나쁜 건 Docker의 포트 퍼블리시가 ufw 같은 방화벽을 우회한다는 점입니다. 방화벽이 막고 있다고 착각하기 딱 좋습니다.


4. 절차를 사람이 기억하지 않게

여기까지 해도 순서는 남습니다. 터널이 살아 있는지 보고, 3000이 이미 물려 있지 않은지 보고, 그다음 dev 서버를 띄우는 순서요. 이걸 매번 기억하게 두면 언젠가 건너뜁니다.

디자이너는 터미널보다 코딩 에이전트로 작업하는 쪽이 편했기 때문에, 저장소에 /local 슬래시 커맨드를 만들어 절차를 고정했습니다. 그리고 “로컬 열어줘” 같은 자연어 요청도 같은 절차를 타도록 에이전트 지침 파일 맨 위에 규칙을 적었습니다. 슬래시 커맨드를 몰라도 결과가 같아야 하니까요.

즉석에서 pnpm dev만 실행하면 두 군데서 깨집니다.

  • 터널이 죽어 있으면 전 화면이 500입니다. 원인이 화면 코드처럼 보여서 엉뚱한 데를 파게 됩니다.
  • 이미 dev 서버가 떠 있으면 Next가 3001로 밀립니다. 3000을 열어둔 채 “안 고쳐지는데요”가 나옵니다.

그래서 커맨드에 순서를 박았습니다. 3000 점유 확인 → 터널 상태 확인(죽었으면 재등록) → dev 서버를 백그라운드로 → curl로 응답을 확인한 뒤 주소 안내. 마지막 단계가 특히 중요한데, 확인 없이 “떴습니다”라고 답하는 걸 막기 위해서입니다.

하지 말아야 할 것도 같이 적었습니다. next build는 dev 서버와 .next/를 공유해서 켜둔 서버를 깨뜨리고, 화면이 안 뜬다고 설정 파일 값을 임의로 바꾸면 원인이 묻힙니다. 서버에 docker compose를 실행하는 것도 금지 항목입니다. 사람에게 주의를 주는 대신 절차 파일에 적어두면, 그 자리에 있지 않아도 규칙이 적용됩니다.

기준에 비춰본 채점

2절에서 세운 표로 돌아옵니다.

기준결과
서버를 조작할 수 없어야 한다✅ 셸·명령 실행 모두 차단(실측 확인)
내(개발자) 노트북이 개입하지 않아야 한다✅ 디자이너가 자기 키로 직접 붙는다
터널 목적지는 하나여야 한다permitopen으로 고정
회수가 한 줄이어야 한다authorized_keys에서 한 줄 삭제

네 개 다 지켰습니다. 다만 이 방식이 유일한 답은 아닙니다.

더 깔끔한 쪽은 별도 미리보기 환경을 띄우는 것입니다. 브랜치를 서버에 올려 URL 하나로 공유하면 상대 노트북에 아무것도 설치할 필요가 없습니다. 그걸 고르지 않은 이유는 단순합니다. 지금 필요한 건 디자이너가 코드를 고치면서 즉시 확인하는 것이고, 미리보기 환경은 매번 빌드를 기다려야 합니다. 작업 성격이 바뀌면 그때 옮기면 됩니다.

돌아보면 제일 값을 한 건 기준을 먼저 적어둔 것이었습니다. 컨테이너 주소를 쫓아다니던 첫 구현을 버린 것도 표의 세 번째 줄 때문입니다. 그게 없었으면 “터널 뚫리네” 하고 그대로 넘겼을 겁니다.