배경
캐시워크 한국 서비스 내 숏폼이라는 서비스의 어드민 페이지를 새롭게 구축하고 있었다. 로그인 페이지에서 구글 로그인으로 인증을 하고, 홈으로 이동하는 흐름이다.
어드민은 프론트엔드와 백엔드 도메인이 따로 있고, 둘 다 cashwalk.com 아래 서브 도메인이다.
문제
구글 로그인을 했을 때, 간헐적으로 홈으로 이동하지 않고 다시 로그인 페이지로 돌아오는 문제가 발생했다.
상황 파악
문제가 발생하는 ‘간헐적으로’의 조건을 파악하고자 했다.
- 기존 크롬 브라우저 : 항상 발생
- 회사 이메일 크롬 프로필의 크롬 브라우저 : 발생하지 않음
- 시크릿 모드 : 항상 발생
여기서 원인이 어느 정도 추려졌다. 리다이렉트가 원인의 한 축에 속한다고 판단했다. 기존 크롬 브라우저는 구글 로그인을 할 때 여러 구글 계정 중 하나를 선택한다. 시크릿 모드는 구글 계정의 이메일과 비밀번호를 입력한 후 로그인을 한다. 하지만 회사 이메일 크롬 프로필의 크롬 브라우저는 계정 선택 화면 없이 바로 로그인이 된다. 회사 이메일로만 구글 로그인할 수 있는 단일 계정이기에 계정을 따로 선택할 필요가 없기 때문이다. 계정 선택 화면이 끼면 구글 쪽 리다이렉트 홉이 늘어난다.
그 다음으로는 이 리다이렉트를 수행하는 주체를 찾고자 했다. 개발자 도구 > 네트워크에서 Preserve Log를 켜고 Doc만 필터링하면 로그인 과정에서 리다이렉트되는 과정을 살펴볼 수 있다.
- 리다이렉트 응답을 내려주는 마지막 요청은
https://local.cashwalk.com/clip/list이다.- 이 때 상태 코드가 307 임시 리다이렉트이고, Location(리다이렉트할 경로)은
/login페이지인 것을 확인할 수 있다.
- 이 때 상태 코드가 307 임시 리다이렉트이고, Location(리다이렉트할 경로)은
- 이 리다이렉트의 주체는 백엔드 서버가 아닌 Next.js 서버의 middleware이다.
// src/middleware.ts
export const middleware = (request: NextRequest) => {
const cmsSessionCookie = request.cookies.get(getCmsSessionCookieName())?.value
if (cmsSessionCookie) {
return NextResponse.next()
}
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('return_to', `${request.nextUrl.pathname}${request.nextUrl.search}`)
return NextResponse.redirect(loginUrl)
}
위 로직을 살펴보면, 인증 쿠키가 존재하는지 확인하고 만약에 없다면 로그인 페이지로 리다이렉트시켜주는 것을 확인할 수 있다. 즉, 로그인 페이지 외에는 로그인 이후 이용하는 리다이렉트 로직이다.
인증 쿠키의 만료 기간이 존재하기 때문에 쿠키가 존재한다고 해서 무조건 인증이 된다고는 할 수 없지만, 인증을 하지 않은 사용자가 어드민 페이지에 접근하는 것을 1차적으로 방지하는 로직이다.
이 로직만 보기에는 크게 문제가 없는 로직이다. 그렇다면 어디서 문제가 발생한 것일까?
네트워크 창에서 쿠키의 상황을 살펴보았다.
/clip/list페이지로 오기 전/callback경로에는 쿠키가 담겨 서버로부터 전달이 된다.- 이
/callback은 백엔드 도메인의 경로이며,/clip/list로 리다이렉트하는 응답을 반환한다.
- 이
- 이 쿠키는
/callback경로에서 설정(Set-Cookie)이 된다. - 그런데 다음 경로인
/clip/list에서는 쿠키가 담기지 않은 채 서버에 전달이 된 것이다.
즉, 정리하면 /callback에서 쿠키가 심겼지만 /clip/list 페이지로 이동하며 쿠키가 소실이 된 것이다. /callback은 백엔드 도메인이고, /clip/list는 프론트엔드 도메인이다. 이 둘이 쿠키를 어떻게 공유하게 되는 걸까?
쿠키가 어디로 전송되느냐(스코프)를 결정하는 건 Domain이다. Domain을 명시하지 않으면 host-only 쿠키가 되며, 설정한 호스트(e.g. app.cashwalk.com)에만 전송이 되며 서브 도메인이 다른 도메인에는 전송되지 않는다. 반대로 Domain을 명시하면 그 도메인과 하위 서브 도메인 전체에 전송된다. 대신 아무 도메인이나 쓸 수는 없고, 현재 호스트의 상위 도메인만 지정할 수 있다. 즉, Set-Cookie: token=abc; Domain=cashwalk.com과 같이 쿠키를 설정했다면 app.cashwalk.com, cms.cashwalk.com 등에도 해당 쿠키가 전송이 된다.
즉, 프론트와 서버 도메인이 다른 상황에서 쿠키를 공유하기 위해서는 둘의 eTLD+1(등록 가능 도메인)이 동일해야 하며 Domain 설정이 필요한 것이다. 확인 결과, Domain=cashwalk.com으로 도메인 설정이 잘 되어 있어 문제되지 않는다고 판단했다. (.cashwalk.com처럼 앞에 점을 붙이는 표기도 보이는데, 지금 표준에서는 앞의 점을 무시하기 때문에 동작은 같다.)
💡 eTLD+1(registrable domain, 등록 가능 도메인)
: eTLD(effective TLD, 실질 최상위 도메인)에 한 레이블을 더한 것
- TLD(Top-Level Domain) : DNS 이름의 가장 오른쪽 레이블
- e.g.
.com,.kr,.io- eTLD(effective TLD) : 실질적으로 TLD 역할을 하는 접미사
- e.g.
co.kr,github.io처럼 독립 주체들이 등록하는 접미사 전부- eTLD+1 : 호스트명에서 PSL과 매칭되는 가장 긴 접미사를 찾고(eTLD), 거기에 왼쪽 레이블 하나를 더한 것
- e.g.
cashwalk.com
다음으로 체크해볼 것은 쿠키의 SameSite 옵션이다. SameSite는 스코프가 아니라, 요청을 시작한 사이트와 요청 대상 사이트가 다를 때 쿠키를 실어 보낼지를 결정한다.
- Strict : 다른 사이트에서 시작된 요청엔 절대 안 실림
- Lax : 최상위 네비게이션 GET에만 실림
- None: 크로스 사이트에도 실림(단, Secure 필수)
확인 결과, SameSite가 Strict로 설정되어 있었다.
프론트와 서버는 둘 다 cashwalk.com 아래라 서로 같은 사이트다. 그런데도 쿠키가 실리지 않은 이유는, 이 이동이 accounts.google.com에서 시작됐기 때문이다. 브라우저는 리다이렉트 체인 전체를 보고 같은 사이트인지 판단하는데, 중간에 다른 사이트가 하나라도 끼면 그 요청은 크로스 사이트로 취급된다. Strict는 여기에 쿠키를 붙이지 않는다. 쿠키가 저장은 됐는데 다음 요청에만 안 실린 이유가 이것이다.
반대로 Lax는 최상위 네비게이션 GET이라면 쿠키를 실어준다. 그래서 SameSite를 Lax로 바꿔달라고 서버 측에 요청했다.
그렇게, 해결이 되었다. 👍🏻
결론
이론으로만 알고 있던 쿠키의 Domain, SameSite 옵션, 그리고 Set-Cookie를 통해서 서버에서 쿠키를 설정해 내려주는 것을 확인할 수 있던 유의미한 디버깅 경험이었다. 이 개념들은 앞으로 잊지 않을 것 같다..ㅎ
실제로 위와 같이 순탄하게 하나씩 디버깅하지는 못했다. 처음에는 많이 헤매기도 했다. ‘프론트에서 라우팅 방식이 잘못된 게 아닐까?’ 등의 다른 가설들을 세우고는 했었다. 하지만 위와 같은 프로세스로 차근차근 현재 상황을 살피고, 원인을 추리고, 해결 방법을 모색해냈으면 더 빠르고 명쾌하게 문제를 해결할 수 있었을 것이다. 다음번에 유사한 문제가 생기면 더 빠르게 해결할 수 있겠지.
혹시나 쿠키가 제대로 심기지 않거나, 로그인 과정에서 리다이렉트에 문제가 생긴 사람들과 미래의 나를 위해 글을 바친다.