여러 프로세스나 스레드가 동시에 하나의 자원에 접근하는 상황에서는 데이터의 무결성을 지키기 위한 동시성 제어가 필요합니다.
예를 들어, 하나의 자원(재고, 티켓 등)에 여러 사용자가 동시에 접근하는 상황에서는 데이터의 정합성을 유지하기 위한 동시성 제어가 필수적입니다.
우리는 서비스의 가용성(장애 없이 안정적으로 서비스를 제공하는 능력)을 높이기 위해 서버를 여러 대 운영합니다.
이러한 환경에서는 서버를 Stateless하게 구성하는 것이 일반적이며,
이에 따라 Stateless 아키텍처에 적합한 동시성 제어 방식을 설계할 수 있어야 합니다.
📌 다양한 서버 Stateless
HTTP stateless
HTTP 요청 간에 상태를 공유하지 않기 때문에, 각 요청은 독립적으로 처리.
JWT stateless
로그인 상태를 서버가 기억하는 것이 아니라, 클라이언트가 JWT를 들고 다니면서 인증을 처리
Server stateless
서버 인스턴스가 상태를 가지지 않기 때문에, 수평 확장(Scaling Out)에 유리하고 가용성 향상
1. 동시성 제어 방법
*️⃣ Java 언어의 기능 활용 - 사용하지 않음
Java에서는 `synchronized`나 `ReentrantLock` 등을 이용한 락 제어가 가능합니다.
하지만 이는 단일 JVM 내의 스레드 간 동기화에만 유효하기 때문에, 여러 대의 서버가 Stateless하게 운영되는 환경에서는 사용할 수 없습니다.
➡️ 한 서버에서 획득한 락 상태를 다른 서버에서는 알 수 없기 때문에, Java 언어 수준의 락은 분산 환경에서의 동시성 제어 수단으로는 적절하지 않습니다.
1️⃣ 트랜잭션 격리 레벨 활용
데이터베이스의 트랜잭션 격리 수준을 조정해 충돌을 방지할 수 있습니다.
- READ UNCOMMITTED
다른 트랜잭션에서 아직 커밋되지 않은 데이터도 조회 가능
→ Dirty Read 발생 가능 - READ COMMITTED
커밋된 데이터만 조회 가능
→ Dirty Read는 방지되지만, Non-Repeatable Read 발생 가능 - REPEATABLE READ (MySQL의 기본 격리 수준)
같은 트랜잭션 내에서 동일한 데이터를 반복 조회하면 항상 같은 결과 보장
→ Non-Repeatable Read 방지
단, Phantom Read는 발생 가능 - SERIALIZABLE
가장 강력한 격리 수준
모든 트랜잭션을 순차적으로 처리하는 것처럼 동작
→ 데이터 접근 시 락을 걸어 다른 트랜잭션이 동시에 접근하지 못하게 함
→ 모든 Read 문제(Dirty, Non-Repeatable, Phantom Read) 방지, 그러나 성능 저하 가능
Serializable을 사용하면 동시성 문제가 거의 발생하지 않지만, 반대로 성능은 크게 저하됩니다.
2️⃣ 데이터베이스 기반의 Lock
(1) 낙관적 락 (Optimistic Lock)
데이터 충돌이 드물다고 가정
version을 사용하여 변경 시점의 충돌을 감지
충돌 시 재시도 로직을 태워야 함
JPA의 낙관적 락: `@Version` 어노테이션을 사용하여 엔티티의 버전을 관리.
// 예시 흐름
A 버전 0 → 업데이트 요청
B 버전 0 → 업데이트 요청
A 성공 → 버전 1로 변경
B 실패 (버전이 맞지 않음) → 재시도
(2) 비관적 락 (Distributed Lock)
데이터 충돌이 빈번하거나, 반드시 정합성이 보장되어야 할 때 사용
조회 시점에 다른 트랜잭션이 접근하지 못하게 막음
하지만 데드락의 위험이 존재하며, 대기 시간이 발생할 수 있음
JPA의 비관적 락: `@Lock(LockModeType.PESSIMISTIC_WRITE)`를 사용.
3️⃣ 분산 락 (Distributed Lock)
분산 환경에서 동일한 자원에 대한 경쟁 조건을 방지하고 데이터의 무결성을 유지하기 위해 사용됩니다.
- 별도의 시스템(Redis, Kafka, etcd 등)을 활용하여 락을 제어
- 서버는 락을 직접 관리하지 않고, 공통된 외부 시스템을 이용하여 락의 상태를 관리
- 분산 시스템에서 데이터 일관성과 무결성을 보장
단점: 네트워크 지연이나 장애로 인해 락 획득 및 해제의 신뢰성이 문제될 수 있습니다.
📌 Redis 기반 분산 락
Redis는 빠르고, 싱글 스레드 기반이라 명확한 락 처리 가능
`Redisson`이라는 라이브러리를 활용하면 쉽게 구현 가능
Redisson의 `FairLock` 이라는 리스트와 Lua Script를 활용한 고수준으로 추상화된 기능을 제공
2. Redisson을 활용한 Redis 분산 락
📌Windows + WSL 환경에서 Redis 실행
org.redisson.client.RedisConnectionException: Unable to connect to Redis server: localhost/127.0.0.1:6379
애플리케이션을 실행할 때, localhost:6379에 Redis 서버가 실행 중이지 않거나 접속이 혀용되지 않아서 위와 같은 에러가 발생하는 경우
1) Redis 서버 실행 `redis-server`
2) 6379 포트 사용 중인 프로세스 확인 `sudo lsof -i :6379`
3) Redis가 정상적으로 실행중인데, 윈도우에서 접근이 불가능한 경우 redis 설정 파일 열기 `sudo nano /etc/redis/redis.conf`
bind 수정
모든 IP에서의 접속을 허용
bind 127.0.0.1 - ::1 ➡️ bind 0.0.0.0 ::0
protected-mode 수정
보호 모드 끔
yes ➡️no
⚠️ 개별 로컬 환경이므로 괜찮지만, 실서버에서는 보호 모드를 유지하는 것이 안전
⚠️ 위 설정은 개발 환경에서만 허용하는 것이 좋다!
분산 락 코드 분석
https://github.com/Nhahan/spring-lock
GitHub - Nhahan/spring-lock
Contribute to Nhahan/spring-lock development by creating an account on GitHub.
github.com
@Entity
public class DistributedCounter {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int count;
}
위와 같은 카운터 엔티티가 있을 때 1000개의 요청이 동시에 들어와서 count를 하나씩 증가 시킨다고 가정하면,
동시성 문제가 발생하여 1000보다 작은 값으로 저장될 수도 있습니다.
이 문제를 Redisson을 활용하여 해결합니다.
Redisson을 이용하기 위해서 application.properties에 Redis 설정을 합니다.
# application.properties
spring.data.redis.host=localhost
spring.data.redis.port=6379
락 구현 클래스
@Component
@RequiredArgsConstructor
public class RedissonDistributedLockManager implements DistributedLockManager {
private final RedissonClient redissonClient;
private static final String LOCK_KEY_PREFIX = "distributed-counter-lock:";
@Override
public void executeWithLock(Long key, Runnable task) throws InterruptedException {
String lockKey = LOCK_KEY_PREFIX + key;
RLock lock = redissonClient.getFairLock(lockKey);
// 10초 내로 락 획득 시도
if (lock.tryLock(10, TimeUnit.SECONDS)) {
try {
task.run();
} finally {
lock.unlock(); // 작업 완료 후 락 해제
}
} else {
throw new IllegalStateException("락 획득 실패: " + lockKey);
}
}
}
- LOCK_KEY_PREFIX로 카운터 id마다 별도의 락이 생성됩니다.
- getFairLock()을 사용해 락을 적용합니다.
- tryLock()으로 지정한 시간 내에 락을 획득하지 못하면 false를 반환합니다.
- finally 블록에서 락을 해제해줘야 합니다.
서비스
@Service
@RequiredArgsConstructor
public class DistributedCounterService {
private final DistributedCounterProvider distributedCounterProvider;
private final DistributedLockManager distributedLockManager;
public void incrementCounterWithDistributedLock(Long counterId) {
try {
distributedLockManager.executeWithLock(counterId, () ->
distributedCounterProvider.incrementCounter(counterId));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("락 획득 중 인터럽트 발생: " + counterId, e);
}
}
}
- 락 구현 클래스(`RedissonDistributedLockManager`)의 executeWithLock() 래핑합니다.
- ➡️ 락을 적용한 상태로 incrementCounter를 실행합니다.
테스트 코드
@Test
public void testDistributedLock() throws InterruptedException {
int testCount = 1000;
ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(testCount);
AtomicInteger successfulUpdates = new AtomicInteger(0);
for (int i = 0; i < testCount; i++) {
executorService.submit(() -> {
try {
distributedCounterService.incrementCounterWithDistributedLock(counterId);
successfulUpdates.incrementAndGet();
} catch (Exception e) {
System.out.println("락 충돌: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 스레드가 작업을 완료할 때까지 대기
executorService.shutdown();
DistributedCounter finalCounter = distributedCounterProvider.getCounter(counterId);
System.out.println("성공한 업데이트 수: " + successfulUpdates.get());
System.out.println("최종 count: " + finalCounter.getCount());
assertEquals(successfulUpdates.get(), finalCounter.getCount());
}
- 동시에 1000개 요청을 보내서 락이 잘 동작하는지 테스트합니다.
테스트 결과
'Java Study > Frameworks' 카테고리의 다른 글
Windows 개발 환경에서 AWS 활용 과제 | Spring Boot v3.3.3 & Gradle 8.12 (0) | 2025.03.21 |
---|---|
Spring Security를 활용한 JWT 인증 | Stateless하게 제대로 적용하기! (0) | 2025.03.21 |
H2 콘솔 접속 시 Whitelabel Error Page (400 Bad Request) 해결 과정 + spring boot에서 active profile 선택하기 (IntelliJ) (0) | 2025.03.12 |
H2 데이터베이스 설치 방법 | Server / In-memory / Embedded Mode❔ (0) | 2025.03.10 |
Spring Security를 활용한 JWT 인증 적용해보기 🖥️🔑 (0) | 2025.02.27 |