중복 검사 사례로 알아보는 동시성 제어

Hansu Park·2024년 3월 17일
4
post-thumbnail

프로젝트를 진행하며 마주한 에러와 그 해결방법에 대해 공유해보려고 합니다.
(Mysql 5.7, Spring 3 환경)

문제 상황

회원가입 로직 중, 이메일이 DB에 중복 저장되어 MySQLIntegrityConstraintViolationException 라는 예외가 발생했습니다.

발생 원인 분석

원인

UNIQUE 제약조건이 있는 이메일이 중복되어 예외가 발생했습니다.

코드 분석

public void 회원가입() {
	이메일_중복_검증();
	DB에_사용자_저장(); // Exception!
}

(기존 코드 예시)

위와 같이 이메일 중복 검증 로직이 있음에도 예외가 발생했습니다.

따라서, 중복 검증 시점에는 존재하지 않았으나 DB 저장시점에는 동일한 이메일이 DB에 저장되어 있어 예외가 발생했다고 판단했습니다.

상황 분석

(시연 이미지)
사용자가 반복하여 회원가입 버튼을 눌러 API가 여러 번 실행되었고, 위와 같이 race condition이 발생했다고 판단했습니다.

문제 해결

시도1

회원가입 로직에 트랜잭션이 없다는 것을 확인했고, 이를 추가해주면 검증 쿼리와 조회 쿼리가 하나의 트랜잭션에서 처리돼서 중복 검증 문제가 없을 것이라 판단했습니다.

@Transactional
public void 회원가입() {
	이메일_중복_검증();
	DB에_사용자_저장(); // Exception!
}

(시도1의 변경된 코드)

시도1-결과

(바뀐 흐름도의 모습)

mysql 쉘에서 이와 같이 테스트해봤습니다.

트랜잭션을 추가해줬음에도 ERROR 1062 (23000): Duplicate entry 'asd@kakao.com' for key 'email_UNIQUE' 라는 에러가 나오는 것을 확인했습니다.

왜 실패했을까요??

시도1-분석

Mysql의 기본 격리 수준은 REPEATABLE READ이고, 해당 수준에서는 스냅샷을 이용하여 격리 수준을 보장합니다.

검증 쿼리와 조회 쿼리가 (다른 트랜잭션은 끼어들지 못하는) 하나의 트랜잭션에서 처리돼서 중복 검증 문제가 없을 것

이라는 제 생각은 SERIALIZABLE 수준의 이야기였습니다. 프로젝트의 격리 수준(mysql 기본값인 REPEATABLE READ)에서는 이번 글의 문제처럼 첫 읽기(중복 검증)에서 없던 레코드가 생기는 Phantom Read가 발생할 수 있습니다.

시도2

그렇다면 어떻게 해결해야 할까요? 위에 이미 언급했듯이

검증 쿼리와 조회 쿼리가 (다른 트랜잭션은 끼어들지 못하는) 하나의 트랜잭션에서 처리돼서 중복 검증 문제가 없을 것

을 만들어 주어야 합니다. 이를 위해서

  • 격리 수준을 SERIALIZABLE 로 올린다.
    - @Transactional(isolation = Isolation.SERIALIZABLE)
  • 두 쿼리를 하나의 쿼리로 합친다.
  • Application에서 락을 건다.
    등의 방법이 있습니다.

시도3

시도2를 통해 해결할 수도 있지만, 성능과 구현 리소스 측면에서 오버 엔지니어링이라고 생각했습니다. 왜냐하면 문제의 본질은 예외를 잘 잡아 클라이언트 에러(4XX 에러)로 처리하는 것 이기 때문입니다. 따라서 발생한 예외를 try-catch 구문으로 잡아 후처리하는 것만으로도 충분하다고 생각했고, 시도 3으로 진행했습니다.

@Transactional
public void 회원가입() {
	try {
		이메일_중복_검증();
		DB에_사용자_저장(); // Exception!
	} catch (MySQLIntegrityConstraintViolationException  e) {
	예외처리_로직();
	}
}

(시도3의 코드)
하지만 예상외로 발생한 예외를 캐치하지 못하는 모습을 확인했습니다.

시도3 문제 해결

Spring에서 DB 관련 예외를 이를 DuplicateKeyException 으로 래핑하여 주고있다는 것을 알게 되었습니다.

@Transactional // 시도1 내용이지만, 추가함.
public void 회원가입() {
	try {
		이메일_중복_검증();
		DB에_사용자_저장(); // Exception!
	} catch (DuplicateKeyException  e) {
	예외처리_로직();
	}
}

(시도3의 수정된 코드)
로 catch하는 예외를 수정하여 해결했습니다.

번외 (클러스터링 환경에서는?)

동아리 멘토님이 멀티 프로세스 환경도 고민해보라는 조언을 주셨습니다. Spring 이든 Mysql이든 현 서버에서는 싱글 프로세스 - 멀티 쓰레드로 동작한다고 판단하였고 번외로 Mysql 클러스터링 관점에서 동시성 제어를 고민해보았습니다.

Mysql 클러스터링 환경에서는 InnoDB 대신 NDB를 사용합니다. NDB는 격리 수준은 READ COMMITED만 지원하므로 SERIALIZABLE 격리 수준을 활용할 순 없고, s-lock & x-lock은 지원하여 이를 활용할 수 있을 것 같습니다.

느낀점

  • 면접 준비할 때 달달 외웠던 동시성 제어, 격리 수준 등의 깊이가 내 생각보다 많이 부족했다는 걸 알게 됐고, 실제 사례를 통해 배우니 이해가 더욱 잘됐던 것 같습니다.
  • 데드락, s-lock & x-lock에 대한 이해가 부족했다는 것, DB 관련해서는 공부할 것들이 많다는 것을 느꼈습니다.

참고

격리 수준

동시성 문제

Mysql Engine

Spring Exception

0개의 댓글