[작성중] 로그인 구현 방법 정리

2025. 9. 22. 15:26

0. 시작하기 전에 간단 개념 정리

  • AT(Access Token): 사용자 식별을 위해 요청에 포함하는 토큰으로, 만료기간을 짧게 한다.(ex. 30분~1시간) 만약 탈취 되더라도 금방 만료되기 때문
  • RT(Refresh Token): AT를 재발급하기 위한 용도로, 만료기간이 길다(ex. 2주~1달 정도)
    • AT를 갱신할 때만 서버에 전송되기 때문에 유출의 위험에선 안전한 편이라고 함.
  • RTR(Refresh Token Rotation): RT를 가지고 AT를 재발급 받을 때, 이 RT도 무효화하고 함께 재발급하는 것. 만약 RT를 탈취당했을 경우 해커가 이 RT로 AT를 재발급하여 RT 만료기간동안 데이터 접근하는 것을 방지하기 위함

 


1. React.js

토큰을 어디에 저장하는 게 좋을까?

(1) 로컬스토리지

  • 브라우저를 닫아도 유지됨
  • XSS(Cross-Site Scripting) 공격에 취약
    • 단, React는 기본적으로 XSS 공격을 방지하는 설계가 적용되어 있다고 함(자동 이스케이프 처리)
    • 자동 이스케이프: 특수 문자를 본래의 기능이 아닌 '단순 문자'로 해석
    • 그래서 dangerouslySetInnerHTML 같은 것을 사용해서 DOM에 직접 적용하지 않는 한 거의 안전하다고 볼 수 있음
    • 만약 프로젝트에서 사용하고 있는 서드파티 라이브러리에 취약점이 있다면 결국 XSS에 취약한 것은 그대로.
  • 로컬스토리지 용량 제한에 걸릴 수 있음
    • Chrome 기준 5MB 정도라고 하기 때문에 대부분의 경우 문제가 되지 않을 것으로 보임

(2) 쿠키

  • HttpOnly 플래그를 설정하면 XSS 이슈 해소됨
    • JavaScript 코드로 쿠키에 접근할 수 없게 됨.
    • 추가로, Secure와 SameSite 플래그를 설정하면 더 보안이 강화됨.
      • Secure: HTTPS 프로토콜 상에서 암호화된 요청일 경우에만 전송됨.
      • SameSite: 사이트 간 요청과 함께 쿠키를 보낼지 여부를 제어함. CSRF 공격을 방어하기 위해선 Lax나 Strict로 설정.
        • Lax | Strict | None
        • Lax(최신 브라우저들의 기본값): 같은 도메인이거나, 주소창이 바뀌는 GET 요청(외부 사이트에서 내 사이트 링크를 클릭하여 접속한 요청)에만 쿠키를 전송함
        • Strict: 같은 도메인만 허용
          • 이 경우 구글 검색 결과에서 내 사이트 링크를 클릭하여 들어올 경우 쿠키가 안 넘어감.
        • None: cross-site에서도 쿠키 전송 가능. 그래서 Secure 옵션 추가 필수.
  • 다만, HttpOnly 옵션이 있을 경우 JavaScript에서 쿠키 접근이 불가능하기 때문에 요청 로직에 직접 포함할 수 없음. 
    • 그래서 Cross-Origin으로 요청할 경우에는 백엔드와 추가적으로 상의해서 각자 헤더를 추가해야 함.
    • 프론트엔드에서는 credentials: include 옵션을 추가하여 요청에 쿠키를 포함시키고,
    • 백엔드에서는
      • Access-Control-Allow-Origin: 요청하는 도메인 
      • Access-Control-Allow-Credentials: true

(3) 메모리 변수

  • 메모리에만 저장되므로 상대적으로 XSS 난이도가 올라감.
  • 페이지를 새로고침하면 토큰이 사라지고, 여러 탭이나 창에서 공유되지 않음
    • BroadcastChannel을 사용하여 탭 간 AT를 공유하여 불필요한 refresh 남발 최소화할 수 있다고 함
    • 또는 revalidateOnFocus 방식을 사용해서 탭이 포커스될 때마다 토큰 재검증을 한 번 하는 방법
    • (아직 시도해보진 않았다. 구현의 복잡성이 존재함)
  • 단, AT 재발급 요청(과 RT 무효화)이 너무 자주 일어날 수도 있음. 새로고침할 때마다 로그아웃됨.

참조: https://mori29.tistory.com/25

 

JWT 인증 시리즈 3편: Frontend에서의 JWT 구현 (React/Next.js)

안녕하세요! JWT 인증 시리즈의 세 번째 글입니다. [1편](https://mori29.tistory.com/23)에서는 JWT의 기본 개념과 구조에 대해 알아보았고, [2편](https://mori29.tistory.com/24)에서는 Node.js와 Express를 사용한 서버

mori29.tistory.com

만료기간이 짧은 AT는 메모리 변수에, 만료기간이 긴 RT는 HttpOnly 쿠키에 저장하는 것이 권장된다고 함

+) 또는 RT를 Redis에 저장하기도 한다는 것을 알게 됐다.

Redis는 휘발성 메모리를 사용하기 때문에 장애가 발생하거나 전원이 끊어질 경우 데이터가 손실될 수 있다. 이런 점에서 RDB가 더 안정적이라고 생각할 수 있다.
하지만 Redis와 RDB는 각각의 특징이 존재한다. 
- Redis: In-memory 캐시 서버로서 빠른 속도가 강점.
- RDB: 영속적인 저장소로, 데이터의 안정성을 보장.

참조: https://trysolve.tistory.com/42

 

RTR 방식과 Refresh Token을 Redis에 저장하는 이유

Token과 RTR 방식AccessToken: 사용자가 특정 서비스에 접근할 권한을 증명하는 문자열이다. 사용자의 인증 정보를 확인한 후 발급되며, 사용자가 서비스를 이용할 때마다 이 토큰을 사용해 자신의 인

trysolve.tistory.com

 

 

리액트 환경에서 구현 방법 정리

  1. (이전 프로젝트에서 했던 방법)만료기간이 짧은 AT만 사용하기
    1. Nuxt.js + BE(Spring) 환경이었고, 백엔드에서 Response Body로 AT 내려줌(만료기간 30분. 수명이 짧은 AT라서 탈취되더라도 30분만 유효하다고 생각하여 그렇게 했으나 RT가 없기 때문에 보안에 좋지 않음. 잘못하면 무한하게 토큰이 갱신될 수 있음)
    2. Nuxt.js 서버에서 이를 set-cookie 하고(httpOnly, secure, sameSite 기본)
    3. API 요청 시 요청 헤더에 Bearer token 설정해서 백엔드에 보내고,
    4. 요청할 때마다 Nuxt.js 미들웨어 코드에서 AT 만료기간을 체크하고,
    5. 5분 미만으로 남았을 때, AT 갱신 요청을 백엔드한테 보낸다.
    6. 새로 받은 AT를 다시 set-cookie 해서 하려던 요청을 그대로 이어간다. 그러면 유저는 재로그인 할 필요없어짐(UX 개선)
    7. 이 방식의 단점은, 만약 해커가 AT를 탈취한 상황에서 api 호출을 지속한다면, AT가 알아서 갱신되므로 보안에 취약해진다.
    8. 그래서 간단한 미니 프로젝트에서나 적절하다.
  2. 백엔드에서 RT는 Set-Cookie, AT는 Response Body로 내려주는 방법
    1. 프론트에서 AT를 메모리 변수에 저장한 다음, API 요청 시 Request Header에 Authorization: Bearer AT로 보내기
    2. 그리고 이때 AT의 만료여부를 체크하여 만료된 경우, RTR 하여 새 AT를 요청 헤더에 적용하기
      • 프론트에서 exp 체크하는 것은 단순히 참고용일 뿐이고 완전히 신뢰할 순 없기 때문에 결국엔 백엔드에서 401 내려주는 게 정확함(시간 차이도 있을 수 있음)
      • 그리고 백엔드에서 401을 내려주면 이 에러로 트리거해서 RTR 갱신하는 게 탈취 당하더라도 자동 갱신되지 않고 좋음
    3. 일반 API 요청 시 401 응답이 내려오면, 프론트에서 재발급 요청 API를 보내고, 다시 (1) 과정으로.
    4. 이때 401 응답을 가로채서 재발급 API 호출하는 로직을 적용해야 한다(사용자 화면에 401을 띄우지 않기 위해)
      • 단, 이때 다수 요청이 동시에 401을 받으면 refresh가 중복 호출될 수 있음. 이미 refresh 중이면 대기하는 로직을 인터셉터에 추가
    5. 단, 재발급 API 요청 시에도 401이 내려오면 그때는 사용자 화면에 띄우거나, 다시 로그인 페이지로 이동시켜야 함.
    6. 정리하면, 일반 API 401 → refresh 시도
      • → 성공 시 원래 요청 재시도
      • → 401 에러 → 로그인 이동
    7. (OAuth 2.0과 OpenID Connect에서 표준적으로 권장하는 방법은 Bearer Token 방식)
    8. 단, 새로고침 하면 항상 로그인이 풀리고 RT가 있을 경우 AT 갱신 API를 요청해야 함.
  3. 백엔드에서 AT, RT 둘다 Set-Cookie로 내려주는 방법
    • Bearer Token 방식을 사용할 수 없음. AT, RT 둘다 HttpOnly라서 리액트 JS 코드로 이 쿠키들에 접근할 수 없음.
    • 프론트에서 API 호출 시 credentials: 'include'를 포함(동일 오리진일 경우는 불필요)
    • AT가 만료된 경우, 백엔드에서 401을 내려주고, 프론트가 재발급 요청 API를 보내면 그때 백엔드가 다시 AT, RT를 Set-Cookie
    • 마찬가지로 이때도 401 응답을 가로채서 재발급 API 호출하는 로직을 적용
      • 이때도 다수 요청이 동시에 401을 받으면 refresh가 중복 호출될 수 있음. 이미 refresh 중이면 대기하는 로직을 인터셉터에 추가
    • 이때도 401이 내려오면 그때는 사용자 화면에 띄우거나, 다시 로그인 페이지로 이동시켜야 함.
  4. 백엔드에서 AT, RT 둘다 Response Body로 내려주는 방법(Supabase 기본 방식)
    • 프론트에서 이 둘을 localStorage에 저장하고, 프론트에서 전역 상태 관리 시스템을 도입해서 세션 관리하기

 

 


2. Next.js

(1) 기본 방식 

  • 백엔드에서 AT, RT를 Response Body로 내려줄 경우, Next.js 서버가 RT를 Set-Cookie하고 AT는 Response Body로 내려서 클라이언트에 두는 방식(넥스트 서버가 프록시 서버 역할을 하는 방식)
    • Next Client - Next Server - BE (BFF패턴)

 

(2) Next-Auth를 사용하는 방식