아티클/개발
Node.js에서 동시성 문제와 데이터 무결성을 고민하며 배운 것들
오웬
안경원 고객 관리 프로그램을 운영하면서 기능이 정상적으로 동작하는지만큼 신경 쓰이기 시작한 것이 있다.
같은 데이터를 여러 요청이 동시에 수정해도 괜찮을까?
요청 하나만 놓고 보면 문제가 없어 보이는 코드도, 실행 순서가 겹치면 예상하지 못한 결과를 만들 수 있다.
비동기 처리를 이해하는 것과 동시성 문제를 다루는 것은 다른 이야기였다. 운영하면서 궁금해 찾아본 문제와 해결 방법을 정리해본다.
마지막 재고를 동시에 주문한다면
재고가 1개 남은 상품을 두 사람이 동시에 주문한다고 가정해보자.
- A 요청이 재고를 조회한다.
1개가 있으므로 주문할 수 있다고 판단한다. - A 요청이 재고를 차감하기 전에 B 요청도 재고를 조회한다.
- B 요청 역시
1개가 있다고 판단한다. - 두 요청이 모두 주문을 진행한다.
각 요청에서 재고를 확인했는데도, 전체 결과는 잘못될 수 있다.
문제는 재고를 확인하는 작업과 차감하는 작업이 분리되어 있다는 점이다. 확인 당시에는 맞았던 조건이 실제로 변경하는 시점에도 유효하다는 보장이 없다.
따라서 요청 하나가 올바르게 동작하는지만 볼 것이 아니라, 여러 요청의 실행 순서가 섞였을 때도 규칙이 유지되는지 확인해야 한다.
락을 도입하기 전에 갱신 조건부터 본다
이런 문제를 해결하려고 처음부터 복잡한 락을 도입해야 하는 것은 아니다.
MongoDB에서는 단일 문서에 대한 쓰기가 원자적으로 처리된다. 이를 활용해 재고 조건 확인과 차감을 하나의 갱신으로 묶을 수 있다.
설명을 위한 예시는 다음과 같다.
const result = await products.updateOne(
{
_id: productId,
stock: { $gte: 1 },
},
{
$inc: { stock: -1 },
}
);
if (result.matchedCount === 0) {
throw new Error('상품이 없거나 재고가 부족합니다.');
}
재고를 먼저 읽고 나중에 수정하는 대신, 재고가 남아 있는 경우에만 차감하도록 DB에 요청하는 방식이다.
재고가 1개라면 먼저 성공한 요청이 이를 차감하고, 뒤의 요청은 재고 조건을 만족하지 못한다.
다만 여기서 보장하는 것은 해당 문서의 재고 차감이다. 별도 문서에 주문을 생성하는 작업까지 함께 성공하거나 실패해야 한다면, 그 범위에 맞는 트랜잭션 설계를 추가로 검토해야 한다.
하나의 쓰기가 원자적이라는 것과, 전체 업무가 올바르게 처리된다는 것은 구분해야 한다. (mongodb.com)
비관적 락과 낙관적 락
조건부 갱신만으로 표현하기 어려운 작업에서는 락이나 버전 검사를 검토할 수 있다.
비관적 락은 충돌할 가능성을 고려해, 필요한 데이터를 잠근 상태에서 작업을 진행하는 방식이다.
예를 들어 PostgreSQL에서는 트랜잭션 안에서 SELECT ... FOR UPDATE로 조회한 행에 락을 걸 수 있다. 다른 트랜잭션이 같은 행을 수정하거나 충돌하는 락을 획득하려 하면 기다리게 된다.
모든 조회를 막는 것은 아니다. 대신 충돌하는 작업을 기다리게 하므로, 락을 오래 유지할수록 대기 부담이 커질 수 있다. 여러 데이터를 서로 다른 순서로 잠그면 데드락도 고려해야 한다. (postgresql.org)
낙관적 락은 먼저 작업을 진행하되, 저장할 때 내가 읽은 이후 다른 변경이 있었는지 확인하는 방식이다.
예를 들어 문서를 읽을 당시의 버전을 갱신 조건에 넣고, 수정에 성공하면 버전도 함께 증가시킨다. 이미 다른 요청이 버전을 바꿨다면 저장을 거부하고, 다시 조회하거나 충돌을 사용자에게 알려줄 수 있다.
여기서 주의할 점이 있었다. __v는 MongoDB 자체의 자동 충돌 방지 기능이 아니라 Mongoose의 기본 버전 키다. 이 필드가 존재한다고 모든 수정 충돌이 자동으로 감지되지는 않는다.
Mongoose에서 save()를 사용하는 경우에는 optimisticConcurrency 설정 등을 검토해야 한다. 쿼리 기반 갱신에서도 버전 조건과 증가 처리가 필요한지 확인해야 한다.
따라서 중요한 수정은 조회 후 save()를 사용하면 안전하다는 식으로 구분할 수는 없다. 조회와 저장 사이에도 다른 요청이 데이터를 바꿀 수 있기 때문이다. 중요한 것은 메서드 이름이 아니라, 변경 조건을 어떻게 검증하느냐였다. (mongoosejs.com)
원자적인 연산도 중복 실행에는 안전하지 않을 수 있다
동시 수정과 함께 생각해야 할 문제가 재시도다.
서버에서는 처리가 끝났지만, 응답이 전달되지 않아 클라이언트가 같은 요청을 다시 보낼 수 있다. 이때 같은 업무가 중복 실행되지 않도록 하는 것이 멱등성이다.
단순한 필드 변경만 보면 차이가 드러난다.
$set으로 값을10으로 설정하는 연산은 반복해도 해당 필드가10으로 유지된다.$inc로 값을 증가시키는 연산은 실행할 때마다 값이 달라진다.
$inc는 동시 증가 처리에 유용하지만, 같은 요청이 중복 실행되는 문제까지 해결하지는 않는다. 반대로 필드 하나를 $set으로 수정한다고 외부 결제나 메시지 발송까지 포함한 API 전체가 멱등적으로 되는 것도 아니다.
이런 작업에는 업무 요청을 식별하는 멱등성 키를 사용할 수 있다.
핵심은 같은 업무를 재시도할 때 같은 키를 사용하는 것이다. 재시도마다 새로운 키를 만들면 서버는 이를 별개의 요청으로 받아들일 수 있다.
키를 전달하는 것에서 끝나는 것도 아니다. 서버가 해당 키의 처리 상태와 결과를 기록하고, 같은 키의 동시 요청을 중복 실행하지 않도록 설계해야 한다. Stripe의 API 설계 사례에서도 같은 키를 사용한 재시도를 중요한 원칙으로 설명한다. (stripe.com)
삭제 정책도 함께 살펴봤다
동시성 문제를 찾아보면서 데이터 삭제 방식도 함께 살펴봤다. 직접적인 동시성 제어 방법은 아니지만, 데이터의 생명주기를 어떻게 다룰지에 관한 문제였다.
Soft Delete는 데이터를 즉시 제거하는 대신 deletedAt이나 삭제 여부 필드로 삭제 상태를 표시하는 방식이다.
복구가 필요하거나 삭제된 이력을 확인해야 하는 경우에 활용할 수 있다. 대신 일반 조회에서는 삭제된 데이터를 제외하고, 복구나 관리 화면에서는 필요한 데이터에 접근할 수 있도록 기준을 세워야 한다. (learn.microsoft.com)
따라서 무조건 실제 삭제를 피하는 것이 정답은 아니라고 생각한다. 복구 필요성, 보관 기간, 실제 삭제 조건을 함께 보고 선택해야 한다.
데이터를 남겨두는 것과 데이터 무결성을 지키는 것도 같은 말은 아니었다.
결국 먼저 정해야 하는 것은 지켜야 할 규칙이다
처음에는 비관적 락과 낙관적 락 중 무엇을 써야 하는지부터 궁금했다.
찾아볼수록 그보다 먼저 정해야 할 것이 있었다.
- 동시에 수정돼도 반드시 유지되어야 하는 조건은 무엇인가.
- 어느 작업까지 함께 성공하거나 실패해야 하는가.
- 충돌했을 때 기다릴 것인가, 실패시키고 다시 시도할 것인가.
- 같은 요청이 반복되면 중복 실행을 어떻게 막을 것인가.
조건부 갱신, 트랜잭션, 버전 검사, 멱등성 키는 각각 다른 문제를 해결한다. 하나를 도입했다고 나머지 문제까지 사라지는 것은 아니다.
그래서 이제는 어떤 기술을 사용할지보다, 이 작업에서 절대 깨지면 안 되는 규칙이 무엇인지 먼저 생각하려고 한다.
빠르게 처리하는 것과 정확하게 처리하는 것은 다르다.
기능을 만들었다면, 동시에 실행되거나 다시 실행돼도 괜찮은지까지 물어봐야 한다.
댓글 0개
댓글은 로그인 후 작성할 수 있어요.
로그인하기아직 작성된 댓글이 없습니다.