개요
식당 추천 시스템을 개발하면서 식당과 리뷰 데이터를 확보하기 위해 ETL 파이프라인을 구축했습니다.
https://growth-coder.tistory.com/359
식당 추천 시스템 개발기 #4 - ETL 데이터 파이프라인 아키텍처
개요"현재 자신의 상황에 맞는 식당 추천 시스템"을 개발하기 위해 지금까지 식당, 리뷰 크롤링 및 기본적인 벡터 기반 유사도 검색에 대해 알아보았습니다. 이번에는 작성한 크롤링 코드를 기
growth-coder.tistory.com
아키텍처는 아래와 같습니다.

이 데이터 파이프라인에서 문제가 발생한 부분은 데이터 저장 부분입니다
하나의 식당에 대해서 여러 리뷰를 수집하고 해당 리뷰를 요약한 뒤 vector로 만들어 S3에 저장하면 lambda 함수에서 해당 데이터를 조회해서 병렬로 RDS에 저장하는 구조입니다.
여기서 데이터를 저장할 때 수백 개 리뷰 정보와 벡터 정보를 같은 트랜잭션에서 데이터를 저장하고 있습니다.
적은 데이터를 기반으로 테스트를 해보았을 때는 큰 문제 없이 동작했지만 대용량 데이터를 저장할 때 실제 수집한 데이터보다 훨씬 적은 수의 데이터만 RDS에 저장된 것을 확인했습니다.
Cloud Watch에 있는 로그를 확인해 본 결과 deadlock이 여러 번 발생했다는 것을 확인할 수 있었습니다.
Error saving reviews to database: deadlock detected
DETAIL: Process 18165 waits for ShareLock on transaction 51807; blocked by process 18171.
Process 18171 waits for ShareLock on transaction 51801; blocked by process 18165.
CONTEXT: while inserting index tuple (194,5) in relation "crawling_review"
로그를 보면 18165 process와 18171 process가 특정 트랜잭션 아이디에 대한 share lock을 기다리면서 deadlock이 발생한 것을 확인할 수 있습니다.
또한 crawling_review에 리뷰 데이터를 삽입할 때 이러한 deadlock이 발생했습니다.
deadlock 발생 원인 - transaction id에 의한 배타 락
그렇다면 deadlock이 왜 발생했을까요?
원인을 알아보기에 앞서 리뷰 데이터의 테이블 구조를 확인해봅시다.

우선 저는 이미 수집한 리뷰를 중복 수집하는 것을 방지하기 위해 content 컬럼에 unique 제약 조건을 걸어두었습니다.
그리고 저는 S3에 저장된 한 식당에 대한 수백 개의 리뷰 데이터를 하나의 트랜잭션에서 저장하고 있었고 여러 식당에 대해 병렬로 리뷰 데이터 저장 작업을 수행하고 있었습니다.
결론적으로 말씀드리자면 deadlock의 원인은 여러 트랜잭션이 동일한 리뷰 content에 대한 unique constraint 검증 과정에서 서로의 트랜잭션 완료를 기다렸기 때문입니다.
deadlock이 발생하는 상황은 다음과 같이 서로 다른 트랜잭션에서 unique 제약 조건을 위배했을 때 발생합니다.

조금 더 자세히 알아봅시다.
우선 트랜잭션이 실행되면 프로세스는 가상 트랜잭션 id에 대해서 배타 락을 획득합니다.

그리고 트랜잭션이 데이터를 삽입할 때, 영구 트랜잭션 id가 할당되고 영구 트랜잭션 id에 대한 배타 락 또한 유지합니다.

이제 두 번째 트랜잭션에서 첫 번째 트랜잭션에서 삽입한 데이터를 삽입하게 되면 먼저 unique index를 확인해서 unique constraint 위배 여부를 확인합니다.
참고로 postgres index의 경우 커밋되지 않은 경우에도 인덱스 엔트리를 생성하기 때문에 첫 번째 트랜잭션이 커밋되지 않았지만 unique constraint 위배 여부를 판단할 수 있습니다.
이미 "맛있어요"에 대한 인덱스 엔트리가 생성되었기 때문에 두 번째 트랜잭션에서 "맛있어요"를 삽입하게 되면 unique constraint에 위배된다는 사실을 알고 해당 트랜잭션에 대한 공유 락 획득을 시도합니다.
배타 락과 공유 락은 동시에 획득할 수 없기 때문에 두 번째 트랜잭션은 배타 락이 해제될 때까지 기다립니다.
그러면 이 상태에서 첫 번째 트랜잭션이 두 번째 트랜잭션이 이미 삽입한 "최고예요"를 삽입하면 어떻게 될까요?

첫 번째 트랜잭션도 두 번째 트랜잭션 id에 대한 공유 락 획득을 시도하고 배타 락이 해제될 때까지 기다리면서 데드락이 발생합니다.
지금까지 unique constraint에서 발생할 수 있는 동시성 문제에 대해 알아보았습니다.
저희 프로젝트에서 이러한 deadlock이 발생했던 원인은 다음과 같습니다.
첫 번째, 리뷰 데이터 삽입 트랜잭션의 덩치가 큽니다.
수백 개 리뷰를 하나의 트랜잭션에서 batch로 삽입하기 때문에 트랜잭션의 덩치가 큽니다.
덩치가 크기 때문에 트랜잭션 처리 시간 또한 길고 transaction id에 대한 배타 락을 획득하는 시간 또한 깁니다.
이렇게 되면 당연히 unique constraint로 인한 충돌 문제가 발생할 가능성 또한 커집니다.
두 번째, 리뷰 데이터 삽입을 병렬로 수행합니다.
여러 식당에 대한 리뷰 데이터를 수집했을 때 각 식당마다 리뷰 데이터 batch insert를 병렬로 수행하고 있습니다.
동시에 여러 개의 batch insert transaction이 실행되고 이 중 두 개의 트랜잭션이 다른 트랜잭션이 이미 삽입한 데이터를 삽입하면서 unique constraint에 의해 deadlock이 걸리게 됩니다.
❌ deadlock 해결 시도 - 1. DO UPDATE, DO NOTHING 사용
우선 제가 transaction id 배타 락에 의한 deadlock을 방지하기 위해 시도한 첫 번째 방법은 DO UPDATE 혹은 DO NOTHING을 활용하는 방법이었습니다.
충돌이 발생했을 때 값을 변경하거나 아무것도 하지 않는 방식으로 deadlock을 회피할 수 있을 것이라고 생각했습니다.
하지만 테스트를 해 본 결과 여전히 transaction id 배타 락에 의한 deadlock이 발생했습니다.
결국 transaction id에 대한 공유 락을 획득하려고 배타 락 해제를 기다리는 과정은 똑같기 때문입니다.
참고로 DO UPDATE, DO NOTHING을 사용하면 추가적으로 row에 대한 배타 락도 획득하게 됩니다.
이러한 락의 종류는 pg_locks 테이블에서 확인할 수 있습니다.
아래는 transaction id에 의한 배타 락입니다.

아래는 row level 배타 락입니다.

❌ deadlock 해결 시도 - 2. Advisory Lock 사용
제가 두 번째로 시도한 방법은 advisory lock을 사용하는 방법입니다.
advisory lock이란 애플리케이션에서 직접 lock을 제어할 수 있는 기능입니다.
이러한 advisory lock에는 크게 session level lock과 transaction level lock으로 나뉩니다.
session level lock은 세션이 종료될 때까지 유지되는 lock이고 transaction level lock은 트랜잭션이 종료될 때까지 유지되는 lock입니다.
또한 advisory lock은 blocking lock과 non blocking lock으로 나뉩니다.
blocking lock은 lock 획득 충돌이 발생했을 때 lock이 해제될 때까지 기다리고 non blocking lock은 충돌이 발생하면 바로 false를 반환합니다.
공유 락, 배타 락 또한 존재합니다.
우선 제가 시도한 advosiry lock은 transaction level lock이었기 때문에 간단하게 lock 종류에 대해 정리해보았습니다.
Transaction Level Locks (트랜잭션 종료시 자동 해제)
| 함수명 | 동작 방식 | 반환 값 | 설명 |
| pg_advisory_xact_lock | Blocking | 없음 | 트랜잭션 종료시 자동 해제되는 배타적 잠금 |
| pg_advisory_xact_lock_shared | Blocking | 없음 | 트랜잭션 종료시 자동 해제되는 공유 잠금 |
| pg_try_advisory_xact_lock | Non-blocking | boolean | 즉시 트랜잭션 배타적 잠금 시도 |
| pg_try_advisory_xact_lock_shared | Non-blocking | boolean | 즉시 트랜잭션 공유 잠금 시도 |
우선 저는 pg_advisory_xact_lock을 사용하여 수백 개 리뷰를 저장하는 트랜잭션을 시작할 때 모든 리뷰에 대한 배타 락을 걸었습니다.
단 하나의 리뷰에 대한 배타 락을 획득하지 못 하면 batch insert 연산 자체를 하지 않았기 때문에 deadlock을 해결할 수 있을 것이라고 생각했습니다.
하지만 한 가지 문제점이 있었는데 pg_advisory_xact_lock은 여러 배타 락을 동시에 획득하지 못 한다는 점이었습니다.
순차적으로 하나씩 배타 락을 획득해야 했는데 이 과정 속에서 다시 한 번 deadlock이 발생했습니다.
위에서 설명했던 transaction id 배타 락에 의한 데드락과 유사한 상황에서 deadlock이 발생했습니다.
심지어 blocking lock의 경우 lock을 획득할 때까지 DB connection을 점유하고 있기 때문에 DB connection이 부족해지는 현상 또한 발생했습니다.
그래서 블록킹 배타 락에서 논블록킹 배타 락으로 변경했습니다.
deadlock 자체가 lock을 얻지 못하면 lock이 해제될 때까지 기다려서 발생한 것이기 때문에 lock을 얻지 못하면 트랜잭션을 종료한 뒤에 다시 시도하는 방식으로 변경했습니다.
이 과정에서 시스템 부하와 동시 락 획득 시도를 방지하기 위해 exponential backoff와 full jitter를 사용했습니다.
이 방식을 적용한 이후 deadlock을 방지할 수 있었으나 계속 lock을 얻지 못 해 결국 리뷰 저장이 실패하는 현상이 발생했습니다.
수백 개의 리뷰에 대한 락을 획득하는 트랜잭션이 동시에 여러 개가 실행되기 때문에 단 하나라도 획득에 실패하면 트랜잭션이 종료되었기 때문입니다.

✅ deadlock 해결 시도 - 3. Lock ordering 사용
다양한 방법을 시도했지만 데드락을 방지하기 위한 가장 단순한 방법이 있었습니다.
바로 항상 동일한 순서로 Lock을 획득하는 Lock ordering 기법을 사용하는 것이었습니다.
기존에 deadlock은 서로 다른 두 트랜잭션이 상대 트랜잭션이 이미 획득했던 락을 획득하면서 발생했습니다.
그런데 항상 같은 순서로 lock을 획득하면 이러한 일이 발생하지 않습니다.
예를 들어 두 트랜잭션 모두 "맛있어요"와" "최고예요"를 삽입한다고 합시다.

둘 다 같은 순서로 데이터를 삽입하기 때문에 두 번째 트랜잭션에서 "맛있어요" 데이터의 충돌이 발생하고 첫 번째 트랜잭션이 끝날 때까지 기다리게 됩니다.

그리고 첫 번째 트랜잭션이 "최고예요"를 삽입하고 트랜잭션이 종료되면 두 번째 트랜잭션의 블록킹이 끝나고 "맛있어요"가 이미 삽입되어 있기 때문에 unique constraint 위배 오류를 발생시키고 트랜잭션이 종료됩니다.
즉, 서로 다른 두 트랜잭션이 모두 상대 트랜잭션이 이미 획득했던 락을 획득하는 상황 자체가 나오지 않게 됩니다.
또한 단순히 review 데이터에 unique 제약 조건을 걸어주지 않고 "작성자", "작성 날짜", "내용"을 합친 뒤 SHA-256 해쉬 알고리즘을 적용한 hash 컬럼에 unique 제약 조건을 걸어주어 충돌 확률 자체를 낮추었습니다.

마무리
이번 포스팅에서는 postgres에서 unique 제약 조건이 걸린 컬럼에 대해 batch insert를 병렬로 수행하면서 발생한 deadlock의 발생 원인과 해결 방안을 알아보았습니다.
deadlock을 해결하기 위해 여러가지 방법을 시도했는데 효과가 있었던 방식은 가장 단순한 Lock Ordering 방식이었습니다.
사실 대학교 운영체제 수업 때 deadlock의 4가지 조건 중 순환 대기(Circular Wait)를 방지하기 위해 모든 자원 유형에 순서를 정의하고 정해진 순서대로만 자원을 할당하는 방법이 있다고 배웠습니다.
그런데 막상 순환 대기를 마주했을 때 이론적인 부분을 바로 적용하지 못 했습니다.
이론으로는 알고 있어도 실제 상황에서는 다양한 복잡한 요소들 때문에 핵심 원리를 놓치기 쉬운 것 같습니다.
앞으로는 문제를 마주했을 때 기본 원리부터 차근차근 되짚어보는 습관을 길러야겠다는 생각이 듭니다.
출처
https://www.postgresql.org/files/developer/concurrency.pdf
https://rcoh.me/posts/postgres-unique-constraints-deadlock/
https://www.postgresql.org/docs/current/view-pg-locks.html
https://www.postgresql.org/docs/current/sql-insert.html
https://www.postgresql.org/docs/current/index-unique-checks.html
