로그인 기능이 없는 서비스는 거의 없습니다. 특히 프로젝트를 진행하게 되면 반복적으로 로그인 기능을 구현하게 되는데, 이번 프로젝트를 진행하며 고민한 것들을 정리해봤습니다.
대표적인 로그인 방식은 stateful / stateless 두 가지로 나뉩니다.
서버에서 사용자의 인증 상태를 직접 기억하고 있으면 stateful 하다고 하고 일반적으로 세션
방식이 해당됩니다.
반면, 서버에서 사용자의 인증 상태를 기억하지 않으면 stateless 하다고 하고 일반적으로 토큰
기반 인증이 사용됩니다.
우선 세션 방식이 왜 생기게 됐는지부터 알아봅시다.
이를 이해하기 위해선 HTTP의 가장 중요한 특징인 비연결성과 비상태성을 알아야 합니다.
HTTP를 잘모르시는 분들은 HTTP는 왜 0.9부터 시작할까 이 글을 읽어보세요.
쉽게 설명하면 요청과 응답이 오고가면 연결은 끊긴다
, 서버는 이전 요청 정보를 기억하지 못한다
입니다.
예를 들어, 사용자가 1번 게시물 조회 요청을 보내고, 서버에서 응답을 했다고 합시다.
이후 사용자가 "이전 요청 기준 다음 게시물 정보 조회" 요청을 보내더라도 HTTP는 상태를 저장하지 않기 때문에 서버는 적절한 응답을 할 수 없게 됩니다.
그렇기 때문에 사용자가 로그인 했는지, 어떤 게시물을 읽었는지 같은 사용자별 상태를 유지할 수 없습니다.
이를 해결하기 위해 등장한 것이 세션입니다.
서버는 사용자가 로그인하는 등의 특정 행동을 했을 때, 해당 사용자에 대한 정보를 서버에 저장하고,
이를 구분하기 위한 고유한 식별자(SID, Session ID)를 생성해 클라이언트에게 전달합니다.
이후 클라이언트는 요청마다 이 SID를 함께 보내고, 서버는 이를 통해 누구인지 식별하고 이전 상태를 기억할 수 있게 됩니다.
보통 세션은 쿠키
에 보관하는데 쿠키가 무엇인지 알아봅시다.
쿠키는 넷스케이프의 직원이었던 루 몬툴리(louis j montulli ii)에 의해 만들어졌습니다.
당시 MCI라는 미국의 통신 회사와 전자 상거래 프로그램을 개발하면서 장바구니 같은 정보를 어딘가에 저장할 필요가 있었습니다.
각 고객의 정보들을 서버에 저장을 하게 되면 서버에 부하 및 비용이 발생하게 되어서 클라이언트에 저장할 수 있는 방법을 고안했고, 그것이 쿠키입니다.
쿠키의 어원이 정확히 밝혀지지는 않았지만, 과거 유닉스 개발자들이 수신 후 그대로 전송하는 데이터 블록을 매직 쿠키로 불렀고 여기서 유래됐다는 게 유력합니다.
탄생 배경을 알면 특징을 알 수 있죠
그래서 쿠키는 클라이언트에서 관리된다는 특징이 있습니다.
쿠키는 HTTP 요청 시마다 자동으로 요청 헤더에 포함되어 전송되므로, 서버가 세션 ID(SID)를 쿠키에 설정하면 이후 클라이언트의 모든 요청에 해당 SID가 자동으로 포함됩니다.
쿠키에는 Secure, HttpOnly, SameSite
와 같은 보안 옵션을 설정할 수 있어,
클라이언트 측에서 데이터를 저장하는 방식 중에서는 상대적으로 보안성이 높은 편입니다.
클라이언트에 저장할 수 있는 보관소는 뭐가 있을까요?
항목 | Cookie | LocalStorage | SessionStorage |
---|---|---|---|
저장 위치 | 브라우저 (클라이언트) | 브라우저 | 브라우저 |
서버 전송 여부 | ✅ 매 요청 시 자동 전송 (Cookie 헤더) | ❌ 직접 전송 필요 | ❌ 직접 전송 필요 |
만료 시점 | 설정한 Expires /Max-Age 또는 세션 종료 | 영구 저장 (삭제 전까지 유지) | 탭/창 닫을 때 삭제 |
저장 용량 | 약 4KB | 약 5~10MB | 약 5~10MB |
보안 옵션 | ✅ Secure , HttpOnly , SameSite 설정 가능 | ❌ 없음 | ❌ 없음 |
브라우저 접근 | JavaScript로 접근 가능 (단, HttpOnly 면 불가) | JavaScript로 접근 가능 | JavaScript로 접근 가능 |
사용 예시 | 세션 ID, 로그인 상태 유지 | 캐시, 비회원 장바구니 | 단기 상태(탭 단위) 저장 |
클라이언트 측 저장 방식에는 대표적으로 Cookie, LocalStorage, SessionStorage가 있습니다.
이 중에서는 쿠키가 상대적으로 보안 옵션이 다양하여 보안성이 높은 편이지만,
클라이언트에 저장되는 모든 데이터는 XSS 등의 공격에 노출될 수 있어 민감한 정보를 저장할 때는 항상 주의가 필요합니다.
"그러면 클라이언트에 저장할 때 쿠키쓰고 모든 보안 옵션 걸어놓으면 되겠네요?"
만약 여러분이 이 글을 읽으시고 "보안 옵션 = 좋은 거", 그냥 다 걸어야겠다 생각하시면 큰일이 날 수도 있습니다.
예를 들어 OAuth 로그인(OIDC) 구현을 한다고 가정해보죠
OAuth를 잘모르시는 분들은 아래 글들을 읽어보세요
OAuth는 어쩌다 탄생했을까, OAuth 2.0~2.1, 소셜 로그인은 OAuth가 아니다?!
OIDC는 위 그림과 같은 방식으로 진행됩니다.
사용자가 로그인 버튼 클릭 → 팝업 또는 리다이렉트로 Kakao 인증 서버로 이동 (accounts.kakao.com)
인증 성공 → 다시 우리 사이트로 리다이렉트 (예: 우리 서비스.com/oauth/callback)
서버는 전달받은 Authorization Code를 이용해 Kakao로 부터 ID Token 발급 및 발리데이션 진행
이후, DB에 등록된 사용자인지 확인 후 엑세스 토큰을 쿠키에 저장함
클라이언트는 로그인 후 메인 페이지로 이동 → 로그인 된 상태로 보여야 함
하지만, 클라이언트가 메인 페이지로 이동을 하게 될 때 쿠키에 SameSite=Lax일 경우, 리다이렉트 직후 요청은 크로스 사이트 컨텍스트라고 판단해서 브라우저가 요청에 쿠키를 안붙이게 됩니다.
서버는 엑세스 토큰이 없으니 사용자를 인식하지 못하고 “로그인 안 됨” 상태가 되게 됩니다.
만약 여러분이 개발자로 입사한 후,
"쿠키 보안 옵션은 좋은 거니까 전부 설정해야지" 하고 무심코 머지하게 된다면,
해당 사이트는 사용자가 로그인해도 로그인되지 않은 것처럼 보이는 심각한 버그가 발생할 수 있습니다.
이제 세션 방식을 쓸 때 왜 쿠키를 사용하는지 이해하셨을 거라 생각하고
다시 세션으로 돌아가겠습니다
세션 방식은 서버가 사용자의 인증 상태를 직접 관리한다는 특징이 있었죠.
소규모 서비스는 단일 서버 메모리에 세션 정보를 저장해도 충분합니다.
하지만 서비스 규모가 커져서 서버가 여러 대로 늘어나고 로드밸런싱 환경이라면 어떨까요?
사용자가 인증을 하더라도, 세션 정보는 처음 접속한 서버 메모리에만 세션 정보가 저장되기 때문에
사용자의 다음 요청이 다른 서버로 가게 된다면 로그인 되지 않은 상태로 인식되게 됩니다.
이러한 문제를 해결하기 위한 방법은 대표적으로 두 가지가 있습니다:
이 중 두 번째 방식인 토큰 기반 인증을 알아봅시다.
토큰 기반 인증 방식은 세션 방식과 달리, 서버에 사용자 인증 상태를 저장하지 않습니다.
그렇다면 서버는 어떻게 사용자가 로그인한 상태인지 판단할 수 있을까요?
정답은 바로, 토큰 자체에 사용자 정보를 담는 것입니다.
사용자가 로그인하면, 서버는 JWT(JSON Web Token)와 같은 토큰을 생성하고,
이 토큰을 클라이언트가 저장한 뒤, 요청마다 함께 전송하게 됩니다.
서버는 이 토큰의 서명을 검증하고, 내부에 담긴 사용자 정보를 확인하여
사용자를 인증합니다.
이 방식은 인증 상태를 서버가 별도로 보관하지 않기 때문에,
분산된 서버 환경에서도 동일한 JWT만으로 사용자 인증이 가능하다는 장점이 있습니다.
여기서 베어러 토큰(Bearer Token)의 개념을 아시는 분들은 토큰으로 인증을 한다는 게 이상하게 느껴질 수 있습니다.
Bearer는 '소지자', '보유하는 사람'이라는 의미입니다.
즉, Bearer Token은 해당 리소스에 접근할 수 있는 권한을 가진 사람(소지자)에게 부여되는 토큰입니다.
여기서 중요한 포인트는, 이 토큰은 권한만 나타낼 뿐, 실제로 인증된 사용자임을 보장하지는 않는다는 점입니다.
Bearer Token을 가지고 있다고 해서, 그것이 해당 사용자가 실제 로그인하고 인증받은 주체라는 뜻은 아닙니다.
많은 사람들이 소셜 로그인을 OAuth라고 알고 있지만, OAuth는 '인가(Authorization)'를 위한 프로토콜이지 '인증(Authentication)'을 위한 프로토콜은 아닙니다.
즉, OAuth만으로는 로그인(=인증)을 할 수 없으며,
OAuth로 발급받은 액세스 토큰(Access Token)을 사용자 인증 수단으로 사용하는 것은 보안적으로 위험합니다.
왜냐하면, OAuth의 액세스 토큰은 단순한 Bearer Token이기 때문입니다.
그렇다면, 토큰 기반 방식에서 어떻게 사용자를 인증할 수 있을까요?
OIDC를 사용한 소셜 로그인에서는 ID Token으로 사용자를 인증할 수 있습니다.
다만, ID Token이 검증되었다 하더라도 Authorization Code로 OAuth 서버로 부터 받은 엑세스 토큰을 인증용 토큰으로 사용하면 안됩니다.
ID Token 검증 이후 우리 서비스 서버에서 자체 발급한 토큰을 인증용 토큰으로 사용해야 하며, Opaque 토큰이 아닌 JWT와 같이 클레임을 담고 있고, 서버가 서명을 검증할 수 있는 토큰을 사용해야 합니다.
그렇다면, 왜 OAuth 서버에서 발급한 Access Token은 인증 수단으로 쓰면 안 되고,
우리 서버에서 발급한 토큰은 인증 수단으로 사용할 수 있을까요?
JWT에 대해서 알아봅시다.
JWT는 Header, Payload, Signature 세 부분으로 구성된 토큰입니다:
JWT의 가장 큰 장점은 바로 서명(Signature)
을 통해 토큰이 변조되지 않았음을 서버가 검증할 수 있다는 점입니다.
토큰은 클라이언트에 저장되기 때문에 언제든지 탈취되거나 변조될 위험이 존재합니다.
하지만 JWT는 서명을 통해 위변조 여부를 검증할 수 있기 때문에,
서버는 이 토큰을 신뢰하고 사용자 인증에 활용할 수 있습니다.
또한 Payload(페이로드)에 userId
, role
, exp
, iss
등의 정보를 담을 수 있어,
추가적인 데이터 조회 없이도 다양한 인증/인가 로직에 활용이 가능합니다.
즉, 서비스 자체적으로 발행하는 JWT 토큰의 경우 시그니처를 통해 검증을 할 수 있어서 인증에 사용할 수 있는 것입니다.
하지만 또 떠오르는 질문이 있죠
위변조는 시그니처로 검증이 가능하지만, 토큰이 탈취되면 위험한 거 아닌가요?
맞습니다.
토큰은 탈취당하면 어뷰징을 막기 어렵기 때문에 여러 보안적인 요소를 고려해야 합니다.
대표적인 방법을 설명드리겠습니다.
엑세스 토큰의 만료 기한을 15분 정도로 짧게 설정해두면, 해당 토큰이 탈취되어도 최대 15분밖에 어뷰징을 못하게 됩니다.
다만, 이럴 경우 엑세스 토큰이 만료되면 또 다시 로그인을 해야 하므로 UX에 큰 악영향을 끼칩니다.
이 문제를 해결하기 위해 일반적으로 Refresh Token을 함께 사용합니다.
예를 들어, Access Token은 15분마다 만료되도록 설정하고,
Refresh Token은 1주일 정도의 만료 기한을 두면,
사용자는 로그인 한 번만 하면 1주일 동안은 추가 로그인 없이 서비스를 계속 이용할 수 있습니다.
그러면, 리프레시가 탈취되면 오히려 일주일동안 어뷰징이 가능해 더 위험한 거 아닌가요?
그래서 리프레시 토큰에 보안적인 요소도 고려해야 합니다.
엑세스 토큰 A를 발급받을 때 엑세스 토큰 B를 발급할 수 있는 리프레시 토큰 1을 발급받는 방식입니다.
이럴 경우 리프레시 토큰이 탈취되어도 하나의 엑세스 토큰만 발급 받을 수 있기 때문에 과한 어뷰징을 방지할 수 있습니다.
근데 리프레시 토큰 1으로 Access Token B를 발급받으면,
그때 받은 리프레시 토큰 2로 또 Access Token C를 발급받을 수 있잖아요?
그럼 결국 탈취자는 계속 어뷰징할 수 있는 거 아닌가요?
그래서 Refresh Token Rotation(RTR)의 핵심은 바로 리프레시 토큰의 재사용을 감지하고 차단하는 것에 있습니다.
서버는 리프레시 토큰이 사용될 때마다 해당 정보를 기록하고 있다가,
이미 사용된 토큰이 다시 사용되면 → 탈취 시도로 간주하여 해당 세션을 무효화하거나
로그아웃 처리 등의 보안 조치를 수행합니다.
또한, 보안을 강화하기 위해 사용자 IP, User-Agent, Device 정보 등을 토큰에 바인딩하는 토큰 바인딩 기법도 함께 사용됩니다.
이를 통해 정당한 사용자와 탈취된 토큰 사용자를 구분하고, 정상 사용자는 보호하면서 악의적인 요청만 차단할 수 있습니다.
처음에는 간단하게 작성하려고 했는데 글을 작성하다 보니 많은 내용을 작성하게 된 것 같습니다.
로그인을 구현할 때 어떤 고민을 해야하는 지 정리해봤는데 처음 구현하시는 분들에게 많은 도움이 되었으면 좋겠습니다.