Mini Project 5 07
CORS와 환경변수: 런타임 경계를 잇는 아키텍처적 도구
CORS 에러는 브라우저가 괜히 막는 오류처럼 느껴진다. 하지만 실제로는 frontend와 backend가 서로 다른 런타임이라는 사실이 드러나는 지점이다.
근거 · 교안 CORS/frontend-backend 연결 파트
1장: CORS 에러, 브라우저의 오지랖일까?
솔라는 화면 한구석에 뜬 붉은색 에러 메시지를 노려보고 있었다. 영락없이 또 그 메시지였다. Cross-Origin Resource Sharing Error. 프론트엔드 코드에서 백엔드 API를 호출하기만 하면 어김없이 나타나는 불청객. 솔라는 자기도 모르게 마우스를 쥔 손에 힘을 주었다.
“아니, 왜 또 막는 건데! 내가 내 컴퓨터에서 실행하는 건데, 뭐가 그렇게 위험하다고.”
에러 메시지는 늘 브라우저 콘솔 창에 나타났다. 그러니 범인은 당연히 브라우저라고 생각했다. 마치 까다로운 보안 요원이 사소한 규칙 하나를 트집 잡아 앞을 가로막는 느낌이었다. ‘보안 정책 때문에 요청이 차단되었습니다.’ 라는 말은 ‘우리가 그냥 막을 거니까 그런 줄 알아.’ 라는 선언처럼 들렸다.
한참 동안 굳은 표정으로 모니터를 응시하던 솔라는 결국 의자 등받이에 몸을 기댔다. 분명히 프론트엔드 프로젝트는 localhost:5173에서, 백엔드 프로젝트는 localhost:8080에서 문제없이 잘 돌고 있었다. 각자 자기 세상에서는 모든 것이 완벽했다. 그런데 프론트엔드가 백엔드에게 ‘데이터 좀 줘’ 하고 손을 뻗는 순간, 브라우저가 귀신같이 나타나 그 손을 쳐내는 형국이었다.
“이건 그냥… 브라우저의 오지랖이 아닐까?”
그때, 조용히 책을 읽고 있던 언니 루나가 고개를 들었다.
“오지랖이라기엔 너무 일관성 있지 않아?”
루나는 자리에서 일어나 솔라의 책상 위로 다가왔다. 솔라가 가리킨 모니터의 에러 메시지를 잠시 들여다보더니, 책상 위에 굴러다니는 서로 다른 색의 포스트잇 두 장을 집어 들었다. 그리고는 하나에 ‘프론트엔드: 5173번 집’, 다른 하나에는 ‘백엔드: 8080번 집’이라고 적었다.
“솔라, 네 프론트엔드 앱은 어디 살고 있어?”
“여기, 5173번.”
솔라가 노란색 포스트잇을 가리켰다.
“데이터를 달라고 요청하는 백엔드 서버는?”
“8080번.”
솔라의 손가락이 분홍색 포스트잇으로 옮겨갔다. 루나는 두 포스트잇을 책상 양 끝에 떼어놓았다.
“그럼 이건 같은 주소야, 다른 주소야?”
“다른 주소지. localhost는 같아도 포트 번호가 다르니까.”
너무나 당연한 사실을 확인하는 질문에 솔라는 무심코 대답했다. 바로 그 순간, 무언가 머리를 스쳤다. 루나는 솔라의 표정 변화를 놓치지 않고 말을 이었다.
“브라우저는 ‘5173번 집’에 방문한 손님 같은 거야. 그런데 그 집 안에 있는 스크립트가 갑자기 ‘저기 멀리 있는 8080번 집에 가서 물건 좀 가져올게요!’라고 외치는 거지.”
루나는 책상 가운데를 손가락으로 가르며 말했다.
“손님을 안내하는 브라우저 입장에서는 어떨까? ‘잠깐만요, 처음 방문한 곳은 5173번 집인데, 갑자기 전혀 다른 주소인 8080번 집으로 가신다고요? 그 집 주인이 외부 손님을 받아도 괜찮다고 허락했는지, OPTIONS라는 특별한 문의 절차를 통해 먼저 확인해 봐야겠는데요?’ 라고 말하는 게 당연하지 않을까?”
솔라는 책상 양 끝에 놓인 포스트잇을 번갈아 보았다. ‘5173번 집’과 ‘8080번 집’. 늘 하나의 프로젝트처럼 생각했지만, 사실은 처음부터 완전히 분리된 공간에서 실행되는 별개의 프로그램이었다. 같은 localhost라는 이름 아래 있었기에 한동네 이웃쯤으로 여겼던 것이다. 하지만 컴퓨터의 관점에서 포트 번호가 다르면 그건 명백히 다른 ‘세상’이었다.
“아…”
작은 탄식이 흘러나왔다.
“그럼… 브라우저가 일부러 막는 게 아니었네. 원래부터 둘은 완전히 다른 세상이었던 거야. 그냥 다른 주소니까, 브라우저는 원칙대로 확인 절차를 밟으려고 했던 것뿐이고.”
CORS 에러는 브라우저가 부리는 심술이 아니었다. 프론트엔드와 백엔드가 서로 다른 출처(Origin)를 가진, 독립적인 런타임이라는 명백한 증거였다. 붉은색 에러 메시지는 문제가 아니라, 두 개의 분리된 세상이 존재한다는 사실을 알려주는 신호등이었던 셈이다.
이제 솔라의 눈에는 붉은색 에러 메시지가 다르게 보였다. 더 이상 짜증을 유발하는 방해물이 아니었다. ‘경고: 출처가 다른 두 애플리케이션이 통신을 시도하고 있습니다. 연결 규칙이 정의되지 않았습니다.’ 라는 친절한 안내문처럼 느껴졌다.
문제를 바라보는 관점이 바뀌자, 해결해야 할 질문도 달라졌다. 더 이상 ‘이 성가신 브라우저 보안 기능을 어떻게 우회하지?’가 아니었다.
“그렇다면, 이 두 개의 다른 세상이 서로를 알아보고 안전하게 대화하게 하려면… 어떻게 해야 하는 거지? ‘8080번 집’이 ‘5173번 집’에서 온 손님은 환영한다고, 어떻게 알려줄 수 있을까?”
2장: 백엔드의 ‘환영 인사’: WebConfig와 Origin 허용 목록
솔라의 시선은 더 이상 브라우저의 붉은색 에러 메시지에 머물러 있지 않았다. 그녀의 눈은 이제 백엔드 프로젝트의 파일 탐색기를 샅샅이 훑고 있었다. 어제 책상 양 끝에 붙여두었던 ‘5173번 집(프론트엔드)’과 ‘8080번 집(백엔드)’ 포스트잇은 그대로였다. 두 집이 서로 다른 세상이라는 사실을 받아들이자, 문제의 열쇠는 ‘8080번 집’이 쥐고 있을 거라는 확신이 들었다.
‘8080번 집 주인이 문을 열어줘야 해. 그렇다면 집 어딘가에 손님을 어떻게 대할지 적어둔 규칙이 있을 거야.’
솔라는 ‘src/main/java’ 폴더를 열고 여러 패키지들을 훑어보았다. 컨트롤러는 요청을 받는 문 같았고, 서비스는 집안일을 처리하는 로직 같았다. 하지만 ‘외부 손님 출입 규칙’ 같은 건 어디에 있을까? 솔라는 무심코 모든 요청을 일단 받아들이는 게 기본이고, 위험한 요청만 골라서 막는 방식일 거라고 짐작했다. 마치 공항 검색대처럼 말이다.
“보안 관련 설정 파일 같은 게 있지 않을까? ‘이런 이상한 손님은 받지 마세요’ 하는 블랙리스트 같은 거.”
혼잣말을 들은 루나가 솔라의 화면을 들여다보았다. 그러고는 ‘8080번 집’이라고 적힌 분홍색 포스트잇을 툭, 하고 가볍게 쳤다.
“관점을 바꿔보면 어떨까? 집 주인이 기본적으로 아무나 들이지 않고, 초대장에 이름이 적힌 손님만 환영하는 방식이라면?”
“초대장?”
“응. 블랙리스트가 아니라, 화이트리스트. ‘모든 손님을 거부하되, 이 목록에 있는 손님만 환영합니다.’ 라고 선언하는 거지. 그게 훨씬 안전하잖아.”
루나는 마우스를 움직여 config 라는 이름의 패키지를 가리켰다. 그 안에는 WebConfig.java 라는 파일이 있었다. 파일 이름만 봐서는 웹 전반에 대한 설정 같았다. 솔라가 파일을 열자, 익숙하면서도 낯선 자바 코드가 화면을 채웠다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins}")
private String[] allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*");
}
}
솔라의 눈이 allowedOrigins라는 단어에 꽂혔다. ‘허용된 출처들’. 루나가 말한 ‘초대장 명단’이 바로 이것 같았다. 그 아래 allowedMethods 목록에는 GET, POST 등과 함께 OPTIONS라는 생소한 메서드도 있었다. 브라우저가 실제 요청을 보내기 전, “혹시 이런 요청 보내도 괜찮을까요?” 하고 미리 물어보는 예비 요청(preflight request)에 사용되는 메서드였다. 이 예비 요청까지 명시적으로 허용해야 비로소 본 통신이 시작될 수 있었다.
“언니, 여기 allowedOrigins라고 있어. 이게 ‘5173번 집’ 주소를 담고 있는 건가?”
“거의 다 왔어. 그런데 그 값은 어디서 오는 것 같아?”
솔라는 다시 코드를 들여다봤다. @Value("${app.cors.allowed-origins}"). 코드 안에 주소가 직접 적혀있지 않았다. 대신 어떤 설정 파일에서 app.cors.allowed-origins라는 이름의 값을 가져와 사용하는 것처럼 보였다. 프로젝트의 application.properties 파일을 열자, 그곳에 해답이 있었다.
app.cors.allowed-origins=http://localhost:5173,http://localhost:3000
“찾았다! 여기 http://localhost:5173이 명시되어 있네.”
마치 숨겨진 보물을 찾은 듯 솔라의 목소리가 들떴다. 바로 이 한 줄이 ‘8080번 집’이 ‘5173번 집’에서 온 손님을 환영한다는 명시적인 증표였다. 백엔드는 기본적으로 문을 굳게 닫고 있다가, WebConfig라는 설정 관리자를 통해 application.properties에 적힌 손님 명단을 확인하고 문을 열어주는 것이었다. ‘모두에게 열려있을 것’이라는 솔라의 처음 짐작과는 정반대였다.
“그럼, 정말 이 한 줄 때문에 되고 안 되고 했던 건지 확인해 볼까?”
루나의 제안에 솔라는 기다렸다는 듯 application.properties 파일에서 http://localhost:5173 부분을 지우고 백엔드 서버를 재시작했다. 그리고 프론트엔드 화면으로 돌아가 데이터를 요청하는 버튼을 눌렀다.
결과는 명확했다. 브라우저 콘솔 창에 어제 지겹게 봤던 붉은색 CORS 에러 메시지가 다시 나타났다.
솔라는 잠시 멍하니 화면을 보다가, 다시 백엔드 설정 파일로 돌아가 지웠던 주소를 복원하고 서버를 재시작했다. 그리고 다시 프론트엔드에서 같은 버튼을 클릭했다. 그러자 언제 그랬냐는 듯, 데이터가 화면에 깔끔하게 표시되었다.
“와…”
짧은 탄성과 함께 솔라는 의자 등받이에 몸을 기댔다. 이제 모든 조각이 맞춰졌다. CORS 에러는 브라우저의 오지랖이 아니었다. 그것은 ‘초대받지 않은 손님’이라는 사실을 알려주는 정직한 신호였다. 그리고 백엔드의 WebConfig와 allowed-origins 설정은, 바로 그 ‘환영받는 손님’의 목록을 관리하는 초대장이었다. 경계는 명확했고, 그 경계를 넘기 위한 규칙 또한 명시적이었다.
한 가지 의문이 풀리자, 자연스럽게 다음 질문이 고개를 들었다.
“좋아, 이제 ‘8080번 집’이 나를 알아보고 문을 열어주는 건 알겠어. 그런데 반대로 ‘5173번 집’에 있는 내 코드는 ‘8080번 집’의 주소를 어떻게 알고 찾아가는 거지? 코드 어딘가에 http://localhost:8080 이라고 내가 직접 써넣었나? 만약 나중에 백엔드 서버 주소가 바뀌면 어떡하지?”
3장: 프론트엔드의 ‘주소록’: VITE_API_BASE_URL
솔라의 손가락이 키보드 위에서 분주하게 움직였다. 프론트엔드 프로젝트 전체를 대상으로 한 검색창에 http://localhost:8080을 입력하고 엔터 키를 눌렀다. 백엔드 집의 주소. 프론트엔드 코드가 이 주소를 모른다면 애초에 통신 자체가 불가능할 터였다. 당연히 코드 어딘가에 이 주소 문자열이 명시적으로 박혀있을 거라고, 솔라는 확신했다.
하지만 검색 결과창에 뜬 메시지는 차가웠다. ‘검색 결과 없음.’
솔라는 미간을 찌푸렸다. 오타가 있었나 싶어 다시 한번 localhost:8080으로 검색해봤지만 결과는 같았다.
‘말도 안 돼. 주소를 모르는데 어떻게 찾아간다는 거지? 그럼 지금까지 내가 본 성공 화면은 뭐였단 말이야?’
마치 목적지 주소 없이 편지를 보내는 것과 같은 불가능한 상황처럼 느껴졌다. 백엔드가 ‘초대장’을 보내 자신을 환영한다는 사실은 알게 되었지만, 정작 그 초대장을 보낸 집이 어디인지 모른다면 무용지물이었다. 솔라의 처음 생각, 즉 프론트엔드 코드 어딘가에 백엔드 주소가 하드코딩되어 있을 거라는 짐작은 완전히 빗나갔다.
솔라의 혼란스러운 표정을 본 루나가 책상 위에 놓여 있던 ‘5173번 집’ 포스트잇을 가리켰다.
“솔라, 네가 친구 집에 놀러 갈 때를 생각해 봐. 친구 집 주소를 외워서 매번 벽에다 직접 새겨놓고 찾아가진 않잖아.”
“그야 당연히 아니지. 휴대폰 주소록에 저장해두고 필요할 때 꺼내보는데.”
솔라는 무심코 대답했다가 순간 멈칫했다. 루나는 바로 그 지점을 기다렸다는 듯 말을 이었다.
“바로 그거야. 프론트엔드 코드도 마찬가지일 수 있어. 백엔드 서버의 주소를 코드 구석구석에 새겨두는 게 아니라, ‘주소록’ 같은 곳에 따로 보관해두고 필요할 때마다 그 주소록을 참조해서 가져다 쓰는 거지.”
‘주소록’. 그 말에 솔라의 머릿속이 환해졌다. 그녀는 지금까지 ‘주소’ 그 자체를 찾고 있었다. 하지만 찾아야 할 것은 주소가 아니라, 주소가 적힌 ‘주소록’이었을지도 모른다.
관점을 바꾸자 다른 것들이 보이기 시작했다. 솔라는 이번엔 http://localhost:8080 대신, api나 baseURL 같은 단어로 다시 검색하기 시작했다. 잠시 후, API 요청을 보내는 클라이언트 설정 부분에서 결정적인 코드를 발견했다.
// src/api/client.js
import axios from 'axios';
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL
});
export default client;
솔라의 눈이 import.meta.env.VITE_API_BASE_URL이라는 부분에 고정되었다. baseURL 값으로 http://localhost:8080이라는 문자열이 직접 들어가는 대신, VITE_API_BASE_URL이라는 이름의 변수를 env(environment, 환경)라는 곳에서 가져오고 있었다.
“찾았다! 주소록을 찾은 것 같아!”
솔라는 프로젝트의 루트 디렉터리에 있는 .env 파일을 열었다. 그 파일 안에는 단 한 줄의 코드가 적혀 있었다.
VITE_API_BASE_URL=http://localhost:8080
빙고. 바로 여기였다. 프론트엔드 코드는 백엔드 주소를 직접 알지 못했다. 대신 VITE_API_BASE_URL이라는 별명을 가진 주소를 찾아달라고 ‘환경 변수’라는 주소록에 요청할 뿐이었다. 이렇게 하니 코드를 직접 수정하지 않고도 .env 파일의 내용만 바꾸면 API 서버 주소를 얼마든지 바꿀 수 있었다.
“정말 이 파일이 프론트엔드의 ‘주소록’이 맞는지 확인해봐야지.”
루나의 말에 솔라는 .env 파일의 내용을 주석 처리하고 프론트엔드 개발 서버를 재시작했다. 그리고 다시 화면에서 데이터를 요청하는 버튼을 눌렀다. 이번에는 CORS 에러가 아니었다. 브라우저 개발자 도구의 네트워크 탭에는 http://undefined/api/…로 요청을 보내려다 실패한 기록이 남았다. VITE_API_BASE_URL 값을 읽어오지 못해 목적지 주소 자체가 사라져버린 것이다.
솔라는 다시 .env 파일의 주석을 풀고 서버를 재시작했다. 모든 것이 정상으로 돌아왔다.
“아… 코드는 ‘어떤 주소로 가야 할지’를 직접 아는 게 아니라, ‘주소가 어디에 적혀 있는지’만 아는 거였구나.”
이제 명확해졌다. 백엔드는 WebConfig와 allowed-origins라는 ‘초대장 명단’으로 허가된 손님을 식별하고, 프론트엔드는 .env 파일과 VITE_API_BASE_URL이라는 ‘주소록’으로 찾아갈 집 주소를 확인한다. 두 개의 분리된 런타임은 이렇게 각자의 설정 파일을 통해 서로의 경계를 명시하고, 그 경계를 넘기 위한 약속을 정의하고 있었다.
그런데 문득 새로운 궁금증이 생겼다. 지금은 개발 환경이니까 프론트엔드도, 백엔드도 모두 localhost라는 한 컴퓨터 안에서 실행된다. 하지만 이 애플리케이션을 실제 서버에 배포하면 어떻게 될까?
“언니, 지금은 내 컴퓨터에서 하니까 localhost:5173이랑 localhost:8080인데… 만약에 나중에 프론트엔드는 luna.com에, 백엔드는 api.sola.dev 같은 다른 주소로 이사 가면 어떻게 되는 거지? 그때마다 이 주소록 파일을 직접 고쳐서 다시 배포해야 하나? 개발할 때랑 배포할 때랑 주소가 달라질 텐데, 이건 좀 번거로울 것 같은데?”
4장: 배포 환경의 ‘동적 설정’: APP_CORS_ALLOWED_ORIGINS
솔라의 시선은 더 이상 코드 편집기에 머물러 있지 않았다. 그녀는 스크롤을 내려 프로젝트의 README.md 파일 끝자락에 있는 ‘문제 해결(Troubleshooting)’ 섹션을 보고 있었다. 개발 환경에서의 연결은 이제 명확해졌다. ‘5173번 집(프론트엔드)’은 .env라는 주소록을 보고 ‘8080번 집(백엔드)’을 찾아가고, ‘8080번 집’은 application.properties라는 초대장 명단을 보고 문을 열어준다. 완벽한 시나리오였다. 하지만 솔라의 마음속에 남은 찝찝함은 바로 어제 루나에게 던졌던 질문이었다. ‘만약 이사 가면 어떡하지?’
개발 환경과 배포 환경. localhost라는 아늑한 동네를 떠나 실제 인터넷이라는 거대한 세상으로 나아갔을 때의 이야기. 프론트엔드가 luna.com으로, 백엔드가 api.sola.dev로 이사 간다면, 그때마다 application.properties 파일을 고쳐서 새로 집을 지어야(재배포해야) 하는 걸까? 상상만 해도 번거로운 일이었다.
그때, ‘문제 해결’ 섹션의 한 줄이 솔라의 눈에 들어왔다.
CORS 오류 발생 시: 백엔드 실행 환경에 APP_CORS_ALLOWED_ORIGINS=http://localhost:5173 와 같이 환경변수를 추가해 보세요.
솔라는 미간을 살짝 찌푸렸다.
“어? 이상하다. allowed-origins는 application.properties 파일에서 설정하는 거 아니었어? 왜 여기서 또 다른 방법을 알려주지?”
이 문서는 마치 application.properties 파일의 설정을 잊어버린 사람을 위한 임시방편, 혹은 다른 종류의 오류에 대한 해결책처럼 보였다. 솔라의 생각은 ‘일단은 코드에 박아두는 게 정석이고, 이건 급할 때 쓰는 꼼수 같은 건가?’ 하는 쪽으로 기울었다. 배포 환경에서도 그냥 application.properties 파일에 배포될 프론트엔드의 주소(httpss://luna.com)를 미리 적어두면 될 거라고 막연히 생각했다.
“언니, 이 README 문서 좀 봐. CORS 설정하는 방법이 또 있네. application.properties 파일 말고, 환경변수로 설정하라고 되어 있어. 어차피 application.properties에 배포 주소를 미리 넣어두면 되는 거 아닌가? 왜 이렇게 복잡하게 하는 거지?”
솔라의 질문에 루나는 잠시 모니터를 들여다보더니, 책상 위에 놓여 있던 분홍색 ‘8080번 집’ 포스트잇을 들어 올렸다.
“솔라, 네가 지금 이 파일을 고칠 수 있는 건, 이 집이 아직 네 책상 위에 있기 때문이야. 즉, 네 컴퓨터 안에서 개발 중이니까.”
루나는 포스트잇을 손에 든 채로 방 반대편, 창문 쪽을 가리켰다.
“저기 어딘가에 있는 ‘서버’라는 동네로 이 집을 통째로 옮겨서 배포했다고 생각해 봐. 이제 이 집은 단단하게 지어진 건물이야. 안에 있는 가구(코드)나 벽지(application.properties)를 바꾸려면, 건물을 부수고 새로 지어야(재배포해야) 해. 그런데 손님 명단이 바뀔 때마다 매번 건물을 새로 짓는 건 너무 비효율적이지 않아?”
“그건 그렇지. 그냥 문 앞에 ‘새로운 손님 명단’을 붙여두면 편할 텐데.”
“바로 그거야.”
루나는 솔라의 백엔드 프로젝트에서 다시 WebConfig.java 파일을 열었다.
@Value("${app.cors.allowed-origins}")
private String[] allowedOrigins;
“이 코드를 다시 봐봐. ‘app.cors.allowed-origins 라는 이름표가 붙은 값을 가져와라’ 라고 되어 있지, ‘application.properties 파일에서 가져와라’ 라고는 안 되어 있어. 스프링 부트는 똑똑해서, 이 이름표에 해당하는 값을 찾을 때 아주 체계적인 순서로 찾아다녀.”
루나는 말을 이으며 솔라가 이해하기 쉽도록 손가락으로 순서를 짚었다.
“먼저, 애플리케이션이 실행되는 바로 그 ‘환경’에게 물어봐. ‘혹시 APP_CORS_ALLOWED_ORIGINS라는 이름의 환경 변수가 있나요?’ 하고. 자바 밖의 세상, 즉 이 프로그램을 실행시킨 껍데기(터미널, Docker, 클라우드 서버 등)에 먼저 물어보는 거야. 만약 거기에 값이 있으면, 그걸 최우선으로 사용하고 탐색을 멈춰.”
솔라는 README에서 봤던 바로 그 변수 이름이 나오자 숨을 죽였다.
“만약 환경 변수가 없으면? 그때서야 자기 자신, 즉 코드와 함께 포장된 application.properties 파일 안을 들여다보고 app.cors.allowed-origins 값을 사용해. 이게 바로 ‘대체 값(fallback)’이야. 말로만 들으면 헷갈리지? 직접 눈으로 확인해보자.”
루나는 솔라의 백엔드 터미널을 가리켰다. “백엔드 서버를 잠시 끄고, application.properties 파일에서 app.cors.allowed-origins 줄을 주석 처리해봐. 기본 초대장을 잠시 숨기는 거야.”
솔라는 시키는 대로 했다. 파일에 #을 추가하고 저장했다.
“이제 다시 백엔드를 실행하고, 프론트엔드에서 데이터를 요청해봐.”
잠시 후, 솔라의 화면에는 다시 그 붉은 CORS 에러 메시지가 나타났다. 백엔드가 허용하는 Origin 목록이 비어있으니 당연한 결과였다.
“자, 이제 코드는 건드리지 말고. 백엔드 서버를 다시 끈 다음에, 터미널에서 이렇게 실행해봐.” 루나가 명령어 한 줄을 타이핑했다.
APP_CORS_ALLOWED_ORIGINS=http://localhost:5173 ./gradlew bootRun
“이건 백엔드 프로그램을 실행할 때, APP_CORS_ALLOWED_ORIGINS라는 이름의 환경 변수를 임시로 설정해서 실행하라는 명령어셔. 문 앞에 ‘http://localhost:5173 손님 환영’이라는 팻말을 붙이고 집을 여는 거지.”
솔라는 떨리는 손으로 명령어를 입력하고 엔터를 쳤다. 백엔드 서버가 다시 구동되었다. 그녀는 프론트엔드 화면으로 돌아가 다시 한번 데이터를 요청했다. CORS 에러는 사라지고, 데이터가 화면에 정상적으로 나타났다.
“아…!”
솔라는 탄성을 내뱉었다. 코드를 단 한 줄도 바꾸지 않았다. application.properties 파일은 여전히 주석 처리된 상태 그대로였다. 하지만 백엔드는 마치 새 초대장을 받은 것처럼 프론트엔드의 요청을 허락했다. README에 있던 환경 변수 설정은 꼼수나 임시방편이 아니었다. 그것은 이미 설계된, 훨씬 유연하고 동적인 방식이었다.
“그러니까 application.properties는 이사 갈 수 없는 붙박이 가구고, 이 환경 변수는 상황에 따라 문 앞에 걸어두는 ‘환영’ 팻말 같은 거구나! 배포할 때마다 코드를 고치는 게 아니라, 프로그램을 실행시키는 바깥 세상에서 팻말 내용만 바꿔주면 되는 거였어.”
application.properties에 localhost 주소를 적어두는 것은 개발 환경을 위한 ‘기본값’ 설정이었고, 환경 변수는 배포 환경이라는 외부 세계의 변화에 대응하기 위한 ‘우선권’을 가진 설정이었다. 같은 코드가 다른 환경에서 다르게 동작하는 비밀이 바로 이 ‘외부 설정’에 있었다.
솔라는 잠시 생각에 잠겼다. 프론트엔드의 ‘주소록’(.env), 백엔드의 ‘기본 초대장’(application.properties), 그리고 배포 환경의 ‘동적 팻말’(환경 변수). 이 세 가지는 모두 각자의 위치에서 ‘경계’를 설정하고 ‘연결’을 관리하고 있었다.
“이 설정들은 그냥 개별적인 오류 해결 방법이 아니었네. 프론트엔드, 백엔드, 그리고 배포 환경이라는 각각의 세상을 구분하고, 이들이 서로 소통할 수 있도록 길을 터주는, 더 큰 그림의 일부였어.”
5장: 오류를 넘어, 경계를 명시하는 도구로
솔라는 지금까지의 여정을 한눈에 볼 수 있도록 책상 위에 흩어져 있던 포스트잇과 메모들을 한데 모으기 시작했다. 처음엔 그저 성가신 오류 해결 팁으로 보였던 것들이었다. CORS 에러, allowed-origins, VITE_API_BASE_URL, 환경 변수… 이 단어들은 이제 각기 다른 퍼즐 조각처럼 보였다. 하지만 조각들은 흩어져 있었고, 아직 하나의 그림을 이루지 못하고 있었다. 솔라는 이 조각들을 어떻게 맞춰야 할지 막막함을 느꼈다. 특정 문제를 해결하기 위한 일회성 조치들의 나열일 뿐, 이들을 관통하는 하나의 원리가 보이지 않았다.
“결국엔… 다 따로따로 외워야 하는 건가?”
솔라는 깨끗한 노트를 새로 펼치고, 가운데에 큰 선을 하나 그었다. 그리고 왼쪽에는 ‘프론트엔드에서 할 일’, 오른쪽에는 ‘백엔드에서 할 일’이라고 적었다. VITE_API_BASE_URL 설정은 프론트엔드 칸에, allowed-origins 설정은 백엔드 칸에 적었다. CORS 에러가 나면 백엔드 설정을 확인하고, API 주소가 잘못되면 프론트엔드 설정을 확인한다. 나름 깔끔한 정리였지만, 여전히 개별적인 문제와 해결책의 목록처럼 보일 뿐이었다.
그때, 솔라의 노트를 들여다보던 루나가 말없이 펜을 들었다. 루나는 솔라가 그어놓은 ‘할 일’ 목록 위로 커다란 네모 상자 두 개를 그렸다. 하나는 프론트엔드 목록을, 다른 하나는 백엔드 목록을 감쌌다. 그리고 두 상자 사이의 공간, 솔라가 그었던 희미한 경계선을 굵게 덧칠했다.
“솔라, 네가 그린 건 ‘할 일’ 목록이 아니야. 이건 ‘지도’야.”
루나는 프론트엔드를 감싼 상자에 ‘5173번 세상 (React 개발 서버 런타임)’이라고 적고, 백엔드를 감싼 상자에는 ‘8080번 세상 (Spring Boot 서버 런타임)’이라고 적었다.
“이 두 상자는 완전히 분리된 세상, 즉 ‘런타임 경계’를 나타내. 그리고 네가 적은 저 설정들은, 그 경계선 위에서 일어나는 일들을 정의하는 도구들이지. 흩어져 있는 게 아니라, 정확히 자기 자리가 있어.”
루나는 솔라가 ‘프론트엔드에서 할 일’ 칸에 적어둔 VITE_API_BASE_URL을 손가락으로 가리켰다. “이건 ‘5173번 세상’ 안에서 바깥세상으로 나가는 출구의 이정표야. ‘저쪽으로 가야 8080번 세상이 나와’라고 알려주는 거지.”
그리고는 ‘백엔드에서 할 일’ 칸으로 손가락을 옮겼다. “allowed-origins는 ‘8080번 세상’의 입국 심사대야. ‘5173번 세상에서 온 방문객만 들어올 수 있습니다’라고 적힌 허가증 목록이고. 그리고 우리가 마지막에 봤던 환경 변수 APP_CORS_ALLOWED_ORIGINS는? 그건 입국 심사 규칙을 건물 내부(application.properties)가 아니라, 이 세상 바깥의 관제소(배포 환경)에서 원격으로 바꿀 수 있게 해주는 비상 통신 장치 같은 거야.”
솔라는 루나가 새로 그려준 지도를 가만히 들여다보았다. ‘할 일 목록’이라고 생각했던 것이 ‘런타임 지도’로 보이자, 모든 것이 제자리를 찾기 시작했다. VITE_API_BASE_URL, WebConfig, allowed-origins… 이것들은 더 이상 오류를 막기 위한 방어적인 조치가 아니었다. 두 개의 분리된 시스템이 어떻게 서로의 존재를 인지하고, 어떤 규칙으로 소통할지 명시적으로 설계하는 ‘아키텍처 도구’였다. React 개발 서버와 Spring Boot 서버가 서로 다른 origin으로 실행된다는 사실은 문제의 시작이 아니라, 이러한 아키텍처 설계를 필요하게 만드는 당연한 전제 조건이었다.
“아…”
솔라는 낮은 탄성을 내뱉었다.
“나는 지금까지 계속 문제 해결 팁을 모으고 있다고 생각했는데… 사실은 지도를 그리고 있었던 거구나. 프론트엔드라는 세상과 백엔드라는 세상, 그리고 이 둘을 둘러싼 배포 환경이라는 더 큰 세상 사이의 경계를 정의하는 방법.”
CORS 에러는 더 이상 브라우저의 변덕이 아니었다. 지도에 없는 길로 가려고 할 때 울리는 내비게이션의 경고음이었다. ‘경로가 명확하지 않습니다. 출발지와 목적지 사이의 연결 규칙을 설정해 주세요.’
생각의 전환이 일어나자, 솔라는 새로운 프로젝트를 시작할 때 자신이 무엇을 해야 할지 분명해졌다. 지금까지는 일단 코딩부터 시작하고, CORS 에러가 나면 그때 가서 구글링을 했을 것이다. 하지만 이제는 아니었다.
솔라는 방금 전까지 보던 노트를 덮고, 새로운 페이지를 펼쳤다. 그리고 맨 위에 이렇게 제목을 적었다.
[ 신규 프로젝트 아키텍처 설계 v1 ]
그녀는 더 이상 루나에게 묻지 않고, 스스로 목록을 작성하기 시작했다.
-
런타임 경계 정의
- 프론트엔드 실행 환경 (예:
localhost:5173, 배포 시our-service.com) - 백엔드 실행 환경 (예:
localhost:8080, 배포 시api.our-service.com)
- 프론트엔드 실행 환경 (예:
-
경계 연결 전략
- 프론트엔드 → 백엔드 (주소록):
VITE_API_BASE_URL같은 환경 변수를 사용하여 API 기본 주소를 코드와 분리한다..env파일에 개발용 주소를 명시. - 백엔드 → 프론트엔드 (초대장):
WebConfig를 통해 CORS 정책을 명시적으로 설계한다.- 허용 출처 (allowed-origins): 개발 환경(
http://localhost:5173)을application.properties에 기본값으로 설정하고,APP_CORS_ALLOWED_ORIGINS환경 변수를 통해 배포 환경에서 동적으로 주입할 수 있도록 한다. - 허용 메서드 (allowed-methods):
GET,POST,PATCH,DELETE등 필요한 메서드와 함께, 브라우저의 예비 요청(preflight)을 처리하기 위한OPTIONS메서드를 반드시 허용 목록에 포함한다.
- 허용 출처 (allowed-origins): 개발 환경(
- 프론트엔드 → 백엔드 (주소록):
노트를 다 채운 솔라는 만족스러운 표정으로 펜을 내려놓았다. 이것은 더 이상 임시방편적인 트러블슈팅 가이드가 아니었다. 분리된 두 시스템이 성공적으로 협력하기 위한, 명시적이고 안정적인 통신 설계도였다. 오류를 만난 지점에서 시작했지만, 이제 솔라의 손에는 오류를 예방하고 시스템의 구조를 단단하게 만드는 도구가 들려 있었다.