Mini Project 6 05
프론트엔드와 백엔드의 경계: 현명한 서비스 노출 전략
frontend와 backend를 모두 배포했는데 왜 backend는 외부 LoadBalancer가 아니고 ClusterIP인지, Cloudflare 도메인은 어디에 붙는지 혼란스럽다.
근거 · 교안 공개 경계·로드밸런싱 파트
1장: 루나: 서비스 노출, 모든 문을 열 필요는 없어요
솔라는 모니터에 떠 있는 몇 줄의 텍스트에서 눈을 떼지 못했다. 프론트엔드와 백엔드, 두 개의 서비스를 성공적으로 쿠버네티스 클러스터에 배포했다. 하지만 kubectl get svc 명령어가 보여주는 결과는 어딘가 이상했다.
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
frontend-service LoadBalancer 10.100.12.34 34.64.XXX.XXX 80:30000/TCP
backend-service ClusterIP 10.100.56.78 <none> 8080/TCP
프론트엔드 서비스는 LoadBalancer 타입으로 외부 IP 주소(34.64.XXX.XXX)를 할당받았다. 솔라는 고개를 끄덕였다. 바로 저 IP 주소에 준비해 둔 Cloudflare 도메인을 연결하면 사용자들이 웹사이트에 접속할 수 있을 터였다. 외부로 통하는 문이 명확하게 보였다. 하지만 백엔드 서비스는 달랐다. 타입은 ClusterIP였고, 외부 IP(EXTERNAL-IP) 항목은 깨끗하게 <none>으로 비어 있었다. 외부로 향하는 문이 아예 없었다.
“언니, 이거 좀 이상하지 않아?”
솔라는 뒤편 소파에서 책을 읽던 루나를 불렀다.
“백엔드 서비스에 외부 IP가 없어. 이러면 프론트엔드에서 API 요청을 어떻게 보내? 데이터베이스랑 통신하고 비즈니스 로직을 처리하는 핵심은 전부 백엔드에 있는데, 문이 없잖아. 백엔드도 똑같이 LoadBalancer로 열어줘야 하는 거 아니야?”
솔라의 머릿속에는 ‘서비스는 외부에서 접근할 수 있어야 한다’는 생각이 단단히 자리 잡고 있었다. 그래야 사용자가 쓰든, 다른 서비스가 쓰든 의미가 있으니까. 마치 가게를 열었는데 정문이 없는 셈이었다.
루나는 책을 덮고 조용히 솔라의 옆으로 다가와 모니터를 들여다보았다. 루나는 솔라의 혼란스러운 질문에 바로 답하는 대신, 빈 메모장을 화면 한쪽에 열었다.
“솔라, 그 서비스들을 ‘프론트엔드’와 ‘백엔드’라는 이름표 대신 다른 기준으로 한번 분류해볼래?”
루나는 메모장에 간단히 두 개의 제목을 적었다.
# 외부 손님용 문
# 내부 직원용 문
솔라는 잠시 언니의 의도를 파악하려는 듯 눈을 가늘게 떴다. 외부 손님, 내부 직원. 가게의 비유가 머릿속에 다시 떠올랐다.
“쿠버네티스 서비스 타입들, LoadBalancer, NodePort, ClusterIP를 저 두 종류의 문 아래에 각각 나눠서 넣어봐. 어떤 문이 어떤 타입에 어울릴지.”
솔라는 키보드로 손을 옮겼다. LoadBalancer는 고민할 것도 없었다. 클라우드 플랫폼이 제공하는 외부 IP를 할당받아 인터넷 트래픽을 서비스로 연결해주는 역할이니, 명백히 외부 손님용 문이었다. NodePort도 클러스터의 모든 노드(작업용 컴퓨터)에 특정 포트를 열어 외부에서 접근할 길을 터주니, 마찬가지로 외부 손님용 문에 속했다.
문제는 ClusterIP였다. 솔라는 잠시 망설였다. 자신의 백엔드 서비스가 바로 이 타입이었고, 외부 IP가 없었다. 그것이 바로 혼란의 시작이었다. 솔라는 키보드를 두드려 ClusterIP를 ‘내부 직원용 문’ 아래에 적어 넣었다.
# 외부 손님용 문 (인터넷에서 직접 접근 가능)
- LoadBalancer
- NodePort
# 내부 직원용 문 (클러스터 안에서만 접근 가능)
- ClusterIP
분류를 마치고 나자, 그림이 명확해졌다. 키보드에서 손을 뗀 솔라는 조용히 중얼거렸다.
“아… ClusterIP는 외부 IP가 없는 게 당연한 거였구나. 고장 나거나 설정이 잘못된 게 아니라, 원래 그렇게 만들어진 타입이었어.”
솔라는 그제야 자신이 ‘모든 서비스는 외부로 통하는 문이 있어야 한다’는 고정관념에 갇혀 있었다는 것을 깨달았다. ClusterIP는 문이 없는 게 아니었다. 클러스터라는 건물 안에서만 통용되는, 직원들만 사용하는 내부 통로였던 것이다.
“그럼 교안에 나왔던 ‘서비스 안정성을 위해 공개 경계를 설계해야 한다’는 말의 의미가… 모든 서비스를 다 공개하라는 게 아니었네. 외부 손님을 맞이할 정문(LoadBalancer)은 단단하게 만들고, 직원만 드나드는 내부 통로(ClusterIP)는 굳이 바깥에 노출시키지 말라는 뜻이었구나.”
솔라의 판단이 바뀌자, 처음의 막막한 혼란이 구체적인 질문으로 바뀌었다. 백엔드가 의도적으로 숨겨져 있다는 사실을 받아들이니, 다음 단계가 궁금해졌다.
“알겠어, 언니. 백엔드는 내부용 서비스라는 건 이해했어. 그런데 여전히 궁금한 건… 외부 손님용 정문으로 들어온 프론트엔드가, 내부 직원용 통로에 있는 백엔드랑은 대체 어떻게 통신하는 거야? 서로 다른 종류의 문을 쓰는데.”
2장: 루나: 프론트엔드가 백엔드로 가는 숨겨진 통로
솔라는 프론트엔드 애플리케이션의 소스 코드를 샅샅이 뒤지고 있었다. 어제 언니와 이야기를 나눈 후, 백엔드 서비스가 ClusterIP로 의도적으로 숨겨져 있다는 사실은 받아들였다. 하지만 ‘어떻게?’라는 질문이 풀리지 않았다. 외부용 정문(LoadBalancer)으로 들어온 프론트엔드가, 내부 직원용 통로(ClusterIP)를 쓰는 백엔드와 통신하는 방법. 그 연결고리를 찾고 싶었다.
분명 프론트엔드 코드 어딘가에 백엔드 서비스의 주소를 가리키는 설정이 있을 터였다. http://backend-service라던가, 하다못해 IP 주소라도. 하지만 아무리 찾아봐도 그런 코드는 보이지 않았다. 브라우저에서 실행되는 자바스크립트 코드는 그저 /api/books 같은 상대 경로로 API를 호출할 뿐, 그 ‘api’가 대체 어디에 있는 백엔드로 연결되는지에 대한 단서는 어디에도 없었다. 프론트엔드와 백엔드는 서로 다른 종류의 문을 쓸 뿐만 아니라, 서로의 존재조차 모르는 것처럼 보였다.
“프론트엔드 코드에는 백엔드 주소가 한 줄도 없는데… 유령이랑 통신하는 것도 아니고, 대체 어떻게 데이터를 가져오는 거지?”
솔라가 혼잣말처럼 중얼거리며 미간을 찌푸렸다. 그녀의 생각은 ‘프론트엔드와 백엔드는 서로를 명확히 알고 직접 통신해야 한다’는 가정에 묶여 있었다.
그때 루나가 솔라의 화면을 가만히 들여다보더니, 소스 코드 창이 아닌 다른 곳을 가리켰다.
“솔라, 사용자의 브라우저에서 실행되는 자바스크립트 코드만 보고 있어서 그래. 사용자가 보는 UI 말고, 그 UI를 사용자에게 전달해주는 프론트엔드 ‘웹 서버’의 역할을 생각해봐.”
루나는 솔라가 열어둔 프로젝트 폴더에서 Dockerfile과 그 옆에 있는 nginx.conf 파일을 차례로 클릭했다.
“사용자의 /api/books 요청을 받는 건, 최종적으로 백엔드가 아니라 프론트엔드 서버야. 그럼 그 요청을 받은 프론트엔드 서버가 무언가 역할을 하겠지?”
솔라는 루나의 말에 따라 nginx.conf 파일을 열었다. 복잡한 설정들이 눈에 들어왔지만, 루나는 특정 부분을 확대해서 보여주었다.
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend-service:8080;
}
# ... 기타 설정 ...
}
솔라는 location /api/ 블록에 시선을 고정했다. proxy_pass라는 낯선 지시어가 보였다. 그리고 그 뒤에는 http://backend-service:8080이라는, 그토록 찾아 헤맸던 주소가 적혀 있었다.
“아!”
낮은 탄성이 터져 나왔다. 퍼즐 조각이 맞춰지는 순간이었다.
“proxy_pass… 대리 통과? 그러니까 /api/로 시작하는 모든 요청은… 이 주소로 대신 보내라는 뜻이구나. 여기서 backend-service는… 어제 봤던 그 ClusterIP 서비스의 이름!”
쿠버네티스 클러스터 내에서는 서비스 이름을 도메인 이름처럼 사용할 수 있다는 사실이 떠올랐다. 클러스터 내부의 DNS가 backend-service라는 이름을 백엔드 서비스의 내부 IP 주소로 알아서 변환해줄 터였다.
솔라는 키보드에 손을 올리고 방금 깨달은 통신 과정을 메모장에 정리했다.
- 사용자가 브라우저에서 책 목록을 보기 위해 무언가를 클릭한다.
- 브라우저(프론트엔드 UI)는
/api/books라는 경로로 데이터를 요청한다. - 이 요청은 프론트엔드 서비스의
LoadBalancer를 통해 프론트엔드 컨테이너의 Nginx 서버로 전달된다. - Nginx 서버는 요청 경로가
/api/로 시작하는 것을 확인하고,proxy_pass설정에 따라 요청을http://backend-service:8080/books로 넘겨준다. - 백엔드 서비스는 요청을 처리하고 결과를 다시 Nginx를 통해 사용자에게 전달한다.
“그러니까… 프론트엔드 서버가 일종의 안내원 역할을 하는 거였어. 외부 손님(사용자)의 요청을 직접 처리하는 척하면서, 뒤에서는 자기가 아는 내부 직원(백엔드)에게 요청을 몰래 전달해주고 있었던 거네.”
솔라는 이제 교안의 ‘공개 경계를 설계해야 한다’는 문장을 다르게 읽을 수 있었다. 안정적인 공개 경계란, 모든 서비스를 노출하는 것이 아니라 단 하나의 잘 관리된 정문(프론트엔드)을 두고, 그 정문이 내부의 복잡한 통신을 모두 책임지도록 설계하는 것일 수도 있겠다는 생각이 들었다. 백엔드의 존재를 외부에 완벽히 숨기면서 말이다.
연결의 ‘어떻게’를 이해하자, 솔라는 고개를 끄덕이면서도 새로운 의문에 사로잡혔다.
“어떻게 연결되는지는 알겠어. 그런데 언니, 왜 이렇게 복잡하게 하는 거야? 그냥 백엔드도 외부 IP를 열어주고, 프론트엔드 자바스크립트에서 바로 그 주소로 API를 호출하면 더 간단하지 않아? 굳이 /api라는 경로를 만들고 Nginx에서 한번 더 거쳐가게 하는 이유가 있을까?”
3장: 루나: ‘/books’ 문제, 프록시로 해결된 이야기
솔라의 의문은 간단했다. ‘왜 복잡하게?’ 그녀는 프론트엔드가 백엔드와 통신하는 방법을 알아냈지만, 그 방식이 영 마음에 들지 않았다. 굳이 프론트엔드 서버를 한 번 거쳐 /api라는 경로로 요청을 돌리는 이유를 이해할 수 없었다. ‘더 간단한 길이 있는데.’
솔라는 자신의 생각을 증명해 보이고 싶었다. 그녀는 빈 메모장을 열고 ‘더 간단한 구조’라는 제목 아래 자신만의 아키텍처를 그리기 시작했다. 프론트엔드와 백엔드 서비스가 각각 외부 IP를 가진 LoadBalancer로 노출되어 있고, 프론트엔드 자바스크립트 코드가 백엔드의 외부 IP로 직접 API를 호출하는 그림이었다. /api 같은 군더더기 경로도, Nginx 프록시 설정도 없었다. 모든 것이 명쾌하고 직관적으로 보였다.
그녀는 만족스러운 미소와 함께 의자 등받이에 몸을 기댔다. 하지만 그림을 보면 볼수록 미세한 균열이 느껴졌다. 특히 두 서비스가 같은 도메인 아래에서 작동해야 한다면 문제가 복잡해졌다. 예를 들어, 사용자가 my-app.com/books라는 주소로 접속한다고 상상해 보자. 이 요청은 프론트엔드로 가야 할까, 아니면 책 목록 데이터를 주는 백엔드 API로 가야 할까?
“언니, 내가 생각한 간단한 구조인데… 뭔가 이상해.”
솔라의 부름에 루나가 다가와 모니터 속 그림을 들여다보았다. 루나는 솔라의 그림을 비판하는 대신, 브라우저 창을 하나 띄우고 주소창을 가리켰다.
“좋아. 그 간단한 구조의 세상이라고 상상해 보자. 사용자가 우리 책 목록 페이지, my-app.com/books를 보고 있어. 화면도 예쁘게 잘 나왔고, 데이터도 잘 보이지. 그런데 거기서 사용자가 F5, 새로고침 버튼을 누르면 어떻게 될까?”
솔라는 잠시 생각에 잠겼다. 그녀는 ‘프론트엔드와 백엔드의 API 경로가 겹쳐도 문제없다’고 생각했지만, 루나의 질문은 그 생각의 허점을 정확히 찔렀다. 앱 안에서 페이지를 이동할 때는 프론트엔드 라우터가 /books 경로를 가로채 화면을 그려주니 문제가 없다. 하지만 새로고침은 다르다. 브라우저가 서버로 GET /books라는 새로운 HTTP 요청을 처음부터 다시 보내는 행위다.
“요청이 서버로 가겠지. 그런데… 어느 서버로?”
솔라는 자신의 그림에서 my-app.com이라는 도메인이 두 개의 다른 LoadBalancer를 동시에 가리킬 수 없다는 사실을 깨달았다. 결국 하나의 진입점이 필요했다. 그렇다면 다시 원래 구조로 돌아와, 프론트엔드 LoadBalancer가 모든 요청을 받는다고 가정해 보자.
루나는 솔라의 깨달음을 보고, 상황을 두 가지 시나리오로 나누어 메모장에 정리했다.
시나리오 1: Nginx 프록시에 /api 접두사 없음
- 프론트엔드 라우팅 경로:
/books(UI 페이지) - 백엔드 API 엔드포인트:
/books(JSON 데이터) - 사용자가
my-app.com/books에서 새로고침 - Nginx 서버는
GET /books요청을 받음 - Nginx는 혼란에 빠진다. 이 요청을 프론트엔드 앱(index.html)으로 보내야 할까, 아니면 백엔드 API로 전달해야 할까?
시나리오 2: Nginx 프록시에 /api 접두사 있음 (현재 구조)
- 프론트엔드 라우팅 경로:
/books(UI 페이지) - 백엔드 API 엔드포인트:
/api/books(JSON 데이터) - 사용자가
my-app.com/books에서 새로고침 - Nginx 서버는
GET /books요청을 받음 /api/가 아니므로, 프론트엔드 앱(index.html)을 반환.- 프론트엔드 앱이 로드된 후, 데이터를 가져오기 위해
/api/books로 다시 요청. - Nginx 서버는
GET /api/books요청을 받고,/api/규칙에 따라 백엔드로 전달.
두 시나리오를 나란히 놓고 보자 모든 것이 명확해졌다. 솔라는 낮은 탄식을 내뱉었다.
“아… ‘/books’라는 주소의 주인이 둘일 수는 없구나. 프론트엔드 UI도 /books를 자기 땅이라고 생각하고, 백엔드 API도 /books를 자기 땅이라고 생각하면 충돌이 날 수밖에 없었네.”
교안 어딘가에서 스쳐 지나갔던 문장이 떠올랐다. ‘이 설정으로 /books 페이지 새로고침 시 UI 대신 API의 JSON이 보이던 문제가 해결되었다.’ 이제야 그 문장의 의미가 피부로 와닿았다. /api 접두사는 복잡함을 위한 장식이 아니라, 두 주인이 충돌하지 않도록 교통정리를 해주는 명확한 표지판이었던 것이다. 같은 주소에 대한 소유권 분쟁을 피하게 해주는, 영리한 ‘경로 충돌 회피’ 전략이었다.
“복잡한 게 아니었어. 오히려 혼란을 막기 위한 질서였구나. 프론트엔드 Nginx의 proxy_pass 설정이 단순히 백엔드로 가는 길을 알려주는 걸 넘어서, 요청의 성격에 따라 갈 길을 분류해주는 똑똑한 안내원이었던 거야.”
솔라는 자신이 그렸던 ‘더 간단한 구조’ 그림을 깨끗이 지웠다. 이제 현재 아키텍처가 왜 최선인지 설명할 수 있게 되었다. 외부 사용자는 단 하나의 정문(프론트엔드 LoadBalancer)으로만 들어오고, 그 안에서 안내원(Nginx)이 손님의 목적에 따라 ‘UI를 보여줄지(프론트엔드 자체 처리)’ 아니면 ‘데이터를 가져다줄지(백엔드 프록시)’를 결정하는 그림. 훨씬 안정적이고 예측 가능한 설계였다.
퍼즐의 내부 조각들은 모두 제자리를 찾았다. 하지만 솔라의 시선은 다시 그림의 가장 바깥쪽, 모든 것이 시작되는 지점으로 향했다.
“좋아, 이제 사용자가 보낸 요청이 어떻게 프론트엔드 정문을 통과하고, 내부에서 백엔드까지 흘러가는지는 완벽히 이해했어. 그런데 아직 한 가지가 남았어. 사용자는 이 복잡한 IP 주소를 외워서 치지 않잖아. dev-m-6th-t24.ldhcloud.com 같은 도메인 이름을 쓰지. 이 도메인은 이 전체 그림의 어디에 연결되는 거야? 맨 앞의 프론트엔드 LoadBalancer에 붙는 건가?”
4장: 루나: Cloudflare, 외부 트래픽의 첫 관문
솔라는 자신의 아키텍처 다이어그램에서 마지막 남은 물음표를 지우고 싶었다. 사용자, 도메인 이름, 프론트엔드, 백엔드. 이 모든 것을 연결하는 완전한 그림. 그녀는 내부 통신 흐름의 퍼즐 조각—Nginx 프록시와 경로 충돌 회피 전략—은 맞췄지만, 모든 것의 시작점, 즉 사용자가 주소창에 입력하는 그 도메인 이름이 어디에 붙는지에 대한 확신이 없었다.
그녀는 브라우저에 새 탭을 열고 Cloudflare 대시보드에 로그인했다. 화면에는 dev-m-6th-t24.ldhcloud.com 도메인의 DNS 레코드 설정이 나타났다. 여러 종류의 레코드들 사이에서, 솔라는 자신의 개발 환경에 해당하는 항목을 찾아냈다.
| 타입 | 이름 | 내용 |
|---|---|---|
| CNAME | dev-m-6th-t24.ldhcloud.com | a1b2c3d4…elb.ap-northeast-2.amazonaws.com |
‘내용’ 항목에 적힌 길고 복잡한 주소. 아마존 웹 서비스(AWS)의 로드밸런서 주소처럼 보였다. 그리고 바로 이 지점에서 솔라의 생각이 멈췄다. ‘프론트엔드 로드밸런서에 붙는 게 맞겠지’라고 막연히 추측했지만, 그 ‘연결’이 어떻게 이루어지는지는 안갯속이었다. 어쩌면 이 CNAME 레코드를 백엔드 서비스의 ClusterIP로 직접 바꿀 수도 있지 않을까? 물론 외부 IP가 없으니 불가능하겠지만, 그 생각이 머리를 스쳤다는 것 자체가 아직 무언가 명확하지 않다는 증거였다. ‘도메인이 직접 백엔드 서비스로 연결될 수도 있다’는 미련이 희미하게 남아있었다.
“언니, 이것 좀 봐봐.”
솔라는 옆에 있던 루나를 불렀다.
“사용자가 dev-m-6th-t24.ldhcloud.com을 치면, Cloudflare가 이 긴 주소로 안내해주는 건 알겠어. 근데 이 주소는 대체 누구야? 우리 시스템의 어느 부분이지? 그리고 이걸 어떻게 확인해?”
루나는 솔라의 질문에 바로 답하는 대신, 솔라가 열어둔 터미널 창을 가리켰다. 솔라는 얼마 전 프론트엔드와 백엔드 서비스의 상태를 확인했던 kubectl get svc 명령어를 다시 입력했다.
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
frontend-service LoadBalancer 10.100.12.34 a1b2c3d4...elb.ap-northeast-2.amazonaws.com 80:30000/TCP
backend-service ClusterIP 10.100.56.78 <none> 8080/TCP
결과가 화면에 나타나는 순간, 솔라의 눈이 커졌다.
“아…!”
그녀는 Cloudflare 대시보드 창과 터미널 창을 번갈아 보았다. 두 화면에 떠 있는 길고 복잡한 주소는 정확히 일치했다. 퍼즐의 마지막 조각이 ‘찰칵’ 소리를 내며 제자리를 찾았다.
루나는 아무 말 없이 솔라가 스스로 깨달음을 정리할 때까지 기다렸다.
솔라는 키보드를 두드려 DNS 레코드가 어떻게 작동하는지 자신만의 비유로 정리하기 시작했다.
- 사용자: “솔라네 집에 가고 싶은데, 주소가 ‘솔라네 집’이야.” (브라우저에
dev-m-6th-t24.ldhcloud.com입력) - DNS (우체국): “아, ‘솔라네 집’이라는 별명으로 등록된 실제 주소는 ‘서울시 강남구 테헤란로 123번지’입니다.” (Cloudflare가 CNAME 레코드를 보고
a1b2c3d4...amazonaws.com주소를 알려줌) - 사용자: “알겠습니다. ‘서울시 강남구 테헤란로 123번지’로 가주세요.” (브라우저가 로드밸런서의 실제 IP 주소로 연결)
정리하고 보니 너무나 간단한 원리였다. Cloudflare는 마법을 부리는 것이 아니었다. 그저 ‘이름표(도메인)’와 ‘실제 주소(로드밸런서 외부 주소)’를 연결해주는 거대한 주소록 서비스였을 뿐이다. 그리고 그 실제 주소의 주인은 바로 frontend-service의 LoadBalancer였다. 백엔드는 애초에 이 주소록에 등록될 ‘외부용 실제 주소’ 자체가 없었다.
“알겠다. LoadBalancer는 외부 손님을 맞는 ‘정문’이고, Cloudflare 도메인은 그 정문 앞에 세워진 ‘간판’이었구나. 손님들은 간판을 보고 정문으로 찾아오는 거였어. 내가 ‘간판을 직원용 뒷문에 달 수는 없을까?’ 같은 엉뚱한 생각을 하고 있었네.”
솔라는 이제 교안의 ‘공개 경계를 설계해야 한다’는 문장이 가진 무게를 온전히 이해할 수 있었다. 그 경계의 최전선은 바로 Cloudflare라는 간판과 LoadBalancer라는 정문이 형성하고 있었다. 이 둘이 외부 세계와 내부 시스템을 가르는 첫 번째 방어선이자 유일한 통로였다. 사용자의 모든 트래픽은 예외 없이 이 관문을 통과해야만 했다.
모든 연결고리가 마침내 하나로 이어졌다. 사용자가 브라우저 주소창에 도메인을 입력하는 순간부터, 그 요청이 DNS를 거쳐 프론트엔드 로드밸런서에 도착하고, Nginx 프록시를 통해 내부 백엔드 서비스에 전달된 후, 응답이 다시 사용자에게 돌아가기까지의 전 과정이 머릿속에 선명하게 그려졌다.
그림이 완성되자, 솔라의 마음속에는 새로운 질문이 떠올랐다. 각 부분의 역할과 연결 방식은 이해했지만, 이제는 전체를 조망하고 싶었다.
“좋아, 이제 모든 조각이 맞춰졌어. 그런데 언니, 이렇게 여러 단계를 거치는 전체 설계가 궁극적으로 우리에게 주는 이점은 뭘까? 보안? 안정성? 아니면 확장성? 이 모든 복잡함이 해결하려는 근본적인 문제가 뭔지, 한 문장으로 정리해보고 싶어.”
5장: 루나: 통합된 노출 경계, 현명한 설계
솔라의 머릿속에서 모든 연결고리가 맞춰지자, 흩어져 있던 퍼즐 조각들이 하나의 완성된 그림이 되었다. 사용자, 도메인, 프론트엔드 로드밸런서, Nginx 프록시, 그리고 내부의 백엔드 서비스까지. 요청의 흐름이 물 흐르듯 선명하게 그려졌다. 하지만 그림이 완성되자, 솔라는 그 그림의 ‘의도’가 궁금해졌다. 왜 이런 복잡한 구조를 선택했는지, 그 핵심적인 이유를 자신의 것으로 만들고 싶었다.
질문을 던지는 대신, 솔라는 직접 행동에 나섰다. 새로운 아키텍처를 직접 설계해보는 것이야말로 자신의 이해를 검증할 가장 확실한 방법이라고 생각했다. 그녀는 빈 다이어그램 도구를 화면에 띄우고 ‘새로운 서비스 아키텍처 구상’이라는 제목을 붙였다. 가상의 시나리오는 간단했다. 기존의 책방 애플리케이션에 ‘주문 처리’ 기능을 추가하는 것. 이 새로운 ‘주문 서비스(order-service)’를 어떻게 기존 시스템에 통합할 것인가?
솔라는 먼저 ‘order-service’라는 이름의 네모 상자를 그렸다. 그리고 바로 그 지점에서 펜을 멈췄다. 가장 근본적인 질문이 다시 발목을 잡았다. ‘그래서, 이 서비스의 노출 방식은 뭘로 해야 하지?’ 사용자가 주문 내역을 보려면 당연히 외부에서 접근할 수 있어야 할 것 같았다. ‘그럼 LoadBalancer인가?’ 하지만 이 생각은 곧바로 다른 질문과 충돌했다. ‘모든 걸 외부에 열어두는 게 과연 최선일까?’ 그녀의 초기 생각, ‘모든 서비스는 균일하게 외부 노출되어야 가장 효율적이다’라는 믿음이 흔들리기 시작했다. 머릿속이 다시 복잡해졌다.
“언니, 나 새로운 서비스를 하나 설계해보고 있는데, 첫 단계부터 막혔어.”
솔라의 부름에 루나가 다가와 모니터를 들여다보았다. 화면 중앙에는 덩그러니 놓인 ‘order-service’ 상자만 그려져 있었다.
“어떤 부분이 막히는데?”
“이 주문 서비스를 LoadBalancer로 외부에 열어야 할지, 아니면 ClusterIP로 내부에 숨겨야 할지 모르겠어. 주문 내역은 사용자가 봐야 하는 거니까 외부로 열어야 할 것 같은데… 지금까지 우리가 확인한 아키텍처를 생각하면 그것도 아닌 것 같고.”
루나는 솔라의 고민을 듣고는, 답을 주는 대신 새로운 다이어그램 옆에 솔라가 이제껏 이해했던 기존 책방 서비스의 아키텍처 그림을 나란히 띄웠다. 그리고 질문을 던졌다.
“솔라, 우리 시스템 전체를 하나의 큰 건물이라고 상상해봐. 그 건물에 손님이 들어올 수 있는 유일한 정문은 어디지?”
솔라는 망설임 없이 기존 아키텍처 그림에서 Cloudflare 도메인과 연결된 프론트엔드 LoadBalancer를 가리켰다.
“여기. 프론트엔드 서비스. 여기가 유일한 입구야.”
“맞아. 그럼 그 정문의 안내원(Nginx)은 손님의 요청에 따라 어떤 두 가지 행동을 하지?”
“손님이 UI 페이지를 찾으면(GET /books) 내부의 전시실(프론트엔드 앱)로 안내하고, 손님이 데이터가 필요하다고 하면(GET /api/books) 창고 직원(백엔드 서비스)에게 물건을 가져오라고 시키지.”
솔라가 대답하는 사이, 그녀의 눈빛이 미세하게 변했다. 루나는 그 변화를 놓치지 않고 마지막 질문을 던졌다.
“좋아. 그럼 새로 만들 ‘주문 서비스’는 누구랑 대화하는 서비스일까? 건물 밖의 손님과 직접 대화할까, 아니면 정문 안내원이랑 대화할까?”
“아…!”
짧은 탄성과 함께 모든 것이 명확해졌다. 솔라는 자신이 ‘서비스의 기능’(주문 처리)에만 집중한 나머지 ‘서비스의 고객이 누구인가’를 놓쳤다는 사실을 깨달았다. 주문 서비스가 처리하는 데이터는 결국 프론트엔드 UI를 통해 사용자에게 보여진다. 즉, 주문 서비스와 직접 통신하는 ‘고객’은 최종 사용자의 브라우저가 아니라, 프론트엔드 서버였던 것이다.
솔라는 다시 다이어그램 도구로 손을 옮겼다. 더 이상 망설임은 없었다. 그녀는 ‘order-service’의 타입을 ClusterIP로 명확히 지정하고, ‘내부 전용’이라는 주석을 달았다. 그리고 곧장 프론트엔드 서비스의 Nginx 설정 다이어그램에 새로운 규칙을 추가했다.
location /api/orders/ { proxy_pass http://order-service:8080; }
이제 모든 요청은 기존과 동일하게 단 하나의 정문, 프론트엔드 LoadBalancer를 통해 들어온다. 그리고 정문의 안내원 Nginx는 /api/orders/로 시작하는 요청이 오면, 새로 생긴 내부의 ‘주문 처리팀’에게 조용히 업무를 전달할 것이다. 외부 세계는 ‘주문 서비스’의 존재 자체를 알 필요가 없었다.
솔라는 자신이 완성한 다이어그램을 보며 고개를 끄덕였다. 처음엔 복잡해 보이기만 했던 이 설계가 왜 ‘현명한 설계’인지 이제 설명할 수 있었다.
첫째, 보안. 공격에 노출될 수 있는 표면적을 최소화한다. 오직 검증된 단 하나의 정문(프론트엔드)만 방어하면 된다. 둘째, 관리의 단순성. 모든 트래픽이 한 곳으로 모이기 때문에 인증, 로깅, 모니터링을 중앙에서 관리하기 쉽다. 셋째, 유연성. 내부 백엔드 서비스들의 구조를 바꾸거나 새로 추가해도, 외부 사용자가 바라보는 주소는 전혀 영향을 받지 않는다.
솔라는 마침내 교안의 첫 문장이었던 ‘서비스 안정성을 위해 공개 경계를 설계해야 한다’는 말의 진짜 의미를 깨달았다. 그것은 모든 문을 활짝 열어두라는 뜻이 아니었다. 외부로 통하는 문은 신중하게 단 하나만 선택하고, 그 문을 가장 견고하고 똑똑하게 만들어, 내부의 복잡성은 안전하게 감추라는 깊은 뜻이었다. 이것이 바로 ‘통합된 노출 경계 설계’의 핵심이었다.
솔라는 만족스럽게 자신이 그린 ‘새로운 서비스 통합 아키텍처’ 파일을 저장했다. 이제 그녀는 단순히 지식을 이해하는 것을 넘어, 비슷한 문제 상황에서 어떤 서비스 노출 전략을 선택하고 그 이유를 자신 있게 설명할 수 있는 구체적인 판단 기준을 갖게 된 것이다.