들어가며
글을 쓰기에 앞서 네이버/카카오/구글 등 업체는 소셜 프로바이더로, 이 업체의 서비스를 이용해 로그인하는 방식은 소셜 로그인이라고 칭한다. 또, 카카오 기준으로 '인카 코드'는 code로, '코드'는 access token으로 칭한다.
소셜 로그인을 지원하지 않는 서비스는 이제 없다고 봐도 무방하다. 그 압도적인 편의성은 사용자를 끌어모으는데 큰 역할을 한다. 서비스를 만드는 입장에서도 상당히 편리하다. 복잡한 회원 서비스를 훨씬 간단하게 만들어준다. 하지만 항상 간단한 것은 아니다. 사용자에게 제공해야 하는 서비스 수준에서 구현해보면 특유의 진입점 컨트롤이 생각보다 어렵다는 것을 금새 깨닫게 된다. 특이하게도 소셜 로그인은 예제가 많지만 실제 서비스 수준에서 쓰기에는 매우 부적절한 '토이 프로젝트' 수준의 예제가 대부분이다. 심지어 네이버나 카카오는 국내 위주의 서비스이기에 더욱이 예제를 찾기 어렵다. 물론 LLM으로는 도움 받을 수 있지만 실제 서비스에서는 훨씬 더 많은 엣지 케이스가 펼쳐지니 LLM에 의존하기 힘들어진다. 소셜 로그인 진입점과 비즈니스 로직을 처리하는 것도 어렵다. 예를 들어, 이 글에서 소개했던 이전 프로젝트에서는 회원 가입/로그인, 간편 로그인 연결/해제, 회원 탈퇴 등 5개 이상의 소셜 로그인 진입점이 있었다. 진입점별로 처리해야 하는 비즈니스 로직이 달라지니 엔드포인트의 수가 너무 많아지게 되었다.
소셜 서비스의 개발자 센터에서 등록할 수 있는 URL의 수는 생각보다 적다. 서비스마다 다르지만, 네이버는 5개, 카카오는 10개 수준이었다. 직접 문의하면 더 늘려주거나 직접 등록해주기도 하지만 그게 모든 회사에서, 모든 서비스에서 가능할지는 모른다. 이런 상황에서 어떤 고민을 하고 소셜 로그인을 개발하였는지 공유하고자 이 글을 적었다. 늘 그렇듯 간단한 수준의 설정하는 법 등은 모두 생략한다.
소셜 로그인 살펴보기
먼저 소셜 로그인의 순서를 명확하게 할 필요가 있다. 토이 프로젝트로 진행해본 경우가 많아 대부분 알고 있지만 다시 한번 짚고 넘어가는 것이 크게 도움된다. 더 나은 방법을 찾기 전에 기본은 해야 한다. 네이티브 환경에서의 SDK 로그인도 별반 다르지 않기에 한번 기억해두면 큰 도움이 된다.
웹에서의 소셜 로그인은 아래 순서로 진행된다.
- JS SDK 설정 및 초기화
- JS로 구성된 SDK를 설치한다. 생각보다 더 긴 시간이 소요되므로 정상적으로 모두 준비되었는지 확인이 필요하다.
자체적인 Initialize 함수를 사용하는 경우가 많아 onLoad 등 스크립트 이벤트로 후킹이 불가능하다고 봐야 한다. - 서비스에 따라 아래 2번에서 설정할 상태값을 JS SDK의 Initalize 함수에서 미리 설정하기도 한다.
- JS로 구성된 SDK를 설치한다. 생각보다 더 긴 시간이 소요되므로 정상적으로 모두 준비되었는지 확인이 필요하다.
- 버튼 클릭 -> 소셜 프로바이더 사이트로 이동(혹은 팝업)
- 사용자의 동작(로그인 버튼 클릭)에 이어 시작되는 과정이다.
- 소셜 로그인을 위한 옵션을 설정하고 로그인 폼이 있는 사이트로 이동한다.
- 옵션으로 팝업 혹은 리다이렉션같은 로그인 방식부터, 로그인 서비스를 이용하기 위한 Client ID, 그리고 로그인 이후 이동할 Redirect URI와 CSRF 공격을 막기 위한 state 등 상태 값이 포함된다.
- [소셜 프로바이더 사이트] 소셜 로그인 진행
- 로그인 폼을 제공하는 소셜 프로바이더의 사이트로 이동하여 로그인한다.
- 소셜 프로바이더의 개발자 센터에서 등록한 약관 등이 노출된다. 1번에서 설정한 조회 범위(scope)에 따라 제공 여부를 선택하기도 한다.
- UA 같은 접속 정보에 따라 '앱으로 로그인' 버튼을 제공하거나 아예 로그인이 막히기도 한다.
- [소셜 프로바이더 사이트] 얻어낸 code, 입력했던 state와 함께 Redirect URI로 이동
- code는 1회용으로 이 값만으로는 아무것도 할 수 없다.
- Redirect URI: code로 access token 획득
- 소셜 로그인을 진행하여 받은 코드와 1번에서 설정했던 Client ID, Redirect URI 등을 이용해 토큰 조회 API에 요청해 Access Token을 획득한다.
- access token으로 회원 정보 조회
- Access Token을 이용해 소셜 프로바이더의 프로필 조회 API에서 회원 정보를 조회한다.
- 1번에서 설정한 조회 범위(scope) 옵션에 따라 응답 값이 달라진다.
예를 들어 1번 단계에서 scope에 성별을 입력해두어야만 응답으로 성별을 받을 수 있다.
- 비즈니스 로직 수행
- 프로필 조회 API로 얻은 회원 정보에서 id(혹은 사용자 구분 토큰)와 사용자 정보를 이용해 회원 가입/로그인, 탈퇴 등 동작을 수행한다.
위 순서에서 주의해야 할 것은 먼저 토큰이 code와 access token으로 분리되어 있다는 것이다. 각 서비스마다 부르는 방식은 다르지만 이렇게 두가지로 분리되어 있는 것은 대부분 동일하다. 다만 서비스에 따라 code가 아닌 access token을 직접 전달하는 경우도 있다. 네이티브 로그인을 이용하는 애플이 대표적이다. 애플은 JS SDK 설정에 Redirect 방식을 명시하더라도 IOS라면 네이티브 로그인을 시도한다. 네이티브로 로그인되면 Redirect 없이 JS 코드 상에서 access token과 state를 획득하게 된다. 또, IOS가 아니라 Redirect URI로 이동되더라도 POST로 code와 state를 전송한다는 점이 다른 소셜 프로바이더와 크게 다르다.
그렇기 때문에 만약 Redirect URI을 Next.js의 페이지로 구성했다면 특별한 로직이 필요해진다. context.request 객체에서 body를 뽑아내는 로직이다. formdiable 같은 라이브러리를 이용하면 아주 간단하게 해결할 수 있다. 다만 애플일 경우에만 이렇게 body에서 얻어낸 값을 사용한다는 점을 코드에 남겨야 할 것이다.
얼핏 보면 앞서 설명한 과정들을 모두 하나의 URL, 그러니까 Redirect URI로 설정한 페이지에 구현해도 무리가 없어 보인다. 하지만 한가지를 놓쳤다. 팝업 로그인이다. 팝업의 경우에도 Redirect URI로 이동하게 되는데, 로그인 폼이 노출되었던 자식 창이 이동한다는 점을 주의해야 한다. 일반적인 경우라면 자식 창을 닫고 부모 창에서 이동해야 한다.
물론 간단하게 해결할 수 있다. 부모 창에 메시지를 보내는 함수인 postmessage를 이용하면 된다. 하지만 이렇게 로직이 난해해지면 코드를 어떻게 관리해야 할지 고민되기 시작한다. 앞서 계속 언급한 것처럼, 회원 가입/로그인만이 아니라 회원 탈퇴, 연동/해제 등 많은 액션이 필요하므로 이 로직들을 모든 곳에 삽입할 수 없기 때문이다.
더 나은 방법을 찾아서
일단 무엇이 문제인지, 혹은 앞으로 문제가 될지 파악했다. 그러면 각 요소들을 어떻게 해야 더 나은 방향으로 변경할 수 있을지 고민해야 한다.
1. Redirect URI
소셜 로그인은 보안을 위해 로그인 된 이후 이동할 URL을 미리 등록해놓는다. 문제는 회원 가입/로그인, 탈퇴 등 사용자가 할 수 있는 모든 액션에 대해 Redirect URI를 등록하는 것이 맞느냐는 것이다.
모든 액션에 대해 등록하려고 하면 너무 많은 URL을 등록하고 관리해야 한다. 테스트를 위해 만든 다른 환경들(QA, STAGE 등)을 생각한다면 URL이 많아도 너무 많아진다. 심지어 액션이 추가될 때마다 다시 등록해야 한다. 이미 등록 가능한 개수를 초과했다면 등록 할 때마다 직접 연락해야 할지도 모른다. 그래서 나는 하나의 Redirect URI를 등록하고자 했다. 소셜 로그인을 진행하면 정해진 하나의 URL로만 이동하고, 그 URL에서 상태에 따라 다시 분기하는 형식이다.
2. 상태 관리
1번에서 언급한대로 하나의 Redirect URI만을 등록한다면 액션에 따라 어떻게 다시 분기해야 할지 고민하게 된다. 자연스럽게 쿠키를 이용해보게 되었다. 클라이언트에서 설정할 수 있고 서버에도 전달된다. 아주 완벽해 보였다.
하지만 쿠키는 치명적인 문제가 있었다. 브라우저에 남는다는 것이다. 아무리 짧게 설정해도, 그 시간동안 문제가 되거나 오히려 너무 빨리 사라져 문제가 될 수 있다. 쿠키가 유실되거나 다른 액션을 하고자 했던 값이 남아 있다면(혹은 다른 창에서 시도한다면) 의도하지 않은 상태로 변하게 된다. 심지어 고객이 이 상태에 돌입했다면 문제를 해소하기 위해 '쿠키 삭제'를 권할 수 밖에 없다는 것이 아주 치명적이다.
심지어 원래 쿠키는 set-cookie 헤더를 이용해 '문서 단위'로 설정하는 값이기에 동적으로 움직이는 웹 어플리케이션에는 어울리지 않는다. 실제로 테스트 중 가끔 원하는 타이밍에 쿠키가 설정되지 않던 것도 확인했는데, 이건 나중에 재현하고 더 깊게 확인해보려 한다.
아무튼 쿠키는 각하되었다. 다음으로 사용할 방법에 대해 고민하던 중 state를 활용하는 방법이 떠올랐다.
개발해야 하는 소셜 프로바이더인 구글, 애플, 카카오, 네이버 4개 서비스 모두 소셜 로그인에 상태 토큰인 state를 포함할 수 있다. state는 주로 CSRF 공격을 막기 위해 사용되지만, 그 대신 state에 직렬화된 JSON 객체를 사용하면 되겠다는 생각이 들었다. state의 본래 목적인 CSRF 토큰과 로그인 이후 처리할 동작 등 다양한 것들을 모아 JSON 객체로 만들고, 이것을 직렬화-암호화하여 문자열로 만든다. 그리고 로그인할 때 함께 전송한다. 이렇게 전송한 state 문자열은 로그인 이후 되돌려 받게 된다. 로그인 이전의 상태를 이어갈 수 있는 것이다. 이렇게 state를 사용하는 방법이 없던 것은 아닌 것 같지만 서비스 수준에서 사용해도 괜찮은지 확신이 없었다. 하지만 여러 방면에서 테스트해보니 다른 방법들보다 훨씬 더 안정적이라는 것을 알게 되었다.
소셜 로그인이 완료되어 이동한 Redirect URI에서 이 state 문자열을 복호화-역직렬화하면 로그인을 시도했을 때의 상태를 알게 되므로 더 명확하게, 심지어 여러 윈도우를 유지하고 있더라도 상태에 대해 명확하게 파악하고 처리할 수 있게 된다. 복호화 과정이 있으니 state에 임의의 값을 넣는 공격에서도 자유로워진다.
3. 역할 분리
앞서 설명한 단계에 따르면, 소셜 로그인 5~7번 과정은 명확하게 다른 로직이다. 하나의 Redirect URI을 이용하게 되면 이 로직들이 같은 페이지에서 분기 처리되어야 하므로 코드를 관리에 어려움이 생긴다. 그래서 나는 위에서 설명한 state에 다음에 진행해야 할 액션의 구분 값을 넣도록 했다.
로그인이 완료된 후 이동한 Redirect URI에서는 state에서 확인한 액션의 구분 값을 통해 비즈니스 로직을 수행하는 페이지로 이동하도록 했다. 각 페이지에서는 주어진 역할만을 수행하고 다음 로직을 수행할 페이지로 리다이렉션 한다.
이렇게 하면 페이지 자체가 하나의 모듈이 되어 재활용도 가능해지고, 비즈니스 로직 수정이 다른 페이지에 영향을 주지 않기 때문에 사이드 이펙트에서 안전해진다. 302 리다이렉션은 브라우저 히스토리 스택에 쌓이지 않으므로 유저의 뒤로가기에도 영향을 주지 않는다. 이 방법을 통해 코드 관리에 이점을 더할 수 있다.
4. 팝업 핸들링
앞서 말했듯 데스크탑의 경우 사용자 편의를 위해 팝업 로그인을 하곤 한다. 팝업 로그인을 설정하면 새 창으로 소셜 로그인을 진행하게 되는데, 팝업으로 열린 윈도우에서 로그인하고 등록한 Redirect URI로 이동한다.
3번 역할 분리에서 URL을 분리하니 이 문제도 손쉽게 해결할 수 있게 된다. Redirect URI은 window.opener가 있으면(자식 창이라면) postMessage를 이용해 code나 state 등을 부모 창에 전달하고 창을 닫는다. 그리고 메시지를 받은 부모 창에서 3번에서 정의한 세부 URL로 이동하면 된다.
위 그림처럼 소셜 로그인과 관련된 로직은 전혀 수정할 필요가 없다. 덕분에 소셜 로그인의 각 페이지가 명확하게 모듈화되고, 비즈니스 로직 변경에 따른 사이드 이펙트에서 안전해진다.
정리
앞서 말한 내용들을 정리하면 아래와 같다. 먼저 소셜 로그인이 필요한 진입점들을 구성한다. 그리고 진입점에서 소셜 로그인을 하기 전, state에 로그인 후 시도할 동작을 작성하여 직렬화한다. 소셜 프로바이더 사이트에서 소셜 로그인이 완료되면 일종의 '게이트웨이' 역할을 하는 Redirect URI 페이지로 이동한다. 이 '게이트웨이' 페이지에서는 state를 역직렬화하여 다음으로 실행해야 할 동작에 대해 알아낸다. 이어서 동작을 수행하는 페이지로 리다이렉션 시킨다.
각각의 요소를 설명할 때에는 꽤 복잡한 것 같지만 이렇게 전체 플로우로 보면 납득 가능할 것이다. 이 구조로 구성했을 때 가장 큰 장점은 URL, 그러니까 페이지별로 역할이 확실히 분리되어 있다는 점이다. 위 그림을 예로 들면 /social/withdraw 페이지는 정말 회원 탈퇴 비즈니스 로직만을 수행하면 된다. 이 페이지를 수정하기 위해 회원 가입 로직을 파악해야 한다거나, 잘못 수정해서 영향이 가거나 하는 일은 없다. 그렇기 때문에 조금 더 마음 편하게 수정이 가능해진다. 추가 작업에도 장점을 가진다. 다른 액션이 추가되더라도 /social 하위 URL에 페이지를 추가하면 되기 때문이다.
마치며
로그인은 한번 만들어지면 정말 웬만해서는 바뀌지 않는다. 그렇기 때문에 서비스에서 중요한 부분을 차지함에도 경험자가 적다. 그래서 나는 누구나 한눈에 알아볼 수 있고 또 마음놓고 수정할 수 있어야 한다고 생각했고, 최대한 노력해 이런 구조를 생각하고 적용했다. 하지만 정말 이 구조를 누구나 한눈에 알아볼 수 있을지 장담할 수 없다. 이전 글에 적었듯 페이지 단위의 로직에 익숙하지 않은 개발자가 많고, 클라이언트에서 동작하는 것을 선호하기 때문에 이 구조에 대해 가이드해주어야 하기 때문이다. 그래서 조만간 팀 내에 이 구조에 대해 공유하고 의견을 물어 더 개선할 점이 있을지 논의해볼 생각이다. 이미 런칭하여 수정하기는 어렵겠지만, 좋은 구조를 고민해둔다면 분명 다음 기회에 더 나은 결과물이 나올 것이다. 다만 회원 시스템을 다시 만들 일이 있을지 의문스럽다. 다른 회사에 가도 있던걸 쓰지 새로 만들 것 같지는 않기 때문이다.
'ECMAScript | TypeScript' 카테고리의 다른 글
회원통합 프로젝트에 대한 회고 (3) | 2025.05.02 |
---|---|
배려하는 코드란 무엇일까 (1) | 2025.04.16 |
atob와 encodeURIComponent. 짝이 되는 변환 함수들 (0) | 2024.07.31 |
React의 hook deps와 Object.is (0) | 2023.09.17 |
더블 클릭이란 무엇일까 (0) | 2023.07.14 |