왜 대기업은 API를 프론트 도메인 뒤에 숨길까? (CORS부터 BFF 아키텍처의 완성까지)
The Problem: CORS와 쿠키 증발, 그리고 새로운 고민들
저는 오랫동안 CORS에 대해 두 가지 큰 오해를 하고 있었습니다.
1. CORS는 서버의 보안 정책이다?
아닙니다. CORS를 검사하고 요청을 차단하는 주체는 서버가 아니라 '브라우저' 입니다. 서버는 단지 Access-Control-Allow-Origin 헤더를 내려줄 뿐, 최종적으로 응답을 버릴지 말지 결정하는 문지기는 브라우저입니다. 이 사실 하나로 "왜 서버 to 서버 통신에서는 CORS 에러가 발생하지 않는가?"에 대한 의문이 완벽히 해소되었습니다.
2. SameSite=None 설정의 딜레마와 쿠키 증발의 진실
localhost:3000(프론트)에서 api.test.com(백엔드)으로 요청을 보낼 때, 타 도메인 간의 인증을 억지로 성사시키기 위해 백엔드 쿠키에 SameSite=None; Secure 옵션을 부여하곤 합니다. 크롬(Chrome)에서는 localhost를 예외적으로 안전한 환경으로 취급하여 이 쿠키가 저장되고 동작하기도 합니다.
하지만 이것은 해결책이 아니라 '시한폭탄' 이었습니다. 첫째, 시스템을 CSRF 공격에 완전히 노출시킵니다. 둘째, Safari의 ITP(지능형 추적 방지)나 시크릿 모드 등에서는 서드파티 쿠키를 가차 없이 차단하므로, 유저 환경에 따라 쿠키가 증발하는 치명적인 버그를 낳습니다. 결국 물리적으로 분리된 도메인 간의 쿠키 통신은 구조적 한계에 부딪힐 수밖에 없었습니다.
이 문제를 해결하기 위해 BFF(Backend For Frontend)와 프록시를 도입하기로 했지만, 아키텍처를 설계하며 "모든 트래픽이 프론트 서버(BFF)를 거치면 병목이 생기지 않을까?", "Same-Site 환경이 되면 오히려 CSRF 공격에 취약해지는 것은 아닐까?" 라는 더 깊은 구조적 고민에 직면하게 되었습니다.
The Solution: 브라우저의 '룰'에 순응하는 견고한 아키텍처 설계
단순한 우회가 아닌, 브라우저 생태계에 최적화된 아키텍처와 코드로 문제들을 하나씩 돌파했습니다.
1. 프록시(Proxy)의 본질: 우회가 아니라 Same-Origin 충족
브라우저가 A.test.com/api로 요청을 보내면, 프록시가 내부적으로 B.test.com(Core API)에 요청을 대리합니다. 브라우저 입장에서는 오직 Same-Origin인 프록시하고만 통신했으므로 CORS 자체가 발생할 여지가 사라집니다.
2. Same-Site 환경의 그림자, CSRF 방어선 구축
BFF 구조로 쿠키를 원활하게 주고받게 되었지만, 이는 곧 외부 사이트에서도 쿠키가 자동으로 딸려가는 CSRF(교차 사이트 요청 위조) 공격의 타겟이 될 수 있음을 의미했습니다.
이를 방어하기 위해 세션 쿠키의 SameSite 속성을 Lax로 격상 시키고, Double Submit Cookie 패턴 을 적용해 방어 로직을 구현했습니다.
// BFF API Route: 로그인 성공 시 안전한 쿠키 세팅 예시
res.setHeader('Set-Cookie', [
// 1. HttpOnly 세션 쿠키 (SameSite=Lax로 외부 도메인 POST 차단)
serialize('accessToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
}),
// 2. CSRF 검증용 쿠키 (자바스크립트 접근 허용)
serialize('csrfToken', generateCsrf(), {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
})
]);
3. 프레임워크의 동작 원리를 활용한 Node.js 블로킹 방어
BFF(Next.js)가 시스템의 병목이 되는 것을 막기 위해 가장 경계한 것은 '대용량 JSON 파싱'이었습니다. Node.js 환경에서 백엔드의 거대한 응답을 메모리에 올려 파싱하면 이벤트 루프가 블로킹되어 전체 트래픽이 지연됩니다.
이를 방어하기 위해 직접 API 모듈을 만들지 않고, Next.js의 rewrites 기능을 적극 활용 했습니다. rewrites는 내부적으로 데이터를 메모리에 버퍼링하지 않고 스트림(Stream) 형태로 파이프라이닝 하여 클라이언트에 흘려보내기 때문에 CPU 부하를 원천 차단할 수 있었습니다.
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/:path*',
// 단순 중계 요청은 메모리 버퍼링 없이 스트림 파이프라이닝으로 처리
destination: 'http://internal-api.test.com/:path*',
},
];
},
};
4. 망 분리와 다이렉트 Fetching 전략
마지막으로 렌더링 환경에 따라 네트워크 통신 경로를 완벽히 분리했습니다.
클라이언트(브라우저)는 프록시 경로(/api/...)를 타야 하지만, Next.js의 Server Component나 SSR 단에서는 외부 인터넷 망과 프록시를 탈 필요가 없습니다. 따라서 서버 사이드에서는 내부망(Private Network)의 Core API로 다이렉트 요청을 쏘도록 유틸을 분리하여 불필요한 네트워크 홉(Hop)을 획기적으로 줄였습니다.
// 1. Client Component (브라우저): 프록시(BFF)를 경유하여 CORS 우회
export function ClientProfile() {
useEffect(() => {
fetch('/api/users/me').then(...);
}, []);
}
// 2. Server Component (SSR): 내부망을 통한 다이렉트 통신 (네트워크 홉 최소화)
export async function ServerProfile() {
// 프록시(/api)를 거치지 않고, VPC 내부 Core API로 직접 요청
const res = await fetch('http://internal-api.local/users/me');
const data = await res.json();
return <div>{data.name}</div>;
}
The Result & Retrospective
"왜 이렇게 복잡하게 구성하지?"라는 과거의 불만은, 브라우저 보안 모델과 네트워크 흐름을 깊이 이해한 순간 "이보다 완벽한 설계는 없다"는 감탄으로 바뀌었습니다.
단순히 기능을 동작하게 만드는 프론트/백엔드 개발을 넘어, 클라이언트와 서버의 네트워크 경로를 영리하게 분리하고, 내부 통신은 안전한 Private 망에 위임하며, 스트리밍 파이프라이닝으로 Node.js의 약점을 방어하는 법을 배웠습니다. 아키텍처는 유행을 따르는 것이 아니라, '보안(Security)'과 '네트워크 룰(Network Rule)'이라는 가장 견고한 기반 위에서 파생되는 필연적인 결과물 임을 뼈저리게 깨달은 값진 경험이었습니다.