프로그래밍/디자인패턴

동시성 제어 패턴: 비관적 락(Pessimistic) vs 낙관적 락(Optimistic)

Jinwookoh 2025. 12. 7. 19:31

"콘서트 티켓이 딱 1장 남았습니다. A와 B가 동시에 '결제하기' 버튼을 눌렀습니다."

만약 아무런 안전장치가 없다면, A와 B 모두에게 결제 성공 메시지가 뜨고 티켓은 -1장이 되는 "갱신 손실(Lost Update)" 문제가 발생합니다. 이를 막기 위해 **"누가, 언제, 어떻게 데이터를 점유할 것인가"**를 결정하는 두 가지 상반된 패턴이 있습니다.


1. 방식 1: 비관적 락 (Pessimistic Lock) - "독점적 잠금"

이름 그대로 세상을 비관적으로 바라봅니다. "분명히 내가 수정하는 동안 남들도 건드릴 거야"라고 가정하고, 아예 트랜잭션이 시작될 때 DB 줄(Row) 자체에 자물쇠를 채워버리는 방식입니다.

특징

  • DB 기능 이용: SELECT ... FOR UPDATE 구문을 사용하여 물리적인 락을 겁니다.
  • 무결성 최강: 락을 획득하지 못한 다른 트랜잭션은 대기(Block) 상태가 됩니다.

코드 예시 (JPA)

Java
public interface TicketRepository extends JpaRepository<Ticket, Long> {

    // 1. 비관적 락 걸기 (쓰기 잠금)
    // 이 쿼리가 실행되는 순간, 커밋될 때까지 다른 누구도 이 row를 건들지 못함
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select t from Ticket t where t.id = :id")
    Optional<Ticket> findByIdWithLock(Long id);
}

@Service
@Transactional
public class TicketService {
    public void buyTicket(Long ticketId) {
        // 락 획득 (대기 발생 가능)
        Ticket ticket = ticketRepository.findByIdWithLock(ticketId)
                .orElseThrow();

        if (ticket.getStock() > 0) {
            ticket.decreaseStock();
        }
    }
}

장단점

  • 장점: 데이터 충돌을 원천 차단합니다. 데이터 정합성이 생명인 금융권 로직에서 선호합니다.
  • 단점:
    • 성능 저하: 락을 기다리는 대기 시간이 길어집니다.
    • 데드락(Deadlock): 서로 락을 잡고 놔주지 않는 교착 상태에 빠질 위험이 큽니다.

2. 방식 2: 낙관적 락 (Optimistic Lock) - "버전 관리"

세상을 낙관적으로 바라봅니다. "설마 동시에 수정하겠어? 충돌 나면 그때 가서 해결하지 뭐."라고 생각하며 DB 락을 걸지 않습니다. 대신 버전(Version) 정보를 이용해 논리적으로 충돌을 감지합니다.

특징

  • 애플리케이션 레벨: DB 락 기능을 쓰지 않고, 엔티티에 version 컬럼을 추가하여 관리합니다.
  • CAS (Compare And Set): "내가 읽었을 때 버전이 1이었는데, 지금 수정하려고 보니 여전히 1이니? 그럼 2로 바꾸고 수정해. 아니면 에러 내." 라는 식입니다.

코드 예시 (JPA)

Java
// 1. 엔티티에 버전 필드 추가
@Entity
public class Ticket {
    @Id @GeneratedValue
    private Long id;

    private int stock;

    @Version // 낙관적 락의 핵심!
    private Long version; 
}

@Service
@Transactional
public class TicketService {
    public void buyTicket(Long ticketId) {
        try {
            // 락 없이 그냥 조회 (빠름)
            Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
            ticket.decreaseStock();
            
            // 트랜잭션 커밋 시점에 버전 체크 발생!
            // UPDATE ticket SET stock=stock-1, version=2 WHERE id=1 AND version=1
        } catch (ObjectOptimisticLockingFailureException e) {
            // 충돌 발생 시 개발자가 직접 재시도 로직을 짜야 함
            retryBuyTicket(ticketId); 
        }
    }
}

장단점

  • 장점: DB에 락을 걸지 않으므로 동시 처리 성능(Throughput)이 매우 좋습니다.
  • 단점:
    • 충돌 처리 비용: 충돌이 났을 때(예외 발생 시), 재시도(Retry) 로직을 개발자가 직접 구현해야 합니다. (while 루프 등)
    • 충돌이 잦은 환경에서는 재시도가 계속 일어나 오히려 성능이 떨어집니다.

3. 실무 비교: 언제 무엇을 쓰는가?

구분 Pessimistic Lock (비관적) Optimistic Lock (낙관적)
제어 주체 Database (레코드 잠금) Application (버전 체크)
성능 락 대기로 인한 저하 발생 락이 없어 빠름 (충돌 없을 시)
충돌 발생 시 대기 후 순차 처리 예외 발생 (재시도 필요)
데드락 위험 있음 (설계 주의 필요) 없음
추천 상황 충돌이 빈번한 경우 (선착순 이벤트, 돈 계산) 충돌이 거의 없는 경우 (게시글 수정, 일반 주문)

결론

"데이터가 틀어지면 절대 안 되고, 충돌이 자주 일어난다"면 성능을 좀 포기하더라도 **비관적 락(Pessimistic)**을 써야 합니다. (예: 은행 계좌 이체)

반면 "대부분의 경우 충돌이 안 나는데, 가끔 나는 충돌만 잡으면 된다"면 **낙관적 락(Optimistic)**을 사용하여 성능을 확보하는 것이 현대적인 백엔드 설계의 정석입니다.