Skip to content
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,4 @@ TECHSPEC 코드 패턴을 **정확히** 따를 것.
- **커밋 메시지:** Conventional Commits 형식
- **문자열:** 더블쿼트 우선 (rubocop-rails-omakase 기본)
- **Skinny Controller, Fat Model:** 비즈니스 로직과 쿼리는 모델(scope, 메서드)에 두고, 컨트롤러는 요청/응답 흐름만 담당
- **데이터 무결성은 모델이 보호한다:** 마감, 정원, 중복 등의 규칙은 반드시 모델에서 검증한다. 컨트롤러의 사전 체크는 UX용 빠른 거부일 뿐, 데이터 보호의 주체는 모델이다.
10 changes: 5 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,17 +211,17 @@ bundle exec rails runner "puts 'OK'"

**Unit Tests**

- [ ] Race#registration_closed? - 마감일 경과 시 true
- [ ] Course#available? - 마감 OR 정원 초과 시 false
- [x] Race#registration_closed? - 마감일 경과 시 true
- [x] Course#available? - 마감 OR 정원 초과 시 false

**Integration Tests (P0)**

- [ ] 마감일 경과 후 신청 → 차단, 메시지: "신청 기간이 종료되었습니다."
- [ ] 정원 마감 후 신청 → 차단, 메시지: "선택하신 코스의 정원이 마감되었습니다."
- [x] 마감일 경과 후 신청 → 차단, 메시지: "신청 기간이 종료되었습니다."
- [x] 정원 마감 후 신청 → 차단, 메시지: "선택하신 코스의 정원이 마감되었습니다."

**완료 조건:** 마감 후 신청 완전 차단

- Commits:
- Commits: 7aac534, 4eb119b, e2c95f2, 2cf2c45, 71abcf8

---

Expand Down
16 changes: 7 additions & 9 deletions TECHSPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ end
def create
create_registration(@course, registration_params)
redirect_to complete_path, notice: "신청이 완료되었습니다."
rescue CapacityExceededError => e
rescue RegistrationClosedError, CapacityExceededError => e
redirect_to new_registration_path, alert: e.message
rescue ActiveRecord::RecordNotUnique
redirect_to new_registration_path, alert: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다."
Expand All @@ -584,12 +584,10 @@ def create_registration(course, params)
Course.transaction do
course.lock!

current_count = course.registrations.where(status: :applied).count
if current_count >= course.capacity
raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다."
end
raise RegistrationClosedError, "신청 기간이 종료되었습니다." if course.race.registration_closed?
raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다." if course.full?

course.registrations.create!(params.merge(status: :applied))
course.registrations.create!(params.merge(race: course.race, status: :applied))
end
end
```
Expand All @@ -598,7 +596,7 @@ end

1. 트랜잭션 시작
2. Course 행 잠금 (`lock!` → FOR UPDATE)
3. applied 상태 신청 수 확인
3. 마감일 경과? → RegistrationClosedError, 롤백, 끝
4. 정원 초과? → CapacityExceededError, 롤백, 끝
5. 정원 OK → Registration INSERT 시도
6. DB unique index 검사 (INSERT 시점에 자동)
Expand Down Expand Up @@ -655,9 +653,9 @@ end

**흐름:**

1. 마감 여부 먼저 체크 (트랜잭션 불필요)
1. 컨트롤러: `available?`로 빠른 거부 (UX용, 트랜잭션 불필요)
2. 마감 시 사유별 메시지 반환
3. 통과 시 6.2 정원 + 중복 체크 (트랜잭션) 진행
3. 통과 시 `create_registration!` → 트랜잭션 내 마감 + 정원 + 중복 재검증 (데이터 무결성 보장)

---

Expand Down
9 changes: 8 additions & 1 deletion app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ def new

def create
@course = Course.find(params[:course_id])

unless @course.available?
alert = @course.race.registration_closed? ? "신청 기간이 종료되었습니다." : "선택하신 코스의 정원이 마감되었습니다."
redirect_to new_course_registration_path(@course), alert: alert
return
end

@registration = @course.create_registration!(registration_params)
redirect_to root_path
rescue Course::CapacityExceededError => e
rescue Course::RegistrationClosedError, Course::CapacityExceededError => e
redirect_to new_course_registration_path(@course), alert: e.message
rescue ActiveRecord::RecordNotUnique
redirect_to new_course_registration_path(@course),
Expand Down
10 changes: 5 additions & 5 deletions app/models/course.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Course < ApplicationRecord
class CapacityExceededError < StandardError; end
class RegistrationClosedError < StandardError; end

belongs_to :race
has_many :registrations, dependent: :destroy
Expand All @@ -13,18 +14,17 @@ def full?
end

def available?
!full? && race.registration_deadline > Time.current
!race.registration_closed? && !full?
end

def create_registration!(params)
Course.transaction do
lock!

if full?
raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다."
end
raise RegistrationClosedError, "신청 기간이 종료되었습니다." if race.registration_closed?
raise CapacityExceededError, "선택하신 코스의 정원이 마감되었습니다." if full?

registrations.create!(params)
registrations.create!(params.merge(race: race))
end
end
end
4 changes: 4 additions & 0 deletions app/models/race.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@ class Race < ApplicationRecord
has_many :registrations, dependent: :destroy

scope :upcoming, -> { where("registration_deadline > ?", Time.current).order(:event_date) }

def registration_closed?
Time.current > registration_deadline
end
end
12 changes: 8 additions & 4 deletions docs/resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@

```ruby
Course.transaction do
lock! # SELECT ... FOR UPDATE
lock! # SELECT ... FOR UPDATE
raise RegistrationClosedError if race.registration_closed?
raise CapacityExceededError if full?
registrations.create!(params)
end
```

- `lock!`으로 같은 코스에 대한 동시 트랜잭션을 직렬화
- 두 번째 요청은 첫 번째 커밋 이후에 count를 다시 확인하므로 정원 초과를 정확히 감지
- 마감일 체크와 정원 체크를 동일한 트랜잭션 내에서 수행 — 모델이 자기 데이터를 스스로 보호
- 컨트롤러의 `available?` 사전 체크는 UX를 위한 빠른 거부용이고, 모델이 최종 안전장치

### 중복 신청 방지: 3단계 방어

Expand Down Expand Up @@ -102,7 +104,9 @@ rescue ActiveRecord::RecordNotUnique

| 계층 | 방어 대상 | 수단 | 실패 시 |
|------|-----------|------|---------|
| Application | 순차 중복 요청 | `validates uniqueness` | 폼 re-render + 에러 메시지 |
| Application | 정원 초과 | `lock!` + count 체크 | redirect + flash alert |
| Controller | 빠른 거부 (UX) | `available?` 사전 체크 | redirect + flash alert |
| Model | 마감일 초과 | `lock!` + `registration_closed?` | `RegistrationClosedError` |
| Model | 정원 초과 | `lock!` + count 체크 | `CapacityExceededError` |
| Model | 순차 중복 요청 | `validates uniqueness` | 폼 re-render + 에러 메시지 |
| Database | 동시 중복 요청 | Unique Index | `RecordNotUnique` 발생 |
| Controller | 500 에러 방지 | `rescue RecordNotUnique` | redirect + flash alert |
19 changes: 19 additions & 0 deletions test/integration/registration_form_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ class RegistrationFormTest < ActionDispatch::IntegrationTest
assert_select ".field-errors", text: /이미 동일한 이름과 전화번호로 신청된 내역이 있습니다/
end

test "registration fails when registration deadline has passed" do
course = courses(:closed_five_km)

assert_no_difference "Registration.count" do
post course_registrations_path(course), params: {
registration: {
name: "테스트",
phone_number: "01099999999",
birth_date: "1990-01-01",
gender: "male",
address: "서울시 강남구"
}
}
end

assert_redirected_to new_course_registration_path(course)
assert_equal "신청 기간이 종료되었습니다.", flash[:alert]
end

test "submitting with missing fields re-renders form with validation errors and preserves input" do
course = courses(:five_km)
kept_name = "김철수"
Expand Down
14 changes: 14 additions & 0 deletions test/models/course_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,18 @@ class CourseTest < ActiveSupport::TestCase
course = courses(:closed_five_km)
assert_not course.available?
end

test "create_registration! raises RegistrationClosedError when deadline has passed" do
course = courses(:closed_five_km)

assert_raises(Course::RegistrationClosedError) do
course.create_registration!(
name: "테스트",
phone_number: "01099999999",
birth_date: "1990-01-01",
gender: "male",
address: "서울시 강남구"
)
end
end
end
8 changes: 8 additions & 0 deletions test/models/race_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,12 @@ class RaceTest < ActiveSupport::TestCase
race = races(:marathon_2026)
assert_includes race.registrations, registrations(:hong_5km)
end

test "registration_closed? returns true when deadline has passed" do
assert races(:closed_race).registration_closed?
end

test "registration_closed? returns false when deadline is in the future" do
assert_not races(:marathon_2026).registration_closed?
end
end