Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a924bbe
Feat: 동시성 문제 발생을 위한 기본 API 개발. ProductController에 ServiceImpl 참 조하는 부…
Max-1012 Apr 22, 2026
3223ecb
Merge branch 'develop' into feat/EventOrder-Create
Max-1012 Apr 22, 2026
6ac56f4
Merge branch 'develop' into Test/EventOrder-WithoutLock
Max-1012 Apr 22, 2026
96a569a
Test: EventOrder Without Lock Test - ticket 10 Users 100
Max-1012 Apr 23, 2026
bfa5b4c
Refactor: 코드리뷰 반영하여 수정하였습니다.
Max-1012 Apr 23, 2026
eaad376
Merge branch 'develop' into feat/EventOrder-Create
Max-1012 Apr 23, 2026
ff79a9e
Merge branch 'feat/EventOrder-Create' into Test/EventOrder-WithoutLock
Max-1012 Apr 23, 2026
5eae5b2
Test : 락 없는 버전 테스트 완료 및 주석 추가
Max-1012 Apr 23, 2026
b40378b
Refactor : 테스트 코드 리팩토링.
Max-1012 Apr 23, 2026
749db35
Refactor : 동시성 테스트 코드 리팩토링
Max-1012 Apr 23, 2026
cf51bd8
Feat: Redis Lock cherry pick 버전 가져오기. 락 없는 버전, 레디스 락 테스트 완료
Max-1012 Apr 23, 2026
1fbd947
Test: Redis Retry Connection Pool 비교 테스트 완료
Max-1012 Apr 24, 2026
e97a6a1
Feat: Redis Lock을 AOP를 사용하여 구현하여 관심사 분리. 테스트 완료
Max-1012 Apr 24, 2026
0f9cd61
Feat: Redisson + AOP를 사용한 락 구현, 실제 주문이 들어왔을 때 호출은 아직 락 없는 버전을 호출하고 있습…
Max-1012 Apr 24, 2026
135645a
Merge develop & Conflict Resolve
Max-1012 Apr 24, 2026
79aca20
Chore : 주석 해제
Max-1012 Apr 24, 2026
bb073b9
chore: remove .DS_Store
Max-1012 Apr 24, 2026
1885455
Chore-로깅 루트 수정
Max-1012 Apr 24, 2026
27abb9e
chore: application-test.yml 파일 수정
Max-1012 Apr 24, 2026
d1d6a40
Remove secrets
Max-1012 Apr 24, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ out/
.vscode/

.env

.DS_Store
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ dependencies {

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.51.0'

// WebSocket + STOMP
implementation 'org.springframework.boot:spring-boot-starter-websocket'
Expand All @@ -73,3 +75,8 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}

// 파라미터명을 SpEL에서 쓰려면 Gradle 컴파일 옵션에 -parameters가 필요
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += ['-parameters']
}
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package jpa.basic.alldayprojectcommerce.application;

import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedisLock;
import jpa.basic.alldayprojectcommerce.common.lock.annotation.RedissonLock;
import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy;
import jpa.basic.alldayprojectcommerce.common.lock.service.RedisLockService;
import jpa.basic.alldayprojectcommerce.domain.order.dto.response.EventOrderResponse;
import jpa.basic.alldayprojectcommerce.domain.order.service.EventOrderService;
import jpa.basic.alldayprojectcommerce.domain.order.service.event.EventOrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Expand All @@ -10,9 +14,190 @@
public class EventOrderFacade {

private final EventOrderService eventOrderService;
private final RedisLockService redisLockService;


/**
* 락을 사용하지 않은 버전
* @param productId 제품 ID
* @param userId 주문자 ID
* @return
*/
public EventOrderResponse createEventOrderWithoutLock(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}

/**
* Redis 분산락 - Fail Fast 전략 적용 버전
*
* 상품 기준으로 락을 걸어
* 동일 상품에 대한 동시 주문을 직렬화한다.
*
* @param productId 제품 ID
* @param userId 주문자 ID
*/
public EventOrderResponse createEventOrderWithRedisLockFailFast(Long productId, Long userId) {

// 🔥 락 키 설계 (핵심)
String key = "lock:product:" + productId;

// TTL (초)
long timeoutSeconds = 5;

return redisLockService.executeWithLockFailFast(
key,
timeoutSeconds,
() -> eventOrderService.createEventOrder(productId, userId)
);
}

/**
* Redis 분산락 - Retry 전략 적용 버전
*
* 상품 기준으로 락을 걸어
* 동일 상품에 대한 동시 주문을 직렬화한다.
*
* @param productId 제품 ID
* @param userId 주문자 ID
*/
public EventOrderResponse createEventOrderWithRedisLockRetry(Long productId, Long userId) {

String key = "lock:product:" + productId;

return redisLockService.executeWithLockRetry(
key,
5,
() -> eventOrderService.createEventOrder(productId, userId)
);
}

/**
* Redis 분산락 - Blocking 전략 적용 버전
*
* 상품 기준으로 락을 걸어
* 동일 상품에 대한 동시 주문을 직렬화한다.
*
* @param productId 제품 ID
* @param userId 주문자 ID
*/
public EventOrderResponse createEventOrderWithRedisLockBlocking(Long productId, Long userId) {

String key = "lock:product:" + productId;

return redisLockService.executeWithLockBlocking(
key,
5,
() -> eventOrderService.createEventOrder(productId, userId)
);
}

/**
* Redis 분산락 - AOP FailFast 전략 적용 버전
*/
@RedisLock(
key = "'lock:product:' + #productId",
timeoutSeconds = 5,
strategy = RedisLockStrategy.FAIL_FAST
)
public EventOrderResponse createEventOrderWithRedisLockAopFailFast(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}

/**
* Redis 분산락 - AOP Retry 전략 적용 버전
*/
@RedisLock(
key = "'lock:product:' + #productId",
timeoutSeconds = 5,
strategy = RedisLockStrategy.RETRY
)
public EventOrderResponse createEventOrderWithRedisLockAopRetry(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}

/**
* Redis 분산락 - AOP Blocking 전략 적용 버전
*/
@RedisLock(
key = "'lock:product:' + #productId",
timeoutSeconds = 5,
strategy = RedisLockStrategy.BLOCKING
)
public EventOrderResponse createEventOrderWithRedisLockAopBlocking(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}

/**
* Redisson 분산락 - AOP 적용 버전
*
* waitTimeSeconds:
* - 락 획득을 최대 몇 초까지 기다릴지.
* 짧으면 Retry 전략, 길면 Blocking 전략
*
* leaseTimeSeconds:
* - 락을 몇 초 동안 유지할지
* - 이 시간이 지나면 자동으로 락이 해제됨
*
* Retry + TTL
*/
@RedissonLock(
key = "'lock:product:' + #productId",
waitTimeSeconds = 2,
leaseTimeSeconds = 5
)
public EventOrderResponse createEventOrderWithRedissonLockAopRetry(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}

/**
* Redisson 분산락 - AOP 적용 버전
* Blocking + TTL
*/
@RedissonLock(
key = "'lock:product:' + #productId",
waitTimeSeconds = 10,
leaseTimeSeconds = 10
)
public EventOrderResponse createEventOrderWithRedissonLockAopBlocking(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}



/**
* Redisson 분산락 - Watchdog 적용 버전
*
* leaseTimeSeconds = -1
* - Redisson Watchdog 사용
* - 작업이 끝나기 전까지 락 TTL을 자동 연장
*
* Retry + Watchdog
*/
@RedissonLock(
key = "'lock:product:' + #productId",
waitTimeSeconds = 2,
leaseTimeSeconds = -1
)
public EventOrderResponse createEventOrderWithRedissonLockAopRetryWatchdog(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}

/**
* Redisson 분산락 - Watchdog 적용 버전
*
* leaseTimeSeconds = -1
* - Redisson Watchdog 사용
* - 작업이 끝나기 전까지 락 TTL을 자동 연장
*
* Blocking + Watchdog
*/
@RedissonLock(
key = "'lock:product:' + #productId",
waitTimeSeconds = 10,
leaseTimeSeconds = -1
)
public EventOrderResponse createEventOrderWithRedissonLockAopBlockingWatchdog(Long productId, Long userId) {
return eventOrderService.createEventOrder(productId, userId);
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package jpa.basic.alldayprojectcommerce.common.config;

import jpa.basic.alldayprojectcommerce.domain.user.entity.User;
import jpa.basic.alldayprojectcommerce.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
@RequiredArgsConstructor
public class DummyDataUser implements CommandLineRunner {

private final UserRepository userRepository;

@Override
public void run(String... args) {

// 이미 유저 데이터가 있으면 중복 생성하지 않음
if (userRepository.count() > 0) {
return;
}

// 10,000명 더미 유저 생성
List<User> users = new ArrayList<>();

for (int i = 1; i <= 10000; i++) {
// 이메일과 비밀번호를 먼저 세팅하여 User 엔티티 생성
User user = User.createUser(
"user" + i + "@test.com",
"encoded-password-" + i
);

// 주문 가능 조건에 필요한 프로필 정보 세팅
user.updateProfile(
"유저" + i,
"010-1234-" + String.format("%04d", i % 10000),
"서울시 강남구 테스트로 " + i
);

users.add(user);

// 1000명 단위로 배치 저장하여 메모리 사용량 절약
if (i % 1000 == 0) {
userRepository.saveAll(users);
users.clear();
}
}

// 혹시 남은 데이터가 있으면 마지막 저장
if (!users.isEmpty()) {
userRepository.saveAll(users);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package jpa.basic.alldayprojectcommerce.common.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

/**
* RedissonClient = Redis 분산락 핵심 객체
*
* - Lettuce에서는 StringRedisTemplate으로 직접 SET NX 호출
* - Redisson에서는 RedissonClient 하나로 Lock, Queue, Semaphore 등 다 처리함
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient(RedisProperties redisProperties) {
Config config = new Config();
/**
* Redis 주소 설정
*
* - Spring Redis 설정(application.yml)에 있는 host, port 가져옴
* - redis:// prefix 꼭 필요
*/
String address = "redis://" + redisProperties.getHost() + ":" + redisProperties.getPort();

config.useSingleServer()
.setAddress(address);

/**
* RedissonClient 생성
*
* - 내부적으로 Netty 기반으로 Redis와 통신
* - RLock 등 분산락 기능 제공
*/
return Redisson.create(config);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,20 @@ public enum ErrorCode {
PAYMENT_ORDER_NOT_MATCHES(HttpStatus.BAD_REQUEST, "PAY004", "입력한 주문에 대하여 생성된 결제 건이 아닙니다."),
PAYMENT_INVALID_UID(HttpStatus.BAD_REQUEST, "PAY005", "유효하지 않은 결제 UID 입니다."),

// 채팅 도메인(CH001)
// 채팅 도메인(CH###)
CHAT_INVALID_STATUS_TRANSITION(HttpStatus.BAD_REQUEST, "CH001", "유효하지 않은 상태 전이입니다."),
CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "CH002", "채팅방을 찾을 수 없습니다."),
CHAT_ROOM_FORBIDDEN(HttpStatus.FORBIDDEN, "CH003", "채팅방에 접근할 권한이 없습니다."),
CHAT_ROOM_ALREADY_EXISTS(HttpStatus.CONFLICT, "CH004", "이미 진행 중인 상담이 있습니다."),
CHAT_ROOM_CLOSED(HttpStatus.BAD_REQUEST, "CH005", "종료된 상담방입니다."),
CHAT_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "CH006", "채팅 인증에 실패했습니다."),
CHAT_MESSAGE_EMPTY(HttpStatus.BAD_REQUEST, "CH007", "메시지 내용은 비어있을 수 없습니다."),
CHAT_MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "CH008", "메시지는 1000자를 초과할 수 없습니다.");
CHAT_MESSAGE_TOO_LONG(HttpStatus.BAD_REQUEST, "CH008", "메시지는 1000자를 초과할 수 없습니다."),

// Lock 관련(L###)
LOCK_ACQUISITION_FAILED(HttpStatus.CONFLICT,"L001","락 획득에 실패했습니다.")

;



Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package jpa.basic.alldayprojectcommerce.common.lock.annotation;

import jpa.basic.alldayprojectcommerce.common.lock.enums.RedisLockStrategy;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedisLock {

/**
* SpEL 기반 락 키
* 예: "'lock:product:' + #productId"
*/
String key();

/**
* Redis 락 TTL
*/
long timeoutSeconds() default 5;

/**
* 락 획득 전략
*/
RedisLockStrategy strategy() default RedisLockStrategy.RETRY;
}
Loading
Loading