회원가입 동시성 문제 해결

LeeYulhee·2023년 10월 12일
0

👉 문제 상황


  • 최초 로그인(회원가입) 진행 시, 같은 Steam ID로 동시에 가입을 하면 DB에 중복 회원 정보가 두 개 생성됨
    • 다만 id 값은 다르기 때문에 내부 내용은 동일하고, 다른 사람으로 생성되는 상황
    • 해당 회원이 사이트를 이용할 수 없는 오류 발생
  • ⇒ 동시성 이슈로 짐작하고 문제 해결을 위해 조사



👉 문제 접근


  • 동시성 이슈를 해결하기 위해 조사했을 때 찾은 해결 방법
    • 중복되면 안 되는 column에 unique 설정
    • 비관적 락/낙관적 락
    • synchronized를 이용하여 스레드 여러 개의 동시 접근을 제한하고 하나만 실행시킴



👉 unique 설정 + 오류 페이지 적용


  • Steam ID에 해당하는 값이 중복될 수 없기 때문에 요청이 동시에 들어와도 먼저 save된 회원 정보만 저장되고 나중에 들어온 요청에는 오류 반환
  • 아쉬운 점은 첫 번째 요청이 save가 늦게 된 경우 DB에 id가 2부터 시작하게 됨
    • ⇒ 문제는 아니지만 동시성 이슈에 대해 공부하기 위해 해결할 수 있는 방법이 있는지 실험



👉 비관적 락(Pessimistic Locking) 적용


📌 정의

  • 데이터에 대한 변경을 예상하고 미리 락을 걸어 다른 트랜잭션의 접근을 차단
  • SELECT ... FOR UPDATE 문장을 사용하여 DB 레코드에 락을 걸 수 있음

📌 적용 과정

  • 일단, save 자체에 비관적 락을 걸 수는 없었음
    • JPA에서 비관적 락은 EntityManager나 Repository를 통해 설정해야 함
    • save 자체에 직접 락을 거는 것은 Spring Data JPA의 기본 Repository에서 제공하는 기능이 아님
  • save에 걸 수 없어서 회원 생성하는 메서드 시작에 이미 존재하는 steamId인지 확인하는 로직을 추가하고 해당 로직에 비관적 락을 적용
    • Repository
      @Lock(LockModeType.PESSIMISTIC_WRITE)
      Optional<Entity> findForUpdateSteamId(Long id);
      • 메서드에 ForUpdate를 작성하는 것만으로 비관적 락이 적용되지는 않고 어노테이션을 적용해줘야 함
        • ForUpdate를 작성하는 이유는 두 가지로 추측
          • 다른 메서드 등에서 해당 메서드를 사용하면 거기서도 락이 걸리기 때문에 오류 발생이나 지연 등의 문제가 생길 수 있어 메서드 분리
          • FOR UPDATE는 SQL의 일반적인 구문 중 하나로, 트랜잭션 내에서 해당 레코드에 대한 비관적 락을 적용하려고 할 때 사용하기 때문에 해당 메서드의 이름을 보면 비관적 락을 적용한다는 걸 다른 개발자 등에게 쉽게 전할 수 있음
    • Service
      @Transactional
      public void create(UserInfoResponse userInfo) {
        String steamId = userInfo.getResponse().getPlayers().get(0).getSteamid();
      
        // 비관적 락을 사용하여 steamId가 이미 존재하는지 확인
        if(userRepository.findForUpdateSteamId(steamId).isPresent()) {
            throw new IllegalArgumentException("User with this steamId already exists");
        }
      
        // ... 나머지 회원 생성 로직
      }

📌 적용 결과

  • 어차피 회원 가입 요청이 save 되기 전에 발생하는 거라 steamId가 이미 존재하는지 확인하는 로직을 적용해도 의미는 없었음

📌 주의할 점

  • 동시성을 제한하기 때문에 트랜잭션의 대기 시간이 늘어나고, 이로 인해 시스템의 전반적인 성능이 저하될 수 있음
  • 두 개 이상의 프로세스나 스레드가 서로 다른 리소스의 락을 기다리면서 상호 블록되는 데드락(Deadlock)이 발생할 수 있음
    • 아래 4가지 조건이 만족되면 발생
      • 상호 배제 (Mutual Exclusion)
        • 한 번에 한 프로세스/스레드만이 리소스를 사용할 수 있어야 함
      • 점유와 대기 (Hold and Wait)
        • 프로세스/스레드가 이미 어떤 리소스를 점유하고 있으면서 다른 리소스를 대기하는 상황이어야 함
      • 비선점 (No Preemption)
        • 다른 프로세스/스레드가 이미 점유하고 있는 리소스를 강제로 뺏어오지 못함
      • 순환 대기 (Circular Wait)
        • 프로세스/스레드 간에 순환 구조로 리소스를 대기하게 되는 상황이 발생해야 함



👉 낙관적 락(Optimistic Locking)


📌 정의

  • 데이터에 대한 변경을 예상하지 않고 락 없이 작업을 진행
  • 작업 완료 시점에 실제 데이터 변경이 이루어졌는지 확인하고 변경이 없다면 커밋
  • 변경이 있었다면 충돌이 발생한 것으로 판단하고 롤백하거나 다시 시도

📌 적용 과정

  • @Version 어노테이션을 엔티티에 적용
    @Entity
    public class User {
    
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      private Long id;
      
      private String username;
      
      @Version
      private int version;
      
      // ... 기타 필드 및 메서드
    }
    • 보통 int 또는 long 타입의 변로 version 선언
    • 해당 변수에 @Version 어노테이션을 붙여 JPA에게 이 변수를 버전 관리 필드로 사용하도록 지시
      • Version의 용도
        • 특정 필드의 값이 변경될 때마다 버전 번호를 자동으로 증가시켜 충돌을 감지

📌 적용 결과

  • 기존 오류와 오류 내용이 변경됨
    • 기존에는 IncorrectResultSizeDataAccessException 발생
      • List로 받지 않는 값에 return이 2개 이상 발생되는 내용의 오류가 발생했었음
    • 이번에는 DataIntegrityViolationException 발생
      • 데이터베이스의 무결성 제약 조건에 위반될 때 발생하는 오류
      • 유니크 제약 조건이 있는 컬럼에 동일한 값을 가진 두 개 이상의 레코드를 삽입하려고 할 때 발생하는 오류
        • steamId 컬럼에 unique는 걸어야 하는 상태여서 건 상태로 시도
  • 하지만 앞의 상황과 동일하게 같은 요청이 이미 2개가 들어간 상태
    • 중복 저장은 안 되지만 id가 요청 순서대로 적용되어 먼저 save된 요청의 id를 따라감
    • 결국 두 번째 요청이 먼저 save 되면 id는 2로 적용됨



👉 synchronized 적용


📌 정의

  • 해당 메서드는 동시에 하나의 스레드만 실행할 수 있게 됨
  • 즉 해당 메서드에 대한 동시 접근이 제한되어 여러 스레드가 동시에 해당 메서드를 실행할 수 없게 되는 것

📌 적용 과정

@Transactional
public synchronized void create(UserInfoResponse userInfo) throws IllegalAccessException {
	// 회원 생성 로직
}
  • 메서드에 synchronized 적용

📌 적용 결과

  • 동시에 두 개의 요청이 들어가도 하나만 처리되기 때문에 id가 1번으로 시작하고 2번 요청은 중복 steamId가 있는지 검증하는 과정에서 오류 발생(unique 컬럼)
    • ⇒ 원하는 방향 달성
  • 하지만 다음 회원 가입 요청이 들어왔을 때, 2번 요청이 들어왔다가 save 되지 않은 거라서 1번 id 다음 3번으로 저장됨
    • ⇒ 비슷한 상황에 직면

📌 주의할 점

  • 한 번에 하나의 스레드만 실행하다 보니 성능이 저하될 수 있음
  • 애플리케이션의 사용자 수가 증가하면 synchronized로 인해 발생하는 대기 시간이 늘어날 수 있고, 이는 시스템 확장성에 영향을 미칠 수 있음
  • JVM 레벨의 락을 사용하기 때문에, 분산 시스템이나 다중 인스턴스 환경에서는 효과적인 동기화를 제공하지 못함



👉 Id를 순차적으로 유지하기 위해서 고안한 방법


📌 적용 과정

  • 만약 Id를 순차적으로 유지하는 게 더 중요해서 성능을 고려하지 않는 경우라면 synchronized와 비관적 락을 동시에 적용하여 id의 순차를 유지할 수 있음
    • Repository
      @Repository
      public interface UserRepository extends JpaRepository<User, Long> {
      
          @Lock(LockModeType.PESSIMISTIC_WRITE)
          Optional<User> findForUpdateBySteamId(String steamId);
      		
      		// ... 나머지 메서드
      }
    • Service
      @Transactional
      public synchronized void create(UserInfoResponse userInfo) throws IllegalAccessException {
      
          String steamId = userInfo.getResponse().getPlayers().get(0).getSteamid();
      
          // 비관적 락을 사용하여 steamId가 이미 존재하는지 확인합니다.
          if(userRepository.findForUpdateBySteamId(steamId).isPresent()) {
              throw new IllegalArgumentException("User with this steamId already exists");
          }
      
      		// ... 나머지 회원 생성 로직
      }

📌 적용 결과

  • synchronized 때문에 요청은 하나씩 들어오고 요청이 시작되면 비관적 락으로 다른 요청이 들어오지 않게 막혀 있어서 중복 steamId가 있는지 확인하는 과정에서 의도한 대로 오류 발생
  • ⇒ 원하는 대로 동시 요청이 들어와도 DB Id가 순차적으로 생성

📌 주의할 점

  • 성능 문제 외에도 분산 시스템이나 다중 인스턴스 환경에서 문제가 발생할 수 있음



👉 결론


  • DB Id의 순차를 유지하는 게 그렇게 중요한 이슈는 아님
    • 중간에 회원이 삭제되거나 여러 상황에 노출되면 순차가 유지되지 않을 확률이 높음
    • 성능을 포기하면서까지 적용해야 하는 것은 아님
  • 결국 Id의 순차를 포기하고 steamId 컬럼에 unique 설정 및 오류 페이지 적용으로 마무리
profile
공부 중인 신입 백엔드 개발자입니다

0개의 댓글