Skip to content

Commit 0aefd5d

Browse files
authored
feat: 이벤트 신청, 수정 시 락 구현 (#1239)
* feat: 락 유틸 인터페이스, 구현체 생성 * feat: AOP 추가 * feat: 이벤트 수정, 참여 신청 시 락 어노테이션 추가 * test: 테스트용 인메모리 락 구현체 생성 * refactor: TODO 추가 * refactor: 로그 변경, 미사용 어노테이션 제거 * refactor: ReetrantLock 사용하도록 인메모리 락 구현 변경
1 parent 6b78702 commit 0aefd5d

File tree

10 files changed

+259
-1
lines changed

10 files changed

+259
-1
lines changed

src/main/java/com/gdschongik/gdsc/domain/event/application/EventParticipationService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import com.gdschongik.gdsc.domain.member.domain.Member;
3030
import com.gdschongik.gdsc.global.exception.CustomException;
3131
import com.gdschongik.gdsc.global.exception.ErrorCode;
32+
import com.gdschongik.gdsc.global.lock.DistributedLock;
3233
import java.util.List;
3334
import java.util.function.Predicate;
3435
import lombok.RequiredArgsConstructor;
@@ -236,6 +237,8 @@ public void applyManualForUnregistered(EventUnregisteredManualApplyRequest reque
236237
request.participant());
237238
}
238239

240+
// TODO: 현재 본행사, 뒤풀이 신청 제한 인원 이하인지 검증 추가
241+
@DistributedLock(key = "'event:' + #request.eventId()")
239242
@Transactional
240243
public void applyManual(EventManualApplyRequest request) {
241244
Event event =
@@ -311,6 +314,8 @@ private void revokeAfterPartyStatusByAfterPartyUpdateTarget(
311314
}
312315
}
313316

317+
// TODO: 현재 본행사, 뒤풀이 신청 제한 인원 이하인지 검증 추가
318+
@DistributedLock(key = "'event:' + #request.eventId()")
314319
@Transactional
315320
public void applyOnline(EventApplyOnlineRequest request) {
316321
Event event =

src/main/java/com/gdschongik/gdsc/domain/event/application/EventService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.gdschongik.gdsc.domain.event.dto.request.EventUpdateFormInfoRequest;
1313
import com.gdschongik.gdsc.domain.event.dto.response.EventResponse;
1414
import com.gdschongik.gdsc.global.exception.CustomException;
15+
import com.gdschongik.gdsc.global.lock.DistributedLock;
1516
import java.util.List;
1617
import lombok.RequiredArgsConstructor;
1718
import lombok.extern.slf4j.Slf4j;
@@ -62,6 +63,7 @@ public List<EventDto> searchEvent(String name) {
6263
return events.stream().map(EventDto::from).toList();
6364
}
6465

66+
@DistributedLock(key = "'event:' + #eventId")
6567
@Transactional
6668
public void updateEventBasicInfo(Long eventId, EventUpdateBasicInfoRequest request) {
6769
Event event = eventRepository.findById(eventId).orElseThrow(() -> new CustomException(EVENT_NOT_FOUND));

src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ public enum ErrorCode {
224224
AFTER_PARTY_PREPAYMENT_STATUS_NOT_UPDATABLE_ALREADY_UPDATED(CONFLICT, "뒤풀이 선입금 상태가 이미 요청 상태로 수정되어있습니다."),
225225
AFTER_PARTY_POSTPAYMENT_STATUS_NOT_UPDATABLE_NONE(CONFLICT, "결제 상태가 None 일 때는 뒤풀이 정산 상태를 수정할 수 없습니다."),
226226
AFTER_PARTY_POSTPAYMENT_STATUS_NOT_UPDATABLE_ALREADY_UPDATED(CONFLICT, "뒤풀이 정산 상태가 이미 요청 상태로 수정되어있습니다."),
227+
228+
// Lock
229+
LOCK_ACQUIRE_FAILED(INTERNAL_SERVER_ERROR, "락 획득에 실패했습니다. 다시 시도해주세요."),
227230
;
228231

229232
private final HttpStatus status;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.gdschongik.gdsc.global.lock;
2+
3+
import java.lang.annotation.ElementType;
4+
import java.lang.annotation.Retention;
5+
import java.lang.annotation.RetentionPolicy;
6+
import java.lang.annotation.Target;
7+
8+
@Target(ElementType.METHOD)
9+
@Retention(RetentionPolicy.RUNTIME)
10+
public @interface DistributedLock {
11+
12+
/**
13+
* 락 식별자 (SpEL 사용 가능)
14+
*/
15+
String key();
16+
17+
/**
18+
* 락 획득 대기 시간 (초)
19+
*/
20+
int timeoutSec() default 5;
21+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.gdschongik.gdsc.global.lock;
2+
3+
import static com.gdschongik.gdsc.global.exception.ErrorCode.*;
4+
5+
import com.gdschongik.gdsc.global.exception.CustomException;
6+
import jakarta.validation.constraints.NotNull;
7+
import java.lang.reflect.Method;
8+
import lombok.RequiredArgsConstructor;
9+
import org.aspectj.lang.ProceedingJoinPoint;
10+
import org.aspectj.lang.annotation.Around;
11+
import org.aspectj.lang.annotation.Aspect;
12+
import org.aspectj.lang.reflect.MethodSignature;
13+
import org.springframework.context.expression.MethodBasedEvaluationContext;
14+
import org.springframework.core.DefaultParameterNameDiscoverer;
15+
import org.springframework.core.ParameterNameDiscoverer;
16+
import org.springframework.core.annotation.Order;
17+
import org.springframework.expression.ExpressionParser;
18+
import org.springframework.expression.spel.standard.SpelExpressionParser;
19+
import org.springframework.stereotype.Component;
20+
21+
@Order(1) // 트랜잭션 AOP보다 먼저 실행되어야 합니다.
22+
@Aspect
23+
@Component
24+
@RequiredArgsConstructor
25+
public class LockAspect {
26+
27+
private final LockUtil lockUtil;
28+
private final ExpressionParser parser = new SpelExpressionParser();
29+
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
30+
31+
@Around("@annotation(com.gdschongik.gdsc.global.lock.DistributedLock)")
32+
public Object around(@NotNull ProceedingJoinPoint joinPoint) throws Throwable {
33+
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
34+
Method method = signature.getMethod();
35+
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
36+
37+
String key = parseLockKey(distributedLock, method, joinPoint.getArgs(), joinPoint.getTarget());
38+
boolean lockAcquired = false;
39+
40+
try {
41+
lockAcquired = lockUtil.acquireLock(key, distributedLock.timeoutSec());
42+
43+
if (!lockAcquired) {
44+
throw new CustomException(LOCK_ACQUIRE_FAILED);
45+
}
46+
47+
return joinPoint.proceed();
48+
} finally {
49+
if (lockAcquired) {
50+
lockUtil.releaseLock(key);
51+
}
52+
}
53+
}
54+
55+
/**
56+
* SpEL을 사용하여 키를 파싱합니다.
57+
*/
58+
private String parseLockKey(DistributedLock distributedLock, Method method, Object[] args, Object target) {
59+
String key = distributedLock.key();
60+
61+
// 어노테이션에 락 이름이 비어있으면 클래스명:메서드명으로 기본 값 생성
62+
if (key.isEmpty()) {
63+
return String.format("%s:%s", target.getClass().getSimpleName(), method.getName());
64+
}
65+
66+
// SpEL 표현식 평가
67+
MethodBasedEvaluationContext context =
68+
new MethodBasedEvaluationContext(target, method, args, parameterNameDiscoverer);
69+
70+
return parser.parseExpression(key).getValue(context, String.class);
71+
}
72+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.gdschongik.gdsc.global.lock;
2+
3+
public interface LockUtil {
4+
5+
/**
6+
* 락을 획득합니다.
7+
* @param key 락 식별자
8+
* @param timeoutSec 최대 대기 시간(초)
9+
* @return 락 획득 성공 여부
10+
*/
11+
boolean acquireLock(String key, long timeoutSec);
12+
13+
/**
14+
* 락을 해제합니다.
15+
* @param key 락 식별자
16+
* @return 락 해제 성공 여부
17+
*/
18+
boolean releaseLock(String key);
19+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.gdschongik.gdsc.global.lock;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.jdbc.core.JdbcTemplate;
6+
import org.springframework.stereotype.Component;
7+
8+
/**
9+
* MySQL 네임드 락 기능을 이용해 락을 제어하는 LockUtil 구현체입니다.
10+
*/
11+
@Slf4j
12+
@Component
13+
@RequiredArgsConstructor
14+
public class MySqlLockUtil implements LockUtil {
15+
16+
private final JdbcTemplate jdbcTemplate;
17+
18+
private static final String GET_LOCK_QUERY = "SELECT GET_LOCK(?, ?)";
19+
private static final String RELEASE_LOCK_QUERY = "SELECT RELEASE_LOCK(?)";
20+
21+
/**
22+
* MySQL 네임드 락을 획득합니다.
23+
* @param lockName 락 이름
24+
* @param timeoutSec 최대 대기 시간(초)
25+
* @return 락 획득 성공 여부
26+
*/
27+
public boolean acquireLock(String lockName, long timeoutSec) {
28+
Integer result = jdbcTemplate.queryForObject(GET_LOCK_QUERY, Integer.class, lockName, timeoutSec);
29+
boolean acquired = result != null && result == 1; // GET_LOCK 결과: 1(성공), 0(타임아웃), null(에러)
30+
31+
if (acquired) {
32+
log.info("[MySqlLockUtil] 락 획득 성공: {}", lockName);
33+
} else {
34+
log.info("[MySqlLockUtil] 락 획득 실패: {}", lockName);
35+
}
36+
37+
return acquired;
38+
}
39+
40+
/**
41+
* MySQL 네임드 락을 해제합니다.
42+
* @param lockName 락 이름
43+
* @return 락 해제 성공 여부
44+
*/
45+
public boolean releaseLock(String lockName) {
46+
Integer result = jdbcTemplate.queryForObject(RELEASE_LOCK_QUERY, Integer.class, lockName);
47+
boolean released = result != null && result == 1; // RELEASE_LOCK 결과: 1(성공), 0(타임아웃), null(에러)
48+
49+
if (released) {
50+
log.info("[MySqlLockUtil] 락 해제 성공: {}", lockName);
51+
} else {
52+
log.info("[MySqlLockUtil] 락 해제 실패: {}", lockName);
53+
}
54+
55+
return released;
56+
}
57+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.gdschongik.gdsc.config;
2+
3+
import com.gdschongik.gdsc.global.lock.LockUtil;
4+
import com.gdschongik.gdsc.helper.InmemoryLockUtil;
5+
import org.springframework.boot.test.context.TestConfiguration;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Primary;
8+
9+
@TestConfiguration
10+
public class TestLockConfig {
11+
12+
@Primary
13+
@Bean
14+
public LockUtil inmemoryLockUtil() {
15+
return new InmemoryLockUtil();
16+
}
17+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.gdschongik.gdsc.helper;
2+
3+
import com.gdschongik.gdsc.global.lock.LockUtil;
4+
import java.util.Map;
5+
import java.util.concurrent.ConcurrentHashMap;
6+
import java.util.concurrent.TimeUnit;
7+
import java.util.concurrent.locks.ReentrantLock;
8+
import lombok.extern.slf4j.Slf4j;
9+
10+
/**
11+
* 테스트를 위한 인메모리 락 유틸 구현체입니다.
12+
*/
13+
@Slf4j
14+
public class InmemoryLockUtil implements LockUtil {
15+
16+
private final Map<String, ReentrantLock> locks = new ConcurrentHashMap<>();
17+
18+
/**
19+
* 인메모리 락을 획득합니다.
20+
*/
21+
public boolean acquireLock(String key, long timeoutSec) {
22+
ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
23+
24+
try {
25+
boolean acquired = lock.tryLock(timeoutSec, TimeUnit.SECONDS);
26+
27+
if (acquired) {
28+
log.info("[InMemoryLockUtil] 락 획득: {}", key);
29+
} else {
30+
log.info("[InMemoryLockUtil] 락 획득 실패: {}", key);
31+
}
32+
33+
return acquired;
34+
} catch (InterruptedException e) {
35+
Thread.currentThread().interrupt();
36+
log.error("[InMemoryLockUtil] 락 획득 중 인터럽트 발생: {}", key, e);
37+
return false;
38+
}
39+
}
40+
41+
/**
42+
* 인메모리 락을 해제합니다.
43+
*/
44+
public boolean releaseLock(String lockName) {
45+
ReentrantLock lock = locks.get(lockName);
46+
47+
if (lock == null) {
48+
log.info("[InMemoryLockUtil] 락 해제 실패, 존재하지 않는 락: {}", lockName);
49+
return false;
50+
}
51+
52+
try {
53+
lock.unlock();
54+
log.info("[InMemoryLockUtil] 락 해제: {}", lockName);
55+
return true;
56+
} catch (IllegalMonitorStateException e) {
57+
log.error("[InMemoryLockUtil] 락 해제 실패, 현재 스레드가 락을 소유하고 있지 않음: {}", lockName, e);
58+
return false;
59+
}
60+
}
61+
}

src/test/java/com/gdschongik/gdsc/helper/IntegrationTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import static com.gdschongik.gdsc.global.common.constant.TemporalConstant.*;
1010
import static org.mockito.Mockito.*;
1111

12+
import com.gdschongik.gdsc.config.TestLockConfig;
1213
import com.gdschongik.gdsc.config.TestSyncExecutorConfig;
1314
import com.gdschongik.gdsc.domain.common.model.SemesterType;
1415
import com.gdschongik.gdsc.domain.common.vo.Money;
@@ -66,7 +67,7 @@
6667
import org.springframework.security.core.context.SecurityContextHolder;
6768
import org.springframework.test.context.ActiveProfiles;
6869

69-
@Import(TestSyncExecutorConfig.class)
70+
@Import({TestSyncExecutorConfig.class, TestLockConfig.class})
7071
@SpringBootTest
7172
@ActiveProfiles("test")
7273
public abstract class IntegrationTest {

0 commit comments

Comments
 (0)