diff --git a/build.gradle b/build.gradle index 741a97c3..32de6923 100644 --- a/build.gradle +++ b/build.gradle @@ -64,6 +64,9 @@ dependencies { // AWS S3 의존성 implementation 'software.amazon.awssdk:s3:2.17.70' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // 레디스 의존성 + implementation 'org.redisson:redisson-spring-boot-starter:3.23.2' } tasks.named('test') { diff --git a/src/main/java/com/brainpix/BrainpixApplication.java b/src/main/java/com/brainpix/BrainpixApplication.java index d2d4645f..8f16b885 100644 --- a/src/main/java/com/brainpix/BrainpixApplication.java +++ b/src/main/java/com/brainpix/BrainpixApplication.java @@ -1,17 +1,19 @@ package com.brainpix; -import jakarta.annotation.PostConstruct; - import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.mongodb.config.EnableMongoAuditing; import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; + +import jakarta.annotation.PostConstruct; @SpringBootApplication @EnableMongoAuditing @EnableMongoRepositories(basePackages = {"com.brainpix.message.repository", "com.brainpix.alarm.repository"}) +@EnableScheduling public class BrainpixApplication { @PostConstruct diff --git a/src/main/java/com/brainpix/api/code/error/KakaoPayErrorCode.java b/src/main/java/com/brainpix/api/code/error/KakaoPayErrorCode.java index c5f590c2..dcb614a9 100644 --- a/src/main/java/com/brainpix/api/code/error/KakaoPayErrorCode.java +++ b/src/main/java/com/brainpix/api/code/error/KakaoPayErrorCode.java @@ -13,6 +13,9 @@ public enum KakaoPayErrorCode implements ErrorCode { IDEA_OWNER_PURCHASE_INVALID(HttpStatus.BAD_REQUEST, "KAKAOPAY400", "글 작성자는 구매할 수 없습니다."), QUANTITY_INVALID(HttpStatus.BAD_REQUEST, "KAKAOPAY400", "구매 수량은 재고를 초과할 수 없습니다."), + // 404 Not Found - 리소스를 찾을 수 없음 + PAYMENT_DATA_NOT_FOUND(HttpStatus.NOT_FOUND, "KAKAOPAY404", "결제 정보를 찾을 수 없습니다."), + // 500 Internal Server Error - 서버 내부 오류 API_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "KAKAOPAY500", "카카오페이 API 호출 중 오류가 발생하였습니다."); diff --git a/src/main/java/com/brainpix/config/RedisConfig.java b/src/main/java/com/brainpix/config/RedisConfig.java new file mode 100644 index 00000000..44c14487 --- /dev/null +++ b/src/main/java/com/brainpix/config/RedisConfig.java @@ -0,0 +1,28 @@ +package com.brainpix.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedisConfig { + + @Value("${spring.redis.host}") + private String host; + + @Value("${spring.redis.port}") + private Integer port; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://" + host + ":" + port) // TLS이면 rediss:// + .setConnectionMinimumIdleSize(10) + .setConnectionPoolSize(50); + return Redisson.create(config); + } +} diff --git a/src/main/java/com/brainpix/kakaopay/api_client/KakaoPayApiClient.java b/src/main/java/com/brainpix/kakaopay/api_client/KakaoPayApiClient.java index 31d64efa..65195f87 100644 --- a/src/main/java/com/brainpix/kakaopay/api_client/KakaoPayApiClient.java +++ b/src/main/java/com/brainpix/kakaopay/api_client/KakaoPayApiClient.java @@ -12,7 +12,7 @@ import com.brainpix.kakaopay.dto.KakaoPayApproveDto; import com.brainpix.kakaopay.dto.KakaoPayReadyDto; -import com.brainpix.kakaopay.entity.KakaoPaymentData; +import com.brainpix.kakaopay.dto.KakaoPaymentDataDto; import com.brainpix.post.entity.idea_market.IdeaMarket; import com.brainpix.user.entity.User; @@ -51,7 +51,7 @@ public KakaoPayReadyDto.KakaoApiResponse requestPaymentReady( // 카카오페이 결제 승인 API public KakaoPayApproveDto.KakaoApiResponse requestPaymentApprove( - KakaoPayApproveDto.Parameter parameter, KakaoPaymentData kakaoPaymentData) { + KakaoPayApproveDto.Parameter parameter, KakaoPaymentDataDto kakaoPaymentData) { Map parameters = getApproveParams(parameter, kakaoPaymentData); HttpHeaders headers = getHeaders(); @@ -89,14 +89,14 @@ private Map getReadyParams( // 결제 승인 파라미터 생성 private Map getApproveParams(KakaoPayApproveDto.Parameter parameter, - KakaoPaymentData kakaoPaymentData) { + KakaoPaymentDataDto kakaoPaymentData) { Map params = new HashMap<>(); params.put("cid", cid); params.put("tid", kakaoPaymentData.getTid()); params.put("partner_order_id", parameter.getOrderId()); - params.put("partner_user_id", String.valueOf(kakaoPaymentData.getBuyer().getId())); + params.put("partner_user_id", String.valueOf(kakaoPaymentData.getBuyerId())); params.put("pg_token", parameter.getPgToken()); return params; diff --git a/src/main/java/com/brainpix/kakaopay/controller/KakaoPayController.java b/src/main/java/com/brainpix/kakaopay/controller/KakaoPayController.java index b3f5c447..d8b3d24e 100644 --- a/src/main/java/com/brainpix/kakaopay/controller/KakaoPayController.java +++ b/src/main/java/com/brainpix/kakaopay/controller/KakaoPayController.java @@ -30,7 +30,7 @@ public class KakaoPayController { private final KakaoPayFacadeService kakaoPayService; @AllUser - @Operation(summary = "결제 준비 API", description = "아이디어 식별자, 판매자 식별자, 상품 수량, 총 결제금액(vat 포함), vat를 json 본문으로 입력받아 카카오페이 쪽에 결제 정보를 생성합니다.
사용자를 결제 화면으로 이동시킬 수 있도록 리다이렉트 url이 응답값으로 제공됩니다.
해당 url로 이동하여 비밀번호 입력을 마치면 프론트 쪽 경로로 이동합니다. 이때 쿼리 파라미터로 전달되는 pgToken, ideaId, orderId를 /kakao-pay/approve에 전달하시면 됩니다.") + @Operation(summary = "결제 준비 API", description = "아이디어 식별자, 판매자 식별자, 상품 수량, 총 결제금액(vat 포함), vat를 json 본문으로 입력받아 카카오페이 쪽에 결제 정보를 생성합니다.
사용자를 결제 화면으로 이동시킬 수 있도록 리다이렉트 url과 주문 번호가 응답값으로 제공됩니다.
주문 번호는 이후 승인 단계에서 pgToken과 함께 전달해주시면 됩니다.") @PostMapping("/ready") public ResponseEntity> kakaoPayReady(@UserId Long userId, @RequestBody KakaoPayReadyDto.Request request) { diff --git a/src/main/java/com/brainpix/kakaopay/converter/KakaoPayApproveDtoConverter.java b/src/main/java/com/brainpix/kakaopay/converter/KakaoPayApproveDtoConverter.java index 9ff25967..1896f39e 100644 --- a/src/main/java/com/brainpix/kakaopay/converter/KakaoPayApproveDtoConverter.java +++ b/src/main/java/com/brainpix/kakaopay/converter/KakaoPayApproveDtoConverter.java @@ -15,6 +15,7 @@ public static KakaoPayApproveDto.Parameter toParameter(Long userId, KakaoPayAppr .userId(userId) .orderId(request.getOrderId()) .pgToken(request.getPgToken()) + .ideaId(request.getIdeaId()) .build(); } diff --git a/src/main/java/com/brainpix/kakaopay/converter/KakaoPayReadyDtoConverter.java b/src/main/java/com/brainpix/kakaopay/converter/KakaoPayReadyDtoConverter.java index 4ccbbab2..c38b5a09 100644 --- a/src/main/java/com/brainpix/kakaopay/converter/KakaoPayReadyDtoConverter.java +++ b/src/main/java/com/brainpix/kakaopay/converter/KakaoPayReadyDtoConverter.java @@ -1,9 +1,6 @@ package com.brainpix.kakaopay.converter; import com.brainpix.kakaopay.dto.KakaoPayReadyDto; -import com.brainpix.kakaopay.entity.KakaoPaymentData; -import com.brainpix.post.entity.idea_market.IdeaMarket; -import com.brainpix.user.entity.User; public class KakaoPayReadyDtoConverter { @@ -27,18 +24,4 @@ public static KakaoPayReadyDto.Response toResponse(KakaoPayReadyDto.KakaoApiResp .orderId(orderId) .build(); } - - public static KakaoPaymentData toKakaoPaymentData(KakaoPayReadyDto.KakaoApiResponse kakaoApiResponse, - KakaoPayReadyDto.Parameter parameter, - String orderId, User buyer, - IdeaMarket ideaMarket) { - - return KakaoPaymentData.builder() - .tid(kakaoApiResponse.getTid()) - .quantity(parameter.getQuantity()) - .orderId(orderId) - .buyer(buyer) - .ideaMarket(ideaMarket) - .build(); - } } diff --git a/src/main/java/com/brainpix/kakaopay/converter/KakaoPaymentDataDtoConverter.java b/src/main/java/com/brainpix/kakaopay/converter/KakaoPaymentDataDtoConverter.java new file mode 100644 index 00000000..70b1a578 --- /dev/null +++ b/src/main/java/com/brainpix/kakaopay/converter/KakaoPaymentDataDtoConverter.java @@ -0,0 +1,15 @@ +package com.brainpix.kakaopay.converter; + +import com.brainpix.kakaopay.dto.KakaoPaymentDataDto; + +public class KakaoPaymentDataDtoConverter { + + public static KakaoPaymentDataDto toKakaoPaymentDataDto(String tid, Long buyerId, Long quantity) { + + return KakaoPaymentDataDto.builder() + .tid(tid) + .buyerId(buyerId) + .quantity(quantity) + .build(); + } +} diff --git a/src/main/java/com/brainpix/kakaopay/dto/KakaoPaymentDataDto.java b/src/main/java/com/brainpix/kakaopay/dto/KakaoPaymentDataDto.java new file mode 100644 index 00000000..808dde56 --- /dev/null +++ b/src/main/java/com/brainpix/kakaopay/dto/KakaoPaymentDataDto.java @@ -0,0 +1,13 @@ +package com.brainpix.kakaopay.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class KakaoPaymentDataDto { + + private String tid; // 카카오페이 쪽 주문 고유 번호 + private Long buyerId; // 유저 ID + private Long quantity; // 구매 수량 +} diff --git a/src/main/java/com/brainpix/kakaopay/entity/KakaoPaymentData.java b/src/main/java/com/brainpix/kakaopay/entity/KakaoPaymentData.java deleted file mode 100644 index 56c4dcc4..00000000 --- a/src/main/java/com/brainpix/kakaopay/entity/KakaoPaymentData.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.brainpix.kakaopay.entity; - -import com.brainpix.jpa.BaseTimeEntity; -import com.brainpix.post.entity.idea_market.IdeaMarket; -import com.brainpix.user.entity.User; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToOne; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor -public class KakaoPaymentData extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String orderId; - private String tid; - private Long quantity; - - @ManyToOne - private User buyer; - @ManyToOne - private IdeaMarket ideaMarket; - - @Builder - public KakaoPaymentData(Long id, String orderId, String tid, Long quantity, User buyer, IdeaMarket ideaMarket) { - this.id = id; - this.orderId = orderId; - this.tid = tid; - this.quantity = quantity; - this.buyer = buyer; - this.ideaMarket = ideaMarket; - } -} diff --git a/src/main/java/com/brainpix/kakaopay/repository/KakaoPaymentDataRepository.java b/src/main/java/com/brainpix/kakaopay/repository/KakaoPaymentDataRepository.java deleted file mode 100644 index e0c060a3..00000000 --- a/src/main/java/com/brainpix/kakaopay/repository/KakaoPaymentDataRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.brainpix.kakaopay.repository; - -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.brainpix.kakaopay.entity.KakaoPaymentData; - -public interface KakaoPaymentDataRepository extends JpaRepository { - - Optional findByOrderId(String orderId); -} diff --git a/src/main/java/com/brainpix/kakaopay/repository/NamedLockRepository.java b/src/main/java/com/brainpix/kakaopay/repository/NamedLockRepository.java deleted file mode 100644 index dc7e9f38..00000000 --- a/src/main/java/com/brainpix/kakaopay/repository/NamedLockRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.brainpix.kakaopay.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import com.brainpix.joining.entity.purchasing.IdeaMarketPurchasing; - -public interface NamedLockRepository extends JpaRepository { - - // 네임드 락 획득 - @Query(value = "SELECT GET_LOCK(:lockName, 10)", nativeQuery = true) - Integer getLock(@Param("lockName") String lockName); - - // 네임드 락 해제 - @Query(value = "SELECT RELEASE_LOCK(:lockName)", nativeQuery = true) - Integer releaseLock(@Param("lockName") String lockName); -} diff --git a/src/main/java/com/brainpix/kakaopay/service/KakaoPayFacadeService.java b/src/main/java/com/brainpix/kakaopay/service/KakaoPayFacadeService.java index 4f0de8db..b08c3028 100644 --- a/src/main/java/com/brainpix/kakaopay/service/KakaoPayFacadeService.java +++ b/src/main/java/com/brainpix/kakaopay/service/KakaoPayFacadeService.java @@ -1,5 +1,9 @@ package com.brainpix.kakaopay.service; +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -7,7 +11,6 @@ import com.brainpix.api.exception.BrainPixException; import com.brainpix.kakaopay.dto.KakaoPayApproveDto; import com.brainpix.kakaopay.dto.KakaoPayReadyDto; -import com.brainpix.kakaopay.repository.NamedLockRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,35 +20,35 @@ @RequiredArgsConstructor public class KakaoPayFacadeService { - private final NamedLockRepository namedLockRepository; private final KakaoPayService kakaoPayService; + private final RedissonClient redissonClient; @Transactional public KakaoPayReadyDto.Response kakaoPayReady(KakaoPayReadyDto.Parameter parameter) { return kakaoPayService.kakaoPayReady(parameter); } - @Transactional + // 락 획득 → 트랜잭션 시작 → 트랜잭션 해제 → 락 해제 (락 해제가 커밋 이후로 보장되어야 하므로 Facade 패턴 적용) public KakaoPayApproveDto.Response kakaoPayApprove(KakaoPayApproveDto.Parameter parameter) { - String lockName = "LOCK_KAKAO_PAY_APPROVE_" + parameter.getIdeaId(); - try { - acquireLock(lockName); - return kakaoPayService.kakaoPayApprove(parameter); - } finally { - releaseLock(lockName); - } - } - private void acquireLock(String lockName) { - log.info("락 요청 lockName : {}", lockName); - Integer result = namedLockRepository.getLock(lockName); - if (result == null || result == 0) { + String lockKey = "lock:idea:" + parameter.getIdeaId(); + RLock lock = redissonClient.getLock(lockKey); + boolean isLocked = false; + + try { + isLocked = lock.tryLock(5, TimeUnit.SECONDS); // 5초동안 락 획득 대기 + if (isLocked) { + log.info("결제 락 획득 성공: {}", lockKey); + return kakaoPayService.kakaoPayApprove(parameter); // 승인 서비스 코드 실행 + } else { + log.info("결제 락 획득 실패: {}", lockKey); + throw new BrainPixException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } catch (InterruptedException e) { throw new BrainPixException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } finally { + lock.unlock(); // 락 해제 + log.info("결제 락 해제 성공: {}", lockKey); } } - - private void releaseLock(String lockName) { - log.info("락 반환 lockName : {}", lockName); - namedLockRepository.releaseLock(lockName); - } } diff --git a/src/main/java/com/brainpix/kakaopay/service/KakaoPayService.java b/src/main/java/com/brainpix/kakaopay/service/KakaoPayService.java index 05abca00..9a2284ca 100644 --- a/src/main/java/com/brainpix/kakaopay/service/KakaoPayService.java +++ b/src/main/java/com/brainpix/kakaopay/service/KakaoPayService.java @@ -4,7 +4,6 @@ import java.util.UUID; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import com.brainpix.api.code.error.CommonErrorCode; @@ -19,12 +18,13 @@ import com.brainpix.kakaopay.api_client.KakaoPayApiClient; import com.brainpix.kakaopay.converter.KakaoPayApproveDtoConverter; import com.brainpix.kakaopay.converter.KakaoPayReadyDtoConverter; +import com.brainpix.kakaopay.converter.KakaoPaymentDataDtoConverter; import com.brainpix.kakaopay.dto.KakaoPayApproveDto; import com.brainpix.kakaopay.dto.KakaoPayReadyDto; -import com.brainpix.kakaopay.entity.KakaoPaymentData; -import com.brainpix.kakaopay.repository.KakaoPaymentDataRepository; +import com.brainpix.kakaopay.dto.KakaoPaymentDataDto; import com.brainpix.post.entity.idea_market.IdeaMarket; import com.brainpix.post.repository.IdeaMarketRepository; +import com.brainpix.redis.service.RedisKakaoPayCacheService; import com.brainpix.user.entity.User; import com.brainpix.user.repository.UserRepository; @@ -39,9 +39,9 @@ public class KakaoPayService { private final UserRepository userRepository; private final IdeaMarketRepository ideaMarketRepository; private final IdeaMarketPurchasingRepository ideaMarketPurchasingRepository; - private final KakaoPaymentDataRepository kakaoPaymentDataRepository; private final AlarmEventService alarmEventService; private final KakaoPayApiClient kakaoPayApiClient; + private final RedisKakaoPayCacheService redisKakaoPayCacheService; @Transactional public KakaoPayReadyDto.Response kakaoPayReady(KakaoPayReadyDto.Parameter parameter) { @@ -74,34 +74,35 @@ public KakaoPayReadyDto.Response kakaoPayReady(KakaoPayReadyDto.Parameter parame log.info("카카오페이 결제 준비 API 성공"); - // 4. 결제 정보 DB에 저장 - KakaoPaymentData kakaoPaymentData = KakaoPayReadyDtoConverter.toKakaoPaymentData(kakaoApiResponse, - parameter, orderId, buyer, ideaMarket); + // 4. 결제 정보 캐싱 + KakaoPaymentDataDto kakaoPaymentData = KakaoPaymentDataDtoConverter.toKakaoPaymentDataDto( + kakaoApiResponse.getTid(), buyer.getId(), + parameter.getQuantity()); - kakaoPaymentDataRepository.save(kakaoPaymentData); + redisKakaoPayCacheService.savePaymentData(orderId, kakaoPaymentData); // 5. 최종 응답 return KakaoPayReadyDtoConverter.toResponse(kakaoApiResponse, orderId); } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public KakaoPayApproveDto.Response kakaoPayApprove(KakaoPayApproveDto.Parameter parameter) { - // 1. DB에서 결제 정보 조회 - KakaoPaymentData kakaoPaymentData = kakaoPaymentDataRepository.findByOrderId(parameter.getOrderId()) - .orElseThrow(() -> new BrainPixException(CommonErrorCode.RESOURCE_NOT_FOUND)); + // 1. 결제 정보 조회 + KakaoPaymentDataDto kakaoPaymentData = redisKakaoPayCacheService.getPaymentData(parameter.getOrderId()) + .orElseThrow(() -> new BrainPixException(KakaoPayErrorCode.PAYMENT_DATA_NOT_FOUND)); // 2. 엔티티 검색 User user = userRepository.findById(parameter.getUserId()) .orElseThrow(() -> new BrainPixException(CommonErrorCode.RESOURCE_NOT_FOUND)); - IdeaMarket ideaMarket = ideaMarketRepository.findById(kakaoPaymentData.getIdeaMarket().getId()) + IdeaMarket ideaMarket = ideaMarketRepository.findById(parameter.getIdeaId()) .orElseThrow(() -> new BrainPixException(PostErrorCode.POST_NOT_FOUND)); // 3. API 호출 전 검증 로직 if (kakaoPaymentData.getQuantity() > ideaMarket.getPrice().getRemainingQuantity()) { throw new BrainPixException(KakaoPayErrorCode.QUANTITY_INVALID); // 주문 수량이 재고를 초과한 경우 } - if (user != kakaoPaymentData.getBuyer()) { + if (user.getId() != kakaoPaymentData.getBuyerId()) { throw new BrainPixException(CommonErrorCode.INVALID_PARAMETER); // API 호출자와 실 구매자가 일치하지 않는 경우 } @@ -123,7 +124,7 @@ public KakaoPayApproveDto.Response kakaoPayApprove(KakaoPayApproveDto.Parameter ideaMarket, PaymentDuration.ONCE); ideaMarketPurchasingRepository.save(ideaMarketPurchasing); - kakaoPaymentDataRepository.delete(kakaoPaymentData); + redisKakaoPayCacheService.deletePaymentData(parameter.getOrderId()); // 8. 판매자에게 알람 생성 alarmEventService.publishIdeaSold(ideaMarket.getWriter().getId(), ideaMarket.getWriter().getName()); diff --git a/src/main/java/com/brainpix/post/repository/PostRepository.java b/src/main/java/com/brainpix/post/repository/PostRepository.java index e188e701..6d910a02 100644 --- a/src/main/java/com/brainpix/post/repository/PostRepository.java +++ b/src/main/java/com/brainpix/post/repository/PostRepository.java @@ -15,6 +15,6 @@ public interface PostRepository extends JpaRepository { Page findByWriter(User writer, Pageable pageable); @Modifying - @Query("update Post p set p.viewCount = p.viewCount + 1 where p.id = :id") - Integer increaseViewCount(@Param("id") Long postId); + @Query("update Post p set p.viewCount = p.viewCount + :viewCount where p.id = :id") + Integer updateViewCount(@Param("id") Long postId, @Param("viewCount") Long viewCount); } \ No newline at end of file diff --git a/src/main/java/com/brainpix/post/service/CollaborationHubService.java b/src/main/java/com/brainpix/post/service/CollaborationHubService.java index 7a9f6744..4561af4f 100644 --- a/src/main/java/com/brainpix/post/service/CollaborationHubService.java +++ b/src/main/java/com/brainpix/post/service/CollaborationHubService.java @@ -32,8 +32,8 @@ import com.brainpix.post.repository.CollaborationHubRepository; import com.brainpix.post.repository.CollaborationRecruitmentRepository; import com.brainpix.post.repository.IdeaMarketRepository; -import com.brainpix.post.repository.PostRepository; import com.brainpix.post.repository.SavedPostRepository; +import com.brainpix.redis.service.RedisViewCountService; import com.brainpix.security.authority.BrainpixAuthority; import com.brainpix.user.entity.User; import com.brainpix.user.repository.UserRepository; @@ -55,7 +55,7 @@ public class CollaborationHubService { private final IdeaMarketRepository ideaMarketRepository; private final RequestTaskPurchasingRepository requestTaskPurchasingRepository; private final AlarmEventService alarmEventService; - private final PostRepository postRepository; + private final RedisViewCountService redisViewCountService; @Transactional public Long createCollaborationHub(Long userId, CollaborationHubCreateDto createDto) { @@ -115,7 +115,7 @@ public CommonPageResponse getCol return GetCollaborationHubListDtoConverter.toResponse(result); } - @Transactional + @Transactional(readOnly = true) public GetCollaborationHubDetailDto.Response getCollaborationHubDetail( GetCollaborationHubDetailDto.Parameter parameter) { @@ -134,7 +134,7 @@ public GetCollaborationHubDetailDto.Response getCollaborationHubDetail( } // 조회수 증가 - postRepository.increaseViewCount(collaborationHub.getId()); + redisViewCountService.increaseViewCount(collaborationHub.getId(), parameter.getUserId()); // 작성자 조회 User writer = collaborationHub.getWriter(); diff --git a/src/main/java/com/brainpix/post/service/IdeaMarketService.java b/src/main/java/com/brainpix/post/service/IdeaMarketService.java index 58d1618b..b4f6b556 100644 --- a/src/main/java/com/brainpix/post/service/IdeaMarketService.java +++ b/src/main/java/com/brainpix/post/service/IdeaMarketService.java @@ -28,8 +28,8 @@ import com.brainpix.post.entity.idea_market.IdeaMarket; import com.brainpix.post.entity.idea_market.IdeaMarketType; import com.brainpix.post.repository.IdeaMarketRepository; -import com.brainpix.post.repository.PostRepository; import com.brainpix.post.repository.SavedPostRepository; +import com.brainpix.redis.service.RedisViewCountService; import com.brainpix.security.authority.BrainpixAuthority; import com.brainpix.user.entity.User; import com.brainpix.user.repository.UserRepository; @@ -47,7 +47,7 @@ public class IdeaMarketService { private final PriceService priceService; private final CreateIdeaMarketConverter createIdeaMarketConverter; private final RequestTaskPurchasingRepository requestTaskPurchasingRepository; - private final PostRepository postRepository; + private final RedisViewCountService redisViewCountService; @Transactional public Long createIdeaMarket(Long userId, IdeaMarketCreateDto createDto) { @@ -106,7 +106,7 @@ public CommonPageResponse getIdeaList(GetIdeaListDto. } // 아이디어 식별자 값을 입력받아 상세보기에 관한 내용을 반환합니다. - @Transactional + @Transactional(readOnly = true) public GetIdeaDetailDto.Response getIdeaDetail(GetIdeaDetailDto.Parameter parameter) { // 유저 조회 @@ -124,7 +124,7 @@ public GetIdeaDetailDto.Response getIdeaDetail(GetIdeaDetailDto.Parameter parame } // 조회수 증가 - postRepository.increaseViewCount(ideaMarket.getId()); + redisViewCountService.increaseViewCount(ideaMarket.getId(), parameter.getUserId()); // 작성자 조회 User writer = ideaMarket.getWriter(); diff --git a/src/main/java/com/brainpix/post/service/RequestTaskQueryService.java b/src/main/java/com/brainpix/post/service/RequestTaskQueryService.java index 1428196c..3eb37651 100644 --- a/src/main/java/com/brainpix/post/service/RequestTaskQueryService.java +++ b/src/main/java/com/brainpix/post/service/RequestTaskQueryService.java @@ -19,9 +19,9 @@ import com.brainpix.post.entity.PostAuth; import com.brainpix.post.entity.request_task.RequestTask; import com.brainpix.post.repository.IdeaMarketRepository; -import com.brainpix.post.repository.PostRepository; import com.brainpix.post.repository.RequestTaskRepository; import com.brainpix.post.repository.SavedPostRepository; +import com.brainpix.redis.service.RedisViewCountService; import com.brainpix.security.authority.BrainpixAuthority; import com.brainpix.user.entity.User; import com.brainpix.user.repository.UserRepository; @@ -38,7 +38,7 @@ public class RequestTaskQueryService { private final CollectionGatheringRepository collectionGatheringRepository; private final UserRepository userRepository; private final RequestTaskPurchasingRepository requestTaskPurchasingRepository; - private final PostRepository postRepository; + private final RedisViewCountService redisViewCountService; // 요청 과제 메인페이지에서 검색 조건을 적용하여 요청 과제 목록을 반환합니다. @Transactional(readOnly = true) @@ -96,7 +96,7 @@ public GetRequestTaskDetailDto.Response getRequestTaskDetail( } // 조회수 증가 - postRepository.increaseViewCount(requestTask.getId()); + redisViewCountService.increaseViewCount(requestTask.getId(), parameter.getUserId()); // 작성자 조회 User writer = requestTask.getWriter(); diff --git a/src/main/java/com/brainpix/redis/service/RedisKakaoPayCacheService.java b/src/main/java/com/brainpix/redis/service/RedisKakaoPayCacheService.java new file mode 100644 index 00000000..fac065d2 --- /dev/null +++ b/src/main/java/com/brainpix/redis/service/RedisKakaoPayCacheService.java @@ -0,0 +1,38 @@ +package com.brainpix.redis.service; + +import java.time.Duration; +import java.util.Optional; + +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +import com.brainpix.kakaopay.dto.KakaoPaymentDataDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RedisKakaoPayCacheService { + + private final RedissonClient redissonClient; + private final String PAYMENT_DATA_PREFIX = "kakao:paymentData:"; + + // 결제 정보 캐싱 + public void savePaymentData(String orderId, KakaoPaymentDataDto kakaoPaymentDataDto) { + RBucket bucket = redissonClient.getBucket(PAYMENT_DATA_PREFIX + orderId); + bucket.set(kakaoPaymentDataDto, Duration.ofMinutes(5)); // 유효 시간 5분 + } + + // 결제 정보 조회 + public Optional getPaymentData(String orderId) { + RBucket bucket = redissonClient.getBucket(PAYMENT_DATA_PREFIX + orderId); + return Optional.ofNullable(bucket.get()); + } + + // 결제 정보 삭제 + public void deletePaymentData(String orderId) { + RBucket bucket = redissonClient.getBucket(PAYMENT_DATA_PREFIX + orderId); + bucket.delete(); + } +} diff --git a/src/main/java/com/brainpix/redis/service/RedisViewCountService.java b/src/main/java/com/brainpix/redis/service/RedisViewCountService.java new file mode 100644 index 00000000..e58541ed --- /dev/null +++ b/src/main/java/com/brainpix/redis/service/RedisViewCountService.java @@ -0,0 +1,48 @@ +package com.brainpix.redis.service; + +import java.time.Duration; + +import org.redisson.api.RSet; +import org.redisson.api.RedissonClient; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.brainpix.post.repository.PostRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RedisViewCountService { + + private final RedissonClient redissonClient; + private final PostRepository postRepository; + private final String POST_PREFIX = "post:"; + + // 조회수 어뷰징 방지를 위한 set 사용 + public void increaseViewCount(Long postId, Long userId) { + RSet viewUsers = redissonClient.getSet(POST_PREFIX + postId); + viewUsers.add(userId); + viewUsers.expire(Duration.ofDays(1)); + } + + @Scheduled(fixedRate = 60000) // 1분마다 조회수 업데이트 + @Transactional + public void increaseViewCountToDB() { + Iterable keys = redissonClient.getKeys().getKeysByPattern(POST_PREFIX + "*"); + + for (String key : keys) { + RSet viewUsers = redissonClient.getSet(key); + + Long postId = Long.parseLong(key.substring(POST_PREFIX.length())); + Long viewCount = (long)viewUsers.size(); + + // DB에 조회수 반영 + postRepository.updateViewCount(postId, viewCount); + + // 반영 후 삭제 + viewUsers.delete(); + } + } +}