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); 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()); 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 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);