From b8da530cc1253a04308da083dabda37e276bf92e Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Thu, 2 Oct 2025 12:47:32 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[Feat]:=20=ED=88=AC=ED=91=9C=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/back/domain/post/dto/PostRequest.java | 4 +++- .../com/back/domain/post/mapper/PostMappers.java | 4 +++- .../com/back/domain/post/service/PostService.java | 12 +++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/back/src/main/java/com/back/domain/post/dto/PostRequest.java b/back/src/main/java/com/back/domain/post/dto/PostRequest.java index 4143e28..ba38dfa 100644 --- a/back/src/main/java/com/back/domain/post/dto/PostRequest.java +++ b/back/src/main/java/com/back/domain/post/dto/PostRequest.java @@ -19,5 +19,7 @@ public record PostRequest( Boolean hide, - PollRequest poll + PollRequest poll, + + Long scenarioId ) { } diff --git a/back/src/main/java/com/back/domain/post/mapper/PostMappers.java b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java index b19c4de..a60ee9d 100644 --- a/back/src/main/java/com/back/domain/post/mapper/PostMappers.java +++ b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java @@ -7,6 +7,7 @@ import com.back.domain.post.dto.PostSummaryResponse; import com.back.domain.post.entity.Post; import com.back.domain.post.enums.PostCategory; +import com.back.domain.scenario.entity.Scenario; import com.back.domain.user.entity.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +26,7 @@ public class PostMappers { private final PollConverter pollConverter; - public Post toEntity(PostRequest request, User user) { + public Post toEntity(PostRequest request, User user, Scenario scenario) { String voteContent = null; if (request.category() == PostCategory.POLL && request.poll() != null) { UUID pollUid = UUID.randomUUID(); @@ -39,6 +40,7 @@ public Post toEntity(PostRequest request, User user) { .user(user) .hide(request.hide() != null ? request.hide() : false) .voteContent(voteContent) + .scenario(scenario) .likeCount(0) .build(); } diff --git a/back/src/main/java/com/back/domain/post/service/PostService.java b/back/src/main/java/com/back/domain/post/service/PostService.java index e10898e..b864496 100644 --- a/back/src/main/java/com/back/domain/post/service/PostService.java +++ b/back/src/main/java/com/back/domain/post/service/PostService.java @@ -12,6 +12,8 @@ import com.back.domain.post.enums.PostCategory; import com.back.domain.post.mapper.PostMappers; import com.back.domain.post.repository.PostRepository; +import com.back.domain.scenario.entity.Scenario; +import com.back.domain.scenario.repository.ScenarioRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ApiException; @@ -39,6 +41,7 @@ public class PostService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PollVoteRepository pollVoteRepository; + private final ScenarioRepository scenarioRepository; private final PostMappers postMappers; private final PollConverter pollConverter; @@ -46,7 +49,14 @@ public class PostService { public PostDetailResponse createPost(Long userId, PostRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - Post post = postMappers.toEntity(request, user); + + Scenario scenario = null; + if (request.category() == PostCategory.SCENARIO) { { + scenario = scenarioRepository.findById(request.scenarioId()) + .orElseThrow(() -> new ApiException(ErrorCode.SCENARIO_NOT_FOUND)); + }} + + Post post = postMappers.toEntity(request, user, scenario); Post savedPost = postRepository.save(post); return postMappers.toDetailResponse(savedPost, false); From f15da0107ec51646fc1a0a2478f865c5fd3f91dd Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Sun, 5 Oct 2025 17:20:18 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[Feat]:=20=ED=88=AC=ED=91=9C=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/post/dto/PostDetailResponse.java | 12 +++-- .../back/domain/post/mapper/PostMappers.java | 44 ++++++++++++++++++- .../back/domain/post/service/PostService.java | 42 +++++++++++++++--- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java b/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java index 66a10bf..b51aa81 100644 --- a/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java +++ b/back/src/main/java/com/back/domain/post/dto/PostDetailResponse.java @@ -2,6 +2,7 @@ import com.back.domain.poll.dto.PollOptionResponse; import com.back.domain.post.enums.PostCategory; +import com.back.domain.scenario.dto.ScenarioDetailResponse; import com.back.global.common.DateFormat; import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; @@ -35,8 +36,13 @@ public record PostDetailResponse( @DateFormat LocalDateTime createdDate, - @Schema(description = "투표 정보, 투표가 없는 게시글인 경우 null") + @Schema(description = "투표 정보, 투표 게시글이 아닌 경우 반환 되지 않는다") @JsonInclude(JsonInclude.Include.NON_NULL) - PollOptionResponse polls -) {} + PollOptionResponse polls, + + @Schema(description = "게시글에 첨부된 시나리오 정보, 시나리오 게시글이 아닌 경우 반환되지 않는다.") + @JsonInclude(JsonInclude.Include.NON_NULL) + ScenarioDetailResponse scenario +) { +} diff --git a/back/src/main/java/com/back/domain/post/mapper/PostMappers.java b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java index a60ee9d..e4b8b7b 100644 --- a/back/src/main/java/com/back/domain/post/mapper/PostMappers.java +++ b/back/src/main/java/com/back/domain/post/mapper/PostMappers.java @@ -7,6 +7,7 @@ import com.back.domain.post.dto.PostSummaryResponse; import com.back.domain.post.entity.Post; import com.back.domain.post.enums.PostCategory; +import com.back.domain.scenario.dto.ScenarioDetailResponse; import com.back.domain.scenario.entity.Scenario; import com.back.domain.user.entity.User; import lombok.RequiredArgsConstructor; @@ -14,6 +15,7 @@ import org.springframework.stereotype.Component; import java.util.UUID; +import java.util.function.Function; /** * PostMapper @@ -45,6 +47,27 @@ public Post toEntity(PostRequest request, User user, Scenario scenario) { .build(); } + public PostDetailResponse toDetailByCategory( + Post post, + boolean isLiked, + Function pollInfoProvider, + Function scenarioProvider + ) { + return switch (post.getCategory()) { + case CHAT -> toDetailResponse(post, isLiked); + + case POLL -> { + PollOptionResponse pollResponse = pollInfoProvider.apply(post); + yield toDetailWithPollsResponse(post, isLiked, pollResponse); + } + + case SCENARIO -> { + ScenarioDetailResponse scenarioResponse = scenarioProvider.apply(post); + yield toDetailWithScenarioResponse(post, isLiked, scenarioResponse); + } + }; + } + public PostDetailResponse toDetailResponse(Post post, Boolean isLiked) { return new PostDetailResponse( post.getId(), @@ -55,7 +78,8 @@ public PostDetailResponse toDetailResponse(Post post, Boolean isLiked) { post.getLikeCount(), isLiked, post.getCreatedDate(), - pollConverter.fromPollOptionJson(post.getVoteContent()) + null, + null ); } @@ -82,7 +106,23 @@ public PostDetailResponse toDetailWithPollsResponse(Post post, Boolean isLiked, post.getLikeCount(), isLiked, post.getCreatedDate(), - pollResponse + pollResponse, + null + ); + } + + public PostDetailResponse toDetailWithScenarioResponse(Post post, Boolean isLiked, ScenarioDetailResponse scenarioResponse) { + return new PostDetailResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.isHide() ? "익명" : post.getUser().getNickname(), + post.getCategory(), + post.getLikeCount(), + isLiked, + post.getCreatedDate(), + null, + scenarioResponse ); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/service/PostService.java b/back/src/main/java/com/back/domain/post/service/PostService.java index b864496..5d4210c 100644 --- a/back/src/main/java/com/back/domain/post/service/PostService.java +++ b/back/src/main/java/com/back/domain/post/service/PostService.java @@ -12,13 +12,17 @@ import com.back.domain.post.enums.PostCategory; import com.back.domain.post.mapper.PostMappers; import com.back.domain.post.repository.PostRepository; +import com.back.domain.scenario.dto.ScenarioDetailResponse; import com.back.domain.scenario.entity.Scenario; +import com.back.domain.scenario.entity.SceneType; import com.back.domain.scenario.repository.ScenarioRepository; +import com.back.domain.scenario.repository.SceneTypeRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -42,6 +46,7 @@ public class PostService { private final PostLikeRepository postLikeRepository; private final PollVoteRepository pollVoteRepository; private final ScenarioRepository scenarioRepository; + private final SceneTypeRepository sceneTypeRepository; private final PostMappers postMappers; private final PollConverter pollConverter; @@ -59,7 +64,12 @@ public PostDetailResponse createPost(Long userId, PostRequest request) { Post post = postMappers.toEntity(request, user, scenario); Post savedPost = postRepository.save(post); - return postMappers.toDetailResponse(savedPost, false); + return postMappers.toDetailByCategory( + savedPost, + false, + this::getPollInfoForCreate, + this::getScenarioInfoForCreate + ); } public PostDetailResponse getPost(Long userId, Long postId) { @@ -69,10 +79,15 @@ public PostDetailResponse getPost(Long userId, Long postId) { boolean isLiked = userId != null && postLikeRepository.existsByPostIdAndUserId(postId, userId); - if (post.getCategory() == PostCategory.CHAT) { - return postMappers.toDetailResponse(post, isLiked); - } + return postMappers.toDetailByCategory( + post, + isLiked, + p -> getPollInfo(userId, postId, p), + this::getScenarioInfoForCreate + ); + } + private PollOptionResponse getPollInfo(Long userId, Long postId, Post post) { List options = pollConverter.fromPollOptionJson(post.getVoteContent()).options(); @@ -82,9 +97,7 @@ public PostDetailResponse getPost(Long userId, Long postId) { .orElse(Collections.emptyList()) : Collections.emptyList(); - PollOptionResponse pollResponse = new PollOptionResponse(selected, options); - - return postMappers.toDetailWithPollsResponse(post, isLiked, pollResponse); + return new PollOptionResponse(selected, options); } public Page getPosts(Long userId, PostSearchCondition condition, Pageable pageable) { @@ -139,4 +152,19 @@ private Set getUserLikedPostIds(Long userId, Page posts) { return postLikeRepository.findLikedPostIdsByUserAndPostIds(userId, postIds); } + + private PollOptionResponse getPollInfoForCreate(Post post) { + List options = + pollConverter.fromPollOptionJson(post.getVoteContent()).options(); + + return new PollOptionResponse(Collections.emptyList(), options); + } + + private ScenarioDetailResponse getScenarioInfoForCreate(Post post) { + List sceneTypes = + sceneTypeRepository.findByScenarioIdOrderByTypeAsc(post.getScenario().getId()); + + return ScenarioDetailResponse.from(post.getScenario(), sceneTypes); + } + } From 9634456d02ececafefe38ad3fb33cb5207ace6b4 Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Sun, 5 Oct 2025 17:25:12 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[Fix]:=20macOS=20ARM64=EC=9A=A9=20Netty=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20DNS=20=EB=A6=AC=EC=A1=B8?= =?UTF-8?q?=EB=B2=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- back/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/back/build.gradle.kts b/back/build.gradle.kts index 2125f6f..258e9c4 100644 --- a/back/build.gradle.kts +++ b/back/build.gradle.kts @@ -78,6 +78,13 @@ dependencies { // AI Services - WebFlux for non-blocking HTTP clients implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + // macOS Netty 네이티브 DNS 리졸버 (WebFlux 필요) + val isMacOS: Boolean = System.getProperty("os.name").startsWith("Mac OS X") + val architecture = System.getProperty("os.arch").lowercase() + if (isMacOS && architecture == "aarch64") { + developmentOnly("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64") + } } tasks.withType { From 4c3b95930dd543c0778899c143892c47306cf53b Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Thu, 9 Oct 2025 15:41:15 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[Refactor]:=20=EB=B9=84=EA=B4=80=EC=A0=81?= =?UTF-8?q?=20=EB=9D=BD=EC=9D=84=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=A0=88=EB=B2=A8=20ReentrantLock=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=9D=BD=EC=9C=BC=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 --- .../comment/repository/CommentRepository.java | 6 +- .../back/domain/like/service/LikeService.java | 16 +++- .../post/repository/PostRepository.java | 6 +- .../java/com/back/global/aop/LockAspect.java | 80 +++++++++++++++++++ .../com/back/global/common/LockManager.java | 40 ++++++++++ .../java/com/back/global/common/WithLock.java | 13 +++ .../com/back/global/exception/ErrorCode.java | 4 +- .../post/controller/PostControllerTest.java | 8 +- 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 back/src/main/java/com/back/global/aop/LockAspect.java create mode 100644 back/src/main/java/com/back/global/common/LockManager.java create mode 100644 back/src/main/java/com/back/global/common/WithLock.java diff --git a/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java b/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java index b3562f4..d151c94 100644 --- a/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java +++ b/back/src/main/java/com/back/domain/comment/repository/CommentRepository.java @@ -24,9 +24,9 @@ public interface CommentRepository extends JpaRepository { int countByUserId(Long userId); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT c FROM Comment c WHERE c.id = :commentId") - Optional findByIdWithLock(@Param("commentId") Long commentId); +// @Lock(LockModeType.PESSIMISTIC_WRITE) +// @Query("SELECT c FROM Comment c WHERE c.id = :commentId") +// Optional findByIdWithLock(@Param("commentId") Long commentId); @EntityGraph(attributePaths = {"post"}) Page findByUserIdOrderByCreatedDateDesc(Long userId, Pageable pageable); diff --git a/back/src/main/java/com/back/domain/like/service/LikeService.java b/back/src/main/java/com/back/domain/like/service/LikeService.java index 74b117e..457fa87 100644 --- a/back/src/main/java/com/back/domain/like/service/LikeService.java +++ b/back/src/main/java/com/back/domain/like/service/LikeService.java @@ -10,11 +10,15 @@ import com.back.domain.post.repository.PostRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; +import com.back.global.common.WithLock; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.core.Ordered; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; +import org.springframework.transaction.interceptor.TransactionInterceptor; /** * 좋아요 관련 비즈니스 로직을 처리하는 서비스. @@ -31,8 +35,9 @@ public class LikeService { private final UserRepository userRepository; @Transactional + @WithLock(key = "'post:' + #postId") public void addLike(Long userId, Long postId) { - Post post = postRepository.findByIdWithLock(postId) + Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { @@ -46,8 +51,9 @@ public void addLike(Long userId, Long postId) { } @Transactional + @WithLock(key = "'post:' + #postId") public void removeLike(Long postId, Long userId) { - Post post = postRepository.findByIdWithLock(postId) + Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, userId) > 0; @@ -60,8 +66,9 @@ public void removeLike(Long postId, Long userId) { } @Transactional + @WithLock(key = "'comment:' + #commentId") public void addCommentLike(Long userId, Long postId, Long commentId) { - Comment comment = commentRepository.findByIdWithLock(commentId) + Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); if (commentLikeRepository.existsByCommentIdAndUserId(commentId, userId)) { @@ -75,8 +82,9 @@ public void addCommentLike(Long userId, Long postId, Long commentId) { } @Transactional + @WithLock(key = "'comment:' + #commentId") public void removeCommentLike(Long userId, Long postId, Long commentId) { - Comment comment = commentRepository.findByIdWithLock(commentId) + Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); boolean deleted = commentLikeRepository.deleteByCommentIdAndUserId(commentId, userId) > 0; diff --git a/back/src/main/java/com/back/domain/post/repository/PostRepository.java b/back/src/main/java/com/back/domain/post/repository/PostRepository.java index 496c971..c0bb309 100644 --- a/back/src/main/java/com/back/domain/post/repository/PostRepository.java +++ b/back/src/main/java/com/back/domain/post/repository/PostRepository.java @@ -17,9 +17,9 @@ */ @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM Post p WHERE p.id = :postId") - Optional findByIdWithLock(@Param("postId") Long postId); +// @Lock(LockModeType.PESSIMISTIC_WRITE) +// @Query("SELECT p FROM Post p WHERE p.id = :postId") +// Optional findByIdWithLock(@Param("postId") Long postId); int countByUserId(Long userId); diff --git a/back/src/main/java/com/back/global/aop/LockAspect.java b/back/src/main/java/com/back/global/aop/LockAspect.java new file mode 100644 index 0000000..3cd8183 --- /dev/null +++ b/back/src/main/java/com/back/global/aop/LockAspect.java @@ -0,0 +1,80 @@ +package com.back.global.aop; + +import com.back.global.common.LockManager; +import com.back.global.common.WithLock; +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +@Aspect +@Component +@Order(Ordered.LOWEST_PRECEDENCE - 1) // 트랜잭션 AOP보다 낮은 우선순위 설정 +public class LockAspect { + private final LockManager lockManager; + private final ExpressionParser parser = new SpelExpressionParser(); + + public LockAspect(LockManager lockManager) { + this.lockManager = lockManager; + } + + @Around("@annotation(withLock)") + public Object applyLock(ProceedingJoinPoint joinPoint, WithLock withLock) throws Throwable { + String lockKey = generateLockKey(joinPoint, withLock.key()); + ReentrantLock lock = lockManager.getLock(lockKey); + + boolean acquired = false; + + try { + // 락 획득 시도 + acquired = lock.tryLock(withLock.waitTime(), TimeUnit.MILLISECONDS); + + if (!acquired) { + throw new ApiException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + + return joinPoint.proceed(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ApiException(ErrorCode.LOCK_ACQUISITION_FAILED); + } finally { + if (acquired) { + lock.unlock(); + log.debug("Lock released: {}", lockKey); + lockManager.releaseLock(lockKey); + } + } + } + + private String generateLockKey(ProceedingJoinPoint joinPoint, String keyExpression) { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + + // SpEL Context 생성 + StandardEvaluationContext context = new StandardEvaluationContext(); + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + // SpEL 표현식 평가 + Expression expression = parser.parseExpression(keyExpression); + return expression.getValue(context, String.class); + } + +} diff --git a/back/src/main/java/com/back/global/common/LockManager.java b/back/src/main/java/com/back/global/common/LockManager.java new file mode 100644 index 0000000..028cd01 --- /dev/null +++ b/back/src/main/java/com/back/global/common/LockManager.java @@ -0,0 +1,40 @@ +package com.back.global.common; + +import lombok.Getter; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; + +@Component +public class LockManager { + private final ConcurrentHashMap lockMap = new ConcurrentHashMap<>(); + + public ReentrantLock getLock(String key) { + LockHolder holder = lockMap.computeIfAbsent(key, k -> new LockHolder()); + holder.incrementRef(); + return holder.getLock(); + } + + public void releaseLock(String key) { + LockHolder holder = lockMap.get(key); + if (holder != null && holder.decrementRef() == 0) { + lockMap.remove(key, holder); + } + } + + @Getter + private static class LockHolder { + private final ReentrantLock lock = new ReentrantLock(true); + private final AtomicInteger refCount = new AtomicInteger(0); + + public int incrementRef() { + return refCount.incrementAndGet(); + } + + public int decrementRef() { + return refCount.decrementAndGet(); + } + } +} diff --git a/back/src/main/java/com/back/global/common/WithLock.java b/back/src/main/java/com/back/global/common/WithLock.java new file mode 100644 index 0000000..2b07b8c --- /dev/null +++ b/back/src/main/java/com/back/global/common/WithLock.java @@ -0,0 +1,13 @@ +package com.back.global.common; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface WithLock { + String key(); + long waitTime() default 1000; // 락 획득 대기 시간 (ms) +} diff --git a/back/src/main/java/com/back/global/exception/ErrorCode.java b/back/src/main/java/com/back/global/exception/ErrorCode.java index 4c4f264..27df4e4 100644 --- a/back/src/main/java/com/back/global/exception/ErrorCode.java +++ b/back/src/main/java/com/back/global/exception/ErrorCode.java @@ -73,8 +73,10 @@ public enum ErrorCode { // Poll Errors POLL_VOTE_NOT_FOUND(HttpStatus.NOT_FOUND, "PV001", "Poll Vote Not Found"), POLL_VOTE_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "PV002", "투표 형식이 올바르지 않습니다."), - POLL_VOTE_INVALID_OPTION(HttpStatus.BAD_REQUEST, "PV003", "존재하지 않는 투표 항목입니다." ); + POLL_VOTE_INVALID_OPTION(HttpStatus.BAD_REQUEST, "PV003", "존재하지 않는 투표 항목입니다." ), + // Lock Errors + LOCK_ACQUISITION_FAILED(HttpStatus.CONFLICT, "L001", "다른 요청이 처리 중입니다. 잠시 후 다시 시도해주세요."); private final HttpStatus status; private final String code; diff --git a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java index 520ddd3..0d89be4 100644 --- a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java +++ b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java @@ -113,7 +113,7 @@ class CreatePost { @Test @DisplayName("성공 - 정상 요청") void success() throws Exception { - PostRequest request = new PostRequest("테스트 게시글", "테스트 내용입니다.", PostCategory.CHAT, false, null); + PostRequest request = new PostRequest("테스트 게시글", "테스트 내용입니다.", PostCategory.CHAT, false, null, null); mockMvc.perform(post("/api/v1/posts") .contentType(MediaType.APPLICATION_JSON) @@ -127,7 +127,7 @@ void success() throws Exception { @Test @DisplayName("실패 - 유효성 검사 실패 (빈 제목)") void failEmptyTitle() throws Exception { - PostRequest request = new PostRequest("", "내용", PostCategory.SCENARIO, false, null); + PostRequest request = new PostRequest("", "내용", PostCategory.CHAT, false, null, null); mockMvc.perform(post("/api/v1/posts") .contentType(MediaType.APPLICATION_JSON) @@ -310,7 +310,7 @@ void createPost() { @Test @DisplayName("성공 - 본인 게시글 수정") void success() throws Exception { - PostRequest updateRequest = new PostRequest("수정된 제목", "수정된 내용", PostCategory.CHAT, false, null); + PostRequest updateRequest = new PostRequest("수정된 제목", "수정된 내용", PostCategory.CHAT, false, null, null); mockMvc.perform(put("/api/v1/posts/{postId}", savedPost.getId()) .contentType(MediaType.APPLICATION_JSON) @@ -330,7 +330,7 @@ void failUnauthorizedUser() throws Exception { .build(); postRepository.save(otherPost); - PostRequest updateRequest = new PostRequest("수정 시도", "수정 시도 내용", PostCategory.CHAT, false, null); + PostRequest updateRequest = new PostRequest("수정 시도", "수정 시도 내용", PostCategory.CHAT, false, null, null); mockMvc.perform(put("/api/v1/posts/{postId}", otherPost.getId()) .contentType(MediaType.APPLICATION_JSON) From 5f6bde8ac783ee412b5d986dfc6c6349e4dbd68b Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Thu, 9 Oct 2025 16:04:29 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[Refactor]:=20LockManager=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../back/global/{aop => lock}/LockAspect.java | 2 +- .../com/back/global/lock/LockManager.java | 8 ++++ .../back/global/lock/MemoryLockManager.java | 45 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) rename back/src/main/java/com/back/global/{aop => lock}/LockAspect.java (98%) create mode 100644 back/src/main/java/com/back/global/lock/LockManager.java create mode 100644 back/src/main/java/com/back/global/lock/MemoryLockManager.java diff --git a/back/src/main/java/com/back/global/aop/LockAspect.java b/back/src/main/java/com/back/global/lock/LockAspect.java similarity index 98% rename from back/src/main/java/com/back/global/aop/LockAspect.java rename to back/src/main/java/com/back/global/lock/LockAspect.java index 3cd8183..5a22e83 100644 --- a/back/src/main/java/com/back/global/aop/LockAspect.java +++ b/back/src/main/java/com/back/global/lock/LockAspect.java @@ -1,4 +1,4 @@ -package com.back.global.aop; +package com.back.global.lock; import com.back.global.common.LockManager; import com.back.global.common.WithLock; diff --git a/back/src/main/java/com/back/global/lock/LockManager.java b/back/src/main/java/com/back/global/lock/LockManager.java new file mode 100644 index 0000000..d8d01f6 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/LockManager.java @@ -0,0 +1,8 @@ +package com.back.global.lock; + +import java.util.concurrent.locks.ReentrantLock; + +public interface LockManager { + ReentrantLock getLock(String key); + void releaseLock(String key); +} diff --git a/back/src/main/java/com/back/global/lock/MemoryLockManager.java b/back/src/main/java/com/back/global/lock/MemoryLockManager.java new file mode 100644 index 0000000..5467e21 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/MemoryLockManager.java @@ -0,0 +1,45 @@ +package com.back.global.lock; + +import lombok.Getter; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; + +@Component +@Primary +public class MemoryLockManager implements LockManager { + private final ConcurrentHashMap lockMap = new ConcurrentHashMap<>(); + + @Override + public ReentrantLock getLock(String key) { + LockHolder holder = lockMap.computeIfAbsent(key, k -> new LockHolder()); + holder.incrementRef(); + return holder.getLock(); + } + + @Override + public void releaseLock(String key) { + LockHolder holder = lockMap.get(key); + if (holder != null && holder.decrementRef() == 0) { + lockMap.remove(key, holder); + } + } + + @Getter + private static class LockHolder { + private final ReentrantLock lock = new ReentrantLock(true); + private final AtomicInteger refCount = new AtomicInteger(0); + + public int incrementRef() { + return refCount.incrementAndGet(); + } + + public int decrementRef() { + return refCount.decrementAndGet(); + } + } +} + From 6504dbae0fa313dc16575cffaa7be5ff6704fce5 Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Thu, 9 Oct 2025 20:44:26 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[Refactor]:=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C,=20=ED=88=AC?= =?UTF-8?q?=ED=91=9C=20=EC=8B=9C=20voteCount=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../poll/controller/PollVoteController.java | 6 ++--- .../domain/poll/converter/PollConverter.java | 9 ------- .../domain/poll/dto/PollOptionResponse.java | 3 ++- .../domain/poll/service/PollVoteService.java | 4 ++- .../back/domain/post/service/PostService.java | 27 ++++++++++++++++--- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java b/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java index 75bd6b9..0305316 100644 --- a/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java +++ b/back/src/main/java/com/back/domain/poll/controller/PollVoteController.java @@ -21,13 +21,13 @@ public class PollVoteController { private final PollVoteService pollVoteService; @PostMapping - public ResponseEntity vote( + public ResponseEntity vote( @PathVariable Long postId, @RequestBody @Valid VoteRequest request, @AuthenticationPrincipal CustomUserDetails cs) { - pollVoteService.vote(cs.getUser(), postId, request); - return ResponseEntity.ok().build(); + PollResponse response = pollVoteService.vote(cs.getUser(), postId, request); + return ResponseEntity.ok().body(response); } @GetMapping diff --git a/back/src/main/java/com/back/domain/poll/converter/PollConverter.java b/back/src/main/java/com/back/domain/poll/converter/PollConverter.java index 4eaae62..eeac836 100644 --- a/back/src/main/java/com/back/domain/poll/converter/PollConverter.java +++ b/back/src/main/java/com/back/domain/poll/converter/PollConverter.java @@ -77,13 +77,4 @@ public PollOptionResponse fromPollOptionJson(String voteContent) { throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT); } } - - public PollOptionResponse.VoteOption fromPollOptionInVoteOptionJson(String voteContent) { - if (voteContent == null) return null; - try { - return objectMapper.readValue(voteContent, PollOptionResponse.VoteOption.class); - } catch (JsonProcessingException e) { - throw new ApiException(ErrorCode.POLL_VOTE_INVALID_FORMAT); - } - } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java b/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java index 2517b62..8aa59e8 100644 --- a/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java +++ b/back/src/main/java/com/back/domain/poll/dto/PollOptionResponse.java @@ -11,6 +11,7 @@ public record PollOptionResponse( ) { public record VoteOption( int index, - String text + String text, + Integer voteCount ) {} } diff --git a/back/src/main/java/com/back/domain/poll/service/PollVoteService.java b/back/src/main/java/com/back/domain/poll/service/PollVoteService.java index a613a08..54ecab7 100644 --- a/back/src/main/java/com/back/domain/poll/service/PollVoteService.java +++ b/back/src/main/java/com/back/domain/poll/service/PollVoteService.java @@ -33,7 +33,7 @@ public class PollVoteService { private final PollConverter pollConverter; @Transactional - public void vote(User user, Long postId, @Valid VoteRequest request) { + public PollResponse vote(User user, Long postId, @Valid VoteRequest request) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); @@ -54,6 +54,8 @@ public void vote(User user, Long postId, @Valid VoteRequest request) { .build(); pollVoteRepository.save(pollVote); + + return getVote(postId); } public PollResponse getVote(Long postId) { diff --git a/back/src/main/java/com/back/domain/post/service/PostService.java b/back/src/main/java/com/back/domain/post/service/PostService.java index 5d4210c..b4014fb 100644 --- a/back/src/main/java/com/back/domain/post/service/PostService.java +++ b/back/src/main/java/com/back/domain/post/service/PostService.java @@ -22,7 +22,6 @@ import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -30,6 +29,7 @@ import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -88,9 +88,22 @@ public PostDetailResponse getPost(Long userId, Long postId) { } private PollOptionResponse getPollInfo(Long userId, Long postId, Post post) { - List options = - pollConverter.fromPollOptionJson(post.getVoteContent()).options(); + // 전체 투표 결과 카운트 + Map countMap = pollVoteRepository.findByPostId(postId).stream() + .flatMap(pv -> pollConverter.fromChoiceJson(pv.getChoiceJson()).stream()) + .collect(Collectors.groupingBy(i -> i, Collectors.counting())); + // 옵션별 매핑 + 각 옵션에 voteCount 채우기 + List options = + pollConverter.fromPollOptionJson(post.getVoteContent()).options().stream() + .map(opt -> new PollOptionResponse.VoteOption( + opt.index(), + opt.text(), + countMap.getOrDefault(opt.index(), 0L).intValue() + )) + .toList(); + + // 현재 유저 선택값 List selected = userId != null ? pollVoteRepository.findByPostIdAndUserId(postId, userId) .map(vote -> pollConverter.fromChoiceJson(vote.getChoiceJson())) @@ -155,7 +168,13 @@ private Set getUserLikedPostIds(Long userId, Page posts) { private PollOptionResponse getPollInfoForCreate(Post post) { List options = - pollConverter.fromPollOptionJson(post.getVoteContent()).options(); + pollConverter.fromPollOptionJson(post.getVoteContent()).options().stream() + .map(opt -> new PollOptionResponse.VoteOption( + opt.index(), + opt.text(), + 0 + )) + .toList(); return new PollOptionResponse(Collections.emptyList(), options); } From f6da92183976375e2451581771d54675001fa866 Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Thu, 9 Oct 2025 21:32:48 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[Refactor]:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9C=A0=EC=A0=80=20=EC=A1=B0=ED=9A=8C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 59 ++++++++----------- .../back/domain/comment/entity/Comment.java | 4 +- .../comment/service/CommentService.java | 33 +++++------ .../like/controller/LikeController.java | 17 +++--- .../back/domain/like/service/LikeService.java | 43 +++++--------- .../post/controller/PostController.java | 29 +++++---- .../com/back/domain/post/entity/Post.java | 6 +- .../back/domain/post/service/PostService.java | 55 +++++++++-------- 8 files changed, 113 insertions(+), 133 deletions(-) diff --git a/back/src/main/java/com/back/domain/comment/controller/CommentController.java b/back/src/main/java/com/back/domain/comment/controller/CommentController.java index 2c26854..c179ac5 100644 --- a/back/src/main/java/com/back/domain/comment/controller/CommentController.java +++ b/back/src/main/java/com/back/domain/comment/controller/CommentController.java @@ -4,6 +4,7 @@ import com.back.domain.comment.dto.CommentResponse; import com.back.domain.comment.enums.CommentSortType; import com.back.domain.comment.service.CommentService; +import com.back.domain.user.entity.User; import com.back.global.common.PageResponse; import com.back.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -20,72 +21,62 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "Comment", description = "댓글 관련 API") @RestController @RequestMapping("/api/v1/posts/{postId}/comments") @RequiredArgsConstructor +@Tag(name = "Comment", description = "댓글 관련 API") public class CommentController { private final CommentService commentService; - // 댓글 생성 @PostMapping @Operation(summary = "댓글 생성", description = "새 댓글을 생성합니다.") - public ResponseEntity createPost( - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "생성할 댓글 정보", - required = true - ) + public ResponseEntity createComment( @RequestBody @Valid CommentRequest request, - @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId, + @PathVariable("postId") Long postId, @AuthenticationPrincipal CustomUserDetails cs ) { - CommentResponse response = commentService.createComment(cs.getUser().getId(), postId, request); + User user = cs.getUser(); + CommentResponse response = commentService.createComment(user, postId, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @GetMapping @Operation(summary = "댓글 목록 조회", description = "게시글 목록을 조회합니다.") - public ResponseEntity> getPosts( - @Parameter(description = "페이지 정보") Pageable pageable, - @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId, - @Parameter(description = "정렬 조건 LATEST or LIKES") @RequestParam(defaultValue = "LATEST") CommentSortType sortType, + public ResponseEntity> getComments( + Pageable pageable, + @PathVariable("postId") Long postId, + @RequestParam(defaultValue = "LATEST") CommentSortType sortType, @AuthenticationPrincipal CustomUserDetails cs) { - Sort sort = Sort.by(Sort.Direction.DESC, sortType.getProperty()); - - Pageable sortedPageable = PageRequest.of( - pageable.getPageNumber(), - pageable.getPageSize(), - sort - ); + User user = cs != null ? cs.getUser() : null; - Long userId = (cs != null && cs.getUser() != null) ? cs.getUser().getId() : null; + Sort sort = Sort.by(Sort.Direction.DESC, sortType.getProperty()); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); - Page responses = commentService.getComments(userId, postId, sortedPageable); + Page responses = commentService.getComments(user, postId, sortedPageable); return ResponseEntity.ok(PageResponse.of(responses)); } - @PutMapping("/{commentId}") @Operation(summary = "댓글 수정", description = "자신의 댓글을 수정합니다.") public ResponseEntity updateComment( - @Parameter(description = "수정할 댓글 ID", required = true) @PathVariable Long commentId, - @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "수정할 댓글 정보", - required = true - ) + @PathVariable Long commentId, @RequestBody @Valid CommentRequest request, @AuthenticationPrincipal CustomUserDetails cs) { - return ResponseEntity.ok(commentService.updateComment(cs.getUser().getId(), commentId, request)); + + User user = cs.getUser(); + return ResponseEntity.ok(commentService.updateComment(user, commentId, request)); } @DeleteMapping("/{commentId}") @Operation(summary = "댓글 삭제", description = "자신의 댓글을 삭제합니다.") - public ResponseEntity deletePost( - @Parameter(description = "삭제할 댓글 ID", required = true) @PathVariable Long commentId, + public ResponseEntity deleteComment( + @PathVariable Long commentId, @AuthenticationPrincipal CustomUserDetails cs) { - commentService.deleteComment(cs.getUser().getId(), commentId); - return ResponseEntity.ok(null); + + User user = cs.getUser(); + commentService.deleteComment(user, commentId); + return ResponseEntity.ok().build(); } -} \ No newline at end of file +} diff --git a/back/src/main/java/com/back/domain/comment/entity/Comment.java b/back/src/main/java/com/back/domain/comment/entity/Comment.java index 85b2ec6..99cc768 100644 --- a/back/src/main/java/com/back/domain/comment/entity/Comment.java +++ b/back/src/main/java/com/back/domain/comment/entity/Comment.java @@ -50,8 +50,8 @@ public class Comment extends BaseEntity { @LastModifiedDate private LocalDateTime updatedAt; - public void checkUser(Long userId) { - if (!user.getId().equals(userId)) + public void checkUser(Long targetUserId) { + if (!targetUserId.equals(user.getId())) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); } diff --git a/back/src/main/java/com/back/domain/comment/service/CommentService.java b/back/src/main/java/com/back/domain/comment/service/CommentService.java index f7ae1e0..88377c9 100644 --- a/back/src/main/java/com/back/domain/comment/service/CommentService.java +++ b/back/src/main/java/com/back/domain/comment/service/CommentService.java @@ -31,33 +31,27 @@ @Transactional(readOnly = true) public class CommentService { - private final UserRepository userRepository; private final PostRepository postRepository; private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; - public CommentResponse createComment(Long userId, Long postId, CommentRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - + public CommentResponse createComment(User user, Long postId, CommentRequest request) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + CommentMappers.CommentCtxMapper ctxMapper = new CommentMappers.CommentCtxMapper(user, post); Comment savedComment = commentRepository.save(ctxMapper.toEntity(request)); return ctxMapper.toResponse(savedComment); } - public Page getComments(Long userId, Long postId, Pageable pageable) { - User user = userId != null - ? userRepository.findById(userId).orElse(null) - : null; - + public Page getComments(User user, Long postId, Pageable pageable) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + Page commentsPage = commentRepository.findCommentsByPostId(postId, pageable); - Set userLikedComments = userId != null - ? getUserLikedComments(userId, commentsPage) + Set userLikedComments = user != null + ? getUserLikedComments(user, commentsPage) : Collections.emptySet(); return commentsPage.map(comment -> CommentMappers.toCommentResponse( @@ -68,29 +62,30 @@ public Page getComments(Long userId, Long postId, Pageable page } @Transactional - public Long updateComment(Long userId, Long commentId, CommentRequest request) { + public Long updateComment(User user, Long commentId, CommentRequest request) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - comment.checkUser(userId); + + comment.checkUser(user.getId()); comment.updateContent(request.content()); return comment.getId(); } @Transactional - public void deleteComment(Long userId, Long commentId) { + public void deleteComment(User user, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - comment.checkUser(userId); + + comment.checkUser(user.getId()); commentRepository.delete(comment); } - // 특정 사용자가 한 게시글 내 댓글에서 좋아요를 누른 댓글 ID 집합 조회 - private Set getUserLikedComments(Long userId, Page comments) { + private Set getUserLikedComments(User user, Page comments) { Set commentIds = comments.getContent() .stream() .map(Comment::getId) .collect(Collectors.toSet()); - return commentLikeRepository.findLikedCommentsIdsByUserAndCommentIds(userId, commentIds); + return commentLikeRepository.findLikedCommentsIdsByUserAndCommentIds(user.getId(), commentIds); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/like/controller/LikeController.java b/back/src/main/java/com/back/domain/like/controller/LikeController.java index e5d1416..f94f081 100644 --- a/back/src/main/java/com/back/domain/like/controller/LikeController.java +++ b/back/src/main/java/com/back/domain/like/controller/LikeController.java @@ -15,33 +15,34 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/posts") public class LikeController { + private final LikeService likeService; @PostMapping("/{postId}/likes") public ResponseEntity addLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.addLike(cs.getUser().getId(), postId); - return ResponseEntity.status(HttpStatus.CREATED).body(null); + likeService.addLike(cs.getUser(), postId); + return ResponseEntity.status(HttpStatus.CREATED).build(); } @DeleteMapping("/{postId}/likes") public ResponseEntity removeLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.removeLike(postId, cs.getUser().getId()); - return ResponseEntity.ok(null); + likeService.removeLike(cs.getUser(), postId); + return ResponseEntity.ok().build(); } @PostMapping("/{postId}/comments/{commentId}/likes") public ResponseEntity addCommentLike(@PathVariable Long postId, @PathVariable Long commentId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.addCommentLike(cs.getUser().getId(), postId, commentId); - return ResponseEntity.status(HttpStatus.CREATED).body(null); + likeService.addCommentLike(cs.getUser(), postId, commentId); + return ResponseEntity.status(HttpStatus.CREATED).build(); } @DeleteMapping("/{postId}/comments/{commentId}/likes") public ResponseEntity removeCommentLike(@PathVariable Long postId, @PathVariable Long commentId, @AuthenticationPrincipal CustomUserDetails cs) { - likeService.removeCommentLike(cs.getUser().getId(), postId, commentId); - return ResponseEntity.ok(null); + likeService.removeCommentLike(cs.getUser(), postId, commentId); + return ResponseEntity.ok().build(); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/like/service/LikeService.java b/back/src/main/java/com/back/domain/like/service/LikeService.java index 457fa87..e897599 100644 --- a/back/src/main/java/com/back/domain/like/service/LikeService.java +++ b/back/src/main/java/com/back/domain/like/service/LikeService.java @@ -32,19 +32,21 @@ public class LikeService { private final CommentLikeRepository commentLikeRepository; private final CommentRepository commentRepository; private final PostRepository postRepository; - private final UserRepository userRepository; @Transactional @WithLock(key = "'post:' + #postId") - public void addLike(Long userId, Long postId) { + public void addLike(User user, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { + if (postLikeRepository.existsByPostIdAndUserId(postId, user.getId())) { throw new ApiException(ErrorCode.POST_ALREADY_LIKED); } - PostLike postLike = createPostLike(post, userId); + PostLike postLike = PostLike.builder() + .post(post) + .user(user) + .build(); postLikeRepository.save(postLike); post.incrementLikeCount(); @@ -52,11 +54,11 @@ public void addLike(Long userId, Long postId) { @Transactional @WithLock(key = "'post:' + #postId") - public void removeLike(Long postId, Long userId) { + public void removeLike(User user, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, userId) > 0; + boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, user.getId()) > 0; if (!deleted) { throw new ApiException(ErrorCode.LIKE_NOT_FOUND); @@ -67,15 +69,18 @@ public void removeLike(Long postId, Long userId) { @Transactional @WithLock(key = "'comment:' + #commentId") - public void addCommentLike(Long userId, Long postId, Long commentId) { + public void addCommentLike(User user, Long postId, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - if (commentLikeRepository.existsByCommentIdAndUserId(commentId, userId)) { + if (commentLikeRepository.existsByCommentIdAndUserId(commentId, user.getId())) { throw new ApiException(ErrorCode.COMMENT_ALREADY_LIKED); } - CommentLike commentLike = createCommentLike(comment, userId); + CommentLike commentLike = CommentLike.builder() + .comment(comment) + .user(user) + .build(); commentLikeRepository.save(commentLike); comment.incrementLikeCount(); @@ -83,11 +88,11 @@ public void addCommentLike(Long userId, Long postId, Long commentId) { @Transactional @WithLock(key = "'comment:' + #commentId") - public void removeCommentLike(Long userId, Long postId, Long commentId) { + public void removeCommentLike(User user, Long postId, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); - boolean deleted = commentLikeRepository.deleteByCommentIdAndUserId(commentId, userId) > 0; + boolean deleted = commentLikeRepository.deleteByCommentIdAndUserId(commentId, user.getId()) > 0; if (!deleted) { throw new ApiException(ErrorCode.LIKE_NOT_FOUND); @@ -95,20 +100,4 @@ public void removeCommentLike(Long userId, Long postId, Long commentId) { comment.decrementLikeCount(); } - - private PostLike createPostLike(Post post, Long userId) { - User userReference = userRepository.getReferenceById(userId); - return PostLike.builder() - .post(post) - .user(userReference) - .build(); - } - - private CommentLike createCommentLike(Comment comment, Long userId) { - User userReference = userRepository.getReferenceById(userId); - return CommentLike.builder() - .comment(comment) - .user(userReference) - .build(); - } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/controller/PostController.java b/back/src/main/java/com/back/domain/post/controller/PostController.java index fda2cf1..d7ca00b 100644 --- a/back/src/main/java/com/back/domain/post/controller/PostController.java +++ b/back/src/main/java/com/back/domain/post/controller/PostController.java @@ -5,6 +5,7 @@ import com.back.domain.post.dto.PostSearchCondition; import com.back.domain.post.dto.PostSummaryResponse; import com.back.domain.post.service.PostService; +import com.back.domain.user.entity.User; import com.back.global.common.PageResponse; import com.back.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -39,9 +40,9 @@ public ResponseEntity createPost( required = true ) @RequestBody @Valid PostRequest request, - @AuthenticationPrincipal CustomUserDetails cs - ) { - PostDetailResponse response = postService.createPost(cs.getUser().getId(), request); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = userDetails.getUser(); + PostDetailResponse response = postService.createPost(user, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -51,9 +52,9 @@ public ResponseEntity createPost( public ResponseEntity> getPosts( @Parameter(description = "검색 조건") @ModelAttribute PostSearchCondition condition, @Parameter(description = "페이지 정보") Pageable pageable, - @AuthenticationPrincipal CustomUserDetails cs) { - Long userId = (cs != null && cs.getUser() != null) ? cs.getUser().getId() : null; - Page responses = postService.getPosts(userId, condition, pageable); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = (userDetails != null) ? userDetails.getUser() : null; + Page responses = postService.getPosts(user, condition, pageable); return ResponseEntity.ok(PageResponse.of(responses)); } @@ -62,9 +63,9 @@ public ResponseEntity> getPosts( @Operation(summary = "게시글 상세 조회", description = "게시글 ID로 게시글을 조회합니다.") public ResponseEntity getPost( @Parameter(description = "조회할 게시글 ID", required = true) @PathVariable Long postId, - @AuthenticationPrincipal CustomUserDetails cs) { - Long userId = (cs != null && cs.getUser() != null) ? cs.getUser().getId() : null; - return ResponseEntity.ok(postService.getPost(userId, postId)); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = (userDetails != null) ? userDetails.getUser() : null; + return ResponseEntity.ok(postService.getPost(user, postId)); } @PutMapping("/{postId}") @@ -76,16 +77,18 @@ public ResponseEntity updatePost( required = true ) @RequestBody @Valid PostRequest request, - @AuthenticationPrincipal CustomUserDetails cs) { - return ResponseEntity.ok(postService.updatePost(cs.getUser().getId(), postId, request)); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = userDetails.getUser(); + return ResponseEntity.ok(postService.updatePost(user, postId, request)); } @DeleteMapping("/{postId}") @Operation(summary = "게시글 삭제", description = "게시글 ID로 게시글을 삭제합니다.") public ResponseEntity deletePost( @Parameter(description = "삭제할 게시글 ID", required = true) @PathVariable Long postId, - @AuthenticationPrincipal CustomUserDetails cs) { - postService.deletePost(cs.getUser().getId(), postId); + @AuthenticationPrincipal CustomUserDetails userDetails) { + User user = userDetails.getUser(); + postService.deletePost(user, postId); return ResponseEntity.ok(null); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/post/entity/Post.java b/back/src/main/java/com/back/domain/post/entity/Post.java index 8ac3e84..7c046d8 100644 --- a/back/src/main/java/com/back/domain/post/entity/Post.java +++ b/back/src/main/java/com/back/domain/post/entity/Post.java @@ -10,6 +10,7 @@ import com.back.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.*; +import lombok.extern.slf4j.Slf4j; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.service.spi.ServiceException; @@ -20,6 +21,7 @@ import java.util.ArrayList; import java.util.List; +@Slf4j @Entity @Getter @Table(name = "post", @@ -80,8 +82,8 @@ public void updatePost(String title, String content, PostCategory category) { this.category = category; } - public void checkUser(User targetUser) { - if (!user.equals(targetUser)) + public void checkUser(Long targetUserId) { + if (!user.getId().equals(targetUserId)) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); } diff --git a/back/src/main/java/com/back/domain/post/service/PostService.java b/back/src/main/java/com/back/domain/post/service/PostService.java index b4014fb..97755f1 100644 --- a/back/src/main/java/com/back/domain/post/service/PostService.java +++ b/back/src/main/java/com/back/domain/post/service/PostService.java @@ -18,10 +18,10 @@ import com.back.domain.scenario.repository.ScenarioRepository; import com.back.domain.scenario.repository.SceneTypeRepository; import com.back.domain.user.entity.User; -import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -36,12 +36,12 @@ /** * 게시글 관련 비즈니스 로직을 처리하는 서비스. */ +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class PostService { - private final UserRepository userRepository; private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PollVoteRepository pollVoteRepository; @@ -51,10 +51,7 @@ public class PostService { private final PollConverter pollConverter; @Transactional - public PostDetailResponse createPost(Long userId, PostRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - + public PostDetailResponse createPost(User user, PostRequest request) { Scenario scenario = null; if (request.category() == PostCategory.SCENARIO) { { scenario = scenarioRepository.findById(request.scenarioId()) @@ -72,22 +69,22 @@ public PostDetailResponse createPost(Long userId, PostRequest request) { ); } - public PostDetailResponse getPost(Long userId, Long postId) { + public PostDetailResponse getPost(User user, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - boolean isLiked = userId != null && - postLikeRepository.existsByPostIdAndUserId(postId, userId); + boolean isLiked = user != null && user.getId() != null && + postLikeRepository.existsByPostIdAndUserId(postId, user.getId()); return postMappers.toDetailByCategory( post, isLiked, - p -> getPollInfo(userId, postId, p), + p -> getPollInfo(user, postId, p), this::getScenarioInfoForCreate ); } - private PollOptionResponse getPollInfo(Long userId, Long postId, Post post) { + private PollOptionResponse getPollInfo(User user, Long postId, Post post) { // 전체 투표 결과 카운트 Map countMap = pollVoteRepository.findByPostId(postId).stream() .flatMap(pv -> pollConverter.fromChoiceJson(pv.getChoiceJson()).stream()) @@ -104,8 +101,8 @@ private PollOptionResponse getPollInfo(Long userId, Long postId, Post post) { .toList(); // 현재 유저 선택값 - List selected = userId != null - ? pollVoteRepository.findByPostIdAndUserId(postId, userId) + List selected = user != null && user.getId() != null + ? pollVoteRepository.findByPostIdAndUserId(postId, user.getId()) .map(vote -> pollConverter.fromChoiceJson(vote.getChoiceJson())) .orElse(Collections.emptyList()) : Collections.emptyList(); @@ -113,11 +110,11 @@ private PollOptionResponse getPollInfo(Long userId, Long postId, Post post) { return new PollOptionResponse(selected, options); } - public Page getPosts(Long userId, PostSearchCondition condition, Pageable pageable) { + public Page getPosts(User user, PostSearchCondition condition, Pageable pageable) { Page posts = postRepository.searchPosts(condition, pageable); - Set likedPostIds = userId != null - ? getUserLikedPostIds(userId, posts) + Set likedPostIds = user != null && user.getId() != null + ? getUserLikedPostIds(user.getId(), posts) : Collections.emptySet(); return posts.map(post -> postMappers.toSummaryResponse( @@ -127,31 +124,33 @@ public Page getPosts(Long userId, PostSearchCondition condi } @Transactional - public Long updatePost(Long userId, Long postId, PostRequest request) { - if (userId == null) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + public Long updatePost(User user, Long postId, PostRequest request) { + if (user == null || user.getId() == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + } - Post post = validatePostOwnership(userId, postId); + Post post = validatePostOwnership(user, postId); post.updatePost(request.title(), request.content(), request.category()); return postId; } @Transactional - public void deletePost(Long userId, Long postId) { - if (userId == null) throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + public void deletePost(User user, Long postId) { + if (user == null || user.getId() == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED_USER); + } - Post post = validatePostOwnership(userId, postId); + Post post = validatePostOwnership(user, postId); postRepository.delete(post); } - private Post validatePostOwnership(Long userId, Long postId) { + private Post validatePostOwnership(User requestUser, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); - User requestUser = userRepository.findById(userId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); - - post.checkUser(requestUser); + log.info("Validating post ownership for user ID: {} and post ID: {}", requestUser.getId(), post.getUser().getId()); + post.checkUser(requestUser.getId()); return post; } @@ -186,4 +185,4 @@ private ScenarioDetailResponse getScenarioInfoForCreate(Post post) { return ScenarioDetailResponse.from(post.getScenario(), sceneTypes); } -} +} \ No newline at end of file From e58da369bb25c66cafd99ec34a9771208d2c8500 Mon Sep 17 00:00:00 2001 From: shihan00321 Date: Fri, 10 Oct 2025 08:55:20 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[Feat]:=20=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=8A=A4=ED=95=80=EB=9D=BD=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/global/common/LockManager.java | 40 ----------- .../java/com/back/global/common/WithLock.java | 2 +- .../java/com/back/global/lock/LockAspect.java | 32 ++------- .../back/global/lock/RedisLockManager.java | 27 +++++++ .../com/back/global/lock/RedisLuaLock.java | 71 +++++++++++++++++++ 5 files changed, 105 insertions(+), 67 deletions(-) delete mode 100644 back/src/main/java/com/back/global/common/LockManager.java create mode 100644 back/src/main/java/com/back/global/lock/RedisLockManager.java create mode 100644 back/src/main/java/com/back/global/lock/RedisLuaLock.java diff --git a/back/src/main/java/com/back/global/common/LockManager.java b/back/src/main/java/com/back/global/common/LockManager.java deleted file mode 100644 index 028cd01..0000000 --- a/back/src/main/java/com/back/global/common/LockManager.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.back.global.common; - -import lombok.Getter; -import org.springframework.stereotype.Component; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.locks.ReentrantLock; - -@Component -public class LockManager { - private final ConcurrentHashMap lockMap = new ConcurrentHashMap<>(); - - public ReentrantLock getLock(String key) { - LockHolder holder = lockMap.computeIfAbsent(key, k -> new LockHolder()); - holder.incrementRef(); - return holder.getLock(); - } - - public void releaseLock(String key) { - LockHolder holder = lockMap.get(key); - if (holder != null && holder.decrementRef() == 0) { - lockMap.remove(key, holder); - } - } - - @Getter - private static class LockHolder { - private final ReentrantLock lock = new ReentrantLock(true); - private final AtomicInteger refCount = new AtomicInteger(0); - - public int incrementRef() { - return refCount.incrementAndGet(); - } - - public int decrementRef() { - return refCount.decrementAndGet(); - } - } -} diff --git a/back/src/main/java/com/back/global/common/WithLock.java b/back/src/main/java/com/back/global/common/WithLock.java index 2b07b8c..d8df22a 100644 --- a/back/src/main/java/com/back/global/common/WithLock.java +++ b/back/src/main/java/com/back/global/common/WithLock.java @@ -9,5 +9,5 @@ @Retention(RetentionPolicy.RUNTIME) public @interface WithLock { String key(); - long waitTime() default 1000; // 락 획득 대기 시간 (ms) + long waitTime() default 5000; // 락 획득 대기 시간 (ms) } diff --git a/back/src/main/java/com/back/global/lock/LockAspect.java b/back/src/main/java/com/back/global/lock/LockAspect.java index 5a22e83..6c4d4c9 100644 --- a/back/src/main/java/com/back/global/lock/LockAspect.java +++ b/back/src/main/java/com/back/global/lock/LockAspect.java @@ -1,9 +1,6 @@ package com.back.global.lock; -import com.back.global.common.LockManager; import com.back.global.common.WithLock; -import com.back.global.exception.ApiException; -import com.back.global.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; @@ -17,13 +14,12 @@ import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; @Slf4j @Aspect @Component -@Order(Ordered.LOWEST_PRECEDENCE - 1) // 트랜잭션 AOP보다 낮은 우선순위 설정 +@Order(Ordered.LOWEST_PRECEDENCE - 1) public class LockAspect { private final LockManager lockManager; private final ExpressionParser parser = new SpelExpressionParser(); @@ -37,27 +33,13 @@ public Object applyLock(ProceedingJoinPoint joinPoint, WithLock withLock) throws String lockKey = generateLockKey(joinPoint, withLock.key()); ReentrantLock lock = lockManager.getLock(lockKey); - boolean acquired = false; - + lock.lock(); try { - // 락 획득 시도 - acquired = lock.tryLock(withLock.waitTime(), TimeUnit.MILLISECONDS); - - if (!acquired) { - throw new ApiException(ErrorCode.LOCK_ACQUISITION_FAILED); - } - return joinPoint.proceed(); - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ApiException(ErrorCode.LOCK_ACQUISITION_FAILED); } finally { - if (acquired) { - lock.unlock(); - log.debug("Lock released: {}", lockKey); - lockManager.releaseLock(lockKey); - } + lock.unlock(); + log.debug("Lock released: {}", lockKey); + lockManager.releaseLock(lockKey); } } @@ -66,15 +48,13 @@ private String generateLockKey(ProceedingJoinPoint joinPoint, String keyExpressi String[] parameterNames = signature.getParameterNames(); Object[] args = joinPoint.getArgs(); - // SpEL Context 생성 StandardEvaluationContext context = new StandardEvaluationContext(); for (int i = 0; i < parameterNames.length; i++) { context.setVariable(parameterNames[i], args[i]); } - // SpEL 표현식 평가 Expression expression = parser.parseExpression(keyExpression); return expression.getValue(context, String.class); } - } + diff --git a/back/src/main/java/com/back/global/lock/RedisLockManager.java b/back/src/main/java/com/back/global/lock/RedisLockManager.java new file mode 100644 index 0000000..4f783a1 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/RedisLockManager.java @@ -0,0 +1,27 @@ +package com.back.global.lock; + +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Component; + +import java.util.concurrent.locks.ReentrantLock; + +@Component +public class RedisLockManager implements LockManager { + + private final RedisConnectionFactory factory; + + public RedisLockManager(RedisConnectionFactory factory) { + this.factory = factory; + } + + @Override + public ReentrantLock getLock(String key) { + return new RedisLuaLock(key, factory); + } + + @Override + public void releaseLock(String key) {} +} + + diff --git a/back/src/main/java/com/back/global/lock/RedisLuaLock.java b/back/src/main/java/com/back/global/lock/RedisLuaLock.java new file mode 100644 index 0000000..46bf429 --- /dev/null +++ b/back/src/main/java/com/back/global/lock/RedisLuaLock.java @@ -0,0 +1,71 @@ +package com.back.global.lock; + +import com.back.global.exception.ApiException; +import com.back.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +import java.util.Collections; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +public class RedisLuaLock extends ReentrantLock { + + private final StringRedisTemplate redisTemplate; + private final String key; + private static final long TTL = 5000; + private static final int RETRIES = 50; + private static final long RETRY_DELAY_MS = 50; + + private static final String LUA_LOCK = """ + if redis.call('SETNX', KEYS[1], 'locked') == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + return 1 + else + return 0 + end + """; + + public RedisLuaLock(String key, RedisConnectionFactory factory) { + super(true); + this.key = "lock:" + key; + this.redisTemplate = new StringRedisTemplate(factory); + } + + @Override + public void lock() { + int retries = RETRIES; + boolean acquired = false; + while (!acquired && retries-- > 0) { + acquired = tryLockLua(); + if (!acquired) { + try { + Thread.sleep(RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Lock interrupted", e); + } + } + } + if (!acquired) { + throw new ApiException(ErrorCode.LOCK_ACQUISITION_FAILED); + } + } + + private boolean tryLockLua() { + Long result = redisTemplate.execute( + new DefaultRedisScript<>(LUA_LOCK, Long.class), + Collections.singletonList(key), + String.valueOf(TTL) + ); + return Long.valueOf(1).equals(result); + } + + @Override + public void unlock() { + redisTemplate.delete(key); + } +} +