[Spring Boot] Google OAuth 2.0과 JWT를 통한 회원 관리 방식 이해하기
들어가며
반려동물 쇼핑몰 프로젝트에서 Google OAuth 2.0과 JWT를 회원 관리 방식으로 채택했습니다. 이미 다른 블로그에 코드에 대한 Reference는 많이 존재하고 있지만 사용자 인증부터 JWT 발급 및 갱신까지의 흐름을 자세하게 설명하는 곳이 없어서 자료조사하고 이해하는 데 시간이 많이 걸렸습니다. 그래서 이번 프로젝트에서 회원 관리 기능을 구현하며 사용한 사용자 인증 방식의 흐름을 자세하게 설명하는 글을 쓰려고 합니다. 사용자 인증 방식은 개발자 마음 가는대로 얼마든지 다양하게 구현할 수 있기 때문에 그 중 하나의 흐름을 설명하려고 합니다.
우선 웹 애플리케이션이고 Google OAuth 2.0으로 제 3자인 구글에게 인증을 위임하고 인증에 성공한 사용자에 대해서 JWT 형식의 Access Token과 Refresh Token을 발급해 지속적인 회원 관리를 하도록 만들었습니다. 그리고 Access Token은 로컬 스토리지에 저장하고, Refresh Token은 쿠키에 저장했습니다. 또한, 첫 로그인 기능 개발이기 때문에 최대한 기교 없이 구현하고 싶어 OIDC 같은 추가적인 기술은 사용하지 않았습니다.
앞서 말했듯이 사용자 인증에 대한 실제 구현 코드를 설명하는게 아닌 흐름을 설명하는 글이기 때문에 이미지와 텍스트 위주로 글을 작성할 것 같습니다. 그리고 JWT 등의 백엔드에에 대한 기초적인 지식이 있어야 글 읽기가 수월할 것 같습니다. 코드 위주의 구현은 다음에 자세히 다뤄보도록 하겠습니다.
개요
우선 흐름에 대한 설명을 하기 전 용어에 대한 정리를 하면 좋을 것 같습니다. 편의와 독자의 이해를 위해 최대한 쉽게 정했습니다.
- User(=사용자)
- 실제 로그인을 시도하려는 사용자입니다. Google을 통해 인증을 요청하고 애플리케이션을 이용하기 위해 필요한 자격 증명을 받습니다.
- App(=Application)
- 애플리케이션을 의미하며, 이 글에서는 클라이언트를 지칭합니다. UI를 제공하고, 백엔드 서버와 상호작용하여 사용자가 로그인 및 인증 과정을 수행할 수 있도록 합니다.
- Spring Boot(=백엔드 서버)
- 백엔드 서버를 의미합니다. Google OAuth 2.0을 통해 인증된 사용자를 관리하며, JWT 발급 및 갱신을 담당합니다. 프로젝트에서 Spring Boot를 사용했기 때문에 편의상 백엔드 서버 Spring Boot라고 부르겠습니다.
- MySQL(=DBMS)
- 백엔드에서 사용하는 DBMS입니다. DBMS 또한 프로젝트에서 MySQL을 사용했기 때문에 MySQL이라고 부르겠습니다.
- Google OAuth 2.0 API를 제공하는 서버입니다. 인증 요청을 처리하고 사용자 정보를 제공하는 역할을 합니다. 편의상 Google이나 Google 서버라고 부르겠습니다.
- JWT
- JSON Web Token입니다. 일반적으로 클라이언트와 서버 간의 통신에서 인증을 위해 사용되고 Spring Boot에 존재하는 Jwts 라이브러리를 지칭합니다. Access Token과 Refresh Token 두 가지로 나누어서 사용자를 관리합니다.
Google OAuth 2.0과 JWT를 통한 최초 로그인
#1 User와 App과 Google
① 사용자가 로그인 버튼을 눌러 Login Flow 시작
웹 애플리케이션에 접속한 User는 로그인 버튼을 눌러서 최초로 로그인을 시도합니다. 로그인 버튼을 누르면 Google Login 페이지로 이동됩니다.
② 사용자가 구글 계정으로 로그인
본인의 구글 계정을 선택하고 계속을 눌러서 로그인합니다. 만약 성공한다면 Google Console Platform에 저장해둔 Redirection URI로 이동하게 됩니다. 이때 Redirection URI는 인가 코드를 받을 곳의 도메인으로 구글 콘솔에 등록하면 됩니다.
③ 로그인 성공한 경우 Google은 App에게 인가 코드(Authorization Code) 반환
이동한 Redirection URl에는 code=으로 시작하는 인가 코드를 받게 됩니다. 이 인가 코드는 나중에 구글에게 토큰을 요청할 때 사용됩니다.
#2 App과 Spring Boot와 Google
① App은 Spring Boot에게 인가 코드를 추출해서 전달
인가 코드는 URL에 포함되어 있기 때문에 클라이언트 측에서 인가 코드를 추출해서 Body에 담아 POST 요청을 합니다.
인가 코드를 클라이언트 측에서 추출할 지 백엔드 측에서 추출할 지는 선택사항입니다. 저는 백엔드 서버의 부담을 줄이기 위해 클라이언트 측에서 인가 코드를 추출하는 것으로 정했습니다.
② Spring Boot는 Google에게 인가 코드와 함께 Access Token 요청
Spring Boot의 Controller에서 인가 코드를 받아 Body에 담은 뒤 Google에게 Access Token을 요청합니다. 이 Access Token을 요청하는 이유는 구글 계정에 등록된 사용자의 정보를 가져오기 위함입니다.
URL에 포함된 인가 코드는 보안과 전송 효율을 위해 Encoding 되어 있기 때문에 클라이언트나 백엔드 측에서 1회 Decoding하는 로직이 필요합니다. Encoding 된 인가 코드는 %2F로 시작하고 저는 백엔드 측에 Decoding 로직을 추가했습니다.
③ Google은 인가 코드가 유효하다면 Access Token 반환
Google은 Spring Boot의 요청에서 인가 코드를 확인해서 인가 코드가 유효하다면 Google의 Access Token을 반환합니다.
만약, 인가 코드가 유효하지 않다는 응답이 계속 발생한다면 아래의 글을 참고해주세요.
[Spring Boot] Google OAuth 2.0 API 요청 시 invalid grant와 malformed token 응답 해결하기
들어가며반려동물의 쇼핑몰 제작 프로젝트에서 구글 소셜 로그인을 맡았습니다. Google OAuth 2.0로 로그인해서 인가 코드를 받아 사용자 인증을 하고, JWT를 생성해서 회원을 관리하려 했습니다. Spr
jundyu.tistory.com
#3 Spring Boot와 Google과 MySQL
① Spring Boot는 Google에게 받은 Access Token으로 다시 Google에게 User 정보 요청
이전 단계에서 Google에게 Access Token을 받았습니다. 이는 다시 Google에게 사용자 프로필 정보를 요청하기 위해 받은 것입니다. 따라서 Access Token을 Body에 담아서 다시 Google에게 사용자의 프로필 정보를 요청합니다. 이 프로필 정보는 Google 계정에 저장되어 있는 프로필 정보입니다.
② Google은 Access Token이 유효하다면 User 정보 반환
만약 Access Token이 유효하다면 Google은 Spring Boot에게 사용자의 프로필 정보를 반환합니다. 이때, 반환되는 프로필 정보는 이름, 이메일, 프로필 사진 등이 있습니다.
③ User 정보를 바탕으로 MySQL에서 사용자 확인
사용자 정보 중 Google Email은 세계적으로 고유한 값이기 때문에 Google Email로 DB에 사용자가 존재하는 지 확인합니다.
④ User 반환
만약 최초로 로그인하는 사용자라면 DB에 사용자를 추가한 뒤 사용자를 반환하고, 2번 이상 로그인을 시도하는 사용자라면 바로 DB에서 사용자를 찾아 반환합니다.
이로써 Google을 통해 사용자의 인증을 받았기 때문에 Google은 필요 없습니다. 이제 DB와 JWT를 이용해 App에서 자체적으로 회원 관리를 진행합니다.
#4 Spring Boot와 MySQL과 JWT
① Spring Boot는 JWT 형식의 Access Token과 Refresh Token 생성
Spring Boot의 라이브러리에 존재하는 Jwts를 통해 Access Token과 Refresh Token을 생성합니다. 일반적으로 TokenProvider라는 클래스를 따로 만들어 JWT 생성 로직을 관리합니다. JWT에는 일반적으로 사용자의 고유값, 생성일, 만료일, 서명 키가 들어갑니다.
JWT Secret Key?
JSON Web Token을 생성할 때 서명을 추가하고, 이를 검증하는 데 사용되는 비밀 키입니다. 토큰이 본인의 애플리케이션에서 발급된 것임을 확인하고, 토큰의 위변조를 방지하는 중요한 역할을 합니다.
② Access Token과 Refresh Token 반환
TokenProvider에서 생성한 Access Token과 Refresh Token을 Spring Boot에게 반환합니다.
③ Refresh Token을 MySQL에 저장
DB에서 사용자를 다시 조회해서 Refresh Token을 저장해줍니다. String 값으로 저장된 Refresh Token은 추후 Access Token 만료 시 재발급을 위해 사용됩니다.
Access Token은 왜 저장하지 않을까?
대부분의 API 요청 시 Access Token을 기반으로 회원을 인증합니다. 그런데 Access Token이 DB에 저장된다면 보안상으로 위험하고, API 요청이 발생할 때마다 DB에서 조회하는 구조는 서버의 성능 면에서도 그리 좋지 않습니다. 따라서 서론에서 말했듯이 Access Token은 클라이언트 측에서 로컬 스토리지에 저장해 단기적으로만 회원을 인증하는 데 사용하고, DB에 저장된 Refresh Token으로 Access Token을 갱신하는 구조를 가집니다.
#5 Spring Boot와 App
① Access Token은 App의 Local Storage에 저장
앞서 말했듯이 Access Token은 보안과 서버의 부담을 줄여주기 위해 Local Storage에 저장합니다. 정확하게 말하면 Access Token을 Body에 담아서 App에게 전달하면 App이 Local Storage에 저장합니다.
② Refresh Token은 App의 Cookie에 저장
Refresh Token은 Local Storage와 다르게 Spring Boot에서 직접 Cookie에 저장해야합니다. Spring Boot에서 Cookie를 생성할 때 HttpOnly 속성을 true로 설정하면 클라이언트 측에서 자바스크립트를 통한 Cookie 접근을 제한할 수 있습니다. 따라서 XSS 공격을 보호해줄 수 있고 자바스크립트로 접근할 수 없게 된 클라이언트는 Cookie에 저장된 Refresh Token에 접근할 수 없습니다.
자바스크립트로 Cookie에 접근 못하는 것뿐이지 개발자 도구로 Cookie 조회는 가능합니다.
Access Token 만료로 인한 갱신 요청 - Refresh Token 유효
사용자가 Header에 Access Token을 담아서 API 요청을 했는데 서버 측에서 Access Token의 만료 기간이 지났다고 판단해 INVALID_ACCESS_TOKEN 응답을 반환합니다. 이때 클라이언트(App)는 Cookie를 Header에 담아서 Access Token 갱신을 요청할 수 있습니다.
흐름은 아래와 같습니다.
#1 User와 App과 Spring Boot
① User가 App에서 데이터 조회 요청(예시)
사용자는 마이페이지 등의 데이터를 조회하기 위해 App에게 요청하 수 있습니다.
② App은 Header에 Access Token을 담아서 Spring Boot에게 특정 API를 호출
App은 사용자가 원하는 데이터를 보여주기 위해 Spring Boot에게 API를 호출합니다. 이때, Local Storage에 저장된 Access Token을 요청의 Header에 담습니다.
③ Spring Boot에서 토큰의 유효성 검사 후 Access Token이 만료 되었다는 응답
지금은 Access Token이 만료된 경우를 보고 있기 때문에 Spring Boot에서 Access Token의 유효성을 검사한다면 Access Token이 만료되었다는 응답을 반환하게 됩니다.
Access Token의 유효성 검사는 백엔드에서?
Access Token은 Local Storage에 저장되어 있기 때문에 클라이언트에서 자바스크립트로 접근이 가능합니다. 따라서 일반적으로 클라이언트 측에서 먼저 Access Token에 대한 유효성 검사를 진행한 뒤 Spring Boot에 API 호출하는 것이 바람직합니다. 위 그림에선 단순히 흐름을 보여주기 위해 백엔드에서만 유효성 검사를 했습니다.
#2 App과 Spring Boot와 MySQL
① App은 Cookie를 Header에 포함시켜 백엔드 서버에 Access Token 재발급 요청
Access Token을 재발급하기 위해서는 Refresh Token이 저장된 Cookie를 백엔드 서버에게 보내서 Access Token 재발급을 요청해야합니다. 클라이언트에서 자바스크립트 코드로 Cookie에 접근할 수 없기 때문에 Header에 Cookie를 포함한다는 정보를 담습니다.
App에서 Header에 Cookie를 담아서 요청하기
Axios를 사용하는 경우엔 요청의 header에서 withCredentials를 true로 설정해주면 됩니다. 그리고 fetch를 사용하는 경우엔 credentials를 include로 설정해주면 자동으로 요청의 Header에 Cookie가 담겨 백엔드 서버로 전달됩니다.
② Spring Boot에서 Refresh Token에 저장된 정보를 기반으로 MySQL에서 조회
Refresh Token을 생성할 때 사용자의 email 정보를 고유 아이디 용도로 같이 담아뒀습니다. Cookie에서 email을 추출한 뒤 email 정보를 통해 DB에서 해당 사용자를 찾습니다.
③ MySQL에서 Refresh Token을 가진 사용자 반환
사용자를 찾았다면 Spring Boot에 해당 사용자 객체를 반환합니다.
#3 Spring Boot와 JWT와 App
① JWT 형식의 새로운 Access Token 생성
Spring Boot는 사용자 정보를 바탕으로 다시 TokenProvider에게 Access Token 생성을 맡깁니다.
② Spring Boot에게 Access Token 반환
TokenProvide는 성공적으로 Access Token을 생성한 뒤에 Spring Boot에 전달합니다.
③ Access Token을 App에게 전달해서 Local Storage에 저장
Spring Boot는 해당 Access Token을 App에게 전달하면 클라이언트 측에서 Local Storage에 저장합니다.
Access Token 만료로 인한 갱신 요청 - Refresh Token 만료
Access Token이 만료되어서 Refresh Token으로 갱신을 요청 하다보면 Refresh Token이 만료되는 순간도 반드시 오게 됩니다. 이때 백엔드 서버에서 Cookie가 유효하지 않다(=Refresh Token이 유효하지 않다)는 응답인 INVALID_REFRESH_TOKEN을 반환합니다. 해당 응답을 받은 App은 사용자를 최초 로그인과 동일한 구글 로그인 화면으로 리디렉션 시킵니다. 그리고 최초로 로그인했던 당시처럼 로그인에 성공한 사용자는 똑같은 과정을 거쳐 새로운 Access Token과 Refresh Token을 발급 받습니다.
처음 흐름은 직전 #2의 1번까지 동일합니다. 문제는 #2의 2번에서 Spring Boot 내부에서 Refresh Token의 유효성을 검사하는 로직에서 발생합니다. 이후의 흐름은 아래와 같습니다.
#1 Spring Boot와 App과 User
① Spring Boot는 내부 로직에서 Cookie 자체의 유효성 검사
Cookie에 Refresh Token을 담아 저장할 때 Cookie 자체에도 만료 시간을 지정해야 합니다. 이때 저는 개발의 편의를 위해 굳이 Cookie와 Refresh Token을 나누지 않고 동일시해 만료 시간을 똑같이 적용했습니다. 따라서 App에서 Header에 Cookie를 담아 API를 요청하고 Spring Boot에서 우선 Cookie의 유효성 검사를 했을 때 Cookie가 유효한지 아닌지를 판단했습니다. 여기서 Refresh Token이 유효한 경우와 그렇지 않은 경우로 나뉘게 된 것입니다.
Cookie는 만료 시간이 지나면 브라우저에서 자동으로 삭제됩니다.
② Spring Boot는 App에게 Refresh Token이 만료되었다는 응답
앞서 언급했듯이 Cookie가 만료 되었다는 것은 Refresh Token이 만료되었다는 것과 같습니다. 따라서 App에게 INVALID_REFRESH_TOKEN이라는 응답을 반환합니다.
③ App은 User를 로그인 페이지로 Redirection해서 최초 Login Flow 유도
INVALID_REFRESH_TOKEN 응답을 받은 App은 해당 사용자를 Google 로그인 페이지로 Redirection해서 다시 로그인을 유도합니다. 다시 구글 로그인을 성공한 사용자는 처음과 동일한 Login Flow를 통해 새로운 Access Token과 Refresh Token을 받게 되고, Refresh Token은 DB의 사용자 테이블에 업데이트 되고, Access Token은 Local Storage에 새로 저장됩니다.
Login Flow 한 눈에 보기
추가 예정
.
.
.
마치며
지금껏 작성했던 글 중에 쉬우면서 가장 어려운 주제였고, 가장 시간을 많이 투자한 것 같습니다. 저 또한 이 소셜 로그인 흐름을 배우는 데 어려움이 많았습니다. 다 배웠다고 생각할때마다 계속 새로운 궁금증이 생겨서 전부 알아보고 이해하느라 시간이 많이 걸린 것 같습니다.
제가 어렵게 이 소셜 로그인 Flow를 이해하며 느낀 희열을 이 글을 읽는 여러분들도 느끼셨으면 하는 바람으로 최대한 자세히. 그리고 최대한 쉽게 이미지를 만들어 첨부했습니다. 처음부터 차근차근 읽어보시면 Google OAuth 2.0과 JWT를 이용한 회원 관리 방식이 충분히 이해 될 것이라고 생각합니다.
한 번 이해해두면 다른 소셜 로그인을 구현할때도 큰 어려움 없이 진행할 수 있어서 이번 기회에 꼭 제대로 공부해보셨으면 좋겠습니다. 피드백이나 궁금한 점은 언제나 환영합니다! 😊