👉 문제 상황
- 최초 로그인(회원가입) 진행 시, 같은 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();
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)
📌 정의
- 데이터에 대한 변경을 예상하지 않고 락 없이 작업을 진행
- 작업 완료 시점에 실제 데이터 변경이 이루어졌는지 확인하고 변경이 없다면 커밋
- 변경이 있었다면 충돌이 발생한 것으로 판단하고 롤백하거나 다시 시도
📌 적용 과정
📌 적용 결과
- 기존 오류와 오류 내용이 변경됨
- 기존에는 IncorrectResultSizeDataAccessException 발생
- List로 받지 않는 값에 return이 2개 이상 발생되는 내용의 오류가 발생했었음
- 이번에는 DataIntegrityViolationException 발생
- 데이터베이스의 무결성 제약 조건에 위반될 때 발생하는 오류
- 유니크 제약 조건이 있는 컬럼에 동일한 값을 가진 두 개 이상의 레코드를 삽입하려고 할 때 발생하는 오류
- steamId 컬럼에 unique는 걸어야 하는 상태여서 건 상태로 시도
- 하지만 앞의 상황과 동일하게 같은 요청이 이미 2개가 들어간 상태
- 중복 저장은 안 되지만 id가 요청 순서대로 적용되어 먼저 save된 요청의 id를 따라감
- 결국 두 번째 요청이 먼저 save 되면 id는 2로 적용됨
👉 synchronized
적용
📌 정의
- 해당 메서드는 동시에 하나의 스레드만 실행할 수 있게 됨
- 즉 해당 메서드에 대한 동시 접근이 제한되어 여러 스레드가 동시에 해당 메서드를 실행할 수 없게 되는 것
📌 적용 과정
@Transactional
public synchronized void create(UserInfoResponse userInfo) throws IllegalAccessException {
}
📌 적용 결과
- 동시에 두 개의 요청이 들어가도 하나만 처리되기 때문에 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();
if(userRepository.findForUpdateBySteamId(steamId).isPresent()) {
throw new IllegalArgumentException("User with this steamId already exists");
}
}
📌 적용 결과
- synchronized 때문에 요청은 하나씩 들어오고 요청이 시작되면 비관적 락으로 다른 요청이 들어오지 않게 막혀 있어서 중복 steamId가 있는지 확인하는 과정에서 의도한 대로 오류 발생
- ⇒ 원하는 대로 동시 요청이 들어와도 DB Id가 순차적으로 생성
📌 주의할 점
- 성능 문제 외에도 분산 시스템이나 다중 인스턴스 환경에서 문제가 발생할 수 있음
👉 결론
- DB Id의 순차를 유지하는 게 그렇게 중요한 이슈는 아님
- 중간에 회원이 삭제되거나 여러 상황에 노출되면 순차가 유지되지 않을 확률이 높음
- 성능을 포기하면서까지 적용해야 하는 것은 아님
- 결국 Id의 순차를 포기하고 steamId 컬럼에 unique 설정 및 오류 페이지 적용으로 마무리