ACTIVE 상태로 활성화 되어야 워크스페이스에서 활동할 수 있다.ACTIVE 유저 인원수를 제한해야하는 요구사항이 있다.BASIC 플랜에서는 10명ENTERPRISE 플랜에서는 50명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명이 가입될 수 있다.
때문에 동시성 이슈를 제어하였다.
크게 두 가지 방법으로 생각해보았다.
이를 또 세부적으로 나누어봤다.
read-and-modify 주기를 원자적으로 실행하여 조회와 변경을 수행함으로써 레이스 컨디션을 제거한다.CAS 적용하기하지만 아직 어떤 로직이 세부적으로 어떻게 빠를지 확신이 없었기 때문에 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)
}
AOP를 이용해서 처리한 이유
1.HikariCP 커넥션을 낭비하지 않기 위해
- 처음에는 트랜잭션의 비즈니스 로직 안에서 분산락을 잡도록 했는데
- HikariCP의 커넥션을 물고 락을 기다리기에 커넥션을 낭비하는 것 같다는 피드백을 들었다.
- 때문에 트랜잭션이 시작되기 전에 분산락을 걸고 대기가 끝나서 락을 획득하면 트랜잭션을 시작하도록 했다.
2. MySQL의 REAPEATABLE_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?
}
PESSIMISTIC_WRITE 락 모드로 조회하면 DB 레벨에서 베타락을 얻을 수 있다.select for update 구문을 이용하여 베타락을 얻는다.select for update로 베타락을 얻으면 단순히 베타락을 얻는 것 뿐만아니라, 고립성 수준에 관계없이 비반복 읽기로 레코드를 읽어오기 때문에 레디스를 이용해 분산락을 잡을 때와 같은 문제도 발생하지 않는다.