Big Project · AI's EYE 38

PostgreSQL을 공개하지 않고 미니PC API만 Tailscale로 공유한 팀 개발 환경

팀이 같은 DB와 API를 쓰려면 개발 서버 포트를 인터넷에 열거나 DB 주소를 공유해야 할 것 같다. 로컬 프런트와 공용 백엔드를 어떻게 안전하게 연결하는지 막막하다.

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

1장: 공용 개발 환경의 막막함, 어디서부터 시작해야 할까요?

솔라는 노트북 화면의 코드 에디터와 터미널 창을 번갈아 보며 한숨을 쉬었다. 화면 한쪽에는 방금 완성한 프런트엔드 컴포넌트가 localhost:5173 주소 위에서 매끄럽게 돌아가고 있었다. 하지만 이 컴포넌트가 실제 데이터를 가져와야 할 API 서버는 팀원들과 함께 쓰는 사무실 구석의 작은 미니PC 안에 있었다.

“언니, 이거 어떡하지?”

거실 소파에 앉아 있던 루나가 고개를 들었다. 솔라는 노트북을 들고 루나 옆으로 가 화면을 보여주었다.

“내 컴퓨터에서 만든 화면인데, 팀 공용 API를 붙여보려니까 막혔어. 팀원 컴퓨터에 있는 백엔드 서버에 접속할 방법이 없잖아. 결국 그 미니PC의 인터넷 주소를 알아내서 포트를 열어달라고 해야 하나? 아니면 다 같이 데이터베이스 접속 비밀번호라도 공유해야 해?”

솔라의 말에는 짜증과 불안함이 섞여 있었다. 팀 개발을 위해 다 같이 써야 할 자원인데, 막상 접근하려니 인터넷에 개발 서버를 무방비로 노출하거나 중요한 비밀을 단체 채팅방에 풀어놓는 것 같은 찜찜한 방법밖에 떠오르지 않았다. 얼마 전 스터디 자료에서 ‘PostgreSQL을 공개하지 않고 미니PC API만 Tailscale로 공유한 팀 개발 환경’이라는 제목을 스치듯 본 기억이 났지만, 그게 정확히 무엇을 의미하는지는 와닿지 않았다. 아는 단어들의 조합일 뿐, 실제 막막함 앞에서는 아무런 힘이 되지 못했다.

“팀원들이랑 같이 쓰려면 무언가를 공유해야 하는 건 맞지. 그런데 인터넷에 서버를 통째로 여는 건, 우리 집 현관문을 길거리 한복판에 열어두는 거랑 비슷하지 않을까?”

루나는 솔라의 노트북을 잠시 들여다보더니, 자신의 노트북을 가져왔다.

“잠깐 실험 하나만 해보자. 솔라 네 노트북에 아주 간단한 웹서버를 하나 띄워봐. 그리고 공유기 설정 페이지에 한번 들어가 볼래?”

솔라는 영문을 모른 채 간단한 파이썬 웹서버를 터미널에서 실행했다. Serving HTTP on 0.0.0.0 port 8000. 그리고 웹 브라우저 주소창에 192.168.1.1을 입력해 익숙한 통신사 로고가 박힌 공유기 관리자 페이지로 들어갔다. 루나는 ‘포트 포워딩’이라는 메뉴를 찾아보라고 했다.

솔라는 메뉴를 찾아 들어갔다. 외부에서 들어오는 특정 요청을 내부 네트워크의 어떤 기기로 연결해 줄지 정하는 규칙 목록이 나타났다. 루나의 말에 따라, 외부 포트 8000번으로 들어오는 요청을 솔라의 노트북 내부 IP 주소와 8000번 포트로 연결하는 새 규칙을 추가하려 했다. 솔라가 ‘적용’ 버튼을 누르려는 순간, 화면에 붉은색 경고 문구가 번쩍였다.

[경고] 포트를 외부에 개방하면 보안에 취약해질 수 있습니다. 허용되지 않은 접근으로 인해 개인정보 유출 및 시스템 손상이 발생할 수 있으니 주의하십시오.

솔라의 손가락이 멈칫했다. 늘 보던 경고문이었지만, 지금은 그 의미가 훨씬 무겁게 다가왔다. 팀원 한 명의 접속을 위해, 전 세계 모든 사람에게 내 노트북으로 들어오는 문을 하나 열어주는 셈이었다. 게다가 집 인터넷의 공인 IP 주소는 수시로 바뀔 수 있었다. 주소가 바뀔 때마다 팀원에게 새로 알려줘야 하는 번거로움은 덤이었다.

“이것 봐. 그냥 포트 하나 여는 건데도 이렇게 찜찜하고 번거롭네. 팀 개발 서버라면 훨씬 더 위험하겠지.”

“그렇지. 그래서 우리는 인터넷이라는 공공 광장이 아니라, 우리 팀원만 아는 비밀 통로를 만들 거야.”

루나는 ‘Tailscale’이라는 서비스의 홈페이지를 화면에 띄웠다.

“이건 우리 기기들만으로 이루어진 가상의 사설 네트워크를 만들어 줘. 인터넷에 연결되어 있지만, 마치 같은 방에 있는 컴퓨터들처럼 서로를 찾아갈 수 있게 해주는 거지. 각자 자기 컴퓨터랑, 팀 미니PC에 이걸 설치하기만 하면 돼.”

루나의 설명에 따라 솔라는 자신의 노트북과, 테스트용으로 쓰는 다른 기기에 Tailscale을 설치했다. 구글 계정으로 로그인하자 몇 분 만에 두 기기는 Tailscale 관리 페이지의 기기 목록에 나타났다. 각 기기에는 100.x.x.x로 시작하는 새로운 IP 주소가 부여되어 있었다.

솔라는 자신의 노트북 터미널을 열고, 방금 전 Tailscale이 다른 기기에 부여한 IP 주소로 ping 명령을 날려보았다.

PING 100.x.x.y (100.x.x.y): 56 data bytes 64 bytes from 100.x.x.y: icmp_seq=0 ttl=64 time=2.455 ms 64 bytes from 100.x.x.y: icmp_seq=1 ttl=64 time=1.987 ms

신호가 갔다. 공유기 설정은 전혀 건드리지 않았다. 인터넷에 어떤 포트도 열지 않았다. 하지만 두 기기는 서로를 정확히 찾아내 통신하고 있었다.

“와… 됐다. 그냥 프로그램을 설치하고 로그인했을 뿐인데, 바로 연결되네. 그럼 이제 팀 미니PC에 부여된 이 Tailscale IP 주소를 내 프런트엔드 코드에 넣으면 되는 거구나.”

솔라의 얼굴에 안도감이 퍼졌다. 복잡한 네트워크 설정이나 위험한 노출 없이 팀의 공용 서버에 안전하게 접속할 수 있는 길을 찾은 것 같았다. 드디어 막막함의 첫 실타래가 풀리는 기분이었다. 이제 팀 개발 환경의 그림이 조금씩 그려지기 시작했다.

“그럼 이 Tailscale 네트워크 안에서는 다 안전한 거니까, 미니PC에 있는 데이터베이스에도 이 IP로 바로 접속해서 작업하면 되는 거지? 이 안은 우리만 쓰는 공간이니까.”

솔라는 새로운 가능성에 들떠 물었다. 하지만 그 질문은 이제 막 열린 비밀 통로 안쪽에 또 다른 문이 있음을 암시하고 있었다. 이 안전한 통로 안에서 어떻게 움직여야 하는지에 대한 규칙은 아직 정해지지 않았다.

2장: DB는 왜 ‘127.0.0.1’에만 묶여야 하나요?

솔라는 자신의 노트북 화면에 띄워진 데이터베이스 연결 도구를 노려보고 있었다. 연결 설정 창에는 팀 미니PC의 Tailscale IP 주소와 PostgreSQL의 기본 포트인 5432가 정확히 입력되어 있었다. 하지만 ‘연결 테스트’ 버튼을 누를 때마다 돌아오는 것은 ‘연결 시간 초과(Connection timed out)’라는 차가운 메시지뿐이었다. 어제 Tailscale로 비밀 통로를 만들었을 때의 환희는 온데간데없었다.

분명 같은 네트워크 안에 있는데. ping 명령으로는 신호가 잘만 가는데, 왜 데이터베이스는 묵묵부답일까. 솔라는 미니PC의 API 서버도 같은 방식으로 연결하면 될 거라고 지레짐작하고 프런트엔드 코드를 짜기 전에 데이터 구조부터 확인하려던 참이었다. 하지만 첫 관문부터 막혀버렸다.

“어제는 잘 됐는데… 왜 안 되지?”

솔라의 혼잣말을 들은 루나가 다가와 노트북 화면을 들여다보았다. 화면에는 데이터베이스 연결 실패를 알리는 팝업창이 떠 있었다.

“Tailscale 네트워크 주소로 연결하려는 거구나. 좋은 시도네. 그런데 연결이 안 되는 게 맞아.”

“안 되는 게 맞다고? 왜? 어제 우리가 만든 비밀 통로는 팀원들끼리만 쓰는 안전한 공간이라며. 그럼 이 안에서는 데이터베이스든 뭐든 다 직접 연결할 수 있어야 하는 거 아니야?”

솔라의 목소리에 당혹감이 묻어났다. 안전한 길을 만들었으니 그 길에 있는 모든 문은 열려 있어야 한다고 생각했는데, 가장 중요한 문이 굳게 잠겨 있는 상황이었다.

루나는 말없이 솔라의 노트북 터미널을 열어 ssh 명령어를 입력했다. 팀 미니PC의 Tailscale 주소를 이용해 원격으로 접속하는 명령어였다. 잠시 후, 터미널 프롬프트가 sola@laptop:~$에서 team@mini-pc:~$로 바뀌었다. 솔라의 노트북에서 미니PC의 내부로 들어온 것이다.

“자, 이제 솔라 너는 미니PC 안에 들어와 있어. 여기서 다시 데이터베이스에 접속해볼래? 이번엔 주소를 127.0.0.1로 입력해 봐.”

솔라는 어리둥절하며 루나가 시키는 대로 psql 클라이언트 명령어에 127.0.0.1 주소를 넣어 실행했다.

psql -h 127.0.0.1 -U team_user -d team_db

비밀번호를 입력하자, 언제 그랬냐는 듯 데이터베이스 프롬프트(team_db=>)가 나타났다. 미니PC 자기 자신 안에서는 데이터베이스에 접속하는 데 아무런 문제가 없었다.

“이게 무슨… 127.0.0.1은 그냥 ‘나 자신’을 가리키는 주소 아니야? 왜 내 노트북에서 Tailscale IP로 접속하는 건 막아놓고, 미니PC 자기 자신한테는 열어주는 건데? 일부러 이렇게 설정한 거야?”

“응, 맞아. 일부러 그렇게 한 거야.”

루나는 미니PC의 터미널에서 PostgreSQL 설정 파일(postgresql.conf)을 열어 한 줄을 가리켰다.

listen_addresses = '127.0.0.1'

“데이터베이스 서버를 시작할 때, 이 설정이 ‘어떤 주소로부터 오는 연결 요청을 들을 것인지’를 결정해. 만약 여기를 * (모든 주소)로 설정했다면, 솔라 네가 Tailscale IP로 시도했을 때 바로 연결됐을 거야. 하지만 우리는 127.0.0.1이라고, 즉 ‘이 컴퓨터 내부에서 오는 요청만 받겠다’고 명시해 둔 거지.”

“아니, 왜? 그렇게 안전하게 Tailscale 네트워크까지 만들어 놓고, 그 안에서조차 접근을 막는 건 너무 비효율적인 거 아니야?” 솔라가 따져 물었다.

“그게 바로 ‘최소 검증 표면(Minimum Verification Surface)’ 원칙이야. Tailscale은 우리 집까지 오는 길을 안전하게 만들어준 거지, 우리 집 현관문 비밀번호까지 알려준 건 아니거든.”

루나는 그림을 그리듯 설명했다.

“Tailscale은 신뢰할 수 있는 팀원들만 우리 동네(가상 사설망)에 들어올 수 있도록 하는 경비원 역할을 해. 하지만 동네에 들어왔다고 해서 모든 집(서비스)에 마음대로 들어갈 수 있다면 어떨까? 어떤 팀원이 실수로 데이터베이스의 모든 데이터를 날려버리는 쿼리를 실행한다면? 공용 데이터베이스에 직접 접속하는 건 그만큼 위험 부담이 커.”

솔라는 잠시 생각에 잠겼다. 팀원 중 누군가 UPDATE 쿼리에서 WHERE 절을 빠뜨리는 끔찍한 상상이 스쳐 지나갔다.

“데이터베이스를 127.0.0.1에만 묶어두는 건, 그 집의 문은 오직 집주인(미니PC 자신)만 열 수 있게 하는 것과 같아. 외부의 그 누구도, 심지어 같은 동네 주민(팀원)이라도 직접 문을 열 수 없지.”

“그럼… 우리는 어떻게 데이터를 써? 문을 열 수 없다면?”

솔라의 눈에 다시금 근본적인 질문이 떠올랐다. 데이터에 접근할 수 없다면 이 모든 설정이 무슨 소용이란 말인가.

“그래서 우리는 문 앞에 인터폰을 다는 거야. 필요한 것을 인터폰에 대고 말하면, 집주인이 듣고 안에서 물건을 꺼내다 주는 거지. 그 인터폰 역할이 바로 API야.”

그제야 솔라는 퍼즐 조각이 맞춰지는 것을 느꼈다. 공용 개발 환경이라는 것은 모든 것을 날것 그대로 공유하는 게 아니었다. 데이터베이스처럼 민감하고 중요한 자원은 단단한 벽 안에 보호하고, 그 벽에 ‘API’라는 잘 제어된 창구 하나만 내어주는 것. 그것이 바로 안전과 효율을 동시에 잡는 방법이었다. ‘PostgreSQL을 공개하지 않는다’는 말의 진짜 의미는, 숨기거나 사용하지 않는다는 뜻이 아니라, 아무나 직접 만질 수 없도록 잘 제어된 방식으로만 제공한다는 뜻이었다.

“알겠다. 그럼 내 프런트엔드 코드는 데이터베이스가 아니라, 그 인터폰, 그러니까 API를 호출해야 하는 거구나. 그 API는 미니PC 안에서 데이터베이스에 안전하게 접근할 테고.”

솔라는 자신의 데이터베이스 연결 도구 창을 말끔히 닫았다. 그녀가 봐야 할 것은 데이터베이스의 속살이 아니라, 잘 정의된 API의 입구였다.

“그럼 그 API는 어떻게 생겼어? 내 프런트엔드 말고, 다른 서비스도 그 API를 쓴다고 했는데, 다들 똑같은 방식으로 쓰는 거야?”

솔라는 이제 다음 질문으로 나아갈 준비가 되었다. 문이 닫힌 이유를 이해했으니, 이제 유일한 통로인 인터폰을 어떻게 사용해야 할지 알아볼 차례였다.

3장: API는 어떻게 최소한의 검증 표면이 될까요?

솔라는 자신의 프런트엔드 프로젝트 폴더를 열고 vite.config.js 파일과 함께 놓인 .env.local 파일을 노려보았다. 데이터베이스에 직접 접속하려다 실패한 뒤, 이제 그녀의 목표는 명확해졌다. 데이터베이스의 문을 직접 두드리는 게 아니라, 문 옆에 달린 인터폰, 즉 API를 호출하는 것.

그녀는 파일에 새로운 한 줄을 추가했다.

VITE_API_BASE_URL=http://100.x.y.z:8000

100.x.y.z는 팀 미니PC의 Tailscale IP 주소였다. 솔라는 코드를 저장하고, 터미널에서 npm run dev 명령어로 로컬 개발 서버를 다시 띄웠다. 브라우저에서 localhost:5173을 새로고침하자, 이전에는 텅 비어 있던 대시보드 화면에 드디어 의미 있는 데이터가 채워지기 시작했다. 팀 공용 데이터베이스의 내용이 표와 그래프로 말끔하게 그려졌다. 성공이었다. 자신의 로컬 개발 환경이 안전한 통로를 통해 팀의 공용 백엔드와 완벽하게 연동되는 순간이었다.

“됐어! 이제 API 주소만 알면 내 컴퓨터에서 바로 프런트엔드 개발을 할 수 있겠네. 다른 팀원들도 이 주소를 자기 개발 환경에 넣으면 되고.”

안도의 한숨을 내쉬던 솔라의 시선이 문득 스터디 자료의 한 구절에 멈췄다. ‘로컬 Dashboard는 VITE_API_BASE_URL로 공용 API를 사용하고 AICC container는 http://api:8000으로 내부 통신한다.’ AICC는 팀에서 사용하는 또 다른 백엔드 서비스였다. 분명 같은 API를 사용할 텐데, 왜 주소가 다를까? 100.x.y.z 같은 IP 주소가 아니라, api라는 단순한 이름은 뭘까? 모든 클라이언트가 똑같은 주소를 써야 하는 것 아닌가?

“언니, 이것 좀 봐. 내 프런트엔드 코드는 Tailscale IP 주소로 API에 접속했는데, 여기 문서에는 AICC라는 다른 서비스가 http://api:8000이라는 주소를 쓴다고 되어 있어. 똑같은 API를 부르는 거 아니야?”

솔라의 질문에 루나는 잠시 생각하더니, 미니PC의 터미널에 ssh로 접속해 docker-compose.yml 파일을 열어 보여주었다. 복잡해 보이는 설정 파일 안에서 솔라의 눈에 익숙한 이름들이 보였다.

services:
  api:
    # ... API 서비스 설정 ...
    ports:
      - "8000:8000"

  aicc:
    # ... AICC 서비스 설정 ...
    environment:
      - API_HOST=http://api:8000

  db:
    # ... PostgreSQL 설정 ...
    ports:
      - "127.0.0.1:5432:5432"

“여기 services 아래에 있는 api, aicc, db가 미니PC 안에서 함께 돌아가는 서비스들이야. 얘들은 Docker라는 컨테이너 기술 덕분에 서로를 이름으로 찾을 수 있어. 같은 건물에 사는 이웃사촌 같은 거지.”

루나는 흰 종이에 큰 사각형을 그리고 ‘미니PC’라고 적었다. 그 안에 API, AICC, DB라는 작은 상자를 그렸다.

“AICC 서비스가 API 서비스를 부를 땐, 그냥 이웃집 문을 두드리는 것과 같아. 그래서 http://api:8000이라는 간단한 주소를 쓸 수 있어. 이건 건물 내부 통신이야.”

루나는 AICC 상자에서 API 상자로 향하는 짧은 화살표를 그었다.

“하지만 솔라 네 노트북은 이 건물 밖에 있잖아.”

루나는 ‘미니PC’ 사각형 바깥에 ‘솔라 노트북’이라는 상자를 하나 더 그렸다.

“밖에서 건물 안으로 들어오려면 정문을 통과해야지. 우리가 API 서비스 설정에서 ports: - "8000:8000"이라고 한 부분이 바로 건물 8000번 창문을 바깥으로 열어둔 거야. 하지만 이 건물은 Tailscale이라는 사설 단지 안에 있어서, 단지 출입증(Tailscale 로그인)이 있는 사람만 창문 앞까지 올 수 있어. 그래서 네 노트북은 http://100.x.y.z:8000이라는, 단지 주소가 포함된 주소를 써야 하는 거고.”

루나는 ‘솔라 노트북’ 상자에서 시작해, 구불구불한 ‘Tailscale 터널’을 지나 ‘미니PC’ 사각형의 API 상자로 향하는 긴 화살표를 그렸다. 그리고 지난번처럼, DB 상자 주변을 굵은 선으로 다시 한번 둘러쌌다. 어떤 화살표도 DB 상자에 직접 닿지 않았다. 모든 길은 API를 통해서만 이어졌다.

솔라는 그림을 보고 나서야 모든 조각이 맞춰지는 것을 느꼈다. API는 단순히 데이터를 전달하는 파이프가 아니었다. 그것은 외부 침입을 막고 내부 질서를 유지하는 건물의 유일한 출입구이자 안내 데스크였다. 건물 안 이웃끼리는 서로의 이름을 부르며 편하게 소통하지만, 외부 방문객은 반드시 정문을 통해, 정해진 절차를 밟아야만 안으로 들어올 수 있다.

“아하… 그러니까 API가 하나의 통제 지점이 되는 거구나. 누가, 어디서 요청하든 결국 API라는 단일한 창구를 거치게 만드는 것. 데이터베이스를 직접 건드릴 위험을 원천 차단하고, 모든 접근 기록을 API에서 관리할 수 있으니까. 이게 바로 ‘최소한의 검증 표면’이라는 말이구나!”

공용 개발이란 모든 것을 헐겁게 열어두는 것이 아니었다. 오히려 그 반대였다. 팀이 함께 검증하고 책임질 수 있는 최소한의 표면, 즉 잘 설계된 API만을 남기고 나머지는 모두 감추는 것. 그것이 안전하고 효율적인 협업의 핵심이었다.

솔라는 고개를 끄덕였다. 이제 네트워크와 데이터 접근에 대한 그림은 명확해졌다. 하지만 이 안정적인 구조를 유지하려면 또 다른 규칙이 필요해 보였다.

“그럼 이 API 코드는 어떻게 관리해? 만약 내가 작업하던 새로운 기능 코드를 실수로 미니PC에 바로 올려버리면, 다른 팀원들까지 전부 영향을 받는 거잖아. 그리고 이 API가 데이터베이스에 접속할 때 쓰는 비밀번호 같은 건 코드에 그대로 넣으면 안 될 텐데… 그런 건 다들 어떻게 해?”

안전한 집의 구조를 이해하자, 이제 그 집을 깨끗하고 질서 있게 유지하는 방법에 대한 궁금증이 자연스럽게 피어올랐다.

4장: 안정적인 통합과 비밀 유지를 위한 운영 규칙은?

새로운 대시보드 위젯 기능 개발을 막 끝낸 솔라의 손가락이 키보드 위에서 경쾌하게 움직였다. 자신의 노트북에서 git commit 명령어로 작업을 마무리한 그녀는 만족스러운 표정으로 터미널의 출력 메시지를 바라보았다. [feature/new-widget 2b48a9e] Add realtime chart widget. 이제 이 멋진 기능을 다른 팀원들에게 보여줄 차례였다. 팀 공용 미니PC에 접속해서 방금 만든 feature/new-widget 브랜치를 받아오고, API 서버를 재시작하면 되겠지. 아주 간단한 계획이었다.

솔라는 지난번에 그랬던 것처럼 ssh로 미니PC에 접속했다. 터미널 프롬프트가 team@mini-pc:~$로 바뀌자, 그녀는 망설임 없이 API 서버의 소스 코드가 있는 폴더로 이동했다. git pull origin feature/new-widget을 입력하려던 찰나, 거실 한쪽에서 조용히 책을 읽고 있던 루나가 고개를 들었다.

“솔라, 지금 서버에 네 기능 브랜치를 바로 받으려는 거야?”

“응. 다 만들었으니까. 빨리 보여주고 싶어서. 여기서 바로 실행하면 다들 내 노트북에 접속하지 않아도 새 기능을 볼 수 있잖아.”

솔라는 당연하다는 듯 대답했다. 공용 서버는 이럴 때 쓰라고 있는 게 아닌가. 하지만 루나는 자리에서 일어나 솔라의 옆으로 다가왔다. 그리고는 솔라가 입력하려던 명령어를 지우고, 대신 다른 명령어를 타이핑했다.

git log --oneline --graph --all

화면에 알록달록한 선들과 함께 여러 개의 브랜치 흐름이 나타났다. 맨 위에는 솔라의 feature/new-widget이 있었고, 그 아래로 develop 브랜치와 main 브랜치가 보였다. 다른 팀원들이 작업한 것으로 보이는 여러 기능 브랜치들도 develop 브랜치 주변에 흩어져 있었다. 마치 잘 닦인 고속도로(develop) 옆으로 난 여러 갈래의 샛길들 같았다.

“만약 솔라 네가 지금 이 샛길을 고속도로에 바로 연결해버리면 어떻게 될까? 아직 포장도 끝나지 않았고, 다른 차들이 달려도 안전한지 검증도 안 된 길인데 말이야. 만약 네 코드에 작은 버그라도 있다면, 고속도로 전체가 마비될 수도 있어. API 서버가 멈추면, 다른 모든 팀원의 작업도 멈추게 되는 거지.”

루나의 말에 솔라는 잠시 상상했다. 자신의 코드로 인해 팀 전체의 개발 환경이 먹통이 되는 아찔한 장면. 그녀는 키보드에서 조용히 손을 뗐다.

“그럼… 어떻게 해야 해?”

“고속도로에 합류하려면 정해진 규칙을 따라야 해. 샛길에서 작업을 마친 차는 ‘합류 요청서(Pull Request)‘를 제출하고, 교통 관제소(다른 팀원)의 검토와 승인을 받아야만 안전하게 진입할 수 있어. 우리 팀에서는 develop 브랜치가 바로 그 고속도로 역할을 하는 거야. 모든 기능 브랜치는 develop으로 합쳐진 후에야 비로소 미니PC 서버에 배포될 자격을 얻어.”

그제야 솔라는 문서에서 스치듯 봤던 구절의 의미를 깨달았다. ‘기능 branch를 서버에 바로 올리지 않고 develop에 통합된 코드만 배포한다.’ 그것은 단순히 귀찮은 절차가 아니었다. 여러 사람이 함께 사용하는 공용 환경의 안정성을 지키기 위한 최소한의 약속이자 안전장치였다.

“알겠다. 그럼 코드는 develop 브랜치만 배포하는 걸로. 그런데 궁금한 게 또 있어. 이 API 서버가 데이터베이스에 접속하려면 비밀번호가 필요하잖아. 내 로컬 환경에서는 .env 파일에 넣어서 쓰고 있는데, 서버에는 이 파일을 어떻게 전달해?まさか Git에 같이 올리는 건 아닐 테고…”

솔라가 자신의 노트북으로 돌아와 코드 에디터를 열었다. 그녀는 .env 파일을 실수로라도 Git에 올리면 안 된다는 것을 어렴풋이 알고 있었다. 루나는 솔라의 노트북에서 직접 실험해보자고 했다.

“네 프로젝트 폴더에서 .env 파일을 git add 해보려고 해봐.”

솔라는 터미널에 git status를 먼저 입력했다. .env 파일이 ‘추적하지 않는 파일(Untracked files)’ 목록에 빨간색으로 떠 있었다. 프로젝트의 .gitignore 파일에 .env가 명시되어 있기 때문이었다. Git 자체가 이 파일을 무시하도록 처음부터 설정되어 있었던 것이다. 솔라가 장난스럽게 git add .env --force 명령어로 강제로 추가하려고 하자, 루나가 웃으며 그녀의 손을 막았다.

“절대 안 돼. 데이터베이스 비밀번호, API 키 같은 민감 정보는 우리 집 열쇠나 마찬가지야. 그걸 코드 저장소에 올리는 건, 집 열쇠를 복사해서 인터넷에 사진 찍어 올리는 것과 같아. 한번 올라가면, 누군가 보기 전에 지운다 해도 기록에 영원히 남아.”

“그럼 서버에서는 어떻게?”

“서버에는 그 파일을 딱 한 번, 안전한 방법으로 직접 복사해두는 거야. 코드 저장소 바깥, 정해진 위치에. 그러면 배포 스크립트가 develop 브랜치의 최신 코드를 받아와 서버를 실행할 때, 코드 자체는 바뀌어도 서버에 미리 놓여있던 .env 파일을 읽어서 필요한 비밀 정보를 사용하는 거지.”

코드는 흐르는 물처럼 계속 바뀌고 Git을 통해 공유되지만, 비밀 정보는 그 자리에 고정된 바위처럼 흔들리지 않고 코드가 참조만 하도록 분리하는 것. 그것이 핵심이었다.

“아…”

솔라의 입에서 나지막한 탄성이 터져 나왔다. 공용 개발 환경의 안정성은 단지 네트워크나 API 설계로만 완성되는 것이 아니었다. 코드를 통합하는 규칙, 비밀을 다루는 원칙 같은 운영상의 약속들이 단단하게 뒷받침되어야만 지켜지는 것이었다.

develop 브랜치라는 ‘통합의 창구’와 저장소 외부에서 관리되는 .env 파일이라는 ‘비밀의 금고’. 이 두 가지가 API라는 ‘접근의 창구’와 어우러져 비로소 신뢰할 수 있는 팀 개발 환경을 완성하고 있었다.

“이제 보니, 공용 개발 환경을 만든다는 건 팀이 함께 검증할 최소한의 표면을 정하고, 그 표면의 안정성을 지키기 위한 규칙을 세우는 일이구나. API뿐만 아니라, 통합 브랜치와 비밀 관리 방식까지 모두 그 표면의 일부였네.”

솔라는 고개를 끄덕이며 자신의 생각을 정리했다. 그러나 이내 새로운 의문이 고개를 들었다.

“이 구조가 정말 완벽할까? Tailscale, 127.0.0.1 바인딩, API 게이트웨이, 그리고 통합 브랜치와 비밀 관리 원칙까지… 이 모든 게 모여서 최소 검증 표면을 만든다는 건 알겠어. 그런데 이 방식의 한계는 없을까? 우리 팀이 두 배, 세 배로 커져도 이 미니PC 한 대로 계속 버틸 수 있을까?”

안전하고 질서 있는 집의 설계도와 생활 수칙을 모두 이해하자, 이제 그 집이 얼마나 오랫동안, 얼마나 많은 사람들을 품을 수 있는지에 대한 현실적인 질문이 남았다.

5장: 통제된 노출, 최소 검증 표면의 완성

솔라는 거실 테이블 위에 백지를 펼쳐놓고 복잡한 그림을 그리고 있었다. 한쪽에는 ‘미니PC’라는 상자 안에 API, DB, AICC 같은 작은 상자들이 오밀조밀 모여 있었다. 굵은 선으로 둘러싸인 상자는 단단해 보였지만, 어딘지 답답해 보였다. 다른 한쪽에는 ‘미래 확장?’이라는 물음표와 함께 거대한 구름 그림이 그려져 있었다. 그 안팎으로는 수많은 화살표들이 자유롭게 드나들고 있었다. 안전하지만 갑갑해 보이는 현재와, 유연해 보이지만 혼란스러운 미래 사이에서 솔라의 펜은 갈 길을 잃고 헤매고 있었다.

지난 며칠간 루나와 함께 구축한 미니PC 개발 환경은 분명 안전하고 질서 있었다. 하지만 솔라의 마음 한구석에는 ‘이게 최선일까?’ 하는 의문이 남아있었다. 팀이 커지고 서비스가 복잡해지면, 이 모든 규칙이 성장을 가로막는 족쇄가 되지는 않을까. 차라리 처음부터 모든 것을 열어두고 필요할 때마다 각자 알아서 조심하는 게 더 효율적이라는 생각의 유령이 어른거렸다.

“그 구조의 한계가 궁금한 거구나.”

어느새 다가온 루나가 솔라의 그림을 들여다보며 말했다.

“응. 지금은 좋지만… 나중에 우리 팀에 데이터 분석가가 합류해서 새로운 분석 대시보드를 만들어야 한다면? 그 사람은 데이터베이스에 직접 접속해서 이런저런 쿼리를 날려봐야 할 텐데, 지금 구조로는 어림도 없잖아. 매번 API를 만들어달라고 요청해야 할까? 답답해서 일 못 하겠다고 할지도 몰라.”

솔라는 자신이 상상할 수 있는 가장 현실적인 반박을 내놓았다. 루나는 잠시 생각하더니, 솔라의 그림 옆에 새로운 백지를 한 장 더 놓았다.

“좋은 질문이야. 그럼, 우리가 직접 그 새로운 ‘분석 서비스’를 추가하는 계획을 한번 세워보자. 솔라 네가 설계자가 되는 거야. 어떤 규칙을 적용하고, 어떤 문을 열어줄지.”

그것은 시험이었다. 지금까지 배운 원칙들을 단순히 지켜야 할 규칙으로 보는지, 아니면 새로운 상황에 적용할 수 있는 도구로 이해하는지에 대한. 솔라는 잠시 망설이다 펜을 들었다.

“음… 일단 새로운 서비스니까, ‘analytics-api’라는 이름으로 컨테이너를 하나 더 띄워야겠지. 그리고 이 서비스는 자체 데이터베이스가 필요할 거야. 분석용이니까… 그럼 ‘analytics-db’도 만들고.”

여기까지는 순조로웠다. 하지만 곧장 난관에 부딪혔다.

“데이터 분석가는 이 DB에 직접 접속해야 할 텐데… 그럼 ‘analytics-db’의 포트는 Tailscale 네트워크에 열어줘야 하나? 127.0.0.1이 아니라…”

솔라의 펜이 멈칫했다. 스스로 정립했던 원칙을 제 손으로 무너뜨려야 하는 순간이었다. 그것이 더 효율적이라는 유혹이 강하게 들었다.

루나는 아무 말 없이 솔라를 지켜보았다. 정답을 알려주는 대신, 솔라가 자신의 논리적 모순과 마주할 시간을 주었다. 몇 초간의 침묵 끝에 솔라가 먼저 입을 열었다.

“아니, 잠깐만. 그러면 안 되지.”

솔라는 스스로를 교정했다.

“하나의 예외를 만드는 순간, 모든 규칙이 무너질 거야. ‘왜 저 팀원은 되고 나는 안 되냐’는 말이 나오겠지. 원칙은… 원칙대로 가야 해.”

솔라의 펜이 다시 움직이기 시작했다. 이번에는 망설임이 없었다.

“‘analytics-db’도 PostgreSQL 설정에서 listen_addresses = '127.0.0.1'로 묶어야 해. 이 DB는 오직 ‘analytics-api’ 컨테이너만 접근할 수 있어. 그리고 데이터 분석가를 포함한 모든 팀원은 Tailscale을 통해 기존의 메인 API 게이트웨이에만 접속해. 새로운 분석 기능이 필요하면 메인 API 게이트웨이에 /analytics 라는 경로를 새로 만드는 거야. 그 요청을 받아서 내부적으로 ‘analytics-api’를 호출해주면 돼.”

그녀는 방금 자신이 그린 혼란스러운 그림 위에 명료한 아키텍처 다이어그램을 새로 그려나갔다.

  1. 네트워크: 새로운 서비스(analytics-api, analytics-db)는 기존 미니PC의 Docker 네트워크 안에 추가된다. 외부 노출은 없다.
  2. 데이터베이스: analytics-db127.0.0.1에 바인딩된다. Tailscale 망에서도 직접 접근은 불가능하다.
  3. 접근 제어: 모든 사용자는 기존 API 게이트웨이의 새로운 엔드포인트 (/api/v1/analytics/*)를 통해서만 분석 기능에 접근한다. API 게이트웨이가 요청을 받아 내부적으로 analytics-api를 호출한다.
  4. 코드 관리: analytics-api 프로젝트는 자체 Git 저장소를 가지며, develop 브랜치에 통합된 코드만 미니PC에 배포된다.
  5. 비밀 관리: analytics-db의 접속 정보는 별도의 .env 파일에 담겨, 코드 저장소 외부에서 안전하게 관리된다.

계획을 다 세우고 나자 솔라는 놀라운 사실을 깨달았다. 이 규칙들은 성장을 막는 족쇄가 아니었다. 오히려 안전하게 성장하기 위한 레일(Rail)이었다. 새로운 서비스를 추가할 때마다 ‘어떻게 열지?’를 고민하는 게 아니라, 이미 검증된 패턴 위에 ‘어떻게 올릴지?’만 고민하면 되었다. 이것이 바로 ‘최소 검증 표면’ 전략의 힘이었다. 공용 개발은 모든 것을 공개하는 것이 아니라, 팀이 함께 검증할 최소한의 표면을 현명하게 설계하고, 그 외의 모든 것은 감추는 일이었다.

“알겠다… 이 방식의 한계는 미니PC의 물리적 성능일 뿐, 구조 자체의 한계는 아니었구나. 필요하다면 이 패턴 그대로 더 큰 서버, 혹은 여러 서버로 옮겨갈 수도 있겠어.”

솔라는 처음의 막막함이 담겼던 백지를 치우고, 방금 자신이 세운 계획과 원칙들을 깔끔하게 정리하기 시작했다. 그것은 더 이상 루나에게서 배운 지식이 아니었다. 새로운 문제를 해결하며 스스로 체득한 ‘검증 표면 전략’이었다.

[팀 개발 환경 아키텍처 원칙]

  1. 접근: 모든 서비스는 Tailscale 사설망을 통해서만 접근한다. 공인 IP 노출은 없다.
  2. 격리: 데이터베이스와 같은 민감한 서비스는 127.0.0.1에 바인딩하여, 지정된 애플리케이션 외의 직접 접근을 원천 차단한다.
  3. 통제: 모든 기능은 단일 API 게이트웨이를 통해 정제된 형태로 노출된다. 클라이언트는 내부 구조를 알 필요가 없다.
  4. 통합: develop 브랜치에 통합되고 검증된 코드만이 공용 환경에 배포된다.
  5. 비밀: 모든 비밀 정보(자격 증명, API 키)는 코드와 분리되어 환경 외부에서 주입된다.

한 줄 한 줄 써 내려가는 솔라의 얼굴에 자신감이 피어났다. 이제 그녀는 팀 개발 환경이라는 복잡한 문제 앞에서 더 이상 막막해하지 않을 것이다. 안전한 공유의 원칙을 손에 쥐었기 때문이다.