많은 서비스에 구현된 로그인 기능을 어떻게 구현하면 좋을 지 고민하며 작성하는 글이에요. 또한, 왜 그렇게 하고 싶은가?
를 포함하여 프로젝트 팀원에게 제안하기 위한 글이에요.
많은 서비스에서 제공하는 로그인 기능은 사용자에게 맞춤형 경험을 제공하는 등 다양한 비즈니스 요구사항을 충족시키는 핵심 요소에요. 많은 서비스에 구현되어 있는 만큼 간단한 검색만으로도 다양한 구현 방법이 있다는 사실을 알 수 있어요. 다방면으로 고민하고 비교하며 최선의 선택을 하려고 해요.
간단한 결정이라도 계속되는 고민으로 인해 의식의 흐름대로 진행될 수도 있다는 점 이해해주시면 좋을 것 같아요.
로그인은 중요한 데이터와 리소스를 보호하기 위해 컴퓨터 시스템과 앱에서 사용되는 일반적인 보안 수단이에요.
로그인은 사용자가 특정 시스템(애플리케이션, 웹사이트 등)에 접근할 수 있도록 인증하는 절차라고 말할 수 있어요. 로그인 과정에서 사용자는 일반적으로 사용자 이름(또는 이메일 주소)과 비밀번호 같은 자격 증명(credentials)
을 입력해요. 이 정보를 기반으로 시스템은 사용자가 등록된 사용자임을 확인하고, 해당 사용자가 시스템에 접근할 수 있는 권한을 부여해요.
로그인 과정에서 중요한 요소는 다음과 같아요.
인증 (Authentication)
유저가 누구인지 확인하는 절차, 회원가입하고 로그인 하는 것.
인가 (Authorization)
유저에 대한 권한을 허락하는 것. 출처
백엔드 파트인 제 입장에서 전달받은 프론트엔드 파트의 요구사항에 대해 설명할게요.
서비스가 정말 필요한가?
에 대해 빠르게 알아보기 위해 빠른 개발도 큰 목적 중에 하나였어요. 그래서 백엔드 파트에서는 모두 경험이 있는 JWT 방식을 구현하고 로컬 스토리지에 저장하는 방식으로 구현하려고 했어요. 이러한 결정에 대해 프론트엔드 파트가 전달한 요구사항임을 알립니다!
SSR
환경에서 로컬 스토리지
에 접근할 수 없어요. (NextJS를 사용해요.)로컬 스토리지
에 토큰이 있으면 js에서 접근할 수 있다보니 보안에 취약할 수 있어요.MSA-모노레포 환경
을 고려하고 있어요. 서브 도메인끼리 로컬 스토리지, 세션 스토리지를 공유하지 않아요.우리가 구현하려는 로그인 기능은 여러 가지 비즈니스 및 기술적 요구사항을 충족시켜야 해요. 특히, 함께 작업하는 프론트엔드와 백엔드 사이의 상호작용을 이해해야 해요.
우선 SSR
환경에서 로컬 스토리지
에 접근할 수 없다는 말에 대한 이해가 필요했어요.
[10분 테코톡] 🎨 신세한탄의 CSR&SSR 영상을 통해 기본적인 지식을 습득했어요.
CSR(Client-Side Rendering)
클라이언트 측에서 자바스크립트를 통해 HTML을 동적으로 생성하고 렌더링하는 방식이에요. 초기 로드 시간은 길지만, 페이지 간 이동이 빠르고 사용자 경험이 부드럽다고 해요.SSR(Server-Side Rendering)
서버에서 미리 렌더링된 HTML을 생성하여 클라이언트에 전달하는 방식이에요. 초기 로드 시간이 짧아SEO
에 유리하며, 콘텐츠가 빠르게 표시되지만, 서버에 더 많은 부하가 발생할 수 있어요.
SSR 환경에서는 서버에서 HTML을 렌더링하여 클라이언트에 전달하기 때문에, 클라이언트에서만 접근 가능한 로컬 스토리지에 접근할 수 없어요. 즉, 로그인 토큰을 로컬 스토리지에 저장하고 사용해야 하는 경우, 초기 페이지 렌더링 시 문제가 발생할 수 있다는 것을 의미해요.
사용자 유형에 따라 다른 화면을 제공해야 하는데, 로컬 스토리지에서 토큰을 관리하다 보니 인증 및 인가가 필요한 API 호출이 지연되어 사용성이 저하될 수 있어요.
위 내용을 바탕으로 제가 이해한 내용을 그림으로 표현해봤어요.
상세 흐름메인 페이지에 {사용자 이름}님 어서오세요. 가 있는 경우를 예시로 설정했어요.
1
: 메인 페이지 접근 요청
사용자가 로그인 페이지를 통해 자격 증명을 서버로 전송해요. 서버는 데이터베이스에서 해당 사용자 정보를 조회하고, 제공된 자격 증명이 올바른지 확인해요.
2
: 요청한 페이지의 HTML을 서버에서 렌더링
SSR 방식은 서버에서 HTML을 미리 렌더링해요.
예를 들어, "홍길동님 어서오세요"라는 메시지를 보여주려면, 서버는 클라이언트의 로그인 상태를 확인하고, 사용자 이름을 가져와 HTML에 포함시켜야 해요.
이때, 서버는 클라이언트가 보유하고 있는 로그인 토큰을 사용하여 사용자의 정보를 조회하려고 시도해요.
3
: 서버에서는 클라이언트의 로컬 스토리지 접근 불가
서버가 사용자의 로그인 토큰을 확인하려고 할 때, 클라이언트의 로컬 스토리지에 접근해야 해요. 그러나 로컬 스토리지는 클라이언트(브라우저)에서만 접근할 수 있는 자원이에요.
4
: 일단 그거 빼고 나머지 렌더링 완료 후 전달
서버는 클라이언트의 로컬 스토리지에 접근할 수 없기 때문에, 로그인 토큰을 읽어올 수 없어요. 이는 서버에서 사용자 정보를 가져오지 못하게 만들고, 결과적으로 클라이언트에 완전한 정보를 포함한 HTML을 렌더링할 수 없게 만들어요.
이러한 이유로 서버는 사용자의 로그인 상태를 알지 못한 채, 기본 HTML만 렌더링해서 클라이언트에 전송해요.
5
: 브라우저가 HTML 표시 및 JS 실행
클라이언트(브라우저)가 서버에서 전달받은 HTML을 받아서 화면에 표시해요. 이 시점에서 클라이언트 측 JavaScript가 실행되고, 클라이언트는 이제 window 객체와 localStorage에 접근할 수 있어요.
클라이언트는 로컬 스토리지에서 로그인 토큰을 가져와서, 해당 토큰을 이용해 사용자의 정보를 확인하기 위한 API 요청을 다시 서버로 보내요.
6
: 다시 API 호출
클라이언트는 로컬 스토리지에 저장된 토큰을 기반으로 사용자 정보를 가져오기 위해 API 호출을 수행해요.
API 호출에 대한 응답을 받고 클라이언트는 화면을 갱신하여 "홍길동님 어서오세요"와 같은 메시지가 보이게 해요. 이 과정에서 화면이 한 번 깜빡이거나, 정보 로딩 시간이 지연될 수 있어요. 이는 초기 HTML 렌더링 시 사용자의 정보를 확인하지 못해 발생하는 지연이에요.
CSR (Client-Side Rendering) 작업에 익숙한 경우 서버에서 localStorage에 액세스할 수 없는 경우가 발생할 수 있다. 이는 localStorage가
window
객체에 정의되어 있지 않고 Next.js가 클라이언트 사이드 렌더 전에 서버 사이드 렌더를 수행하기 때문이다. 출처
위 포스팅처럼 window 객체의 존재 유무를 살피고 window 함수에 접근하거나, useEffect를 통해 window 객체에 접근하는 방식을 사용할 수 있을 것 같아요. 하지만 이를 위해 작성하는 불필요하게 반복되는 코드
와 사용성
측면에서 봤을 때 완전 좋은 방식이라고 느껴지진 않았어요. 그래서 로컬 스토리지 저장 대신에 쿠키를 사용하는 방식에 대해 살펴봤어요.
SSR 환경에서 쿠키는 알 수 있어요. 따라서 로그인 토큰을 서버가 클라이언트에 전달할 때, 로컬 스토리지 대신 쿠키
에 저장하는 방식을 사용할 수 있어요. 쿠키는 HTTP 요청 시마다 서버로 자동으로 전송
되므로, 클라이언트에서 직접 접근하지 않아도 인증 상태를 유지할 수 있어요. 이를 통해 SSR에서도 인증 처리를 구현할 수 있을 것 같아요.
클라이언트 측에서 로컬 스토리지에 토큰을 저장하면, JavaScript를 통해 해당 토큰에 접근할 수 있기 때문에, 악의적인 스크립트가 토큰을 탈취할 위험이 있어요.
보안 강화를 위해 토큰을 Secure
, HttpOnly
, SameSite
속성을 설정한 쿠키에 저장하는 방식을 사용해요.
Secure
: HTTPS 연결에서만 쿠키가 전송되도록 해요.HttpOnly
: JavaScript에서 쿠키에 접근할 수 없게 만들어요.SameSite
: CSRF(Cross-Site Request Forgery) 공격을 방지해요. 위와 같은 헤더 속성들을 통해, 클라이언트 측에서의 보안 취약점을 줄일 수 있을 것 같아요.
MSA(Microservices Architecture) 및 모노레포 환경에서는 서브 도메인 간의 로컬 스토리지나 세션 스토리지를 공유할 수 없다고 해요. 이로 인해, 사용자가 서브 도메인을 이동할 때마다 인증 과정이 반복되어야 하는 불편함이 발생한다고 설명했어요. MSA-모노레포에 대한 내용은 프론트엔드 파트에게 간단하게 질문할 예정이에요.
이 문제를 해결하기 위해, 앞에서 본 문제처럼 (서브 도메인 간에 공유할 수 있는) 쿠키
를 사용해 인증 상태를 관리하는 방안을 고려해야 할 것 같아요. 예를 들어, domain=.example.com
과 같은 형식으로 쿠키의 Domain 속성을 설정하면, sub1.example.com
과 sub2.example.com
같은 서브 도메인 간에 쿠키를 공유할 수 있어요. 이를 통해 불필요한 인증 요청을 줄이고, 원활한 사용자 경험을 제공할 수 있을 것 같아요.
현재 프론트엔드 파트의 요구 사항을 분석해보니 응답 본문으로 토큰을 전달하고 이를 로컬 스토리지에 저장하는 방식으로 구현하는 경우 여러 보안 위험과 사용성 저하가 발생할 우려가 있다고 생각해요.
따라서, 프론트엔드 파트의 원활한 SSR
및 MSA-모노레포
환경 구축을 위해 쿠키
방식으로 토큰을 전달하는 방향으로 구현해야 할 것 같아요.
지금까지 로그인 토큰을 전달한다고 생각하며 알아봤어요. 여기서 토큰은 JWT를 말해요.
JWT는 Json Web Token의 약자로 json 객체를 이용해서 토큰 자체의 정보를 저장하고 있는 웹 토큰이에요. RFC-7519
JWT는 당사자 간의 비밀을 제공하기 위해 암호화될 수 있지만,
서명된 토큰에 초점을 맞출 것입니다.
서명된 토큰은 그 안에 포함된 클레임의 무결성을 확인할 수 있는 반면, 암호화된 토큰은 다른 당사자에게 해당 클레임을 숨깁니다. 토큰이 공개/비공개 키 쌍을 사용하여 서명되는 경우, 서명은 또한 개인 키를 보유한 당사자만이 서명한 사람임을 증명합니다. - jwt.io
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
위와 같이 JWT는 암호화되어 매우 복잡하고 사람이 읽을 수 없는 문자열 형태로 구성되어 있어요.
JWT는 모든 팀원이 사용해본 경험이 있기 때문에 자세한 설명 대신 간단한 특징을 알아보고, 사용 시나리오를 설정하여 문제가 될만한 상황을 찾아보려고 해요. JWT에 대해 더 학습이 필요한 경우 공식문서를 참고하면 좋을 것 같아요.
1
: 사용자가 로그인 요청을 보내요.
사용자가 로그인 페이지를 통해 자격 증명을 서버로 전송해요. 서버는 데이터베이스에서 해당 사용자 정보를 조회하고, 제공된 자격 증명이 올바른지 확인해요.
2~4
: 서버가 토큰을 생성해요
로그인 자격 증명이 확인되면, 서버는 해당 사용자 정보를 기반으로 토큰을 생성해요. 이는 수정하거나 삭제할 수 없으며, 서명 알고리즘을 통해 위변조를 확인해요.
5
: 클라이언트에게 토큰
을 전달해요.
서버는 이 토큰을 쿠키나 HTTP 응답 본문을 통해 클라이언트로 전달해요. (AccessToken만 살펴봤어요. RefreshToken은 따로 찾아보시면 더 좋을 것 같아요.)
1~2
: 서버가 토큰을 확인해요.
쿠키나 헤더를 통해 전달받은 AccessToken을 검증해요. 위변조 여부, 만료 기한 등을 확인해요.
만약 토큰이 만료되었다면, 다시 인증을 진행하거나 RefreshToken을 활용하여 AccessToken을 재발급받을 수 있어요.
3~5
: 요청에 따라 서버가 응답해요
토큰이 유효한 경우, 서버는 요청에 따라 적절한 응답을 클라이언트에게 전송해요. 사용자는 추가적인 로그인 과정 없이도 계속해서 애플리케이션을 사용할 수 있어요.
기타
: 로그아웃, 무효화
사용자가 로그아웃을 요청하더라도, 별도의 구현 없이는 AccessToken의 유효성이 사라지지 않아요. 만료 기한이 지나기 전까지는 특정 토큰을 무력화시킬 방법이 없어요. 그래서 로그아웃을 위해 블랙 리스트를 구현하기도 해요.
위변조 방지
: JWT는 서명(Signature)
을 포함하고 있어, 발급된 토큰이 중간에 변조되지 않았음을 확인할 수 있어요.무상태(StateLess) 유지
: JWT는 토큰 자체에 모든 인증 정보를 포함
하고 있기 때문에, 서버 측에서 별도의 세션 저장소나 데이터베이스 조회 없이도 인증이 가능해요.모바일 앱 호환성
: JWT는 클라이언트와 서버 간의 통신이 매우 가벼워 모바일 환경에서도 잘 동작해요. 세션 방식과 달리, 별도의 서버 상태를 유지하지 않기 때문에 모바일 기기에서도 효율적으로 사용할 수 있어요.토큰 크기
: JWT는 서명 및 페이로드를 포함하고 있어, 토큰의 크기가 비교적 커질 수 있어요. 매번 전송되는 경우가 많기 때문에 네트워크 대역폭이 낭비되거나 성능에 영향을 미칠 수 있어요.탈취 위험
: JWT는 클라이언트 측에 저장되기 때문에 탈취될 위험이 있어요. 특히, 만료 기한이 긴 토큰이 탈취되면, 해당 토큰을 악용한 공격이 장기간 발생할 수 있어요. (JWT는 생성과 동시에 수정 및 삭제가 불가능하기 때문에 잘 관리해야 해요.)토큰 무효화 어려움
: JWT는 상태가 없기 때문에, 발급된 토큰을 서버에서 무효화하기 어려워요. 예를 들어, 사용자가 로그아웃하거나 계정 정보를 변경해도, 이미 발급된 JWT는 만료될 때까지 유효해요. 블랙 리스트를 구현하는 방법으로 보완할 수 있지만, 추가적인 구현을 요구해요.AccessToken만 사용하기
AccessToken만 사용할 경우 크게 2가지 경우로 볼 수 있어요.
AccessToken이 평생 만료되지 않기 때문에, 사용자는 로그아웃하지 않는 이상 계속 인증 상태를 유지할 수 있어요. 하지만, AccessToken이 탈취되는 경우 탈취된 AccessToken을 무력화할 수 있는 방법은 존재하지 않기 때문에 매우 위험해요.
AccessToken이 만료되는 경우 사용자는 다시 인증해야 해요. 이러한 경험을 줄이기 위해 AccessToken의 만료 기한을 길게 설정한다면, 보안에 취약해져요(위와 같은 맥락). 반대로 만료 기한을 짧게 가져가는 경우 계속된 재인증 요구로 사용성이 저하돼요.
AccessToken과 RefreshToken을 함께 사용하기
AccessToken의 만료기한은 짧게 설정하고 RefreshToken을 통해 사용성을 올리는 방법이에요.
HTTPS로 암호화를 진행하지만, 혹시 모르니 매 요청마다 RefreshToken을 보낼 필요는 없어요. 어떤 요청의 내용을 알아내더라도 대부분 만료 기한이 짧은 AccessToken만 있을 것을 기대해요. RefreshToken을 모르면 재발급받을 수 없어요.
하지만, 프론트엔드 요구사항에 맞게 백엔드 파트는 쿠키를 통해 토큰을 전송할 예정이에요.
쿠키를 한번 설정하면 만료되기 전까지 모든 HTTP 요청에 쿠키가 함께 전송돼요. 즉, 로그인을 통해 쿠키로 전달된 AccessToken과 RefreshToken이 항상 모든 요청에 함께 전송된다는 말이에요.
항상 모든 토큰이 전송되니 탈취 가능성이 비슷해져요. RefreshToken을 통해 AccessToken을 다시 발급 받을 수 있기 때문에 AccessToken과 RefreshToken을 사용한다고 하더라도 보안 문제를 해결하지 못할 수도 있을 것 같아요.
위와 같이 쿠키와 토큰 방식을 살펴봤지만, 걱정 없는 방법이 떠오르지 않아서 다른 로그인 구현 방식을 찾아봤어요.
세션(Session)
기반 인증은 사용자 로그인 시 서버에서 세션을 생성하고, 클라이언트에게는 세션 ID
를 쿠키에 담아 전송하는 방식이에요. 이후 클라이언트의 모든 요청에는 세션 ID
가 포함되어 전송되며, 서버는 이 세션 ID
를 바탕으로 사용자의 인증 상태와 관련된 데이터를 관리하게 돼요.
1
: 사용자가 로그인 요청을 보내요.
사용자가 로그인 페이지를 통해 자격 증명을 서버로 전송해요. 서버는 데이터베이스에서 해당 사용자 정보를 조회하고, 제공된 자격 증명이 올바른지 확인해요.
2~4
: 서버가 세션을 생성해요
로그인 자격 증명이 확인되면, 서버는 해당 사용자에 대해 세션(Session)을 생성해요. 세션은 서버 메모리, 데이터베이스, 또는 인메모리 저장소(Redis 같은)와 같은 서버 쪽에 저장되는 데이터 구조에요.
세션에는 추가적인 정보(사용자 ID, 세션 생성/만료 시간, 사용자의 권한 등)가 포함될 수 있어요.
5
: 세션 ID를 생성해요
서버는 세션을 생성한 후, 이 세션을 식별할 수 있는 세션 ID
를 생성해요. 세션 ID는 클라이언트와 서버 간에 교환되는 유일한 데이터에요. 이 ID는 매우 긴 무작위 문자열로, 다른 사람에게 추측되기 어렵도록 설계되어 있어요.
6~7
: 클라이언트에게 세션 ID
를 전달해요
서버는 이 세션 ID를 클라이언트에게 쿠키(Cookie)로 전송해요. 쿠키는 클라이언트 측에 저장되어 이후의 모든 요청에 자동으로 포함돼요.
1~3
: 서버가 세션을 확인해요
서버는 요청을 받을 때마다 세션 ID
를 기반으로 세션 저장소에서 해당 세션을 조회해요. 세션이 유효하면, 서버는 세션에 저장된 데이터를 기반으로 사용자가 누구인지, 어떤 권한이 있는지 등을 확인해요.
만약 세션이 만료되었거나 무효화되었다면, 서버는 클라이언트에게 재인증(로그인)을 요구할 수 있어요.
4~7
: 요청에 따라 서버가 응답해요
세션이 유효한 경우, 서버는 요청에 따라 적절한 응답을 클라이언트에게 전송해요. 이 과정에서 세션 ID가 계속 유지되며, 사용자는 추가적인 로그인 과정 없이도 계속해서 애플리케이션을 사용할 수 있어요.
기타
: 로그아웃 및 세션 무효화
사용자가 로그아웃을 요청하면, 서버는 해당 사용자의 세션을 삭제하거나 무효화해요. 이후 이 세션 ID는 더 이상 유효하지 않으며, 동일한 세션 ID를 포함한 요청은 인증되지 않아요. 로그아웃 후 다시 접근하려면 사용자는 다시 로그인해야 해요.
안전한 전송
: 세션 기반 인증에서는 클라이언트와 서버 간에 오가는 것은 세션 ID
뿐이에요. 사용자 정보나 민감한 데이터는 세션 ID를 통해 서버에서만 관리
되므로, 클라이언트 측에서 데이터 유출의 위험이 줄어들어요. 또한 세션 ID는 별도의 사용자 정보 없이 고유한 식별자로만 사용되기 때문에 비교적 안전해요.인증 상태 관리 가능
: 사용자가 로그아웃하거나, 일정 시간이 지나 세션이 만료되었을 때, 서버에서 세션을 즉시 무효화할 수 있어요. 즉, 서버에서 인증 상태를 관리할 수 있다는 말이에요. (계정당 하나의 기기에서 로그인, 탈취된 세션 ID를 즉시 차단하기 등이 가능해져요.)서버 부담
: 서버에서 세션을 관리하기 때문에, 사용자가 늘어나면 서버의 메모리 사용량이 증가해요.확장성 부담
: 서버에 세션 저장소를 두기 때문에, 여러 대의 서버를 구축하는 등 확장할 때 고려할 문제가 증가돼요.저는 아래와 같은 이유로
세션, 쿠키
를 이용한 로그인 구현 방식을 제안하려고 해요.
제가 바라보고 있지 못하는 부분이나 틀린 부분이 있을 수 있으니 함께 이야기 해보면 좋을 것 같아요.
읽어주셔서 감사합니다.
*다음 글도 추가됐습니다!
쿠키를 사용한 이유 대부분 이해가 가지만 몇가지 의문점이 있는 사항이 있는데요
JWT를 무력화 하는 방법은, JWT Secret을 바꿔주면 되지 않나요
물론 그렇게 되면 로그인한 모든 사용자가 다시 로그인을 해야하는 상황이 발생하긴하지만,,,,
그리고 토큰에도 개인정보를 담아서는 안됩니다.. 회원을 식별할수있는 UUID정도로만 담는게 좋아요
그렇게 되면 sessionId랑 크게 차이 없을겁니다.