diff --git a/CLAUDE.md b/CLAUDE.md index a99fcfb..adde85d 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ - 마일스톤 시작 시 `feat/m{번호}-{설명}` 브랜치를 생성하고 전환한다 - 브랜치 생성/전환/머지는 사용자가 직접 수행 - 에이전트는 브랜치 생성/전환/커밋을 수행하되, 머지와 push는 하지 않는다 +- 코드 작성 전 반드시 확인: (1) 현재 브랜치가 작업 대상 마일스톤과 일치하는지 (2) 이전 마일스톤 브랜치가 main에 머지되었고 현재 브랜치에 반영되었는지 --- diff --git a/Gemfile.lock b/Gemfile.lock index 29ac397..4a8207c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -191,17 +191,17 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.19.0-aarch64-linux-gnu) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-aarch64-linux-musl) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-arm-linux-musl) + nokogiri (1.19.1-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.0-x86_64-linux-musl) + nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.3) parallel (1.27.0) @@ -224,7 +224,7 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.4) + rack (3.2.5) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) diff --git a/PLAN.md b/PLAN.md index 665cf1c..8a1ed06 100755 --- a/PLAN.md +++ b/PLAN.md @@ -187,19 +187,19 @@ bundle exec rails runner "puts 'OK'" **Unit Tests** -- [ ] 동일 (race_id, name, phone_number) 중복 저장 시 에러 +- [x] 동일 (race_id, name, phone_number) 중복 저장 시 에러 **Concurrency Tests (P0)** ← Issues #1 참조 -- [ ] 동일 정보로 동시 신청 2건 → 1건만 성공 +- [x] 동일 정보로 동시 신청 2건 → 1건만 성공 **Integration Tests** -- [ ] 중복 시 에러: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." +- [x] 중복 시 에러: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." **완료 조건:** 중복 신청 차단, 동시성 테스트 통과 -- Commits: +- Commits: ca85d33, a5862cc, f897a6a --- diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 532efee..255e0d3 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -10,6 +10,9 @@ def create redirect_to root_path rescue Course::CapacityExceededError => e redirect_to new_course_registration_path(@course), alert: e.message + rescue ActiveRecord::RecordNotUnique + redirect_to new_course_registration_path(@course), + alert: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." rescue ActiveRecord::RecordInvalid => e @registration = e.record render :new, status: :unprocessable_entity diff --git a/app/models/registration.rb b/app/models/registration.rb index 872cdc8..368abfd 100644 --- a/app/models/registration.rb +++ b/app/models/registration.rb @@ -9,7 +9,10 @@ class Registration < ApplicationRecord normalizes :name, with: ->(name) { name.gsub(/\s+/, "") } normalizes :phone_number, with: ->(phone_number) { phone_number.gsub(/\D/, "") } - validates :name, presence: true, length: { maximum: 10 } + validates :name, presence: true, length: { maximum: 10 }, uniqueness: { + scope: [ :race_id, :phone_number ], + message: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." + } validates :phone_number, presence: true, length: { is: 11 } validates :birth_date, :gender, presence: true validates :address, presence: true, length: { maximum: 30 } diff --git a/docs/resume.md b/docs/resume.md new file mode 100644 index 0000000..70d5a85 --- /dev/null +++ b/docs/resume.md @@ -0,0 +1,108 @@ +# 동시성 환경에서의 데이터 무결성 보장 + +마라톤 대회 신청 시스템에서 정원 초과와 중복 신청을 방지하기 위해 설계한 다층 방어 전략. + +--- + +## 해결해야 할 문제 + +### 정원 초과 + +정원이 1명 남은 상태에서 2명이 동시에 신청하면, 둘 다 `SELECT COUNT`에서 여유 있음으로 판단하고 INSERT에 성공하여 정원을 초과한다. + +### 중복 신청 + +같은 사람이 브라우저 탭 2개로 동시에 제출하면, 둘 다 uniqueness validation의 `SELECT`에서 레코드 없음으로 판단하고 INSERT에 성공하여 중복 저장된다. + +--- + +## 방어 전략 + +### 정원 초과 방지: 트랜잭션 + Row Lock + +```ruby +Course.transaction do + lock! # SELECT ... FOR UPDATE + raise CapacityExceededError if full? + registrations.create!(params) +end +``` + +- `lock!`으로 같은 코스에 대한 동시 트랜잭션을 직렬화 +- 두 번째 요청은 첫 번째 커밋 이후에 count를 다시 확인하므로 정원 초과를 정확히 감지 + +### 중복 신청 방지: 3단계 방어 + +``` +[요청] → Model Validation → DB Unique Index → Controller Rescue + (순차 요청 차단) (동시 요청 차단) (500 에러 방지) +``` + +**1단계 — Model Validation (순차 요청 차단)** + +```ruby +validates :name, uniqueness: { + scope: [:race_id, :phone_number], + message: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." +} +``` + +- `SELECT`로 중복 여부를 확인하여 순차 요청은 100% 차단 +- `RecordInvalid` 발생 → 폼을 re-render하여 필드별 에러 메시지를 표시 +- 한계: SELECT와 INSERT 사이의 시간차로 동시 요청이 모두 통과할 수 있음 + +**2단계 — DB Unique Index (동시 요청 차단)** + +```ruby +add_index :registrations, [:race_id, :name, :phone_number], unique: true +``` + +- 1단계를 동시에 통과한 요청이 INSERT될 때 DB 레벨에서 물리적으로 차단 +- `RecordNotUnique` 발생 — `RecordInvalid`와 다른 예외 + +**3단계 — Controller Rescue (사용자 경험)** + +```ruby +rescue ActiveRecord::RecordNotUnique + redirect_to new_course_registration_path(@course), + alert: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." +``` + +- 2단계에서 발생한 `RecordNotUnique`를 잡지 않으면 500 에러로 노출됨 +- rescue를 추가하여 사용자에게 동일한 안내 메시지를 표시 + +### 다른 코스 동시 신청 문제 + +같은 사람이 5km, 10km에 동시 제출하는 경우: + +- `course.lock!`은 각각 다른 행을 잠그므로 직렬화되지 않음 +- 1단계 validation을 둘 다 통과할 수 있음 +- 이때 2단계 DB unique index + 3단계 rescue가 최종 안전망으로 작동 + +--- + +## 테스트 + +### 설계 원칙 + +- 동시성 테스트는 모델 레벨(`ActiveSupport::TestCase`)에서 작성 +- `Thread` + `ActiveRecord::Base.connection_pool.with_connection`으로 별도 DB 커넥션 확보 +- 통합 테스트의 `post`는 `@response` 인스턴스 변수를 공유하여 thread-safe하지 않으므로 사용 금지 + +### 검증 항목 + +| 시나리오 | 입력 | 기대 결과 | +|----------|------|-----------| +| 정원 1명, 동시 신청 2건 | 서로 다른 사람 | 1건 성공, 1건 `CapacityExceededError` | +| 동일 정보 동시 신청 2건 | 같은 사람 | 1건 성공, 1건 `RecordNotUnique` 또는 `RecordInvalid` | + +--- + +## 요약 + +| 계층 | 방어 대상 | 수단 | 실패 시 | +|------|-----------|------|---------| +| Application | 순차 중복 요청 | `validates uniqueness` | 폼 re-render + 에러 메시지 | +| Application | 정원 초과 | `lock!` + count 체크 | redirect + flash alert | +| Database | 동시 중복 요청 | Unique Index | `RecordNotUnique` 발생 | +| Controller | 500 에러 방지 | `rescue RecordNotUnique` | redirect + flash alert | diff --git a/test/integration/registration_form_test.rb b/test/integration/registration_form_test.rb index f62dc39..d43ae2d 100644 --- a/test/integration/registration_form_test.rb +++ b/test/integration/registration_form_test.rb @@ -57,6 +57,26 @@ class RegistrationFormTest < ActionDispatch::IntegrationTest assert_equal "선택하신 코스의 정원이 마감되었습니다.", flash[:alert] end + test "duplicate registration re-renders form with error message" do + existing = registrations(:hong_5km) + course = courses(:ten_km) + + assert_no_difference "Registration.count" do + post course_registrations_path(course), params: { + registration: { + name: existing.name, + phone_number: existing.phone_number, + birth_date: "1995-06-15", + gender: "female", + address: "부산시 해운대구" + } + } + end + + assert_response :unprocessable_entity + assert_select ".field-errors", text: /이미 동일한 이름과 전화번호로 신청된 내역이 있습니다/ + end + test "submitting with missing fields re-renders form with validation errors and preserves input" do course = courses(:five_km) kept_name = "김철수" diff --git a/test/models/registration_duplicate_test.rb b/test/models/registration_duplicate_test.rb new file mode 100644 index 0000000..fb5b67e --- /dev/null +++ b/test/models/registration_duplicate_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class RegistrationDuplicateTest < ActiveSupport::TestCase + test "concurrent duplicate registrations with same info allows only 1 success" do + course = courses(:ten_km) + params = { + name: "김철수", + phone_number: "01099998888", + birth_date: "1985-03-15", + gender: "male", + address: "부산시 해운대구" + } + results = Concurrent::Array.new + + threads = 2.times.map do + Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + course.create_registration!(params.dup) + results << :success + rescue ActiveRecord::RecordNotUnique + results << :duplicate + rescue ActiveRecord::RecordInvalid => e + if e.message.include?("이미 동일한 이름과 전화번호로") + results << :duplicate + else + raise + end + end + end + end + + threads.each(&:join) + + assert_equal 1, results.count(:success) + assert_equal 1, results.count(:duplicate) + end +end diff --git a/test/models/registration_test.rb b/test/models/registration_test.rb index 79ce208..441b708 100644 --- a/test/models/registration_test.rb +++ b/test/models/registration_test.rb @@ -49,6 +49,21 @@ class RegistrationTest < ActiveSupport::TestCase assert_includes registration.errors[:address], "is too long (maximum is 30 characters)" end + test "rejects duplicate registration with same race, name, and phone_number even on different course" do + existing = registrations(:hong_5km) + duplicate = Registration.new( + race: existing.race, + course: courses(:ten_km), + name: existing.name, + phone_number: existing.phone_number, + birth_date: "1995-06-15", + gender: "female", + address: "부산시 해운대구" + ) + assert_not duplicate.valid? + assert_includes duplicate.errors[:name], "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." + end + test "requires name, phone_number, birth_date, gender, and address" do registration = registrations(:hong_5km) registration.name = nil