다양한 방법으로 데이터베이스의 동시성 처리하기

Dltmd202·2024년 8월 5일

현재상황

  • 워크스페이스라는 여러 유저들이 상호작용할 수 있는 공간이 있다.
  • 워크스페이스에 유저를 초대하는 것은 자유지만, ACTIVE 상태로 활성화 되어야 워크스페이스에서 활동할 수 있다.
  • 이 워크스페이스에는 플랜에 따라 ACTIVE 유저 인원수를 제한해야하는 요구사항이 있다.
    • BASIC 플랜에서는 10
    • ENTERPRISE 플랜에서는 50
  • 매번 count 쿼리를 날릴 수도 있지만, 워크스페이스를 조회할 때도 매번 count 쿼리를 해줘야 했기 때문에 비정규화를 거쳐 workspace 테이블에 beloning_number라는 인원수 칼럼을 유지하게 되었다.

워크스페이스에 유저를 활성화하는 로직

@Transactional
fun activateWorkspaceUser(
    userId: String,
    workspaceId: Long,
): WorkspaceUserDto {
    val user = userService.getById(userId)

    val workspace = workspaceService.getById(workspaceId)
    if (!workspace.isAvailToJoin()) throw BusinessException(StatusCode.INVALID_WORKSPACE_JOIN)

    val workspaceUser = workspaceUserService.getWorkspaceUser(workspace, user)
    if (workspaceUser.isActivated()) throw BusinessException(StatusCode.ALREADY_JOINED_WORKSPACE)

    notificationEventService.publishNotificationEvent(WorkspaceJoinedEvent(user, workspace))
    workspaceUserService.save(workspaceUser.activate())

    chatRoomUserService.save(chatRoomService.getDefaultChatRoomByWorkspaceId(workspaceId).addUser(user))
    workspaceService.save(workspace.increaseMember())

    return WorkspaceUserDto.of(user.email, workspaceUser)
}

평범한 비즈니스 로직이 담긴 코드인데 workspaceService.save(workspace.increaseMember()) 이 부분이 동시성 이슈가 터질 수 있는 레이스 컨디션이 발생할 수 있는 곳이다.

해당 부분만 나눠서 보면 이렇게 유저가 9명일 때, 두명의 유저가 가입 승인을 요청하여, 운이 좋지 않으면 11명이 가입될 수 있다.

때문에 동시성 이슈를 제어하였다.

동시성 이슈를 제어하는 방법

크게 두 가지 방법으로 생각해보았다.

  1. 실제 락을 잡고 다른 쓰레드에서 이 레코드에 접근하려할때 접근을 막는 방법
  2. 논리적인 로직으로 갱신 손실을 막는 방법

이를 또 세부적으로 나누어봤다.

  1. 실제 락을 잡고 다른 쓰레드에서 이 레코드에 접근하려할때 접근을 막는 방법
    1. 레디스와 같은 외부 시스템에서 분산락을 잡는 방법
    2. 데이터베이스레벨에서 베타락을 잡는 방법
  2. 논리적인 로직으로 갱신 손실을 막는 방법
    1. read-and-modify 주기를 원자적으로 실행하여 조회와 변경을 수행함으로써 레이스 컨디션을 제거한다.
    2. 버전 칼럼을 유지하여, 기존 버전과 같아야 변경이 같도록 하는 CAS 적용하기

하지만 아직 어떤 로직이 세부적으로 어떻게 빠를지 확신이 없었기 때문에 3가지만 추려서 개발하였다.

  • 버전 칼럼을 추가하는 것은 기존 테이블 구조가 바뀌기 때문에 시도하지 않았다.
  1. 레디스와 같은 외부 시스템에서 분산락을 잡는 방법
  2. 데이터베이스레벨에서 베타락을 잡는 방법
  3. read-and-modify 주기를 원자적으로 실행하여 조회와 변경을 수행함으로써 레이스 컨디션을 제거한다.

레디스와 같은 외부 시스템에서 분산락을 잡는 방법

/** 
  * 분산락을 잡는 부분
  * 직접 정의한 어노테이션에 AOP 처리
**/
@DistributedLock(domain = Workspace.DOMAIN_NAME)
@Transactional
fun activateWorkspaceUserWithDistributedLock(
    userId: String,
    /** 
      * 분산락에 사용할 레디스 키를 가져오기 위한 어노테이션
    **/
    @DistributedLockKey workspaceId: Long,
    
): WorkspaceUserDto {
    val user = userService.getById(userId)

    val workspace = workspaceService.getById(workspaceId)
    if (!workspace.isAvailToJoin()) throw BusinessException(StatusCode.INVALID_WORKSPACE_JOIN)

    val workspaceUser = workspaceUserService.getWorkspaceUser(workspace, user)
    if (workspaceUser.isActivated()) throw BusinessException(StatusCode.ALREADY_JOINED_WORKSPACE)

    notificationEventService.publishNotificationEvent(WorkspaceJoinedEvent(user, workspace))
    workspaceUserService.save(workspaceUser.activate())

    chatRoomUserService.save(chatRoomService.getDefaultChatRoomByWorkspaceId(workspaceId).addUser(user))
    workspaceService.save(workspace.increaseMember())

    return WorkspaceUserDto.of(user.email, workspaceUser)
}
  • redis는 데이터를 처리하는 메인이 되는 쓰레드가 싱글 쓰레드로 동작하여, 레디스를 활용하여 공유자원에 대해 분산락을 구현하기에 용이하다
  • 어노테이션을 만들어 이 어노테이션에 대해 분산락을 잡도록 AOP로 처리해주었다.

AOP를 이용해서 처리한 이유

1.HikariCP 커넥션을 낭비하지 않기 위해
- 처음에는 트랜잭션의 비즈니스 로직 안에서 분산락을 잡도록 했는데
- HikariCP의 커넥션을 물고 락을 기다리기에 커넥션을 낭비하는 것 같다는 피드백을 들었다.
- 때문에 트랜잭션이 시작되기 전에 분산락을 걸고 대기가 끝나서 락을 획득하면 트랜잭션을 시작하도록 했다.
2. MySQLREAPEATABLE_READ 고립성 수준의 특성 때문에
- MySQL은 기본 고립성 수준으로 REAPETABLE_READ를 지원한다.
- 이는 같은 트랜잭션 안에서 항상 일관된 읽기를 제공한다.
- 다른 트랜잭션에서 데이터를 수정하여도, 나의 트랜잭션 안에서는 반영되지 않는다.
- 때문에 이미 트랜잭션을 시작했다면, 다른 트랜잭션에서 유저의 수를 반영하고, 분산락을 얻어도 업데이트 된 유저 숫자가 반영되지 않은 워크스페이스를 조회할 수 없다.
- 이를 위해 @Transactional(isolation = Isolation.*READ_COMMITTED*) 이렇게 옵션을 주고 테스트를 해봤는데 정상적으로 동작함을 확인할 수 있었다.

데이터베이스레벨에서 베타락을 잡는 방법

@Transactional
fun activateWorkspaceUserWithPessimistic(
    userId: String,
    workspaceId: Long,
): WorkspaceUserDto {
    val user = userService.getById(userId)

		/** 
		  * 베타락이 잡힌 엔티티를 조회
		**/
    val workspace = workspaceService.getByIdWithPessimisticLock(workspaceId)
    if (!workspace.isAvailToJoin()) throw BusinessException(StatusCode.INVALID_WORKSPACE_JOIN)

    val workspaceUser = workspaceUserService.getWorkspaceUser(workspace, user)
    if (workspaceUser.isActivated()) throw BusinessException(StatusCode.ALREADY_JOINED_WORKSPACE)

    notificationEventService.publishNotificationEvent(WorkspaceJoinedEvent(user, workspace))
    workspaceUserService.save(workspaceUser.activate())

    chatRoomUserService.save(chatRoomService.getDefaultChatRoomByWorkspaceId(workspaceId).addUser(user))
    workspaceService.save(workspace.increaseMember())

    return WorkspaceUserDto.of(user.email, workspaceUser)
}

interface WorkspaceRepository : JpaRepository<WorkspaceEntity, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select w from Workspace w where w.id = :id")
    fun getLockedWorkspaceById(id: Long): WorkspaceEntity?
}
  • 로직에서 달라지는 부분은 거의 없지만, JPA 레포지토리에서 PESSIMISTIC_WRITE 락 모드로 조회하면 DB 레벨에서 베타락을 얻을 수 있다.
    • 실제로 쿼리레벨에서 select for update 구문을 이용하여 베타락을 얻는다.
  • 베타락을 잡으면 다른 커넥션에서 해당 레코드에 대한 쓰기 요청 뿐만 아니라 조회 요청까지 막히기 때문에 동시성 이슈를 제어할 수 있다.
  • 추가적으로 select for update로 베타락을 얻으면 단순히 베타락을 얻는 것 뿐만아니라, 고립성 수준에 관계없이 비반복 읽기로 레코드를 읽어오기 때문에 레디스를 이용해 분산락을 잡을 때와 같은 문제도 발생하지 않는다.

0개의 댓글