CRUD 앱의 경쟁 조건은 중복 주문, 잘못된 합계 등을 유발합니다. 제약, 락, UX 가드로 흔한 충돌 지점과 실무적 해결책을 배우세요.

경쟁 조건(race condition)은 두 개 이상의 요청이 거의 동시에 같은 데이터를 갱신할 때 발생하며, 최종 결과가 타이밍에 따라 달라지는 상황입니다. 각 요청 자체는 올바르지만, 함께 작동하면 잘못된 결과를 만듭니다.
간단한 예: 두 사람이 같은 고객 레코드에서 1초 간격으로 저장을 누릅니다. 한쪽은 이메일을 바꾸고, 다른 쪽은 전화번호를 바꿉니다. 두 요청이 모두 전체 레코드를 전송하면 두 번째 쓰기가 첫 번째를 덮어써 한 변경이 사라질 수 있습니다.
이런 현상은 빠르게 반응하는 앱에서 더 자주 보입니다. 사용자들이 분당 더 많은 행동을 트리거하고, 플래시 세일, 월말 리포트, 대규모 이메일 캠페인 같은 바쁜 순간에 같은 행에 요청이 몰리면 급증합니다.
사용자들은 보통 "경쟁 조건이 생겼다"고 말하지 않습니다. 대신 증상을 보고합니다: 중복 주문이나 댓글, 사라진 업데이트("저장했는데 다시 돌아갔어요"), 이상한 합계(재고가 음수로 가거나 카운터가 뒤로 뛴다), 상태가 예기치 않게 뒤집히는 경우(승인되었다가 다시 대기 상태로 돌아가는 등).
재시도는 상황을 악화시킵니다. 사람들은 더블클릭을 하거나 응답이 느려 새로고침을 하거나 두 탭에서 제출하거나 불안정한 네트워크로 인해 브라우저나 모바일 앱이 요청을 다시 보냅니다. 서버가 모든 요청을 새로운 쓰기로 처리하면 두 번 생성, 두 번 결제, 또는 한 번만 일어나야 할 상태 변경이 두 번 실행될 수 있습니다.
대부분의 CRUD 앱은 단순해 보입니다: 행을 읽고, 필드를 바꾸고, 저장합니다. 문제는 앱이 타이밍을 제어하지 못한다는 점입니다. 데이터베이스, 네트워크, 재시도, 백그라운드 작업, 사용자 행동이 모두 겹칩니다.
흔한 트리거 중 하나는 두 사람이 같은 레코드를 편집하는 경우입니다. 둘 다 같은 "현재" 값을 로드하고, 둘 다 유효한 변경을 하며, 마지막 저장이 조용히 첫 번째를 덮어씁니다. 아무도 잘못한 게 없지만 한 업데이트가 사라집니다.
한 명의 사용자로도 발생할 수 있습니다. 저장 버튼을 더블클릭하거나 뒤로/앞으로 탭을 누르거나 느린 연결 때문에 다시 제출하는 경우 같은 쓰기가 두 번 전송될 수 있습니다. 엔드포인트가 멱등하지 않으면 중복 생성, 이중 결제, 또는 상태가 두 단계 전진하는 문제가 생깁니다.
현대 사용 환경은 겹침을 더 늘립니다. 같은 계정에 로그인한 여러 탭이나 장치가 충돌하는 업데이트를 보낼 수 있습니다. 이메일 발송, 과금, 동기화, 정리 같은 백그라운드 잡이 웹 요청과 같은 행을 건드릴 수 있습니다. 클라이언트, 로드밸런서, 잡 러너의 자동 재시도가 이미 성공한 요청을 반복할 수 있습니다.
기능을 빠르게 배포하면 같은 레코드가 기억보다 더 많은 곳에서 업데이트됩니다. Koder.ai 같은 채팅 기반 빌더를 쓰면 앱이 더 빨리 성장할 수 있으니 동시성 문제를 엣지 케이스가 아닌 정상적 동작으로 취급하는 것이 좋습니다.
경쟁 조건은 보통 "레코드 생성" 데모에서는 잘 드러나지 않습니다. 거의 동시에 같은 진실(truth)을 건드리는 곳에서 나타납니다. 일반적인 핫스팟을 알면 처음부터 안전한 쓰기를 설계하는 데 도움이 됩니다.
"그냥 1 더하기"처럼 보이는 모든 것은 부하 아래서 깨질 수 있습니다: 좋아요 수, 조회수, 합계, 송장 번호, 티켓 번호 등. 위험한 패턴은 값을 읽고 더한 다음 쓰는 것입니다. 두 요청이 같은 시작 값을 읽어 서로 덮어쓸 수 있습니다.
Draft -> Submitted -> Approved -> Paid 같은 워크플로는 간단해 보이지만 충돌이 흔합니다. 동시에 가능한 두 가지 액션(승인과 편집, 취소와 결제)이 있을 때 문제가 생깁니다. 가드가 없으면 레코드가 단계를 건너뛰거나 뒤집히거나 테이블마다 다른 상태를 보일 수 있습니다.
상태 변경을 계약처럼 다루세요: 다음 유효한 한 단계만 허용하고 그 외는 거부하세요.
남은 좌석, 재고 수, 예약 슬롯, "남은 용량" 필드는 고전적인 오버셀 문제를 만듭니다. 두 명의 구매자가 동시에 체크아웃해 둘 다 가능하다고 보면 둘 다 성공할 수 있습니다. 데이터베이스가 최종 심판이 아니면 결국 재고보다 더 많이 팔립니다.
한 계정당 하나의 이메일, 사용자당 하나의 활성 구독, 사용자당 하나의 열린 장바구니 같은 규칙은 절대적입니다. 먼저 확인하는("존재하나?") 후 삽입하는 패턴은 동시성에서 실패합니다. 두 요청이 확인을 통과할 수 있습니다.
Koder.ai처럼 CRUD 흐름을 빠르게 생성한다면 이런 핫스팟을 일찍 기록하고 UI 체크만으로 끝내지 말고 제약과 안전한 쓰기로 뒷받침하세요.
많은 경쟁 조건은 지루한 원인에서 시작합니다: 같은 액션이 두 번 전송됩니다. 사용자가 더블클릭을 하거나 네트워크가 느려 다시 클릭하거나 폰이 두 번 탭을 등록하거나 POST 후 페이지를 새로고침해 브라우저가 재전송을 제안하는 경우입니다.
이럴 때 백엔드는 두 개의 생성 혹은 업데이트를 병렬로 실행할 수 있습니다. 둘 다 성공하면 중복, 잘못된 합계, 또는 상태 변경이 두 번 일어납니다(예: 승인 두 번). 타이밍에 따라 달라지기 때문에 무작위처럼 보입니다.
가장 안전한 접근은 다층 방어입니다. UI를 고치되, UI가 실패할 것을 전제로 삼으세요.
대부분의 쓰기 흐름에 적용할 수 있는 실용적 변경:
예: 사용자가 모바일에서 "송장 결제"를 두 번 탭했다면 UI가 두 번째 탭을 막아야 하고, 서버는 같은 멱등성 키를 보면 두 번째 요청을 거부하고 원래 성공 결과를 반환해야 결제가 두 번 발생하지 않습니다.
상태 필드는 단순해 보이지만 두 프로세스가 동시에 바꾸려 들면 문제가 됩니다. 사용자가 승인 버튼을 누르는 동안 자동 작업이 같은 레코드를 만료로 표시하거나, 두 팀원이 다른 탭에서 같은 항목을 처리할 수 있습니다. 두 업데이트가 모두 성공하지만 최종 상태는 규칙이 아닌 타이밍에 달립니다.
상태를 작은 상태 머신으로 다루세요. 허용된 이동 목록(예: Draft -> Submitted -> Approved, Submitted -> Rejected)을 짧게 유지한 뒤, 모든 쓰기가 "이 전환이 현재 상태에서 허용되는가?"를 확인하게 하세요. 그렇지 않으면 조용히 덮어쓰지 말고 거부하세요.
낙관적 락은 다른 사용자를 차단하지 않으면서 오래된 업데이트를 잡아내는 데 도움이 됩니다. 버전 번호(또는 updated_at)를 추가하고 저장할 때 일치해야 하게 하세요. 누군가가 로드한 뒤에 행을 변경했다면 업데이트는 0행에 영향을 주고, "이 항목이 변경되었습니다. 새로고침 후 다시 시도하세요." 같은 명확한 메시지를 보여줄 수 있습니다.
상태 업데이트의 단순한 패턴:
또한 상태 변경은 한 곳에 모으세요. 업데이트가 여러 화면, 백그라운드 잡, 웹훅에 흩어져 있으면 규칙을 놓칩니다. 동일한 전환 검사를 항상 적용하는 단일 함수나 엔드포인트 뒤에 상태 변경을 두세요.
가장 흔한 카운터 버그는 무해해 보입니다: 앱이 값을 읽고 1을 더한 뒤 다시 씁니다. 부하가 걸리면 두 요청이 같은 숫자를 읽고 둘 다 같은 새 숫자를 쓰므로 한 증가는 사라집니다. 테스트에서는 보통 작동하기 때문에 놓치기 쉽습니다.
값을 단순히 증가/감소시키는 경우 데이터베이스에 한 문장으로 처리하세요. 그러면 많은 요청이 동시에 들어와도 데이터베이스가 안전하게 적용합니다.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
같은 아이디어를 재고, 조회수, 재시도 카운터 등 "new = old + delta"로 표현 가능한 모든 곳에 적용하세요.
합계(order_total, account_balance, project_hours 등)를 파생값으로 저장하고 여러 곳에서 업데이트하면 흔히 틀어집니다. 합계를 소스 행(라인 아이템, 원장 항목)에서 계산할 수 있다면 드리프트 버그를 피할 수 있습니다.
성능 때문에 합계를 저장해야 한다면 중요한 쓰기로 취급하세요. 소스 행과 저장된 합계를 같은 트랜잭션에서 업데이트하고, 동일한 합계에 대해 하나의 쓰기만 허용하게 하세요(락, 가드 업데이트, 단일 소유자 경로). 불가능한 값을 막는 제약(예: 재고 >= 0)을 추가하고, 주기적으로 재계산해 불일치를 플래그하세요.
구체적 예: 두 사용자가 동시에 같은 장바구니에 항목을 추가하면, 각 요청이 cart_total을 읽고 항목 가격을 더해 쓰면 하나의 추가가 사라질 수 있습니다. 장바구니 항목과 합계를 하나의 트랜잭션으로 업데이트하면 많은 병렬 클릭 아래서도 합계는 정확하게 유지됩니다.
경쟁 조건을 줄이고 싶다면 데이터베이스부터 시작하세요. 앱 코드는 재시도하거나 타임아웃 되거나 두 번 실행될 수 있습니다. 데이터베이스 제약은 두 요청이 동시에 와도 올바르게 유지되는 마지막 관문입니다.
고유 제약은 원래 "절대 일어나지 말아야 할" 중복을 막습니다: 이메일 주소, 주문 번호, 송장 ID, "사용자당 하나의 활성 구독" 규칙 등. 두 가입이 동시에 들어와도 데이터베이스는 하나의 행만 받아들이고 다른 하나는 거부합니다.
외래 키는 깨진 참조를 막습니다. 외래 키가 없으면 한 요청이 부모를 삭제하는 동안 다른 요청이 부모가 없는 자식 레코드를 만들 수 있어 고아 행이 생기기 쉽습니다.
체크 제약은 값 범위를 안전하게 유지하고 단순한 상태 규칙을 강제합니다. 예: quantity >= 0, rating 1~5 사이, 또는 상태를 허용된 집합으로 제한.
제약 실패를 "서버 오류"로만 보지 마세요. 고유성, 외래 키, 체크 위반을 잡아 사용자에게 "해당 이메일은 이미 사용 중입니다" 같은 명확한 메시지를 반환하고, 디버깅을 위해 내부 정보 누출 없이 로그를 남기세요.
예: 두 사람이 지연 중에 "주문 생성"을 두 번 클릭하면 (user_id, cart_id)에 고유 제약이 있으면 두 주문이 생성되지 않습니다. 한 주문만 생성되고 다른 하나는 깔끔하게 거부됩니다.
일부 쓰기는 단일 문장이 아닙니다. 행을 읽고 규칙을 확인하고 상태를 업데이트하고 감사 로그를 삽입할 수도 있습니다. 두 요청이 동시에 하면 둘 다 검사를 통과하고 둘 다 쓰는 상황이 생깁니다. 이것이 고전적 실패 패턴입니다.
다단계 쓰기를 하나의 데이터베이스 트랜잭션으로 감싸 모든 단계가 함께 성공하거나 함께 실패하게 하세요. 더 중요한 건 트랜잭션이 같은 데이터를 동시에 누가 바꿀 수 있는지 통제할 장소를 제공한다는 점입니다.
하나의 주체만 레코드를 편집할 수 있어야 한다면 행 레벨 락을 쓰세요. 예: 주문 행을 잠그고 여전히 "pending" 상태인지 확인한 뒤 "approved"로 바꾸고 감사 항목을 쓰세요. 두 번째 요청은 대기했다가 상태를 다시 확인하고 중단합니다.
충돌 빈도에 따라 선택하세요:
락을 짧게 유지하세요. 락을 잡고 있는 동안 가능한 최소한의 작업만 수행하세요: 외부 API 호출, 느린 파일 작업, 큰 루프를 하지 마세요. Koder.ai 같은 도구로 플로우를 만들면 트랜잭션은 데이터베이스 단계만 감싸고 나머지는 커밋 후에 수행하세요.
충돌 시 금전적 손해나 신뢰를 잃을 수 있는 하나의 흐름을 선택하세요. 흔한 예는: 주문 생성 → 재고 예약 → 주문 상태를 확인됨으로 설정.
현재 코드가 구체적으로 어떤 단계를 밟는지 순서대로 적으세요. 무엇을 읽고, 무엇을 쓰는지, "성공"이 무엇인지 명확히 하세요. 충돌은 읽기와 그 다음 쓰기 사이의 간극에 숨어 있습니다.
대부분의 스택에서 작동하는 강화 경로:
update stock where stock >= qty처럼 안전하게 실패하도록).수정이 올바른지 증명하는 테스트 하나를 추가하세요. 동일한 상품과 수량에 대해 두 요청을 동시에 실행하고 정확히 하나의 주문만 확인되고 다른 하나는 통제된 방식으로 실패하는지(assert) 확인하세요(음수 재고 없음, 중복 예약 행 없음).
Koder.ai로 앱을 빠르게 생성하더라도 중요한 몇 가지 쓰기 경로에는 이 체크리스트를 적용할 가치가 있습니다.
가장 큰 원인 중 하나는 UI를 신뢰하는 것입니다. 버튼 비활성화와 클라이언트 검사로 도움이 되지만 사용자는 더블클릭하거나 새 탭을 열거나 요청을 재생할 수 있습니다. 서버가 멱등하지 않으면 중복이 빠져나갑니다.
조용한 버그: 데이터베이스 오류(예: 고유 제약 위반)를 잡아놓고 워크플로를 계속 진행하는 경우입니다. 흔히 "생성 실패했지만 이메일은 보냈다"거나 "결제 실패했지만 주문을 결제 처리로 표시했다" 같은 상황이 됩니다. 사이드 이펙트가 발생하면 되돌리기 어렵습니다.
긴 트랜잭션도 함정입니다. 이메일, 결제, 타사 API 호출을 하며 트랜잭션을 열어두면 락을 불필요하게 오래 잡습니다. 그로 인해 대기, 타임아웃, 요청 차단이 늘어납니다.
백그라운드 잡과 사용자 액션이 단일 진실 소스 없이 혼합되면 분기 상태(split-brain)가 생깁니다. 잡이 재시도되어 행을 업데이트하는 동안 사용자가 편집하면 둘 다 마지막 작성자라고 생각할 수 있습니다.
몇 가지 '해결책'이 실제로는 해결하지 못하는 것들:
Koder.ai 같은 채팅-투-앱 도구로 빌드하더라도 같은 규칙이 적용됩니다: 서버 측 제약과 명확한 트랜잭션 경계를 요구하세요. 단지 UI만 깔끔하게 하는 것으로는 부족합니다.
경쟁 조건은 실제 트래픽에서만 드러나는 경우가 많습니다. 출시 전 점검으로 대부분의 충돌 지점을 고칠 수 있습니다.
데이터베이스부터 시작하세요. 어떤 것이 고유해야 한다면(이메일, 송장 번호, 사용자당 하나의 활성 구독 등) 진짜 고유 제약으로 만드세요. 앱 레벨의 "먼저 확인" 규칙이 아니어야 합니다. 그런 다음 코드가 제약 실패를 때때로 기대하고 명확하고 안전한 응답을 반환하는지 확인하세요.
다음으로 상태를 살펴보세요. 모든 상태 변화(Draft -> Submitted -> Approved)는 허용된 전환 집합에 대해 검증되어야 합니다. 두 요청이 같은 레코드를 이동하려 할 때 두 번째는 거부되거나 아무 일도 일어나지 않는(no-op) 방식이 되어야 중간 상태가 생기지 않습니다.
실용적인 배포 전 체크리스트:
Koder.ai에서 플로우를 생성한다면 이를 수락 기준으로 삼으세요: 생성된 앱은 반복과 동시성에서 안전하게 실패해야 하고, 단지 해피패스만 통과해서는 안 됩니다.
두 명의 직원이 같은 구매 요청을 엽니다. 둘 다 몇 초 내에 승인 버튼을 누릅니다. 두 요청이 서버에 도달합니다.
잘못될 수 있는 것은 복잡합니다: 요청이 두 번 "승인"되어 두 개의 알림이 나가고, 승인에 묶인 예산이나 일일 승인 수 같은 합계가 2만큼 증가할 수 있습니다. 두 업데이트 모두 개별적으로는 유효하지만 충돌합니다.
PostgreSQL 스타일 데이터베이스에서 잘 작동하는 해결 계획은 다음과 같습니다.
승인 레코드를 별도 테이블에 저장하고 request_id에 고유 제약을 두세요. 이제 두 번째 삽입은 앱 코드에 버그가 있어도 실패합니다.
승인 시에는 전체 전환을 하나의 트랜잭션에서 처리하세요:
두 번째 직원이 늦게 도착하면 0행이 업데이트되거나 고유 제약 오류가 발생합니다. 어쨌든 한 번만 변경이 이루어집니다.
수정을 적용하면 첫 번째 직원은 정상적인 확인과 함께 Approved를 봅니다. 두 번째 직원은 "이미 다른 사람이 이 요청을 승인했습니다. 최신 상태를 보려면 새로고침하세요." 같은 친절한 메시지를 보게 됩니다. 회전이나 중복 알림, 조용한 실패는 없습니다.
Koder.ai로 CRUD 흐름을 생성한다면 approve 액션에 이 체크를 한 번만 구현해두고 다른 "한 명만 승리"해야 하는 액션들에 재사용할 수 있습니다.
경쟁 조건은 일회성 버그 사냥이 아니라 반복 가능한 루틴으로 다룰 때 고치기 쉽습니다. 가장 중요한 몇몇 쓰기 경로에 집중해 먼저 확실하게 만들고 그 다음에 나머지를 다듬으세요.
우선 최상위 충돌 지점을 이름으로 적으세요. 많은 CRUD 앱에서 흔한 삼총사는: 카운터(좋아요, 재고, 잔액), 상태 변경(Draft -> Submitted -> Approved), 이중 제출(더블클릭, 재시도, 느린 네트워크)입니다.
유지되는 루틴 예:
Koder.ai에서 빌드한다면 Planning Mode는 각 쓰기 흐름을 단계와 규칙으로 매핑하기 좋은 곳입니다. 제약이나 락 동작을 도입할 때 스냅샷과 롤백은 문제 발생 시 빠르게 되돌릴 수 있어 유용합니다.
시간이 지나며 습관이 됩니다: 새로운 쓰기 기능이 추가될 때마다 제약, 트랜잭션 계획, 동시성 테스트가 따라오는 것입니다. 그렇게 하면 CRUD 앱의 경쟁 조건은 더 이상 놀라움이 아닙니다.