들어가며
JWT 인증 방식은 서버가 상태를 관리하지 않아도 되기 때문에 확장성이 뛰어나다는 장점이 있습니다. 하지만 토큰이 탈취될 경우 보안 문제가 발생할 수 있다는 점은 늘 고민거리입니다. 이를 보완하기 위해 일반적으로 Access Token의 유효기간을 짧게 설정하고, 만료 시에는 Refresh Token을 사용해 새로운 Acces Token을 발급받는 방식을 사용합니다. Refresh Token을 Cookie로 사용하는 경우 브라우저를 새로고침하거나 종료했다가 다시 접속해도 로그인 상태를 유지할 수 있다는 장점도 있습니다. 그러나 Refresh Token이 탈취된다면 여전히 심각한 보안 문제가 발생할 수 있습니다. 이를 해결하기 위한 방식이 Refresh Token Rotation입니다.
이번 글에서는 Refresh Token의 개념을 살펴보고, Refresh Token Rotation을 구현하는 방법을 설명합니다. 더불어 다중 기기 로그인, 접속 수 제한, 개별 로그아웃, 전체 로그아웃을 어떻게 처리할 수 있는지도 함께 알아보겠습니다. 이 글에서는 Spring Kotlin로 작성한 코드를 기반으로 설명하니 참고하시면 됩니다.
이 글에서는 JWT의 기본 지식을 설명하지 않습니다. 그러므로 다른 JWT 관련 블로그 글을 먼저 보시는 것을 추천합니다.
Refresh Token
앞서 설명했듯이, Access Token은 유효기간을 짧게 설정하고 만료되면 Refresh Token을 사용해 새로운 Access Token을 발급받습니다. 이때 주의해야 할 점은 두 토큰의 역할이 명확히 구분된다는 것입니다. Access Token은 인증 과정에서만 사용되며, Refresh Token은 새로운 Access Token을 발급받기 위해서만 사용됩니다. 즉, Access Token으로 Refresh Token을 발급받을 수는 없다는 뜻입니다. 참고로 Refresh Token은 최초 로그인 시 Access Token과 함께 발급됩니다.
Refresh Token 역시 Access Token과 마찬가지로 JWT 형식을 사용합니다. JWT는 서명 기능 덕분에 서버가 상태를 별도로 저장하지 않고도 위변조 여부를 검증할 수 있습니다. 이를 통해 DB 조회 없이도 토큰 자체만으로 유효성을 확인할 수 있어 확장성과 성능이 뛰어납니다.
Refresh Token은 브라우저를 새로고침하거나 종료한 후에도 로그인 상태를 유지할 수 있도록 브라우저에 저장됩니다. 이때 저장 방식으로는 localStorage와 Cookie가 선택지로 존재합니다.
- localStorage는 JavaScript를 통해 접근할 수 있기 때문에, XSS(악성 스크립트 삽입) 공격에 취약할 수 있습니다.
- Cookie는 HttpOnly 옵션을 설정하면 JavaScript에서 접근할 수 없으므로 보안성이 높습니다.
또한 Cookie를 사용하면, 브라우저가 서버로 요청을 보낼 때 자동으로 토큰을 포함하기 때문에, 클라이언트에서 별도로 토큰을 붙이는 작업 없이 인증을 처리할 수 있다는 장점이 있습니다.
Cross-Origin & CORS & Cookie
인증을 구현할 때, Cross-Origin, CORS, Cookie은 항상 헷갈리는 부분인 것 같습니다. 각 개념은 서로 연결되어 있고, 안전한 인증을 위해 반드시 이해해야 하기 때문에 정리하고 넘어가겠습니다.
Cross-Origin
Origin은 protocol + host + port로 이루어져 있습니다.
- protocol: http, https 등
- host: example.com, api.example.com 등
- port: 5173, 8080 등
Cross-Origin은 웹 페이지가 다른 Origin의 리소스에 접근하려 할 때 발생하는 상황을 의미합니다. 어떤 경우가 Cross-Origin인지 예시를 통해 알아보겠습니다.
| 차이 | 프론트 URL | API URL | Cross-Origin인가? |
| X | https://example.com | https://example.com/api | X |
| host | https://example.com | https://api.example.com | O |
| protocol | http://example.com | https://example.com | O |
| port | http://localhost:5173 | http://localhost:8080 | O |
동일 출처 정책(Same-Origin Policy)으로 인해 Cross-Origin 요청에는 제약이 있습니다. 기본적으로 Cookie나 Authorization Header 등 민감 정보를 전송하지 않습니다. 이를 해결하기 위해서 CORS(Cross-Origin Resource Sharing)를 사용하게 됩니다.
CORS(Cross-Origin Resource Sharing)
CORS는 웹 보안 정책인 동일 출처 정책을 우회하여, 한 웹페이지가 다른 출처의 리소스에 안전하게 접근할 수 있도록 허용하는 메커니즘입니다. CORS는 크게 두 가지 방법으로 처리됩니다.
- 단순 요청(Simple Request): GET, HEAD, POST와 같이 특정 조건을 만족하는 요청은 브라우저가 서버에 직접 보냅니다. 서버는 응답 헤더에 Access-Control-Allow-Origin을 포함하여 클라이언트의 요청을 허용한다는 사실을 알려줍니다.
- 프리플라이트 요청(Preflight Request): PUT, DELETE와 같이 단순하지 않은 요청이나 커스텀 헤더(예: Authorization)가 포함된 요청의 경우, 본 요청을 보내기 전에 OPTIONS 메소드를 사용하는 Preflight 요청을 먼저 보냅니다. 이 요청을 통해 브라우저는 서버가 본 요청을 허용하는지 미리 확인하고, 허용될 경우에만 본 요청을 보냅니다.
서로 다른 출처의 클라이언트와 서버가 Authorization Header와 Cookie를 주고받기 위해서는 서버와 클라이언트 모두 설정을 해줘야합니다. 서버에서는 클라이언트의 요청을 허용한다는 CORS 관련 헤더를 응답에 포함시켜야 합니다.
- Access-Control-Allow-Origin: 요청을 허용할 클라이언트의 출처를 명시합니다. 예를 들어, "https://example.com"이 될 수 있고, 모든 출처를 허용할 경우 "*"을 사용합니다.
- Access-Control-Allow-Credentials: 이것을 "true"로 설정하면 Authorization Header나 Cookie와 같은 인증 정보를 포함한 요청을 허용하게 됩니다. 이 Header가 "true"일 경우, Access-Control-Allow-Origin에 "*"을 사용할 수 없고, 반드시 구체적인 출처를 명시해야 합니다.
- Access-Control-Allow-Headers: 요청을 허용할 HTTP Header를 명시합니다. Authorization Header를 허용하기 위해서 "Authorization"이라고 명시하거나 "*"을 사용합니다. 브라우저 호환성에 따라서 "*"에 Authorization이 포함되지 않을 수 있습니다.
클라이언트의 경우, 서버에 요청을 보낼 때, 쿠키와 인증 헤더를 함께 보내도록 명시적으로 설정해야 합니다.
- fetch API: credentials 옵션을 include로 설정해야 합니다.
- Axios: withCredentials 옵션을 true로 설정해야 합니다.
이러한 Cross-Origin 요청, CORS, Preflight 요청은 브라우저가 안전하게 요청을 수행할 수 있는지 확인하는 과정입니다. 즉, 브라우저를 위한 보안 메커니즘일 뿐, 서버가 요청을 직접 막는 것은 아닙니다. 이러한 이유로 Postman과 같은 도구에서는 요청이 정상적으로 작동했지만, 브라우저에서는 동일한 요청이 차단되는 경우가 발생할 수 있습니다.
Spring에서는 WebMvcConfigurer을 상속받고, addCorsMappings 구현을 통해서 CORS를 설정할 수 있습니다. OPTIONS 메서드로 Preflight 요청을 보낼 경우, DispatcherServlet을 거친 후 CORS에 대한 응답을 하게 됩니다. 예제 코드와 같이 인증/인가를 Filter로 구현할 경우, Preflight 요청이 인증 Filter에 막힐 수 있습니다. 이런 문제를 해결하기 위해서 addCorsMapping 구현 대신 CorsFilter를 제일 앞에 둘 수 있습니다. 자세한 내용은 코드를 통해서 확인할 수 있습니다.
Cookie - Domain
서버는 Set-Cookie 응답 헤더를 통해 쿠키를 클라이언트에 전달할 때 Domain 속성을 지정할 수 있습니다. 이 속성은 쿠키가 전송될 수 있는 도메인의 범위를 정의합니다.
- Domain 속성을 지정하지 않으면: 쿠키는 현재 요청이 도착한 서버의 호스트명에만 쿠키를 전송할 수 있습니다.
- Domain 속성을 지정하면: 지정한 도메인과 그 하위 도메인에서도 쿠키를 전송할 수 있습니다.
다만, 서버가 Domain을 임의로 설정할 수 있는 것은 아닙니다. 보안상의 이유로 현재 요청 도메인의 상위 도메인만 지정할 수 있습니다. api.example.com에서 쿠키 발급 시 예시는 다음과 같습니다.
- Domain이 example.com인 경우: 상위 도메인이라서 가능
- Domain이 a.api.example.com인 경우: 하위 도메인이라서 불가능
- Domain이 google.com인 경우: 상위 도메인이 아니라서 불가능
클라이언트 주소가 example.com이고, 서버 주소가 api.example.com인 경우 Cookie Domain이 example.com으로 설정되면, example.com에서 api.example.com으로 요청을 보낼 때 쿠키가 함께 전송됩니다.
마찬가지로, 클라이언트 주소가 dev.example.com이고, 서버 주소가 api.example.com인 경우 Cookie Domain이 example.com으로 설정되면, dev.example.com에서 api.example.com으로 요청 본래 때 쿠키가 함께 전송됩니다.
과거에는 example.com이 아닌 .example.com과 같이 "."을 붙여야 하위 도메인까지 포함되었으나 현대 브라우저에서는 example.com과 .example.com이 동일하게 하위 도메인을 포함한다.
Cookie - Secure
Secure 설정은 Cookie가 HTTPS 연결에서만 전송되도록 강제하여 보안을 강화합니다. HTTP 통신에서는 Cookie가 전송되지 않습니다. 참고로 MDN 공식문서를 확인하면 localhost의 경우 Secure 설정을 해도 무시된다고 합니다.
Cookie - SameSite
Cross-Site는 Cross-Origin과 비슷하지만 다른 개념입니다. Cross-Site는 요청을 보내는 곳과 받는 곳의 사이트가 다른 경우를 말합니다. 사이트는 최상위 도메인(.com, .org)과 그 바로 앞 도메인(google.com, example.org)를 기준으로 합니다.
예를 들어, example.com과 api.example.com은 Cross-Origin이지만 Same-Site입니다. 또한, dev.example.com과 api.example.com도 Same-Site입니다.
최상위 도메인(TLD)과 그 앞 도메인까지라고 했지만 .co.kr이나 .github.io는 Site를 식별할만큼 세분화되지 않습니다. 그래서 eTLD라는 eTLD(effective TLDs)라는 개념이 있고, eTLD+1을 기준으로 SameSite로 보고 있습니다. 예를 들어, alstn113.github.io나 alstn113.co.kr를 기준으로 Same-Site를 판별한다고 볼 수 있습니다.
SameSite 속성에는 Strict, Lax, None이 있습니다. 각각의 옵션에 대해서 알아보겠습니다.
- Strict: Cookie는 오직 동일한 사이트 내의 요청에서만 전송됩니다.
- Lax: 대부분의 브라우저에서 기본값으로 사용됩니다. Strict보다 덜 엄격하며, 동일 사이트 요청에서 Cookie를 전송합니다. 또한, 링크 클릭과 같은 최상위 탐색(Top-Level navigation)에서는 Cross-Origin일지라도 Cookie가 전송됩니다. 즉, example.com이라는 domain으로 생성된 Cookie가 있고, other.com에서 example.com이라는 링크를 클릭하면 Cookie가 함께 전송됨을 의미합니다.
- None: 모든 Cross-Oriogin 요청에서 Cookie를 전송하도록 허용합니다. 이 설정을 사용하려면 Secure 속성도 함께 설정해야 합니다.
localhost:5173과 localhost:8080은 SameSite이기 때문에 Strict, Lax, None 모두 쿠키가 전송됩니다. 참고로 Chrome은 조금 다른 방식의 SameSite 방식을 가지고 있습니다. Chrome 브라우저에서 chrome://flags를 입력후 scheme을 검색하면 알 수 있습니다. 참고하시면 좋을 것 같습니다.

Refresh Token Rotation
Access Token의 탈취 문제를 보완하기 위해서 Access Token의 유효기간을 짧게 하고, Refresh Token을 통해 재발급받게 하였습니다. 하지만 Refresh Token이 탈취 당하게 되면 Access Token을 무제한으로 발급받을 수 있어 이 또한 문제가 됩니다. 이 문제를 해결하기 위해 등장한 것이 바로 Refresh Token Rotation(이하 RTR)입니다. RTR은 Refresh Token으로 새로운 Access Token을 발급받을 때, 기존의 Refresh Token을 무효화하고 새로운 Refresh Token을 재발급하는 방식입니다. 이를 통해 만약 Refresh Token이 공격자에 의해 탈취되더라도 실제 사용자가 Refresh Token을 재발급 받거나 운영자가 Refresh Token을 무효화하는 경우 공격자는 더 이상 탈취한 Refresh Token을 사용할 수 없어 Access Token을 발급받을 수 없게 됩니다.
Refresh Token Rotation 재발급 과정과 탈취 대응에 대해서는 Auth0 공식문서를 참고할 수 있습니다.
이 방식을 구현하기 위해서는 Refresh Token에 대한 정보를 저장하고 비교 검증해야 합니다. DB에 저장하고 조회하기에는 느릴 수 있습니다. 서버의 메모리에 저장하면 빠르게 조회할 수 있고, Refresh Token Cookie와 동일하게 만료 시간을 설정할 수 있습니다. 하지만 서버를 수평 확장하는 경우 문제가 생깁니다. Redis가 적절할 것 같습니다.
Redis에 Refresh Token을 저장하는 방식을 고민할 때, Refresh Token이 JWT일 필요가 있는지 고민해볼 수 있습니다. JWT는 불필요한 정보를 포함하기도 하고, 크기가 상대적으로 커서 네트워크 전송 비용이 증가할 수 있습니다. 대신 세션 ID로 쓰이는 불투명 토큰(Opaque Token)으로 대체할 수 있습니다. 불투명 토큰이란 토큰 자체에 어떤 정보도 포함하고 있지 않은, 의미 없는 임의의 문자열입니다. Refresh Token으로 JWT 대신 불투명 토큰을 사용하고 Redis에 키값으로 저장 후 재발급 시마다 비교하고 새로운 값으로 교체할 수 있습니다.
하지만 단점도 있습니다. 만료됐거나 존재하지 않는 Refresh Token으로 재발급 요청이 들어올 경우, 토큰 탈취 여부를 판단해야 하지만 어떤 사용자인지 확인할 방법이 없습니다. 이전에 사용했던 모든 Refresh Token을 저장하면 메모리 낭비가 커질 수 있습니다. 따라서 일정한 트레이드오프를 고려해 JWT를 사용하는 접근이 현실적입니다. 이때 JWT에는 사용자 ID와 서버에서 생성한 임의의 값을 JWT ID(이하 JTI)에 넣고, Redis에서 사용자 ID를 키로 사용하고 값으로 얻은 JTI를 비교 검증할 수 있습니다. JWT를 사용하면 토큰 변조 위험을 방지할 수 있으며, 이전에 발급된 Refresh Token의 존재 여부를 정확히 확인할 수 있습니다.
Family Id
위의 방식으로 RTR을 구현하면 충분히 작동하지만, 한 가지 문제점이 있습니다. 바로 동시에 하나의 기기에서만 로그인 상태를 유지할 수 있다는 점입니다. 예를 들어, 데스크탑 브라우저에서 로그인 후 모바일 브라우저에서 다시 로그인하면, 기존 데스크탑의 Refresh Token 쿠키가 무효화됩니다. 이는 마치 한 번에 한 기기만 접속이 허용되는 것처럼 보입니다. 물론, 서비스 정책에 따라 한 기기 접속만 허용할 수도 있습니다. 하지만 여러 기기에서 동시 접속을 허용하거나, 또는 전체 접속 가능한 기기 수를 제한하는 방식의 요구사항이 있을 수도 있습니다.
이 기능을 구현하기 위해 Family Id라는 개념을 사용할 수 있습니다. Family Id는 첫 로그인 시 서버에서 생성되는 값으로 Refresh Token에 부여됩니다. Refresh Token 재발급 시 이전 Refresh Token에 부여된 Family Id를 새로 생성된 Refresh Token이 이어받게 됩니다. JWT에 Family Id를 추가하고, Redis에서 기존 키에 Family Id를 추가합니다. 재발급 시 Refresh Token(JWT)에서 얻은 사용자 ID, Family Id를 키로 하여, 값(JTI)을 얻고, JWT의 JTI와 비교하여 검증할 수 있습니다.
지금까지 구현한 방식의 문제점은 무한한 기기에서 로그인할 수 있다는 것이고, 전체 기기에서 로그아웃하는 로직을 만들기가 어렵다는 문제가 있습니다. 예를 들어, 현재 Redis에서 "refresh-token:$accountId:$familyId"라는 키를 사용하고 있다고 하겠습니다. 이 경우, 전체 로그아웃을 하기 위해서 "refresh-token:$accontId:*" 패턴의 키들을 찾아야 합니다. 이 때 Keys 명령어를 사용하게 되는데 Blocking 방식으로 Redis 전체 키를 탐색하기 때문에 성능적으로 문제가 있습니다.
이를 구조적으로 해결하기 위해서 역인덱스를 구성할 수 있습니다. 사용자 ID가 키이고 Family Id가 값인 집합 자료구조를 사용하면 사용자에 대해서 전체 접속 수와 연결 정보를 알 수 있습니다. 이 때 ZSET을 사용하면 Score를 부여하여 정렬된 집합 자료구조를 만들 수 있습니다. 각 Family Id에 대해서 현재 Refresh Token의 만료 시각을 Score로 부여하면 최대 연결 수를 제한했을 때, 제일 오래 전에 재발급한 family Id의 연결을 끊을 수 있습니다. JWT의 사용자 ID와 Family Id를 알기 때문에 현재 접속 중인(Family Id가 Redis에 있는) 연결을 개별적으로 삭제하여 로그아웃할 수 있고, 사용자 ID를 통해 전체 연결 정보를 삭제하여 전체 로그아웃을 할 수 있습니다.
위에 설명했던 대부분의 기능은 여러 Redis 명령어를 사용하게 됩니다. 예시 코드와 같이 재발급 로직 등을 원자적으로 처리하기 위해 Lua Script를 사용할 수 있습니다.
SET이나 ZSET은 키에 대해서는 만료일을 지정할 수 있지만 값에 대해서는 만료일을 지정할 수 없습니다. 그래서 "refresh-token:$accountId:$familyId" -> JTI와 같은 것은 만료일이 되면 사라지나 사용자 ID가 키인 ZSET에 있는 값은 남아있게 됩니다. 하지만 연결 개수 제한으로 오래된 Refresh Token은 자동으로 사라질 수 있고, Score가 발급 시각이므로 연결 여부를 파악할 수 있습니다.
추가적인 작업을 줄이기 위해서 ZSET 자료구조를 사용하여 구조적으로 해결했습니다. 다른 방법으로는 주기적인 삭제 처리를 해주거나, Redis의 키 만료 이벤트를 구독하여 처리할 수 있습니다.
Overlap Period
Access Token의 유효기간을 짧게한만큼 Refresh Token을 사용해 토큰을 재발급 받는 주기도 짧습니다. RTR 방식을 사용하게 되면서 토큰 재발급에 실패하면 다시 로그인이 필요합니다. 만약 모바일 브라우저를 주로 사용하는 서비스가 있고, 지하철을 주로 이용하는 사용자가 있다고 가정하겠습니다. 사용자는 토큰 재발급 요청을 했고, 서버는 그 요청을 받아 Refresh Token을 교체했습니다. 하지만 네트워크 타임아웃으로 사용자는 응답을 받지 못할 수 있습니다. 이후에 동일한 Refresh Token으로 재발급을 요청하는 경우 JTI가 달라 토큰 탈취로 인식하고 해당 Family Id를 무효화하게 됩니다. 이런 일이 반복되면 사용자 경험을 해치게 됩니다.
이를 보완하기 위해서, Overlap Period라는 개념을 사용할 수 있습니다. 이는 새로운 Refresh Token이 발급된 후에도 이전 Refresh Token이 짧은 시간 동안 유효하게 유지되는 기간을 의미합니다. 현재 키를 "refresh-token:$accountId:$familyId:current"라 하고, 이전 키를 "refresh-token:$accountId:$familyId:previous"라 합니다. 현재 키와 이전 키를 모두 조회하고, 각각에 대해서 비교를 합니다.
- 현재 키와 값이 일치하는 경우: 현재 키에 해당하는 값을 이전 키의 값으로 옮기고 10초 정도 짧은 유효기간을 부여합니다. 현재 키에 대한 값으로 새로 발급한 토큰의 JTI를 부여합니다. 사용자 ID로 된 집합에서 familyId의 Score(만료 시각)을 토큰과 동일하게 합니다.
- 이전 키와 값이 일치하는 경우: 현재 키에 대한 값으로 새로 발급한 토큰의 JTI를 부여합니다. 이전 키는 제거합니다. 사용자 ID로 된 집합에서 familyId의 Score(만료 시각)을 토큰과 동일하게 합니다.
- 모든 키와 값이 일치하지 않는 경우: 현재 키와 이전 키를 만료시키고, 사용자 ID로 된 집합에서 familyId를 제거한다.
Overlap Period를 도입함으로써 재발급 응답을 받지 못하는 경우나 재발급 동시 요청에 대해서 사용자 경험을 향상시킬 수 있습니다. 하지만 공격자에게 공격할 기회 또한 제공하게 됩니다. 그러므로 서비스에 따라 트레이드 오프하면서 도입해야할 것 같습니다.
고민되는 점
탈취된 Refresh Token은 사용자가 재발급을 받거나 서버에서 해당 Refresh Token을 무효화하면 더 이상 사용할 수 없습니다. 사용자가 직접 로그아웃하면 그동안 사용하던 Refresh Token을 무효화할 수 있고, 서버 측에서도 의심되는 토큰을 찾아 무효화할 수 있습니다. 하지만 이 두 경우 모두 이미 발급된 Access Token은 만료될 때까지 유효하게 남아 있어, 탈취된 Access Token으로 계속 접근이 가능한 문제점이 있습니다.
이를 완화하려면 Access Token에도 JTI(또는 FamilyId 등 식별자)를 부여하고 로그아웃이나 강제 무효화 시 해당 JTI를 블랙리스트에 등록하여 확인하는 방법이 있습니다. 다만 이 방식은 매 요청마다 블랙리스트 검사를 수행해야 하므로, JWT의 장점인 무상태성을 부분적으로 잃게 됩니다.
요약하면, JWT는 무상태성으로 확장성을 제공하는 반면, 보안을 강화하기 위해 블랙리스트나 Redis 기반 검증을 추가하면 결국 상태 기반 세션 방식과 유사해집니다. 따라서 서비스의 보안 요구 수준과 확장성 요구사항을 고려해 JWT 또는 세션(Redis)방식 중 적절한 인증 방식을 선택하는 것이 바람직할 것 같습니다.
마치며
이 글에서는 Refresh Token을 구현하기 위한 Cross-Origin, CORS, Cookie 설정을 먼저 살펴본 뒤, Refresh Token Rotation의 개념과 제가 직접 구현한 방식, 그리고 그 과정에서 마주한 문제점을 공유했습니다. 처음에는 예시 코드까지 포함해 상세히 설명하려 했지만, 오히려 글이 복잡해지는 것 같아 설명 위주로 정리했습니다. 그래도 실제 구현 코드는 Refresh Token Rotation뿐만 아니라 인증 관련해서도 충분히 참고할 만한 가치가 있으니, 시간이 되신다면 한 번 훑어보시는 것을 추천드립니다. 혹시 글에서 잘못된 부분이 있거나 더 나은 구현 방식을 알고 계신다면 댓글로 공유해주시면 감사하겠습니다.
참고
- Auth0 공식문서 / Refresh Token Rotation 설명
- Auth0 공식문서 / Refresh Token Rotation Overlap Period 설명
- Spoqa 기술 블로그 / 인증 개선 글
- 코드잇 블로그 / 쿠키 SameSite 옵션 설명
- Inpa 블로그 / JWT 정리 글
'서버' 카테고리의 다른 글
| Redis 분산락, 직접 구현하면서 이해해보자! - 1편: Spin Lock 방식 (0) | 2025.10.31 |
|---|---|
| POST 요청의 중복 처리를 막는 멱등키 헤더 구현 (Interceptor + Redis) (0) | 2025.09.17 |
| @Transactional과 @Cacheable을 같이 쓰면 어떤 것이 먼저 동작할까? (1) | 2025.09.07 |
| Spring Security, 직접 구현하면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL (0) | 2025.05.02 |
| Spring Security, 직접 구현하면서 이해해보자! - 2편: 인증, 인가, 예외 처리 (0) | 2025.05.02 |
