Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[test] uploadPhoto, updateEmail 동시 호출 시 문제 확인 및 테스트 #46

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2138bf1
[test] 메서드 실행 시간 측정을 위한 ExeTimer 마킹용 어노테이션
RumosZin Sep 4, 2024
59dcf5b
[feat] email 수정 관련 도메인 메서드 추가
RumosZin Sep 4, 2024
e3d3def
[feat] email 수정 서비스 코드 작성 (인터페이스, 구현체)
RumosZin Sep 4, 2024
87696f4
[fix] redis 없이 db에 접근하여 사진 생성 요청의 진행상황 파악 후 새 요청 생성
RumosZin Sep 4, 2024
cb21978
[test] photo_request의 이메일 수정 / 이메일 전송이 동시에 일어나는 경우 테스트
RumosZin Sep 4, 2024
3cb902a
[test] 메소드 실행시간 측정 ExecutionTimer 코드 작성
RumosZin Sep 4, 2024
f8a4ae9
[refactor] uploadPhoto 작업 관련 주석 작성
RumosZin Sep 4, 2024
5fb6f3c
[fix] 클래스 이름 오타 수정, 이메일 변경
RumosZin Sep 4, 2024
206afa8
[fix] email 수정 메서드 transactional readonly 제거
RumosZin Sep 4, 2024
ddbef83
[fix] 불필요한 로그 삭제
RumosZin Sep 4, 2024
aa6d478
[test] 낙관적 락의 적용으로 충돌하는 트랜잭션에 대해 최초의 커밋만 인정하는 로직 구현
RumosZin Sep 5, 2024
d0cfdd4
[test] 낙관적 잠금 rollback 발생 시 재시도를 위한 Retry AOP 구현
RumosZin Sep 5, 2024
97b10ca
[test] uploadPhoto, updateEmail에 재시도 AOP 적용
RumosZin Sep 5, 2024
3c69103
[test] spring의 @Retryable으로 낙관적 락 메서드에 재시도 적용
RumosZin Sep 5, 2024
4210ea3
[test] photo_request 테이블에 비관적 락을 적용해 동시성 문제 해결하기
RumosZin Sep 5, 2024
1773de1
Merge branch 'main' into refactor/#45
RumosZin Sep 5, 2024
9851972
Merge branch 'main' into refactor/#45
RumosZin Sep 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/gdsc/cau/puangbe/PuangbeApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/annotation/Retry.java
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, "이미 존재하는 사용자입니다."),
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/gdsc/cau/puangbe/photo/entity/PhotoRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,8 +12,11 @@

@Repository
public interface PhotoRequestRepository extends JpaRepository<PhotoRequest, Long> {

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<PhotoRequest> findById(Long photoRequestId);

// 특정 유저의 최근에 만들어진 PhotoRequest 조회
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<PhotoRequest> findTopByUserIdOrderByCreateDateDesc(Long photoRequestId);
}
33 changes: 20 additions & 13 deletions src/main/java/gdsc/cau/puangbe/photo/service/PhotoServiceImpl.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -36,34 +43,34 @@ 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);

photoRequest.finishStatus();
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 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ public interface PhotoRequestService {

//최근 생성 요청한 이미지의 상태 조회
String getRequestStatus(Long userId);

// 이미지 처리 요청이 끝나지 않았을 경우 이메일 업데이트
Long updateEmail(Long userId, String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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)
Expand Down Expand Up @@ -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();
}

Expand All @@ -107,19 +121,30 @@ public List<String> 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();
log.info("사용자의 요청 상태 조회, 현재 상태: {} {}", status.name(), 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)){
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +22 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트명을 한글로 작성하셔도 되지만 @DisplayName 어노테이션으로 별도로 분리하시는 것도 좋습니다.


Long userId = 1000L; // 테스트할 사용자 ID
String email = "[email protected]"; // 변경할 이메일

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());
Comment on lines +64 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.out보다는 log를 활용하시는 게 더 적합할 것 같습니다.

}
}
Loading