From 1b9a44cf27dfd49bdb04184c94c006e4b0482468 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Mon, 24 Nov 2025 16:19:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[fix]=20User=20=EC=A1=B0=ED=9A=8C=EC=8B=9C?= =?UTF-8?q?=20=EB=B9=84=EA=B4=80=EB=9D=BD=20=ED=9A=8D=EB=93=9D=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/UserCommandPersistenceAdapter.java | 9 ++++++++- .../out/persistence/repository/UserJpaRepository.java | 11 +++++++++++ .../user/application/port/out/UserCommandPort.java | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java index 774ae5481..4ddbee6b6 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserCommandPersistenceAdapter.java @@ -21,7 +21,6 @@ public class UserCommandPersistenceAdapter implements UserCommandPort { private final UserJpaRepository userJpaRepository; - private final UserMapper userMapper; @Override @@ -38,6 +37,14 @@ public User findById(Long userId) { return userMapper.toDomainEntity(userJpaEntity); } + @Override + public User findByIdWithLock(Long userId) { + UserJpaEntity userJpaEntity = userJpaRepository.findByUserIdWithLock(userId).orElseThrow( + () -> new EntityNotFoundException(USER_NOT_FOUND)); + + return userMapper.toDomainEntity(userJpaEntity); + } + @Override public Map findByIds(List userIds) { List entities = userJpaRepository.findAllById(userIds); // 내부 구현 메서드가 jpql 기반이므로 필터 적용 대상임을 확인함 diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java index 166f3795d..5da17010e 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserJpaRepository.java @@ -1,8 +1,12 @@ package konkuk.thip.user.adapter.out.persistence.repository; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import java.util.List; @@ -15,6 +19,13 @@ public interface UserJpaRepository extends JpaRepository, U */ Optional findByUserId(Long userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints({ + @QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000") // 5초 + }) + @Query("select u from UserJpaEntity u where u.userId = :userId") + Optional findByUserIdWithLock(@Param("userId") Long userId); + Optional findByOauth2Id(String oauth2Id); boolean existsByNickname(String nickname); diff --git a/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java b/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java index ed45f3593..78b54d5c2 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java +++ b/src/main/java/konkuk/thip/user/application/port/out/UserCommandPort.java @@ -9,6 +9,7 @@ public interface UserCommandPort { Long save(User user); User findById(Long userId); + User findByIdWithLock(Long userId); Map findByIds(List userIds); void update(User user); void delete(User user); From 52b02f8370f155d7b20642b7c12ec37dafece44f Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Mon, 24 Nov 2025 16:21:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[chore]=20Spring=20Retry=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ src/main/java/konkuk/thip/config/RetryConfig.java | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 src/main/java/konkuk/thip/config/RetryConfig.java diff --git a/build.gradle b/build.gradle index cd2969af9..a63ccf50d 100644 --- a/build.gradle +++ b/build.gradle @@ -101,6 +101,9 @@ dependencies { // Spring AI - Google AI(Gemini) 연동 implementation 'org.springframework.ai:spring-ai-starter-model-openai:1.0.1' + + // spring Retry + implementation 'org.springframework.retry:spring-retry' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/src/main/java/konkuk/thip/config/RetryConfig.java b/src/main/java/konkuk/thip/config/RetryConfig.java new file mode 100644 index 000000000..4fc1133ba --- /dev/null +++ b/src/main/java/konkuk/thip/config/RetryConfig.java @@ -0,0 +1,9 @@ +package konkuk.thip.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableRetry(proxyTargetClass = true) +public class RetryConfig { +} \ No newline at end of file From 0031c527127eaaf600cdc94343315cddebcbeaa8 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Mon, 24 Nov 2025 16:21:45 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[fix]=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/common/exception/code/ErrorCode.java | 2 ++ .../service/following/UserFollowService.java | 26 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index b7fb384c0..bb262ba7d 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -32,6 +32,8 @@ public enum ErrorCode implements ResponseCode { PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."), + RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."), + /* 60000부터 비즈니스 예외 */ /** * 60000 : alias error diff --git a/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java b/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java index c0316e510..c9a63b4f4 100644 --- a/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java +++ b/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java @@ -1,6 +1,8 @@ package konkuk.thip.user.application.service.following; import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.user.application.port.in.UserFollowUsecase; import konkuk.thip.user.application.port.in.dto.UserFollowCommand; @@ -9,12 +11,15 @@ import konkuk.thip.user.domain.Following; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; -import static konkuk.thip.common.exception.code.ErrorCode.*; +import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_FOLLOW_SELF; @Service @RequiredArgsConstructor @@ -27,6 +32,18 @@ public class UserFollowService implements UserFollowUsecase { @Override @Transactional + @Retryable( + notRecoverable = { + BusinessException.class, + InvalidStateException.class + }, + noRetryFor = { + BusinessException.class, + InvalidStateException.class + }, + maxAttempts = 3, + backoff = @Backoff(delay = 100, maxDelay = 500, multiplier = 2) + ) public Boolean changeFollowingState(UserFollowCommand followCommand) { Long userId = followCommand.userId(); Long targetUserId = followCommand.targetUserId(); @@ -35,7 +52,7 @@ public Boolean changeFollowingState(UserFollowCommand followCommand) { validateParams(userId, targetUserId); Optional optionalFollowing = followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId); - User targetUser = userCommandPort.findById(targetUserId); + User targetUser = userCommandPort.findByIdWithLock(targetUserId); boolean isFollowRequest = Following.validateFollowingState(optionalFollowing.isPresent(), type); @@ -53,6 +70,11 @@ public Boolean changeFollowingState(UserFollowCommand followCommand) { } } + @Recover + public Boolean recoverChangeFollowingState(Exception e, UserFollowCommand followCommand) { + throw new BusinessException(ErrorCode.RESOURCE_LOCKED); + } + private void sendNotifications(Long userId, Long targetUserId) { User actorUser = userCommandPort.findById(userId); feedNotificationOrchestrator.notifyFollowed(targetUserId, actorUser.getId(), actorUser.getNickname()); From b7f3ed29fad49ce4248045acd0dacbcdba4ca072 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Mon, 24 Nov 2025 16:37:04 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[chore]=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=8C=8C=EC=9D=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V251120__Add_following_unique_constraint.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/db/migration/V251120__Add_following_unique_constraint.sql b/src/main/resources/db/migration/V251120__Add_following_unique_constraint.sql index 254049234..6b2946f3b 100644 --- a/src/main/resources/db/migration/V251120__Add_following_unique_constraint.sql +++ b/src/main/resources/db/migration/V251120__Add_following_unique_constraint.sql @@ -1,3 +1,4 @@ +-- 팔로잉 테이블에 사용자와 타겟 사용자 간의 유니크 제약 조건 추가 ALTER TABLE followings ADD CONSTRAINT uq_followings_user_target UNIQUE (user_id, following_user_id); \ No newline at end of file From c3515570db1fc426500a62aa62e9c23bc9edb4a6 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Mon, 24 Nov 2025 16:47:08 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[fix]=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=8B=9C=EA=B7=B8=EB=8B=88=EC=B2=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/user/application/service/UserFollowServiceTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java index 050b5c692..d11a8c673 100644 --- a/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java +++ b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java @@ -71,7 +71,7 @@ void follow_newRelation() { .thenReturn(Optional.empty()); User user = createUserWithFollowerCount(0); - when(userCommandPort.findById(targetUserId)).thenReturn(user); + when(userCommandPort.findByIdWithLock(targetUserId)).thenReturn(user); when(userCommandPort.findById(userId)).thenReturn(user); // 알림 전송용 UserFollowCommand command = new UserFollowCommand(userId, targetUserId, true); @@ -106,7 +106,7 @@ void unfollow_existingRelation() { when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) .thenReturn(Optional.of(existing)); - when(userCommandPort.findById(targetUserId)).thenReturn(user); + when(userCommandPort.findByIdWithLock(targetUserId)).thenReturn(user); UserFollowCommand command = new UserFollowCommand(userId, targetUserId, false); From 9ade7f6daaa867ed0f82d1e362442f893f8780a2 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Thu, 1 Jan 2026 19:29:13 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[fix]=20=EC=A4=91=EB=B3=B5=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/common/exception/code/ErrorCode.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index ac02f7224..38af91a62 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -33,8 +33,6 @@ public enum ErrorCode implements ResponseCode { PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."), RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."), - RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."), - /* 60000부터 비즈니스 예외 */ /** * 60000 : alias error