package silsups.plussilsup;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class RaceConditionExample {
static int balance = 100; // 현재 100원이 있음. 10명이 1원씩 빼는 것을 10번 실행할 것임
public static void main(String[] args) throws InterruptedException {
//동시에 실행할 수 있는 환경을 만들어줌. 동시에 10명이 실행하도록 설정함
ExecutorService executor = Executors.newFixedThreadPool(10);
//총 100번 실행한다
for (int i = 0; i < 100; i++) {
executor.submit(() -> withdraw(1));
}
//현재 executor에 위의 코드들을 담아둔 것. 셧다운 하면 이제부터 실행해라 라는 의미임
executor.shutdown();
Thread.sleep(2000); // 모든 스레드가 끝날 때까지 잠시 대기
System.out.println("최종 잔액: " + balance);
}
static void withdraw(int amount) {
int current = balance; // 1. 잔액 조회
try {
Thread.sleep(10); // 2. 동시에 실행되도록 일시정지시켜서 다같이 기다리게 함
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
balance = current - amount; // 3. 잔액 갱신
}
}
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Account findByIdForLOCK(@Param("id") Long id);
}
// 테스트 코드
@SpringBootTest
class PessimisticLockTest {
@Autowired
AccountService accountService;
@Autowired
AccountRepository accountRepository;
@Test
void 동시에_두_트랜잭션_출금_락있는버전() {
// 초기 데이터 세팅
Account account = accountRepository.save(new Account(100));
ExecutorService executor = Executors.newFixedThreadPool(3);
Runnable task1 = () -> {
try {
accountService.withdrawLock(account.getId(), 10);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
}
};
Runnable task2 = () -> {
try {
accountService.withdrawLock(account.getId(), 10);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
}
};
Runnable task3 = () -> {
try {
accountService.withdrawLock(account.getId(), 10);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
}
};
// 동시에 실행
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.shutdown();
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
throw new RuntimeException();
}
// 결과 검증
Account result = accountRepository.findById(account.getId()).orElseThrow();
System.out.println("최종 잔액: " + result.getBalance());
}
@Test
void 동시에_두_트랜잭션_출금_테스트_락없음() throws InterruptedException {
// 초기 데이터 세팅
Account account = accountRepository.save(new Account(100));
//동시에 접근하는 환경을 만들어줘야 함
ExecutorService executor = Executors.newFixedThreadPool(3);
Runnable task1 = () -> {
try {
accountService.withdrawNoLock(account.getId(), 10);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
}
};
Runnable task2 = () -> {
try {
accountService.withdrawNoLock(account.getId(), 10);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
}
};
Runnable task3 = () -> {
try {
accountService.withdrawNoLock(account.getId(), 10);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + " 실패: " + e.getMessage());
}
};
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.shutdown();
//실무에서는 sleep 안씀 억지로 잡아두는 거라 성능이 떨어짐
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
Account result = accountRepository.findById(account.getId()).orElseThrow();
System.out.println("최종 잔액: " + result.getBalance());
}
}
// 엔티티에 아래 필드 추가
@Version
private Long version; // 낙관적 락 버전 컬럼
// service(재시도)
package org.example.nbcamchapter5.domain.account.service;
import lombok.RequiredArgsConstructor;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountCoreService coreService;
public void withdrawWithRetry(Long id, int amount) {
int retry = 0;
while (retry < 3) {
try {
coreService.withdraw(id, amount);
return;
} catch (ObjectOptimisticLockingFailureException e) {
retry++;
System.out.println("충돌 발생, 재시도: " + retry);
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {
}
}
}
//3번 다 실패하면
throw new IllegalStateException("3회 재시도 후 실패");
}
}
//coreservice(출금)
@Service
@RequiredArgsConstructor
public class AccountCoreService {
private final AccountRepository accountRepository;
@Transactional
public void withdraw(Long id, int amount) {
Account account = accountRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("계좌 없음"));
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 여기서 버전이 다를 경우 ObjectOptimisticLockingFailureException 예외를 발생시킴
account.decrease(amount);
}
}
//테스트 코드
@SpringBootTest
class OptimisticLockTest {
@Autowired
AccountRepository accountRepository;
@Autowired
AccountService accountService;
@Test
void 낙관적락_정상동작_테스트() throws InterruptedException {
Account account = accountRepository.save(new Account(100));
ExecutorService executor = Executors.newFixedThreadPool(2);
Runnable task = () -> accountService.withdrawWithRetry(account.getId(), 10);
executor.submit(task);
executor.submit(task);
executor.shutdown();
try {
Thread.sleep(2000);
} catch(InterruptedException e) {
throw new RuntimeException(e);
}
//executor.awaitTermination(30, TimeUnit.SECONDS);파일에는 이건데 강의에선 아님
Account result = accountRepository.findById(account.getId()).orElseThrow();
System.out.println("최종 잔액: " + result.getBalance());
}
}
여러 서버가 동시에 같은 작업을 하지 못하도록 문지기 하는 것
서버 a가 작업중이면 서버 b는 기다리거나 실패 응답을 받음
서버가 1대이면 낙관/비관적 락으로 가능하지만 서버가 여러 대가 되면 각 서버가 상태를 알 수 없으므로 각 서버에서 동일한 로직을 동시에 수행할 수 있음
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
@Transactional
@RedisLock(key = "lock:account")
public void withdraw(Long accountId, int amount) {
Account account = accountRepository.findById(accountId).orElseThrow();
account.decrease(amount);
System.out.println(Thread.currentThread().getName() + " → 출금 완료 (잔액: " + account.getBalance() + ")");
}
}
package org.example.nbcamchapter5.common.redis.service;
import java.time.Duration;
import java.util.Collections;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.nbcamchapter5.common.annotation.RedisLock;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisLockService {
//저번에는 다양한 타입 매칭해주는 애를 따로 만들었었음. RedisTemplate<String, String> 이거랑 같음
private final StringRedisTemplate redisTemplate;
//락 획득 여부를 조사하는 메서드
public boolean tryLock(String key, String value, long timeoutSeconds) {
//레디스에 키가 있는지 검사하고
// 락을 획득할 수 없으면. 키가 있으면 false(누군가 작업중)
// 락을 획득할 수 있으면, 키가 없으면 true lock:account:1 형태로 키를 생성함
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, value, Duration.ofSeconds(timeoutSeconds));
//result 가 true면 true를 리턴, false면 false를 리턴
return Boolean.TRUE.equals(result);
}
//락을 해제하는 메서드
public void unlock(String key, String value) {
//레디스에서 쓰는 문법을 그대로 가져온 것
//누가 만들었는지 담겨있는 value를 가지고 레디스에 value가 일치하는지 검사
// 일치한다면 키 삭제(del), 불일치한다면 0 리턴
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
value
);
log.info("획득한 락 키 반납 :::: {}", Thread.currentThread().getName());
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
String key(); // 계좌 별 Lock key
long timeout() default 5;
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisLockAspect {
private final RedisLockService lockService;
@Around("@annotation(redisLock)")
public Object run(
ProceedingJoinPoint joinPoint,
RedisLock redisLock
) throws Throwable {
String keyPreFix = redisLock.key();
//누가 키를 만들었는지 랜덤값 생성
String value = UUID.randomUUID().toString();
//RedisLock 어노테이션이 붙은 메서드의 첫번째 파라미터를 accountId로 둔다
Object[] args = joinPoint.getArgs();
Object accountId = args[0];
//key: lock:account:1(accountId) 이런식이 됨
String key = keyPreFix + accountId;
//어노테이션에서 기본 타임아웃 5초로 설정함
boolean locked = lockService.tryLock(key, value, redisLock.timeout());
//locked == true라면 키가 없고 작업 가능
//locked가 false라면 키가 있고 누가 이미 작업중인 것
//락을 획득하지 못했다면(누가 작업중이라면)
if (!locked) {
log.info("락획득 실패 : {}", Thread.currentThread().getName());
throw new IllegalStateException("이 계좌는 현재 처리 중입니다. 잠시 후 다시 시도해주세요.");
}
//락을 획득했다면
try {
//락을 획득했다면 accountService.withdraw 메서드를 실행
log.info("락획득 성공 : {}", Thread.currentThread().getName());
return joinPoint.proceed();
} finally {
//accountService.withdraw 메서드가 실행되었고 리턴까지 되었다면 락 반환
lockService.unlock(key, value);
}
}
}
@SpringBootTest
public class DistributedLockTest {
//빈 주입
@Autowired
AccountService accountService;
@Autowired
AccountRepository accountRepository;
@Test
void redis_분산락_테스트() throws InterruptedException {
Account account = accountRepository.save(new Account(100));
//두명
ExecutorService executor = Executors.newFixedThreadPool(2);
Runnable task = () -> {
try {
accountService.withdraw(account.getId(), 10);
} catch (Exception e) {
throw new RuntimeException(e);
//System.out.println("실패");
}
};
executor.submit(task);
executor.submit(task);
executor.shutdown();
Thread.sleep(1000);
Account result = accountRepository.findById(account.getId()).orElseThrow();
System.out.println("최종 결과 : " + result.getBalance());
}
}
int retry = 0;
while (retry < 5) {
boolean locked = lockService.tryLock(key, value, 5);
if (locked) break; // 락 획득 성공
Thread.sleep(100); // 100ms 기다림
retry++;
}
if (retry == 5) {
throw new IllegalStateException("잠시 후 다시 시도해주세요.");
}
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select e from Event e where e.id = :id")
Event findByIdForUpdate(Long id);
@Query(value = "select * from event where id = :id for update",
nativeQuery = true)
Event findByIdForUpdate(Long id);
entityManager.find(Event.class, id, LockModeType.PESSIMISTIC_WRITE);