Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ packy-support/src/main/resources/*.yml

### QueryDsl QClass ###
packy-domain/src/main/generated

### p6spy ###
spy.log
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public GiftBoxIdResponse createGiftBox(GiftBoxRequest giftBoxRequest) {
giftBox.getBox().getKakaoMessageImgUrl());
}

@RedissonLock(value = "#giftBoxId")
public GiftBoxResponse openGiftBox(Long giftBoxId) {
Member member = memberService.getMember();
GiftBox giftBox = giftBoxReader.findById(giftBoxId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.dilly.gift.application;

import static com.dilly.GiftBoxFixture.sendGiftBoxFixtureWithGift;
import static com.dilly.LetterFixture.createLetterFixture;
import static com.dilly.MemberEnumFixture.NORMAL_MEMBER_RECEIVER;
import static com.dilly.MemberEnumFixture.NORMAL_MEMBER_SENDER;
import static org.assertj.core.api.Assertions.assertThat;

import com.dilly.gift.adaptor.GiftBoxWriter;
import com.dilly.gift.adaptor.LetterWriter;
import com.dilly.gift.adaptor.ReceiverReader;
import com.dilly.gift.domain.giftbox.GiftBox;
import com.dilly.gift.domain.letter.Letter;
import com.dilly.gift.domain.receiver.Receiver;
import com.dilly.global.WithCustomMockUserSecurityContextFactory;
import com.dilly.member.adaptor.MemberReader;
import com.dilly.member.adaptor.MemberWriter;
import com.dilly.member.domain.Member;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.TestPropertySource;

@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "spring.profiles.active=test"
)
@TestPropertySource(locations = {"classpath:application-test.yml"})
class GiftBoxConcurrencyTest {

@Autowired
private GiftBoxService giftBoxService;

@Autowired
private MemberReader memberReader;

@Autowired
private MemberWriter memberWriter;

@Autowired
private GiftBoxWriter giftBoxWriter;

@Autowired
private LetterWriter letterWriter;

@Autowired
private ReceiverReader receiverReader;

@Autowired
private WithCustomMockUserSecurityContextFactory withCustomMockUserSecurityContextFactory;

private Member MEMBER_SENDER;

private final String SENDER_ID = "1";

private Letter letter;

@BeforeEach
void setUp() {
Long senderId = Long.parseLong(SENDER_ID);

MEMBER_SENDER = memberWriter.save(NORMAL_MEMBER_SENDER.createMember(senderId));

letter = letterWriter.save(createLetterFixture());
}

@DisplayName("선물박스 열기 동시성 테스트")
@Test
void multipleUserOpenGiftBox() throws InterruptedException {
// given
int memberCount = 10;
int giftBoxAmount = 1;

List<Member> receivers = new ArrayList<>();
Long lastMemberId = memberReader.count();
for (long i = lastMemberId + 1; i <= lastMemberId + memberCount; i++) {
Member member = memberWriter.save(NORMAL_MEMBER_RECEIVER.createMember());
receivers.add(member);
}

GiftBox giftBox = giftBoxWriter.save(
sendGiftBoxFixtureWithGift(MEMBER_SENDER, letter));

ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch countDownLatch = new CountDownLatch(memberCount);

AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();

Long receiverBefore = receiverReader.countByGiftBox(giftBox);

// when
for (int i = 0; i < memberCount; i++) {
Member member = receivers.get(i);
Long memberId = member.getId();
executorService.submit(() -> {
try {
createSecurityContextWithMockUser(memberId.toString());
giftBoxService.openGiftBox(giftBox.getId());
successCount.incrementAndGet();
} catch (Exception e) {
e.printStackTrace();
failCount.incrementAndGet();
} finally {
countDownLatch.countDown();
}
});
}

countDownLatch.await();

Long receiverAfter = receiverReader.countByGiftBox(giftBox);
List<Receiver> receiverList = receiverReader.findByGiftBox(giftBox);

System.out.println("successCount = " + successCount);
System.out.println("failCount = " + failCount);

System.out.println("receiverBefore = " + receiverBefore);
System.out.println("receiverAfter = " + receiverAfter);

System.out.println("receiver 목록 출력");
for (Receiver receiver : receiverList) {
System.out.println("memberId = " + receiver.getMember().getId());
}

// then
assertThat(successCount.get()).isEqualTo(giftBoxAmount);
assertThat(receiverAfter).isEqualTo(giftBoxAmount);
}

private void createSecurityContextWithMockUser(String memberId) {
SecurityContext securityContext = withCustomMockUserSecurityContextFactory.createSecurityContext(
memberId);
SecurityContextHolder.setContext(securityContext);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DynamicTest;
Expand Down Expand Up @@ -91,11 +90,6 @@ void setUp() {
letter = letterWriter.save(createLetterFixture());
}

@AfterEach
void tearDown() {
memberWriter.deleteAll();
}

@Nested
@DisplayName("선물박스를 만들 때")
@WithCustomMockUser(id = SENDER_ID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
properties = "spring.profiles.active=test"
)
@TestPropertySource(locations = {"classpath:application-test.yml"})
@TestPropertySource(properties = "ableRedissonLock=false")
@Transactional
public abstract class IntegrationTestSupport {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.dilly.global.IntegrationTestSupport;
import com.dilly.global.WithCustomMockUser;
import com.dilly.member.domain.Member;
import com.dilly.member.domain.ProfileImage;
import com.dilly.member.dto.request.ProfileRequest;
import com.dilly.member.dto.response.ProfileResponse;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -76,16 +77,21 @@ void updateNickname() {
@WithCustomMockUser
void updateProfileImage() {
// given
Long memberId = SecurityUtil.getMemberId();
Member member = memberReader.findById(memberId);

ProfileRequest profileRequest = ProfileRequest.builder()
.profileImg(2L)
.build();

ProfileImage profileImage = profileImageReader.findById(profileRequest.profileImg());

// when
ProfileResponse response = myPageService.updateProfile(profileRequest);

// then
assertThat(response.nickname()).isEqualTo("1번유저");
assertThat(response.imgUrl()).isEqualTo("www.example2.com");
assertThat(response.nickname()).isEqualTo(member.getNickname());
assertThat(response.imgUrl()).isEqualTo(profileImage.getImgUrl());
}
}
}
5 changes: 5 additions & 0 deletions packy-api/src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ spring:
username: sa
password:

data:
redis:
host: localhost
port: 6379

jpa:
hibernate:
ddl-auto: create-drop
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.dilly.exception;

public class ConcurrencyFailedException extends BusinessException {

public ConcurrencyFailedException() {
super(ErrorCode.FAILED_TO_ACCESS_CONCURRENCY);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public enum ErrorCode {
API_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 API를 찾을 수 없습니다."),
QUERY_PARAMETER_REQUIRED(HttpStatus.BAD_REQUEST, "쿼리 파라미터가 필요한 API입니다."),
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."),
FAILED_TO_ACCESS_CONCURRENCY(HttpStatus.INTERNAL_SERVER_ERROR, "동시 접근에 실패했습니다."),

// Kakao
KAKAO_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 서버 연동에 오류가 발생했습니다."),
Expand Down
8 changes: 7 additions & 1 deletion packy-domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ dependencies {
// orm
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

// database
// mysql
runtimeOnly 'com.mysql:mysql-connector-j'

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.34.1'

// logging
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
Expand All @@ -40,6 +43,9 @@ dependencies {

// tsid
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.7.6'

// aop
implementation 'org.springframework.boot:spring-boot-starter-aop'
}

test {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dilly.global.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class AopForTransaction {

@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}

}
19 changes: 19 additions & 0 deletions packy-domain/src/main/java/com/dilly/global/aop/RedissonLock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.dilly.global.aop;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {

String value(); // 락의 이름
TimeUnit timeUnit() default TimeUnit.MILLISECONDS; // 시간 단위
long waitTime() default 5_000L; // 락 획득을 위해 waitTime만큼 대기
long leaseTime() default 5_000L; // 락 획득 후 leaseTime만큼 락 유지
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.dilly.global.aop;

import com.dilly.exception.ConcurrencyFailedException;
import com.dilly.global.util.CustomSpringELParser;
import java.lang.reflect.Method;
import lombok.RequiredArgsConstructor;
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.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
@ConditionalOnExpression("${ableRedissonLock:true}")
public class RedissonLockAspect {

@Value("${spring.profiles.active}")
private String profilePrefix;

private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;

@Around("@annotation(com.dilly.global.aop.RedissonLock)")
public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RedissonLock annotation = method.getAnnotation(RedissonLock.class);

String lockKey =
profilePrefix + ":" + method.getName() + CustomSpringELParser.getDynamicValue(
signature.getParameterNames(), joinPoint.getArgs(), annotation.value());

RLock lock = redissonClient.getLock(lockKey);

try {
boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(),
annotation.timeUnit());
if (!lockable) {
log.info("Lock 획득 실패: {}", lockKey);
throw new ConcurrencyFailedException();
}

log.info("Lock 획득 성공: {}", lockKey);
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
log.info("에러 발생");
throw e;
} finally {
if (lock != null && lock.isHeldByCurrentThread()) {
log.info("Lock 해제");
lock.unlock();
}
}
}
}
Loading