diff --git a/build.gradle b/build.gradle index b61a77e..d80b64f 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,10 @@ dependencies { // Rabbit MQ implementation 'org.springframework.boot:spring-boot-starter-amqp' + + // spring-retry + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' // bucket4j implementation 'com.bucket4j:bucket4j_jdk17-redis-common:8.13.1' diff --git a/src/main/java/gdsc/cau/puangbe/PuangbeApplication.java b/src/main/java/gdsc/cau/puangbe/PuangbeApplication.java index 7303131..124aa89 100644 --- a/src/main/java/gdsc/cau/puangbe/PuangbeApplication.java +++ b/src/main/java/gdsc/cau/puangbe/PuangbeApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; +@EnableRetry @SpringBootApplication @ConfigurationPropertiesScan @EnableScheduling diff --git a/src/main/java/gdsc/cau/puangbe/common/annotation/Retry.java b/src/main/java/gdsc/cau/puangbe/common/annotation/Retry.java new file mode 100644 index 0000000..b89703d --- /dev/null +++ b/src/main/java/gdsc/cau/puangbe/common/annotation/Retry.java @@ -0,0 +1,11 @@ +package gdsc.cau.puangbe.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Retry { +} diff --git a/src/main/java/gdsc/cau/puangbe/common/util/OptimisticLockRetry.java b/src/main/java/gdsc/cau/puangbe/common/util/OptimisticLockRetry.java new file mode 100644 index 0000000..f1b1856 --- /dev/null +++ b/src/main/java/gdsc/cau/puangbe/common/util/OptimisticLockRetry.java @@ -0,0 +1,38 @@ +package gdsc.cau.puangbe.common.util; + +import jakarta.persistence.OptimisticLockException; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.hibernate.StaleObjectStateException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; + +@Order(Ordered.LOWEST_PRECEDENCE - 1) +@Aspect +@Component +public class OptimisticLockRetry { + private static final int MAX_RETRIES = 1000; // 최대 1000번 재시도 + private static final int RETRY_DELAY_MS = 100; // 0.1초 간격으로 재시도 + + @Pointcut("@annotation(Retry)") + public void retry() { + } + + @Around("retry()") + public Object retryOptimisticLock(ProceedingJoinPoint joinPoint) throws Throwable { + Exception exceptionHolder = null; + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + return joinPoint.proceed(); + } catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) { + exceptionHolder = e; + Thread.sleep(RETRY_DELAY_MS); + } + } + throw exceptionHolder; + } +} diff --git a/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java b/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java index 5534217..1cfe477 100644 --- a/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java +++ b/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java @@ -28,6 +28,7 @@ public enum ResponseCode { // 405 Method Not Allowed METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, false, "허용되지 않은 메서드입니다."), + OPTIMISTIC_LOCK_FAILED(HttpStatus.METHOD_NOT_ALLOWED, false, "낙관적 락에 의해 요청이 취소되었습니다."), // 409 Conflict USER_ALREADY_EXISTS(HttpStatus.CONFLICT, false, "이미 존재하는 사용자입니다."), diff --git a/src/main/java/gdsc/cau/puangbe/photo/entity/PhotoRequest.java b/src/main/java/gdsc/cau/puangbe/photo/entity/PhotoRequest.java index b42bcfd..c46ebca 100644 --- a/src/main/java/gdsc/cau/puangbe/photo/entity/PhotoRequest.java +++ b/src/main/java/gdsc/cau/puangbe/photo/entity/PhotoRequest.java @@ -61,4 +61,9 @@ public void finishStatus() { this.status = RequestStatus.FINISHED; this.updateDate = LocalDateTime.now(); } + + public void modifyEmail(String email) { + this.email = email; + this.updateDate = LocalDateTime.now(); + } } diff --git a/src/main/java/gdsc/cau/puangbe/photo/repository/PhotoRequestRepository.java b/src/main/java/gdsc/cau/puangbe/photo/repository/PhotoRequestRepository.java index 4a8437e..bf00b71 100644 --- a/src/main/java/gdsc/cau/puangbe/photo/repository/PhotoRequestRepository.java +++ b/src/main/java/gdsc/cau/puangbe/photo/repository/PhotoRequestRepository.java @@ -1,7 +1,9 @@ package gdsc.cau.puangbe.photo.repository; import gdsc.cau.puangbe.photo.entity.PhotoRequest; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,8 +12,11 @@ @Repository public interface PhotoRequestRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findById(Long photoRequestId); // 특정 유저의 최근에 만들어진 PhotoRequest 조회 + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findTopByUserIdOrderByCreateDateDesc(Long photoRequestId); } diff --git a/src/main/java/gdsc/cau/puangbe/photo/service/PhotoServiceImpl.java b/src/main/java/gdsc/cau/puangbe/photo/service/PhotoServiceImpl.java index 8391738..6909a4d 100644 --- a/src/main/java/gdsc/cau/puangbe/photo/service/PhotoServiceImpl.java +++ b/src/main/java/gdsc/cau/puangbe/photo/service/PhotoServiceImpl.java @@ -1,5 +1,7 @@ package gdsc.cau.puangbe.photo.service; +import gdsc.cau.puangbe.common.annotation.ExeTimer; +import gdsc.cau.puangbe.common.annotation.Retry; import gdsc.cau.puangbe.common.enums.RequestStatus; import gdsc.cau.puangbe.common.exception.BaseException; import gdsc.cau.puangbe.common.util.ConstantUtil; @@ -11,11 +13,16 @@ import gdsc.cau.puangbe.photo.repository.PhotoRequestRepository; import gdsc.cau.puangbe.user.entity.User; import jakarta.mail.internet.MimeMessage; +import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.thymeleaf.TemplateEngine; @@ -36,11 +43,14 @@ public class PhotoServiceImpl implements PhotoService { @Override @Transactional public void uploadPhoto(Long photoRequestId, String imageUrl) { + // 예외처리 PhotoRequest photoRequest = photoRequestRepository.findById(photoRequestId) .orElseThrow(() -> new BaseException(ResponseCode.PHOTO_REQUEST_NOT_FOUND)); if (photoRequest.getStatus() == RequestStatus.FINISHED) { throw new BaseException(ResponseCode.URL_ALREADY_UPLOADED); } + + // 결과 이미지 업데이트 User user = photoRequest.getUser(); PhotoResult photoResult = getPhotoResult(photoRequestId); @@ -48,22 +58,19 @@ public void uploadPhoto(Long photoRequestId, String imageUrl) { photoRequestRepository.save(photoRequest); photoResult.update(imageUrl); photoResultRepository.save(photoResult); - log.info("결과 이미지 URL 업로드 완료: {}", imageUrl); - // Redis 대기열의 user 정보 삭제 - redisTemplate.opsForSet().remove(ConstantUtil.USER_ID_KEY, user.getId()); - redisTemplate.delete(user.getId().toString()); - log.info("Redis 대기열에서 요청 삭제 : {}", user.getId()); + log.info("upload photo"); + // 이메일 발송 - EmailInfo emailInfo = EmailInfo.builder() - .email(photoRequest.getEmail()) - .photoUrl(imageUrl) - .name(user.getUserName()) - .framePageUrl("https://www.google.com/") // TODO : 프론트 분들 링크 관련 답변 오면 프레임 페이지 링크 관련 수정 - .build(); - - sendEmail(emailInfo); +// EmailInfo emailInfo = EmailInfo.builder() +// .email(photoRequest.getEmail()) +// .photoUrl(imageUrl) +// .name(user.getUserName()) +// .framePageUrl("https://www.google.com/") // TODO : 프론트 분들 링크 관련 답변 오면 프레임 페이지 링크 관련 수정 +// .build(); +// +// sendEmail(emailInfo); } // 특정 요청의 imageUrl 조회 diff --git a/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestService.java b/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestService.java index c129f39..1fb7fda 100644 --- a/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestService.java +++ b/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestService.java @@ -12,4 +12,7 @@ public interface PhotoRequestService { //최근 생성 요청한 이미지의 상태 조회 String getRequestStatus(Long userId); + + // 이미지 처리 요청이 끝나지 않았을 경우 이메일 업데이트 + Long updateEmail(Long userId, String email); } diff --git a/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestServiceImpl.java b/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestServiceImpl.java index 839b87b..d8eb6c3 100644 --- a/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestServiceImpl.java +++ b/src/main/java/gdsc/cau/puangbe/photorequest/service/PhotoRequestServiceImpl.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import gdsc.cau.puangbe.common.annotation.ExeTimer; +import gdsc.cau.puangbe.common.annotation.Retry; import gdsc.cau.puangbe.common.enums.Gender; import gdsc.cau.puangbe.common.enums.RequestStatus; import gdsc.cau.puangbe.common.exception.BaseException; @@ -16,9 +18,14 @@ import gdsc.cau.puangbe.photorequest.dto.ImageInfo; import gdsc.cau.puangbe.user.entity.User; import gdsc.cau.puangbe.user.repository.UserRepository; +import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,6 +50,17 @@ public class PhotoRequestServiceImpl implements PhotoRequestService { @Transactional public Long createImage(CreateImageDto dto, Long userId){ User user = userRepository.findById(userId).orElseThrow(() -> new BaseException(ResponseCode.USER_NOT_FOUND)); + + // 가장 최신의 photo_request 조회 + PhotoRequest latestRequest = photoRequestRepository + .findTopByUserIdOrderByCreateDateDesc(userId) + .orElseThrow(() -> new RuntimeException("No photo requests found for userId: " + userId)); + + // 상태 체크 + if (latestRequest.getStatus() == RequestStatus.WAITING) { + return latestRequest.getId(); // 상태가 'waiting'이면 requestId 반환 + } + // PhotoRequest 생성 PhotoRequest request = PhotoRequest.builder() .user(user) @@ -79,10 +97,6 @@ public Long createImage(CreateImageDto dto, Long userId){ throw new PhotoRequestException(ResponseCode.JSON_PARSE_ERROR); } - // Redis에 userId 저장하고, userId로 requestId 추적할 수 있도록 함 - redisTemplate.opsForSet().add(ConstantUtil.USER_ID_KEY, userId); - redisTemplate.opsForSet().add(userId.toString(), request.getId()); - log.info("Redis 대기열 등록 완료: {}", userId); return request.getId(); } @@ -107,12 +121,6 @@ public List getRequestImages(Long userId){ public String getRequestStatus(Long userId){ validateUser(userId); - // Redis에 userId가 존재하면 아직 처리 대기 중인 요청이므로 WAITING 반환 - if(Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(ConstantUtil.USER_ID_KEY, userId))){ - log.info("사용자의 요청 상태 조회, 현재 대기 중: {}", userId); - return RequestStatus.WAITING.name(); - } - RequestStatus status = photoRequestRepository.findTopByUserIdOrderByCreateDateDesc(userId) .orElseThrow(() -> new BaseException(ResponseCode.PHOTO_REQUEST_NOT_FOUND)) .getStatus(); @@ -120,6 +128,23 @@ public String getRequestStatus(Long userId){ return status.name(); } + + @Override + @Transactional + public Long updateEmail(Long userId, String email) { + + // 가장 최근의 PhotoRequest 조회 + PhotoRequest photoRequest = photoRequestRepository.findTopByUserIdOrderByCreateDateDesc(userId) + .orElseThrow(() -> new BaseException(ResponseCode.PHOTO_REQUEST_NOT_FOUND)); + + photoRequest.modifyEmail(email); + photoRequestRepository.save(photoRequest); + + log.info("update email"); + + return photoRequest.getId(); + } + // 유저id 유효성 검사 private void validateUser(Long userId){ if(!userRepository.existsById(userId)){ diff --git a/src/test/java/gdsc/cau/puangbe/photorequest/service/PhotoConcurrencyTest.java b/src/test/java/gdsc/cau/puangbe/photorequest/service/PhotoConcurrencyTest.java new file mode 100644 index 0000000..fb83bec --- /dev/null +++ b/src/test/java/gdsc/cau/puangbe/photorequest/service/PhotoConcurrencyTest.java @@ -0,0 +1,67 @@ +package gdsc.cau.puangbe.photorequest.service; + +import gdsc.cau.puangbe.photo.service.PhotoServiceImpl; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +@SpringBootTest +public class PhotoConcurrencyTest { + + @Autowired + private PhotoServiceImpl photoService; + + @Autowired + private PhotoRequestServiceImpl photoRequestService; + + @Test + void 수정_조회가_동시에_발생하는_경우_테스트() throws InterruptedException { + + Long userId = 1000L; // 테스트할 사용자 ID + String email = "newUser998@example.com"; // 변경할 이메일 + + CountDownLatch latch = new CountDownLatch(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // uploadPhoto 호출 + executor.submit(() -> { + try { + photoService.uploadPhoto(998L, "http://example.com/image.jpg"); + successCount.incrementAndGet(); + } catch (Exception e) { + System.out.println(e.getMessage()); + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + // modifyEmail 호출 + executor.submit(() -> { + try { + Thread.sleep(100); + photoRequestService.updateEmail(userId, email); + successCount.incrementAndGet(); + } catch (Exception e) { + System.out.println(e.getMessage()); + failCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + latch.await(); // 두 개의 쓰레드가 완료될 때까지 대기 + executor.shutdown(); + + System.out.println("success count: " + successCount.get()); + System.out.println("fail count: " + failCount.get()); + } +}