Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
- 마일스톤 시작 시 `feat/m{번호}-{설명}` 브랜치를 생성하고 전환한다
- 브랜치 생성/전환/머지는 사용자가 직접 수행
- 에이전트는 브랜치 생성/전환/커밋을 수행하되, 머지와 push는 하지 않는다
- 코드 작성 전 반드시 확인: (1) 현재 브랜치가 작업 대상 마일스톤과 일치하는지 (2) 이전 마일스톤 브랜치가 main에 머지되었고 현재 브랜치에 반영되었는지

---

Expand Down
14 changes: 7 additions & 7 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down
3 changes: 3 additions & 0 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion app/models/registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
108 changes: 108 additions & 0 deletions docs/resume.md
Original file line number Diff line number Diff line change
@@ -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 |
20 changes: 20 additions & 0 deletions test/integration/registration_form_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "김철수"
Expand Down
37 changes: 37 additions & 0 deletions test/models/registration_duplicate_test.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions test/models/registration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down