
서로 다른 프레임워크, 한 지붕 아래 공존시키기
개발팀에서 새로운 기술 스택을 도입하거나 기존 시스템을 점진적으로 개선하려 할 때, 어김없이 등장하는 질문이 하나 있습니다. "이미 잘 돌아가고 있는 우리 서비스에 이 새로운 걸 어떻게 자연스럽게 붙일 수 있을까?" 하는 고민이죠.
예를 들어, 오랫동안 Angular로 운영해온 서비스에 최신 트렌드에 맞춰 일부 페이지나 새로운 기능은 React로 개발하고 싶다고 가정해 봅시다. 기술적으로는 가능하겠지만, 이걸 사용자들이 아무런 불편함 없이, 늘 쓰던 바로 그 도메인 안에서 이용하게 만들려면 어떻게 해야 할까요?
이런 상황에 처음 부딪히면 많은 팀이 new-feature.example.com처럼 별도의 서브도메인을 따거나, 임시로 다른 포트 번호(example.com:8080)로 애플리케이션을 띄워 내부 테스트만 진행하곤 합니다. 하지만 결국 사용자에게는 여러 주소를 기억하게 하는 대신, 하나의 일관된 웹사이트 주소를 제공하는 것이 중요합니다. 서비스의 신뢰도나 사용자 경험 측면에서 당연한 이야기죠.
그렇다면, 정말 같은 도메인 안에서 서로 다른 기술로 만들어진 여러 웹 애플리케이션을 동시에 서비스하는 것이 가능할까요?
네, 가능합니다. 그리고 생각보다 많은 곳에서 이미 그렇게 하고 있습니다.
이 글에서는 바로 이 질문에 대한 답을, 실제 운영 환경에서 마주칠 법한 문제들과 함께 처음 접하는 분들도 이해하기 쉽게 정리해 보려고 합니다.
웹은 어떻게 동작하는가?
우선 웹 브라우저가 서버와 통신하는 가장 기본적인 방식을 떠올려 봅시다. 사용자가 주소창에 URL을 입력하거나 링크를 클릭하면, 브라우저는 크게 두 가지 정보를 서버에 전달합니다.
- 도메인 이름 (Hostname): 예)
www.example.com - 경로 (Path): 예)
/,/products/awesome-item,/admin/dashboard
예를 들어, 다음과 같은 요청들이 있다고 생각해 보죠.
https://example.com/ // 홈페이지
https://example.com/react-app/ // React로 만든 새로운 기능 페이지
https://example.com/legacy/ // 기존 시스템 페이지
사용자 눈에는 모두 example.com이라는 동일한 도메인으로 보이지만, 웹 서버 입장에서는 이 요청들을 URL의 경로(path) 부분을 기준으로 명확하게 구분할 수 있습니다.
바로 이 점을 활용하면, 서로 다른 애플리케이션들을 하나의 도메인 우산 아래 논리적으로 나눠 배치할 수 있습니다. 여기서 핵심은, 현관문 역할을 하며 "이 요청은 A 방으로, 저 요청은 B 방으로 보내세요"라고 교통정리를 해주는 똑똑한 안내원이 필요하다는 것입니다.
리버스 프록시 (Reverse Proxy)
그렇다면 우리 서버는 경로에 따라 들어오는 요청들을 어떻게 각기 다른 애플리케이션으로 안내할 수 있을까요? 이때 등장하는 해결사가 바로 리버스 프록시(Reverse Proxy) 입니다.
아마 Nginx나 Apache 같은 웹 서버 소프트웨어 이름을 들어보셨을 텐데요, 이들이 리버스 프록시 역할을 훌륭하게 수행할 수 있습니다. 리버스 프록시는 클라이언트(사용자 브라우저)로부터 요청을 받아서, 그 요청을 내부 네트워크에 있는 실제 애플리케이션 서버들 중 적절한 곳으로 대신 전달해주고 응답을 받아 다시 클라이언트에게 돌려주는 역할을 합니다.
마치 회사 대표번호로 전화하면, 교환원이 담당 부서로 연결해주는 것과 비슷하다고 생각할 수 있습니다.
[사용자 브라우저] --- 요청 (예: https://example.com/react-app/profile)
↓
[Nginx (리버스 프록시)] --- "/react-app/ 경로는 B 서버로!"
↙ ↘
[기존 앱 서버 (A)] [React 앱 서버 (B)] --- Nginx가 요청을 이곳으로 전달
(예: example.com/legacy/ 담당) (예: example.com/react-app/ 담당)
예를 들어, 루트 경로(/)나 /legacy/로 시작하는 요청은 기존에 운영 중이던 애플리케이션 서버로 보내고, /react-app/으로 시작하는 요청은 새로 만든 React 애플리케이션 서버로 보내도록 Nginx에서 설정할 수 있습니다. 이렇게 하면 사용자들은 여전히 example.com이라는 하나의 주소만 사용하지만, 내부적으로는 여러 애플리케이션이 각자의 경로에서 서비스를 제공하게 됩니다.
CSR에서의 새로고침
리버스 프록시의 개념은 간단하지만, 실제 애플리케이션을 연동하다 보면 몇 가지 넘어야 할 산이 있습니다. 특히 Angular, React(CRA), Vue처럼 클라이언트 사이드 렌더링(CSR) 방식으로 동작하는 앱들은 라우팅에서 특별히 신경 써야 할 부분이 있습니다.
CSR 앱들은 처음에 서버로부터 index.html 파일 하나와 JavaScript 번들 파일들을 내려받습니다. 그 후에는 페이지 이동(라우팅)이 발생해도 서버에 새로운 HTML을 요청하는 대신, 브라우저 내에서 JavaScript가 동적으로 페이지 내용을 바꿔치기합니다. 여기서 문제는 사용자가 특정 경로(예: example.com/react-app/user/123)에서 브라우저 새로고침(F5) 버튼을 눌렀을 때 발생합니다.
새로고침 시 브라우저는 해당 URL 그대로 서버에 요청을 보냅니다. 하지만 서버에는 /react-app/user/123에 해당하는 실제 파일이나 디렉터리가 없습니다 (CSR 앱은 대부분 index.html만 있으니까요). 결국 서버는 "그런 파일 없는데?" 하며 404 Not Found 오류를 뱉어내기 쉽습니다.
이 문제를 해결하기 위해 Nginx 설정에는 거의 필수적으로 다음과 같은 try_files 지시어가 들어갑니다.
location /react-app/ {
alias /var/www/my-react-app/build/; # React 앱의 빌드 파일이 위치한 실제 경로
index index.html;
try_files $uri $uri/ /react-app/index.html; # 요청한 파일/디렉터리가 없으면 react-app의 index.html을 반환
}
이 try_files $uri $uri/ /react-app/index.html; 구문이 마법의 열쇠입니다. Nginx에게 이렇게 말하는 것과 같습니다:
- "일단 요청받은 URI (
$uri)에 해당하는 파일이 있는지 찾아봐." - "없으면, 혹시 그 이름의 디렉터리 (
$uri/)가 있고 그 안에index파일(여기서는index.html)이 있는지 찾아봐." - "그래도 없으면, 그냥
/react-app/index.html파일을 대신 내려줘."
이렇게 설정하면, 사용자가 어떤 하위 경로에서 새로고침을 하든 일단 React 앱의 index.html이 로드됩니다. 그 후에는 React Router 같은 앱 내부의 라우팅 라이브러리가 URL을 보고 알아서 올바른 화면을 그려주게 됩니다. / 경로를 서비스하는 Angular 앱이 있다면 비슷한 설정을 해당 location 블록에도 적용해야 합니다.
Nginx 설정으로 Angular와 React를 한번에
말로만 하는 것보다 실제 Nginx 설정 파일을 보면 감이 더 잘 오실 겁니다. 루트 경로(/)는 기존 Angular 앱이, /new-react/ 하위 경로는 새로운 React 앱이 처리하도록 하는 간단한 예시입니다.
server {
listen 80;
server_name example.com; # 여러분의 실제 도메인으로 변경하세요
# 기존 Angular 앱 (루트 경로 / 담당)
location / {
root /var/www/angular-app/dist; # Angular 앱 빌드 파일 경로
index index.html;
try_files $uri $uri/ /index.html; # CSR 앱의 새로고침 문제 해결
}
# 새로운 React 앱 (/new-react/ 하위 경로 담당)
location /new-react/ {
alias /var/www/react-app/build/; # React 앱 빌드 파일 경로. `root` 대신 `alias`!
index index.html;
# 중요: try_files의 fallback 경로도 앱의 base path를 포함해야 합니다.
try_files $uri $uri/ /new-react/index.html;
}
}
여기서 눈여겨볼 몇 가지 포인트가 있습니다:
rootvsalias: 루트 경로(/)를 서비스하는 Angular 앱은root지시어로 문서 루트를 지정했습니다. 하지만/new-react/처럼 특정 하위 경로를 다른 폴더에 매핑할 때는alias를 사용하는 것이 더 직관적이고 편리할 때가 많습니다.alias는location에서 지정한 경로 부분(/new-react/)을 파일 시스템 경로에서 제외하고 나머지 요청 경로를 그대로 이어 붙입니다. 반면root는 요청 경로 전체를 지정된 폴더 아래에서 찾으려 합니다. (예:/new-react/main.js요청 시,alias는/var/www/react-app/build/main.js를 찾고,root는/var/www/react-app/build/new-react/main.js를 찾으려 함)try_files의 fallback 경로: React 앱의try_files마지막 인자가/new-react/index.html로, 해당 앱의 기본 HTML 파일 경로를 정확히 가리키도록 했습니다. React 앱을 빌드할 때PUBLIC_URL(CRA의 경우)이나homepage(package.json) 값을/new-react/로 설정해서, 앱 내부에서 생성하는 정적 리소스(JS, CSS 파일 등) 경로가 올바르게 잡히도록 하는 것도 중요합니다.
이렇게 설정하면 사용자들은 example.com과 example.com/new-react/some-page를 오가며 자연스럽게 서비스를 이용하지만, 내부적으로는 Nginx가 요청을 서로 다른 애플리케이션으로 안내하고 있는 것이죠.
SSR 프레임워크 (예: Next.js)는 조금 다른 접근이 필요합니다
지금까지는 주로 CSR 앱을 다뤘습니다. 하지만 Next.js, Nuxt.js처럼 서버 사이드 렌더링(SSR)을 적극적으로 활용하거나, 자체적으로 API 서버 기능까지 내장한 프레임워크들은 Nginx와의 연동 방식이 조금 다릅니다. 이런 앱들은 단순히 정적 HTML 파일을 제공하는 게 아니라, 요청이 올 때마다 서버에서 동적으로 HTML을 만들거나 데이터를 처리해서 응답하기 때문입니다.
이런 경우에는 Nginx가 특정 경로의 파일을 직접 찾아주는 대신, 해당 애플리케이션이 내부적으로 실행 중인 서버(예: Node.js 서버)로 요청을 그대로 넘겨주는(프록시 패스, proxy_pass) 역할을 해야 합니다.
예를 들어, Next.js 애플리케이션이 내부적으로 localhost:3000에서 실행되고 있고, 이 앱을 example.com/next-app/ 경로로 서비스하고 싶다고 해봅시다. Nginx 설정은 다음과 비슷해집니다.
location /next-app/ {
# 중요: proxy_pass 뒤 주소의 마지막 슬래시(/) 유무에 따라 동작이 달라집니다.
# 여기서는 /next-app/abc 요청을 http://localhost:3000/abc 로 전달합니다.
proxy_pass http://localhost:3000/;
# WebSocket 등 실시간 통신을 위한 헤더 설정 (필요한 경우)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# 클라이언트의 실제 IP와 호스트 정보 등을 백엔드 서버로 전달
proxy_set_header Host $host; # 원래 요청의 Host 헤더
proxy_set_header X-Real-IP $remote_addr; # 실제 클라이언트 IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 프록시를 거친 IP 목록
proxy_set_header X-Forwarded-Proto $scheme; # http 또는 https
proxy_cache_bypass $http_upgrade; # 업그레이드 요청 시 캐시 우회
}
이 설정은 /next-app/으로 시작하는 모든 요청을 내부의 http://localhost:3000/으로 전달합니다. proxy_pass 지시어 뒤에 오는 주소 끝에 슬래시(/)가 있는지 없는지에 따라 요청 URI가 백엔드 서버로 전달되는 방식이 달라지므로 주의해야 합니다. (위 예시처럼 proxy_pass http://localhost:3000/;로 끝나면, /next-app/some/path 요청이 http://localhost:3000/some/path로 전달됩니다. 만약 proxy_pass http://localhost:3000;으로 슬래시 없이 끝나면, /next-app/some/path 요청이 http://localhost:3000/next-app/some/path로 전달됩니다. 상황에 맞게 선택해야 합니다.)
여기서 빼놓을 수 없는 중요한 점이 또 있습니다. Next.js 같은 프레임워크는 기본적으로 모든 페이지 라우팅과 정적 리소스(JavaScript, CSS, 이미지 파일 등) 경로를 /(루트) 기준으로 인식하도록 만들어져 있습니다. 그런데 Nginx를 통해 /next-app/이라는 특정 하위 경로로 서비스를 제공하게 되면, Next.js 앱 자체도 이 사실을 알고 있어야 합니다. 그렇지 않으면 앱이 생성하는 링크나 리소스 경로가 맞지 않아 화면이 깨지거나 페이지 이동이 안 되는 문제가 발생합니다.
이를 해결하기 위해 Next.js는 next.config.js 파일에 basePath 설정을 제공합니다.
// next.config.js
module.exports = {
basePath: '/next-app',
// 필요에 따라 정적 리소스 경로만 다르게 하고 싶다면 assetPrefix도 고려할 수 있습니다.
// assetPrefix: '/next-app',
}
이 basePath: '/next-app' 설정은 Next.js에게 "너는 이제부터 /next-app이라는 경로 아래에서 서비스될 거니까, 모든 자체 링크나 리소스 요청 경로 앞에 이걸 붙여서 만들어줘!" 라고 알려주는 것과 같습니다. 이렇게 설정해야 Next.js가 만드는 모든 경로가 Nginx의 location /next-app/ 설정과 맞아떨어져 앱이 올바르게 동작합니다.
쿠키, 인증은 어떻게 해야할까요?
서로 다른 프레임워크와 기술로 앱을 분리해 운영하더라도, 사용자에게는 여전히 하나의 통합된 서비스처럼 보여야 합니다. 사용자가 Angular 페이지에서 로그인했는데, React 페이지로 넘어가니 로그인이 풀려버린다면 황당하겠죠? 따라서 로그인 상태를 유지하는 인증 토큰이나 세션 정보를 담은 쿠키 등은 프레임워크 종류에 관계없이 일관되게 공유되어야 합니다.
이를 위해서는 쿠키를 설정할 때 몇 가지 속성을 신중하게 다뤄야 합니다.
Path속성: 여러 앱이 동일 도메인의 서로 다른 경로에서 실행되므로, 쿠키가 특정 경로에만 국한되지 않도록Path=/로 설정하는 것이 일반적입니다. 이렇게 하면example.com/angular-app에서 생성된 쿠키가example.com/react-app에서도 접근 가능해집니다.Domain속성: 이 시나리오(단일 도메인, 경로 기반 분리)에서는 보통Domain속성을 명시적으로 설정할 필요는 없습니다. 하지만 만약app.example.com과api.example.com처럼 서브도메인 간 쿠키 공유가 필요하다면,Domain=.example.com(앞에 점(.)을 붙여서 루트 도메인과 모든 서브도메인에서 공유)처럼 설정해야 합니다.SameSite속성: 브라우저의 쿠키 정책 변화로SameSite속성이 중요해졌습니다.Lax(기본값인 경우가 많음) 또는Strict로 설정하면 CSRF 공격을 방어하는 데 도움이 됩니다. 만약 외부 도메인에서 POST 요청과 함께 쿠키가 전송되어야 하는 특수한 상황이라면None으로 설정해야 하며, 이때는 반드시Secure속성도 함께 지정해야 합니다 (즉, HTTPS 환경에서만 전송).Secure속성: 쿠키가 HTTPS 연결을 통해서만 전송되도록 강제하는 옵션입니다. 프로덕션 환경에서는 반드시 사용하는 것이 좋습니다.HttpOnly속성: JavaScript를 통해 쿠키에 접근하는 것을 막아 XSS(Cross-Site Scripting) 공격의 위험을 줄여줍니다. 서버에서만 확인하면 되는 인증 토큰 같은 민감한 정보는 이 속성을 켜두는 것이 안전합니다.
만약 각 애플리케이션에서 쿠키를 생성하거나 사용하는 로직에서 이 속성들을 일관성 없게 다룬다면, 로그인 상태가 유지되지 않거나, 특정 앱에서만 데이터가 보이는 등 사용자가 예측하기 어려운 문제를 겪을 수 있습니다.
마무리
하나의 도메인 주소 아래에서 서로 다른 프레임워크로 개발된 웹 애플리케이션들을 함께 운영하는 것은, 생각보다 복잡한 마법이 아니라 잘 정의된 웹 표준과 리버스 프록시라는 강력한 도구를 활용하는 영리한 설계 전략입니다.
핵심은 간단합니다: 웹 서버(주로 Nginx)를 문지기로 두고, 들어오는 요청의 URL 경로를 기준으로 각기 다른 애플리케이션 서버로 안내하는 것입니다. 이렇게 하면 겉으로는 하나의 매끄러운 서비스처럼 보이면서도, 내부적으로는 Angular, React, Vue, Next.js, 혹은 완전히 다른 언어로 만들어진 백엔드 API까지도 각자의 역할에 맞춰 조화롭게 공존할 수 있습니다.
물론, 성공적인 통합을 위해 몇 가지 실무 포인트를 기억해두면 좋습니다.
- CSR 앱의 깊은 경로 새로고침 문제:
try_files설정을 통해 어떤 경로에서든 사용자가 새로고침해도 앱의 진입점인index.html을 잘 찾아갈 수 있도록 길을 터줘야 합니다. - SSR 또는 자체 서버 앱의 경로 설정:
proxy_pass로 요청을 정확히 전달하는 것 외에도, 앱 자체(예: Next.js의basePath)가 자신이 서비스될 하위 경로를 인지하도록 설정해야 링크나 리소스 경로가 꼬이지 않습니다. - 쿠키와 세션의 일관성:
Path=/등 쿠키 속성을 신중하게 관리하여, 여러 앱을 넘나들어도 사용자 로그인 상태나 장바구니 정보 등이 끊김 없이 유지되도록 만들어야 합니다. - 정적 리소스 경로: 각 앱이 자신의 CSS, JavaScript, 이미지 파일 등을 올바른 경로에서 불러올 수 있도록, 빌드 시 설정(예: React의
PUBLIC_URL)과 Nginx 설정을 모두 신경 써야 합니다.
실제로 많은 조직에서 이런 방식을 통해 전체 시스템을 한 번에 뒤엎는 위험 부담 없이, 점진적으로 서비스를 개선하거나 새로운 기술을 도입합니다. 예를 들어, 기존에 잘 운영되던 서비스의 특정 부분만 새로운 프레임워크로 다시 만들어 교체하거나, 마이크로서비스 아키텍처(MSA) 형태로 잘게 나뉜 여러 애플리케이션들을 사용자에게는 하나의 통일된 서비스로 제공하는 것이죠.
결국, 내부적으로 어떤 기술을 사용하든 그것이 사용자 경험을 해치거나 서비스 접근성을 복잡하게 만들어서는 안 됩니다. URL 경로 설계를 명확히 하고 웹 서버 구성을 꼼꼼하게 다듬는다면, 개발팀은 기술 선택의 유연성을 확보하면서도 사용자에게는 변함없이 편리하고 안정적인 서비스를 제공할 수 있을 겁니다.