- A → B → C의 작업 흐름이 있을 때 이 중 하나라도 실패하면 전체 취소가 필요하여 트랜잭션으로 이를 처리
- 여러 트랜잭션들이 동일한 작업을 처리할 때에 마지막으로 커밋된 값이 이미 커밋된 값을 덮어씌우는 lost update problem 확인
- 대표적으로 문제가 발생하였던 부분이 사용권 등록의 흐름. (3)~(5)는 최초 구현 이후에 추가된 내용
- (1) middleware에서 외부 인증 서버를 통한 사용자 접근 제어를 수행하고 그 결과값을 context에 담음
- (2) handler에서 결과값을 바탕으로 사용자를 인가하고, request로 전달받은 값인 사용권 정보 검증
- (3) 로그인 시도 횟수를 의미하는 사용자의 login_fail_count 값이 일정 횟수를 넘기면 사용자 접근을 제한함
- (4) 사용권 검증이 실패하면 해당 사용자의 login_fail_count를 증가시킴
- (5) 사용권 검증이 성공하면 해당 사용자의 login_fail_count를 0으로 만들고 해당 사용권의 user__id 갱신함
- 문제는 여러 요청이 있을 때 이전의 값들을 읽고 UPDATE로 덮어쓰는 lost update 발생
- 현재 사내에서 자체 구현한 sql builder를 가져와 SELECT, UPDATE 쿼리로 사용하고 있음
- SELECT 결과를 바탕으로 계산한 뒤에 UPDATE를 하는 패턴이 많아 race condtion이 발생하기 쉬움
- SELECT 후 UPDATE 패턴은 읽기와 쓰기가 분리되어 있어, 그 사이에 다른 트랜잭션이 끼어드는 경쟁 상태 발생
- 처음 구현 시에도 위험성을 인지하고 있어, SELECT 또한 하나의 트랜잭션 안에서 순차 처리하였으나 문제 발생
- 트랜잭션이 락 (lock)을 자동으로 걸어준다고 오해하였음. 트랜잭션은 MVCC 스냅샷의 일관된 보장을 하는 것이지 mutex가 아님
- postgresql의 UPDATE는 RowExclusiveLock을 얻어 대상 row에 락을 자동으로 거나, SELECT는 기본적으로 어떤 락도 걸지 않음
- 그러므로 SELECT 후 UPDATE 하는 순간에 이미 다른 트랜잭션이 UPDATE를 실행할 수 있음 (ex. 조회수 문제)
아래 해결책들을 고려할 수 있다고 생각하였으며, X: 적용하면 안됨, △: 바로 적용하기 어려움, O: 적용할 만함으로 평가
- mysql의 쓰기 잠금이나 읽기 잠금와 유사한 postgres 트리거를 등록하여 접근 제어 (X)
- 데이터 수정 전에 접근을 미리 제한하는 비관적 락 (pessimistic lock)과 비슷한 접근이나, 트리거는 DML 이후에 동작해. 선제적 제어 불가
- DB 내부 로직을 통해 어플리케이션 동작을 제어하는 것은 유지보수 측면에서도 위험
- DB가 아닌 어플리케이션 로직에서 낙관적 락 (optimistic lock) (△)
- Spring JPA의
@Version처럼 어플리케이션에서 버전 비교를 하도록 sql builder를 수정
UPDATE user SET value = :$1, version = version + 1 WHERE id = :$2 AND version = :$3
- 실패 시 UPDATE 0행 → 트랜잭션 충돌을 감지할 수 있음
- 다만 트랜잭션 단위에서 값이 바뀌었는지 확인하기 위한 version 컬럼이 모든 테이블과 쿼리에 필요
- SQL 레벨에서 잠금 및 원자성 확보
- (A)
SELECT ... FOR UPDATE를 통해 행 단위 락을 SELECT에 걸어 처리 → 비관적 락 (O)
BEGIN; SELECT value FROM user WHERE id = :$1 FOR UPDATE; UPDATE user SET value = :$2 WHERE. id = :id; COMMIT;
- 경쟁 상태를 DB가 직접 제어하여 lost update를 방지할 수 있음
- 다만 동시성이 많은 경우에 트랜잭션 대기 시간이 증가하고, 데드락으로 인한 교착 가능성 존재
- (B) 계산을 단일 쿼리문으로 처리하여 SELECT → UPDATE 분리 문제 제거 (O)
UPDATE user SET value = value + 1 WHERE id = :$1;
- 계산을 UPDATE 쿼리 내에서 수행하므로 SELECT와 UPDATE 분리 문제 자체를 제거 가능
- 트랜잭션 격리 수준을
SERIALIZABLE로 상향 (△)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
- SELECT가 최신 커밋된 값을 읽는
READ COMMITTED와 SELECT가 동일 스냅샷을 읽는 REPEATABLE READ 모두 lost update가 발생할 수 있음
SERIALIZABLE는 DB에서 동시성 충돌을 감지하도록 설정하고 serialization error를 반환
- 매우 안전하지만 성능 비용이 크고 트랜잭션 충돌 시에 대한 재시도 로직이 필요
앞서 문제 상황의 예시로 든 사용자 로그인은 3-(A)와 3-(B)를 모두 적용함
- 사용자 접근 제어 로직은 다음과 같이 추상화 가능
- 미들웨어의 사용자 식별 (Identification) 및 인증 (Authentication)
- 핸들러의 사용자 인가 (Authorization)
- 핸들러의 사용권 검증
- 성공 혹은 실패 시에 DB의 사용자 및 사용권 정보 갱신
-- handler: 사용권 검증
BEGIN;
-- 1) 사용권에 사용자 할당 (이미 다른 사용자가 존재하면 실패)
UPDATE license
SET user__id = :user__id,
assigned_at = now()
WHERE id = :license__id
AND (user__id IS NULL OR user__id = :user__id)
RETURNING id AS license__id;
-- 2) 할당 성공 시, users 카운트 리셋
-- 만약 RETURNING이 비어있다면 할당이 실패한 것이므로, 핸들러에서 트랜잭션 롤백
UPDATE users
SET login_fail_count = 0,
updated_at = now()
WHERE id = :user_id;
COMMIT;
-- handler: 사용권 검증 실패 시
UPDATE users
SET login_fail_count = login_fail_count + 1
WHERE id = :user__id
RETURNING login_fail_count;
- 트랜잭션으로 인한 락의 범위를 최소화해야 함
- 가능한 경우 row 단위로 락을 걸 것
- 여러 테이블에 대한 락을 해야 한다면, 다음에
FOR UPDATE를 위치시켜 일관된 락 취득 순서를 보장할 것
- 트랜잭션으로 인한 락 유지시간을 최소화해야 됨
- HTTP 등의 외부 호출은 절대 넣지 말 것
- 대기 및 데드락을 제어하기 위한 옵션이나 타임아웃을 설정할 것
NOWAIT: 실패 즉시 돌아오도록 설정 (SELECT ... FOR UPDATE NOWAIT)
SKIP LOCKED: 잠금 중인 행을 건너뜀 (FOR UPDATE SKIP LOCKED)
pg_locks, pg_stat_activity 등의 시스템 카탈로그 테이블을 통해 데드락 모니터링
- 트랜잭션 충돌 및 재시도 전략에 대한 고려 필요
- 낙관/직렬화 실패 또는 버전 불일치 시 UPDATE 0행 반환 등의 상황에 대한 재시도 로직 추가