동시성 제어

김현정·2025년 5월 20일
0

동시성 제어의 개념과 이해

동시성 제어는 여러 사용자나 프로세스가 동시에 같은 데이터에 접근하고 수정할 때 발생할 수 있는 문제를 관리하는 기술이다.

1. 동시성 문제란?

1.1 기본개념

여러 사용자가 동시에 같은 데이터를 수정할 때 발생하는 문제이다.
예를 들어) 영화관에서 10명이 동시에 같은 좌석을 예매하려고 시도, 쇼핑몰에서 재고가 1개인 상품을 여러 명이 동시에 구매 시도

1.2 경쟁 상태(Race Condition)

두 개 이상의 프로세스가 공유 자원을 동시에 조작할 때, 실행 순서에 따라 결과가 달라지는 상황이다.

예시 상황:
1. 좌석 수가 1개 남았을 때
2. 사용자 A와 B가 동시에 예약 가능 여부를 확인 (둘 다 "가능" 결과를 받음)
3. 사용자 A와 B 모두 예약 처리를 진행
4. 결과: 1개 좌석에 2개의 예약이 생성됨 (데이터 무결성 손상)

2. 동시성 제어 방법

2.1 낙관적 락(Optimistic Lock)

2.1.1 개념 : "충돌이 적을 것이다"라는 낙관적인 가정하에 동작한다.

2.1.2 방식 : 각 데이터에 버전 번호를 부여하고, 읽은 버전과 수정 시점의 버전이 일치할 때만 수정을 허용한다.

2.1.3 특징 : 락을 획득하지 않고 작업을 수행하다가 커밋 시점에 충돌을 감지하고 롤백한다.

2.1.4 적합한 경우 : 충돌이 드물게 발생하는 환경, 읽기 작업이 많은 환경

@Entity
public class Reservation {
    @Id
    private Long id;
    
    private int seatCount;
    
    @Version  // 이 필드가 버전 관리에 사용됨
    private Long version;
    
    // 다른 필드와 메서드들...
}

JPA에서는 이렇게 구현 된 엔티티를 수정할 때, 트랜잭션 커밋 시점에 버전을 확인한다. 만약 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException이 발생한다.

2.2 비관적 락(Pessimistic Lock)

2.2.1 개념 : "충돌이 자주 발생할 것 이다."라는 비관적인 가정하에 동작한다.

2.2.2 방식 : 데이터를 읽는 시점에 바로 락을 획득하여 다른 트랜잭션이 접근하지 못하게 한다.

2.2.3 특징 : 실제 데이터베이스의 락 기능을 사용하므로 더 강력한 보호를 제공한다.

2.2.4 적합한 경우 : 충돌이 자주 발생하는 환경, 데이터 정합성이 매우 중요한 경우

// Repository 메서드
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM Reservation r WHERE r.id = :id")
Optional<Reservation> findByIdWithLock(@Param("id") Long id);

// 서비스에서 사용
@Transactional
public void updateSeatCount(Long id, int newCount) {
    // 읽는 시점에 락을 획득
    Reservation reservation = reservationRepository.findByIdWithLock(id)
        .orElseThrow(() -> new EntityNotFoundException());
    
    reservation.setSeatCount(newCount);
    // 트랜잭션이 끝날 때 락이 해제됨
}

2.3 분산 락(Distributed Lock)

2.3.1 개념 : 여러 서버나 인스턴스에서 동시에 같은 자원에 접근할 때 사용하는 락 메커니즘이다.

2.3.2 방식 : Redis, ZooKeeper 같은 외부 시스템을 사용하여 락을 관리한다.

2.3.3 특징 : 여러 서버에 걸쳐 동작하는 분산 환경에서 동시성을 제어할 수 있다.

2.3.4 적합한 경우 : 마이크로서비스 아키텍처, 클러스터링된 서버 환경

Redis를 이용한 분산 락 예시(Redisson 라이브러리 사용)

@Service
public class ReservationService {
    private final RedissonClient redissonClient;
    private final ReservationRepository reservationRepository;
    
    @Transactional
    public Reservation createReservation(ReservationDto dto) {
        // 락 키 생성 (보통 특정 리소스의 식별자를 사용)
        String lockKey = "reservation:store:" + dto.getStoreId() + ":time:" + dto.getReservationTime();
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 락 획득 시도 (10초 대기, 30초 유지)
            boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new RuntimeException("락 획득 실패");
            }
            
            // 락을 획득한 후 비즈니스 로직 실행
            int currentReservations = reservationRepository.countByStoreAndTime(...);
            if (seatExceeded(currentReservations, dto.getPeopleCount())) {
                throw new RuntimeException("좌석 초과");
            }
            
            Reservation reservation = new Reservation(...);
            return reservationRepository.save(reservation);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("락 처리 중 인터럽트 발생", e);
        } finally {
            // 락 해제 (현재 스레드가 소유한 경우에만)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

2.4 애플리케이션 수준 락

2.4.1 개념 : 애플리케이션 코드 수준에서 구현하는 락 메커니즘이다.

2.4.2 방식 : Java의 synchronized 키워드나 ReetrantLock 등을 사용한다.

2.4.3 특징 : 단일 JWM내에서만 유효하므로 분산 환경에서는 적합하지 않다.

2.4.4 적합한 경우 : 단일 서버 환경, 간단한 동시성 제어가 필요한 상황

@Service
public class ReservationService {
    private final Map<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
    
    @Transactional
    public Reservation createReservation(ReservationDto dto) {
        String lockKey = dto.getStoreId() + ":" + dto.getReservationTime();
        ReentrantLock lock = lockMap.computeIfAbsent(lockKey, k -> new ReentrantLock());
        
        try {
            if (!lock.tryLock(5, TimeUnit.SECONDS)) {
                throw new RuntimeException("락 획득 실패");
            }
            
            // 비즈니스 로직 수행
            // ...
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("락 처리 중 인터럽트 발생", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

3. Spring에서의 동시성 제어 구현

3.1 JPA/Hibernate에서의 락 사용

  • @Version : 엔티티에 버전 필드를 추가하여 낙관적 락을 구현
  • @Lock 어노테이션 : Repository 메서드에 락 모드를 지정하여 비관적 락을 구현

3.2 Redis를 이용한 분산 락 구현

3.2.1 의존성 추가

implementation 'org.redisson:redisson-spring-boot-starter:3.27.1'

3.2.2 설정 클래스

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}

3.2.3 락 서비스 구현

@Service
public class LockService {
    private final RedissonClient redissonClient;
    
    public LockService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }
    
    public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, 
                                 Supplier<T> supplier) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            boolean isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
            if (!isLocked) {
                throw new RuntimeException("락 획득 실패");
            }
            return supplier.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("락 처리 중 인터럽트 발생", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

3.2.4 서비스에서 사용

@Service
public class ReservationService {
    private final LockService lockService;
    private final ReservationRepository reservationRepository;
    
    @Transactional
    public ReservationResponse saveReservation(ReservationRequest request) {
        String lockKey = "reservation:store:" + request.getStoreId() + 
                         ":time:" + request.getReservationTime();
        
        return lockService.executeWithLock(lockKey, 5000, 10000, () -> {
            // 예약 가능 여부 확인
            int currentReservations = countReservations(...);
            if (exceededCapacity(currentReservations, request.getPeopleCount())) {
                throw new RuntimeException("좌석 초과");
            }
            
            // 예약 생성 및 저장
            Reservation reservation = new Reservation(...);
            reservationRepository.save(reservation);
            
            return new ReservationResponse(...);
        });
    }
}

4. 동시성 제어 전략 선택 기준

4.1 시스템 특성

  • 단일서버 vs 분산 환경
  • 트래픽 양과 패턴
  • 충돌 발생 빈도

4.2 비즈니스 요구사항

  • 데이터 일관성 중요도
  • 응답 시간 요구사항
  • 락 타임아웃 허용 여부

4.3 리소스 특성

  • 읽기 많음 vs 쓰기 많음
  • 충돌 발생 가능성
  • 리소스 접근 패턴

5. 실제 적용 시 고려사항

5.1 데드락 방지

  • 락 획득 순서를 일관되게 유지
  • 타임아웃 설정으로 무한 대기 방지
  • 데드락 감지 메커니즘 구현

5.2 성능 영향

  • 비관적 락은 동시성을 크게 제한할 수 있음
  • 락 범위를 최소화하여 성능 저하 방지
  • 필요한 경우에만 락을 사용

5.3 예외처리

  • 락 획득 실패 시 적절한 사용자 경험 제공
  • 롤백 메커니즘 구현
  • 재시도 로직 고려

0개의 댓글