[프로젝트 이슈] 사용자 로그인 처리(인증, 인가)
0. 들어가며
API 서버를 설계하면서 가장 먼저 고민한 부분 중 하나는 사용자의 로그인 상태를 어떻게 관리할 것인가? 였습니다. 대표적인 방식으로는 세션 방식과 토큰방식이 있으며 관련 레퍼런스를 살펴보니 각각의 방식은 장단점이 존재했습니다.
세션 방식은 서버가 로그인한 사용자 정보를 서버 측 세션 저장소에 유지하고, 클라이언트는 세션 ID를 쿠키에 담아 요청마다 전달하는 구조입니다. 이 방식은 비교적 구현이 간단하지만, 서버가 상태를 유지(stateful)해야 하므로 확장성과 유연성에 제약이 있었습니다. 특히 서버가 여러 대로 구성되는 분산 환경에서는 세션 동기화 또는 공유 저장소를 구성해야 하므로 복잡도가 증가하는 문제점이 있었습니다.
반면, 토큰 기반 인증은 서버가 사용자 정보를 상태로 관리하지 않고, 클라이언트가 직접 인증 정보를 담은 토큰(JWT 등)을 가지고 요청을 보냅니다. 서버는 이 토큰을 검증하여 사용자를 식별하므로 무상태(stateless) 구조를 유지할 수 있습니다.
이번 프로젝트는 프론트엔드와 백엔드가 분리된 구조로, 백엔드 서버는 하나의 인스턴스에서 여러 Java 애플리케이션을 포트 단위로 나눠 운영하고 있습니다. 다만, 항상 두 개의 프로세스가 동시에 운영되는 것은 아니고, 주로 무중단 배포 중에 새로운 자바 프로세스가 띄워지는 구조입니다. 이로 인해 배포 중에는 요청이 어느 프로세스로 전달될지 예측할 수 없는 상황이 발생합니다. 이처럼 요청이 어느 서버 프로세스로 전달될지 확정할 수 없는 구조에서는, 세션 방식보다는 서버가 상태를 기억하지 않아도 되는 토큰 기반 인증 방식이 더 적합하다고 판단했습니다. 세션 동기화를 따로 처리하지 않아도 되고, 인증 로직이 간결해진다는 점에서도 장점이 있었습니다.
또한, 추후 트래픽 증가나 기능 확장으로 인해 멀티 인스턴스 환경이나 분산 구조로 확장될 가능성도 고려해야 했습니다. 그런 상황에서도 토큰 방식은 서버 간 상태 공유 없이도 인증 처리가 가능하므로, 확장성과 유지보수 측면에서도 유리하다고 판단했습니다. 다만, 토큰 방식 역시 몇 가지 문제점을 가지고 있었는데요. 예를 들어, 액세스 토큰의 유효기간을 짧게 설정하면 사용자 경험이 떨어졌고, 길게 설정하면 보안상 위험이 커질 수 있었습니다. 이러한 문제는 이후에 설명할 리프레시 토큰(Refresh Token) 을 통해 해결하였습니다.
1. JWT 토큰 생성
JWT가 무상태하게 인증할 수 있는 이유는 JWT 자체에 인증에 필요한 정보가 모두 담겨 있어서 서버가 별도의 상태(세션)를 유지하지 않아도 된다는 점입니다. 이를 이해하기 위해 먼저 JWT 구조에 대해 먼저 알아봅시다. JWT는 크게 세 부분으로 나뉩니다.
1. Header: 토큰 타입(JWT)과 해싱 알고리즘(예: HS256)을 명시합니다.
2. Payload: 실제 인증에 필요한 클레임(Claims)을 담고 있습니다. (예:사용자 정보, 만료 시간(exp), 발행 시간(iat) 등이 포함)
3. Signature: Header와 Payload를 합친 후 비밀키로 서명한 값으로, 토큰의 위변조를 방지합니다.
인코딩된 헤더와 페이로드를 .(점)으로 연결하여 header.payload 형태의 문자열을 만듭니다. 이 문자열과 비밀키(Secret Key), 그리고 헤더에 명시된 알고리즘(예: HMAC SHA256)을 사용해 서명을 생성합니다. 이렇게 생성된 서명을 Base64Url 인코딩하여 JWT의 세 번째 부분이 됩니다.
최종적으로 아래와 같은 토큰이 만들어집니다.
{Base64Url(Header)}.
{Base64Url(Payload)}.
{Base64Url(Signature)}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.
KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
이제 JWT가 토큰을 인증하는 방식을 알아봅시다.
2. JWT 토큰 인증
먼저 JWT가 토큰을 인증하는 과정은 다음과 같습니다.
- 사용자가 로그인함 → 서버가 JWT를 발급해서 클라이언트에 전달
- 클라이언트는 이후 모든 요청 시 JWT를 HTTP 헤더(Authorization: Bearer <token>)에 실어서 전송
- 서버는 요청을 받을 때마다 JWT를 검증하여 이 사용자가 유효한지 판단
- 검증이 통과하면, 사용자를 인증된 상태로 처리하고 요청을 정상 수행
토큰 방식을 처음 접하는 분은 헷갈릴 수 있으니 서버 입장에서 단계별로 자세하게 알아보려 합니다.
1. 클라이언트는 로그인 후 발급받은 JWT를 HTTP 요청의 헤더에 담아 서버로 보냅니다.
GET /api/mypage
Authorization: Bearer aaa.bbb.ccc
- 클라이언트는 로그인 시 받은 JWT를 헤더에 담아 API 요청을 보냅니다.
2. 서버가 JWT 문자열을 추출함
- 만약 토큰을 Authorization 헤더에서 Bearer 접두사 형태로 주고 있다면 서버는 이를 제거하고 JWT 문자열만 가져옵니다.
- 위 예시의 토큰(aaa.bbb.ccc)을 받았다면 aaa.bbb.ccc를 가져옵니다.
3. 서버가 서명을 검증함
- aaa.bbb(header + "." + payload)를 다시 꺼냅니다.
- 여기에 서버가 원래 가지고 있던 비밀 키를 이용해서 HMAC-SHA256(aaa.bbb, 비밀키)로 새로 서명을 만들어 봅니다.
- 이 새로 만든 서명과, 클라이언트가 준 ccc (원래 JWT에 있던 서명)를 비교합니다.
- 두 값이 완전히 일치한다면 이 토큰은 위조되지 않았다는 의미입니다.
4. 만료 시간(exp) 체크
- Payload에는 만료 시간, 이 들어 있습니다 (UNIX timestamp).
- 서버는 현재 시간과 비교하여 토큰이 유효한지 확인합니다.
- 참고로 Payload에는 만료 시간 뿐만 아니라 사용자 ID, 권한 등의 인증 관련 정보들을 클레임(Claim) 형태로 담을 수 있습니다.
5. 인증 성공 → 사용자 식별 정보 사용
- 위 검증을 모두 통과하면 이 토큰은 유효한 것입니다.
- 페이로드에는 사용자 ID, 권한(role) 같은 정보가 들어 있으므로, 이를 사용해 인증 객체(SecurityContext 등)를 구성할 수 있습니다.
3. 리프레시 토큰 도입 배경
JWT를 이용한 무상태 인증은 서버가 사용자의 상태를 별도로 저장하지 않아도 된다는 장점이 있어, 서버 확장에 용이하고 구조가 단순해지는 이점이 있습니다. 하지만 그와 동시에 몇 가지 현실적인 보안 및 운영상의 단점도 존재했습니다.
- JWT는 한 번 발급되면 서버가 별도로 이를 무효화하거나 만료를 조정할 수 없기 때문에, 토큰이 탈취되었을 경우 만료되기 전까지는 제어할 방법이 없었습니다.
- 로그아웃이나 권한 변경과 같은 상태 변화가 서버에 저장되지 않기 때문에, 이를 즉시 반영하기 어려웠습니다.
위에서 보다시피 JWT는 클라이언트가 로그인에 성공하면, 서버는 사용자 정보를 담은 JWT를 클라이언트에게 발급합니다. 이후 클라이언트는 이 JWT를 요청마다 Authorization 헤더에 포함시켜 보내고, 서버는 그 토큰만으로 사용자의 인증 여부를 판단합니다. 즉, 서버가 별도 상태를 저장하지 않고, 클라이언트가 JWT를 매 요청마다 보내는 무상태(Stateless) 인증 방식입니다. 이 방식은 서버가 사용자 상태를 기억하지 않아도 되기 때문에 구조가 단순하고 분산 환경에서 유리할 수 있습니다만 이 무상태 구조는 제어할 수 없음 이라는 문제점을 동반하게 됩니다.
JWT에는 만료 시간이 포함되어 있지만, 이 시간은 서버가 직접 제어할 수 없습니다. 토큰이 한 번 발급되면, 만료 시간이 지나기 전까지는 항상 유효하다는 뜻입니다. 그렇기 때문에 만약 이 토큰이 제3자에게 탈취된다면 해당 토큰이 유효한 시간 동안은 누구나 사용할 수 있습니다. 서버는 요청을 보낸 사람이 진짜 사용자인지, 토큰만 복사한 공격자인지 알 수 없기 때문입니다. 또한, 서버는 "이 토큰을 더 이상 사용하지 못하게 하겠다"는 식의 무효화를 할 수도 없습니다.
마찬가지로 서버는 토큰만 검증하고 그 안에 있는 사용자 정보를 그대로 신뢰하기 때문에 서버는 "이 사용자가 지금도 유효한지", "로그아웃 했는지" 같은 상태를 알 수 없습니다. 즉, 사용자가 로그아웃하더라도 JWT는 여전히 클라이언트에 남아 있고 만약 토큰이 탈취되었거나 브라우저에 저장된 상태라면, 재사용이 가능합니다. 따라서 이 토큰을 탈취한 사람이 있다면, 공격자가 아무 제한 없이 로그인된 사용자처럼 모든 API를 호출할 수 있다 라는 말이기도 합니다.
이러한 구조적 한계를 보완하기 위해 Access Token과 Refresh Token을 분리해서 사용하는 전략이 많이 활용됩니다. 저도 Access Token 탈취 시 피해 최소화, 로그아웃 처리 가능하다는 기대 효과를 가지고 이번 프로젝트에서는 Access Token과 함께 Refresh Token을 도입하게 되었습니다.
4. 리프레시 토큰 도입
Refresh Token은 Access Token보다 유효 기간이 길며, 주로 Access Token은 재발급 용도로 사용됩니다. 사용자가 로그인하면 다음과 같은 흐름으로 작동합니다.
- 로그인 성공 시, Access Token과 Refresh Token을 함께 생성합니다.(Access Token은 유효 기간 짧게, Refresh Token은 길게)
- Access Token은 사용자 인증에 바로 사용되고,
- Refresh Token은 서버 측 저장소에 저장되며, 이후 Access Token 재발급 요청 시 검증에 사용됩니다.
Refresh Token을 서버에 저장하고, Access Token은 짧은 수명으로 제한한 이유는 다음의 전략이 가능하기 때문이였습니다.
- 토큰 탈취되더라도 짧은 시간 안에 무효화
- Refresh Token은 서버가 직접 삭제할 수 있으므로, 로그아웃 시 재사용 차단 가능
- 새 Access Token을 발급하려 해도 Refresh Token이 없으면 실패
본 프로젝트에서는 Refresh Token 외에 Redis를 사용할 만한 다른 캐시성 데이터가 없었기 때문에, Redis를 도입하면 오히려 관리할 시스템이 하나 더 늘어나는 상황이었기 때문에 Refresh Token은 RDB에 저장하였습니다. 이를 위해 refresh 라는 테이블을 별도로 구성하고, 사용자의 로그인 아이디와 Refresh Token 값, 만료 시간을 함께 저장하도록 설계해봤습니다.
- 로그인 아이디는 토큰이 어떤 사용자에게 발급되었는지를 로그나 DB에서 사람이 쉽게 파악 가능합니다.
- Refresh Token 값은 실제 인증 재발급 시 비교 및 검증에 사용됩니다.
- 만료 시간은 서버 측에서 토큰의 유효 기간을 관리하고 판단하는 데 사용됩니다.
이번 프로젝트는 프론트엔드가 별도로 존재하지 않고, 순수 API 서버 형태로 운영되고 있습니다. 따라서 토큰을 클라이언트 자바스크립트 영역에서 직접 다루는 웹 환경과 달리, XSS 공격에 의한 토큰 탈취 위험이 상대적으로 낮은 상황입니다. 이에 따라, Access Token과 Refresh Token 모두를 응답의 JSON 바디에 포함하여 전달하는 방식을 선택했습니다.
다만, 웹 기반 프론트엔드 환경에서는 토큰 탈취 위험을 줄이기 위해 HttpOnly 쿠키 사용을 권장하지만, 현재 환경에서는 이러한 보안 위협이 크지 않으므로, 유연성을 고려해 JSON 바디 방식으로 토큰을 전달하는 것을 최적의 선택으로 판단하였습니다.
- 클라이언트는 응답 바디에서 토큰 값을 직접 받아 필요한 저장소(메모리, 파일, 보안 저장소 등)에 저장하고, 이후 API 호출 시 Authorization 헤더에 Access Token을 포함시켜 요청을 보낼 수 있습니다.
그리고 Access Token은 만료 주기가 짧기 때문에, 사용자가 토큰 만료로 인해 인증이 끊기는 상황을 방지하고자 토큰 재발급(reissue) 기능을 구현하였습니다. 이를 위해, 클라이언트가 보유한 Refresh Token을 요청에 포함하여 서버에 전달하면, 서버는 해당 토큰의 유효성을 검증하고 새로운 Access Token을 발급하도록 구성하였습니다.
또한 보안을 강화하기 위해 기존 Refresh Token은 재사용할 수 없도록 DB에서 삭제한 뒤, 새로운 Refresh Token을 함께 발급하여 저장하였습니다. 이 방식을 통해 Refresh Token이 탈취되더라도 반복적으로 사용되는 것을 차단할 수 있으며, 하나의 사용자 세션만 유지되도록 하여 보안성과 함께 불필요한 중복 세션을 방지할 수 있었습니다.
기존에는 Access Token만 사용하던 구조였기 때문에, 서버 측에서 토큰의 유효성을 판단할 수는 있어도 사용자의 로그아웃이나 세션 만료와 같은 상태 변화를 직접적으로 반영하기 어려웠습니다. 하지만 Refresh Token을 서버에 저장하는 구조로 전환하면서, 해당 토큰을 삭제함으로써 사용자의 로그아웃 요청을 처리할 수 있게 되었습니다. 즉, 기존 Access Token만 사용하는 방식에서는 어려웠던 로그아웃 기능을 구현할 수 있게 된 것입니다.
🤔 Refresh Token을 도입하게 되면 기존에 유지해왔던 무상태(stateless) 구조의 장점을 잃게 되는 것은 아닌가?
하지만 Refresh Token을 서버에 저장하는 방식은 무상태 인증의 간결함과 분산 환경에서의 유연함을 유지하면서도, 보안성과 관리 측면에서의 이점을 동시에 확보할 수 있었습니다. Access Token을 통해 서버는 여전히 상태를 저장하지 않고 인증할 수 있었고, Refresh Token은 로그아웃 처리, 재발급 제어, 탈취 대응 등 보안 기능을 수행하는 데 활용되었습니다. 즉, 완전한 무상태 구조는 아니지만, 필요한 만큼의 상태만 저장함으로써, 세션 기반 인증 방식에서 발생할 수 있는 몇 가지 문제들 (예를 들어 배포 중 포트 변경으로 인한 세션 유지 문제나, 특정 서버 프로세스를 제어할 수 없는 상황)을 피할 수 있었습니다. 또한, 이후 서버 인스턴스를 확장할 때도 별도의 세션 동기화 없이 유연하게 대응할 수 있다는 점에서 이 구조의 장점이 있다고 생각합니다.
이번 구현을 통해 인증 로직의 흐름을 처음부터 끝까지 설계하고 직접 다뤄보면서 인증/인가가 어떤 방식으로 이루어지고, 어떤 보안 문제가 발생할 수 있는지 생각해 볼 수 있었습니다. 특히, API 서버 구조에서 토큰을 어디에 저장하고, 어떤 방식으로 전달해야 보안적으로 안전한지에 대한 고민과 선택을 해볼 수 있었던 시간이었던 것 같습니다.
이번 구현을 통해 인증과 인가가 어떤 방식으로 이루어지는지, 그리고 어떤 보안 문제가 발생할 수 있는지를 처음부터 끝까지 직접 생각해볼 수 있었습니다. 특히 API 서버 구조에서 토큰을 어디에 저장하고 어떤 방식으로 전달해야 보안적으로 안전한지에 대해 여러 방법을 고민해보고 선택할 수 있는 기회였습니다.
또한, 무상태 구조의 장점인 간결함과 확장성뿐 아니라, 상태를 일부 저장했을 때 가능한 로그아웃, 토큰 재발급, 탈취 대응 같은 기능들도 함께 고려하게 되었습니다. 무상태 구조는 서버 확장에 유리하지만 사용자의 상태를 서버가 기억하지 않기 때문에 제어가 어렵고, 반대로 상태를 저장하는 구조는 복잡도가 다소 증가하지만 제어 측면에서 유리하다는 사실을 확인할 수 있었습니다.