<서론>
최근에 테마별 카페 추천 서비스라는 토이 프로젝트를 한 개 진행했다. 해당 서비스에서 사용자가 자신이 마음에 드는 카페에 리뷰를 달거나 북마크를 하거나 자신의 북마크 리스트를 조회하는 등의 작업을 하기 위해서는 회원가입/로그인 API를 구현해야 했다.
<인증과 인가>
로그인 API를 구현하기전에 인증과 인가라는 개념부터 알고 시작해야 했다. 우선 인증은 사용자가 ID, PassWord등을 입력하여 로그인 하는 자신의 신원을 증명하는 행위이다. 그 후, 로그인 된 사용자가 어떤 서비스에서 어떤 자원까지 접근하게 둘지 사용자의 권한을 확인하는 작업은 인가이다.
예를 들어 어떤 사용자가 ID, Password입력을 통해서 인증에 성공했는데 해당 사용자가 ADMIN이라는 권한을 갖고 있는 사용자이면 평범한 USER 역할을 갖고 있는 사용자들 보다는 해당 서비스에서 더 많은 중한 자원에 접근 할 수 있어야 한다. 반대로 평범한 USER 역할을 갖고 있는 사용자가 ID, PassWord입력을 통한 인증에 성공했다고 해서 관리자만이 들어가 볼수 있는 URL에 접근 할 수 있다면 인가처리가 똑바로 되지 않았구나라고 생각할 수 있다.
이런 인증과 인가를 내 서버에서 어떻게 구현 할 수 있을지 생각해 봤다. 만약 어떤 사용자가 내가 만든 서비스에서 마음에 드는 카페를 북마크 하기 요청을 발생시켰다고 쳐보자.
사용자가 북마크를 하기위해서는 우선 사용자 인증을 거치고 북마크를 생성할 권한이 있는지 확인하는 인가처리 후 북마크 생성 요청을 서버에서 처리하게 된다.
그런데 해당 사용자가 갑자기 이 카페가 마음에 안들어져서 북마크 취소하기 요청을 서버로 날리게 된다면 우선 사용자 인증을 거치고 북마크를 취소할 권한이 있는지 확인 후 북마크 삭제 요청이 서버에서 처리된다.
이러한 문제가 발생하는 이유는 내가 만든 서버는 HTTP를 통해서 통신하고 HTTP는 Stateless라는 특성을 가지고 있기 때문이다. HTTP를 사용해서 클라이언트와 서버가 통신하게 되면 서버는 이전에 나와 연결되어 통신을 한 사용자가 누구인지 전혀 기억하지 못한다. 따라서 이런 문제를 해결할 방법이 필요하다.
<쿠키를 활용한 로그인>
이런 문제를 해결하고 로그인을 정말 간단히 구현 할 수 있는 방법에 쿠키를 이용한 로그인 방식이 있다. 쿠키는 서버에서 응답과 함께 사용자 웹 브라우저로 전송해 주는 작은 데이터이다. 전달받은 쿠키는 웹 브라우저의 쿠키 저장소에 잘 저장해 두었다가 매 요청마다 함께 전송된다. 다만 프론트, 백엔드가 분리되어 있는 환경이라면 Cross Site에 쿠키를 전달할 때는 withCredentials: true를 통해 직접 전달해야 한다.
쿠키 저장소는 웹 브라우저에서 f12로 개발자 모드를 열고 애플리케이션 -> 쿠키를 선택해서 위와같이 확인 가능하다.
사용자가 아이디와 비밀번호를 입력하여 로그인에 성공할 경우, 서버는 Response의 Set-Cookie헤더를 이용해서 사용자의 웹 브라우저 쿠키 저장소에 memberId=1 같은 쿠키 값을 저장하게 할 수 있다. 이 때, 서버가 사용자의 웹 브라우저의 쿠키 저장소에 직접 접근 하는 것이 아니라 Set-Cookie를 통해서 쿠키 값을 업데이트 하는 것이다.
그 후, 서버로 어떤 요청을 보낼 경우 쿠키 저장소에 존재하는 memberId=1을 요청과 함께 전송하게 해서 서버는 매번 사용자가 로그인 하지 않아도 memberId=1 을 확인하고 1번 사용자가 보낸 요청이라고 식별이 가능하다.
하지만 이렇게 쿠키만을 사용해서 로그인 하게 되면 사용자가 개발자 모드에 들어가서 memberId=1 → memberId=2로 변경할 경우 서버를 속이고 다른 사용자인것 처럼 행동 할 수 있는 문제가 발생한다.
또한, 쿠키가 서버와의 통신을 위해 돌아다니다가 해커에게 탈취 당할 경우 쿠키에 주민등록 번호나, 신용카드 번호 같은 중요한 정보가 적혀 있었다면 이는 굉장한 보안 사고가 될 수 있다.
이렇게 한번 탈취당한 쿠키는 만료기간이 있는 것도 아니고 서버에서 memberId=1은 해커에게 탈취당했으니 1번 아이디를 블랙리스트 처리하고 해당 사용자의 memberId를 변경해 주지 않는 이상 평생 악성 요청에 이용당할 수도 있다. 이런 보안상의 문제들 때문에 쿠키만을 이용한 로그인은 보통 사용되지 않는다.
<세션 + 쿠키 방식의 로그인>
쿠키만을 이용하는 로그인의 문제를 해결하기 위해서 세션과 쿠키를 함께 사용하는 로그인 방식을 사용할 수 있다.
서버에는 세션 저장소라는 공간이 있다. 해당 공간은 서버의 메모리 안에 존재 할 수도 있고 서버측의 별도의 저장소에 존재 할 수도 있다.
우선 쿠키 로그인때 처럼 사용자가 ID, PassWord를 입력하게 되어 로그인에 성공하면 서버에서는 DB에 존재하는 해당 회원의 정보를 찾아와 서버측의 세션 저장소에 저장하게 된다.
이 때, 아무 의미도 없는 문자열 난수를 Key로 사용할 수 있다. 이 문자열 난수 Key를 Set-Cookie헤더를 통해서 클라이언트에게 보내주게 된다면 사용자의 웹브라우저는 이를 저장하게 된다.
이제 사용자가 자신의 쿠키와 함께 로그인이 필요한 요청을 보내주게 되면 서버는 사용자가 보내온 쿠키의 Key와 세션저장소의 Key가 일치하는지 확인하고 해당 요청을 처리하게 된다.
이런 방식으로 세션과 쿠키를 함께 사용해서 로그인을 처리하게 되면 세션 키 자체는 UUID같은 랜덤 문자열이기 때문에 사용자가 개발자 모드를 사용해서 다른 사람의 쿠키 값을 추측 후 다른 사용자인척 하는 것이 불가능하다. 또한, 쿠키가 탈취 당하더라도 아무 의미도 들어가지 않은 랜덤문자열만을 가지고 알아낼 수 있는 정보는 아무것도 없어지기 때문에 보안 사고에서 안전해 진다.
또한 세션 저장소는 서버측에 존재하기 때문에 아이디가 해킹당했다는 신고를 받거나 해킹당한 아이디에서 이상한 행동이 감지되는 경우 세션저장소에서 해당 사용자의 세션 키만 제거해 버리면 더이상 해커가 탈취한 Key를 가지고 아무런 요청을 할 수 없고 로그아웃 되어버린다.
세션 방식을 사용하면 사용자의 마지막 요청시간을 기준으로 일정시간이 지나면 자동으로 세션 저장소에 저장된 사용자의 키와 정보를 삭제하는 timeout 설정이 가능하다. 즉 쿠키와 다르게 만료기간을 설정할 수 있기 때문에 비교적으로 안전하다.
이런 세션을 이용한 로그인은 서블릿에서 제공하는 HttpSession을 사용하면 쉽게 구현이 가능하다. 하지만 세션방식의 로그인은 추후 서버의 트래픽 증가에 대처하기 위한 Scale-Out 방식의 서버 증설 시 문제가 발생할 수 있다.
<다중서버에서 세션 불일치 문제>
만약 단 한 개의 서버를 통해 서비스를 운영하다가 사용자가 계속 증가하게 되면 서버 컴퓨터의 스펙 자체를 키워주는 Scale-Up이나 서버 가장 앞부분에 로드 밸런서를 두고 그 뒤에 여러대의 서버를 두는 Scale-Out이 필요하다.
트래픽 증가를 Scale-Up으로만 대처하게 되면 서버가 돌아가고 있는 단 한 대의 컴퓨터에 문제가 발생하면 그 즉시 모든 서비스가 마비 될 수 있다. 반면 Scale-Out은 네트워크 복잡도가 올라가는 대신 한 대의 서버 컴퓨터가 마비된다고 해서 서비스 자체가 마비되는 문제는 발생하지 않아 훨씬 안정적이다 .
따라서 웬만하면 Scale-Out으로 늘어나는 트래픽을 대응하는게 좋아보인다. 하지만 이렇게 Scale-Out 방식의 확장을 할 경우 세션 방식의 로그인에서는 여러대의 서버간의 세션 불일치라는 문제가 발생한다.
예를 들어 어떤 사용자가 내가 만든 서비스에 정상적으로 로그인을 하고 자신의 북마크를 삭제하는, 로그인이 먼저 필요한 요청을 보냈다고 해보자.
우선, 사용자의 요청은 로드밸런서에 도착하고 로드 밸런서에서는 내부적으로 해당 요청을 처리할 서버를 선택하는 알고리즘을 돌린 후 서버 A에게 가서 로그인 요청을 처리한다.
그 후, 사용자는 자신의 SessionId 같은 세션 키를 받아서 쿠키로 저장하고 북마크 삭제 요청을 위해서 해당 쿠키를 요청과 함께 보낸다.
그럼 해당 요청은 또 로드밸런서에서 내부적으로 처리할 서버를 선택하는 알고리즘을 돌다가 로그인을 처리했던 서버 A가 아닌 서버 B로 가버릴 수 있다.
이 때, 서버 B의 세션저장소에는 해당 사용자가 쿠키로 보내온 세션 키에 대한 세션이 자신의 저장소에 없는 걸 보고 로그인 되지 않은 사용자라고 판단하고 다시 로그인 부터 하라는 응답을 보내온다. 이런식으로 다중 서버에서 세션 로그인을 사용할 시 세션 불일치 문제가 발생 할 수 있다.
<다중 서버에서 발생하는 세션 불일치 문제 해결 방법>
이런 세션 불일치 문제를 해결하기 위한 여러가지 방법 중 대표적인 3가지 방법을 알아봤다.
우선, 서버 A에서 로그인한 사용자에게 Set-Cookie를 통해 “앞으로 너의 요청을 담당하는 서버는 무조건 서버 A야 " 와 같이 로그인된 특정 사용자의 요청을 담당할 서버정보를 미리 기록해두는 Sticky Session 방식을 사용할 수 있다.
하지만 이 방식은 각각의 로그인 한 사용자가 한 서버에 무조건 고정되기 때문에 서버를 여러대를 두고 트래픽을 분산 시키려는 Scale-Out의 목적에 부합하지 않다. 또한, 특정 사용자를 담당하는 서버가 다운 되기라도 하면 사용자는 담당 서버의 세션저장소에 있는 모든 정보를 잃게 되어 다시 로그인을 진행해야 한다.
두번째 방식은 사용자가 어떤 서버에서 로그인 하게 되면 해당 서버에 세션을 저장하고 그 세션을 다른 모든 서버에 복사해서 저장하게 하는 Session Clustering 방식이다.
하지만 이 방식을 사용하게 되면 로그인 요청을 처리하면서 모든 서버끼리 통신하여 세션 저장소의 상황을 동기화 해줘야 하기 때문에 시간도 오래걸리고 세션저장소의 저장공간에도 많은 손해를 보게 된다.
또한 서버간 세션 복사가 아직 진행되고 있는 도중에 로그인이 먼저 필요한 요청이 발생하여 아직 세션 복사가 덜 된 서버로 흘러가기라도 하면 문제가 발생할 수 있다.
마지막으로 서버측에서 별도로 각각의 서버 외부에 세션저장소를 두고 모든 서버가 이를 공유해서 사용하는 방식인 Session Storage 방식이 있다.
이 방식에서는 별도의 Session Storage역할을 할 서버가 한 개 더 필요한데 이 저장소를 MySQL같은 RDB로 만들 수도 있고 서버의 In-Memory에서 작동하는 Redis 같은 DB를 사용해서 만들 수도 있다.
MySQL같은 DB를 사용해서 해당 저장소를 만들 경우 SSD, HDD가아닌 메모리 안에서 위치하는Redis보다 속도는 느리지만 혹시라도 세션 저장소 서버가 다운되더라도 세션들이 휘발 되지는 않는다. 반면 Redis를 사용할 경우 MySQL보다 성능은 좋지만 서버가 다운될 경우 모든 세션이 삭제 될 수 있다.
하지만 세션 저장소 서버가 다운 될 일은 흔하지도 않고 다운 되더라도 사용자들이 모두 다시 로그인만 해 준다면 복구가 가능한 문제이기도 하다. 따라서 보통은 Redis를 세션 저장소로 많이 사용한다고 한다.
또한 애초에 세션 저장소를 단 한 개만 두고 운영하게 되면 실제 백엔드 서버는 멀쩡한데 세션 저장소 서버만 다운 되었다고 로그인과 관련된 모든 서비스가 마비될 수 있기 때문에 보통은 세션 저장소 서버와 세션 저장소를 복사한 예비 서버 같이 대안을 두고 운영된다고 한다.
<토큰방식의 로그인>
토큰 로그인은 사용자가 로그인을 하게 되면 사용자를 식별 할 수 있는 최소한의 정보와 토큰의 만료기간 등을 기록해 사용자에게 넘겨주고 사용자가 보내온 토큰을 통해서 인증과 인가를 구현하는 방식이다.
해당 방식에서는 어떤 서버에서 로그인을 했든 클라이언트에서 보내온 토큰을 확인하여 인증과 인가를 진행하기 때문에 다중 서버에서 굉장히 편리하게 사용할 수 있다. 즉, 서버의 Scale-Out에서 세션 방식의 로그인 보다 유리하고 서버측에서 별도로 세션 저장소를 두지 않기 때문에 저장공간도 아낄 수가 있다.
이런 토큰 방식의 로그인은 특히나 사용자가 아주 많은 서비스에 유리하다고 한다. 다만, 세션 로그인과 다르게 서버에서 사용자를 제어하기 힘들다. 예를 들어 같은 아이디로 동시에 로그인하는 사용자 수를 제어하거나 악성 행동이 감지되었을 경우 강제 로그아웃 시키기 등이 어려워진다.
또한 토큰방식의 로그인에서는 만약 토큰을 탈취 당하게 된다면 토큰의 만료기간 까지 답이없는 상황이 발생할 수 있기 때문에 토큰 탈취에 잘 대응해 줘야 한다.
<Json Web Token을 활용한 로그인>
토큰 방식의 로그인에서 중요한 점은 사용자의 로그인 시도시 어떤 방식으로 토큰을 발행하고 토큰에 어떤 방식으로 정보를 기록하고 주고 받을지 등을 정하는것이다. 보통 이런 토큰방식의 로그인에는 Json Web Token, 줄여서 JWT라는 토큰이 많이 사용된다.
실제 JWT는 위와 같이 생겼다. 왼쪽의 eyJhb...로 인코딩된 문자열이 JWT이다. 이를 디코딩 해보면 Json으로 이루어진 토큰의 정보가 표시되어 있다.
JWT를 조금더 자세히 살펴보면 크게 Header, Payload, Signature 3가지 부분으로 구성되어 있다. 조금 더 쉽게 얘기하면 해당 토큰에 대한 메타 정보, 실제 내용, 서명 으로 이루어져 있다.
우선 가장 와닿는 실제내용, Payload부터 살펴보면 여러개의 Key : Value 형식으로 데이터가 들어있다. 이 때, 각각의 Key : Value들을 Claim이라고 부른다.
헤더에는 보통 해당 토큰이 액세스 토큰인지, 리프레시를 위한 토큰인지에 대한 타입 정보와 토큰 발급에 사용된 암호 알고리즘을 나타내는 정보가 들어있다.
토큰이 탈취당할 경우를 대비해서 유효기간이 아주 짧은 실제 인증, 인가용 액세스 토큰과 액세스 토큰이 만료될 경우 사용자의 재 로그인 없이 재발급을 도와줄 리프레시 토큰으로 토큰 타입을 나눌 수 있다.
또한, JWT는 특정한 암호 알고리즘을 사용하는 토큰이 아니다. 사용자가 원하는 방식의 암호 알고리즘을 넣으면 그 방식에 맞게 토큰을 암호화 해준다.
Signature, 서명을 통해서 Payload에 있는 Claim들이 유효한지 확인 할수 있다.
이렇게 만들어진 JSON 형식의 토큰을 JSON 그대로 사용자에게 전달하는 것이 아니라 Base 64 방식으로 인코딩 해서위와 같이 문자열 형식으로 전달 하게 된다. 여기서 빨간 부분은 Header에 해당하는 Json 데이터를 인코딩 한 것이고 보라색 부분은 Payload, 파란색 부분은 Signature에 해당한다. 다만, 실제로는 저렇게 색을 구분해서 전달 할 수는 없기 때문에 . 을 통해서 구분한다.
JWT에서 핵심은 Signature를 통해서 Payload의 Claim들이 유효한지 검증 하는 방법이다. 우선, 각각의 서버는 사용자가 로그인을 성공했을 때 JWT를 발급해 주기 위한 고유의 Secret Key를 가지고 있어야 한다.
Secret Key라는 것이 존재하지 않는다면 암호화 알고리즘이 동일한 모든 서비스의 JWT를 공유해서 사용할 수 있을 것이다. 이런 Secret Key가 노출될 경우 JWT를 통한 로그인 자체가 무너질 수 있기 때문에 절대 외부로 이 값을 노출 해서는 안되고 추측이 불가능한 아무 의미를 가지고 있지 않은 아주 긴 랜덤 문자열을 사용하는 것이 좋아 보인다.
서버에서는 사용자가 로그인을 요청하면 JWT를 발급하기 위한 정보를 Payload에 담아주고 서버에서 갖고 있는 SecretKey와 함께 서명 생성기에 넣어버린다. 그럼 이 두 가지 정보를 바탕으로 Signature가 생성된다. 이제 서버는 Header, Payload, Signature를 Base 64로 인코딩해서 esansajkgj... 같은 문자열로 클라이언트에게넘겨 준 후, SecretKey를 제외한 모든 것을 잊어버린다.
어떤 사용자가 로그인된 회원만 할 수있는 요청과 JWT를 함께 보내온다면 해당 토큰에서 Payload를 추출해 서버의 SecretKey를 이용해서 Signature를 만들어 본다.
만약 사용자가 보내온 JWT를 통해 만든 서명과 JWT에 명시되어있는 서명이 같다면 서버에서 발급해준 토큰임을 확신 할 수 있게 된다.
이런 JWT에는 Access Token과 Refresh Token이라는 개념이 있다. Access Token은 위에서 본 것처럼 실제로 인증과 인가를 위해서 사용하는 토큰이고 Refresh Token은 Access Token의 유효기간이 만료 되었을 때, 사용자가 직접 다시 로그인 하지 않아도 토큰을 갱신 할 수 있게 도와주는 토큰이다.
이렇게 두 가지의 토큰을 같이 사용하는 이유는 JWT가 탈취당할 경우를 대비하기 위해서 이다. 실제 인증과 인가에 필요한 Access Token은 네트워크 상에서 매우 자주 돌아다니기 때문에 외부로 노출 될 가능성도 높다. 따라서 Access Token 자체의 유효기간을 매우 짧게 설정해서 토큰이 탈취당하는 상황을 어느정도 대비 할 수 있다. 토큰이 탈취 당해도 얼마 안가서 토큰이 무효해 지기 때문이다.
예를 들어 Access Token, Refresh Token 같은 토큰 탈취에 대한 대비 없이 단 한개의 토큰만을 사용해서 토큰 로그인을 구현 했다고 해보자. 이 때, 사용자의 토큰이 악성 사용자에게 탈취 당하게 되면 탈취된 토큰의 만료 시간이 끝나기 전까지 해 줄 수 있는 게 거의 없다.
그렇다고 토큰이 탈취당하는 문제를 고려해서 토큰의 유효기간을 아주 짧게 설정한다면 효과는 있겠지만 사용자에게 몇 십분 단위로 계속 ID, Password를 입력해서 다시 로그인 후 토큰을 발급받게 만들어야 한다. 이는 굉장히 부정적인 사용자 경험이 될 것이다.
이를 방지하기 위해서 실제 인증과 인가에 필요한 Access Token과 사용자가 직접 로그인을 다시 하지 않아도 만료된 Access Token을 갱신해 주기 위해서 사용하는 Refresh Token을 분리해둔다. 이 때, Access Token의 유효기간은 매우 짧게, Refresh Token의 유효기간은 길게 설정해 둔다.
이제 사용자의 Access Token이 악성 사용자에게 탈취 당하더라도 Access Token은 유효기간이 짧은 토큰이기 때문에 악성 유저가 얼마 사용하지 못하고 만료되어 버린다.
Access Token이 만료되면 정상적인 사용자의 경우 Refresh Token을 서버로 전송해서 자신의 Access Token을 재발급 받고 이를 사용해서 원래 시도하고 있던 요청을 계속해 나갈 수 있다. 만약 Refresh Token도 만료 되었다면 사용자가 직접 로그인을 해서 두 토큰들을 갱신하면 된다.
하지만 악성 사용자는 Access Token이 만료 되어도 이를 갱신할 Refresh Token을 갖고 있지 않기 때문에 더 이상 할 수 있는 것이 없어진다.
여기서 Access Token의 경우는 네트워크 상에서 인증과 인가가 필요한 모든 요청과 함께 아주 많이 돌아다니게 되고 프론트와 백엔드가 분리된 서비스에서 Refresh Token은 Access Token이 만료된 경우에만 프론트 쪽의 withCredentiol = true 설정을 통해 웹앱서버와의 연결에서 돌아다니게 되어 외부로의 노출이 상대적으로 적다. 하지만 Refresh Token역시 탈취 당할 수 있다.
그래서 Access Token이 만료된 경우 Refresh Token을 서버로 전송해 Access Token을 재발급 하고 Access Token 재발급에 사용된 Refresh Token을 블랙리스트 처리한 후, Refresh Token 또한 새로 발급해서 사용자에게 전달해주는 Refresh Token Rotate라는 방식을 사용한다.
이렇게 되면 Refresh Token이 탈취 당하더라도 Access Token이 갱신 될 때마다 Refresh Token 또한 계속 변경 되고 이전의 Refresh Token은 사용할 수 없게 처리 되기 때문에 Refresh Token 탈취를 어느정도 방어 할 수 있게 된다.
이 때, Refresh Token은 만료기간이 긴 토큰이다. 사용자의 웹브라우저 쿠키 저장소에 저장된 Refresh Token이 Access Token 재발급을 위해서 백엔드 서버에 전달되면 Refresh Token도 갱신되고 Set-Cookie를 통해서 사용자 웹 브라우저 쿠키 저장소의 Refresh Token의 값이 업데이트 된다.
하지만 여기서 업데이트 전 기존의 Refresh Token은 아직 만료 기간이 다 지나지 않았기 때문에 유효한 상태이다. 즉, 사용자의 웹브라우저 쿠키 저장소에서만 제거 되었을 뿐이지 누군가 중간에서 해당 토큰을 탈취 했다면 유효한 Refresh Token이 여러개가 되어 버리는 문제가 발생한다.
따라서 새로 발급한 Refresh Token을 제외하고 Access Token 재발급에 사용된 기존의 Refresh Token을 블랙리스트 처리하지 않으면 곤란하다. 이를 구현하기 위해서 가장 최근에 발급된 Refresh Token들을 기록해 두고 클라이언트가 Refresh Token을 전달해 왔을 때 해당 Refresh Token이 새로 발급된 Refresh Token이 맞는지 검증해야 할 것이다.
다만, 한 아이디로 여러명이 로그인 할 수도 있고, 한 명의 사용자가 여러 기기로 동시에 로그인을 할 수도 있는 등의 여러가지 상황이 존재 할 것이기 때문에 상황에 맞게 잘 생각해서 구현해 줘야 할 것 같다.
<세션 VS 토큰, 어떤 것을 선택할 것인가>
이렇게 로그인을 구현하기 전에 어떤 방식의 로그인을 구현할 지 생각해 봤다. 결론 부터 말하자면 나는 JWT를 활용한 토큰 방식의 로그인을 구현해 볼 생각이다.
우선 그냥 JWT를 이용한 로그인을 한 번 쯤은 구현해 보고 싶기도 했고 프로젝트를 실제로 배포할 때 AWS 프리티어 계정을 이용할 것이기 때문에 Session Storage를 이용한 다중 서버 환경에서의 구현은 불가능 할 것 같았다.
또한 내가 만드려는 테마별 카페 추천 서비스에서는 사용자를 직접 제어해야 할 일이 많지 않을 것 같아서 굳이 세션 로그인을 사용해야 할 이유는 없었다. 실무에서는 세션, 토큰 모두를 함께 사용해서 로그인을 구현하기도 한다는 데 이건 어떤 구조를 얘기하는 건지 아직은 모르겠다.
<OAuth>
로그인을 구현하기 전에 또 한가지 중요하게 고민해 봐야할 문제가 있었다. 요즘 시대에는 매일 매일 엄청난 숫자의 새로운 서비스들이 쏟아져 나온다.
그런데 어떤 새로 런칭한 서비스를 고객이 한번 사용해 보려고 하는데 회원가입 부터 하라면서 아이디 입력, 아이디 중복체크, 비밀번호 입력, 비밀번호 재확인, 주소, 나이, 성별 등 개인 정보 입력, 휴대폰 본인인증 등등의 과정을 거치게 하면 사용자가 새로운 서비스를 사용해 보기도 전에 피로도를 느끼고 다 떠나버릴 것 같다.
특히 요즘 대부분의 서비스들은 구글 연동 로그인, 카카오 연동 로그인 등의 소셜 로그인을 통해서 아주 쉽게 시작해 볼 수 있게 개발 되는 것 같다.
그런데 만약 내가 개발한 서비스에서 카카오 연동 로그인을 구현하고 싶다고 해서 카카오에게 "지금 우리 서비스에 사용자가 자신의 카카오 아이디랑 비밀번호를 입력했어! 카카오쪽 DB에 있는 사용자의 아이디, 비밀번호와 일치하는지 확인해줘!" 같은 요청을 카카오에 보내면 이를 처리해 줄까?
절대로 해 줄리가 없다. 카카오 입장에서는 내가 만든 서비스에서 실제로 사용자가 카카오 아이디로 로그인을 시도하는 것인지 내 서비스 서버에서 사용자의 정보 탈취 후 악의적인 요청을 날리는 것인지 알 수도 없고 괜히 이런 무리한 요구에 응해 줬다가 보안사고라도 발생하면 곤란해 질 것이다.
그렇다고 소셜 연동 로그인을 포기 할 수도 없는 노릇이다. 이럴 경우 OAuth라는 프로토콜을 이용해 볼 수 있다. 또한 OAuth를 사용하면 단순히 로그인 API만 사용 할 수 있는 걸 넘어서 해당 서비스사의 여러가지 기능들도 연동 할 수 있다.
예를 들어 내가 만든 서비스에서 카카오 소셜 로그인을 사용할 경우 카카오 맵의 사용자 북마크 리스트 불러오기 같은 기능들도 OAuth를 이용해서 쉽게 만들 수 있다.
이런 편리한 OAuth를 사용하기 위해서는 OAuth의 작동원리를 이해해야 한다. 특히 OAuth의 인증 방식에는 권한 부여 승인 코드 방식, 암묵적 승인 방식, 자원 소유자 자격증명 승인 방식, 클라이언트 자격증명 승인 방식 과 같은 총 4가지 인증 방식이 존재한다. 우선은 처음에 접근해보기 가장 좋은 권한 부여 승인 코드방식, 줄여서 코드 방식을 자세히 알아봤다.
우선 OAuth 로그인을 하기 위해서 크게 사용자, 내가 만든 서비스, OAuth를 제공하는 카카오 같은 대형 서비스가 필요하다.
여기서 카카오 로그인 API를 사용하고 싶은 내가 만든 서비스를 Client, 실제로 카카오 쪽에 저장된 사용자의 개인 정보를 Resource, 사용자를 Resource Owner, 사용자의 정보를 관리하는 서버를 Resource Server, OAuth에 필요한 인증 과정을 담당하는 서버를 Authorization Server라고 부른다.
우선 사용자는 더 이상 내가 만든 서비스에서 직접 로그인 하는 것이 아니라 카카오같은 OAuth를 제공하는 곳에 ID, Password를 입력해서 직접 로그인 하게 된다. 그럼 인증서버에서 사용자에게 OAuth 인증 과정에 필요한 Authorization Code라는 임시코드를 넘겨준다.
이제 사용자는 Authorization Server에서 받아온 임시 코드를 Client에게 넘겨준다.
이제 Resource Owner에게 받은 임시 코드를 Client가 그대로 Authorization Server에 넘겨준다. 만약 Authorization Server가 Resource Owner에게 전달해 줬던 임시 코드와 Client가 들고온 임시 코드가 일치하면 Resource Server에 접근할수 있는 Access Token을 Client에게 넘겨준다.
해당 Access Token으로 Client는 Resource Owner가 정보 제공에 동의한 Resource에 접근 가능해졌다.
지금 까지의 과정을 더 자세히 살펴보자면 우선, 사용자는 위와 같은 카카오로 로그인 하기 버튼을 눌러 로그인을 시도 할 것이다.
그럼 내 백엔드 서버에서는 응답에 Location 헤더를 사용해서 사용자의 웹브라우저가 자동으로 카카오 로그인 화면으로 리다이렉트 되게 만들 것이다.
이 때 kauth.kakao.com/ouath/authorize는 실제 카카오 로그인 공식 문서에 있는 카카오 로그인 화면으로 이동하기 위한 url이다.
근데 전체 url에 쿼리 파라미터로 client_id, redirect_uri, response_type이라는 것들이 잔뜩 붙어있다. 우선 client_id는 카카오 개발자 센터에서 직접 등록한 내 서비스(client)의 고유 아이디 이다. response_type은 OAuth 인증 방식 4가지 중에서 어떤 방식을 사용할지에 관한 내용이고 나는 code 방식을 사용해서 인증관련 임시코드를 전달했다. redirect_uri는 카카오 개발자 센터에 로그인 API를 이용하기 위해서 내 서비스를 등록하면서 내가 직접 설정한 값이다. 해당 값은 이 다음 단계에서 사용된다.
정리하자면 사용자가 내 서비스 프론트 화면에서 카카오로 로그인 하기 버튼을 클릭하고 내 서비스의 백엔드에서는 로그인 요청에 대한 응답을 주면서 Location 헤더를 사용해 카카오 로그인 페이지로 사용자 웹 브라우저가 바로 리다이렉트 하게 한다.
그럼 카카오 Authorization Server는 사용자의 웹 브라우저가 리다이렉트 되면서 들고온 client_id, redirect_uri가 내가 로그인 API를 사용하기위해 카카오 개발자 센터에 내 서비스를 등록하면서 만들어준 값들과 일치하는지를 확인한다.
이 값들이 모두 일치하면 Authorization Server는 사용자의 웹 브라우저에 위와 같은 화면을 보여주게 된다. 여기서 사용자가 정상적인 아이디와 비밀번호를 입력해서 로그인을 진행한다.
그럼 카카오 쪽에서는 사용자에게 카카오에 저장된 사용자의 실제 정보들 중 어디까지 내 서비스에 제공하는 걸 허용할 것인지를 선택하게 된다. 이를 Scope를 설정한다고 한다.
여기까지 통과했으면 사용자는 동의하고 계속하기 버튼을 클릭하게 되는데 여기서 한가지 문제가 발생한다. 지금 사용자는 CafeHub라는 내가 만든 서비스를 이용하다가 웹브라우저에서 리다이렉트를 통해 카카오 로그인과 관련된 페이지로 넘어와 버렸다.
여기서 동의하고 계속하기 버튼을 클릭하게 되면 카카오 Authorization Server로 요청이 발생하게 된다. 원래 OAuth 작동 과정을 생각해보면 사용자는 자신이 카카오에 로그인을 성공하면서 전달 받은 임시 코드를 내가만든 CafeHub 서버에 전달해 줘야 한다.
이를 가능하게 하기위해서 redirect_uri가 필요하다. 카카오쪽에서는 사용자가 직접 카카오쪽으로 넘어와서 로그인에 성공하게 되면 다시 CafeHub로 넘어갈 수 있게 사용자의 웹브라우저를 리다이렉트 해버리기 위해 내가 직접 설정해놓은 redirect_uri를 이용한다.
즉, https://cafehub/redirect_uri?임시코드=3 과 같은 Get 요청을 사용자의 웹브라우저가 자동으로 날릴 수 있도록 만든다.
이제 내 서비스 (Client)는 카카오 Authorization Server에 사용자로 부터 받은 임시코드와 자신의 고유한 여러가지 값들을 전송하고 이를 카카오 Authorization Server에서 검증해 본 후 모두 일치하면 Access Token을 발급해 준다.
이제 이 Access Token을 이용해서 Client는 자신이 원하는 기능들을 만들어 주면 된다. 지금은 단순히 설명하기 위해서 Access Token이 발급 된다고 한 것인데 실제로는 JWT와 같은 이유로 Access Token, Refresh Token이 함께 넘어오고 이 외에도 여러가지 정보들이 함께 JSON 형태로 넘어오게 된다.
이는 각각의 OAuth 제공 서비스사의 공식 메뉴얼에서 자세하게 확인해야 한다. 또한, 실제로 카카오 로그인이 위와 같이 작동하는게 아닌 OAuth를 설명하기 위한 예시였다. 실제 카카오 소셜 로그인을 구현하기 위해서는 카카오 로그인 API의 의 공식 메뉴얼을 찾아보고 구현해야 한다.
다만 OAuth의 Authorization code방식과 크게 달라지는게 아니라 지금까지 내용을 기반으로 디테일한 부분들만 조금씩 다른것 같다.
<로그인 관련 로직은 어디에 구현해야 할까?>
CafeHub라는 서비스를 구현하기 위해서 API 명세서를 짜다보니 북마크 등록, 삭제, 내 북마크 전체보기, 리뷰작성하기, 리뷰 삭제하기 등등 로그인이 먼저 필요한 API가 10개 이상 나왔다.
만약 매번 인증, 인가 처리를 위한 로직을 각각의 API 앞에 끼워 넣게 된다면 코드를 짜는 것도 쉽지 않을 것이고 추후 로그인과 관련된 로직 변경이 발생할 시 이를 유지보수, 변경하기도 정말 쉽지 않을 것이다.
따라서 로그인 같은 공통 관심사를 분리해서 관리할 방법이 필요했다. 나는 Spring을 사용하기 때문에 이를 위해서 Filter, Interceptor, AOP 사용을 고민해 볼 수 있었다.
Spring Security의 경우 아직 자바, 스프링, JPA, 기초 CS를 공부하고 있기 때문에 동작원리나 내부 구조를 공부해 보지 않아서 선택지에서 제외했다.
<Filter 사용>
필터는 어떤 Request가 스프링에 도착하기 이전에 위치하고 있다. 또한 체인 형식으로 구성되어 있고 Request의 URL 패턴으로 특정 필터를 지나게 할지 선택 할 수 있다.
필터에 로그인 관련 인증 인가 로직을 구현하기 위해서는 JwtCheckFilter 같은 필터를 직접 만들어서 해당 필터 체인 사이에 꽃아 넣으면 된다. 그 후, Request의 URL이 /auth ~ 로 시작한다면 해당 요청이 내가 만든 필터를 반드시 지나가게 만들고 해당 필터에서 JWT는 있는지 토큰은 유효한지 등등을 체크해 줄 수 있다.
이런 필터는 Filter 인터페이스를 이용하면 쉽게 구현 할 수 있다. Filter 인터페이스를 구현하고 내가 만든 필터를 등록하면 서블릿 컨테이너(톰캣) 가 내가 만든 필터를 싱글톤 객체로 관리하게 된다. 또한 스프링 부트를 사용한다면 FilterRegistrationBean을 사용해서 내가 만든 필터를 쉽게 등록해 줄 수 있다.
<Interceptor 사용>
필터와 다르게 인터셉터는 Spring MVC 내부에서 제공하는 기술이다. 인터셉터 역시 필터처럼 여러가지 인터셉터의 체인으로 구성 되어 있고 URL 패턴으로 특정 인터셉터를 요청이 지나가게 할지 말지 결정해 줄 수 있다.
특히 필터는 어떤 요청이 어떤 필터를 지날지만을 설정하고 나머지는 알아서 구현해야 하는데 인터셉터는 필터와 다르게 어떤 요청이 어떤 인터셉터를 지나가고 어떤 인터셉터를 지나가지 않을지를 모두 쉽게 설정 할 수 있다.
인터셉터는 HandlerInterceptor 인터페이스를 구현하고 WebMvcConfigure 인터페이스의 addInterceptor() 메서드로 등록 할 수 있다. 필터보다 더 정교하게 URL 패턴 매칭이 가능하고 컨트롤러 호출전, 호출 후, 요청완료 이후, 3가지 중 원하는 시점에 인터셉터가 작동하도록 조정도 가능하다. 로그인 로직의 경우 인터셉터에서 컨트롤러가 호출 되기 전인 PreHandle시점에 인터셉터가 작동하도록 해 주면 쉽게 구현이 가능하다.
<스프링 AOP 사용>
AOP를 사용하면 특정 URL 패턴을 기반으로 작동하는 것이 아닌 내가 원하는 특정 메서드 앞에서 로그인 체크 로직이 작동하게 할 수 있다. 인증 체크용 AOP를 만들어서 사용하게 되면 보통 메인 비즈니스 로직이 작동할 Controller나 Service의 특정 메서드 앞에서 작동할 것이다. 즉, 인터셉터 보다도 더 뒤에서 작동 할 것이다.
<Filter, Interceptor, AOP 중 무엇을 사용할까?>
우선 결론부터 말하자면 나는 Filter를 사용해서 인증, 인가를 구현할 생각이다. 일단 로그인이 성공하는 경우 말고 실패하는 경우 Filter에서 더 뒤로 요청이 진행되기 전에 체크해서 바로 걸러버리는 게 다른 방식들 보다 훨씬 효율적이지 않을까 생각이 들었다. 항상 인증, 인가가 정상적으로 이루어지는 요청만 있다면 다른 선택지들도 고려해 봤을 것 같다.
또한, 추후 인증, 인가를 위해서 Spring Security도 배워볼 생각인데 해당 기술은 Filter에서 동작한다고 어렴풋이 들은 것 같다. 그래서 Filter 쪽에 인증 인가를 구현한 경험이 있으면 나중에 Security를 배울 때 더 친숙하지 않을까? 하는 생각이 들었다.
<결론>
총 정리를 하자면, 로그인을 구현하기 위해서 인증과 인가를 알아봤고 세션, 토큰 방식의 로그인 중 JWT를 사용한 로그인을 선택했고, OAuth를 사용하기로 했고, 마지막으로 Filter에 인증, 인가 관련 공통 로직을 구현하기로 결정 했다.
<참고 자료>
스케일 업 vs 스케일 아웃: 최적의 서버 확장 전략 선택하기
서버 확장의 필요성, 스케일 업과 스케일 아웃의 개념 및 장단점, 그리고 최적의 서버 확장 전략 선택 방법에 대해 설명합니다.
f-lab.kr
🌐 세션(Session) 불일치 문제 및 해결 방법
서버 다중화 환경에서의 세션 불일치 단일 서버 환경에서는 session을 통한 로그인을 구현할때 session 불일치 문제를 신경쓸 필요가 없다. 하지만 서비스가 커짐에 따라 한대의 서버로 운영하는것
inpa.tistory.com
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런
김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습
www.inflearn.com
🌐 JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)
Cookie / Session / Token 인증 방식 종류 보통 서버가 클라이언트 인증을 확인하는 방식은 대표적으로 쿠키, 세션, 토큰 3가지 방식이 있다. JWT를 배우기 앞서 우선 쿠키와 세션의 통신 방식을 복습해
inpa.tistory.com
https://www.youtube.com/watch?v=36lpDzQzVXs&t=602s
🌐 Access Token & Refresh Token 원리
Access Token & Refresh Token 이번 포스팅에서는 기본 JWT 방식의 인증(보안) 강화 방식인 Access Token & Refresh Token 인증 방식에 대해 알아보겠다. 먼저 JWT(Json Web Token) 에 대해 잘 모르는 독자들은 다음 포스
inpa.tistory.com
https://www.devyummi.com/page?id=6695183e59f57d23e8a0b6b3
개발자 유미 | 커뮤니티
www.devyummi.com
https://blog.naver.com/mds_datasecurity/222182943542
OAuth 2.0 동작 방식의 이해
OAuth 2.0(Open Authorization 2.0, OAuth2)은 인증을 위한 개방형 표준 프로토콜입니다. 이 프로토...
blog.naver.com
https://www.youtube.com/watch?v=hm2r6LtUbk8&list=PLuHgQVnccGMA4guyznDlykFJh28_R08Q-
🌐 OAuth 2.0 개념 - 그림으로 이해하기 쉽게 설명
OAuth란? 웹 서핑을 하다 보면 Google과 Facebook 등의 외부 소셜 계정을 기반으로 간편히 회원가입 및 로그인할 수 있는 웹 어플리케이션을 쉽게 찾아볼 수 있다. 클릭 한 번으로 간편하게 로그인할
inpa.tistory.com
'etc' 카테고리의 다른 글
[실험] 카카오 연동 로그인 (Spring Security X) 구현 (0) | 2024.12.31 |
---|