[Feat/#23] 행사 신청서 폼 조회·신청 API 추가 - #26
Conversation
| /** | ||
| * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. | ||
| */ | ||
| public boolean isCapacityFull(long appliedCount) { |
There was a problem hiding this comment.
이렇게 도메인 안에서 도메인 자체의 메서드를 만들어서 검증하는 방식 너무 좋습니다!!
아주 좋아요~
| @Getter | ||
| @EqualsAndHashCode | ||
| @AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
| public class EventApplicationResult { |
There was a problem hiding this comment.
이런 vo들은 record로 만들어도 좋을 거 같아요~
There was a problem hiding this comment.
말씀해주신대로 record로 수정했습니다 !
|
|
||
| Optional<Event> findById(Long id); | ||
|
|
||
| List<EventQuestion> findQuestionsByEventId(Long eventId); |
There was a problem hiding this comment.
다른 엔티티를 조회하는 레퍼지토리지만 같은 도메인이라 이렇게 묶어놓은 거 너무 좋네요
| * 질문을 조회한 뒤에야 판정할 수 있는 규칙을 모아 둔다. | ||
| */ | ||
| @Component | ||
| class EventApplyAnswerValidator { |
There was a problem hiding this comment.
Validator 컴포넌트 따로 만들어서 응집 강화한 거 너무 좋습니다~
| if (hasAnswerText || selectedOptions.isEmpty()) { | ||
| throw new BusinessException(EventErrorCode.INVALID_ANSWER); | ||
| } | ||
| if (new HashSet<>(selectedOptions).size() != selectedOptions.size()) { |
There was a problem hiding this comment.
중복 제거가 목적이라면 직접 HashSet을 선언하는 것 말고 다른 방법이 더 있을 거 같은데 한 번 더 찾아봐주시면 좋을 거 같아요
There was a problem hiding this comment.
stream().distinct()를 쓰는 방식으로 바꿨습니다. 다만 성능에 큰 차이가 없어서 생각하신 방법이 있으시다면 알려주시면 감사하겠습니다 !
| eventApplyAnswerValidator.validate(questions, command); | ||
|
|
||
| EventApplication application = eventRepository.saveApplication(EventApplication.of( | ||
| null, eventId, memberId, EventApplicationStatus.APPLIED, LocalDateTime.now(), null |
There was a problem hiding this comment.
id를 받지 않는 EventApplication.create()같은 정적 팩토리 메서드를 하나 만들어서 id에 null이 들어가는 걸 코드에 이렇게 공개하지 않아도 될 거 같아요!~
There was a problem hiding this comment.
반영했습니다 ! EventApplicationAnswer도 같이 수정했습니다.
| * 닫혀 있으면 사유를 가른다. 강제 마감·기간 종료가 정원 마감보다 앞선 사유다. | ||
| */ | ||
| private Event getOpenEvent(Long eventId) { | ||
| Event event = eventRepository.findById(eventId) |
There was a problem hiding this comment.
지금 폼 조회할때 findById를 써서 삭제 여부만 확인하는데, 관리자 쪽에서 공개하지않은 행사도 통과할 것 같습니다
행사 목록api 구현할때 events에 is_published 컬럼을 추가하면서 목록·상세 조회는 공개한 행사만 내려주도록 막아뒀는데, 폼 조회와 신청은 그대로 열려 있어서 eventId만 알면 미공개 행사에 신청이 가능해지고 관리자쪽에서 꼬일 수 있을 것 같아서 findPublishedById로 수정해주셔야 할 것 같습니다..!
is_published 컬럼과 findPublishedById는 #27에서 추가하니 머지된 뒤에 반영하셔도 될 것 같습니다
There was a problem hiding this comment.
확인 후 수정했습니다.
괜찮으시다면 작업중이신 브랜치에서 findById -> findPublishedById 한 줄을 같이 바꿔주시면 어떨까요?
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
학생 앱의 행사 신청 화면을 구성하는 API 2개가 없습니다.
GET /v1/app/events/{eventId}/form— 신청서 질문·선택지 조회POST /v1/app/events/{eventId}/applications— 답변 제출 및 참가 신청 확정core:domain:event에는 #13에서 만든 도메인 객체·JPA 엔티티·테이블만 있고 Service/Repository/Controller가 전혀 없었습니다. 이 PR이 event 도메인의 첫 동작 레이어입니다.❓ 왜 해결해야 하나요?
행사 신청은 학생 앱의 핵심 기능이고, 상세 화면의 신청 버튼이 도달할 곳이 아직 없습니다. 또한 이 PR에서 만드는 모집 상태 계산 로직을 행사 상세 조회 API가 그대로 써야 합니다(아래 리뷰 포인트 참조).
⭐ 어떻게 해결했나요?
architecture.md5절의 레이어를 그대로 쌓았습니다.모집 상태를 서버가 계산합니다
저장된
events.recruit_status는 운영진의 강제 마감만 뜻합니다. 실제 상태는 신청 기간과 잔여 정원을 함께 봐야 나오므로Event가 계산합니다.recruit_status = CLOSED) 또는now > applyEndAtCLOSEDnow < applyStartAtBEFORE_OPENFIRST_COME&& 신청자수 >=capacityCLOSEDOPENFIRST_COME일 때만 봅니다. 상시 모집(OPEN)은 인원 제한이 없습니다.OPEN일 때만 허용합니다. 두 API가 같은 기준으로 열리도록getOpenEvent()하나를 공유합니다.답변 길이 제한은
QuestionType의 상수입니다단답형 50자·장문형 500자는 DB 컬럼이 아니라 질문 유형에 딸린 정책이라 enum에 뒀습니다. 폼 조회 응답의
maxLength와 신청 API의 길이 검증이 같은 상수 하나를 봅니다. 마이그레이션이 없습니다.답변 검증은 질문을 조회한 뒤에 합니다
같은
answerText라도 질문 유형에 따라 한도가 달라 Bean Validation으로는 처리할 수 없습니다. 규칙이 많아EventApplyAnswerValidator(package-private 협력 객체)로 분리했습니다.questionId/ 같은 질문 중복 답변INVALID_ANSWERINVALID_ANSWERSHORT_TEXT·LONG_TEXT— 답변 필수, 선택지 비어야 함, 길이 ≤maxLengthINVALID_ANSWERSINGLE_CHOICE— 선택지 정확히 1개INVALID_ANSWERMULTIPLE_CHOICE— 선택지 1개 이상, 중복 선택 불가INVALID_ANSWERselectedOptions가options배열의 0-based 인덱스 범위 밖INVALID_ANSWER선택 질문의 빈 답변은 생략과 같게 봅니다 — 검증을 건너뛰고 저장도 하지 않습니다. 프론트가 모든 질문을 배열에 담고 미답변만 비워 보내도 통과합니다. 필수 질문이 비어 있으면 생략이든 빈 값이든
INVALID_ANSWER입니다.에러 코드 (
EventErrorCode)EVENT_NOT_FOUNDALREADY_CLOSEDCAPACITY_FULLALREADY_APPLIEDINVALID_ANSWER🧩 이 PR의 한계 & 트레이드오프
1. 동시성 — 중복 신청·정원 초과가 경합에서 뚫립니다. (별도 이슈 필요)
count/exists조회와insert사이에 락이 없는 check-then-act이고,event_applications에 유니크 제약이 없습니다(idx_event_applications_event_id_member_id는 일반 인덱스).APPLIED행이 2건 생길 수 있습니다.capacity를 넘긴APPLIED행이 생길 수 있습니다.의도적으로 미뤘습니다. 해결안은 검토해 뒀습니다 — 중복은
flyway-migration.md3-4절의 generated column 패턴(status = 'APPLIED'일 때만member_id를 갖는 가상 컬럼에 유니크)으로 막을 수 있고, 정원은 행사 행에 비관적 락이 필요합니다. 둘 다 마이그레이션·락 설계가 들어가 이번 범위에서 분리했습니다.2. 성공 메시지가 명세와 다릅니다. 명세는
"행사 신청이 완료되었습니다."인데ApiResponse.success()가"요청에 성공했습니다."로 고정입니다. 커스텀 메시지 팩토리가 없고, 공용ApiResponse(common-api) 변경은 다른 팀에 영향이 있어 이번 범위에서 뺐습니다. 필요하면 별도로 논의하면 좋겠습니다.3.
INVALID_ANSWER가 어느 질문 때문인지 알려주지 않습니다. 형식 위반이 전부 한 코드로 묶여 프론트가 사용자에게 지목해 줄 수 없습니다. 프론트가 제출 전에 막는 흐름이라 정상 사용에서는 뜨지 않을 에러로 보고 미뤘습니다.4. 신청 취소 API가 없습니다.
EventApplicationStatus.CANCELED는 이번 작업에서 재신청 판별에만 씁니다. 취소는 별도 이슈입니다.5. Swagger 문서화가 없습니다. springdoc이 아직 프로젝트에 없어
error-handling.md6절의@ApiErrorCode를 쓸 수 없습니다.⛓️ 기존 기능에 미치는 영향
V2__create_event_tables.sql(stream-init.sql 기반 JPA 엔티티·Flyway 마이그레이션 구성 #13)의 테이블과 인덱스를 그대로 씁니다.Event·QuestionType(메서드·필드 추가)과api/app-api/build.gradle.kts(core:domain:event의존 한 줄)뿐입니다. 기존 동작을 바꾸지 않습니다.api/app-api/build.gradle.kts는 [Feat/#19] 게시판(공지) 목록/상세 조회 API 추가 #21(공지 API)도 건드립니다. 의존 한 줄씩 추가라 충돌이 나도 해소가 간단합니다.events가 소프트 삭제 대상이라 조회에findByIdAndIsDeletedFalse를 씁니다.gradle clean check(ModularityTests.verify()+DomainImplAccessTests) 통과를 확인했습니다.🔀 Edge Case & 실패 시나리오
EVENT_NOT_FOUND404BEFORE_OPEN)ALREADY_CLOSED409 — 명세대로 "아직 안 열림"을 마감과 합칩니다ALREADY_CLOSED409CAPACITY_FULL409APPLIED신청이 있음ALREADY_APPLIED409CANCELED) 후 재신청questions: [], 신청은answers: []로 통과options가 NULLanswers에 null 원소 ({"answers":[null]})INVALID_INPUT. 리스트 원소에@NotNull을 걸어 NPE(500)를 막았습니다answerText: ""📋 검토한 대안과 선택 이유
1. 두 API를 별도 이슈로 분리 — 어느 쪽을 먼저 하든 Repository/Service/RepositoryImpl/Controller 스캐폴딩을 첫 PR이 다 떠안고, 신청 API의 핵심 검증이 전부
EventQuestion을 참조해 리뷰어가 두 PR을 오가야 합니다. 하나로 묶었습니다.2. 모집 상태를 DB 컬럼만으로 판단 — 신청 기간이 지나도 스케줄러가 갱신하기 전까지
OPEN으로 남아 행사 상세 조회 명세와 어긋납니다. 읽는 시점에 계산합니다.3.
maxLength를 DB 컬럼으로 추가 — 질문마다 다른 값을 줄 계획이 없어(유형별 고정) 마이그레이션 없이 enum 상수로 뒀습니다. 운영진이 질문별로 설정하는 기획이 생기면 그때 컬럼을 추가하면 됩니다.4. 답변 검증을
EventServiceImpl에 인라인 — 규칙이 8개라 서비스가 뚱뚱해져service.impl협력 객체로 분리했습니다(coding-style.md2-7절).5. 정원 마감을 모집 상태 계산보다 먼저 검사 — 초안은 그랬으나, 3개월 전 끝난 행사가 정원까지 찼으면 "행사 마감"이 아니라 "정원 마감"이 나가는 문제가 있어 상태 계산을 먼저 하고 정원이 유일한 사유일 때만
CAPACITY_FULL로 가르도록 고쳤습니다.💬 리뷰 포인트
[r]Event.calculateRecruitStatus()를 행사 상세 조회 API가 재사용해야 합니다행사 상세 조회명세가
recruitStatus를 "서버가 계산한다(현재 시각·행사 상태·잔여 정원 기준)"고 정의하는데, 이 PR의 계산 규칙과 같아야 합니다. 각자 구현하면 상세 화면엔D-2인데 신청을 누르면 마감이 뜨는 불일치가 납니다.Event가 순수 도메인 객체라 메서드로 넣었습니다. 상세 조회 담당자는event.calculateRecruitStatus(now, appliedCount)를 그대로 쓰면 됩니다.appliedCount는EventRepository.countAppliedByEventId()로 얻습니다.참고로
Event에 로직이 들어간 첫 사례입니다. 지금까지 도메인 객체들은 순수 데이터 홀더였습니다. 이 방향이 맞는지 봐주세요.[c]명세 수정이 필요한 지점questions[].maxLength를 추가했습니다. 폼 조회 Response 표에 없는 필드입니다. 단답형 50자·장문형 500자를 클라이언트가 하드코딩하지 않도록 서버가 내려줍니다. 선택형 질문은null입니다.[a]신청 응답에applicationStatus를 넣지 않았습니다명세에 enum 설명은 있으나 Response 표와 예시 JSON 모두에 필드가 없고, 성공 시 항상
APPLIED라 정보량이 없어 뺐습니다. 사용 뷰에서 필요 없다는 것도 확인했습니다.