From 0fac055799951e653f0a988383653f2e00e02010 Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 22:16:16 +0900
Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20BCryptPasswordEncoder=EB=A5=BC?=
=?UTF-8?q?=20DelegatingPasswordEncoder=EB=A1=9C=20=EC=A0=84=ED=99=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
프리픽스 기반 알고리즘 식별을 도입하여 향후 Argon2id 등으로
점진적 마이그레이션할 수 있는 경로를 확보한다.
---
src/main/java/org/sopt/global/config/SecurityConfig.java | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/main/java/org/sopt/global/config/SecurityConfig.java b/src/main/java/org/sopt/global/config/SecurityConfig.java
index 9f8f1dd..0fb0c17 100644
--- a/src/main/java/org/sopt/global/config/SecurityConfig.java
+++ b/src/main/java/org/sopt/global/config/SecurityConfig.java
@@ -11,7 +11,7 @@
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
-import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -58,8 +58,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.build();
}
+ /**
+ * {@code {bcrypt}} 프리픽스 기반의 DelegatingPasswordEncoder를 제공한다.
+ * 새로 저장되는 비밀번호는 {@code {bcrypt}} 프리픽스가 붙어 알고리즘을 식별하며,
+ * 향후 Argon2id 등으로 점진적 마이그레이션이 가능하다.
+ */
@Bean
public PasswordEncoder passwordEncoder() {
- return new BCryptPasswordEncoder();
+ return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
From 891786fbcde9fb9c1c091e03493930ea22dad53a Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 22:16:35 +0900
Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20Access=20Token=20=EB=B8=94?=
=?UTF-8?q?=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A5=BC=20Caffeine=20?=
=?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=9C=EB=A1=9C=20=EC=A0=84?=
=?UTF-8?q?=ED=99=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
RDB 블랙리스트 엔티티와 JPA 레포지토리를 제거하고,
per-entry TTL을 토큰 잔여 유효 시간에 정렬하는 Caffeine 캐시로 대체한다.
매 요청 DB 조회를 인메모리 조회로 절감하고, 만료된 항목은 자동 제거된다.
사용자 역할 조회도 Caffeine 캐시(5분 TTL)를 적용하여 DB 부하를 줄인다.
---
build.gradle | 3 +
.../domain/model/AccessTokenBlacklist.java | 49 ---------------
.../AccessTokenBlacklistRepository.java | 16 ++---
.../AccessTokenBlacklistJpaRepository.java | 18 ------
.../AccessTokenBlacklistRepositoryImpl.java | 38 ------------
...affeineAccessTokenBlacklistRepository.java | 52 ++++++++++++++++
.../service/UserCommandService.java | 12 ++--
.../JwtAuthenticationFilter.java | 60 ++++++++++++++++---
8 files changed, 118 insertions(+), 130 deletions(-)
delete mode 100644 src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java
delete mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java
delete mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java
create mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java
diff --git a/build.gradle b/build.gradle
index 15636d3..e64ae15 100644
--- a/build.gradle
+++ b/build.gradle
@@ -43,6 +43,9 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
+ // Cache
+ implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
+
// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
diff --git a/src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java b/src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java
deleted file mode 100644
index 5de7fc4..0000000
--- a/src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package org.sopt.domain.auth.domain.model;
-
-import jakarta.persistence.Column;
-import jakarta.persistence.Entity;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.GenerationType;
-import jakarta.persistence.Id;
-import jakarta.persistence.Table;
-import org.sopt.global.entity.BaseTimeEntity;
-
-import java.time.LocalDateTime;
-
-/**
- * 로그아웃된 access token의 jti를 저장하는 블랙리스트 엔티티.
- */
-@Entity
-@Table(name = "access_token_blacklists")
-public class AccessTokenBlacklist extends BaseTimeEntity {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- @Column(nullable = false, unique = true, length = 36)
- private String tokenId;
-
- @Column(nullable = false)
- private Long userId;
-
- @Column(nullable = false)
- private LocalDateTime expiresAt;
-
- protected AccessTokenBlacklist() {
- }
-
- /**
- * 로그아웃된 access token 정보를 생성한다.
- *
- * @param tokenId JWT ID
- * @param userId 토큰 소유 사용자 ID
- * @param expiresAt access token 만료 시각
- */
- public AccessTokenBlacklist(String tokenId, Long userId, LocalDateTime expiresAt) {
- this.tokenId = tokenId;
- this.userId = userId;
- this.expiresAt = expiresAt;
- }
-
-}
diff --git a/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java b/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java
index 3d7d401..7f9862e 100644
--- a/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java
+++ b/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java
@@ -1,25 +1,25 @@
package org.sopt.domain.auth.domain.repository;
-import org.sopt.domain.auth.domain.model.AccessTokenBlacklist;
+import java.time.LocalDateTime;
/**
- * access token 블랙리스트 저장소 추상화.
+ * 로그아웃된 access token을 추적하는 블랙리스트 저장소 추상화.
*/
public interface AccessTokenBlacklistRepository {
/**
- * 블랙리스트를 저장한다.
+ * access token의 JWT ID를 블랙리스트에 등록한다.
*
- * @param accessTokenBlacklist 저장할 블랙리스트
- * @return 저장된 블랙리스트
+ * @param tokenId JWT ID (jti 클레임)
+ * @param expiresAt access token 만료 시각. 구현체는 이 시각까지만 항목을 유지한다.
*/
- AccessTokenBlacklist save(AccessTokenBlacklist accessTokenBlacklist);
+ void add(String tokenId, LocalDateTime expiresAt);
/**
* JWT ID가 블랙리스트에 존재하는지 확인한다.
*
* @param tokenId JWT ID
- * @return 존재 여부
+ * @return 블랙리스트에 존재하면 {@code true}
*/
- boolean existsByTokenId(String tokenId);
+ boolean exists(String tokenId);
}
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java
deleted file mode 100644
index d60b0de..0000000
--- a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package org.sopt.domain.auth.infrastructure;
-
-import org.sopt.domain.auth.domain.model.AccessTokenBlacklist;
-import org.springframework.data.jpa.repository.JpaRepository;
-
-/**
- * AccessTokenBlacklist 엔티티에 대한 Spring Data JPA 저장소.
- */
-public interface AccessTokenBlacklistJpaRepository extends JpaRepository {
-
- /**
- * JWT ID가 블랙리스트에 존재하는지 확인한다.
- *
- * @param tokenId JWT ID
- * @return 존재 여부
- */
- boolean existsByTokenId(String tokenId);
-}
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java b/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java
deleted file mode 100644
index f0c7d12..0000000
--- a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.sopt.domain.auth.infrastructure;
-
-import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.domain.model.AccessTokenBlacklist;
-import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
-import org.springframework.stereotype.Repository;
-
-/**
- * access token 블랙리스트 저장소를 JPA 구현체에 연결하는 어댑터.
- */
-@Repository
-@RequiredArgsConstructor
-public class AccessTokenBlacklistRepositoryImpl implements AccessTokenBlacklistRepository {
-
- private final AccessTokenBlacklistJpaRepository accessTokenBlacklistJpaRepository;
-
- /**
- * 블랙리스트를 저장한다.
- *
- * @param accessTokenBlacklist 저장할 블랙리스트
- * @return 저장된 블랙리스트
- */
- @Override
- public AccessTokenBlacklist save(AccessTokenBlacklist accessTokenBlacklist) {
- return accessTokenBlacklistJpaRepository.save(accessTokenBlacklist);
- }
-
- /**
- * JWT ID가 블랙리스트에 존재하는지 확인한다.
- *
- * @param tokenId JWT ID
- * @return 존재 여부
- */
- @Override
- public boolean existsByTokenId(String tokenId) {
- return accessTokenBlacklistJpaRepository.existsByTokenId(tokenId);
- }
-}
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java
new file mode 100644
index 0000000..9a07b57
--- /dev/null
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java
@@ -0,0 +1,52 @@
+package org.sopt.domain.auth.infrastructure;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
+import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
+import org.springframework.stereotype.Repository;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+/**
+ * Caffeine 로컬 캐시 기반 access token 블랙리스트 구현체.
+ *
+ * 각 항목의 TTL을 토큰의 잔여 유효 시간에 정렬하여,
+ * 토큰이 자연 만료되는 시점에 블랙리스트 항목도 함께 제거된다.
+ */
+@Repository
+public class CaffeineAccessTokenBlacklistRepository implements AccessTokenBlacklistRepository {
+
+ private final Cache cache = Caffeine.newBuilder()
+ .expireAfter(new Expiry() {
+ @Override
+ public long expireAfterCreate(String key, LocalDateTime expiresAt, long currentTime) {
+ Duration remaining = Duration.between(LocalDateTime.now(), expiresAt);
+ return remaining.isNegative() ? 0 : remaining.toNanos();
+ }
+
+ @Override
+ public long expireAfterUpdate(String key, LocalDateTime expiresAt, long currentTime, long currentDuration) {
+ return currentDuration;
+ }
+
+ @Override
+ public long expireAfterRead(String key, LocalDateTime expiresAt, long currentTime, long currentDuration) {
+ return currentDuration;
+ }
+ })
+ .build();
+
+ /** {@inheritDoc} */
+ @Override
+ public void add(String tokenId, LocalDateTime expiresAt) {
+ cache.put(tokenId, expiresAt);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean exists(String tokenId) {
+ return cache.getIfPresent(tokenId) != null;
+ }
+}
diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
index 85c99ef..c90bf31 100644
--- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
+++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
@@ -1,7 +1,6 @@
package org.sopt.domain.user.application.service;
import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.domain.model.AccessTokenBlacklist;
import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
import org.sopt.domain.auth.domain.repository.RefreshTokenRepository;
import org.sopt.domain.user.application.dto.CreateUserCommand;
@@ -67,13 +66,10 @@ public void deleteUser(AuthenticatedUser authenticatedUser) {
User user = findUserOrThrow(authenticatedUser.userId());
user.markDeleted();
refreshTokenRepository.deleteByUserId(authenticatedUser.userId());
- if (!accessTokenBlacklistRepository.existsByTokenId(authenticatedUser.tokenId())) {
- accessTokenBlacklistRepository.save(new AccessTokenBlacklist(
- authenticatedUser.tokenId(),
- authenticatedUser.userId(),
- authenticatedUser.accessTokenExpiresAt()
- ));
- }
+ accessTokenBlacklistRepository.add(
+ authenticatedUser.tokenId(),
+ authenticatedUser.accessTokenExpiresAt()
+ );
}
/**
diff --git a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java
index 7cf298e..9825768 100644
--- a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java
+++ b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java
@@ -1,12 +1,14 @@
package org.sopt.global.security.authentication;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import lombok.RequiredArgsConstructor;
import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
import org.sopt.domain.user.domain.model.User;
+import org.sopt.domain.user.domain.model.UserRole;
import org.sopt.domain.user.domain.repository.UserRepository;
import org.sopt.global.security.exception.JwtAuthenticationException;
import org.sopt.global.security.jwt.JwtTokenPayload;
@@ -20,13 +22,16 @@
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
+import java.time.Duration;
import java.util.List;
/**
* Authorization Bearer 토큰을 읽어 SecurityContext에 인증 사용자를 저장한다.
+ *
+ * 사용자 역할 조회 결과를 Caffeine 캐시(5분 TTL)에 보관하여
+ * 매 요청마다 DB를 조회하는 비용을 절감한다.
*/
@Component
-@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final BearerTokenResolver bearerTokenResolver;
@@ -35,6 +40,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final UserRepository userRepository;
private final AuthenticationEntryPoint authenticationEntryPoint;
+ private final Cache userRoleCache = Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterWrite(Duration.ofMinutes(5))
+ .build();
+
+ public JwtAuthenticationFilter(
+ BearerTokenResolver bearerTokenResolver,
+ JwtTokenProvider jwtTokenProvider,
+ AccessTokenBlacklistRepository accessTokenBlacklistRepository,
+ UserRepository userRepository,
+ AuthenticationEntryPoint authenticationEntryPoint
+ ) {
+ this.bearerTokenResolver = bearerTokenResolver;
+ this.jwtTokenProvider = jwtTokenProvider;
+ this.accessTokenBlacklistRepository = accessTokenBlacklistRepository;
+ this.userRepository = userRepository;
+ this.authenticationEntryPoint = authenticationEntryPoint;
+ }
+
@Override
protected void doFilterInternal(
HttpServletRequest request,
@@ -45,12 +69,14 @@ protected void doFilterInternal(
try {
if (token != null) {
JwtTokenPayload payload = jwtTokenProvider.getPayload(token, JwtTokenType.ACCESS);
- if (accessTokenBlacklistRepository.existsByTokenId(payload.tokenId())) {
+ if (accessTokenBlacklistRepository.exists(payload.tokenId())) {
throw new JwtAuthenticationException("Access token is blacklisted.");
}
- User user = userRepository.findById(payload.userId())
- .orElseThrow(() -> new JwtAuthenticationException("User is inactive."));
- UsernamePasswordAuthenticationToken authentication = createAuthentication(payload, user);
+ UserRole role = userRoleCache.get(payload.userId(), this::loadUserRole);
+ if (role == null) {
+ throw new JwtAuthenticationException("User is inactive.");
+ }
+ UsernamePasswordAuthenticationToken authentication = createAuthentication(payload, role);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (JwtAuthenticationException e) {
@@ -61,10 +87,26 @@ protected void doFilterInternal(
filterChain.doFilter(request, response);
}
- private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, User user) {
+ /**
+ * 사용자 역할 캐시에서 해당 사용자를 제거한다.
+ * 역할 변경이나 사용자 삭제 시 호출하여 캐시 정합성을 유지한다.
+ *
+ * @param userId 캐시에서 제거할 사용자 ID
+ */
+ public void evictUserRole(Long userId) {
+ userRoleCache.invalidate(userId);
+ }
+
+ private UserRole loadUserRole(Long userId) {
+ return userRepository.findById(userId)
+ .map(User::getRole)
+ .orElse(null);
+ }
+
+ private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, UserRole role) {
AuthenticatedUser principal = new AuthenticatedUser(
payload.userId(),
- user.getRole(),
+ role,
payload.tokenId(),
payload.expiresAt()
);
@@ -72,7 +114,7 @@ private static UsernamePasswordAuthenticationToken createAuthentication(JwtToken
return new UsernamePasswordAuthenticationToken(
principal,
null,
- List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))
+ List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))
);
}
}
From 876ba9fdc35c72ace46c6de54cdc1ec53c0eea77 Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 22:16:45 +0900
Subject: [PATCH 3/9] =?UTF-8?q?feat:=20Refresh=20Token=20Reuse=20Detection?=
=?UTF-8?q?=EA=B3=BC=20Grace=20Period=20=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
회전된 구 토큰이 재사용되면 사용자의 토큰을 전체 무효화하여
탈취를 감지·차단한다. 회전 직후 10초간 구 토큰을 Grace Period로
허용하여 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다.
---
.../auth/application/service/AuthService.java | 79 +++++++++++--------
.../auth/domain/exception/AuthErrorCode.java | 1 +
.../RefreshTokenGracePeriodStore.java | 44 +++++++++++
3 files changed, 90 insertions(+), 34 deletions(-)
create mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
index 7da0428..8270d55 100644
--- a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
+++ b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
@@ -1,17 +1,18 @@
package org.sopt.domain.auth.application.service;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry;
import org.sopt.domain.auth.application.dto.AuthTokenResult;
import org.sopt.domain.auth.application.dto.OAuthUserProfile;
import org.sopt.domain.auth.domain.exception.AuthErrorCode;
-import org.sopt.domain.auth.domain.model.AccessTokenBlacklist;
import org.sopt.domain.auth.domain.model.OAuthProvider;
import org.sopt.domain.auth.domain.model.RefreshToken;
import org.sopt.domain.auth.domain.model.SocialAccount;
import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
import org.sopt.domain.auth.domain.repository.RefreshTokenRepository;
import org.sopt.domain.auth.domain.repository.SocialAccountRepository;
+import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore;
import org.sopt.domain.auth.infrastructure.RefreshTokenHasher;
import org.sopt.domain.user.domain.exception.UserErrorCode;
import org.sopt.domain.user.domain.model.User;
@@ -28,8 +29,18 @@
import java.time.LocalDateTime;
/**
- * 로그인과 토큰 재발급을 담당하는 인증 서비스.
+ * 로그인, 토큰 재발급, 로그아웃을 담당하는 인증 서비스.
+ *
+ * Refresh Token Rotation + Reuse Detection + Grace Period 전략을 적용한다.
+ *
+ * - Rotation: 재발급 시 구 토큰을 즉시 무효화하고 새 토큰을 발급한다.
+ * - Reuse Detection: 이미 회전된 토큰이 다시 사용되면 해당 사용자의
+ * 토큰을 전체 무효화하여 탈취를 차단한다.
+ * - Grace Period: 회전 직후 10초간 구 토큰을 허용하여
+ * 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다.
+ *
*/
+@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
@@ -45,11 +56,12 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenHasher refreshTokenHasher;
+ private final RefreshTokenGracePeriodStore gracePeriodStore;
/**
* 로그인 ID와 비밀번호를 검증하고 토큰을 발급한다.
*
- * @param loginId 로그인 ID
+ * @param loginId 로그인 ID
* @param password 평문 비밀번호
* @return 발급된 토큰
*/
@@ -94,16 +106,38 @@ public AuthTokenResult loginWithGoogle(String idToken) {
/**
* refresh token을 검증하고 새 토큰 쌍을 발급한다.
*
- * @param refreshToken refresh token
+ * 재발급 흐름:
+ *
+ * - 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
+ * - DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
+ * - 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
+ *
+ *
+ * @param refreshToken refresh token 원문
* @return 새로 발급된 토큰
*/
public AuthTokenResult reissue(String refreshToken) {
Long userId = jwtTokenProvider.getUserId(refreshToken, JwtTokenType.REFRESH);
User user = findActiveUser(userId);
- RefreshToken savedRefreshToken = findSavedRefreshToken(userId);
- validateRefreshToken(refreshToken, savedRefreshToken, userId);
+ String incomingHash = refreshTokenHasher.hash(refreshToken);
+
+ RefreshToken savedToken = refreshTokenRepository.findByUserId(userId)
+ .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN));
+
+ if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash())
+ && !savedToken.isExpired()) {
+ gracePeriodStore.store(incomingHash, userId);
+ return issueAndSaveTokens(user);
+ }
- return rotateTokens(user);
+ Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash);
+ if (graceUserId != null && graceUserId.equals(userId)) {
+ return issueAndSaveTokens(user);
+ }
+
+ refreshTokenRepository.deleteByUserId(userId);
+ log.warn("Refresh token reuse detected: userId={}", userId);
+ throw new BaseException(AuthErrorCode.REFRESH_TOKEN_REUSE_DETECTED);
}
/**
@@ -113,15 +147,10 @@ public AuthTokenResult reissue(String refreshToken) {
*/
public void logout(AuthenticatedUser authenticatedUser) {
refreshTokenRepository.deleteByUserId(authenticatedUser.userId());
- if (!accessTokenBlacklistRepository.existsByTokenId(authenticatedUser.tokenId())) {
- accessTokenBlacklistRepository.save(
- new AccessTokenBlacklist(
- authenticatedUser.tokenId(),
- authenticatedUser.userId(),
- authenticatedUser.accessTokenExpiresAt()
- )
- );
- }
+ accessTokenBlacklistRepository.add(
+ authenticatedUser.tokenId(),
+ authenticatedUser.accessTokenExpiresAt()
+ );
}
private AuthTokenResult issueAndSaveTokens(User user) {
@@ -139,29 +168,11 @@ private AuthTokenResult issueAndSaveTokens(User user) {
);
}
- private AuthTokenResult rotateTokens(User user) {
- return issueAndSaveTokens(user);
- }
-
private User findActiveUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS));
}
- private RefreshToken findSavedRefreshToken(Long userId) {
- return refreshTokenRepository.findByUserId(userId)
- .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN));
- }
-
- private void validateRefreshToken(String refreshToken, RefreshToken savedRefreshToken, Long userId) {
- if (refreshTokenHasher.matches(refreshToken, savedRefreshToken.getTokenHash())
- && !savedRefreshToken.isExpired()) {
- return;
- }
- refreshTokenRepository.deleteByUserId(userId);
- throw new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN);
- }
-
private void saveRefreshToken(User user, JwtToken token) {
LocalDateTime expiresAt = token.expiresAt();
String tokenHash = refreshTokenHasher.hash(token.value());
diff --git a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java
index aef9614..56c256c 100644
--- a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java
+++ b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java
@@ -11,6 +11,7 @@
public enum AuthErrorCode implements ErrorCode {
INVALID_REFRESH_TOKEN("ATH-E001", HttpStatus.UNAUTHORIZED, "refresh token이 올바르지 않습니다."),
+ REFRESH_TOKEN_REUSE_DETECTED("ATH-E005", HttpStatus.UNAUTHORIZED, "refresh token 재사용이 감지되어 세션이 무효화되었습니다."),
INVALID_ACCESS_TOKEN("ATH-E002", HttpStatus.UNAUTHORIZED, "access token이 올바르지 않습니다."),
INVALID_OAUTH_TOKEN("ATH-E003", HttpStatus.UNAUTHORIZED, "OAuth 토큰이 올바르지 않습니다."),
OAUTH_CLIENT_NOT_CONFIGURED("ATH-E004", HttpStatus.INTERNAL_SERVER_ERROR, "OAuth 클라이언트 설정이 올바르지 않습니다."),
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
new file mode 100644
index 0000000..d9d4158
--- /dev/null
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
@@ -0,0 +1,44 @@
+package org.sopt.domain.auth.infrastructure;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * Refresh Token Rotation 시 동시 요청을 흡수하기 위한 Grace Period 저장소.
+ *
+ * 회전 직후 구 토큰의 해시를 짧은 TTL(10초)로 보관한다.
+ * Grace Period 내에 같은 구 토큰으로 재발급 요청이 들어오면
+ * {@link #consumeIfPresent(String)}가 원자적으로 값을 반환하고 키를 제거하여
+ * 이중 발급을 방지한다.
+ */
+@Component
+public class RefreshTokenGracePeriodStore {
+
+ private final Cache cache = Caffeine.newBuilder()
+ .expireAfterWrite(Duration.ofSeconds(10))
+ .maximumSize(10_000)
+ .build();
+
+ /**
+ * 회전된 구 토큰의 해시를 Grace Period 동안 보관한다.
+ *
+ * @param tokenHash 회전 전 refresh token의 HMAC 해시
+ * @param userId 토큰 소유 사용자 ID
+ */
+ public void store(String tokenHash, Long userId) {
+ cache.put(tokenHash, userId);
+ }
+
+ /**
+ * Grace Period 캐시에서 토큰 해시를 원자적으로 꺼내고 제거한다.
+ *
+ * @param tokenHash refresh token의 HMAC 해시
+ * @return 사용자 ID. Grace Period가 만료되었거나 이미 소비된 경우 {@code null}
+ */
+ public Long consumeIfPresent(String tokenHash) {
+ return cache.asMap().remove(tokenHash);
+ }
+}
From eb0b37259628d18726feddbd25648e951b64bff9 Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 22:20:29 +0900
Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20AuthService=EC=97=90=EC=84=9C?=
=?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0?=
=?UTF-8?q?=EC=99=80=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?=
=?UTF-8?q?=EC=B1=85=EC=9E=84=EC=9D=84=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
9개 의존성을 가진 AuthService를 3개 서비스로 분리한다.
- AuthTokenService: 토큰 발급, 회전(Rotation + Grace Period), 회수
- SocialLoginService: OAuth 프로필 검증과 자동 회원가입
- AuthService: 자격증명 검증과 흐름 조율
UserCommandService도 AuthTokenService.revoke()를 사용하도록 전환한다.
---
.../auth/application/service/AuthService.java | 138 ++---------------
.../application/service/AuthTokenService.java | 141 ++++++++++++++++++
.../service/SocialLoginService.java | 62 ++++++++
.../service/UserCommandService.java | 14 +-
4 files changed, 216 insertions(+), 139 deletions(-)
create mode 100644 src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java
create mode 100644 src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java
diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
index 8270d55..2cf3462 100644
--- a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
+++ b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
@@ -1,62 +1,29 @@
package org.sopt.domain.auth.application.service;
import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry;
import org.sopt.domain.auth.application.dto.AuthTokenResult;
-import org.sopt.domain.auth.application.dto.OAuthUserProfile;
-import org.sopt.domain.auth.domain.exception.AuthErrorCode;
-import org.sopt.domain.auth.domain.model.OAuthProvider;
-import org.sopt.domain.auth.domain.model.RefreshToken;
-import org.sopt.domain.auth.domain.model.SocialAccount;
-import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
-import org.sopt.domain.auth.domain.repository.RefreshTokenRepository;
-import org.sopt.domain.auth.domain.repository.SocialAccountRepository;
-import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore;
-import org.sopt.domain.auth.infrastructure.RefreshTokenHasher;
import org.sopt.domain.user.domain.exception.UserErrorCode;
import org.sopt.domain.user.domain.model.User;
import org.sopt.domain.user.domain.repository.UserRepository;
import org.sopt.global.exception.BaseException;
import org.sopt.global.security.authentication.AuthenticatedUser;
-import org.sopt.global.security.jwt.JwtToken;
-import org.sopt.global.security.jwt.JwtTokenProvider;
-import org.sopt.global.security.jwt.JwtTokenType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.time.LocalDateTime;
-
/**
- * 로그인, 토큰 재발급, 로그아웃을 담당하는 인증 서비스.
- *
- * Refresh Token Rotation + Reuse Detection + Grace Period 전략을 적용한다.
- *
- * - Rotation: 재발급 시 구 토큰을 즉시 무효화하고 새 토큰을 발급한다.
- * - Reuse Detection: 이미 회전된 토큰이 다시 사용되면 해당 사용자의
- * 토큰을 전체 무효화하여 탈취를 차단한다.
- * - Grace Period: 회전 직후 10초간 구 토큰을 허용하여
- * 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다.
- *
+ * 자격증명 검증과 인증 흐름 조율을 담당하는 서비스.
+ * 토큰 생명주기는 {@link AuthTokenService}, 소셜 로그인은 {@link SocialLoginService}에 위임한다.
*/
-@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
- private static final String BEARER_TYPE = "Bearer";
-
private final UserRepository userRepository;
- private final RefreshTokenRepository refreshTokenRepository;
- private final AccessTokenBlacklistRepository accessTokenBlacklistRepository;
- private final SocialAccountRepository socialAccountRepository;
- private final OAuthProviderClientRegistry oAuthProviderClientRegistry;
private final PasswordEncoder passwordEncoder;
- private final JwtTokenProvider jwtTokenProvider;
- private final RefreshTokenHasher refreshTokenHasher;
- private final RefreshTokenGracePeriodStore gracePeriodStore;
+ private final AuthTokenService authTokenService;
+ private final SocialLoginService socialLoginService;
/**
* 로그인 ID와 비밀번호를 검증하고 토큰을 발급한다.
@@ -75,7 +42,7 @@ public AuthTokenResult login(String loginId, String password) {
throw new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS);
}
- return issueAndSaveTokens(user);
+ return authTokenService.issue(user);
}
/**
@@ -85,59 +52,18 @@ public AuthTokenResult login(String loginId, String password) {
* @return 발급된 토큰
*/
public AuthTokenResult loginWithGoogle(String idToken) {
- OAuthUserProfile profile = oAuthProviderClientRegistry.getClient(OAuthProvider.GOOGLE).verify(idToken);
- User user = socialAccountRepository.findByProviderAndProviderUserId(
- profile.provider(),
- profile.providerUserId()
- )
- .map(socialAccount -> {
- socialAccount.updateProfile(profile.email(), profile.profileImageUrl());
- User socialUser = socialAccount.getUser();
- if (socialUser.isDeleted()) {
- throw new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS);
- }
- return socialUser;
- })
- .orElseGet(() -> registerSocialUser(profile));
-
- return issueAndSaveTokens(user);
+ User user = socialLoginService.authenticateWithGoogle(idToken);
+ return authTokenService.issue(user);
}
/**
* refresh token을 검증하고 새 토큰 쌍을 발급한다.
*
- * 재발급 흐름:
- *
- * - 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
- * - DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
- * - 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
- *
- *
* @param refreshToken refresh token 원문
* @return 새로 발급된 토큰
*/
public AuthTokenResult reissue(String refreshToken) {
- Long userId = jwtTokenProvider.getUserId(refreshToken, JwtTokenType.REFRESH);
- User user = findActiveUser(userId);
- String incomingHash = refreshTokenHasher.hash(refreshToken);
-
- RefreshToken savedToken = refreshTokenRepository.findByUserId(userId)
- .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN));
-
- if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash())
- && !savedToken.isExpired()) {
- gracePeriodStore.store(incomingHash, userId);
- return issueAndSaveTokens(user);
- }
-
- Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash);
- if (graceUserId != null && graceUserId.equals(userId)) {
- return issueAndSaveTokens(user);
- }
-
- refreshTokenRepository.deleteByUserId(userId);
- log.warn("Refresh token reuse detected: userId={}", userId);
- throw new BaseException(AuthErrorCode.REFRESH_TOKEN_REUSE_DETECTED);
+ return authTokenService.reissue(refreshToken);
}
/**
@@ -146,52 +72,6 @@ public AuthTokenResult reissue(String refreshToken) {
* @param authenticatedUser 인증된 사용자
*/
public void logout(AuthenticatedUser authenticatedUser) {
- refreshTokenRepository.deleteByUserId(authenticatedUser.userId());
- accessTokenBlacklistRepository.add(
- authenticatedUser.tokenId(),
- authenticatedUser.accessTokenExpiresAt()
- );
- }
-
- private AuthTokenResult issueAndSaveTokens(User user) {
- Long userId = user.getId();
- JwtToken accessToken = jwtTokenProvider.createAccessToken(userId);
- JwtToken refreshToken = jwtTokenProvider.createRefreshToken(userId);
- saveRefreshToken(user, refreshToken);
-
- return new AuthTokenResult(
- BEARER_TYPE,
- accessToken.value(),
- accessToken.expiresAt(),
- refreshToken.value(),
- refreshToken.expiresAt()
- );
- }
-
- private User findActiveUser(Long userId) {
- return userRepository.findById(userId)
- .orElseThrow(() -> new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS));
- }
-
- private void saveRefreshToken(User user, JwtToken token) {
- LocalDateTime expiresAt = token.expiresAt();
- String tokenHash = refreshTokenHasher.hash(token.value());
- refreshTokenRepository.findByUserId(user.getId())
- .ifPresentOrElse(
- refreshToken -> refreshToken.updateTokenHash(tokenHash, expiresAt),
- () -> refreshTokenRepository.save(new RefreshToken(user, tokenHash, expiresAt))
- );
- }
-
- private User registerSocialUser(OAuthUserProfile profile) {
- User user = userRepository.save(User.createSocialUser(profile.nickname()));
- socialAccountRepository.save(new SocialAccount(
- user,
- profile.provider(),
- profile.providerUserId(),
- profile.email(),
- profile.profileImageUrl()
- ));
- return user;
+ authTokenService.revoke(authenticatedUser);
}
}
diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java
new file mode 100644
index 0000000..3df5352
--- /dev/null
+++ b/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java
@@ -0,0 +1,141 @@
+package org.sopt.domain.auth.application.service;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.sopt.domain.auth.application.dto.AuthTokenResult;
+import org.sopt.domain.auth.domain.exception.AuthErrorCode;
+import org.sopt.domain.auth.domain.model.RefreshToken;
+import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
+import org.sopt.domain.auth.domain.repository.RefreshTokenRepository;
+import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore;
+import org.sopt.domain.auth.infrastructure.RefreshTokenHasher;
+import org.sopt.domain.user.domain.exception.UserErrorCode;
+import org.sopt.domain.user.domain.model.User;
+import org.sopt.domain.user.domain.repository.UserRepository;
+import org.sopt.global.exception.BaseException;
+import org.sopt.global.security.authentication.AuthenticatedUser;
+import org.sopt.global.security.jwt.JwtToken;
+import org.sopt.global.security.jwt.JwtTokenProvider;
+import org.sopt.global.security.jwt.JwtTokenType;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+/**
+ * 토큰 발급, 회전, 회수를 담당하는 서비스.
+ *
+ * Refresh Token Rotation + Reuse Detection + Grace Period 전략을 적용한다.
+ *
+ * - Rotation: 재발급 시 구 토큰을 즉시 무효화하고 새 토큰을 발급한다.
+ * - Reuse Detection: 이미 회전된 토큰이 다시 사용되면 해당 사용자의
+ * 토큰을 전체 무효화하여 탈취를 차단한다.
+ * - Grace Period: 회전 직후 10초간 구 토큰을 허용하여
+ * 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다.
+ *
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class AuthTokenService {
+
+ private static final String BEARER_TYPE = "Bearer";
+
+ private final UserRepository userRepository;
+ private final RefreshTokenRepository refreshTokenRepository;
+ private final AccessTokenBlacklistRepository accessTokenBlacklistRepository;
+ private final JwtTokenProvider jwtTokenProvider;
+ private final RefreshTokenHasher refreshTokenHasher;
+ private final RefreshTokenGracePeriodStore gracePeriodStore;
+
+ /**
+ * 사용자에 대한 access token과 refresh token을 발급한다.
+ *
+ * @param user 토큰을 발급할 사용자
+ * @return 발급된 토큰
+ */
+ public AuthTokenResult issue(User user) {
+ return issueAndSaveTokens(user);
+ }
+
+ /**
+ * refresh token을 검증하고 새 토큰 쌍을 발급한다.
+ *
+ * 재발급 흐름:
+ *
+ * - 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
+ * - DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
+ * - 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
+ *
+ *
+ * @param refreshToken refresh token 원문
+ * @return 새로 발급된 토큰
+ */
+ public AuthTokenResult reissue(String refreshToken) {
+ Long userId = jwtTokenProvider.getUserId(refreshToken, JwtTokenType.REFRESH);
+ User user = findActiveUser(userId);
+ String incomingHash = refreshTokenHasher.hash(refreshToken);
+
+ RefreshToken savedToken = refreshTokenRepository.findByUserId(userId)
+ .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN));
+
+ if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash())
+ && !savedToken.isExpired()) {
+ gracePeriodStore.store(incomingHash, userId);
+ return issueAndSaveTokens(user);
+ }
+
+ Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash);
+ if (graceUserId != null && graceUserId.equals(userId)) {
+ return issueAndSaveTokens(user);
+ }
+
+ refreshTokenRepository.deleteByUserId(userId);
+ log.warn("Refresh token reuse detected: userId={}", userId);
+ throw new BaseException(AuthErrorCode.REFRESH_TOKEN_REUSE_DETECTED);
+ }
+
+ /**
+ * refresh token을 삭제하고 현재 access token을 블랙리스트에 등록한다.
+ *
+ * @param authenticatedUser 인증된 사용자
+ */
+ public void revoke(AuthenticatedUser authenticatedUser) {
+ refreshTokenRepository.deleteByUserId(authenticatedUser.userId());
+ accessTokenBlacklistRepository.add(
+ authenticatedUser.tokenId(),
+ authenticatedUser.accessTokenExpiresAt()
+ );
+ }
+
+ private AuthTokenResult issueAndSaveTokens(User user) {
+ Long userId = user.getId();
+ JwtToken accessToken = jwtTokenProvider.createAccessToken(userId);
+ JwtToken refreshToken = jwtTokenProvider.createRefreshToken(userId);
+ saveRefreshToken(user, refreshToken);
+
+ return new AuthTokenResult(
+ BEARER_TYPE,
+ accessToken.value(),
+ accessToken.expiresAt(),
+ refreshToken.value(),
+ refreshToken.expiresAt()
+ );
+ }
+
+ private User findActiveUser(Long userId) {
+ return userRepository.findById(userId)
+ .orElseThrow(() -> new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS));
+ }
+
+ private void saveRefreshToken(User user, JwtToken token) {
+ LocalDateTime expiresAt = token.expiresAt();
+ String tokenHash = refreshTokenHasher.hash(token.value());
+ refreshTokenRepository.findByUserId(user.getId())
+ .ifPresentOrElse(
+ refreshToken -> refreshToken.updateTokenHash(tokenHash, expiresAt),
+ () -> refreshTokenRepository.save(new RefreshToken(user, tokenHash, expiresAt))
+ );
+ }
+}
diff --git a/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java b/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java
new file mode 100644
index 0000000..7ac8863
--- /dev/null
+++ b/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java
@@ -0,0 +1,62 @@
+package org.sopt.domain.auth.application.service;
+
+import lombok.RequiredArgsConstructor;
+import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry;
+import org.sopt.domain.auth.application.dto.OAuthUserProfile;
+import org.sopt.domain.auth.domain.model.OAuthProvider;
+import org.sopt.domain.auth.domain.model.SocialAccount;
+import org.sopt.domain.auth.domain.repository.SocialAccountRepository;
+import org.sopt.domain.user.domain.exception.UserErrorCode;
+import org.sopt.domain.user.domain.model.User;
+import org.sopt.domain.user.domain.repository.UserRepository;
+import org.sopt.global.exception.BaseException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * OAuth 소셜 로그인을 통한 사용자 인증과 자동 회원가입을 담당하는 서비스.
+ */
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class SocialLoginService {
+
+ private final UserRepository userRepository;
+ private final SocialAccountRepository socialAccountRepository;
+ private final OAuthProviderClientRegistry oAuthProviderClientRegistry;
+
+ /**
+ * Google ID Token을 검증하고 소셜 계정 기준으로 사용자를 조회하거나 자동 회원가입한다.
+ *
+ * @param idToken Google ID Token
+ * @return 인증된 사용자
+ */
+ public User authenticateWithGoogle(String idToken) {
+ OAuthUserProfile profile = oAuthProviderClientRegistry.getClient(OAuthProvider.GOOGLE).verify(idToken);
+ return socialAccountRepository.findByProviderAndProviderUserId(
+ profile.provider(),
+ profile.providerUserId()
+ )
+ .map(socialAccount -> {
+ socialAccount.updateProfile(profile.email(), profile.profileImageUrl());
+ User user = socialAccount.getUser();
+ if (user.isDeleted()) {
+ throw new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS);
+ }
+ return user;
+ })
+ .orElseGet(() -> registerSocialUser(profile));
+ }
+
+ private User registerSocialUser(OAuthUserProfile profile) {
+ User user = userRepository.save(User.createSocialUser(profile.nickname()));
+ socialAccountRepository.save(new SocialAccount(
+ user,
+ profile.provider(),
+ profile.providerUserId(),
+ profile.email(),
+ profile.profileImageUrl()
+ ));
+ return user;
+ }
+}
diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
index c90bf31..e299e31 100644
--- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
+++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
@@ -1,8 +1,7 @@
package org.sopt.domain.user.application.service;
import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
-import org.sopt.domain.auth.domain.repository.RefreshTokenRepository;
+import org.sopt.domain.auth.application.service.AuthTokenService;
import org.sopt.domain.user.application.dto.CreateUserCommand;
import org.sopt.domain.user.application.dto.UpdateUserCommand;
import org.sopt.domain.user.application.dto.UserResult;
@@ -27,8 +26,7 @@
public class UserCommandService {
private final UserRepository userRepository;
- private final RefreshTokenRepository refreshTokenRepository;
- private final AccessTokenBlacklistRepository accessTokenBlacklistRepository;
+ private final AuthTokenService authTokenService;
private final PasswordEncoder passwordEncoder;
/**
@@ -58,18 +56,14 @@ public void updateUser(Long authenticatedUserId, UpdateUserCommand command) {
}
/**
- * 인증된 사용자 본인을 소프트 삭제한다.
+ * 인증된 사용자 본인을 소프트 삭제하고 인증 토큰을 회수한다.
*
* @param authenticatedUser 인증 사용자
*/
public void deleteUser(AuthenticatedUser authenticatedUser) {
User user = findUserOrThrow(authenticatedUser.userId());
user.markDeleted();
- refreshTokenRepository.deleteByUserId(authenticatedUser.userId());
- accessTokenBlacklistRepository.add(
- authenticatedUser.tokenId(),
- authenticatedUser.accessTokenExpiresAt()
- );
+ authTokenService.revoke(authenticatedUser);
}
/**
From a29aa98089e4b13fe12f98fe9f9638b6f855310e Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 22:26:42 +0900
Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?=
=?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20=ED=8F=AC=ED=8A=B8?=
=?UTF-8?q?=EB=A5=BC=20domain=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20?=
=?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Application Service(AuthService)와 Domain Service(AuthTokenService,
SocialLoginService)의 계층을 패키지로 명시한다.
- domain/service: 토큰 생명주기, 소셜 로그인 도메인 로직
- domain/port: OAuth 클라이언트 포트 인터페이스
- domain/model: AuthTokenResult 값 객체
- application/service: 유스케이스 조율 (AuthService)
---
.../sopt/domain/auth/application/service/AuthService.java | 4 +++-
.../{application/dto => domain/model}/AuthTokenResult.java | 2 +-
.../client => domain/port}/OAuthProviderClient.java | 4 +---
.../client => domain/port}/OAuthProviderClientRegistry.java | 2 +-
.../{application/dto => domain/port}/OAuthUserProfile.java | 2 +-
.../{application => domain}/service/AuthTokenService.java | 4 ++--
.../{application => domain}/service/SocialLoginService.java | 6 +++---
.../auth/infrastructure/GoogleOAuthProviderClient.java | 4 ++--
.../domain/auth/presentation/controller/AuthController.java | 2 +-
.../auth/presentation/mapper/AuthTokenResponseMapper.java | 2 +-
.../domain/user/application/service/UserCommandService.java | 2 +-
11 files changed, 17 insertions(+), 17 deletions(-)
rename src/main/java/org/sopt/domain/auth/{application/dto => domain/model}/AuthTokenResult.java (86%)
rename src/main/java/org/sopt/domain/auth/{application/client => domain/port}/OAuthProviderClient.java (82%)
rename src/main/java/org/sopt/domain/auth/{application/client => domain/port}/OAuthProviderClientRegistry.java (95%)
rename src/main/java/org/sopt/domain/auth/{application/dto => domain/port}/OAuthUserProfile.java (87%)
rename src/main/java/org/sopt/domain/auth/{application => domain}/service/AuthTokenService.java (98%)
rename src/main/java/org/sopt/domain/auth/{application => domain}/service/SocialLoginService.java (92%)
diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
index 2cf3462..c49c5b5 100644
--- a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
+++ b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java
@@ -1,7 +1,9 @@
package org.sopt.domain.auth.application.service;
import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.application.dto.AuthTokenResult;
+import org.sopt.domain.auth.domain.model.AuthTokenResult;
+import org.sopt.domain.auth.domain.service.AuthTokenService;
+import org.sopt.domain.auth.domain.service.SocialLoginService;
import org.sopt.domain.user.domain.exception.UserErrorCode;
import org.sopt.domain.user.domain.model.User;
import org.sopt.domain.user.domain.repository.UserRepository;
diff --git a/src/main/java/org/sopt/domain/auth/application/dto/AuthTokenResult.java b/src/main/java/org/sopt/domain/auth/domain/model/AuthTokenResult.java
similarity index 86%
rename from src/main/java/org/sopt/domain/auth/application/dto/AuthTokenResult.java
rename to src/main/java/org/sopt/domain/auth/domain/model/AuthTokenResult.java
index 96f59a0..265b432 100644
--- a/src/main/java/org/sopt/domain/auth/application/dto/AuthTokenResult.java
+++ b/src/main/java/org/sopt/domain/auth/domain/model/AuthTokenResult.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.application.dto;
+package org.sopt.domain.auth.domain.model;
import java.time.LocalDateTime;
diff --git a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClient.java b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClient.java
similarity index 82%
rename from src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClient.java
rename to src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClient.java
index d1fb04a..29c1bea 100644
--- a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClient.java
+++ b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClient.java
@@ -1,6 +1,4 @@
-package org.sopt.domain.auth.application.client;
-
-import org.sopt.domain.auth.application.dto.OAuthUserProfile;
+package org.sopt.domain.auth.domain.port;
import org.sopt.domain.auth.domain.model.OAuthProvider;
/**
diff --git a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClientRegistry.java b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClientRegistry.java
similarity index 95%
rename from src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClientRegistry.java
rename to src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClientRegistry.java
index a9f207a..f6f29af 100644
--- a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClientRegistry.java
+++ b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClientRegistry.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.application.client;
+package org.sopt.domain.auth.domain.port;
import org.sopt.domain.auth.domain.model.OAuthProvider;
import org.sopt.domain.auth.domain.exception.AuthErrorCode;
diff --git a/src/main/java/org/sopt/domain/auth/application/dto/OAuthUserProfile.java b/src/main/java/org/sopt/domain/auth/domain/port/OAuthUserProfile.java
similarity index 87%
rename from src/main/java/org/sopt/domain/auth/application/dto/OAuthUserProfile.java
rename to src/main/java/org/sopt/domain/auth/domain/port/OAuthUserProfile.java
index ab38ff1..7808856 100644
--- a/src/main/java/org/sopt/domain/auth/application/dto/OAuthUserProfile.java
+++ b/src/main/java/org/sopt/domain/auth/domain/port/OAuthUserProfile.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.application.dto;
+package org.sopt.domain.auth.domain.port;
import org.sopt.domain.auth.domain.model.OAuthProvider;
diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
similarity index 98%
rename from src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java
rename to src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
index 3df5352..954d1cc 100644
--- a/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java
+++ b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
@@ -1,8 +1,8 @@
-package org.sopt.domain.auth.application.service;
+package org.sopt.domain.auth.domain.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.sopt.domain.auth.application.dto.AuthTokenResult;
+import org.sopt.domain.auth.domain.model.AuthTokenResult;
import org.sopt.domain.auth.domain.exception.AuthErrorCode;
import org.sopt.domain.auth.domain.model.RefreshToken;
import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
diff --git a/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java b/src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java
similarity index 92%
rename from src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java
rename to src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java
index 7ac8863..a53dd9b 100644
--- a/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java
+++ b/src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java
@@ -1,8 +1,8 @@
-package org.sopt.domain.auth.application.service;
+package org.sopt.domain.auth.domain.service;
import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry;
-import org.sopt.domain.auth.application.dto.OAuthUserProfile;
+import org.sopt.domain.auth.domain.port.OAuthProviderClientRegistry;
+import org.sopt.domain.auth.domain.port.OAuthUserProfile;
import org.sopt.domain.auth.domain.model.OAuthProvider;
import org.sopt.domain.auth.domain.model.SocialAccount;
import org.sopt.domain.auth.domain.repository.SocialAccountRepository;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java b/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java
index ab68431..1c136f4 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java
@@ -1,7 +1,7 @@
package org.sopt.domain.auth.infrastructure;
-import org.sopt.domain.auth.application.client.OAuthProviderClient;
-import org.sopt.domain.auth.application.dto.OAuthUserProfile;
+import org.sopt.domain.auth.domain.port.OAuthProviderClient;
+import org.sopt.domain.auth.domain.port.OAuthUserProfile;
import org.sopt.domain.auth.domain.exception.AuthErrorCode;
import org.sopt.domain.auth.domain.model.OAuthProvider;
import org.sopt.global.exception.BaseException;
diff --git a/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java b/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java
index 6eca355..492fdbe 100644
--- a/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java
+++ b/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java
@@ -2,7 +2,7 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.application.dto.AuthTokenResult;
+import org.sopt.domain.auth.domain.model.AuthTokenResult;
import org.sopt.domain.auth.application.service.AuthService;
import org.sopt.domain.auth.presentation.code.AuthSuccessCode;
import org.sopt.domain.auth.presentation.cookie.RefreshTokenCookieFactory;
diff --git a/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java b/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java
index accf3de..18a00f6 100644
--- a/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java
+++ b/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java
@@ -1,6 +1,6 @@
package org.sopt.domain.auth.presentation.mapper;
-import org.sopt.domain.auth.application.dto.AuthTokenResult;
+import org.sopt.domain.auth.domain.model.AuthTokenResult;
import org.sopt.domain.auth.presentation.dto.response.AuthTokenResponse;
import org.springframework.stereotype.Component;
diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
index e299e31..60ad35c 100644
--- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
+++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
@@ -1,7 +1,7 @@
package org.sopt.domain.user.application.service;
import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.application.service.AuthTokenService;
+import org.sopt.domain.auth.domain.service.AuthTokenService;
import org.sopt.domain.user.application.dto.CreateUserCommand;
import org.sopt.domain.user.application.dto.UpdateUserCommand;
import org.sopt.domain.user.application.dto.UserResult;
From 899b1953a9b5a5f29307708a995d53527fe79f0d Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 22:44:26 +0900
Subject: [PATCH 6/9] =?UTF-8?q?fix:=20Grace=20Period=20=EC=9D=B4=EC=A4=91?=
=?UTF-8?q?=20=ED=9A=8C=EC=A0=84=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20?=
=?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=BA=90?=
=?UTF-8?q?=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Grace Period 경로에서 issueAndSaveTokens를 재호출하면 정상 회전 시
발급된 refresh token이 덮어써져 첫 번째 응답의 토큰이 무효화되는
문제를 수정한다. Grace Period 캐시에 발급 결과를 함께 저장하여
동시 요청 시 동일한 토큰을 반환하도록 변경한다.
사용자 삭제 시 역할 캐시를 즉시 무효화하여, 삭제된 사용자가
캐시 TTL 동안 인증되는 문제를 수정한다. 캐시를 UserRoleCache
컴포넌트로 추출하여 필터와 서비스 양쪽에서 접근할 수 있도록 한다.
---
.../auth/domain/service/AuthTokenService.java | 11 ++--
.../RefreshTokenGracePeriodStore.java | 26 +++++----
.../service/UserCommandService.java | 3 +
.../JwtAuthenticationFilter.java | 48 ++--------------
.../authentication/UserRoleCache.java | 55 +++++++++++++++++++
5 files changed, 83 insertions(+), 60 deletions(-)
create mode 100644 src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
diff --git a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
index 954d1cc..16d72cd 100644
--- a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
+++ b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
@@ -82,13 +82,14 @@ public AuthTokenResult reissue(String refreshToken) {
if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash())
&& !savedToken.isExpired()) {
- gracePeriodStore.store(incomingHash, userId);
- return issueAndSaveTokens(user);
+ AuthTokenResult result = issueAndSaveTokens(user);
+ gracePeriodStore.store(incomingHash, result);
+ return result;
}
- Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash);
- if (graceUserId != null && graceUserId.equals(userId)) {
- return issueAndSaveTokens(user);
+ AuthTokenResult graceResult = gracePeriodStore.consumeIfPresent(incomingHash);
+ if (graceResult != null) {
+ return graceResult;
}
refreshTokenRepository.deleteByUserId(userId);
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
index d9d4158..a239d96 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
@@ -2,6 +2,7 @@
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
+import org.sopt.domain.auth.domain.model.AuthTokenResult;
import org.springframework.stereotype.Component;
import java.time.Duration;
@@ -9,36 +10,37 @@
/**
* Refresh Token Rotation 시 동시 요청을 흡수하기 위한 Grace Period 저장소.
*
- * 회전 직후 구 토큰의 해시를 짧은 TTL(10초)로 보관한다.
+ *
회전 직후 구 토큰의 해시와 발급된 토큰 결과를 짧은 TTL(10초)로 보관한다.
* Grace Period 내에 같은 구 토큰으로 재발급 요청이 들어오면
* {@link #consumeIfPresent(String)}가 원자적으로 값을 반환하고 키를 제거하여
- * 이중 발급을 방지한다.
+ * 재회전 없이 동일한 토큰을 반환한다.
*/
@Component
public class RefreshTokenGracePeriodStore {
- private final Cache cache = Caffeine.newBuilder()
+ private final Cache cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(10))
.maximumSize(10_000)
.build();
/**
- * 회전된 구 토큰의 해시를 Grace Period 동안 보관한다.
+ * 회전 시 발급된 토큰 결과를 구 토큰 해시와 함께 Grace Period 동안 보관한다.
*
- * @param tokenHash 회전 전 refresh token의 HMAC 해시
- * @param userId 토큰 소유 사용자 ID
+ * @param oldTokenHash 회전 전 refresh token의 HMAC 해시
+ * @param result 회전 시 발급된 토큰 결과
*/
- public void store(String tokenHash, Long userId) {
- cache.put(tokenHash, userId);
+ public void store(String oldTokenHash, AuthTokenResult result) {
+ cache.put(oldTokenHash, result);
}
/**
* Grace Period 캐시에서 토큰 해시를 원자적으로 꺼내고 제거한다.
+ * 반환된 토큰은 정상 회전 시 발급된 것과 동일하므로 재회전이 발생하지 않는다.
*
- * @param tokenHash refresh token의 HMAC 해시
- * @return 사용자 ID. Grace Period가 만료되었거나 이미 소비된 경우 {@code null}
+ * @param oldTokenHash refresh token의 HMAC 해시
+ * @return 회전 시 발급된 토큰 결과. Grace Period가 만료되었거나 이미 소비된 경우 {@code null}
*/
- public Long consumeIfPresent(String tokenHash) {
- return cache.asMap().remove(tokenHash);
+ public AuthTokenResult consumeIfPresent(String oldTokenHash) {
+ return cache.asMap().remove(oldTokenHash);
}
}
diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
index 60ad35c..7c20872 100644
--- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
+++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
@@ -2,6 +2,7 @@
import lombok.RequiredArgsConstructor;
import org.sopt.domain.auth.domain.service.AuthTokenService;
+import org.sopt.global.security.authentication.UserRoleCache;
import org.sopt.domain.user.application.dto.CreateUserCommand;
import org.sopt.domain.user.application.dto.UpdateUserCommand;
import org.sopt.domain.user.application.dto.UserResult;
@@ -27,6 +28,7 @@ public class UserCommandService {
private final UserRepository userRepository;
private final AuthTokenService authTokenService;
+ private final UserRoleCache userRoleCache;
private final PasswordEncoder passwordEncoder;
/**
@@ -64,6 +66,7 @@ public void deleteUser(AuthenticatedUser authenticatedUser) {
User user = findUserOrThrow(authenticatedUser.userId());
user.markDeleted();
authTokenService.revoke(authenticatedUser);
+ userRoleCache.evict(authenticatedUser.userId());
}
/**
diff --git a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java
index 9825768..cbef80e 100644
--- a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java
+++ b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java
@@ -1,15 +1,12 @@
package org.sopt.global.security.authentication;
-import com.github.benmanes.caffeine.cache.Cache;
-import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
-import org.sopt.domain.user.domain.model.User;
import org.sopt.domain.user.domain.model.UserRole;
-import org.sopt.domain.user.domain.repository.UserRepository;
import org.sopt.global.security.exception.JwtAuthenticationException;
import org.sopt.global.security.jwt.JwtTokenPayload;
import org.sopt.global.security.jwt.JwtTokenProvider;
@@ -22,43 +19,24 @@
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
-import java.time.Duration;
import java.util.List;
/**
* Authorization Bearer 토큰을 읽어 SecurityContext에 인증 사용자를 저장한다.
*
- * 사용자 역할 조회 결과를 Caffeine 캐시(5분 TTL)에 보관하여
+ *
사용자 역할 조회는 {@link UserRoleCache}에 위임하여
* 매 요청마다 DB를 조회하는 비용을 절감한다.
*/
@Component
+@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final BearerTokenResolver bearerTokenResolver;
private final JwtTokenProvider jwtTokenProvider;
private final AccessTokenBlacklistRepository accessTokenBlacklistRepository;
- private final UserRepository userRepository;
+ private final UserRoleCache userRoleCache;
private final AuthenticationEntryPoint authenticationEntryPoint;
- private final Cache userRoleCache = Caffeine.newBuilder()
- .maximumSize(10_000)
- .expireAfterWrite(Duration.ofMinutes(5))
- .build();
-
- public JwtAuthenticationFilter(
- BearerTokenResolver bearerTokenResolver,
- JwtTokenProvider jwtTokenProvider,
- AccessTokenBlacklistRepository accessTokenBlacklistRepository,
- UserRepository userRepository,
- AuthenticationEntryPoint authenticationEntryPoint
- ) {
- this.bearerTokenResolver = bearerTokenResolver;
- this.jwtTokenProvider = jwtTokenProvider;
- this.accessTokenBlacklistRepository = accessTokenBlacklistRepository;
- this.userRepository = userRepository;
- this.authenticationEntryPoint = authenticationEntryPoint;
- }
-
@Override
protected void doFilterInternal(
HttpServletRequest request,
@@ -72,7 +50,7 @@ protected void doFilterInternal(
if (accessTokenBlacklistRepository.exists(payload.tokenId())) {
throw new JwtAuthenticationException("Access token is blacklisted.");
}
- UserRole role = userRoleCache.get(payload.userId(), this::loadUserRole);
+ UserRole role = userRoleCache.getRole(payload.userId());
if (role == null) {
throw new JwtAuthenticationException("User is inactive.");
}
@@ -87,22 +65,6 @@ protected void doFilterInternal(
filterChain.doFilter(request, response);
}
- /**
- * 사용자 역할 캐시에서 해당 사용자를 제거한다.
- * 역할 변경이나 사용자 삭제 시 호출하여 캐시 정합성을 유지한다.
- *
- * @param userId 캐시에서 제거할 사용자 ID
- */
- public void evictUserRole(Long userId) {
- userRoleCache.invalidate(userId);
- }
-
- private UserRole loadUserRole(Long userId) {
- return userRepository.findById(userId)
- .map(User::getRole)
- .orElse(null);
- }
-
private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, UserRole role) {
AuthenticatedUser principal = new AuthenticatedUser(
payload.userId(),
diff --git a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
new file mode 100644
index 0000000..551f26f
--- /dev/null
+++ b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
@@ -0,0 +1,55 @@
+package org.sopt.global.security.authentication;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.sopt.domain.user.domain.model.User;
+import org.sopt.domain.user.domain.model.UserRole;
+import org.sopt.domain.user.domain.repository.UserRepository;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * 인증 필터에서 사용하는 사용자 역할 캐시.
+ * 매 요청마다 DB를 조회하는 비용을 절감한다.
+ */
+@Component
+public class UserRoleCache {
+
+ private final UserRepository userRepository;
+
+ private final Cache cache = Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterWrite(Duration.ofMinutes(5))
+ .build();
+
+ public UserRoleCache(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ /**
+ * 사용자 역할을 조회한다. 캐시에 없으면 DB에서 로드한다.
+ *
+ * @param userId 사용자 ID
+ * @return 사용자 역할. 사용자가 존재하지 않으면 {@code null}
+ */
+ public UserRole getRole(Long userId) {
+ return cache.get(userId, this::loadUserRole);
+ }
+
+ /**
+ * 사용자 역할 캐시에서 해당 사용자를 제거한다.
+ * 역할 변경이나 사용자 삭제 시 호출하여 캐시 정합성을 유지한다.
+ *
+ * @param userId 캐시에서 제거할 사용자 ID
+ */
+ public void evict(Long userId) {
+ cache.invalidate(userId);
+ }
+
+ private UserRole loadUserRole(Long userId) {
+ return userRepository.findById(userId)
+ .map(User::getRole)
+ .orElse(null);
+ }
+}
From e028dde1b3cc27c2486b293c953fc5828c4ebdc7 Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 22:55:32 +0900
Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?=
=?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20NPE=20=EB=B0=A9?=
=?UTF-8?q?=EC=A7=80,=20=ED=8F=AC=ED=8A=B8=20=EC=B6=94=EC=83=81=ED=99=94,?=
=?UTF-8?q?=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A0=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- UserRoleCache에서 Caffeine cache.get()이 null 반환 시 NPE를
던지는 버그를 수정한다. getIfPresent + 수동 put으로 전환.
- AuthTokenService(domain)이 infrastructure를 직접 참조하던 역방향
의존을 RefreshTokenHashPort, RefreshTokenGracePeriodPort
인터페이스로 추상화하여 제거한다.
- AuthErrorCode 선언 순서를 코드 번호순으로 정렬한다.
- reissue()의 동시성 제약을 Javadoc에 명시한다.
---
.../auth/domain/exception/AuthErrorCode.java | 2 +-
.../port/RefreshTokenGracePeriodPort.java | 25 +++++++++++++++++++
.../domain/port/RefreshTokenHashPort.java | 24 ++++++++++++++++++
.../auth/domain/service/AuthTokenService.java | 16 ++++++++----
.../RefreshTokenGracePeriodStore.java | 3 ++-
.../infrastructure/RefreshTokenHasher.java | 3 ++-
.../authentication/UserRoleCache.java | 10 +++++++-
7 files changed, 74 insertions(+), 9 deletions(-)
create mode 100644 src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java
create mode 100644 src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java
diff --git a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java
index 56c256c..d652058 100644
--- a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java
+++ b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java
@@ -11,10 +11,10 @@
public enum AuthErrorCode implements ErrorCode {
INVALID_REFRESH_TOKEN("ATH-E001", HttpStatus.UNAUTHORIZED, "refresh token이 올바르지 않습니다."),
- REFRESH_TOKEN_REUSE_DETECTED("ATH-E005", HttpStatus.UNAUTHORIZED, "refresh token 재사용이 감지되어 세션이 무효화되었습니다."),
INVALID_ACCESS_TOKEN("ATH-E002", HttpStatus.UNAUTHORIZED, "access token이 올바르지 않습니다."),
INVALID_OAUTH_TOKEN("ATH-E003", HttpStatus.UNAUTHORIZED, "OAuth 토큰이 올바르지 않습니다."),
OAUTH_CLIENT_NOT_CONFIGURED("ATH-E004", HttpStatus.INTERNAL_SERVER_ERROR, "OAuth 클라이언트 설정이 올바르지 않습니다."),
+ REFRESH_TOKEN_REUSE_DETECTED("ATH-E005", HttpStatus.UNAUTHORIZED, "refresh token 재사용이 감지되어 세션이 무효화되었습니다."),
;
private final String code;
diff --git a/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java
new file mode 100644
index 0000000..846dce2
--- /dev/null
+++ b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java
@@ -0,0 +1,25 @@
+package org.sopt.domain.auth.domain.port;
+
+import org.sopt.domain.auth.domain.model.AuthTokenResult;
+
+/**
+ * Refresh Token Rotation 시 동시 요청을 흡수하기 위한 Grace Period 포트.
+ */
+public interface RefreshTokenGracePeriodPort {
+
+ /**
+ * 회전 시 발급된 토큰 결과를 구 토큰 해시와 함께 Grace Period 동안 보관한다.
+ *
+ * @param oldTokenHash 회전 전 refresh token의 HMAC 해시
+ * @param result 회전 시 발급된 토큰 결과
+ */
+ void store(String oldTokenHash, AuthTokenResult result);
+
+ /**
+ * Grace Period 캐시에서 토큰 해시를 원자적으로 꺼내고 제거한다.
+ *
+ * @param oldTokenHash refresh token의 HMAC 해시
+ * @return 회전 시 발급된 토큰 결과. Grace Period가 만료되었거나 이미 소비된 경우 {@code null}
+ */
+ AuthTokenResult consumeIfPresent(String oldTokenHash);
+}
diff --git a/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java
new file mode 100644
index 0000000..4847cb2
--- /dev/null
+++ b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java
@@ -0,0 +1,24 @@
+package org.sopt.domain.auth.domain.port;
+
+/**
+ * refresh token 원문을 저장용 해시로 변환하는 포트.
+ */
+public interface RefreshTokenHashPort {
+
+ /**
+ * refresh token 원문으로부터 저장용 해시 값을 만든다.
+ *
+ * @param refreshToken refresh token 원문
+ * @return 저장용 해시 값
+ */
+ String hash(String refreshToken);
+
+ /**
+ * refresh token 원문과 저장된 해시 값을 비교한다.
+ *
+ * @param refreshToken refresh token 원문
+ * @param expectedHash 저장된 해시 값
+ * @return 일치 여부
+ */
+ boolean matches(String refreshToken, String expectedHash);
+}
diff --git a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
index 16d72cd..f092e8e 100644
--- a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
+++ b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java
@@ -7,8 +7,8 @@
import org.sopt.domain.auth.domain.model.RefreshToken;
import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository;
import org.sopt.domain.auth.domain.repository.RefreshTokenRepository;
-import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore;
-import org.sopt.domain.auth.infrastructure.RefreshTokenHasher;
+import org.sopt.domain.auth.domain.port.RefreshTokenGracePeriodPort;
+import org.sopt.domain.auth.domain.port.RefreshTokenHashPort;
import org.sopt.domain.user.domain.exception.UserErrorCode;
import org.sopt.domain.user.domain.model.User;
import org.sopt.domain.user.domain.repository.UserRepository;
@@ -46,8 +46,8 @@ public class AuthTokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final AccessTokenBlacklistRepository accessTokenBlacklistRepository;
private final JwtTokenProvider jwtTokenProvider;
- private final RefreshTokenHasher refreshTokenHasher;
- private final RefreshTokenGracePeriodStore gracePeriodStore;
+ private final RefreshTokenHashPort refreshTokenHasher;
+ private final RefreshTokenGracePeriodPort gracePeriodStore;
/**
* 사용자에 대한 access token과 refresh token을 발급한다.
@@ -65,10 +65,16 @@ public AuthTokenResult issue(User user) {
* 재발급 흐름:
*
* - 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
- * - DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
+ * - DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해
+ * 재회전 없이 기존 발급 결과를 반환한다.
* - 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
*
*
+ * 동시성 참고: 같은 refresh token을 두 스레드가 동시에 정상 경로(1단계)로
+ * 통과하면 두 번째 스레드가 첫 번째의 토큰을 덮어쓸 수 있다.
+ * 트래픽이 높아지면 {@code findByUserId}에 비관적 락(SELECT FOR UPDATE)을 적용해
+ * 직렬화하는 것을 고려해야 한다.
+ *
* @param refreshToken refresh token 원문
* @return 새로 발급된 토큰
*/
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
index a239d96..6ff750f 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
@@ -3,6 +3,7 @@
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.sopt.domain.auth.domain.model.AuthTokenResult;
+import org.sopt.domain.auth.domain.port.RefreshTokenGracePeriodPort;
import org.springframework.stereotype.Component;
import java.time.Duration;
@@ -16,7 +17,7 @@
* 재회전 없이 동일한 토큰을 반환한다.
*/
@Component
-public class RefreshTokenGracePeriodStore {
+public class RefreshTokenGracePeriodStore implements RefreshTokenGracePeriodPort {
private final Cache cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(10))
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java
index f57f637..ee10369 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java
@@ -1,5 +1,6 @@
package org.sopt.domain.auth.infrastructure;
+import org.sopt.domain.auth.domain.port.RefreshTokenHashPort;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@@ -14,7 +15,7 @@
* refresh token 원문을 DB에 저장하지 않기 위한 HMAC 해시 유틸리티.
*/
@Component
-public class RefreshTokenHasher {
+public class RefreshTokenHasher implements RefreshTokenHashPort {
private static final String HMAC_ALGORITHM = "HmacSHA256";
diff --git a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
index 551f26f..22789ad 100644
--- a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
+++ b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
@@ -34,7 +34,15 @@ public UserRoleCache(UserRepository userRepository) {
* @return 사용자 역할. 사용자가 존재하지 않으면 {@code null}
*/
public UserRole getRole(Long userId) {
- return cache.get(userId, this::loadUserRole);
+ UserRole cached = cache.getIfPresent(userId);
+ if (cached != null) {
+ return cached;
+ }
+ UserRole role = loadUserRole(userId);
+ if (role != null) {
+ cache.put(userId, role);
+ }
+ return role;
}
/**
From e64bd6e092ddb8cd17a8c6a86d5cd01b9b49b044 Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 23:21:52 +0900
Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20=EC=84=B8?=
=?UTF-8?q?=EC=85=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B6=84=EB=A6=AC=20?=
=?UTF-8?q?=EB=B0=8F=20=EC=97=AD=ED=95=A0=20=EC=BA=90=EC=8B=9C=20=EA=B0=9C?=
=?UTF-8?q?=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
build.gradle | 2 +-
.../application/port/AuthSessionPort.java | 16 ++++++++++
.../service/UserCommandService.java | 9 ++----
.../adapter/AuthSessionPortAdapter.java | 31 +++++++++++++++++++
.../authentication/UserRoleCache.java | 19 ++++--------
5 files changed, 57 insertions(+), 20 deletions(-)
create mode 100644 src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java
create mode 100644 src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java
diff --git a/build.gradle b/build.gradle
index e64ae15..5157009 100644
--- a/build.gradle
+++ b/build.gradle
@@ -44,7 +44,7 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
// Cache
- implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
+ implementation 'com.github.ben-manes.caffeine:caffeine'
// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
diff --git a/src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java b/src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java
new file mode 100644
index 0000000..68814ae
--- /dev/null
+++ b/src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java
@@ -0,0 +1,16 @@
+package org.sopt.domain.user.application.port;
+
+import org.sopt.global.security.authentication.AuthenticatedUser;
+
+/**
+ * User 유스케이스가 인증 세션 회수 구현에 직접 의존하지 않도록 분리한 포트.
+ */
+public interface AuthSessionPort {
+
+ /**
+ * 인증된 사용자의 현재 인증 세션을 회수한다.
+ *
+ * @param authenticatedUser 인증 사용자
+ */
+ void revoke(AuthenticatedUser authenticatedUser);
+}
diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
index 7c20872..ac0f1a3 100644
--- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
+++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java
@@ -1,12 +1,11 @@
package org.sopt.domain.user.application.service;
import lombok.RequiredArgsConstructor;
-import org.sopt.domain.auth.domain.service.AuthTokenService;
-import org.sopt.global.security.authentication.UserRoleCache;
import org.sopt.domain.user.application.dto.CreateUserCommand;
import org.sopt.domain.user.application.dto.UpdateUserCommand;
import org.sopt.domain.user.application.dto.UserResult;
import org.sopt.domain.user.application.mapper.UserResultMapper;
+import org.sopt.domain.user.application.port.AuthSessionPort;
import org.sopt.domain.user.domain.exception.UserErrorCode;
import org.sopt.domain.user.domain.exception.UserNotFoundException;
import org.sopt.domain.user.domain.model.User;
@@ -27,8 +26,7 @@
public class UserCommandService {
private final UserRepository userRepository;
- private final AuthTokenService authTokenService;
- private final UserRoleCache userRoleCache;
+ private final AuthSessionPort authSessionPort;
private final PasswordEncoder passwordEncoder;
/**
@@ -65,8 +63,7 @@ public void updateUser(Long authenticatedUserId, UpdateUserCommand command) {
public void deleteUser(AuthenticatedUser authenticatedUser) {
User user = findUserOrThrow(authenticatedUser.userId());
user.markDeleted();
- authTokenService.revoke(authenticatedUser);
- userRoleCache.evict(authenticatedUser.userId());
+ authSessionPort.revoke(authenticatedUser);
}
/**
diff --git a/src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java b/src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java
new file mode 100644
index 0000000..31e8c53
--- /dev/null
+++ b/src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java
@@ -0,0 +1,31 @@
+package org.sopt.domain.user.infrastructure.adapter;
+
+import lombok.RequiredArgsConstructor;
+import org.sopt.domain.auth.domain.service.AuthTokenService;
+import org.sopt.domain.user.application.port.AuthSessionPort;
+import org.sopt.global.security.authentication.AuthenticatedUser;
+import org.sopt.global.security.authentication.UserRoleCache;
+import org.springframework.stereotype.Component;
+
+/**
+ * User 도메인용 {@link AuthSessionPort} 구현체.
+ * 회원 탈퇴 시 필요한 인증 토큰 회수와 역할 캐시 무효화를 인증 계층에 위임한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AuthSessionPortAdapter implements AuthSessionPort {
+
+ private final AuthTokenService authTokenService;
+ private final UserRoleCache userRoleCache;
+
+ /**
+ * 현재 access token을 블랙리스트에 등록하고 refresh token과 역할 캐시를 제거한다.
+ *
+ * @param authenticatedUser 인증 사용자
+ */
+ @Override
+ public void revoke(AuthenticatedUser authenticatedUser) {
+ authTokenService.revoke(authenticatedUser);
+ userRoleCache.evict(authenticatedUser.userId());
+ }
+}
diff --git a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
index 22789ad..1b374f1 100644
--- a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
+++ b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java
@@ -8,6 +8,7 @@
import org.springframework.stereotype.Component;
import java.time.Duration;
+import java.util.Optional;
/**
* 인증 필터에서 사용하는 사용자 역할 캐시.
@@ -18,7 +19,7 @@ public class UserRoleCache {
private final UserRepository userRepository;
- private final Cache cache = Caffeine.newBuilder()
+ private final Cache> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
@@ -34,15 +35,8 @@ public UserRoleCache(UserRepository userRepository) {
* @return 사용자 역할. 사용자가 존재하지 않으면 {@code null}
*/
public UserRole getRole(Long userId) {
- UserRole cached = cache.getIfPresent(userId);
- if (cached != null) {
- return cached;
- }
- UserRole role = loadUserRole(userId);
- if (role != null) {
- cache.put(userId, role);
- }
- return role;
+ return cache.get(userId, this::loadUserRole)
+ .orElse(null);
}
/**
@@ -55,9 +49,8 @@ public void evict(Long userId) {
cache.invalidate(userId);
}
- private UserRole loadUserRole(Long userId) {
+ private Optional loadUserRole(Long userId) {
return userRepository.findById(userId)
- .map(User::getRole)
- .orElse(null);
+ .map(User::getRole);
}
}
From 37215aa003e6658265ad5cfcf2981b42ad0ce05a Mon Sep 17 00:00:00 2001
From: Kimgyuilli
Date: Sun, 24 May 2026 23:29:45 +0900
Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20infrastructure=20=ED=8C=A8?=
=?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20=EA=B4=80=EC=8B=AC=EC=82=AC?=
=?UTF-8?q?=EB=B3=84=20=ED=95=98=EC=9C=84=20=ED=8C=A8=ED=82=A4=EC=A7=80?=
=?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
8개 파일이 혼재된 infrastructure 패키지를 정리한다.
- persistence: JPA 레포지토리와 어댑터
- cache: Caffeine 기반 블랙리스트·Grace Period 구현체
- oauth: Google OAuth 클라이언트
- security: Refresh Token HMAC 해셔
---
.../{ => cache}/CaffeineAccessTokenBlacklistRepository.java | 2 +-
.../{ => cache}/RefreshTokenGracePeriodStore.java | 2 +-
.../infrastructure/{ => oauth}/GoogleOAuthProviderClient.java | 2 +-
.../{ => persistence}/RefreshTokenJpaRepository.java | 2 +-
.../{ => persistence}/RefreshTokenRepositoryImpl.java | 2 +-
.../{ => persistence}/SocialAccountJpaRepository.java | 2 +-
.../{ => persistence}/SocialAccountRepositoryImpl.java | 2 +-
.../auth/infrastructure/{ => security}/RefreshTokenHasher.java | 2 +-
8 files changed, 8 insertions(+), 8 deletions(-)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => cache}/CaffeineAccessTokenBlacklistRepository.java (97%)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => cache}/RefreshTokenGracePeriodStore.java (97%)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => oauth}/GoogleOAuthProviderClient.java (99%)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/RefreshTokenJpaRepository.java (92%)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/RefreshTokenRepositoryImpl.java (95%)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/SocialAccountJpaRepository.java (92%)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/SocialAccountRepositoryImpl.java (96%)
rename src/main/java/org/sopt/domain/auth/infrastructure/{ => security}/RefreshTokenHasher.java (97%)
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java
similarity index 97%
rename from src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java
index 9a07b57..5f7fd28 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java
similarity index 97%
rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java
index 6ff750f..2d36214 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java b/src/main/java/org/sopt/domain/auth/infrastructure/oauth/GoogleOAuthProviderClient.java
similarity index 99%
rename from src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/oauth/GoogleOAuthProviderClient.java
index 1c136f4..4e2e5cc 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/oauth/GoogleOAuthProviderClient.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.oauth;
import org.sopt.domain.auth.domain.port.OAuthProviderClient;
import org.sopt.domain.auth.domain.port.OAuthUserProfile;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenJpaRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenJpaRepository.java
similarity index 92%
rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenJpaRepository.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenJpaRepository.java
index e3649c2..0cec217 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenJpaRepository.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenJpaRepository.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.persistence;
import org.sopt.domain.auth.domain.model.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenRepositoryImpl.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenRepositoryImpl.java
similarity index 95%
rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenRepositoryImpl.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenRepositoryImpl.java
index e8410c6..67b6d40 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenRepositoryImpl.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenRepositoryImpl.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.persistence;
import lombok.RequiredArgsConstructor;
import org.sopt.domain.auth.domain.model.RefreshToken;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountJpaRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountJpaRepository.java
similarity index 92%
rename from src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountJpaRepository.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountJpaRepository.java
index b84bb51..6d308f6 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountJpaRepository.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountJpaRepository.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.persistence;
import org.sopt.domain.auth.domain.model.OAuthProvider;
import org.sopt.domain.auth.domain.model.SocialAccount;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountRepositoryImpl.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountRepositoryImpl.java
similarity index 96%
rename from src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountRepositoryImpl.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountRepositoryImpl.java
index 25550e9..f9bf8fd 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountRepositoryImpl.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountRepositoryImpl.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.persistence;
import lombok.RequiredArgsConstructor;
import org.sopt.domain.auth.domain.model.OAuthProvider;
diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java b/src/main/java/org/sopt/domain/auth/infrastructure/security/RefreshTokenHasher.java
similarity index 97%
rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java
rename to src/main/java/org/sopt/domain/auth/infrastructure/security/RefreshTokenHasher.java
index ee10369..a51f22d 100644
--- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java
+++ b/src/main/java/org/sopt/domain/auth/infrastructure/security/RefreshTokenHasher.java
@@ -1,4 +1,4 @@
-package org.sopt.domain.auth.infrastructure;
+package org.sopt.domain.auth.infrastructure.security;
import org.sopt.domain.auth.domain.port.RefreshTokenHashPort;
import org.springframework.beans.factory.annotation.Value;