MERN 조합으로 진행한 개인프로젝트의 기능 개발을 완료한 후 프론트엔드는 vercel에, 백엔드는 GCP(Google Cloud Platform)에 배포하여 서로 다른 프론트엔드와 백엔드의 도메인을 얻게 되었다. 두 도메인 모두 프로토콜은 https.
이렇게 배포 후 배포한 환경에서 테스트를 해보는데 로그인 기능에서 뭔가 안되는 부분이 있었다. 유저가 로그인 정보를 입력하면 서버에서 로그인 처리 후 jwt 토큰 쿠키를 생성하는 것 까지는 잘 됐지만, 그 토큰값(쿠키값)을 프론트에서 읽어야 하는데 그게 안되던 것이었다. 로컬에서는 잘 되던 기능인데 배포한 실제 환경에서 해보니 갑자기 쿠키를 못읽어서 사실상 로그인 기능이 무의미해진것.
해결하느라 정말 많이 찾아보고 정말 많이 시도해보고 정말 많이 짜증났던 프로젝트의 마지막 이슈.. 알고보니 간단한 원인이었고 해결한 방법도 고통받았던 것 치고는 굉장히 허무했지만, 많이 배운 것 같다. 뉴비가 뭐 이렇게 삽질하면서 익혀야지 어쩌겠는가 🫠
형식이 아예 다른 두 도메인간의 https 통신인 경우의 쿠키 사용에 대해 알게된 점들과 프론트에서 쿠키 값을 읽지를 못했던 이유. 틀린 정보가 있을 수 있지만 여러 자료를 찾아보며 알게된 점들을 바탕으로 이 트러블슈팅을 기록해두고자 한다.
사실 처음엔 사이트에서 로그인 버튼을 누르면 jwt 토큰 쿠키도 안생겼었다(물론 로컬에서는 잘 됨). 로그인 버튼을 클릭한 후의 응답을 네트워크 탭에서 찾아보니, 응답 헤더의 내용중 Set-Cookie에 백엔드에서 쿠키의 값으로 지정한 jwt 토큰의 값이 있는데 노란색 경고 마크가 떠있었다. 마우스를 올려보니 다음과 같은 문구가 나옴
This Set-Cookie header didn't specify a "SameSite" attribute and was defaulted to "SameSite=Lax", and was blocked bacause it came from a cross-site response which was not the response to a top-level navigation. The Set-Cookie had to have been set with "SameSite=None" to enable cross-site usage.
요약하자면 "SameSite=None"으로 설정해야 cross-site 사용이 가능하다는 것이다. SameSite 쿠키 속성이 설정되지 않거나 "SameSite=Lax"로 설정된 경우, cross-site 요청에서 쿠키가 차단될 수 있음을 알려주는 경고 문구다.
여기서 cross-site란 서로 다른 사이트(도메인) 간의 요청을 의미한다. 찾아보니 도메인이 서로 다른 서버 사이에 쿠키를 담아보내려면 설정해야 하는 cookie의 옵션이 있었다.
express.js를 사용하는 경우, 응답으로 쿠키를 생성해준다고 하면 다음과 같이 코드를 작성한다.
res.cookie('쿠키명', '쿠키값', '쿠키 옵션 객체');
여기서 쿠키 옵션으로는 httpOnly, sameSite, secure, domain, maxAge 등이 있고 아래 예시와 같이 세번째 인자에 옵션을 객체로 지정하여 쿠키를 생성한다.
const options = {
httpOnly: true,
sameSite: 'none',
secure: true,
domain: 'localhost',
maxAge: config.jwt.expiresInSec * 1000,
};
res.cookie('token', token, options);
이 상황에서 중요한건 위 경고에서 언급된 sameSite 와 그를 위한 secure 옵션이다.
sameSite 옵션은 서로 다른 사이트(도메인)간의 쿠키 전달에 대한 보안을 설정하는 옵션으로, 간단히 말하자면 다른 사이트에 쿠키 전달을 허용하느냐 아니냐를 지정하는 옵션이다. 값으로는 Strict, Lax, None 이 있고 기본값은 Lax 이다.
- Strict와 Lax는 다른 사이트에 쿠키 전달 허용 X
- None은 다른 사이트에 쿠키 전달 허용 O (단, 반드시 secure 옵션을 true로 할것!)
자세한 내용 참고 : https://velog.io/@kaitlin_k/cookie-%EC%98%B5%EC%85%98
sameSite를 따로 지정하지 않았기 때문에 기본값인 Lax 였고 그래서 저 경고 문구가 뜨고 백엔드에서 생성된 쿠키가 전달되지 않았던 것이다. 서로 다른 도메인간에 쿠키를 전달하려면 sameSite: 'None' 으로 쿠키 옵션을 지정해준다.
그리고 sameSite 옵션이 None 이면 secure 옵션을 반드시 true 로 지정해야 한다. secure 를 true 로 지정하면 https 에서만 쿠키를 사용할 수 있도록 설정하는 것인데 sameSite 를 None 으로 하여 cross-site 전달을 하는건 https 에서만 가능하다고 한다. 때문에 secure 를 true 로 해줄것.
따라서 나는 프론트와 백의 도메인이 다르고 둘 다 https 인 상황이기 때문에
res.cookie('token', result, {
sameSite: 'None',
secure: true
});
이렇게 옵션을 설정하여 쿠키를 생성해야 했던 것이다. 당연히 프론트에서 쿠키를 조회해야하니 httpOnly 옵션은 지정하지 않음
이렇게 하니 실제로 로그인 후 쿠키 생성은 성공!
하지만 진짜 문제는 따로 있었다. 이렇게 쿠키 생성에 성공했지만 리액트에서 쿠키를 읽을 수가 없었다. 분명히 브라우저에 쿠키가 존재하는데 그 쿠키를 조회하면 아무리 해도 값이 undefined 가 나온다...
일단 내가 짠 로직은 클라이언트에서 로그인 버튼을 누르면 서버의 /login 경로로 로그인 요청이 가고 로그인 처리를 한 후, 성공하면 응답으로 성공 메세지를 보내고 jwt 토큰 쿠키를 생성해준다. 그 후에 다음의 리액트 코드가 실행된다.
useEffect(() => {
if (login) {
const getData = async () => {
const response = await getUserInfo();
if (!response) return;
const { _id, nickname, email, userImage, likey } = response;
dispatch(setUser({ _id, nickname, email, userImage, likey }));
};
getData();
}
}, [login, reviewInfo.likey]);
백에서 로그인 성공 응답을 받으면 login state가 true로 바뀌고 위의 useEffect가 실행된다. 여기서 사용된 getUserInfo 함수는 다음과 같다.
import axios from 'axios';
import { cookies, origin_URL } from '../App';
export default async function getUserInfo() {
const jwtToken = cookies.get('token');
if (!jwtToken) return;
const response = await axios.get(`${origin_URL}/user/my`, {
headers: {
Authorization: `Bearer ${jwtToken}`,
'Content-Type': 'application/json'
},
withCredentials: true
});
return response.data;
}
여기서 token 이라는 이름의 쿠키를 조회해 그 쿠키의 값으로 현재 로그인중인 사용자의 유저 정보를 조회하는 /user/my 경로로의 get 요청을 보내서 유저 정보를 받고 그것을 user 라는 전역 state에 저장하는 방식이다. 여기서 사용한 cookies는 다른 파일에서 리액트의 universal-cookie 라이브러리를 import 한 것을 가져온 것이다.
import Cookies from 'universal-cookie';
export const cookies = new Cookies();
이런 상황에서, 클라이언트에서 로그인을 하면 로그인 자체는 잘 되고 토큰 쿠키까지도 분명 생성이 되는것을 확인했는데 그에 따른 유저 정보 컴포넌트는 화면에 렌더링되지 않았다. 그리고 새로고침을 하면 로그인조차 풀려버렸다.
앞서 서버로부터 로그인 성공 응답을 받으면 login state 가 true 로 바뀐다고 했는데, 이 login 을 true 로 계속 유지시키는건 토큰 쿠키의 유무를 확인하여 결정된다. login 을 token 이라는 이름의 쿠키가 존재하면 true 로, 그렇지 않으면 false 로 바꾸도록 하는 로직을 App.tsx 에 작성해두었기 때문에 새로고침 시 쿠키가 없는 상태라면 login 이 false 로 바뀌고 로그인 하지 않은 상태의 헤더의 모습이 된다.
그런데 지금 로그인 후 토큰 쿠키가 분명히 존재하고 이름도 token 이 맞는데 token 쿠키가 없다고 판단하는건지, 새로고침하면 login 이 false 가 되어 로그인도 풀리는 상황이었다... 근데 쿠키는 여전히 있음.
login 은 redux-persist 를 적용한 전역 state 이기 때문에 쿠키가 있는 이상 아무리 새로고침을 해도 false 로 초기화되지 않는다. 따라서 쿠키가 있다는걸 인식하지 못하는게 분명하다.
또 네트워크 탭을 보니 /login 요청은 잘 갔고 응답도 정상적으로 왔지만 /my 요청이 없었다.
/login 요청으로 로그인 처리에 성공한 후, 위 코드에서의 useEffect 에서 호출한 getUserInfo 함수에 의해 갔어야 할 /my 로의 요청이 아예 가지도 않은 것이었다. 이러니 유저 정보를 얻어오지 못해 user state 에 값이 저장되지 않고 유저 정보에 따른 컴포넌트도 렌더링되지 않은 것이었다.
그리고 왜 /my 요청이 실행조차 되지 않았나 찾아보니 /my 로의 axios 요청문 위의 if 문에 걸려서 튕겨가지고 axios 요청문은 실행조차 되지 않아서 그런거였다. 그리고 if 문에 걸린 이유가 바로 리액트에서 'token' 이라는 이름의 쿠키를 읽지 못해서 cookies.get('token') 의 값을 담은 jwtToken 변수가 undefined 가 되었기 때문. 역시 로그인해서 생긴 토큰 쿠키를 못읽어서 이사달이 난게 맞았다.
도대체.. 도대체 왜 분명히 존재하는 'token' 쿠키를 못읽냐! 로컬에서는 잘 읽더니.. 정말 많이 구글링을 하고 이런저런 삽질을 해봤다.
일단 쿠키에 도메인이 있다는걸 이번에 처음 알았다...
ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
정말 무지하군
쿠키라는건 당연히 알고 있었지만 쿠키에도 지정된 도메인이 있다는 것을 이 이슈를 해결하며 처음 알게 되었다. 위에서 잠깐 이름을 언급한 express 에서의 쿠키 옵션 중 domain 이라는 옵션이 있다. 백엔드 따로 프론트엔드 따로 개발할때 보통 cors 설정을 해주듯이 쿠키도 이 쿠키가 어느 도메인에서 쓰이게 할지를 지정해야했고 그를 위한 옵션이 domain 옵션이다.
이거다. 쿠키를 생성한 백엔드와 그를 읽으려는 프론트엔드의 도메인이 서로 다른데 domain 옵션을 내가 지정을 안해줬으니, 이래서 프론트에서 쿠키를 못읽었던거군! 바로 프론트의 도메인을 지정해버렸다.
근데 안됨
분명히 프론트의 도메인을 지정해주었는데.. 여전히 못읽는다. https:// 까지 풀 url로도 지정해보고 도메인 부분만 지정해보고 다양하게 해봤는데 다 안됨
그래서 또 이래저래 찾아보다 리액트에서 사용한 universal-cookie 라이브러리의 문서를 보았는데 여기도 domain 이라는 옵션이 있었다.
이건가..?!
위에서 언급한 universal-cookie 라이브러리를 사용하여 만든 cookies 변수
import Cookies from 'universal-cookie';
export const cookies = new Cookies();
여기서 Cookies 라는 클래스의 생성자, 즉 Cookies()에 대한 내용이 공식문서에 나와있었고 이를 번역기 돌려봄
constructor([cookieHeader], [defaultSetOptions])
ㅡ Create a cookies context
- cookieHeader(string|object): 쿠키 헤더 또는 개체를 지정합니다.
- defaultSetOptions(객체): 쿠키 설정 시 기본 옵션을 지정합니다.
경로(문자열): 쿠키 경로, 모든 페이지에서 쿠키에 액세스할 수 있게 하려면 /를 경로로 사용하세요.
만료(날짜): 쿠키의 절대 만료 날짜
maxAge (숫자): 클라이언트가 쿠키를 수신한 시점부터 쿠키의 상대 최대 수명(초)
domain (string): 쿠키의 도메인 (sub.domain.com 또는 .allsubdomains.com)
secure (boolean): HTTPS를 통해서만 접근할 수 있나요?
httpOnly (boolean): 서버만이 쿠키에 접근할 수 있나요? 참고: 브라우저에서는 httpOnly 쿠키를 가져오거나 설정할 수 없으며 서버에서만 쿠키를 설정할 수 있습니다.
sameSite (boolean|none|lax|strict): 엄격하거나 느슨하게 적용
두번째 생성자로 지정하는 옵션들이 express에서와 같이 domain, sameSite, secure 등의 쿠키 옵션들이다.
이거다. 여기서도 도메인과 sameSite: 'None', secure: true 등을 지정해야 하는건가?! 바로 지정
근데 안됨
domain 만 지정해보기도 하고, sameSite, secure 와 같이 지정해보기도 하고 별짓을 다 했는데 모두 안됨. httpOnly가 true 여서 못읽는것도 아닌데.. 도대체 외않되
이 universal-cookie 라이브러리의 Cookies 클래스의 생성자에 지정하는 옵션들은 아직도 정확한 사용처를 잘 모르겠긴 하지만, 아무튼 결론적으론 여기서의 옵션 지정은 내 상황에서는 별 의미 없었고 문제는 express 의 쿠키 옵션의 domain 이었다.
알고보니 express의 domain 쿠키 옵션에 쿠키를 사용할 곳의 도메인을 지정하는 것은 맞는데, secure 쿠키 전달을 하려면 프론트엔드와 백엔드가 같은 도메인을 공유해야 한다고 한다.
즉 https 통신으로 쿠키를 전달하려면 프론트엔드와 백엔드가 같은 도메인이어야 한다는 것이다. 예를 들면 https://www.naver.com 와 https://vibe.naver.com 처럼 적어도 서브도메인을 제외한 도메인명은 같아야 secure 가 true 인 경우 domain 옵션으로 지정한 도메인으로 쿠키 전달이 가능하다는 얘기다.
res.cookie('token', result, {
domain: '프론트엔드 도메인',
sameSite: 'None',
secure: true
});
나는 이렇게 프론트엔드에서 쿠키를 다룰 수 있도록 domain 옵션의 값을 프론트엔드의 도메인으로 지정해주고, sameSite 와 secure 옵션을 지정하여 https 에서의 쿠키 전달을 가능하게 했다. 그런데 이렇게 생성하여 전달한 쿠키의 정보를 네트워크 탭의 응답 쿠키에서 보면 다음과 같다.
Domain 옵션으로 적혀있는 review-it-taw 어쩌구가 내 프론트엔드 도메인인데 여기에 무슨 경고 표시가 되어있다. 앞서 말했듯이 secure 가 true 인 상황인데 domain 옵션에 적은 도메인과 쿠키를 생성하는 백엔드의 도메인이 완전히 달라서 유효하지 않다는 의미의 경고 표시인듯.
실제로 브라우저에 생긴 쿠키의 상세 정보를 보면 쿠키의 도메인이 review-it-taw 어쩌구가 아닌 이상한 도메인이 적혀있다.
(저 이상한 도메인이 백엔드 도메인임)
바로 이래서였다... 프론트에서 저 쿠키를 읽을 수 없는건 쿠키의 도메인이 프론트 도메인과 달라서 그런거였고, 쿠키 생성 시 쿠키의 도메인을 프론트 도메인으로 명시적으로 지정했는데도 적용되지 않은건 쿠키의 secure 옵션이 true인 경우는 아예 다른 형식의 도메인은 domain 옵션이 적용되지 않아서 그런 것 이었다. 그래서 쿠키 도메인은 쿠키를 생성한 곳인 백엔드 도메인으로 디폴트로 적용돼서 저렇게 쿠키 도메인이 백엔드 도메인인듯
모든 문제의 원인을 안 결과, 해결할 방법이 없었다(?). 쿠키를 프론트에서 읽어야 하는데 그를 위해서는 쿠키 도메인을 프론트 도메인으로 지정해야 한다. 그런데 그러려면 secure 옵션이 true 가 아니어야 한다. 그런데 나는 프론트와 백 도메인이 서로 다르고 https 이기 때문에 sameSite 옵션이 None 이어야 하고 그를 위해 secure 옵션이 true 여야 한다.
그래서 원래대로 express 에서 쿠키를 생성하여 보내는 방식으로는 내가 아는한 답이 없다고 판단해서 그냥 백엔드의 응답으로는 성공 메세지와 생성한 jwt 토큰의 값만을 보내고, 그 응답으로 프론트에서 쿠키를 만들기로 했다...
그러고보면 프론트에서 쿠키를 직접 생성해도 되는거였는데 왜인지 이 생각을 못하고 있었다. 근데 별로 좋은 방법은 아닌 거 같은게 jwt 토큰의 값을 프론트로 보내야 하니까 뭔가 보안적으론 좀 별로인듯. 하지만 적어도 쿠키를 사용하는 경우에선 이거 말고는 도저히 방법이 떠오르지 않아서 그냥 이렇게 하기로 했다.
(프론트를 배포한 vercel 이나 백엔드를 배포한 GCP 에서 준 도메인을 바꾸지 않는 한. 아니면 쿠키말고 다른 스토리지를 사용한다거나.. 되나?)
res.status(200).json({
message: 'success',
value: result
});
백엔드에서 쿠키를 생성하지 않고 이렇게 jwt 토큰의 값을 응답 객체에 실어서 보내준다(result 가 jwt 토큰 값). 그리고 이 응답을 받아서 프론트에서
import Cookies from 'universal-cookie';
const cookies = new Cookies();
cookies.set('token', response.data.value, { path: '/' });
이런 식으로 직접 token 이라는 이름의 쿠키를 생성해주었다. 이렇게하면 쿠키를 생성한 곳이 프론트이기 때문에 쿠키의 도메인은 따로 지정하지 않아도 프론트엔드 도메인으로 지정되므로 cookies.get 으로 쿠키를 조회하는 것도 문제없이 잘 된다.
이렇게 어찌저찌 해결하여 로그인 기능도 로컬에서 개발한 결과대로 잘 실행되게 되었다. 한 짓을 많이 쳐내고 요약해서 이정도지 정말 별의별 짓거리를 다 해보았다. 한 2~3일은 이거에 매달린듯하다. 그래도 기껏 완성한 개인프로젝트인데 이거 하나때문에 마지막에 못하진 않아서 참 다행! 힘들었ㄷㅏ...
배포는 처음 해보는데 실제 배포 환경에서의 테스트를 해보니 로컬에서 개발할때와 달리 신경써야할 것이 정말 많구나를 느꼈다. 나는 자잘한 이슈 몇개 말고 애먹었던건 이 쿠키 이슈 하나이긴 했는데 이거보다 훨씬 크고 복잡한 프로젝트라면 어떤 생각지도 못한 것들이 있을지.. 생각만해도 아찔하다. 역시 실제 상황은 다르군
단순히 언어와 프레임워크를 이용한 개발 말고도 웹 전반에 걸쳐 알아야 할 것들이 얼마나 많은지, 내가 아직 얼마나 햇병아리인지 새삼 다시 느끼게된 좋은(?) 경험이었다. 오늘도 사소하지만 하나 배웠다..!
참고자료
https://jeleedev.tistory.com/174
https://velog.io/@kaitlin_k/cookie-%EC%98%B5%EC%85%98
https://www.beusable.net/blog/?p=4507
오 gcp로 배포하셨군요! samesite 이슈는 저도 투두펫 배포할 때 겪었던 이슈였는데, 그것만으로 해결이 안 되었다니 신기하네요. 구조가 달랐나 봐요. 고생 많으셨습니다!