개발/spring boot

동시성(명시적 락)

냥덕_ 2025. 2. 12. 11:05

명시적 락(Explicit Lock)

앞서 synchronized를 사용할 때 멀티 서버 환경에서의 동시성 제어 한계를 해결하기 위해서 나온 방법이 명시적 락이다. 애플리케이션 코드나 데이터베이스 쿼리에서 명확하게 락을 요청하여 특정 리소스에 대한 접근을 제어하는 방식이다.

아래는 대표적인 명시적 락의 종류이다. 하나씩 살펴보자

  • 낙관적 락(Optimistic Lock)
  • 비관적 락(Pessimistic Lock)
  • 네임드 락(Named Lock)

낙관적 락(Optimistic Lock)

낙관적 락은 어플리케이션 레벨에서 동시성을 제어하는 방식으로 충돌이 발생할 것이라고 '낙관적'으로 가정하고, 실제로 충돌이 발생했을 때만 대응하는 방법이다. 데이터베이스에 대한 변경이 드물게 발생하고, 충돌 가능성이 낮은 환경에서 유용한 방식이라고 한다. 

그 이유는 데이터를 읽을 때는 락을 걸지 않고 데이터를 업데이트할 때만 이전 데이터와 현재 데이터를 비교하여 충돌 여부를 판단하여 성능 저하를 최소화하기 때문이다. 하지만 충돌 시 재시도 하는 로직을 직접 처리해줘야 하는 번거로움이 있다.

 

낙관적 락을 구현하는 방법 중 하나는 버전 번호를 사용하는 것이다. 이를 위해서 JPA에서는 @Version을 통해 버전 관리를 쉽게 도와준다. 만약 JPA 를 사용하지 않는다면 직접 version을 관리해야 하는 추가적인 작업이 필요하다(편의성 JPA가 제공하는 기능을 활용해서 낙관적 락을 구현하겠습니다)

먼저 아래 처럼 Board 엔티티에 version을 추가해주자

@Version
private Long version;

 

아래는 서비스 코드이다. 앞서 말한대로 실패에 대한 재시도 로직을 아래처럼 개발자가 처리해야 한다. 

public void increaseViewCountOpticLock(Long boardId) throws InterruptedException {
    while (true) {
        try {
            Board board = boardRepository.getBoardById(boardId);
            board.increaseView();
            boardRepository.save(board);
            break; // 성공 시 종료
        } catch (OptimisticLockingFailureException e) {
            Thread.sleep(50); // 대기 시간 증가
        }
    }
}

 

참고로 이렇게 하면 update 시에 version을 조건절에 추가하여 업데이트를 시도하는데 이때 결과가 0으로 나온다면 업데이트 하려는 데이터가 없다는 것이며 이 말은 다른 트랜잭션에서 해당 데이터를 수정했다는 의미이기도 하다. 이때  OptimisticLockingFailureException이 발생하고 이를 catch 영역에서 sleep을 주면서 다시 시도하게 된다.

update board set title=?,version=?,view=? where id=? and version=?

 

비관적 락(Pessmistic Lock)

데이터의 충돌 가능성이 높다고 가정하고 우선 Lock을 거는 방법이며 DB의 Lock 기능을 이용한다. 주로 select ... for update 구문을 활용하며 DB 레벨에서 직접 락을 걸어 강제적으로 동시 접근을 제한한다.

SELECT FOR UPDATE
SELECT ... FOR UPDATE는 해당 레코드에 배타락(Exclusive Lock)을 거는 문법이다. 락이 걸린 레코드에는 다른 트랜잭션이 해당 레코드에 대한 수정을 할 수 없으며(단순 조회는 가능) 해당 트랜잭션이 commit 또는 rollback을 한 이후에 다른 트랜잭션이 접근이 가능하다. 또한 다른 트랜잭션이 해당 레코드에 대한 공유 락, 배타 락 둘 다 획득 할 수 없다.

 

간단하게 두개의 세션을 열고 트랜잭션을 열어서 비교해자. 먼저 아래 쿼리로 A 트랜잭션에서 FOR UPDATE로 조회를 하고 B 트랜잭션에서 단순 조회를 하면 이미지 처럼 X락을 걸어도 조회 결과가 정상적으로 나오는 것을 알 수 있다.

//A 트랜잭션
START TRANSACTION;
SELECT * FROM board WHERE id = 1 FOR UPDATE;

//B 트랜잭션
START TRANSACTION;
SELECT * FROM board WHERE id = 1;

 

 

아래 쿼리에서 1 -> 2 -> 3 -> 4 순서로 쿼리가 실행 된다고 하면 A 트랜잭션에서 FOR UDPATE 쿼리로 X 락을 걸어 결과적으로 4번 쿼리를 실행해도 아래 이미지처럼 대기 상태인 것을 볼 수 있다.

참고로 4번의 쿼리가 FOR UPDATE가 아닌 FOR SHARE S 락 방식으로 조회를 해도 똑같이 대기 상태에 걸린다.

//A 트랜잭션
1 - START TRANSACTION;
2 - SELECT * FROM board WHERE id = 1 FOR UPDATE;

//B 트랜잭션
3 - START TRANSACTION;
4 - SELECT * FROM board WHERE id = 1 FOR UPDATE;

 

어플리케이션 단에서 비관적 락을 구현하는 방법은 쉽다. 저 쿼리를 그대로 활용하면 된다. 

먼저 JPA 방식은 아래 방식으로 구현이 가능하다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT b FROM Board b WHERE b.id = :boardId")
Board findByIdForUpdate(long boardId);
    @Transactional
    public void increaseViewCountPessmisticLock(final Long boardId) {
        Board board = boardRepository.findByIdForUpdate(boardId);
        board.increaseView();
    }

 

JDBC 방식은 아래와 같다.

@Transactional
public Board findByIdForUpdate(long boardId) {
    return jdbcTemplate.queryForObject("select * from board where id = ? for update", new BoardRowMapper(), boardId);
}
@Transactional
public void increaseViewCountPessmisticLockByJdbc(final Long boardId) {
    Board board = boardDao.findByIdForUpdate(boardId);
    boardDao.updateViewCount(boardId);
}

 

사실 JDBC 사용할 때 저런식으로 해도 되지만 이전 포스팅에서 말했듯이 MySQL에서는 UPDATE 쿼리를 수행할 때 자동으로 베타락을 획득해서 처리한다. 따라서 굳이 FOR UPDATE를 사용하지 않아도 된다. 

 

네임드 락(Named Lock)

데이터베이스 레코드가 아니라 특정 이름(문자열)에 대해 걸리는 락을 활용하는 것이 네임드 락 방식이다. 아래 그림을 보면 쉽게 이해가 된다. 

이미지 처럼 세션들이 일정 시간동안 특정 문자열에 대한 Lock을 획득해서 처리하는 방식이며 획득 후 쿼리를 수행한 후 Lock을 반환하고 다른 세션이 다시 획득하여 처리한다.

 

 

JPA를 활용해서 네임드 락을 구현하는 방법은 아래와 같다.

public interface LockRepository extends JpaRepository<Board, Long> {
    @Query(value = "SELECT get_lock(:boardId, 60)", nativeQuery = true)
    int getLock(String boardId);

    @Query(value = "SELECT release_lock(:boardId)", nativeQuery = true)
    void releaseLock(String boardId);
}

 

먼저 nativeQuery를 사용해서 MySQL 기준 get_lock을 활용하여 락을 획득하는 메소드와 release_lock을 통해 해제하는 메소드를 만들어준다. 

락을 획득하는데 성공한다면 1을 반환하고 실패한다면 0을 반환한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class NamedLockFacade {
    private final LockRepository lockRepository;
    private final BoardService boardService;

    public void increaseViewCountNamedLock(final Long boardId) {
        try {
            if (lockRepository.getLock(String.valueOf(boardId)) != 1) {
                log.info("락 획득 실패");
            }
            boardService.incView(boardId);
        } finally {
            lockRepository.releaseLock(String.valueOf(boardId));
        }
    }
}

 

public class BoardService {
	...
    @Transactional
    public void incView(long boardId) {
        Board board = boardRepository.getBoardById(boardId);
        board.increaseView();
    }
}

 

코드를 보면 기존이랑은 좀 다르다. 

기존에는 BoardService에서 동시성 처리랑 로직을 같이 구현했는데 네임드락은 조금 다르다. 그 이유는 @Transaction 때문이다.

만약에 아래와 코드 처럼 하나의 트랜잭션 안에서 처리를 한다고 하면 다음과 같은 순서로 진행된다.

  • 트랜잭션 시작
  • 락 획득
  • 조회 후 엔티티값 변경
  • 락 해제
  • 커밋

위 순서로 동작하게 되는데 이렇게 될 경우 트랜잭션이 커밋되기 전에 다른 트랜잭션이 동일한 데이터를 조회하면, 변경 전 데이터를 볼 가능성이 존재한다. 

따라서 다른 클래스에서 락을 획득하고 난 후에 트랜잭션을 얻어 조회수를 증가 처리를 하도록 해야한다.

@Transactional
public void incView(long boardId) {
    try {
        if (lockRepository.getLock(String.valueOf(boardId)) != 1) {
            log.info("락 획득 실패");
        }
        Board board = boardRepository.getBoardById(boardId);
    	board.increaseView();
    } finally {
        lockRepository.releaseLock(String.valueOf(boardId));
    }
}

 

 

명시적 락의 한계

낙관적 락, 비관적 락, 네임드 락과 같은 명시적 락은 단일 DB 환경에서는 효과적인 동시성 제어 방식이다.

하지만 분산 DB 환경에서는 락 정보가 노드 간에 공유되지 않기 때문에,
한 노드에서 락을 걸어도 다른 노드에서 이를 인지하지 못해 동시성 문제가 발생할 수 있다.

'개발 > spring boot' 카테고리의 다른 글

동시성(synchronized)  (3) 2025.01.04
전략 패턴과 팩토리 메소드 패턴 리팩토링  (2) 2025.01.03
[Spring] Quartz 도입기 2  (0) 2024.11.21
[Spring] Quartz 도입기 1  (1) 2024.10.31
[Spring] IoC, DI  (0) 2024.04.24