Hun-Bot

Race Condition에 대한 기록

Database Race Condition Pulmuone

[2026-07-13] Race Condition

내가 했었던 간단한 기술적 고민 + 공부를 위해서 기록을 남겨두려고 한다. (해당 문제는 풀무원에서 HRD 통합 플랫폼을 구축하면서 발생할 수 있는 문제점에 대한 고민이다.)

환경은 Supabase(PostgreSQL), Next.js 서버는 Rocky Linux를 사용중이다.

상황

먼저, 본사와 법인간 교육 과정을 공유하기도 하고 새로운 교육 과정이 있다면 추가되기도 하는데, 이때 누군가 동시에 같은 교육과정을 추가할 수 도 있는 문제점이 있다고 생각한다.

  1. 페이지에서 새로운 교육과정을 추가한다 -> 교육과정에 DB에 반영되어 해당 교육 과정을 불러와 인원을 배정한다. 즉, Write -> DB -> Read로 거치는데 여기서 동시에 같은 교육과정이 입력되는 경우는 어떻게 핸들링해야하는가??

이렇게 구현할 경우, DB에 Read, Write하는 비용이 증가할 수도 있고, 동시에 같은 교육과정이 입력되는 경우가 발생할 수 있다는게 기본적인 생각이다.

예를 들어 본사 담당자 A와 법인 담당자 B가 거의 동시에 같은 교육과정을 등록한다고 하자.

A: 동일 과정이 있는지 조회 → 없음
B: 동일 과정이 있는지 조회 → 없음
A: INSERT
B: INSERT

페이지에서 먼저 중복을 조회하는 방식만 사용하면 두 요청 모두 “없다”고 판단할 수 있다. 따라서 중복 방지의 최종 책임은 PostgreSQL의 UNIQUE 제약조건에 둬야 한다.

2. DB에 복합 UNIQUE 제약조건을 둔다

예를 들어 같은 조직에서 같은 과정이 같은 시작일에 두 번 개설되면 안 된다고 정의한다면:

create table course_sessions (
    id uuid primary key default gen_random_uuid(),

    course_id uuid not null
        references courses(id),

    organization_id uuid not null
        references organizations(id),

    start_date date not null,
    end_date date not null,

    capacity integer not null
        check (capacity > 0),

    created_by uuid not null,
    created_at timestamptz not null default now(),

    constraint uq_course_session
        unique (
            course_id,
            organization_id,
            start_date
        )
);

이제 두 요청이 동시에 들어와도 PostgreSQL이 처리한다.

요청 A → INSERT 성공
요청 B → UNIQUE 위반으로 실패

UNIQUE 제약조건은 애플리케이션의 사전 조회와 달리, 동시 트랜잭션 사이에서도 중복을 원자적으로 차단한다. PostgreSQL은 각 SQL 문을 트랜잭션 안에서 실행하며, 충돌하는 동시 입력도 고유 제약조건을 기준으로 판정한다. (PostgreSQL)

3. SELECT → INSERT가 아니라 바로 INSERT한다

이렇게 하면 안 된다.

const existingCourse = await supabase
  .from('course_sessions')
  .select('id')
  .eq('course_id', courseId)
  .eq('organization_id', organizationId)
  .eq('start_date', startDate)
  .maybeSingle();

if (!existingCourse.data) {
  await supabase.from('course_sessions').insert(...);
}

조회와 입력 사이에 다른 사용자가 등록할 수 있기 때문이다.

대신 바로 입력하고, DB에서 중복 오류가 발생하면 처리한다.

const { data, error } = await supabase
  .from('course_sessions')
  .insert({
    course_id: courseId,
    organization_id: organizationId,
    start_date: startDate,
    end_date: endDate,
    capacity,
    created_by: userId,
  })
  .select()
  .single();

if (error) {
  if (error.code === '23505') {
    return {
      success: false,
      message: '동일한 교육과정 운영 차수가 이미 등록되어 있습니다.',
    };
  }

  throw error;
}

23505는 PostgreSQL의 unique_violation 오류 코드다.

페이지에서 중복 여부를 미리 검사하는 것은 사용자 편의를 위해 추가할 수 있지만, 그 검사는 보조 수단일 뿐이다.

프론트 중복 확인: 빠른 사용자 피드백
DB UNIQUE: 실제 데이터 무결성 보장

4. upsert를 사용할지는 업무 규칙에 따라 결정한다

Supabase는 PostgreSQL의 충돌 처리를 이용한 upsertonConflict를 지원한다. 충돌 기준으로 사용할 컬럼에는 실제 UNIQUE 제약조건이 있어야 한다. (Supabase)

const { data, error } = await supabase
  .from('course_sessions')
  .upsert(
    {
      course_id: courseId,
      organization_id: organizationId,
      start_date: startDate,
      end_date: endDate,
      capacity,
      created_by: userId,
    },
    {
      onConflict: 'course_id,organization_id,start_date',
      ignoreDuplicates: true,
    },
  )
  .select();

하지만 교육과정 등록에서는 보통 무조건적인 upsert보다 일반 insert가 더 안전하다.

왜냐하면 upsert의 설정을 잘못하면 나중에 들어온 사용자의 입력으로 기존 과정 정보가 덮어써질 수 있기 때문이다.

예를 들어:

A: 정원 30명으로 등록
B: 정원 50명으로 거의 동시에 등록

ON CONFLICT DO UPDATE라면 B의 요청이 A가 생성한 데이터를 50명으로 변경할 수 있다. 사용자는 신규 등록을 시도했는데 실제로는 다른 사용자의 데이터를 수정한 셈이다.

따라서 추천 방식은 다음과 같다.

신규 교육과정 등록
→ INSERT
→ 중복이면 409 Conflict
→ 기존 과정 정보를 사용자에게 표시
→ 사용자가 명시적으로 수정 여부 결정

5. Next.js에서는 409 Conflict로 반환한다

서버 액션이나 Route Handler에서 중복 오류를 업무 오류로 변환한다.

import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const body = await request.json();

  const { data, error } = await supabase
    .from('course_sessions')
    .insert({
      course_id: body.courseId,
      organization_id: body.organizationId,
      start_date: body.startDate,
      end_date: body.endDate,
      capacity: body.capacity,
      created_by: body.userId,
    })
    .select()
    .single();

  if (error?.code === '23505') {
    return NextResponse.json(
      {
        code: 'COURSE_SESSION_ALREADY_EXISTS',
        message: '같은 교육과정이 이미 등록되어 있습니다.',
      },
      { status: 409 },
    );
  }

  if (error) {
    return NextResponse.json(
      {
        code: 'COURSE_SESSION_CREATE_FAILED',
        message: '교육과정을 등록하지 못했습니다.',
      },
      { status: 500 },
    );
  }

  return NextResponse.json(data, { status: 201 });
}

프론트에서는 다음과 같이 보여줄 수 있다.

동일한 교육과정이 이미 등록되었습니다.

과정명: 개인정보보호교육
운영 조직: 본사
교육 시작일: 2026-07-20
등록자: 홍길동

[기존 과정 보기]

6. 과정 생성과 인원 배정을 함께 처리한다면 트랜잭션이 필요하다

현재 흐름이 다음과 같다고 했다.

교육과정 생성
→ 생성한 과정 조회
→ 대상 인원 배정

이 과정에서 교육과정은 생성됐는데 인원 배정이 실패할 수 있다.

과정 INSERT 성공
인원 INSERT 실패

두 작업이 반드시 함께 성공해야 한다면 하나의 PostgreSQL 함수로 묶고 Supabase RPC로 호출하는 편이 좋다.

create or replace function create_course_session_with_members(
    p_course_id uuid,
    p_organization_id uuid,
    p_start_date date,
    p_end_date date,
    p_capacity integer,
    p_created_by uuid,
    p_member_ids uuid[]
)
returns uuid
language plpgsql
security invoker
as $$
declare
    v_session_id uuid;
begin
    insert into course_sessions (
        course_id,
        organization_id,
        start_date,
        end_date,
        capacity,
        created_by
    )
    values (
        p_course_id,
        p_organization_id,
        p_start_date,
        p_end_date,
        p_capacity,
        p_created_by
    )
    returning id into v_session_id;

    insert into course_session_members (
        session_id,
        member_id
    )
    select
        v_session_id,
        unnest(p_member_ids);

    return v_session_id;
end;
$$;

호출:

const { data, error } = await supabase.rpc(
  'create_course_session_with_members',
  {
    p_course_id: courseId,
    p_organization_id: organizationId,
    p_start_date: startDate,
    p_end_date: endDate,
    p_capacity: capacity,
    p_created_by: userId,
    p_member_ids: memberIds,
  },
);

함수 내부에서 예외가 발생하면 전체 트랜잭션이 롤백된다.

과정 생성 성공 + 인원 배정 성공 → COMMIT
과정 생성 성공 + 인원 배정 실패 → 전체 ROLLBACK
과정 중복 → 전체 실패

여러 SQL을 하나의 트랜잭션으로 묶으면 전체 작업을 하나의 단위로 성공시키거나 실패시킬 수 있다. (PostgreSQL)

최종 권장 구조

Pulmuone 프로젝트에서는 다음 설계가 적절하다.

1. courses
   교육과정의 원본 정보

2. course_sessions
   본사·법인별 실제 개설 차수

3. course_session_members
   개설 차수별 대상 인원

4. course_sessions에 복합 UNIQUE
   (course_id, organization_id, start_date)

5. Next.js에서 바로 INSERT
   사전 SELECT 결과를 신뢰하지 않음

6. 중복 발생 시 PostgreSQL 23505
   → HTTP 409 Conflict로 변환

7. 과정 생성과 인원 배정이 하나의 업무라면
   PostgreSQL 함수 + Supabase RPC 트랜잭션

핵심은 **“동시에 입력되지 않도록 막는다”가 아니라, “동시에 입력되어도 DB가 단 하나만 성공시키게 한다”**는 것이다. Rocky Linux나 Next.js 서버 프로세스 수와 관계없이 PostgreSQL이 최종 데이터 무결성을 보장하도록 설계해야 한다.

수강 신청의 경우

database 1 / 1
이전 편 없음
다음 편 없음

목차

댓글