관리자 인증 인가 - fresh-market/fm-backend GitHub Wiki

인증/인가 (관리자)

목차

  1. 개요
  2. 설계
  3. 관리자 인증 인가 흐름
  4. 트러블 슈팅

1. 개요

관리자만 접근할 수 있는 API를 보호하기 위한 인증/인가 기능이다. 공통 인증 로직인 토큰 관리, 쿠키 처리 등은 회원과 공유함으로써 중복 구현을 줄였다. 다만, 관리자 계정은 보안과 접근 통제를 강화하기 위해서 자체 로그인과 관리자 전용 권한 검증 방식을 사용했다. 구현한 기능과 API는 다음과 같다.

POST    /v1/admin/admins                관리자 계정 발급
POST    /v1/admin/auth/tokens           관리자 로그인
POST    /v1/admin/auth/tokens:refresh   관리자 토큰 재발급
DELETE  /v1/admin/auth/tokens           관리자 로그아웃

Note: 이 문서에서 별도의 역할을 명시하지 않고 관리자라고 표현한 경우, 일반 관리자(ADMIN) 와 최고 관리자(SUPER_ADMIN) 를 모두 포함한다.

Note: 이 문서에서는 가독성을 위해서 Access Token을 AT로, Refresh Token을 RT로 표기했다.


2. 설계

2.1 관리자 계정 발급 설계

2.1.1 관리자 AT의 type과 role 구분

관리자 AT의 type은 모두 ADMIN이다. 인증 과정에서는 이를 기반으로 TYPE_ADMIN이라는 Authority(접근 권한 정보)를 생성한다. 이를 통해 일반 회원용 AT와 관리자용 AT를 구분하고, 관리자용 AT를 가진 사용자만 관리자 전용 API에 접근할 수 있도록 했다.

한편, 관리자 AT의 role은 AT 소유자의 관리자 권한 수준을 나타낸다. 일반 관리자의 role은 ROLE_ADMIN이고, 최고 관리자의 role은 ROLE_SUPER_ADMIN이다.

2.1.2 AdminRegistrationRequest 요청값 검증

AdminRegistrationRequest는 클라이언트로부터 관리자 계정 발급에 필요한 입력값을 전달 받아서 처리하기 위한 DTO이다.여기서는 다음 4가지 필수 입력값의 유효성을 검증한다.

POST /v1/admin/admins
{
  "loginId": "admin.lee",
  "initialPassword": "Freshman!2026",
  "name": "이관리",
  "role": "ADMIN"
}
검증 대상 글자 수 허용 문자 비고
loginId 1~50 영어, 숫자, 점(.), 밑줄(_), 하이픈(-) -
initialPassword 10~72 소문자, 대문자, 숫자, 특수문자 4종류의 허용 문자를 각각 1개 이상 포함.로그에서 마스킹 처리.
name 1~50 문자(한글 포함), 공백, 점(.), 작은따옴표('), 하이픈(-) 로그에서 마스킹 처리.
role 5 / 11 ADMIN 또는 SUPER_ADMIN -

2.1.3 비밀번호 BCrypt 해싱

DB 유출 시, 평문 비밀번호가 노출되지 않도록 하고, 의도적으로 연산 비용이 높은 BCrypt를 사용해서 무차별 대입 공격의 비용을 높였다.

BCrypt는 입력 비밀번호의 앞 72 byte까지만 해싱에 반영한다. 이를 고려해서 initialPassword의 최대 길이를 72자로 제한했다. 다만, 이는 글자 수만 제한한 것이므로, 문자 인코딩에 따라 72 byte를 초과할 수 있다. 또한 BCrypt는 연산 비용이 크기 때문에 DB 트랜잭션 밖에서 수행한다.

2.1.4 @Transactional 대신 TransactionTemplate을 사용한 이유

@Transactional은 일반적으로 메서드 단위로 트랜잭션을 적용한다. 따라서 현재 register() 메서드 전체에 적용하면, 연산 비용이 큰 BCrypt 해싱 과정까지 트랜잭션 범위에 포함될 수 있다. 반면, TransactionTemplate은 코드 블록 단위로 트랜잭션 경계를 직접 지정할 수 있으므로, 의도한 대로 관리자 계정 저장과 감사 로그 저장만 트랜잭션으로 묶을 수 있다.

2.2 관리자 로그인 설계

2.2.1 비밀번호 검증 방식

관리자 로그인에서는 계정 존재 여부와 관계없이 비밀번호 검증에 BCrypt 연산을 수행한다.

앞서 설명했듯이 BCrypt는 의도적으로 연산 비용을 크게 만든 알고리즘이다. 따라서 계정이 존재할 때만 비밀번호 검증을 수행하면, 계정이 있을 때와 없을 때의 처리 시간에 큰 차이가 생길 수 있다. 공격자는 이러한 시간 차이를 이용해 특정 계정의 존재 여부를 추측할 수 있다. 이를 방지하기 위해 계정이 존재하는 경우에는 입력 비밀번호를 DB에 저장된 실제 passwordHash와 검증하고, 계정이 존재하지 않는 경우에는 가짜 비밀번호 Hash인 dummyPasswordHash와 검증한다.

또한 비밀번호 검증에는 equals()가 아닌 matches()를 사용한다.

BCrypt는 Salt를 사용하기 때문에 같은 비밀번호라도 해시 결과가 매번 달라질 수 있으므로, 새로 생성한 Hash와 저장된 Hash를 문자열로 직접 비교하는 방식은 적합하지 않다.

따라서 matches()를 사용하여 입력한 평문 비밀번호와 DB에 저장된 BCrypt Hash가 서로 일치하는지 검증한다. 저장된 BCrypt Hash에는 검증에 필요한 알고리즘 정보, Cost, Salt, Hash 등이 포함되어 있기 때문이다.

참고로 Cost와 Salt는 다음과 같다.

  1. Cost : BCrypt 연산의 작업량을 결정하는 값. 값이 높을수록 Hash 생성과 비밀번호 검증에 더 많은 연산이 필요함. 따라서 무차별 대입 공격이 더 어려워짐.

  2. Salt : 비밀번호 해싱 시, 함께 사용하는 랜덤 값. 같은 비밀번호라도 서로 다른 Salt를 사용하면 서로 다른 Hash가 생성됨.

2.2.2 보안을 위한 에러 코드 통일

존재하지 않는 ID, 비밀번호 불일치, 비활성화된 계정을 모두 LOGIN_FAILED로 처리하여 공격자가 응답을 통해 관리자 계정의 존재 여부나 상태를 유추하기 어렵게 했다.

2.2.3 RT 생성 방식

RT는 Opaque Token으로 생성한다. RT는 토큰 내부 정보를 해석하는 용도가 아니라, 현재 서버가 인정하는 유효한 토큰인지 확인하는 용도로 사용한다. 따라서 JWT처럼 관리자 ID나 권한 등의 의미 있는 정보를 토큰 내부에 담을 필요가 없다. 따라서 랜덤 문자열 형태의 Opaque Token을 사용했다.

또한 RT는 관리자 인증 상태를 이어갈 수 있는 비밀값이므로, 예측하기 어려워야 한다. 따라서 일반적인 Random이 아닌 보안용 난수 생성기인 SecureRandom을 사용한다.

생성한 RT 원문은 SHA-256으로 해싱해서 저장한다. SHA-256 결과는 256 bit이며, 이를 Hex 문자열로 표현하면 64자가 된다. 따라서 Admin 엔티티의 refreshTokenHash도 64자로 길이가 제한되어 있다.

RT는 DB와 Redis 두 저장소에서 관리하며, 각각 역할이 다르다. 먼저, Redis에 저장하기 전에 DB에 RT Hash와 만료 시각을 저장한다. 이를 통해 Redis에 장애가 발생해도 DB에 저장된 RT Hash를 통해 해당 RT의 유효성을 확인할 수 있도록 했다.

다음으로 Redis에는 두 종류의 RT 관련 데이터를 저장한다. 첫 번째는 refreshToken 레코드로, RT Hash를 이용해 관리자 정보를 찾기 위한 데이터이다. 클라이언트가 RT를 보내면, 서버는 먼저 해당 RT를 해싱한 뒤, 이 키를 조회해서 관리자와 권한 정보를 확인한다.

Key : refreshToken:{RT_HASH}
Value : adminId | role | type | remember

두 번째는 activeRefreshToken으로, 관리자 정보를 이용해 현재 활성 상태인 RT Hash를 찾기 위한 보조 인덱스이다. 로그아웃처럼 관리자 정보는 알고 있지만, 현재 활성 RT Hash를 모르는 경우, 이 보조 인덱스를 이용해 해당 관리자의 현재 RT Hash를 찾는다.

Key : activeRefreshToken:{role}:{adminId}
Value : RT_HASH

관리자 RT의 유효 기간은 보안상 회원 RT보다 짧은 **1일(86,400초)**로 설정되어 있다.

2.2.4 AT 생성 방식

AT는 JWT로 생성한다. 서버는 AT 자체를 별도로 저장하지 않기 때문에, 요청이 들어올 때마다 JWT의 내부 정보와 서명을 검증한다. 따라서 서버가 요청을 받을 때 관리자 주체, 권한, 만료 여부 등을 빠르게 판단할 수 있도록 관리자 인증 및 인가에 필요한 정보를 담을 수 있는 JWT를 사용했다. JWT의 형태는 아래와 같다.

{
  "sub": "15",
  "type": "ADMIN",
  "role": "ROLE_ADMIN",
  "iat": 178...,
  "exp": 178...
}

각 Claim의 의미는 다음과 같다.

  1. sub : 관리자 PK
  2. type : 토큰 유형. 관리자용 토큰이므로 ADMIN.
  3. role : ROLE_ADMIN 또는 ROLE_SUPER_ADMIN
  4. iat : 발급 시각
  5. exp : 만료 시각

관리자 로그인 과정에서 AT 생성에 실패하면, 해당 로그인 과정에서 DB에 저장한 RT Hash도 제거하도록 설계되어 있다. AT 생성에 실패하면 로그인 응답이 정상적으로 완료되지 않으므로, 이번 로그인에서 발급한 RT 역시 클라이언트가 사용할 수 없게 되기 때문이다. 이때, 다른 로그인에서 새로 발급된 RT까지 삭제하지 않도록, 현재 DB의 RT Hash가 이번 로그인에서 저장한 Hash와 일치하는 경우에만 제거한다.

관리자 AT의 만료시간은 회원 AT와 마찬가지로 30분이다.

2.2.5 RT와 AT를 클라이언트에게 전달하는 방식

RT와 AT는 모두 HTTP 응답 Header의 Set-Cookie를 통해 브라우저에게 HttpOnly Cookie 형태로 전달된다. Set-Cookie를 사용하면 브라우저가 쿠키를 저장하고, 이후 해당 조건에 맞는 요청에 자동으로 쿠키를 포함해서 전송한다.

쿠키의 주요 설정은 아래와 같다.

구분 AT RT
이름 accessToken refreshToken
HttpOnly true true
SameSite Strict Strict
Path / /v1/admin/auth/
Max-Age AT 수명 RT 수명
Secure 환경설정 환경설정

HttpOnly=true로 설정한 이유는 브라우저의 JS가 토큰 값을 직접 읽지 못하게 하여 토큰 원문 탈취 위험을 줄이고자 했기 때문이다.

SameSite=Strict로 설정한 이유는 다른 사이트에서 시작된 요청에 자동으로 쿠키가 포함되는 것을 강하게 제한하여 CSRF 위험을 완화하고자 한 것이다.

Path의 경우, AT는 인증이 필요한 여러 API에서 사용되지만, RT는 관리자 인증 관련 API에서만 필요하므로 범위를 제한했다.

Secure는 환경설정에 따라 결정되며, Secure=true면 HTTPS 연결에서만 쿠키를 전송한다. 따라서 HTTP 연결에서는 해당 쿠키가 전송되지 않는다.

2.2.6 관리자 계정 활성 상태를 2번 확인하는 이유

관리자 계정의 활성 상태는 동시성 문제를 방지하기 위해 두 번 확인한다.

  1. 1차 확인 : 비밀번호 검증 이후, 현재 관리자 계정이 활성(ACTIVE) 상태인지 확인한다.
  2. 2차 확인 : DB에 RT를 저장하기 직전, 관리자 행에 잠금을 건 상태에서 다시 활성 여부를 확인한다.

1차 확인 이후 RT를 저장하기 전까지의 시간 동안 관리자 계정의 상태가 변경될 수 있다. 따라서 계정 상태를 다시 확인하지 않고 RT를 저장하면 비활성화된 관리자가 로그인에 성공할 수 있으므로, RT를 저장하기 직전에 최신 계정 상태를 다시 확인한다.

2.2.7 AdminLoginRequest 요청값 검증

검증 대상 글자 수 비고
loginId 1~50 null, 빈 문자열, 공백만 있는 문자열 모두 사용 불가.
password 1~72 null, 빈 문자열, 공백만 있는 문자열 모두 사용 불가.로그 출력 시, 마스킹 처리.

2.2.8 로그인 트랜잭션 분리

login() 메서드 전체를 하나의 트랜잭션으로 묶지 않고, DB에서 반드시 원자적으로 처리해야 하는 작업만 별도의 짧은 트랜잭션으로 분리했다.

로그인 과정에는 DB 작업뿐만 아니라 AT 생성과 Redis 통신도 포함된다. 만약 login() 전체를 트랜잭션으로 묶으면 Redis 통신이 끝날 때까지 DB 트랜잭션이 불필요하게 오래 유지될 수 있다.

또한 Redis는 DB 트랜잭션으로 제어할 수 있는 저장소가 아니므로, @Transactional로 DB와 Redis를 하나의 원자적인 작업으로 묶을 수 없다. 이 상태에서 DB Lock까지 오래 유지되면 동시 요청 처리에도 불리할 수 있다.

따라서 issueRefreshToken()에서는 다음 세 가지 작업만 하나의 짧은 트랜잭션으로 처리한다.

  1. 관리자 행 잠금
  2. 계정의 ACTIVE 상태 재확인
  3. RT Hash 및 만료시간 저장

2.2.9 AdminLoginResult DTO의 용도

AdminLoginResult는 AdminAuthService에서 생성한 로그인 결과를 AdminAuthController로 전달하기 위한 서버 내부 DTO이다. AT와 RT 원문, AdminLoginResponse, RT 유효기간 등 로그인 응답 생성에 필요한 정보를 전달하는 용도로 사용한다.

2.3 관리자 토큰 재발급 설계

2.3.1 RT Rotation을 적용한 이유

관리자 토큰 재발급 시에는 기존 RT를 그대로 유지하지 않고 새 RT로 교체하는 Rotation 방식을 사용한다. 기존 RT를 계속 사용할 수 있게 하면 탈취된 RT도 만료될 때까지 재사용될 수 있다. 따라서 재발급에 사용된 기존 RT는 더 이상 사용할 수 없도록 처리하고, 새 RT만 유효한 상태로 변경한다. Redis에서는 Lua Script를 사용해서 새 RT 저장과 기존 RT를 REVOKED 상태로 변경하는 작업을 원자적으로 처리한다. 즉, 두 작업이 하나의 작업처럼 실행되어 중간에 다른 요청이 끼어들 수 없다. 만약, 이미 REVOKED로 변경된 기존 RT가 다시 전달되면 REUSE_DETECTED로 판단해서 재발급을 거부한다.

2.3.2 CAS Rotation을 사용하는 이유

CAS는 현재 값이 예상한 값과 일치할 때만 새 값으로 변경하는 방식이다. DB에서는 기존 RT Hash가 요청에서 전달된 oldHash와 일치하는 경우에만 newHash로 변경하는 CAS(Compare-And-Set) 방식을 사용한다. 이를 통해 동일한 RT로 재발급 요청이 동시에 들어오더라도, 먼저 RT Hash를 newHash로 변경한 이후에는 기존 oldHash가 더 이상 일치하지 않으므로 후속 요청의 재발급을 막을 수 있다.

2.3.3 Redis Rotation timeout을 별도로 처리하는 이유

Redis Rotation 요청 중 timeout이 발생했다고 해서 실제 Rotation이 실패했다고 단정할 수 없다. Redis에서는 이미 새 RT 저장이 완료되었지만 서버가 응답을 받는 과정에서 timeout이 발생했을 수도 있기 때문이다. 따라서 timeout이 발생하면 새 RT가 Redis에 존재하는지 다시 확인한다. 새 RT가 존재하면 Rotation이 성공한 것으로 판단하고, DB Rotation을 계속 진행한다. 반면, 결과를 확인할 수 없는 경우에는 재발급 결과를 확정하지 않고 ADMIN-012 오류로 처리한다.

2.4 관리자 로그아웃 설계

2.4.1 로그아웃 과정에서 AT를 먼저 검증하는 이유

로그아웃을 요청한 관리자를 식별하고, 이전 로그아웃을 통해 이미 차단된 AT인지 확인하기 위해서 AT를 먼저 검증한다. 요청 주체를 정확히 식별해야 해당 관리자의 RT와 AT를 올바르게 폐기할 수 있다. 또한 이미 차단된 AT를 허용하면 무효화된 토큰이 다시 인증 수단으로 사용될 수 있고, 이미 종료된 세션에 대한 RT 폐기 작업이 반복되어 불필요한 처리가 발생할 수 있다.

2.4.2 RT 폐기 전 Redis와 DB에서 RT Hash를 확보하는 이유

로그아웃 과정에서는 두 저장소의 RT를 폐기하기 전에 각 저장소에서 RT Hash를 미리 확보한다. 먼저 DB 작업을 시작하기 전에 Redis의 activeRefreshToken에서 현재 RT Hash를 조회한다. 이후 DB의 RT를 폐기하기 직전에 **DB에 저장되어 있던 기존 RT Hash**를 확보한다.

이처럼 두 곳에서 RT Hash를 확보하는 이유는 로그아웃 도중에 일부 저장소의 폐기 작업만 성공하더라도, 남아 있는 RT를 다시 찾아서 정리할 수 있도록 하기 위해서다.

정상적인 로그아웃 과정에서는 **DB에서 확보한 기존 RT Hash**를 사용해서 Redis의 RT를 폐기한다. 하지만 이전 로그아웃 요청에서 DB의 RT 폐기만 성공하고, Redis의 RT 폐기에는 실패했다면, **재요청 시 DB의 RT Hash는 이미 null**일 수 있다. 이 경우, 로그아웃 초기에 **Redis의 activeRefreshToken에서 미리 확보한 RT Hash**를 사용해서 Redis에 남아 있는 RT를 폐기할 수 있다.

2.4.3 관리자 DB행을 비관적 잠금으로 조회한 이유

관리자의 RT 정보를 변경하는 요청(로그인, 로그아웃, 토큰 재발급)이 동시에 들어오면, 서로 변경 내용을 덮어쓰는 문제가 발생할 수 있다.

이를 방지하기 위해 관리자 DB 행을 PESSIMISTIC_WRITE로 먼저 잠근 상태에서 조회한다. 잠금이 유지되는 동안 다른 요청은 해당 행을 수정하지 못하고 대기하게 되므로, 같은 관리자의 RT 변경 작업을 순서대로 안전하게 처리할 수 있다.

2.4.4 DB와 Redis에서의 RT 폐기 방식

관리자 RT는 DB와 Redis에 모두 저장되지만, 두 저장소의 역할과 데이터 구조가 다르기 때문에 폐기 방식도 다르다.

DB에서는 관리자 행 안에 RT Hash와 만료 시각이 컬럼 형태로 저장되어 있다. 이 값들은 현재 해당 관리자에게 유효한 RT가 있는지를 나타내는 상태 정보이므로, 로그아웃 시 두 값을 모두 null로 설정해 현재 유효한 RT가 없는 상태로 만든다. 만약, RT Hash만 없애고 만료 시각은 남겨두면 실제 RT는 없는데 만료 정보만 남는 불일치 상태가 생길 수 있기 때문에 두 값을 함께 비운다.

반면, Redis에서는 RT 정보를 별도의 키로 저장하고 있으므로, 해당 키 자체를 삭제한다. refreshToken:{hash}는 실제 RT 정보를 담는 레코드이므로 로그아웃 시 반드시 삭제한다. 하지만, activeRefreshToken은 현재 관리자에게 연결된 RT를 가리키는 보조 인덱스이므로, 폐기 대상 Hash와 일치하는 경우에만 삭제한다. 로그아웃 처리 도중 새로운 로그인이나 토큰 재발급으로 activeRefreshToken이 이미 새로운 RT Hash를 가리키고 있을 수 있기 때문이다. 이때, 보조 인덱스를 무조건 삭제하면 새로 발급된 RT를 가리키는 연결 정보까지 삭제되어 정상적인 RT 사용에 문제가 생길 수 있다.

즉, DB에서는 RT가 없는 상태를 명확하게 표현하기 위해 값을 null로 설정하고, Redis에서는 RT 관련 데이터를 제거하기 위해 키를 삭제한다. 또한 Redis의 보조 인덱스는 새로 발급된 RT에 영향을 주지 않도록 Hash가 일치하는 경우에만 삭제한다.

2.4.5 두 저장소에서 모두 RT 폐기에 성공해야 하는 이유

관리자 RT는 DB와 Redis에 모두 저장되어 있다. 이 상태에서 한쪽에만 RT가 남아 있으면, 저장소 간의 상태가 달라져서 이후 토큰 재발급 과정에서 남아 있는 RT 정보가 사용될 가능성이 있다. 따라서 저장소 간 상태 불일치와 폐기되지 않은 RT의 재사용 문제를 방지하기 위해 두 저장소 모두 RT 폐기에 성공해야 한다.

2.4.6 AT에 cutoff를 적용한 이유

AT는 **JWT**이기 때문에 서버가 토큰 자체를 저장해서 관리하지 않는다. 따라서 로그아웃하더라도 서버에서 기존 AT를 직접 삭제할 수 없다. 이 문제를 해결하기 위해 Redis에 로그아웃 시각을 cutoff로 저장하고, 이후 요청에서 AT의 발급 시각(iat)과 비교한다. 이를 통해 로그아웃 이전에 발급된 AT를 만료 시각(exp)이 남아 있더라도 강제로 무효화하여 차단할 수 있다.

TTL, 만료 시각(exp), cutoff는 다음과 같이 정리할 수 있다.

  1. TTL : Redis에 저장된 데이터의 남은 수명.
  2. 만료 시각(exp) : JWT를 언제까지 사용할 수 있는지를 판단하기 위한 시각. 발급 시각(iat) + AT 유효시간(accessTokenValidityMs)으로 계산한다.
  3. cutoff : 로그아웃 시점을 기준으로 아직 exp가 남아 있는 기존 AT도 사용할 수 없도록 강제로 차단하는 기준 시각.

2.4.7 브라우저에게 RT / AT 쿠키 삭제를 지시하는 이유

로그아웃 과정에서 서버의 RT 폐기와 AT 차단 작업이 성공해도, 브라우저에는 기존 RT와 AT 쿠키가 남아 있을 수 있다.

서버는 브라우저에 저장된 쿠키를 직접 삭제할 수 없기 때문에 Max-Age=0인 만료 쿠키를 응답으로 내려서 브라우저가 기존 RT와 AT 쿠키를 삭제하도록 지시한다.

이를 통해 로그아웃 이후 브라우저가 자동으로 요청에 기존 토큰을 포함하는 것을 방지할 수 있다.

2.5 JWT 시간 처리 설계

2.5.1 Clock, Instant, Date의 용도

현재 프로젝트에서는 JWT 및 인증/인가의 시간 처리에 Clock, Instant, Date를 사용하고 있다.

  1. Clock : 인증 서비스에서 현재 시각을 가져올 때 사용한다. 관리자 로그인과 토큰 재발급에서는 RT 만료 시각을 계산하고, 로그아웃에서는 AT cutoff 시각을 설정할 때 사용한다.
  2. Instant : JwtTokenProvider에서 JWT의 발급 시각(iat)과 만료 시각(exp)을 계산할 때 사용한다. Instant.now()로 현재 시각을 구하고, 여기에 AT의 유효 시간(accessTokenValidityMs)을 더해서 만료 시각을 계산한다.
  3. Date : Instant로 계산한 시각을 JJWT에서 사용하는 형식으로 변환할 때 사용한다. JWT 생성 시 Date.from()을 통해 iat와 exp를 전달하고, JWT에서 발급 시각을 조회할 때도 사용한다.

JWT의 iat와 exp는 다음과 같이 계산한다.

  1. Instant.now()로 현재 시각을 구한다.
  2. 현재 시각을 JWT의 발급 시각(iat)으로 사용한다.
  3. 현재 시각 + AT 유효 시간(accessTokenValidityMs) 으로 만료 시각(exp)**을 계산한다.
  4. 계산한 iat와 exp를 Date.from()을 통해 Date 타입으로 변환한다.
  5. 변환한 값을 JJWT의 issuedAt()과 expiration()에 전달한다.

2.5.2 시간 API 비교

  1. Clock (1) 특징

    • 현재 시각을 알려주는 시계처럼 다른 시간 API에게 시각 정보를 제공하는 객체이다. 따라서 Instant.now(clock), LocalDateTime.now(clock)처럼 다른 시간 API와 함께 사용한다.
    • 현재 시각을 기준으로 동작하면서 테스트에서 시간을 제어해야 하는 로직에 주로 사용한다. (2) 장점
    • Clock.fixed()를 사용하면, 테스트에서 시간을 원하는 시각으로 고정할 수 있다.
    • Clock을 외부에서 주입하면, 로직에서 현재 시간을 직접 호출하지 않아도 된다. 따라서 시간 처리와 실제 로직을 분리할 수 있다. (3) 단점
    • Clock 자체는 시간 값을 직접 나타내는 객체가 아니므로, Instant, LocalDateTime 등의 시간 API와 함께 사용해야 한다.
  2. Instant (1) 특징

    • UTC를 기준으로 특정 시점을 나타낸다.
    • 시간대를 포함하지 않고, 전 세계에서 동일한 하나의 시점을 표현한다.
    • 주로 특정 시점을 저장하거나 비교할 때 사용한다. (2) 장점
    • 불변 객체이므로 값이 변경되지 않는다.
    • plusSeconds(), plusMillis() 등을 사용해서 시간 계산을 쉽게 할 수 있다. (3) 단점
    • 연과 월과 일과 시와 분처럼 사람이 보기 쉬운 형태로 표현하려면 다른 시간 타입으로 변환해야 한다.
    • 레거시 라이브러리에서는 Date 등의 타입으로 변환해야 한다.
  3. Date (1) 특징

    • Java 초기부터 제공된 java.util의 날짜와 시간 클래스이다.
    • 현재는 주로 레거시 라이브러리와 날짜와 시간 값을 주고받을 때 사용한다. (2) 장점
    • Java의 기존 레거시 라이브러리와 호환성이 높다.
    • Date.from()과 toInstant()를 통해 Instant와 서로 쉽게 변환할 수 있다. (3) 단점
    • 가변 객체이므로, 생성된 이후에도 값이 변경될 수 있다.
    • 여러 메서드가 deprecated되었다. 따라서 레거시 코드가 아니라면, Instant 등의 java.time API 사용을 권장한다.
  4. System.currentTimeMillis() (1) 특징

    • 현재 시각을 long 타입의 Epoch 기준 밀리초 값으로 반환한다.
    • 주로 현재 시각을 밀리초 단위의 숫자 값으로 처리해야 하는 경우에 사용한다. (2) 장점 : static 메서드이므로, 별도의 객체 생성 없이 현재 시각을 숫자로 바로 얻을 수 있다. (3) 단점
    • 단순한 long 값이므로, 값 자체만으로는 시간을 나타내는 값인지 알기 어렵고, 의미도 명확하지 않다.
    • 직접 호출하기 때문에 테스트에서 현재 시간을 원하는 시각으로 고정하기 어렵다.

3. 관리자 인증 인가 흐름

관리자 인증/인가는 크게 네 흐름으로 나뉜다.(‘일반 관리자’나 ‘최고 관리자’라고 구체적으로 명시하지 않고 ‘관리자’만 쓸 경우에는 일반 관리자와 최고 관리자를 모두 지칭한 것이다.)

  1. 최고 관리자가 관리자 계정을 발급한다.
  2. 관리자가 ID, 비밀번호로 로그인하고 AT와 RT를 발급받는다.
  3. AT가 만료되면, 로그인 때 발급받은 RT를 통해서 AT와 RT를 새로 발급받는다.
  4. 로그아웃하면 기존 RT를 폐기하고 기존 AT까지 즉시 차단한다.

3.1 관리자 계정 발급의 흐름

관리자 계정은 오직 **인증된 최고 관리자(ROLE_SUPER_ADMIN)**에 의해서만 발급될 수 있다. 이때, 최고 관리자는 일반 관리자(ADMIN)뿐만 아니라 다른 최고 관리자(SUPER_ADMIN) 계정도 발급할 수 있다. 새로 발급된 관리자 계정의 상태는 항상 ACTIVE이다. 바로 사용할 수 있는 정상 계정으로 생성되도록 설계했기 때문이다. 관리자 계정 발급의 흐름은 다음과 같다.

sequenceDiagram
    participant SA as 최고 관리자
    participant S as 서버
    participant DB as DB

    SA->>S: POST /v1/admin/admins<br/>관리자 계정 발급 요청
    S->>S: Access Token 인증
    alt Access Token이 없거나 유효하지 않음
        S-->>SA: 401(COMMON-004 / UNAUTHENTICATED)
    end
    S->>S: 관리자 권한 및 CSRF 검증
    alt TYPE_ADMIN 권한 없음 또는 CSRF 검증 실패
        S-->>SA: 403(COMMON-005 / PERMISSION_DENIED)
    end
    S->>S: DTO 요청값 검증(@Valid)
    alt DTO 요청값 검증 실패
        S-->>SA: 400(COMMON-002 / INVALID_INPUT)
    end
    S->>S: 요청자의 최고 관리자 권한 확인
    alt 요청자의 role이 ROLE_SUPER_ADMIN이 아님
        S-->>SA: 403(ADMIN-005 / SUPER_ADMIN_REQUIRED)
    end
    S->>DB: 신규 관리자 loginId 중복 검사
    alt 이미 사용 중인 loginId
        S-->>SA: 409(ADMIN-006 / LOGIN_ID_DUPLICATED)
    end
    S->>S: 초기 비밀번호 BCrypt 해싱
    S->>S: DB 트랜잭션 시작<br/>(timeout 5초)
    S->>DB: 관리자 계정 저장<br/>(status = ACTIVE)
    alt 동시 등록으로 인한 loginId UNIQUE 제약 위반
        DB-->>S: DataIntegrityViolationException
        S->>S: 트랜잭션 롤백
        S-->>SA: 409(ADMIN-006 / LOGIN_ID_DUPLICATED)
    end
    S->>DB: 관리자 계정 발급 감사 로그 저장<br/>(ADMIN_ACCOUNT_CREATE)
    alt 그 외 예상하지 못한 예외
        S->>S: 트랜잭션 롤백
        S-->>SA: 500(COMMON-001 / INTERNAL_ERROR)
    end
    S->>S: 트랜잭션 커밋
    S-->>SA: 관리자 계정 발급 완료(201)
Loading

관리자 계정 발급에 성공하면, 응답 데이터에 아래와 같이 관리자 정보가 포함된다.

{
  "loginId": "admin.lee",
  "name": "이관리",
  "role": "ADMIN"
}

3.2 관리자 로그인의 흐름

로그인 시점에는 아직 유효한 AT와 CSRF Token이 모두 없다. 따라서 로그인 API는 인증 정보 없이 접근할 수 있도록 허용하고, CSRF 검사에서도 제외된다.

관리자 로그인 흐름은 아래와 같이 크게 두 단계로 나눌 수 있다.

  1. 관리자 인증 : 관리자 자격 증명을 검증하는 과정. 관리자 인증에 성공하려면 ID가 존재하고, 비밀번호가 일치하며, 계정이 활성(ACTIVE) 상태여야 한다.
  2. RT 및 AT 발급 : 인증 상태를 유지하기 위한 토큰을 발급하는 과정.
sequenceDiagram
    participant A as 관리자
    participant S as 서버
    participant DB as DB
    participant R as Redis

    A->>S: POST /v1/admin/auth/tokens<br/>관리자 로그인 요청(loginId, password)
    S->>S: DTO 요청값 검증(@Valid)
    alt DTO 요청값 검증 실패
        S-->>A: 400(COMMON-002 / INVALID_INPUT)
    end
    S->>DB: 관리자 계정 조회(loginId)
    alt 존재하지 않는 관리자 계정
        S->>S: 입력 비밀번호를 Dummy BCrypt Hash로 검증
        S-->>A: 401(ADMIN-001 / LOGIN_FAILED)
    else 관리자 계정 존재
        S->>S: 입력 비밀번호를 DB의 BCrypt Hash로 검증
        alt 비밀번호 불일치
            S-->>A: 401(ADMIN-001 / LOGIN_FAILED)
        end
    end
    S->>S: 관리자 계정 활성(ACTIVE) 상태 1차 확인
    alt 비활성화된 관리자(DELETED)
        S-->>A: 401(ADMIN-001 / LOGIN_FAILED)
    end
    S->>S: Refresh Token 생성<br/>(SecureRandom 기반 Opaque Token)
    S->>S: Refresh Token 해싱(SHA-256)
    S->>S: Refresh Token TTL 및 만료시각 계산
    S->>S: DB 트랜잭션 시작<br/>(timeout 1초)
    S->>DB: 관리자 행 잠금 조회<br/>(PESSIMISTIC_WRITE)
    alt 존재하지 않는 관리자 계정
        S->>S: 트랜잭션 롤백
        S-->>A: 401(ADMIN-001 / LOGIN_FAILED)
    end
    S->>S: 관리자 계정 활성(ACTIVE) 상태 2차 확인
    alt 비활성화된 관리자(DELETED)
        S->>S: 트랜잭션 롤백
        S-->>A: 401(ADMIN-001 / LOGIN_FAILED)
    end
    S->>DB: 관리자 행에 Refresh Token Hash 및 만료 시각 저장
    alt DB 저장 또는 트랜잭션 실패
        S->>S: 트랜잭션 롤백
        S-->>A: 500(COMMON-001 / INTERNAL_ERROR)
    end
    DB-->>S: Refresh Token Hash 및 만료 시각 저장 Commit 완료
    S->>S: 관리자 권한 문자열 변환<br/>ADMIN -> ROLE_ADMIN<br/>SUPER_ADMIN -> ROLE_SUPER_ADMIN
    S->>S: Access Token 생성(JWT)<br/>(sub, type, role, iat, exp)
    alt Access Token 생성 실패
        S->>DB: 이번 로그인 Hash와 일치하는 Refresh Token Hash만 제거
        S-->>A: 500(COMMON-001 / INTERNAL_ERROR)
    end
    S->>R: Refresh Token 레코드 저장<br/>refreshToken:{hash}
    S->>R: Refresh Token 보조 인덱스 저장<br/>activeRefreshToken:{role}:{adminId}
    alt Redis 저장 실패
        R-->>S: DataAccessException
        Note over S: 이미 DB에 Refresh Token Hash가 저장되어 있으므로<br/>Redis 장애가 발생해도 로그인 진행
    else Redis 저장 성공
        R-->>S: 저장 완료
    end
    S->>S: 로그인 응답 DTO 생성
    S->>S: Access Token HttpOnly Cookie 생성
    S->>S: Refresh Token HttpOnly Cookie 생성
    S-->>A: 관리자 로그인 성공(201 Created)
Loading

3.3 관리자 토큰 재발급의 흐름

관리자 토큰 재발급은 기존 AT가 만료되었을 때, RT를 이용해서 새 AT를 발급 받는 기능이다. 따라서 관리자 토큰 재발급 API는 기존 AT 없이 접근할 수 있다. 관리자 토큰 재발급의 큰 흐름은 다음과 같다.

  1. RT 쿠키 추출 및 형식 검증하기
  2. 새 RT 생성 및 기존 RT 및 새 RT 해싱하기
  3. Redis에서 기존 RT 조회 및 RT Rotation 수행하기
  4. Redis 조회 실패, 기존 RT 유실 또는 일반적인 Redis Rotation 실패 시 DB fallback 수행하기
  5. DB에서 관리자 및 RT 유효성을 검증하고 oldHash -> newHash로 CAS Rotation 수행하기
  6. Redis Rotation 성공 후 DB 검증 또는 CAS Rotation이 실패하면 새 RT를 Redis에서 삭제하기
  7. 새 AT를 생성하고 새 AT 및 RT를 HttpOnly 쿠키로 전달하기
sequenceDiagram
    participant A as 관리자
    participant S as 서버
    participant R as Redis
    participant DB as DB

    A->>S: POST /v1/admin/auth/tokens:refresh<br/>관리자 토큰 재발급 요청(Refresh Token 쿠키 전달)
    S->>S: Refresh Token 쿠키 추출 및 형식 검증
    break Refresh Token 쿠키 없음 또는 형식 오류
        S-->>A: 401(ADMIN-011 / REFRESH_TOKEN_INVALID)
    end
    S->>S: 새 Opaque Refresh Token 생성
    S->>S: 기존/새 Refresh Token SHA-256 해싱<br/>oldHash / newHash 생성
    S->>S: 새 Refresh Token 만료 시각 계산
    S->>R: 기존 Refresh Token 조회
    alt Redis 조회 실패
        Note over S: DB fallback 필요
    else Redis에 기존 Refresh Token 없음
        Note over S: DB fallback 필요
    else Redis에서 기존 Refresh Token 조회 성공
        S->>S: 관리자용 토큰(ADMIN) 여부 확인
        break ADMIN 타입 아님
            S-->>A: 401(ADMIN-011)
        end
        S->>R: Refresh Token Rotation 수행(oldRT -> newRT)<br/>Lua Script 실행 후 activeRefreshToken 갱신
        alt 이미 사용된 Refresh Token 재사용 감지(REUSE_DETECTED)
            S->>DB: 실패 감사 로그 저장 시도
            break Refresh Token 재사용
                S-->>A: 401(ADMIN-011)
            end
        else Rotation 시 기존 Refresh Token 없음(NOT_FOUND)
            break 기존 Refresh Token 없음
                S-->>A: 401(ADMIN-011)
            end
        else Redis Rotation timeout(결과 미확정)
            S->>R: 새 Refresh Token 존재 여부 재확인
            alt 새 Refresh Token 없음
                S->>DB: 실패 감사 로그 저장 시도
                break Redis Rotation 결과 확인 불가
                    S-->>A: 503(ADMIN-012 / 결과 확인 불가)
                end
            else 새 Refresh Token 조회 실패
                S->>DB: 실패 감사 로그 저장 시도
                break Redis Rotation 결과 확인 실패
                    S-->>A: 503(ADMIN-012 / 결과 확인 불가)
                end
            else 새 Refresh Token이 ADMIN 타입 아님
                S->>DB: 실패 감사 로그 저장 시도
                break Redis Rotation 결과 타입 불일치
                    S-->>A: 503(ADMIN-012 / 결과 확인 불가)
                end
            else Redis Rotation 성공 확인
                Note over S: DB Rotation 최종 확정 필요
            end
        else Redis 일반 장애
            Note over S: DB fallback 필요
        else Redis Rotation 성공
            Note over S: DB Rotation 최종 확정 필요
        end
    end
    alt DB fallback 필요
        S->>DB: oldHash 기준 관리자 조회 및 Refresh Token 유효성 검증<br/>oldHash -> newHash CAS Rotation
        break DB 유효성 검증 또는 CAS 실패
            S->>DB: 실패 감사 로그 저장 시도
            S-->>A: 재발급 실패
        end
        S->>DB: 성공 감사 로그 저장
        S->>R: 새 Refresh Token 저장 시도
        Note over S,R: Redis 저장 실패 시에도<br/>DB 상태 기준으로 재발급 유지
    else DB Rotation 최종 확정 필요
        S->>DB: 관리자 조회 및 Refresh Token 유효성 검증<br/>oldHash -> newHash CAS Rotation
        alt DB 유효성 검증 또는 CAS 실패
            S->>R: Redis Rotation 보상 처리<br/>새 Refresh Token 삭제 및 activeRefreshToken 조건부 삭제
            alt Redis 보상 처리 실패
                S->>DB: 실패 감사 로그 저장 시도
                break Redis 보상 처리 실패
                    S-->>A: 503(ADMIN-012 / 결과 확인 불가)
                end
            else Redis 보상 처리 성공
                S->>DB: 실패 감사 로그 저장 시도
                break DB 유효성 검증 또는 CAS 실패
                    S-->>A: 재발급 실패
                end
            end
        else DB Rotation 성공
            S->>DB: 성공 감사 로그 저장
        end
    end
    S->>S: 새 JWT Access Token 생성
    S->>S: 새 Access Token / Refresh Token<br/>HttpOnly 쿠키 생성
    S-->>A: 관리자 토큰 재발급 성공(200 OK)
Loading

3.4 관리자 로그아웃의 흐름

관리자 로그아웃은 요청에 포함된 AT를 검증해 생성된 CustomUserDetails에서 관리자 id와 role을 가져와서 처리한다. 로그아웃 요청을 보낸 관리자를 식별해서 해당 관리자의 기존 RT와 AT를 정확하게 폐기(무효화)하기 위해서 id와 role이 필요하기 때문이다.

관리자 로그아웃의 큰 흐름은 다음과 같다.

  1. 관리자 AT 검증하기
  2. Redis의 activeRefreshToken에서 현재 RT Hash 선조회하기
  3. DB의 RT Hash 확보하기
  4. DB의 RT 폐기하기(RT Hash + 만료 시각 폐기)
  5. Redis의 RT 폐기하기(refreshToken + activeRefreshToken 폐기)
  6. AT cutoff 설정 및 Redis에 저장하기
  7. 관리자 로그아웃 감사 로그 저장하기
  8. 브라우저에게 RT / AT 쿠키 만료 지시하기
  9. 관리자 로그아웃 최종 성공

또한 관리자 로그아웃에서는 RT 폐기 성공(DB + Redis)과 기존 AT 차단(cutoff)이 모두 성공해야 로그아웃 성공으로 처리된다.

sequenceDiagram
    participant A as 관리자
    participant S as 서버
    participant DB as DB
    participant R as Redis

    A->>S: DELETE /v1/admin/auth/tokens<br/>관리자 로그아웃 요청(Access Token 포함)
    Note over A,S: AT 전달 방식<br/>1순위 : Authorization Header<br/>2순위 : Access Token 쿠키
    S->>S: Access Token 추출 및 유효성 검증
    alt Access Token이 없거나 유효하지 않음
    S-->>A: 401(COMMON-004 / UNAUTHENTICATED)
    end
    S->>S: Access Token에서 인증 정보(id, type, role) 추출
    alt type 또는 role 클레임이 없음
    S-->>A: 401(COMMON-004 / UNAUTHENTICATED)
    end
    S->>S: Access Token에서 발급 시각(iat) 추출
    S->>R: Access Token cutoff 조회<br/>(로그아웃 이전에 발급된 AT인지 확인)
    alt cutoff 조회 중 Redis 장애
    Note right of S: cutoff 조회에 실패해도 JWT가 유효하면 관리자 인증을 계속 진행함
    end
    S->>S: Access Token 유효성 검증(발급 시각과 cutoff 비교)
    alt cutoff 이전에 발급된 Access Token인 경우
    S-->>A: 401(COMMON-004 / UNAUTHENTICATED)
    end
    S->>S: Access Token 기반 관리자 인증 및 TYPE_ADMIN 권한 확인
    alt 관리자 권한 없음
    S-->>A: 403(COMMON-005 / ACCESS_DENIED)
    end
    S->>R: 현재 Refresh Token Hash 선조회<br/>(activeRefreshToken)
    alt Redis 조회 실패
    S-->>A: 503(ADMIN-010 / LOGOUT_FAILED)
    end
    S->>DB: 관리자 계정 조회<br/>(DB의 관리자 행 PESSIMISTIC_WRITE 잠금)
    alt 관리자 계정 없음
    S-->>A: 404(ADMIN-009 / ADMIN_NOT_FOUND)
    end
    S->>DB: 기존 Refresh Token Hash 조회
    S->>DB: Refresh Token Hash 및 만료 시각 폐기
    alt DB의 Refresh Token 폐기 실패
    Note over S: DB 처리 실패 시, 로그만 남기고 서버 내부 재시도는 안 함
    end
    S->>S: 폐기할 Refresh Token Hash 결정
    Note over S: RT Hash 선택 순서<br/>1순위 : DB 폐기 전 확보한 Hash<br/>2순위 : Redis에서 선조회한 active Hash
    alt DB/Redis에 Refresh Token Hash가 모두 없음
    Note over S: Refresh Token 폐기 완료로 판단<br/>Redis 삭제 생략
    else 폐기할 Refresh Token Hash 존재
    S->>R: Refresh Token 폐기(Lua Script 실행)
    Note over R: refreshToken 폐기<br/>activeRefreshToken은 현재 Hash가 폐기 대상 Hash와 같을 때만 폐기
    alt Refresh Token 폐기 실패
    Note over S: Redis 폐기 실패 시, 로그만 남기고 서버 내부 재시도는 안 함
    end
    end
    alt DB 또는 Redis의 Refresh Token 폐기 실패
    S-->>A: 503(ADMIN-010 / LOGOUT_FAILED)
    Note over A,S: 클라이언트 재요청 시 남은 작업을 멱등적으로 재처리
    end
    S->>S: 현재 시각을 Access Token cutoff로 설정
    S->>R: cutoff 저장<br/>(로그아웃 전에 발급된 Access Token 차단)
    alt cutoff 저장 실패
    S-->>A: 503(ADMIN-010 / LOGOUT_FAILED)
    end
    S->>DB: ADMIN_LOGOUT 감사 로그 저장
    alt 감사 로그 저장 실패
    Note over S: 감사 로그 저장 실패 시, 로그만 남기고 로그아웃은 성공 처리
    end
    S-->>A: Refresh Token / Access Token 쿠키 만료 지시
    S-->>A: 관리자 로그아웃 성공(204 No Content)
Loading

4. 트러블 슈팅

4.1 관리자 로그아웃의 성공 여부 판단 기준 설계

4.1.1 문제점

관리자 로그아웃 시, RT를 DB와 Redis에서 모두 폐기해야 한다. 하지만 두 저장소 중 하나에 장애가 발생하면 한쪽의 RT만 폐기되어 두 저장소의 토큰 상태가 서로 달라질 수 있다.

4.1.2 해결 방법(1차) : DB를 RT 폐기 여부 판단의 최종 기준으로 사용

  1. 설명 : DB에서 RT가 폐기되면 로그아웃 성공으로 판단하는 방식이다.
  2. 선택 이유 : Redis는 인메모리 저장소이므로 데이터 유실 가능성이 있지만, DB는 현재 RT Hash를 영속적으로 저장한다. 따라서 Redis보다 DB를 더 안정적인 저장소라고 판단하여 DB를 RT 폐기 여부의 최종 판단 기준으로 사용했다.
  3. 문제점 : DB에 장애가 발생하면 Redis의 RT가 정상적으로 폐기되어도 로그아웃에 실패한다. 즉, DB 장애 상황에는 대응하기 어렵다는 문제가 있었다.

4.1.3 해결 방법(2차) : DB 또는 Redis 한쪽만 RT 폐기에 성공해도 로그아웃 성공 + 스케줄러 재처리

  1. 설명 : DB 또는 Redis 중 한쪽이라도 RT 폐기에 성공하면 로그아웃을 성공으로 처리한다. 실패한 저장소의 작업은 별도로 기록해 두고, 스케줄러가 일정 시간마다 다시 처리하여 두 저장소의 상태를 최종적으로 맞추는 방식이다.
  2. 선택 이유 (1) 한쪽 저장소만 기준으로 판단하면, 기준이 되는 저장소의 장애에 대응하기 어렵기 때문이다. (2) 서버 문제로 로그아웃이 실패하는 상황을 줄여 UX를 개선하고자 했기 때문이다. (3) 두 저장소의 상태 불일치를 방치하지 않고 복구하기 위해서이다.
  3. 문제점 (1) 실패 작업 기록, 재시도 로직, 스케줄러가 추가되면서 구조와 운영 복잡도가 증가한다. (2) 프로젝트의 핵심 기능이 아닌 인증/인가 기능에 과도한 자원이 사용될 수 있다.

4.1.4 해결 방법(최종) : DB와 Redis에서 모두 RT 폐기에 성공한 경우만 로그아웃 성공

  1. 설명 : DB와 Redis에서 모두 RT 폐기에 성공한 경우에만 로그아웃을 성공으로 처리한다. 둘 중 하나라도 RT 폐기에 실패하면 LOGOUT_FAILED를 반환하고, 클라이언트가 로그아웃을 재요청하도록 한다. 재요청이 들어오면 이미 폐기된 저장소의 작업은 그대로 유지하고, 아직 폐기되지 않은 저장소의 작업을 다시 수행할 수 있도록 멱등적으로 처리한다.
  2. 선택 이유 (1) 인증/인가 영역의 구조와 운영 비용을 단순화할 수 있기 때문이다. (2) 일시적인 DB 또는 Redis 장애는 클라이언트의 재요청만으로 복구할 수 있기 때문이다.
  3. 평가 (1) 긍정적 평가 : 추가 자원과 운영 복잡도를 줄이면서도, 기존 구조를 비교적 적은 비용으로 개선할 수 있다. (2) 부정적 평가 : DB 또는 Redis 장애가 지속되면 로그아웃 실패가 반복될 수 있으므로 UX가 저하될 가능성이 있다.
⚠️ **GitHub.com Fallback** ⚠️