반응형

동시성 문제를 해결하는 4가지 방법

안녕하세요, 오늘은 멀티스레드 환경에서 발생하는 동시성 문제를 해결하는 네 가지 방법에 대해 이야기해보려고 합니다. 동시성 문제는 여러 스레드가 동시에 같은 자원에 접근하면서 데이터 무결성이 깨질 수 있는 상황을 말하는데요. 이를 해결하기 위해 자바와 Spring Boot에서 사용할 수 있는 다양한 접근법을 예시와 함께 알아보겠습니다.

1. 자바 synchronized 키워드

가장 기본적인 동시성 제어 방법 중 하나는 자바의 synchronized 키워드를 사용하는 것입니다. 이 방식은 특정 코드 블록이나 메서드에 대해 한 번에 하나의 스레드만 접근할 수 있도록 잠금을 걸어줍니다.

언제 사용하나요?

  • 간단한 동기화가 필요한 경우
  • 객체 단위로 락을 걸어 데이터 무결성을 지키고 싶을 때

Spring Boot 예시

재고 관리 시스템에서 동시에 재고를 감소시키는 상황을 생각해봅시다.

@Service
public class StockService {
    private int stock = 100;

    public synchronized void decreaseStock(int quantity) {
        if (stock >= quantity) {
            stock -= quantity;
            System.out.println("재고 감소! 남은 재고: " + stock);
        } else {
            throw new RuntimeException("재고 부족!");
        }
    }
}
 

위 코드에서 synchronized를 사용하면 여러 스레드가 동시에 decreaseStock 메서드에 접근하더라도 순차적으로 실행됩니다. 단점은 성능이 중요한 대규모 시스템에서는 병목 현상이 발생할 수 있다는 점입니다.


2. 낙관적 락 (Optimistic Lock)

낙관적 락은 "충돌이 별로 없을 거야!"라는 낙관적인 가정을 기반으로 동시성을 제어합니다. 데이터를 수정할 때 충돌 여부를 버전 번호나 타임스탬프를 통해 체크하고, 충돌이 발생하면 예외를 던져서 처리합니다.

언제 사용하나요?

  • 충돌 가능성이 낮은 환경
  • 성능 최적화가 중요한 경우

Spring Boot 예시

JPA를 사용해 낙관적 락을 구현해보겠습니다. @Version 어노테이션을 활용하면 됩니다.

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int stock;

    @Version
    private int version; // 버전 필드 추가
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new RuntimeException("상품 없음"));
        if (product.getStock() >= quantity) {
            product.setStock(product.getStock() - quantity);
            productRepository.save(product);
        } else {
            throw new RuntimeException("재고 부족!");
        }
    }
}
 

위 코드에서 트랜잭션이 끝날 때 JPA가 버전을 체크합니다. 다른 스레드가 먼저 수정해서 버전이 달라지면 OptimisticLockException이 발생하고, 이를 통해 충돌을 감지합니다. 성능이 비관적 락보다 좋지만, 충돌이 자주 발생하면 롤백 처리가 늘어날 수 있어요.


3. 비관적 락 (Pessimistic Lock)

비관적 락은 "충돌이 자주 발생할 거야!"라는 비관적인 가정을 하고, 아예 트랜잭션 시작 시점에 데이터베이스에 락을 겁니다. 다른 스레드는 락이 해제될 때까지 기다려야 합니다.

언제 사용하나요?

  • 충돌 가능성이 높은 환경
  • 데이터 무결성이 매우 중요한 경우 (예: 금융 시스템)

Spring Boot 예시

JPA에서 @Lock 어노테이션을 사용해 비관적 락을 설정할 수 있습니다.

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findByIdWithPessimisticLock(productId)
                .orElseThrow(() -> new RuntimeException("상품 없음"));
        if (product.getStock() >= quantity) {
            product.setStock(product.getStock() - quantity);
            productRepository.save(product);
        } else {
            throw new RuntimeException("재고 부족!");
        }
    }
}

 

PESSIMISTIC_WRITE 락을 걸면 해당 레코드는 트랜잭션이 끝날 때까지 다른 스레드가 수정할 수 없습니다. 성능은 낙관적 락보다 떨어지지만, 중요한 데이터의 정확성을 보장할 수 있어요.


4. 캐시 서버 도입

마지막으로, 캐시 서버(예: Redis)를 활용하는 방법입니다. 이건 돈이 좀 들 수 있는 솔루션이지만, 동시성 문제를 근본적으로 줄이고 성능을 크게 향상시킬 수 있습니다. 캐시는 자주 조회되는 데이터를 메모리에 저장해 DB 부하를 줄여줍니다.

언제 사용하나요?

  • 읽기 요청이 많은 시스템
  • DB 부하를 줄이고 응답 속도를 높이고 싶을 때

Spring Boot 예시

Redis를 사용해 재고를 캐싱하고 동시성을 제어해보겠습니다.

@Service
public class StockService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String STOCK_KEY = "stock:product:1";

    public void decreaseStock(int quantity) {
        String stockStr = redisTemplate.opsForValue().get(STOCK_KEY);
        int stock = stockStr != null ? Integer.parseInt(stockStr) : 100;

        if (stock >= quantity) {
            redisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(stock - quantity));
            System.out.println("재고 감소! 남은 재고: " + (stock - quantity));
        } else {
            throw new RuntimeException("재고 부족!");
        }
    }
}
 

여기서는 단순히 Redis에 재고를 저장했지만, 실제로는 Redis의 SETNX(Set if Not Exists) 같은 명령어나 분산 락(Distributed Lock)을 활용해 동시성을 더 철저히 제어할 수 있습니다. 비용이 들지만 확장성과 성능 면에서 큰 이점이 있어요.


정리: 어떤 방법을 선택할까?

  • synchronized: 간단하고 비용 없음, 소규모 시스템에 적합
  • 낙관적 락: 충돌이 적을 때 성능 좋음, 무료
  • 비관적 락: 충돌이 많고 데이터 무결성이 중요할 때, 무료지만 성능 저하 주의
  • 캐시 서버: 돈은 들지만 성능과 확장성 극대화

프로젝트의 요구사항과 예산에 따라 적절한 방법을 선택하면 됩니다. 예를 들어, 간단한 앱이라면 synchronized나 낙관적 락으로 충분할 수 있고, 대규모 트래픽을 감당해야 한다면 Redis 같은 캐시 서버를 고려해볼 만해요.

이 글이 동시성 문제 해결에 도움이 되셨길 바랍니다.

반응형

'개발 부트캠프 > 백엔드' 카테고리의 다른 글

[Kafka] Kafka 이용하여 Spring Boot로 메시지 보내고 받기  (0) 2025.02.27
[MSA] OpenFeign  (0) 2025.02.27
[Trace] Jaeger  (0) 2025.02.21
[Log] Log 중앙화  (0) 2025.02.21
[Trace] 핀포인트(Pinpoint)  (0) 2025.02.21

+ Recent posts