@Transactional은 동시성 문제를 해결해주지 않는다.

준하·2024년 12월 26일
0

회원가입 로직

@Service
@RequiredArgsConstructor
public class RegisterUserService implements RegisterUserUseCase {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public void register(RegisterCommand command) {
        if(userRepository.existsByUsername(command.getUsername())) {
            throw new DuplicateUsernameException(ErrorCode.DUPLICATE_USERNAME, "이미 존재하는 유저 이름입니다.");
        } //이름이 같은 유저가 존재할 경우, 예외를 던진다.

        User user = new User(command.getUsername(), command.getPassword());

        userRepository.save(user);
    }
}

회원가입을 처리하는 RegisterUserService 구현체이다.

회원가입 로직은,
중복된 유저 이름이 발생하지 않도록 하기 위해

  1. 데이터베이스에 같은 이름이 존재하는지 확인한다.
  2. 존재한다면, 예외를 발생시킨다. 이 예외는 @ControllerAdvice에서 캐치되어 클라이언트에게 에러 응답을 내려준다.
  3. 존재하지 않는다면, 데이터베이스에 저장한다.

간단해보이는 이 코드는 의도대로 중복된 이름의 회원가입을 방지하며 제대로 동작할까?

JMeter를 통해 10개의 스레드로 동시요청을 보내보았다.

의도대로라면, 10개의 동일한 요청 중 첫번째 요청만 성공하고 나머지는 실패해야하지만,

위와 같이 10개 중 3개의 요청이 성공하는 것을 확인할 수 있다.


원인

@Override
@Transactional
public void register(RegisterCommand command) {
    if(userRepository.existsByUsername(command.getUsername())) {
        throw new DuplicateUsernameException(ErrorCode.DUPLICATE_USERNAME, "이미 존재하는 유저 이름입니다.");
    }
    /**
     이 지점에 여러 스레드가 동시에 진입할 수 있다.
    */
    User user = new User(command.getUsername(), command.getPassword());

    userRepository.save(user);
}

문제의 원인은 첫번째 userRepository.save(user) 가 호출되기 이전에,
동시에 여러 스레드에서 userRepository.existsByUsername() 조건문을 통과할 가능성이 있다는 점이다.

부끄럽지만 지금까지 @Transactional 어노테이션이 동시성 문제를 해결해준다고 생각했다.
(ACID 중 Isolation 특성이, 한 트랜잭션에서 메서드를 호출하면 다른 트랜잭션이 호출할 수 없게 '고립' 시킨다고 생각했다라나 뭐라나..)


해결방안

엔티티의 unique 제약조건

@Entity
public class User {
	//...
    
    @Column(nullable = false, unique = true)
    private String username;
}

위와 같이 Entity의 username 컬럼에 unique constraint를 걸어준다. 그러면 여러 스레드가
동시에 조건문을 통과하더라도, 데이터베이스에 저장하는 시점에 제약조건에 의해 단 하나의 스레드만 성공하게 된다.

테스트 결과, 단 하나의 요청만 OK 응답을 받았다.

하지만 위와 같이 실패 응답의 status code가 서로 다른 문제가 발생한다.

이는 여전히 일부 스레드는 조건문을 통과하여 핸들링하지 않은 예외(유니크 제약조건을 위반할 시)를 발생시키기 때문이다.

사실 unique 제약 조건을 건 순간, 앞단의 조건문은 의미가 없다. 따라서 코드를 다음과 같이 개선하였다.

@Service
@RequiredArgsConstructor
public class RegisterUserService implements RegisterUserUseCase {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public void register(RegisterCommand command) {
        try {
            userRepository.save(
                    new User(command.getUsername(), command.getPassword())
            );
        } catch (DataIntegrityViolationException e) {
            throw new DuplicateUsernameException(ErrorCode.DUPLICATE_USERNAME, "이미 존재하는 유저 이름입니다.");
        }
    }
}

곧바로 데이터베이스에 저장 쿼리를 날리고, 유니크 제약조건 위반 예외가 발생하면, 애플리케이션의 예외로 변환하는 방식이다.

그렇게 다시 한 번 테스트를 시도했는데..

또 다른 문제

엥? 어째서인지 예외가 캐치되지 않고, 콘솔에 그대로 스택 트레이스가 출력되었다.

DataIntegrityViolationException 이 아닌가? 싶어서
Exception 으로 바꿔서 모든 예외를 캐치하도록 했음에도, 여전히 콘솔에 스택 트레이스가 출력되었다.

원인

ChatGPT에게 이유를 물어보았고 여러 답변이 나왔다. 그 중 위 답변을 보며 JPA의 영속성 컨텍스트(Persistence Context)를 떠올리게 되었다.

@Transactional 어노테이션을 통해 영속성 컨텍스트(캐시) 내에서 작업이 이루어지게 되고, 실제 DB에 쿼리가 날아가는 flush()가 호출되는 시점은 트랜잭션이 종료되는 시점이다.

@Override
@Transactional
public void register(RegisterCommand command) {
    try {
        userRepository.save(
                new User(command.getUsername(), command.getPassword())
        ); //여기서 실제 DB에 쿼리를 날리지 않는다. 
    } catch (DataIntegrityViolationException e) {
        throw new DuplicateUsernameException(ErrorCode.DUPLICATE_USERNAME, "이미 존재하는 유저 이름입니다.");
    }
    
    //여기서 DB에 쿼리가 날아간다. 따라서 예외가 캐치되지 않는다.
}

userRepository.save() 를 하는 것은 엔티티를 persist 상태로 만드는 행위이며 이는, 엔티티 매니저에의해 관리되는 영속 상태를 만드는 것을 의미한다.

따라서 이 시점에는 DB에 쿼리를 날리지 않아, 유니크 제약조건 위반이 확인되지 않는다.

개선

@Override
@Transactional
public void register(RegisterCommand command) {
    try {
        userRepository.save(
                new User(command.getUsername(), command.getPassword())
        );
        userRepository.flush(); //try-catch 문 내부에서 flush()를 진행
    } catch (DataIntegrityViolationException e) {
        throw new DuplicateUsernameException(ErrorCode.DUPLICATE_USERNAME, "이미 존재하는 유저 이름입니다.");
    }
}

위와 같이 try-catch문 내부에서 곧바로 flush()를 호출하는 방식으로 수정하였다.

콘솔에 스택 트레이스가 출력되지 않고, 유니크 제약조건 위반 예외가 애플리케이션의 예외로 변환되어 제대로 핸들링 된 것을 확인할 수 있다!


역할과 책임

의문점

해결은 했으나 아직 여러 의문이 들었다.

이렇게 엔티티의 unique constraint를 통해 해결하는 것이 과연 적절할까?

'중복된 username은 허용하지 않는다' 는 것은 비즈니스의 규칙이다.
따라서 도메인 레이어(서비스)에서 이를 다룰 책임이 있다고 생각한다.

@Override
@Transactional
public void register(RegisterCommand command) {
    try {
        userRepository.save(
                new User(command.getUsername(), command.getPassword())
        );
        userRepository.flush();
    } catch (DataIntegrityViolationException e) {
        throw new DuplicateUsernameException(ErrorCode.DUPLICATE_USERNAME, "이미 존재하는 유저 이름입니다.");
    }
}

위 코드는 '중복된 username은 허용하지 않는다'는 비즈니스 규칙이 잘 드러나는가? (애플리케이션 예외로 변환하면서 잘 드러나는 것 같기도 하고)

unique 제약조건을 통해 해결하는 것은, 도메인 레이어의 책임을 영속성 레이어로 전가하는 것은 아닌가?


트레이드 오프

다시 ChatGPT에게 물었다.
일리가 있는 주장이나, 트레이드 오프이므로 장단점을 잘 파악해야한다는 답변을 주었다.

문제가 있던 기존 코드는 확실히 비즈니스 규칙이 잘 드러난다.
-> 하지만 동시성 문제가 발생한다.

유니크 제약 조건을 사용하여 경쟁상태를 원천적으로 차단하였다. 이를 통해 중복 이메일 확인 조건도 삭제할 수 있었다.
-> 코드 중복이 줄고, 중복 방지 로직을 쉽게 구현할 수 있었다.
-> 하지만 비즈니스 로직과 데이터베이스 설계가 결합되었다.
-> 비즈니스 규칙의 변경이 필요할 경우, 데이터베이스 설계를 수정해야한다.

정답은 없는 것 같다. 어떤 선택에든 트레이드 오프가 따르기 마련이다.

비즈니스 규칙이 변경될 가능성이 극히 적은데도, 변경에 열려있는 설계를 하기 위해 너무 많이 고민하는 것은 오버 엔지니어링이 아닐까 싶다.

profile
A Sound Code in a Sound Body💪

0개의 댓글