Spring/Spring Boot

동시성 제어 문제에 대한 고찰 (With. Spring, JAVA, MySQL, Redis, Kafka)

민돌v 2024. 8. 30. 13:46
Spring 3.0, JAVA 17, MySQL 환경에서 문제를 풀어갑니다.
코드는 ⚙️깃허브에 있습니다.
동시 다발적인 호출에도 정확한 차감이 이뤄지도록 구현이 되어야 합니다.

얼마전 받았던 기업과제의 요구사항 이었습니다.
요점은 동시성 문제의 백엔드 관점에서의 해결인데, 동시성 문제를 직접 다뤄본 경험은 처음이라 공부를 조금 더 해보고자 합니다.

이전에 정리했던 [🚀 재고시스템으로 알아보는 동시성이슈 해결방법] 과 겹치는 내용이 존재합니다.

 

✨ 이번 포스팅에서 공부해 볼 주제들 입니다.

  1. 동시성 문제란 무엇인가
  2. 동시성 문제를 해결하기 위해서는 무엇이 필요한가
  3. 동시성 문제의 해결 방법들
    1. Thread Access Lock
      1. synchronized
      2. Redis + kafka
    2. DB Lock
      1. 비관적 락
      2. 낙관적 락
      3. 네임드 락
      4. 분산락 (Redis)
        1. Lettuce
        2. Redisson
        3. RedLock
  4. 그 외 방법들 
    1. MVCC
    2. TCC
    3. saga

1. 동시성 문제란 무엇인가

동시성 (Concurrency) : 동시에 실행 중인 것처럼 행동하는 것

✔️ 동시성 문제란, 하나의 작업에 순간적으로 많은 요청이 들어와 요청을 올바르게 제어하지 못했을 때 일어나는 문제들을 말합니다.

 

[동시성을 제어하지 못했을 때 생기는 문제]

  1. 경쟁상태(Race condition)
    • 두 개 이상의 스레드가 동시에 같은 데이터(공유 자원)를 접근하여 값을 변경하고자 할 때, 요청의 실행 순서에 따라 데이터의 값이 달라지는 현상 - 데이터 모순성(Inconsistency)
  2.  교착상태(DeadLock)
    • 두 개 이상의 스레드가 서로의 작업이 완료될 때까지 기다리면서 무한히 대기하는 상태를 유지하는 것
  3. 데이터 손상(Data corruption)
    • 두 개 이상의 스레드가 동시에 같은 데이터에 접근하여 값을 변경할 때, 예상치 못한 데이터의 변형이 발생하는 것
    • DBMS의 갱신 손실 (lost update) : 공유자원에 동시에 데이터 변경이 들어와 작업중인 트랜잭션에 다른 트랜잭션이 데이터를 덮어씌우는 것
  4. 연쇄복귀(Cascading Rollback)
    • 두개의 트랜잭션이 같은 데이터를 갱신하는 작업을 진행하는 과정에서 하나의 트랜잭션이 실패하면 원자성에 의해 두 트랜잭션 모두 복귀하는 경우
  5. 비완료 의존성(Uncommitted Dependency)
    • 한개의 트랜잭션이 실패하였을때, 이 트랜재션이 회복하기전에 다른 트랜잭션이 실패한 수행 결과를 참조하는 경우

등등,, 


2. 동시성 문제를 해결하기 위해서는 무엇이 필요한가

✔️ 공유자원에 대해 동시에 제어하려는 것을 순차적으로 접근시킬 수 있게 하면 되지 않을까?

  1. 작업의 단위를 고립 시키는 것
  2. 데이터(공유 자원)의 접근을 고립시키는 것

💡 본격적으로 동시성 제어방법을 공부해보기전에 사전 환경을 구성해보았습니다.

  • 전체 수량과 남은 재고를 가지는 Ticket 도메인
  • 티켓 예약시 생성되는 TicketReservation 도메인

Ticket

@Entity
@Slf4j
@Getter
@NoArgsConstructor
public class Ticket {

    @Id
    private Long id;
    @Column
    private Integer quantity;
    @Column
    private Integer stock;

    public Ticket(Long id, Integer quantity) {
        this.id = id;
        this.quantity = quantity;
        this.stock = quantity;
    }

    public static Ticket create(Long id, Integer quantity) {
        return new Ticket(id, quantity);
    }

    public void decrease() {
        if (stock <= 0) {
            throw new RuntimeException("남은 수량 없음");
        }
        this.stock = this.stock - 1;
        log.info("남은 재고 : " + this.stock);
    }


    public int getNumber() {
        return quantity - stock + 1;
    }
}

TicketReservation

@Entity
@Slf4j
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor
public class TicketReservation {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "ticket_id")
    private Ticket ticket;

    @Column
    private Integer number;

    @Column
    @CreatedDate
    private LocalDateTime createdAt;

    public TicketReservation(Ticket ticket, Integer number) {
        this.ticket = ticket;
        this.number = number;
    }

    public static TicketReservation create(Ticket ticket, int number) {
        log.info("예약 생성 : " + ticket.getNumber());
        return new TicketReservation(ticket, number);
    }

}

TicketService : 티켓 예약 비지니스로직을 호출하는 응용서비스 

@Service
@RequiredArgsConstructor
public class TicketService {

    private final TicketReservationRepository ticketReservationRepository;
    private final TicketRepository ticketRepository;

    @Transactional
    public void reservation(final Long ticketId) {

        final Ticket ticket = ticketRepository.findById(ticketId).get();
        ticket.decrease();

        ticketReservationRepository.save(TicketReservation.create(ticket, ticket.getNumber()));
    }
}

 


3. 동시성 문제의 해결 방법들

✔️ 실패테스트

  • 100개의 전체 수량을 가지는 티켓을 만든 후, 동시에 100개의 예약을 진행했을 시 테스트 (100 - 100 =0)
  • ExecutorService 를 이용해 100개의 작업 스레드를 만든 후, CountDownLatch 클래스를 이용해 동시에 작업이 진행되도록 sync 를 맞춰주었습니다.
@SpringBootTest
public class ThreadAccessTest {

    @Autowired
    TicketService ticketService;

    @Autowired
    TicketRepository ticketRepository;

    @Autowired
    TicketReservationRepository ticketReservationRepository;


    @Test
    @DisplayName("동시다발적으로 요청이 들어올때 실패함")
    void concurrencyFailTest() throws InterruptedException {

        //give
        Ticket ticket = ticketRepository.save(Ticket.create(1L, 100));

        //when
        int threadCount = 100;

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                    try {
                        ticketService.reservation(1L);
                    } finally {
                        latch.countDown();
                    }
                }
            );
        }

        latch.await();

        Ticket result = ticketRepository.findById(1L).get();

        //then
        assertAll(
            () -> assertThat(result.getStock()).isZero(),
            () -> assertThat(ticketReservationRepository.findAll().size()).isEqualTo(100)
        );
    }
}

→ 테스트는 결과는 실패이고, 각각의 Thread 들이 공유자원에 동시에 접근한다는 것을 알 수 있었습니다.


1) 작업 단위 고립

(1) Thread Access Lock

  • 가장 간단한 방법은, 스레드가 비지니스 로직을 접근하는 순서를 순차적으로 제한하는 것입니다.
  • 이 방법은 @Synchronized 어노테이션이나 메소드에 synchronized 키워드를 붙혀 사용할 수 있습니다.
//    @Transactional
//    @Synchronized
    public synchronized void reservationWithoutTransactional(final Long ticketId) {

        final Ticket ticket = ticketRepository.findById(ticketId).get();
        ticket.decrease();

        ticketRepository.save(ticket);
        ticketReservationRepository.save(TicketReservation.create(ticket, ticket.getNumber()));
    }

→ 테스트 결과

📌 synchronized (동기화)란

  1. JAVA 에서 제공하는 동기화 기법이고, 여기서 동기화란 프로세스 또는 스레드들이 수행되는 시점을 조절하여 서로가 알고 있는 정보가 일치하는 것을 의미합니다.


📌 synchronized 동작 과정

  1. JAVA 에서는 모든 객체가 monitor 를 가집니다.
  2. monitor 란 동기화 기법 중 하나인 세모포어기법을 사용자가 직접 제어하지 않도록 제공해주는 인터페이스 입니다.
  3. synchronized keyword가 있는 객체는 모니터 락을 가진 객체 인스턴스가 생성되며, 이를 통해 2개 이상의 스레드가 임계 구역(공유 자원)에 접근할 때 락(key)을 가지지 못한 스레드는 대기하여, 순차적으로 임계구역에 접근할 수 있도록 합니다.
    ( → JVM 에서는 FIFO 알고리즘을 사용하여 스레드의 스케쥴링 관리합니다.)
  4. Java 에서는 synchronized 키워드를 사용했을 때 → 이러한 과정을 통해 동시성을 제어합니다. 

 

📌 synchronized 단점

  • synchronized 키워드로 이루어지는 모니터 락 방식의 동기화 기법은 JVM 단위에서 일어나기 때문에 단일 어플리케이션에서만 적용됩니다.
  • 그렇기 때문에 Docker 를 사용한다거나 분산 시스템 환경이라면 공유자원에 대한 접근을 막을 수 없습니다.

 

📌 synchronized + @Transactional

  • synchronized 의 다른 제약사항은 @Transactional 어노테이션 함께 사용할 수 없다는 점 입니다.
  • @Transactional 은 Spring AOP 로 이루어져 Proxy 객체가 실행되고 @Synchrozied 어노테이션은 상속되지 않기 때문입니다.

synchronized + @transactinonal 일 때 테스트 실패

✔️ synchronized + @Transactional 안되는 이유

  1. 런타임시 Target 객체를 호출할 때 @Transactional 이 적용된 Proxy 객체가 대신 생성되어 호출 됩니다.
  2. @Synchronized 어노테이션은 상속되지않고, synchronized 도 메소드 시그니처가 아니기 때문에 마찬가지로 Proxy 객체에 상속되지 않습니다.
  3. 따라서 "클라이언트가 호출하면 → proxy 객체에서의 트랜잭션 처리 로직 → 비지니스 로직(synchronized 가 적용된) → 성공 시 커밋" 이러한 흐름을 가지는데, 트랜잭션이 성공 후 DB 에 commit 되기 전에 다른 스레드에서 해당 공유 데이터에 접근하여 데이터를 호출하면 커밋 되기 전의 결과를 가져가기 때문에 데이터 일관성이 깨지게 됩니다.

→ 반례로 Spring 에서 제공하는 @Transactional 을 적용하지 않고 직접 커넥셕을 가져와서 트랜잭션을 구현한다면 @Synchronized 가 정상적으로 적용되는걸 볼 수 있었습니다.


(2) Redis + Kafka

✔️ @synchronized 가 가진 분산환경에서 사용하지 못하는 단점을 Redis를 사용하여 커버할 수 있습니다.

Redis 는 기본적으로 싱글스레드로 동작합니다.
(내부 동작은 Event-driven 에 부분적으로 멀티스레드로 처리하는 부분이 있지만,, 클라이언트 입장에서는 싱글 스레드로 인식하고 사용하면 된다고 합니다.)

👏 Redis 의 이러한 특징을 이용하여 선착순 처리를 INCR 명령어를 이용한 테스트를 진행해보았습니다.

@Repository
@RequiredArgsConstructor
public class TicketCountRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public Long increment(String key) {
        return redisTemplate
            .opsForValue()
            .increment(key, 1L);
    }

    public void clear() {
        redisTemplate.
            getConnectionFactory()
            .getConnection()
            .serverCommands()
            .flushAll();
    }
}
public class service(){
	
    //생략
    @Transactional
    public void reservationToRedis(final Long ticketId) {

        //redis 로 동시성 제어
        final String key = "ticket" + ticketId;

        Long increment = ticketCountRepository.increment(key);
        log.info("increment : " + increment);

        if (increment < 100) {
            // 100명 컷은 되는데, 레디스가 처리가 빨라서 db 쪽 데이터 처리량이 못따라감, 데드락 걸림
            // 이래서 카프카 써야하나봄

            final Ticket ticket = ticketRepository.findById(ticketId).get();
            reservationSuccess(ticket);
        }
    }

}

Redis
WAS 에서의 공유자원 접근 속도

→ 결과는 Redis 에서 100개의 카운팅은 정상적으로 처리하지만, 공유자원 (DB) 에 대한 동시 접근은 제대로 처리하지 못했습니다.

 

Kafka (메세징 시스템) 적용

  • 📌 Kafka 를 사용해 Consumer 에게 데이터 처리를 로직을 맡긴다면, Consumer 가 Topic 에 쌓인 메세지를 순차적으로 불러와, 동시성문제를 제어할 수 있었습니다.

Kafka Config

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory<String, Long> consumerFactory() {
        Map<String, Object> config = new HashMap<>();
        config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        config.put(ConsumerConfig.GROUP_ID_CONFIG, "group_1");
        config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, LongDeserializer.class);

        return new DefaultKafkaConsumerFactory<>(config);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, Long> kafkaListenerContainerFactory() {

        ConcurrentKafkaListenerContainerFactory<String, Long> factory = new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory());
        factory.setConcurrency(10);

        return factory;
    }
}
@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory<String, Long> producerFactory() {
        Map<String, Object> config = new HashMap<>();

        config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, LongSerializer.class);

        return new DefaultKafkaProducerFactory<>(config);
    }

    @Bean
    public KafkaTemplate<String, Long> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }
}

 

Kafka Producer & Conusmer

@Component
@RequiredArgsConstructor
public class TicketReservationProducer {

    private static final Logger log = LoggerFactory.getLogger(TicketReservationProducer.class);
    private final KafkaTemplate<String, Long> kafkaTemplate;

    public void reserve(Long ticketId, Long count) {
        log.info("Produce Reserving ticket id {}, count {}", ticketId, count);
        CompletableFuture<SendResult<String, Long>> future = kafkaTemplate.send("ticket-reservation", ticketId);

        future.whenComplete((result, ex) -> {
            if (ex == null) {
                log.info("[SUCCESS] Produce Reserving ticket id {}, count {}", ticketId, count);
            } else {
                log.error("[ERROR] Reserving ticket id {}, count {}", ticketId, count);
            }
        });
    }
}
@Component
@RequiredArgsConstructor
public class TicketReservationConsumer {

    private static final Logger log = LoggerFactory.getLogger(TicketReservationConsumer.class);
    private final TicketRepository ticketRepository;
    private final TicketReservationRepository ticketReservationRepository;

    @KafkaListener(topics = "ticket-reservation", groupId = "group_1")
    public void listener(Long ticketId) {

        log.info("[CONSUMER] received ticket id {}", ticketId);

        final Ticket ticket = ticketRepository.findById(ticketId).get();

        ticket.decrease();
        ticketRepository.save(ticket);
        ticketReservationRepository.save(TicketReservation.create(ticket, ticket.getNumber()));
    }
}

 

같은 관점으로 비동기적 실시간 처리가 필요하지 않다면, Spring Batch 를 사용해도 동시성 문제를 제어할 수 있지 않을까..?
라는 생각만 해보며, 다음으로 넘어가겠습니다 ㅎ,,ㅎ


2) 공유 자원의 고립

(1) DB Lock

  • 작업단위에서 순차적인 접근을 보장하지 않고, 공유자원(Data)의 접근에 순차석을 보장하는 방법이 있습니다.
  • DB 의 경우 Lock 을 이용해 순차적인 접근을 보장할 수 있습니다.

 

1. 비관적 락 (Pessimistic Lock)

비관적 락은 Repeatable Read 또는 Serializable 정도의 트랜잭션 격리 수준을 제공합니다.

📌 비관적 락이란

  • 비관적 락이란, 데이터 충돌 가능성을 높게 보고 (비관적으로) 스레드가 데이터에 접근 시 락을 걸어 다른 스레드가 공유자원에 접근하지 못하도록 점유하고 → 성공적으로 커밋이 완료되면 점유하고있던 락을 풀어 다른 곳에서도 데이터에 접근할 수 있도록 하는 방식입니다.
  • 비관적  락은 데이터에 대한 변경이 자주 발생하고, 충돌 가능성이 높은 환경에서 선호됩니다.
  • 혹은, 데이터가 명확하게 지켜야하는 조건이 있을때도 선호되어지는 방법이라고 생각됩니다. (ex - 재고가 0 이하가 되지 않도록 한다.)
  • 비관적락은 데이터 수정 즉시, 충돌을 감지합니다.
  • 비관적 락에는 공유락과 베타락이 존재합니다.
    • 공유락 (Shared Lock) : Read Lock이라고도 불리는 공유락은 트랜잭션이 읽기를 할 때 사용하는 락이며, 데이터를 읽기만하기 때문에 같은 공유락끼리는 동시에 접근이 가능하지만, write 작업은 막는다.
    • 배타락 (Exclusive Lock) : Write Lock이라고도 불리며, 데이터를 변경할 때 사용하는 락이다. 트랜잭션이 완료될 때까지 유지되며, 배타락이 끝나기 전까지 read/write를 모두 막는다.

https://www.youtube.com/watch?v=SoQ4ExWnetg

 

📌 비관적 락 사용하기

✔️ MySQL 의 경우 SELECT FOR UPDATE와 같은 쿼리를 사용하여 데이터에 대한 락을 걸 수 있습니다.

  • 추가적으로, 락을 얻어오는 where 절의 조건문이 index 가 걸려있는 컬럼이냐 아니냐에 따라 Table 에 락을 걸지 Column 에 락을 걸지가 결정됩니다.
  • index 가 걸려있는 컬럼으로 조회해야만, index 범위 내의 컬럼에 대해 락을 겁니다. (아니면 테이블 락)
SELECT * FROM ticket WHERE id=1 FOR UPDATE; //or FOR SHARE

 

👏  또한, 트랜잭션 대기시 타임아웃 시간을 지정해야 데드락을 방지할 수 있습니다.


✔️ Spring Data JPA 로 비관적 락 사용하기

  • JPA 를 사용한다면 @Lock 어노테이션을 이용할 수 있습니다.
  • 지원하는 LockModeType 매개변수 타입을 통해 트랜잭션이 어떤 Lock 방식을 사용할지 지정할 수 있습니다.
  • Pessmistic Lock 을 지원하는 변수는 아래 3가지 입니다.
PESSMISTIC_READ - Pessmistic read lock 입니다.
- Dirty read가 발생하지 않을 때마다 Shared Lock (공유 락)을 획득하고
   데이터가 UPDATE, DELETE 되는 것을 방지 할 수 있습니다.
PESSMISTIC_WRITE - Pessmistic write lock 입니다.
- 배타적 잠금(Exclusive Lock)을 획득하고
   데이터를 다른 트랜잭션에서 READ, UPDATE, DELETE 하는것을 방지 할 수 있다.
PESSMISTIC_FORCE_INCREMET - write lock 에 version 업데이트 방식을 사용합니다.
- @Version이 지정된 Entity와 협력하기 위해 도입되었습니다.
- PESSIMISTIC_FORCE_INCREMENT 잠금을 획득할 시 버전이 업데이트 됩니다.

 

⚙️ Lock 을 지정한 JpaRepository

public interface TicketRepositoryForLock extends JpaRepository<Ticket, Long> {

    @Lock(value = LockModeType.PESSIMISTIC_READ)
    @Override
    Optional<Ticket> findById(Long aLong);
}

⚙️ Service

@Transactional
public void reservationToPessimisticLock(final Long ticketId) {
    
    final Ticket ticket = ticketRepositoryForLock.findById(ticketId).get();
    reservationSuccess(ticket);
}

하이버네이트에서 날라가는 쿼리를 보면 "for update"를 사용함을 확인할 수 있습니다. (h2 일경우)

(pessmistic_read 로 공유락을 걸었다면, mysql 에서는 for share 를 사용합니다.)

결과

📌 비관적 락 장, 단점

장점

  1. 데이터를 읽을 때부터 해당 데이터에 대한 락을 걸어 다른 트랜잭션이 해당 데이터를 변경할 수 없게 하기에 데이터의 일관성을 유지하는 데 적합합니다.

단점

  1. 다른 트랜잭션이 해당 데이터에 접근할 수 없기 때문에, 동시성이 떨어져 성능 저하
  2. 서로 자원이 필요한 경우 자원데드락 발생 가능성이 높다
  3. 단일 DB 환경에서만 사용이 가능하다

 

📌 비관적 락이 적절한 경우

  1. 데이터의 무결성이 중요하다.
  2. 데이터 충돌이 많이 발생할 것으로 예상된다. (낙관적 락에 비해 충돌 처리 비용이 저렴하다)
  3. 비관적 락은 데이터의 일관성이 매우 중요한 금융 시스템에서 주로 사용됩니다.

 


2. 낙관적 락 (Optimistic Lock)

📌 낙관적 락이란

  • 처음부터 트랜잭션이 자원에 접근할 때 락을 거는게 아닌, 동시성 문제가 발생하면 그때 처리 하는 방법입니다.
  • 실제 공유 자원을 선점하여 락을 걸지않고, version과 같은 별도의 컬럼을을 사용하여 커밋 시 트랜잭션 충돌을 감지하여 롤백하는 논리적인 락 입니다. (hashcode/timestamp를 사용할 수도 있습니다.)
  • 충돌이 발생했을때, DB가 아닌 애플리케이션 단에서 처리 합니다.
  • 낙관적 락은 어플리케이션에 충돌을 처리하는 만큼, UPDATE에 실패해도 자동으로 예외를 던지지 않으며 단순히 0개의 row를 업데이트 합니다.
    따라서 이때 여러 작업이 묶인 트랜잭션 요청이 실패할 경우, 개발자가 직접 롤백 처리를 해줘야 합니다.

 

📌 낙관적 락 사용하기

✔️ JPA에서의 낙관적 락

  • @Version 을 통해 낙관적 락을 사용할 수 있으며, 낙관적 락이 발생하는 경우 ObjectOptimisticLockingFailureException 예외가 발생하고 이를 애플리케이션 단에서 처리해줘야합니다.
NONE - 별도의 옵션을 사용하지 않아도 Entity에 @Version이 적용된 필드만 있으면
   낙관적 잠금이 적용됩니다.

- 암시적 잠금 (Implicit Lock) 
   : JPA에서는 @Version이 붙은 필드가 존재하거나 @OptimisticLocking 어노테이션이 설정되어 있을 경우 자동적으로 충돌감지를 위한 잠금이 실행
OPTIMISTIC (READ) - Entity 수정시에만 발생하는 낙관적 잠금이 읽기 시에도 발생하도록 설정합니다.
- 읽기시에도 버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서
   변경하지 않음을 보장합니다.

- 이를 통해 dirty read와 non-repeatable read를 방지합니다.
OPTIMISTIC_FORCE_INCREMET (WRITE) - 낙관적 잠금을 사용하면서 버전 정보를 강제로 증가시키는 옵션입니다.

⚙️Entity

import org.springframework.data.annotation.Version;

@Entity
public class Ticket {

    @Id
    private Long id;
    @Column
    private Integer quantity;
    @Column
    private Integer stock;
    @Version
    private Integer version;
}

⚙️ Repository

public interface TicketRepositoryForLock extends JpaRepository<Ticket, Long> {

    @Lock(value = LockModeType.OPTIMISTIC)
    @Query("select t from Ticket t where t.id = :id")
    Optional<Ticket> findByWithOptimisticLock(Long id);
}

⚙️Service 

@Transactional
public void reservationToOptimisticLock(final Long ticketId) {

    final Ticket ticket = ticketRepositoryForLock.findByWithOptimisticLock(ticketId).get();
    reservationSuccess(ticket);
}

→ UPDATE 쿼리가 날라갈때, Select 시 받아온 Version 값과 맞는지 확인하는걸 볼 수 있었고, 롤백 처리가 이루어지지 않아 테스트가 실패함을 확인하였습니다.


⚙️Service 롤백 처리

  • 처음에는 간단하게 생각하여 Transaction 안에 실패하면 요청이 반복되도록 해주었습니다.
@Transactional
public void reservationToOptimisticLock(final Long ticketId) throws InterruptedException {

    while (true) {
        try {
            final Ticket ticket = ticketRepositoryForLock.findByWithOptimisticLock(ticketId).get();
            reservationSuccess(ticket);
            break;

        } catch (Exception e) {
            log.error("error : {}", e.getMessage());
            Thread.sleep(50);
        }
    }
}

❗️하지만 여기에는 "트랜잭션끼리 엔티티 매니저를 공유하지 않는다." 는 문제점이 발생하였습니다.

  1. 메소드가 실행될 때, 트랜잭션이 AOP 로 해당 process 를 인터셉터 하여 트랜잭션 안에서 target 메소드를 실행하게 됩니다.
  2. 트랜잭션이 시작될 때, 1개의 작업 당 EnitytManageFactory 에서 1개의 Entity Manager 가 할당됩니다.
  3. @Entity 가 붙은 객체는 영속화 되어 엔티티매니저에 1차 캐싱 되어 조회 됩니다.
  4. 트랜잭션이 시작될 때, 엔티티 매니저가 할당되어 1차 캐싱된 데이터를 조회합니다.
  5. 낙관적 락이 실패하며 롤백하는 로직을 수행하지만, 트랜잭션 내부에서 실행하여 트랜잭셔이 종료되지 않았으므로 할당된 Entity Manager 에서 다시 조회합니다.
  6. 결국 UPDATE 되지 않은 캐싱 데이터를 계속 조회하기 때문 @Version 컬럼에 대해 이전 데이터 값을 얻게되어, 롤백에 계속 실패합니다.

👏 낙관적 락 트랜잭션 문제를 해결하는 방법

  • 롤백을 담당하는 로직과, @Transaction 이 적용된 비지니스 로직을 분리한다.
    1. Facade Pattern 적용 : 클래스 분리
    2. AOP 적용 : Transaction의 Proxy 객체가 생성되기 전에, 또 다른 AOP 에서 먼저 인터셉터하여 롤백 처리를 담당하도록 분리

1번은 매우 간단하지만, 불필요한 클래스가 생성됩니다.
저는 @Retry 어노테이션을 사용하여 풀어보고자 합니다. (https://github.com/spring-projects/spring-retry)

⚙️ 의존성 추가

//retry
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'

⚙️ Config 추가

@SpringBootApplication
@EnableJpaAuditing
@EnableRetry
public class ConcurrencyApplication {

    public static void main(String[] args) {
       SpringApplication.run(ConcurrencyApplication.class, args);
    }

}

⚙️ Service 에 Retry 적용

  • retryFor : 낙관적락 충돌 시 발생하는 Exception 터지면 재시도 하도록 지정해줍니다.
  • maxAttempts : 최대 시도 횟수 입니다. (default 3)
  • backoff : delay 초를 지정합니다.
@Transactional
@Retryable(
    retryFor = {ObjectOptimisticLockingFailureException.class},
    maxAttempts = 10,
    backoff = @Backoff(delay = 100)
)
public void reservationToOptimisticLock(final Long ticketId) throws InterruptedException {

            final Ticket ticket = ticketRepositoryForLock.findByWithOptimisticLock(ticketId).get();
            reservationSuccess(ticket);
}

@RetryAble Order 값
@Transactional global order 값

👏 사진과 같이, @Transaction 보다 @Retry Order 값이 더 작으므로 테스트를 성공적으로 통과할 수 있었습니다.

 

📌 낙관적 락 장, 단점

장점

  1. DB 자원에 락을 걸지 않아데이터베이스의 성능 저하를 최소화하고, 동시성을 높이는 데 유리
  2. 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 변경할 수 있기 때문에, 데드락(Deadlock) 발생 가능성이 낮다

단점

  1. 롤백 처리를 해줘야한다.
  2. 충돌이 많은 환경에서는 오히려 서버 리소스를 잡아먹는다 (성능 저하)
  3. 낙관적 락 또한 마찬가지로 파티션된 다중 DB 환경에서는 사용하기 어렵습니다.
    각 파티션이 독립적인 트랜잭션을 처리하고 낙관적 락은 각 파티션 내에서만 충돌을 감지하므로, 파티션 간의 트랜잭션 일관성을 유지하기 어렵습니다. 

 

📌 낙관적 락이 적절한 경우

  • 데이터 충돌이 자주 일어나지 않을 것이라고 예상된다.
  • 조회 작업이 많아 동시 접근 성능이 중요하다.

 

📌 비관적 락 vs 낙관적 락

  • 낙관적 락은 트랜잭션을 필요로하지 않기 때문에 성능적으로 비관적 락보다 더 좋습니다.
  • 비관적 락은 데이터 자체에 락을 걸기 때문에 동시성이 떨어져 성능이 많이 저하되며, 서로의 자원이 필요한 경우에는 데드락이 일어날 가능성도 있다.

👏 하지만 충돌이 많이 발생하는 환경에서는 반대가 됩니다.

  • 충돌이 발생했을 때, 비관적 락은 트랜잭션을 롤백하면 끝이지만,
  • 낙관적 락은 까다로운 수동 롤백 처리는 둘째 치고, 성능 면에서도 (version)update를 한번씩 더 해줘야 하기 때문에, 비관적 락 보다 좋지 않습니다.
  • 위의 결과사진을 보았을 때도, 충돌이 많은 환경이기 때문에 오히려 비관적락의 성능이 500ms (낙관적 락은 1252ms) 로 더 좋습니다.

 


3. 네임드 락

MySQL 환경이라면 Named Lock 을 이용할 수 있습니다.
  • 네임드 락(Named Lock)은 특정 이름을 가진 락을 획득하거나 해제하는 방식으로 작동합니다.
  • 네임드 락은 MySQL 서버 내의 세션 간에 공유되며, 이름을 통해 관리됩니다.

 

📌 네임드 동작 과정

  1. 락 획득
    • GET_LOCK('lock_name', timeout)
    • GET_LOCK 함수는 지정된 lock_name에 대해 락을 획득하려고 시도합니다.
    • timeout은 락을 기다릴 시간(초)을 지정하며, 0으로 설정하면 즉시 시도만 하고 실패하면 반환합니다.
    • 성공적으로 락을 획득하면 1을 반환하고, 이미 다른 세션에서 락을 가지고 있거나 시간이 초과된 경우 0을 반환합니다.
  2.  락 사용
    • 락이 획득된 동안에는 다른 세션에서 동일한 이름의 락을 획득할 수 없습니다.
    • 이 상태에서 해당 세션은 데이터를 보호하거나 특정 작업을 수행할 수 있습니다.
  3.  락 해제
    • RELEASE_LOCK('lock_name')
    • RELEASE_LOCK 함수는 지정된 이름의 락을 해제합니다.
    • 락을 성공적으로 해제하면 1을 반환하며, 만약 해당 락을 가지고 있지 않거나 존재하지 않는 락을 해제하려고 시도하면 0을 반환합니다.
-- 락을 획득하고 작업 수행
SELECT GET_LOCK('payment_lock', 10);

-- 결제 관련 작업 수행
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;

-- 락 해제
SELECT RELEASE_LOCK('payment_lock');

 

📌 JPA 환경에서 Named Lock 사용하기

⚙️ Repository

public interface TicketRepositoryForLock extends JpaRepository<Ticket, Long> {

    //생략

    @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
    String getLock(String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    String releaseLock(String key);

}

⚙️ Facade

@Service
@RequiredArgsConstructor
public class NamedLockFacade {

    private final TicketService ticketService;
    private final TicketRepositoryForLock ticketRepositoryForLock;

    @Transactional
    public void setNamedLock(Long ticketId) {

        try {
            ticketRepositoryForLock.getLock("ticket");
            ticketService.reservationToNamedLock(ticketId);
        } finally {
            ticketRepositoryForLock.releaseLock("ticket");
        }
    }
}

⚙️ Service

👏여기서 Facade 로 분리하고 Transactional 타입을 REQUIRES_NEW 로 지정한 이유는 트랜잭 커밋 전에 락이 해제되는 걸 방지하기 위함입니다.

  • Facade 없이 1개의 Transaction으로 묶였을 때
    1. Transaction 실행
    2. lock 을 얻음
    3. 데이터 수정
    4. lock 을 반환
    5. (트랜잰션 커밋 전 다른 트랜잭션에서 공유자원에 접근 가능)
    6. Transaction 커밋
  • Facade 로 Named Lock 을 분리했지만, Facade 에도 @Transactional이 붙는 이유
    • lock 을 획득과 반환은, 하나의 세션에서 이루어져야하기 때문입니다.
  • Service 의 @Transactional 의 옵션을 REQUIRES_NEW 로 두어, 부모 트랜잭션과 분리하는 이유
    • Facade 로 분리하는 이유와 같습니다.
    • 트랜잭션은 전파되기에, 결국 하위 트랜잭션은 상위 트랜잭션에 묶입니다.
    • lock 의 반환되기 전에 데이터 변경을 커밋하기 위해 트랜잭션을 분리합니다.
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void reservationToNamedLock(final Long ticketId) {

    final Ticket ticket = ticketRepository.findById(ticketId).get();
    reservationSuccess(ticket);

}

❗️Named Lock 은 Lock 을 거는 별도의 커넥션이 필요합니다. 
따라서 100개의 요청을 원할하게 처리하기 위해서는 MySQL 의 커넥션 수를 늘려주어야 했습니다.

//MySQL 의 MAX 커넥션 수 확인
show variables like 'max_connections';

//MAX 커넥션 수 지정
set global max_connections=201;

defaul 151 개를 201 로 변경 (스레드 커넥션 100개, 락 커넥션 100개 + mysql 에서 잡고있는 default 커넥션 1개)

 

📌 네임드 락 장, 단점

장점

  1. 구현을 어떻게 하느냐에 따라 분산 락으로 구현이 가능합니다.
  2. MySQL을 사용하고 있다면 추가 인프라 설정없이 사용이 가능합니다.

단점

  1. 추가적인 커넥션이 필요합니다. (너무 큰 단점으로 느껴짐)
  2. lock 을 명시적으로 해제해주어야 합니다.
  3. 애플리케이션 로직이 MySQL에 종속됩니다.
  4. 완벽한 분산락으로 사용하고 락을 획득하는 커넥션이 애플리케이션 커넥션을 점유하지 않도록 하기 위해서는
    별도의 디비환경을 구축해야합니다...
  5. 직접 구현을 해보니 모든 스레드가 먼저 락의 선점을 시도하기 때문에, 속도가 느려지는 것 같습니다. (100개 동시요청 10s)

 


4. Redis로 분산 락 구현하기

Redis 를 사용하여 분산 락 환경을 구성할 수 있습니다.

  1. Lettuce
  2. Redisson
  3. RedLock

 

✔️ Redis Lettuce

Lettuce 는 Named Lock 매커니즘이 비슷한 Sping Lock 방식입니다. Redis 의 Key 값을 점유하고, 반환하는 방식입니다.
스레드에서 Lock 을 얻지 못하면 계속 락 점유 시도를 하게 되고, 레디스의 부하를 일으키게 됩니다.
Named Lock 에 비해서는, RDS(MySQL) 의 부하를 나눌 수 있기에 좋은 선택지라고 생각됩니다.

Named Lock 과 비슷하기도 하고, 비교적 구현이 간단하기 때문에 이전 글 링크를 남기고 넘어가겠습니다.


✔️ Redis Redisson

  • Pub-sub 기반으로 Lock 구현 제공
    • 락이 해제되면, 락을 관리하는 Channel 을 subscribe 하는 클라이언트들에게 락이 해제되었다는 신호를 보냅니다.
    • 따라서, Spin Lock 방식과 다르게 Lock 점유를 시도하는 요청을 Redis 에게 계속 요청하지 않습니다. 
    • 대기 중인 스레드에 Lock 해제를 알려주면, 그때 락 점유를 시도합니다.
  • 이 방식은, Lettuce와 다르게 대부분 별도의 Retry 방식을 작성하지 않아도 됩니다.

 

📌 Redisson 동작과정

Redisson 은 pub/sub 구조와 세마포어를 이용해 스레드가 리소스가 접근하는 걸 제어합니다.

  1. RLock 의 tryLock → Redis 의 Hash 값의 키 중에 lockKey(RLock 의 name = lockKey) 가 없으면 생성하여 락을 점유하거나, value 중 threadId (재접근 가능) 가 있으면 count + 1 을 하여 재접근 허용해줍니다.
  2. 접근하려는 lockKey 가 이미 존재한다면, TTL (유효기간) 을 반환합니다.
  3. TTL 이 존재하면, threadID 를 redis pub/sub channel(name: "redisson_lock__channel" + lockKey)에 구독을 요청합니다.
  4. channel 에 구독후, 지정된 시간안에 세마포어에 사용 가능한지 허가 메세지를 받으면, 락을 얻은 후 구독을 취소합니다.
    1. 세마포어에 접근이 불가하면, 세마포어 내부 CompletableFuture Queue 에 담아 대기합니다.
      (세마포어에 접근가능한 counter 수는 디버깅 시 유동적으로 바뀌던데 기준이 뭘까요,, 흠,,😟)
  5. 락을 얻으면, 클라이언트가 작성한 코드대로 비지니스로직을 수행하고 락을 해제하면 됩니다.

 

🔗 내부 코드를 자세히 분석해보고 싶으신 분은 다음 블로그를 참고해주세요 (redisson trylock 내부로직 살펴보기)

 

📌 Spring Redisson 사용하기

의존성 추가

//redis
implementation 'org.springframework.data:spring-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'

RedissonConfig

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {

        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");

        return Redisson.create(config);
    }
}

Facade & Service

  • 여기서도 앞단 Facade 클래스를 둔 이유가, 데이터가 DB에 커밋하기 전에 Redis Lock 을 풀어 다른 트랜잭션이 공유자원에  접근하는 걸 방지하기 위함입니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class RedissonLockFacade {

    private final TicketService ticketService;
    private final RedissonClient redissonClient;

    public void tryRedissonLock(Long ticketId) {

        //key 로 Lock 객체 가져옴
        String lockKey = "reservation-redisson-lock-" + ticketId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            //획득시도 시간, 락 점유 시간
            boolean lockable = lock.tryLock(5, 1, TimeUnit.SECONDS);
            if (!lockable) {
                log.error("Lock 획득 실패={}", lockKey);
                return;
            }
            log.info("Redisson Lock 획득");
            ticketService.reservationToRedisson(ticketId);

        } catch (InterruptedException e) {
            log.error("Redisson 락 점유 에러");
            throw new RuntimeException(e);
        } finally {
            log.info("락 해제");
            lock.unlock();
        }
    }
}

Facade 지겹기도하고 매우 불편하니, AOP 로 빼볼까 합니다.

📌 Spring Redisson Lock AOP 로 분리하기

RedissonLock Annotaion

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {

    String lockKey();
    long waitTime() default 5000L; // Lock획득을 시도하는 최대 시간 (ms)
    long leaseTime() default 2000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)

}

RedssionLock AOP

@Slf4j
@Order(1)   //@Transactional 보다 먼저 하기 위해
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(com.study.concurrency.config.redisson.RedissonLock)")
    public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedissonLock annotation = method.getAnnotation(RedissonLock.class);

        String lockKey = method.getName() + getDynamicValue(signature.getParameterNames(), joinPoint.getArgs());

        //key 로 Lock 객체 가져옴
        RLock lock = redissonClient.getLock(lockKey);

        try {
            //획득시도 시간, 락 점유 시간
            boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
            if (!lockable) {
                log.error("Lock 획득 실패={}", lockKey);
                return false;
            }
            log.info("Redisson Lock 획득");
            joinPoint.proceed();
        } catch (InterruptedException e) {
            log.error("Redisson 락 점유 에러");
            throw e;
        } finally {
            log.info("락 해제");
            lock.unlock();
        }

        return true;
    }

    private String getDynamicValue(String[] parameterNames, Object[] args) {

        StringBuilder stringBuilder = new StringBuilder();

        for (int i = 0; i < parameterNames.length; i++) {
            stringBuilder.append("-").append(parameterNames[i]).append(":").append(args[i]);
        }

        return stringBuilder.toString();
    }
}

Service 비지니스 로직

@Transactional
@RedissonLock(lockKey = "reservation")
public void reservationToRedisson(final Long ticketId) {

    final Ticket ticket = ticketRepository.findById(ticketId).get();
    reservationSuccess(ticket);
}

 

📌 Spring Redisson Lock AOP, 부모 @Transacnal 전파에서 구출하기

👏 하지만 만약, @RedissonLock 어노테이션을 사용하는 클래스의 앞단에서 @Transactional 이 전파된다면 커밋이 되기전 락을 반환해 테스트에 실패합니다.

 

@RedissonLock 어노테이션이 적용된 AOP 에서 내부적으로 Target Proceed 전에 트랜잭션을 분리하여 해결하고자 했습니다.

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}
@Slf4j
@Order(1)   //@Transactional 보다 먼저 하기 위해
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.study.concurrency.config.redisson.RedissonLock)")
    public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
       
        //...
        
        try {
            //획득시도 시간, 락 점유 시간
            boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
            if (!lockable) {
                log.error("Lock 획득 실패={}", lockKey);
                return false;
            }
            log.info("Redisson Lock 획득");

			// AOP 내부에서 transaction 분리
            return aopForTransaction.proceed(joinPoint);

        } catch (InterruptedException e) {
            log.error("Redisson 락 점유 에러");
            throw e;
        } finally {
            log.info("락 해제");
            lock.unlock();
        }
    }

	//...
}

부모의 트랜잭션을 전파받지 않고, 새로운 트랜잭션으로 분리하다보니 추가적인 Connection Pool Size 확보가 필요했습니다 ..😭


✔️ Redis RedLock

단일 노드로 구축된 Redis 는 단일 장애 지점(SPOF, Single Point Of Failure)을 가집니다.
이러한 단점을 극복하기 위해 Redis 에서 제안한 분산락 알고리즘이 레드락(Redlock) 알고리즘입니다.

자세한 내용은 잘 설명된 블로그를 참고하고 넘어가겠습니다,,

👏 자세한 내용은 여기로! → 출처: https://mangkyu.tistory.com/311 [MangKyu's Diary:티스토리]

 


4. 그 외의 방법들

공부를 하면서 찾아보니 MSA와 같은 분산 환경에서 동시성을 제어하며, 데이터의 일관성을 보장하는 몆가지 패턴들에 알게되어
간단하게 참고용으로 남깁니다.

  1. MVCC (다중버전 동시성 제어, Multi-Version Concurrency Control)
    • DB 스냅샵 이용
  2. Saga 패턴
    • 트랜잭션을 이벤트로 관리 (롤백 이벤트 필요)
  3.  TCC (Try-Confim-Cancel) 
    • Saga 패턴과 마찬가지로 트랜잭션을 이벤트로 관리 (롤백 이벤트 필요)
    • 최종적 일관성만을 보장
    • Sagas 와의 차이점은 롤백의 비동기적 처리...?

 

 

끝!


참고