diff --git a/CLAUDE.md b/CLAUDE.md index adde85d..bdb13be 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,3 +151,4 @@ TECHSPEC 코드 패턴을 **정확히** 따를 것. - **커밋 메시지:** Conventional Commits 형식 - **문자열:** 더블쿼트 우선 (rubocop-rails-omakase 기본) - **Skinny Controller, Fat Model:** 비즈니스 로직과 쿼리는 모델(scope, 메서드)에 두고, 컨트롤러는 요청/응답 흐름만 담당 +- **데이터 무결성은 모델이 보호한다:** 마감, 정원, 중복 등의 규칙은 반드시 모델에서 검증한다. 컨트롤러의 사전 체크는 UX용 빠른 거부일 뿐, 데이터 보호의 주체는 모델이다. diff --git a/PLAN.md b/PLAN.md index 8a1ed06..c2cb34c 100755 --- a/PLAN.md +++ b/PLAN.md @@ -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 --- diff --git a/TECHSPEC.md b/TECHSPEC.md index 5b3265e..983237d 100755 --- a/TECHSPEC.md +++ b/TECHSPEC.md @@ -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: "이미 동일한 이름과 전화번호로 신청된 내역이 있습니다." @@ -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 ``` @@ -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 시점에 자동) @@ -655,9 +653,9 @@ end **흐름:** -1. 마감 여부 먼저 체크 (트랜잭션 불필요) +1. 컨트롤러: `available?`로 빠른 거부 (UX용, 트랜잭션 불필요) 2. 마감 시 사유별 메시지 반환 -3. 통과 시 6.2 정원 + 중복 체크 (트랜잭션) 진행 +3. 통과 시 `create_registration!` → 트랜잭션 내 마감 + 정원 + 중복 재검증 (데이터 무결성 보장) --- diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 255e0d3..9021f35 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -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), diff --git a/app/models/course.rb b/app/models/course.rb index 8d6462d..82abe87 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -1,5 +1,6 @@ class Course < ApplicationRecord class CapacityExceededError < StandardError; end + class RegistrationClosedError < StandardError; end belongs_to :race has_many :registrations, dependent: :destroy @@ -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 diff --git a/app/models/race.rb b/app/models/race.rb index bc8352a..a5b7705 100644 --- a/app/models/race.rb +++ b/app/models/race.rb @@ -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 diff --git a/docs/resume.md b/docs/resume.md index 70d5a85..17accad 100644 --- a/docs/resume.md +++ b/docs/resume.md @@ -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단계 방어 @@ -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 | diff --git a/test/integration/registration_form_test.rb b/test/integration/registration_form_test.rb index d43ae2d..f5a3c6b 100644 --- a/test/integration/registration_form_test.rb +++ b/test/integration/registration_form_test.rb @@ -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 = "김철수" diff --git a/test/models/course_test.rb b/test/models/course_test.rb index dfb9d36..a5023f5 100644 --- a/test/models/course_test.rb +++ b/test/models/course_test.rb @@ -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 diff --git a/test/models/race_test.rb b/test/models/race_test.rb index ee02592..a031544 100644 --- a/test/models/race_test.rb +++ b/test/models/race_test.rb @@ -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