[문제 해결] 동시성 처리 기법 비교와 도입기

neo-the-cow·2024년 5월 25일

문제 해결

목록 보기
1/4
post-thumbnail

실시간 다중 채팅 서비스 프로젝트를 진행하며 겪었던 문제와 해결과정을 기록합니다.

1. 문제 상황과 원인 분석

  • 채팅 방 입장을 수행하는 로직을 의사코드로 표현하면 다음과 같습니다.
public class SimpleChatRoomService {
	...
    @Override
    @Transactional
    public Room enterRoom(식별자_자료형 roomId, 요청_모델 request) {
    	1. 데이터베이스로부터 채팅 방 엔티티를 불러온다. 없다면 예외 발생.
        2. 채팅 방에 입장할 수 있는지 검사 수행.
          2.1 수용인원 검증
          2.2 비밀번호 검증
        3. 채팅 방 입장 처리
          3.1 room.attending++;
          2.3 persis(new 참여자_엔티티));
    }
    ...
}
  • SpringMVC는 멀티 쓰레드를 기반으로 요청 당 한 개 쓰레드를 할당해서 작업을 처리합니다
  • 때문에 순간적으로 요청이 집중되면 공유자원에 대한 Lost Update가 발생할 수 있습니다.

  • 위의 예시에서 채팅 방 123의 최대 참여 가능 인원은 2명이지만 두개의 쓰레드가 한꺼번에 접근합니다.
  • Room 엔티티의 attending 필드는 2가 되지만 실제 데이터베이스 참여자 테이블에는 호스트를 포함한 3개 레코드가 생성되는 이상현상(Lost Update)이 발생합니다.

2. 문제 해결

2.1 해결 방안 1 - synchronized block

  • 채팅 방 입장을 담당하는 로직에 synchronized block을 설정해 최초 접근 쓰레드만 로직 수행을 허용하고 나머지 쓰레드는 대기하도록 만들 수 있습니다.
public class SimpleChatRoomService {
	...
    @Override
    @Transactional
    public synchronized Room enterRoom(식별자_자료형 roomId, 요청_모델 request) {
		... 로직 수행 ...
    }
    ...
}
  • 하지만 sychronized block으로임계영역을 설정한다고 해도 문제를 해결 할 수 없는데 원인은 다음과 같습니다.

@Transactional 프록시의 상속 대상 범위 문제

  • @Transactional 어노테이션이 붙은 메서드를 Spring AOP를 이용한 동작 방식이 적용됩니다. 이 때, 동작 시퀀스를 의사코드로 나타내면 다음과 같습니다.
    public class SimpleChatRoomServiceProxy extends SimpleChatRoomService{
        ...
        @Override
        publci Room enterRoom(식별자_자료형 roomId, 요청_모델 request) {
            try {
                1. 트랜잭션 시작
                2. super.enterRoom(roomId, request); // 임계 영역
                3. 트랜잭션 커밋
            } catch (예외 e) {
                1. 예외 처리 및 트랜잭션 롤백
            }
        }
        ...
    }
  • SimpleChatRoomService.enterRoom()을 상속받은 프록시를 이용해 동작하는데 synchronized 키워드는 메서드 시그니처가 아니라 상속대상이 아닙니다.
  • 따라서 트랜잭션이 반영되기 전 임계영역을 벗어나 다른 트랜잭션이 시작될 수 있습니다.

요청을 수행하는 API 서버가 다중화 됐을 때

  • 임계영역의 범위를 @Transactional 어노테이션으로 적용된 프록시 메서드보다 크게 적용해도 현재 실행중인 프로세스의 범위를 벗어날 수 없습니다.
  • 요청을 수행하는 서버가 2대 이상인 경우 다른 서버 프로세스에서 수행하는 동시 요청에 대해 동시성 처리가 불가능합니다.

2.2 해결 방안 2 - 낙관적 락과 FK 제거

  • 낙관적 락은 트랜잭션간 충돌이 발생하지 않을 거라 가정하고 DB로의 쓰기 요청 결과로 충돌 여부를 판단합니다.
  • 트랜잭션을 수행하고 커밋할 때 타깃 레코드의 버전 정보를 바탕으로 충돌 여부를 반환하고 JPA는 충돌이 발생하면 예외를 던집니다.

MySQL 공식문서 참조: https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html

  • 공식 문서에 따르면 MySQL은 내부적으로 FK가 걸린 테이블에 쓰기 작업이 발생하는 경우 제약조건 검사를 위해 참조하는 값을 가진 레코드에 공유 락을 설정하고 UPDATE시 해당 레코드에 배타 락을 설정합니다.
  • 같은 레코드에 락을 두 번 설정하기 때문에 데드락이 발생합니다.
  • 문제를 해결하기위해 JPA레벨에서 관계만 표현하고 실제 데이터베이스에 FK는 설정하지 않았습니다.
  • 낙관적 락은 JPA에서 지원하는 @Version, @Lock 어노테이션으로 적용할 수 있습니다.
// 엔티티
public class Room {
	...
	@Version
	private Long version;
	...
}

---

// 레포지토리
public interface ChatRoomRepository extends JpaRepository<Room, Long> {
	...
	@Lock(value = LockModeType.OPTIMISTIC)
 	@Query("select room" +
          " from Room room" +
          " join fetch room.participants participant" +
          " where room.id = :id" +
              " and room.removedAt is null" +
              " and participant.removedAt is null")
	Optional<Room> findByIdFetchJoinParticipantWithOptimisticLock(Long roomId);
	...
}
  • 낙관적 락을 적용한 메서드를 사용할 때 반드시 충돌로 인한 예외 처리를 구현해야 합니다.
  • 충돌이 발생하지 않고 처리가 완료될 때 까지 반복 요청을 수행하는 동작을 의사코드로 나타내면 다음과 같습니다.
public class SimpleChatRoomService extends ChatRoomService {
	...
	@Override
	@Transactional
	public Room enterRoom(식별자_자료형 roomId, 요청_모델 request) {
		while (true) {
			try {
				... 동작 수행 및 응답 반환 ...
			} catch (ObjectOptimisticLockingFailureException exception) {
				... 일정 시간 대기 or 예외 로직 수행 ...
			}
		}
	}
	...
}

2.3 해결 방안 3 - 비관적 락

  • 비관적 락은 트랜잭션을 시작하며 락 옵션에 의해 공유 락 또는 배타 락을 얻고 시작하는 방식입니다.
  • 충돌이 발생할 것이라 가정하고 트랜잭션을 시작하기 때문에 한 쓰레드가 락을 획득하면 나머지 쓰레드는 대기하는 방식 입니다.
  • 비관적 락은 JPA에서 지원하는 방법은 @Lock 어노테이션으로 간단히 적용할 수 있었습니다.
public interface ChatRoomRepository extends JpaRepository<Room, Long> {
	...
	@Lock(value = LockModeType.PESSIMISTIC_WRITE)
	@Query("select room" +
          " from Room room" +
          " join fetch room.participants participant" +
          " where room.id = :id" +
              " and room.removedAt is null" +
              " and participant.removedAt is null")
	Optional<Room> findByIdFetchJoinParticipantWithOptimisticLock(Long roomId);
	...
}
  • 락을 획득할 때 까지 대기하기 때문에 별도의 충돌 예외 처리 로직을 구현하지 않아도 되고 단일 데이터베이스를 운용하는 경우 강력한 동시성 처리 수단이 될 수 있습니다.
  • 하지만 락을 획득하지 못한 스레드가 많아질 수록 유휴/대기 상태의 스레드가 많아져 처리량을 저해하는 문제가 발생할 수 있습니다.

3. 적용한 방법 - 비관적 락

  • 비관적 락을 적용했습니다.
  • 특정 인기 채팅 방에 입장하려는 경우 동시에 요청이 집중되는 상황에 충돌이 발생할 여지가 있습니다.
  • 낙관적 락의 재시도 처리과정에서 재시도와 예외처리 로직의 정교한 튜닝이 없다면 무한정 대기하거나 DB로의 요청 수가 많아져 오히려 DB레벨의 부하가 증가할 수 있습니다.
  • Redis나 다른 Lock Storage를 도입해 어플리케이션 레벨에서 락을 잡고 원자적으로 처리하는 방법도 있었습니다.
  • 하지만 단일 DB를 사용하는 환경이고 Lock Storage 도입으로 새로운 인프라 환경을 추가하는 것은 적절하지 못하다 생각했습니다. - v0.0.1 분산 락 적용 참고
profile
Hi. I'm Neo

0개의 댓글