지랄같은 쿠키를 정복해보자
개발 이야기이 쿠키 말고..
쿠키는 정말 지랄맞다. 웹 개발을 하는 동무들이면 대부분 그렇게 생각하리라 믿는다. CORS도 지랄이고, webp도 지원 늦고 온갖 렌더링 따로 가는 사파리도 지랄이고 10년 전에는 IE도 지랄의 끝판왕이었다. 야! 라때는 말이야! 이거 충분히 가능한게 IE니… (니들 IE5.5에서 투명 png가 회색으로 나오는 버그 겪어봤어?)
사진은 경주시에 있는 오베르 카페 옥상이다. 다음에 꼭 참배 갈까 한다. 카페 주인은 나랑 같은 프론트엔드 개발자였을까? 개인 돈 들여서 저런걸 만들 정도면 허심탄회하게 대화하며 에스프레소를 나눠볼 수 있을것 같다.
아무튼 쿠키가 왜 지랄이냐.. 이건 '보통' 서버에서 전해주는거니까 프론트엔드에서는 뭐 손 써볼 부분이 없다. 프론트야 데이터 저장하는건 로컬 스토리지든 세션 스토리지든 다른걸 할 수 있지만, 쿠키로 뭘 할라는 순간 문제들이 많이 발생하는 모습을 봐왔다. 쿠키는 또 자바스크립트로 접근해서 값을 알아오는 것도 안되고,(httpOnly의 경우) CORS 문제 (요즘 프론트엔드 개발하는 지인에게는 욕으로 야 이자식 CORS나 걸려라- 하는중) 나 SameSite 이슈 때문에 쿠키가 설정되지 않기도 하고, 또 온갖 구글이니 애플이니 하는 놈들이 허구한날 보안이 어쩌네 개인정보가 어쩌네 하면서 두들겨대며 뭘 수정해대고, 뭐 안된다 하고, 계속 난이도를 올려대고 있기 때문이다. 외국에서 올라온 이슈 보면 2017년이 다르고 2020년이 완전 다르고 하는 식으로 계속 바뀐다.
내가 평소에 쿠키를 잘 모른다고 생각하고 있었던지라, 이번에 로그인 구현할 때 JWT가 아니라 쿠키를 이용해 일부러 들이 받았다. 못하는 것일 수록 머리부터 들이밀어서 왕창 깨져봐야지. 근데 최근에 아주 쎄게 데어서 오늘 아주 작살을 내고 갈라고 한다. 개발 해보면 알겠지만, 버그나 문제 같은 것들은 항상, 항상, 항상, 때린 곳만 계속 때린다. 한번 제대로 이해하지 못하고 음- 해결했으니까 괜찮아- 하고 넘어가면 반드시 다음번에 또, 또, 또, 같은 문제를 겪게 된다. 그래서 오늘은 버릇 없는 쿠키를 아주 조져버리고 갈 것이다.
조각글 : 외국 사이트들은 법적으로 사용자 컴퓨터에 쿠키를 사용해도 될지를 물어보도록 강제까지 하고 있다. EU의 개인정보보호법 (GPDR) 때문이다. GPDR 법에 따르면 동의를 받는 UI도 깔끔하게 만들어야 한다고 되어있다 ㅋㅋ
“the request must be clear, concise and not unnecessarily disruptive to the use of the service for which it is provided.”
GPDR 관련 정보는 이 글에 잘 정리되어있다.
현재 문제점 확인
지금 보고 있는 이 홈페이지의 구조는 miriya.vercel.app이 프론트엔드, miriya-server.vercel.app이 백엔드를 맡고 있다. 둘은 독립적인 repo로 관리되고 있으며, Vercel로 배포하기 때문에 도메인이 서로 다르다.
현재 쿠키 세팅은 다음과 같다.
SameSite=None // 도메인이 다르기 때문에 쿠키가 전송되게 하기 위해 어쩔 수 없었다.
Secure // SameSite=None 일 때 Secure 플래그 안켜면 크롬에서 쿠키를 뱉어낸다.
Domain은 지정하지 않았다.
HttpOnly // 로그인에 사용되는 쿠키이니만큼, 누가 복붙해서 사용하지 못하게 켜놨다.
MaxAge=5일 // 파이어베이스에서 인증을 만료시키는 주기와 맞췄다.
그리고 증상은..
크롬과 파이어폭스에서는 로그인이 잘 동작하는데, 사파리에서는 안된다. 또 사파리야? ㅋㅋ 어떻게 안되냐.. 서버는 정상적으로 Set-Cookie를 주고 있지만 사파리에서 “아 뭐야 다시 갖고와” 하고 쿠키를 설정하지 않는다. 사실 아무 말도 하지 않고 그냥 쿠키가 설정되지 않는다에 가깝다. (이유를 알 수 없으니 디버깅하기 매우 빡친다) 쿠키가 설정되지 않았으므로 서버에서 사용자 정보를 가져올 수 없고, 로그인이 되지 않는다.
원래는 miriya.vercel.app 레포에서 프론트엔드와 백엔드 두개를 다 돌리고 있어서 쿠키로 만든 로그인이 구현이 되었는데, 백엔드를 다른 레포로 분리했더니 로그인 부분에서 사달이 나버렸다.
쿠키를 이용한 로그인 과정을 간략하게 설명하면..
miriya.vercel.app에서 로그인을 시도할 경우
- 사용자가 이메일 + 비번 혹은 SNS로그인을 시도한다.
- miriya.vercel.app 자체적으로 Firebase Authentication을 받아 idToken을 얻는다.
- miriya.vercel.app 에서 miriya-server.vercel.app으로 idToken을 담아 POST 요청을 보낸다.
- miriya-server.vercel.app에서는 해당 idToken을 받아 사용자를 검증한다.
- 이미 존재하는 사용자며, idToken이 유효할 경우 쿠키를 담아 miriya.vercel.app으로 리턴한다.
- miriya.vercel.app에서는 miriya-server.vercel.app으로 아까 받은 쿠키를 담아 사용자 정보를 요청한다.
- miriya-server.vercel.app에서는 쿠키가 맞나 확인하고, 해당되는 사용자의 정보 (가입일, 닉네임, 프사 등등)을 리턴한다.
- miriya.vercel.app에선 받은 정보를 이용해 사이트 하단에 닉네임을 표시한다. 로그인 완료.
해본 분들은 아시겠지만 쿠키를 다룰땐 5번 과정에서 주로 작살이 난다. 일종의 신분증 확인 작업에서 브라우저가 거부하는 것이다.
1차로 CORS 문제가 있을 수 있으며, 2차로 쿠키가 세팅 되지 않을 수 있다.
서버는 분명히 어 너 착한녀석- 하고 신분증 삼아 쿠키를 담아 보내는데, 브라우저에서 신분증을 슥 보더니 “어 시발 뭐야 이거 이상해” 하고 세팅 되길 거부하는 것이다.
문제점 분석, 쿠키의 핵심 값들
5번 과정을 브라우저에서 모니터링하려면, 개발자도구를 열어 네트워크 탭에서 해당 요청의 Cookies 부분을 보면 된다. 크롬, 파이어폭스, 사파리 모두 동일하다.
이런 식이다. 보면 응답 쿠키에 서버에서 준 session 쿠키가 보인다. 물론 서버에서 이거 신분증이야- 하고 응답했을 뿐 그걸 브라우저에서 받아들였다는 뜻은 아니다. 크롬의 경우 뭔가 문제가 있을 경우 항목 앞에 느낌표로 표시를 해주는데, 이 개놈들은 여기 마우스 올려봐야 아무 정보도 얻을 수 없게 해놨다. 그나마 파이어폭스가 괜찮은데, 얘들은 왜 쿠키가 설정되지 않았는지 로그로 잘 보여준다.
위 내용에서 우리를 화나게 할 가능성이 높은 부분은 Domain, HttpOnly, SameSite, Secure 부분이다. 하나하나 대충 알아보자.
Domain
쿠키를 받을 수 있는 도메인을 적는다. 이거 주는 서버의 도메인이 아니다. 받는 클라이언트의 도메인이다. 이걸 지정하지 않을 경우 브라우저는 기본값으로 쿠키를 준 곳의 도메인을 사용한다.(서브도메인은 제외) miriya.vercel.app
이라고 넣으면 yeah.miriya.vercel.app
에서도 쿠키를 사용할 수 있다. .miriya.vercel.app
이라고 값을 넣는 사람들이 있는데, 앞에 붙은 .은 과거의 유산으로 이젠 무시된다. 점 안붙이고 쓰자. 도메인에 대해서는 밑에 SameSite에서 좀 더 심층적으로 볼것이다.
HttpOnly
로그인용 쿠키에 이걸 쓰지 않으면 보안을 개나준 상황이 되어버린다. 자바스크립트를 이용해 document.cookie
만 치면 접근할 수 있게 되며, 수정할 수도 있게 된다. 나는 예전에 노트북으로 로그인된 넷플릭스를 아이패드에서도 보기 위해 조잡한 해킹을 시도했다. (전여친 계정이었고, 비번이 기억나지 않았다) 넷플릭스도 로그인할 때 쿠키를 사용하는데, HttpOnly 플래그가 찍혀있어서 브라우저 콘솔 창에서 접근할 수 없었고, 쿠키 값을 그대로 복사해서 내 아이패드에 설정해도 HttpOnly가 아니라 그냥 무시당해버리고 로그인 되지 않더라. 이걸 덮어씌우려면 뭐 와이어샤크 같은 뭔가를 써야 할텐데 내가 그정도로 매드 사이언티스트는 아니라 그냥 돈 내고 내 계정으로 로그인해버렸다.
Secure
쿠키가 반드시 https를 통해서만 공유되게 하겠다는 뜻이다. 사실 SameSite=None
으로 설정할 경우 Secure 플래그를 줘야 하기 때문에 넣었다. 개발자들이 https와 http를 섞어 쓰며 야매로 우회하는것을 막기 위해 넣은 기능인듯.
SameSite
이건 밑에서 오지게 길게 설명할거다. 오늘의 핵심 파트다.
자 그럼 사용자 브라우저에 쿠키가 세팅 되어있는지는 어떻게 아는가?
역시 개발자도구에서 어플리케이션이나 저장소, 저장공간 탭을 보면 된다. 크롬의 경우 어플리케이션이라고 되어있고, 파이어폭스는 저장소, 사파리는 저장공간이다.
이런 식으로 현재 브라우저가 해당 도메인 범위 안에서 갖고 있는 쿠키를 볼 수 있다.
여기서 주의할 점은, 지금 브라우저에 miriya.vercel.app 화면이 떠 있다면 miriya.vercel.app에 대한 쿠키만 볼 수 있다는 것이다. 즉, SameSite일 경우에만 볼 수 있다. 내 경우엔 테스트를 한다고 쿠키 Domain 값을 이것저것 바꾸다보니 아예 저 탭에 쿠키가 보이지 않게 되어버렸다. 근데 환장하겠는게 사용자 정보를 요청하는 6번 단계에서 쿠키는 잘 가고 있다. 내겐 보이지 않는 쿠키가 가고 있는 것이다.
이렇게 주소창에 임의로 miriya-server.vercel.app 주소를 넣고 들어가니 비로소 잘못 세팅되어있는 쿠키를 볼 수 있다. 이제 저걸 지워주면 비로소 로그아웃 할 수 있다 -_-;;
자 그럼 지우기 전에 먼저.. 모르고 넘어가면 안되잖아? 아니 그럼 miriya.vercel.app은 왜 브라우저에서는 보이지도 않는 miriya-server.vercel.app의 쿠키를 보내는건데? 그럼 말마따나 miriya.vercel.app에서 shitty.vercel.app으로 내 쿠키를 보낼 수도 있는거 아니냐고. 이유는 내가 SameSite=None으로 설정했기 때문이다.
web.dev 문서에서 퍼온 사진인데 None의 경우 노란색 남의 사이트 안에도 전송됨을 볼 수 있다.
SameSite 값 설명
None
miriya.vercel.app의 쿠키는 shitty.vercel.app으로도 전송될 수 있다.
이것은 SameSite 옵션을 무시하라고 지정하는게 아니다. “내 쿠키를 다른 사이트에도 전송되도록 한다” 라는 의도 전달 내지는 명시에 가깝다. 내가 None으로 설정한 이유는 miriya.vercel.app에서 miriya-server.vercel.app으로 부터 쿠키를 받도록 하기 위함이었으나, 이는 역으로 miriya.vercel.app에서 shitty.vercel.app으로 쿠키를 전달하게 허용하게 된 것이기도 하다. 좀 소름.
Strict
miriya.vercel.app의 쿠키는 miriya.vercel.app으로만 전송된다.
이메일이나 페이스북에 공유된 miriya.vercel.app링크를 타고 들어가면 초기 요청시 miriya.vercel.app으로 쿠키가 전송되지 않는다. 가령 ‘첫방문이라 고마워서 1BTC 제공’ 같은 배너를 띄우고, 배너를 띄운 사용자는 배너 띄웠으니 이제 보여주지 말라고 쿠키를 세팅해뒀을 경우… 이 사용자가 다른데서 링크 타서 들어오면 저 배너를 또 보게 되는 셈이다.
Lax
위의 경우가 거슬리면 Lax로 지정하면 된다. 어떤 놈이 내 사이트의 이미지를 다음과 같이 만들어서 자기 사이트에 올렸다 치자.
<img src=’https://miriya.vercel.app/btc-banner.jpg’ />
<p>어떤놈이 첫 방문자에게 1BTC 준다함 ㅋㅋ</p>
<a href=’https://miriya.vercel.app’>여기 좌표임</a>
브라우저는 첫줄의 배너 이미지를 요청하기 위해 내 서버 문을 두드린다. 이 과정에선 miriya.vercel.app에 대한 쿠키가 전송되지 않는다. 하지만 맨 아랫줄의 링크를 방문자가 클릭해 들어올 경우엔 miriya.vercel.app 관련 쿠키가 내 서버로 전송된다. 이게 LAX다. 따라서 한번 1BTC 받은 사람은 1BTC 준다는 배너를 보지 못하게 되는 것이다. (물론 서비스 이따위로 만들면 안된다)
또한 중요한 부분이, ‘상태를 변경하려는’요청인 POST 같은 부분은 쿠키가 전송되지 않는다. 이미지를 GET 하거나, 아니면 뭐 링크를 클릭해서 이동하는 요청 같은데만 쿠키가 포함된다. 따라서 miriya.vercel.app과 miriya-server.vercel.app 간에 쿠키를 전달하려고 SameSite=Lax를 쓸 수는 없다. 하 로그인에다가 GET을 쓸 수도 없는 노릇이고 말이지.. 역시 어쩔 수 없이 쿠키를 버려야하나.
원래 쿠키는 SameSite를 안적어도 동작했지만, 구글이 2020년에 크롬 84부터 SameSite 안적으면 자동으로 Lax로 동작하도록 설정했다. 이 말인즉슨, 여태 잘 쓰고 있던 쿠키가 고장나 다른 사이트로 전송되지 않게 되었다는 것이다.
가령 뭐 광고 배너 기능을 만들었다 치자고. 광고 배너에 나온 부르르 제로 콜라를 보고 사용자가 그걸 클릭해서 진짜 구매를 했어. 그러면 그 과정에서 쿠키를 사용자 브라우저에 담아놨다가 구매 사이트에 보내거나 등의 뭐 어떤 작업을 해야 하는데, 크롬 84부터는 이게 갑자기 Lax로 동작하면서 다 막히게 되는 식이라 한번 난리가 났던 것이다. 나는 광고 기능이 어떻게 동작하는지는 자세히 모르므로 양해를. 혹은 추가/정정/보강 댓글을.
그리고 크롬 84 부터는 위 이슈를 슬쩍 피하기 위해 SameSite=None을 넣어서 기존처럼 쓰려한 개발자들을 괴롭히기 위해 SameSite=None은 반드시 Secure 플래그가 설정된 상태에서만 처리되도록 변경했다. 따라서 기존에 웹 사이트가 https가 아니라 http로 돌아가면 또 작살이 나는 것이다. 파이어폭스나 Edge도 뒤를 따르고 있으니 우리는 개기지 말고 순응해야한다.
심지어는 장기적으로 타사 쿠키에 대한 지원을 완전히 종료한다고 하니까, 사이트간 공유되는 쿠키의 미래는 없다고 보면 되겠다. 같은 도메인끼리 로그인하는데다 근근히 사용될듯.
유튜브를 embed 방식으로 퍼가기 한 주소에서 찍은 쿠키 사진인데, LOGIN_INFO와 같은 정보는 SameSite가 None으로 설정되어있다. 저게 있기 때문에, 외부 사이트에 퍼간 상태에서도 유튜브 구독 목록이 보이는 기능을 구현할 수 있는 것이다.
SameSite란?
브라우저는 A에서 B로 요청을 보낼 때 A와 B가 다른 주체라는 것을 어떻게 파악하는걸까?
아래 테이블은 두가지 다른 url을 Site와 Origin 관점에서 어떻게 구분하는지를 정리한 것이다.
Origin A | Origin B | Site | Origin |
---|---|---|---|
https://www.miriya.net:443 | https://www.shitty.net:443 | 다른 도메인 | 다른 도메인 |
https://miriya.net:443 | 굿 | 다른 서브도메인 | |
https://login.miriya.net:443 | 굿 | 다른 서브도메인 | |
http://www.miriya.net:443 | 다른 스키마 | 다른 스키마 | |
https://www.miriya.net:80 | 굿 | 다른 포트 | |
https://www.miriya.net:443 | 굿 | 굿 | |
https://www.miriya.net | 굿 | 다른 포트 |
보면 SameSite는 SameOrigin에 더해 포트나 서브도메인을 신경쓰지 않는다.
흐음, 그럼 왜 브라우저는 miriya.vercel.app과 miriya-server.vercel.app이 다른 사이트라고 생각하는걸까? 뒤에가 vercel.app으로 같은데 왜 지랄을 하는걸까. 잠시 생각해보면, vercel.app은 Vercel이 지원하는 배포/호스팅 서비스고, vercel.app 앞에는 온갖 잡놈들의 접두사가 다 붙을 수 있고, 온갖 사용자가 각기 다른 서비스를 굴리고 있을 수 있다. 이 현실 때문에 TLD, 즉 Top Level Domain, 다시 말해 .net이나 .com 등에 한칸 더한 TLD + 1로는 사이트를 구분할 수 없게 된 것이다. 뭐 예전에 웹이 간단할때는 naver.com이나 google.com 등 .com TLD에 naver나 google을 한칸 더한걸로 Site를 구분할 수 있었지만.. 이젠 아니라는 말이다.
그래서 수많은 자원봉사자들이 한땀한땀 작업한 Public Suffix List라는게 생겼다.
개념은 이곳을 보면 되고, 리스트는 이곳을 보면 된다.
이젠 Public Suffix List에 포함된 주소를 eTLD라고 가정하고 거기다가 한칸 더 붙인것으로 Site를 구분하게 된다. 다시 말해 vercel.app이 Public Suffix List에 포함된 eTLD이고, 거기다가 한칸 붙인 miriya.vercel.app과 miriya-server.vercel.app이 구분되게 되는 것이다. 쿠키에다가 냅다 vercel.app 넣으면 되겠지- 하면 안되는 이유가 이것이다.
이 스샷과 같이 vercel.app이 저 리스트에 포함되어 있기 때문에, 브라우저는 아 저거 개나소나 다 쓰는 퍼블릭이구만- 하고 생각해서 vercel.app 앞에 오는 것들은 각각 소유자가 다른걸로, 즉 SameSite가 아니라고 판단한다. 그리고 내 쿠키는 막히게 되고… 혹시나 해서 검색해봤는데 naver나 daum은 나오는게 없다. tistory.com도 없는데, 뭐 이건 쿠키를 사용자가 적용할 수 없으므로 상관 없겠지..
애초에 vercel.app으로 끝나는 주소를 다 인정해줬다면 가령 내가 운영하는 miriya.vercel.app의 쿠키가 shitty.vercel.app으로도 전송이 되고, 그럼 shitty.vercel.app의 소유자는 아이고 개꿀? 이거 로그인 쿠키네? 하고 이걸 사용해 miriya.vercel.app으로 로그인할 수 있게 되는 불상사가 벌어진다.
자.. 아무튼 이런 식으로 브라우저가 miriya.vercel.app과 miriya-server.vercel.app이 다른 사이트라고 생각하는 것이다. 다시 말해 miriya.com과 miriya-server.com이 다른것, daum.net과 naver.com이 다른것과 동일한 상황이다.
이때 쿠키를 SameSite=None이 아니라 SameSite=Lax로 설정하면 사파리가 특별히 '어이구 용썼네' 하고 용서해줘서 사이트간 요청이 되지 않겠냐- 생각할 수 있지만, Lax는 위에서 말한 것 처럼 POST 요청에 사용할 수 없으므로 불가능하다. None이라는 것도 workaround 같은게 아니라 명시에 가까운거고.
SameSite 구분법에 대해서는 이 글이 종합 백과사전 처럼 잘 정리되어있으므로 원문을 보자. 내 글은 그냥 불쏘시개나 조미료 떡칠된 요리 같은거니까 참고만 하자.
도메인을 통일해보자
내 경우 프론트엔드와 백엔드가 다른 repo로 되어있어서 vercel에서 주는 url을 각자 고유하게만 가질 수 있다. 다시 말해 miriya.vercel.app이나 miriya-server.vercel.app은 되는데 miriya.vercel.app과 server.miriya.vercel.app은 안되는 것이다.
대신 Vercel에서는 내가 보유하고 있는 URL을 연동할 수 있게 해놨는데, 나는 이 기능을 이용해보기로 했다.
miriya.vercel.app => miriya.net
miriya-server.vercel.app => server.miriya.net
이렇게 바인딩한다는 말. 그러면 쿠키 설정할 때 Domain을 miriya.net에서만 쓴다고 설정해주면 miriya.net과 server.miriya.net간에 쿠키가 공유되는게 가능하겠다는 판단이 선다.
SameSite=Strict;
Secure;
Domain='miriya.net';
HttpOnly;
MaxAge=5 * 60 * 60 * 24 * 1000; // 5일
도메인을 바꾸고 적용해봤는데 매우 잘 작동한다. 사파리에서도 문제 없이 쿠키가 세팅이 되고, 로그인이 잘 되는걸 확인할 수 있었다. 그리고 이제부터 이 홈페이지의 주소는 miriya.vercel.app이 아니라 miriya.net이 되었다.. miriya.vercel.app으로 들어온 사람들은 자동으로 miriya.net으로 리다이렉트 될 것이다. Vercel 만세.
만약 나처럼 도메인을 내가 설정할 수 없는 상태면 뭐, 쿠키 버리셔야지 ㅋㅋ 방법이 없다. 프록시 하는 것도 프로덕션 레벨에서 그러는건 참 짜치는 일이고. 쿠키를 로그인 말고 또 어디다 써야 할까... 내가 내 사이트에 뭐 사용하는것 말고는 쿠키가 설 자리가 없어지는것 같다. 글 보고 있는 당신의 의견도 궁금하다. 이틀 전에 댓글 기능 달았으니 많은 댓글을...
참고자료
https://web.dev/i18n/ko/schemeful-samesite/
https://web.dev/i18n/ko/samesite-cookie-recipes/
https://web.dev/i18n/ko/samesite-cookies-explained/
https://web.dev/same-site-same-origin/#"schemeful-same-site"
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie