본문 바로가기

내배캠/TIL

락(Lock) 이란?

락 (Lock)

- 물리적으로 특정 행(row) 또는 테이블(table)에 접근 제어를 거는 방법.

- 다른 트랜잭션이 접근하지 못하게 막는 매커니즘.

 

 

비관적 락(Pessimistic Lock)

SELECT FOR UPDATE ...

 

비관적 락은 데이터베이스 락 매커니즘에 의존하는 동시성 제어 방법으로, select for update 쿼리가 수행되는 것이라고 생각하시면 된다. 먼저 자원에 접근한 트랜잭션이 락을 획득하게 되고 다른 트랜잭션은 락을 획득하기 전까지 대기한다.

 

 

낙관적 락(Optimistic Lock)

  • 충돌이 일어나지 않을 것이라고 판단
  • 물리적인 락을 사용하지 않음.
  • version 필드 사용
  • 장점
    • 락 대기 문제 회피
      • 데이터를 읽는 시점에 대응하지 않고 쓰는 시점에 대응하기 때문에 다른 트랜잭션에 영향을 주지 않음.
  • 단점
    • 충돌이 잦은 경우 성능 저하
      • 비관적 락이 대기하는 것과 다르게 충돌이 일어날 때마다 재시도를 해야하는 상황이 발생

 

 

 

즉, 비관적 락동시성 문제가 자주 발생하는 곳, 데이터의 일관성의 중요도가 큰 곳 (결제, 수강 신청...) 에 사용되고

낙관적 락동시성 문제가 발생하지만 매우 드물거나 간단한 작업일 경우 적합하다.

 

 

 

예제로 약 100명이 동시에 하나의 수업에 좋아요를 누른다고 가정해보자. 

 

1. 락을 사용하지 않는 경우

    /**
     * 락 없이 사용
     */
    @Transactional
    public void callWithoutLock(Long courseId) {
        courseService.updateCourseLike(courseId);
    }

 

그냥 똑같이 쓰면 된다.

 

 

2. 비관적 락 사용

 

[repository]

    /**
     * 비관적 락 사용
     */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM Course c WHERE c.id = :id")
    Optional<Course> findByIdWithPessimisticLock(@Param("id") Long id);

 

@Lock(LockModeType.PESSIMISTIC_WRITE) 이 추가되었다.

 

 

[service]

    /**
     * 비관적 락 사용
     */
    @Transactional
    public void callPessimistic(Long courseId) {
        // 1. 수업 엔티티 조회
        Course foundCourse = courseRepository.findByIdWithPessimisticLock(courseId)
                .orElseThrow(RuntimeException::new);

        // 2. like 증가
        foundCourse.increaseLike();
    }

 

 

3. 낙관적 락 사용

 

[course entity]

@Entity
@Getter
@Table(name = "course")
public class Course {

    ...
    
    // 버전 명시 필요
    @Version
    private Long version;

    ...
}

 

낙관적 락은 version으로 변경을 커밋할지 안 할지 결정하기 때문에 @Version 어노테이션을 붙인 변수가 필요하다.

 

 

[service]

    /**
     * 낙관적 락 사용
     */
    public int callOptimistic(Long courseId) {
        boolean success = false;
        int failureCount = 0; // 실패 횟수 기록

        while (!success) {
            try {
                // 1. 로직 수행: 좋아요 증가
                courseService.updateCourseLike(courseId);

                // 2. 성공처리
                success = true;
            } catch (ObjectOptimisticLockingFailureException e) {
                failureCount++;
            }
        }
        return failureCount;
    }

 

 

엄청나게 많은 실패 횟수가 찍히는 것을 볼 수 있다.

 

 

[테스트 코드]

    /**
     * 락없이 테스트
     * 테스트시 Course 엔티티에서 Version 주석 필요
     */
    @Test
    @DisplayName("락없이 테스트")
    public void testWithoutLock() throws InterruptedException {
        // given
        Course newCourse = Course.createNewCourse("noLockCourse");
        Course savedCourse = courseRepository.save(newCourse);

        // when
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            executorService.execute(() -> {
                try {
                    concurrencyService.callWithoutLock(savedCourse.getId());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        executorService.shutdown();

        // then
        Course updatedCourse = courseRepository.findById(savedCourse.getId()).get();
        System.out.println("Final like count (no lock): " + updatedCourse.getLikeCnt());
    }

    /**
     * 비관락 사용
     */
    @Test
    @DisplayName("비관락 테스트")
    public void testPessimistic() throws InterruptedException {
        // given
        Course newCourse = Course.createNewCourse("pessimisticCourse");
        Course savedCourse = courseRepository.save(newCourse);

        // when
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(100);

        for (int i = 0; i < 100; i++) {
            executorService.execute(() -> {
                try {
                    concurrencyService.callPessimistic(savedCourse.getId());
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        executorService.shutdown();

        // then
        Course updatedCourse = courseRepository.findById(savedCourse.getId()).get();
        System.out.println("Final like count (pessimistic): " + updatedCourse.getLikeCnt());
    }

    /**
     * 낙관락 테스트
     * 테스트시 Course 엔티티에서 Version 주석 활성화 필요.
     */
    @Test
    @DisplayName("낙관락 테스트")
    public void testOptimistic() throws InterruptedException {
        // given
        Course newCourse = Course.createNewCourse("optimistic");
        Course savedCourse = courseRepository.save(newCourse);
        AtomicInteger totalFailures = new AtomicInteger(0);

        // when
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(100);

        for (int i = 0; i < 100; i++) {
            executorService.execute(() -> {
                try {
                    int attempts = concurrencyService.callOptimistic(savedCourse.getId());
                    totalFailures.addAndGet(attempts);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        executorService.shutdown();

        // then
        Course updatedCourse = courseRepository.findById(savedCourse.getId()).get();
        System.out.println("Total attempts: " + totalFailures.get());
        System.out.println("Final like count (optimistic): " + updatedCourse.getLikeCnt());
    }
}