한 번에 동작하지 않는 호출
한 서비스를 프론트엔드와 백엔드로 나눠서 배포한다고 해보자. 프론트는 client.example.co.kr, 백엔드는 api.example.co.kr. 같은 회사 도메인 아래 호스트만 다르다.
클라이언트에서 백엔드 API 를 부른다.
이 호출은 의외로 한 번에 동작하지 않는다. 쿠키가 함께 전송되지 않고, CORS 에러가 발생하고, 로컬 dev 환경에서는 또 다른 문제가 따라 붙는다.
이전에 인증, 고를 게 많다 에서 인증의 네 가지 축을 정리해둔 적이 있다. 그때는 "웹은 HttpOnly 쿠키, 모바일은 Secure Store" 정도의 결론에서 멈췄다. 이번 글은 쿠키를 골랐다는 가정에서 출발해, 같은 회사 도메인이 왜 따로 노는지를 단위 단위로 들여다본 기록이다.
같은 회사 도메인이라도 출처는 다르다
client.example.co.kr 와 api.example.co.kr 는 같은 회사가 운영하는 같은 사이트로 보이지만, 브라우저는 둘을 다른 출처로 인식한다.
Origin 과 Site 는 다른 단위다
| 단위 | 정의 | 예시 |
|---|---|---|
| Origin | 프로토콜 + 호스트 + 포트 | https://client.example.co.kr |
| Site | 프로토콜 + eTLD+1 | example.co.kr |
eTLD+1 은 사람이 직접 등록할 수 있는 도메인 단위다. .com, .co.kr 같은 공개 접미사(eTLD) 바로 아래 한 단계를 의미한다. naver.com, example.co.kr 같은 것이 eTLD+1 에 해당한다.
이 기준을 두 도메인에 적용해보면 Origin 은 다르지만(호스트가 다르므로) Site 는 같다. 같은 사이트지만 다른 출처인 상태가 된다.
단위가 두 개인 이유
CORS 는 Origin 단위로, SameSite 쿠키는 Site 단위로 동작한다. 같은 회사 도메인끼리도 어떤 정책은 막히고 어떤 정책은 통과하는 이유가 여기에 있다.
직접 호출하면 어떤 일이 일어나는가
client.example.co.kr 페이지에서 api.example.co.kr 로 fetch 를 보낸다고 해보자. 브라우저는 세 가지를 검증한다.
① CORS 검증 (Origin 기준)
client.example.co.kr 과 api.example.co.kr 는 다른 출처이므로 CORS 정책이 적용된다.
POST, PUT, DELETE 같은 mutation 요청 앞에는 OPTIONS Preflight 요청이 한 번 더 붙는다. "이런 요청 보내도 되나요?" 를 먼저 물어보고, 백엔드가 허용하면 그제서야 본 요청이 나간다.
쿠키를 함께 전송하려면 백엔드가 응답 헤더에 Access-Control-Allow-Origin: https://client.example.co.kr 과 Access-Control-Allow-Credentials: true 를 포함해야 한다.
여기서 한 가지 주의할 점은 Access-Control-Allow-Origin 에 와일드카드(*)를 사용할 수 없다는 것이다. 자격증명을 함께 보내는 요청에서 모든 출처를 허용해버리면, 어떤 사이트에서든 사용자의 쿠키로 API 를 호출할 수 있게 되기 때문이다. 그래서 출처를 명시적으로 적도록 강제되어 있다.
② SameSite 쿠키 정책 (Site 기준)
쿠키의 SameSite 속성은 다른 사이트로 가는 요청에 쿠키를 함께 보낼지를 결정한다. 값은 세 가지다.
| 값 | 동작 |
|---|---|
Strict | 다른 사이트로 가는 요청에는 쿠키를 보내지 않음 |
Lax | 기본값. 일반 링크 이동에서는 보내지만, cross-site 백그라운드 요청(fetch, iframe 등)에서는 차단 |
None | 다른 사이트로 가는 요청에도 쿠키를 보냄. Secure 속성이 함께 필요 |
지금 상황에서 client.example.co.kr 과 api.example.co.kr 는 같은 사이트(example.co.kr)에 속한다. 그래서 어떤 SameSite 값이든 same-site 요청에는 쿠키가 정상적으로 전송된다.
만약 백엔드가 다른 회사 도메인이었다면 쿠키를 SameSite=None; Secure 로 설정해야만 cross-site 요청에 쿠키가 전송된다. 같은 사이트로 묶여 있어 이 부담이 줄어든다.
③ 쿠키 Domain 매칭
세 번째로 확인할 부분은 쿠키의 Domain 속성이다.
서버가 응답으로 Set-Cookie 를 보낼 때 Domain 속성을 어떻게 지정하느냐에 따라 쿠키가 적용되는 범위가 달라진다.
| Domain 값 | 효과 |
|---|---|
| 지정하지 않음 (host-only) | 발급한 호스트에만 묶임 |
Domain=api.example.co.kr | 위와 거의 동일 |
Domain=.example.co.kr | *.example.co.kr 모든 서브도메인에 적용 |
이 값에 따라 다음 요청에 쿠키가 자동으로 첨부될지가 결정된다.
쿠키 Domain 을 어떻게 지정할 것인가
쿠키 Domain 설정은 크게 두 가지 방향이 있다. 하나는 발급한 호스트에만 쿠키를 묶어두는 host-only 방식이고, 다른 하나는 부모 도메인을 지정해서 하위 서브도메인 전체에 공유하는 방식이다.
host-only 쿠키는 격리성이 높다. 쿠키가 발급된 호스트 밖으로 나가지 않으니, 다른 서브도메인에 보안 문제가 생겨도 직접 영향을 받지 않는다. 다만 여러 서브도메인이 같은 인증을 공유해야 한다면 별도의 처리가 필요해진다. 각자 따로 로그인을 시키거나, 토큰을 전달하는 메커니즘을 따로 만들어야 한다.
부모 도메인 쿠키는 반대다. Domain=.example.co.kr 로 지정하면 *.example.co.kr 하위 서브도메인 전체에 자동으로 공유된다.
이렇게 하면 다음과 같은 상황에서 편하다.
- 여러 서브도메인이 한 번의 로그인으로 인증을 공유 (SSO 비슷한 효과)
- SSR 서버와 API 서버가 다른 서브도메인이어도 쿠키가 자연스럽게 전달됨
- OAuth 콜백 redirect 가 여러 서브도메인을 오갈 때도 안전
대신 격리성이 약해진다. 예를 들어 blog.example.co.kr 에 XSS 취약점이 생기면, 공격자가 그 서브도메인에서 api.example.co.kr 로 인증된 요청을 보낼 수 있다. HttpOnly 옵션으로 토큰을 직접 탈취하지는 못하더라도, 사용자의 권한을 도용할 수 있다는 뜻이다.
어느 쪽이 정답이라고 단정하기는 어렵다. 한 회사가 모든 서브도메인을 통제하고 보안 정책을 엄격히 관리한다면 부모 도메인 쿠키의 편의성이 크다. 반대로 화이트 라벨링처럼 외부 파트너에게 서브도메인을 내주는 구조라면, 통제 범위 밖에서 인증이 새어나갈 수 있어 host-only 쪽이 안전한 것 같다.
단, 이 글에서는 부모 도메인 쿠키를 사용하는 상황을 가정해 다른 부분을 더 살펴보았다.
dev 환경에서 또 다른 문제가 생긴다
prod 환경에서는 위 설정으로 잘 동작한다. 그런데 로컬 개발 환경에서는 또 다른 문제가 나타난다.
시나리오
- 프론트:
localhost:5173(로컬 dev 서버) - 백엔드:
https://dev-api.example.co.kr(원격 dev 백엔드)
localhost 에서 dev-api.example.co.kr 로 직접 호출한다고 가정해보자.
백엔드는 요청의 Origin 헤더를 보고 쿠키 Domain 을 다르게 발급한다. 대략 이런 식의 분기가 들어간다.
클라이언트 Origin 에 localhost 가 포함되면 백엔드가 Domain=.localhost 쿠키를 발급한다.
그런데 그 쿠키가 거부된다
브라우저는 Set-Cookie 를 받을 때 RFC 6265 에 따라 검증한다.
응답을 보낸 서버가 쿠키 Domain 과 domain-match 가 되지 않으면 쿠키를 무시한다.
직접 호출한 경우를 따져보면, 응답을 보낸 서버는 dev-api.example.co.kr 인데 쿠키 Domain 은 .localhost 다. dev-api.example.co.kr 이 .localhost 로 끝나지 않으니 매칭이 성립하지 않는다.
그래서 브라우저는 이 쿠키를 거부한다. 쿠키가 저장되지 않으니 다음 요청에 첨부할 것도 없고, 결과적으로 로그인이 동작하지 않는다.
문제를 푸는 몇 가지 방법
이 상황을 해결하는 방법은 여러 가지가 있다.
- 백엔드 쿠키 발급 로직 수정
localhost 분기에서 Domain 속성을 빼고 host-only 쿠키로 발급하는 방법. 도메인 매칭 문제는 사라지지만, 백엔드 코드를 건드려야 하고 prod/dev 분기를 관리해야 한다.
- CORS 와 쿠키 정책 완화
백엔드가 localhost:5173 을 허용하고 쿠키를 SameSite=None; Secure 로 발급하는 방법. dev 설정을 prod 와 분리해야 하고, "dev 에서는 되는데 prod 에서 안 되는" 차이가 생길 여지가 있다.
- hosts 파일 조작
/etc/hosts 에 127.0.0.1 dev.example.co.kr 을 추가해 로컬을 회사 도메인처럼 위장하는 방법. same-origin 이 되어 모든 문제가 풀리지만, 팀원 모두가 동일하게 설정해야 하고 HTTPS 는 로컬 인증서를 따로 발급해야 한다.
- dev 프록시
프론트 dev 서버가 백엔드로 가는 요청을 대신 전달하는 방법. Vite, Webpack 등 빌드 도구에 기본 기능으로 들어 있어 설정이 간단하고, 백엔드나 hosts 파일을 건드리지 않아도 된다.
이 글에서는 마지막 방법인 dev 프록시를 살펴보려고 한다.
프록시는 어떻게 동작하는가
브라우저 시점에서 보면 요청을 보낸 곳도, 응답을 받은 곳도 localhost:5173 이다. dev-api.example.co.kr 은 한 번도 본 적이 없다.
응답이 localhost 에서 온 것처럼 보이기 때문에, .localhost 쿠키도 매칭이 성립해서 정상적으로 저장된다.
프록시는 누구를 가리는가
처음에는 프록시의 방향이 헷갈렸다. localhost 를 백엔드처럼 위장하는 것인가 싶었는데, 실제로는 그 반대였다.
프록시는 백엔드를 localhost 인 것처럼 보이게 만든다. 브라우저는 끝까지 localhost 와만 통신한다고 인식한다.
프록시의 본질은 양쪽이 서로를 직접 보지 못하게 만드는 중간 다리에 있는 것 같다.
정리하면
같은 회사 도메인을 두 개로 나눠 운영할 때, 인증이 동작하려면 다음 세 가지가 동시에 맞아야 한다.
| 메커니즘 | 정책 | 어떻게 풀리나 |
|---|---|---|
| Origin 기준 | CORS | 백엔드가 출처를 명시 허용 |
| Site 기준 | SameSite 쿠키 | 같은 사이트라 SameSite 정책에 막히지 않음 |
| Domain 매칭 | 쿠키 저장 | 부모 도메인 또는 host-only 중 선택 |
그리고 dev 환경에서는 localhost 와 원격 백엔드 사이의 도메인이 어긋나는 문제가 추가로 생긴다. 백엔드 수정, 설정 완화, hosts 조작, dev 프록시 등 여러 방법이 있고 그중 하나를 골라야 한다.
같은 자리에 또 마주칠 단위들
이미 인증이 구현되어 있어서 이 자리를 깊이 들여다본 적이 없었다. 이번에 서버 스터디를 하면서 원래 구현되어 있던 부분을 다시 열어보고 나서야, 동작시키기 위해 어디까지 풀어두어야 했는지를 천천히 살펴볼 수 있었다.
Origin, Site, Domain. 세 단위는 같은 도메인을 두고 서로 다른 기준으로 본다. 같은 회사 안의 두 서브도메인을 다루는 자리뿐 아니라, OAuth 콜백을 받을 때도, iframe 안에서 외부 서비스를 띄울 때도, 같은 세 단위가 어디선가 결정을 만들고 있다.