Skip to content

[Feat/#27] 행사 목록·상세 조회 API 추가 - #29

Open
leegain1 wants to merge 16 commits into
feat/#23-event-applicationfrom
feat/#27-event-list-api
Open

[Feat/#27] 행사 목록·상세 조회 API 추가#29
leegain1 wants to merge 16 commits into
feat/#23-event-applicationfrom
feat/#27-event-list-api

Conversation

@leegain1

@leegain1 leegain1 commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

#️⃣연관된 이슈

⚠️ #26(feat/#23-event-application) 위에 쌓은 stacked PR입니다. base가 main이 아니라 feat/#23-event-application이라 diff에는 이 PR의 커밋 15개만 보입니다. #26이 머지되면 base를 main으로 옮기고 리베이스하겠습니다.

🎯 해결하려는 문제가 무엇인가요?

학생 앱에서 행사를 훑고 고를 수 있는 조회 API가 없습니다. 목록에서 행사를 찾고 상세에서 내용을 확인해야 #23의 신청서 폼 조회·신청으로 넘어갈 수 있는데, 그 앞단이 비어 있습니다.

두 화면 모두 "지금 신청할 수 있는지"와 "마감까지 며칠 남았는지"를 보여줘야 합니다. 이 값들은 events.recruit_status에 저장된 값만으로는 알 수 없습니다. 저장값은 운영진의 강제 마감만 뜻하고, 실제 상태는 신청 기간과 잔여 정원까지 봐야 나옵니다.

또한 운영진이 준비 중인 행사가 학생에게 노출되면 안 되는데, events에 공개 여부를 나타내는 컬럼이 없습니다.

Method Endpoint 설명
GET /v1/app/events 목록 조회 (커서 페이지네이션, 모집 상태 필터)
GET /v1/app/events/{eventId} 상세 조회

❓ 왜 해결해야 하나요?

행사 신청 플로우(#23)의 진입점입니다. 목록·상세가 없으면 이미 만든 폼 조회·신청 API를 쓸 방법이 없습니다.

⭐ 어떻게 해결했나요?

모집 상태와 D-Day는 조회 시점에 계산합니다. #23이 추가한 Event.calculateRecruitStatus(now, appliedCount)를 목록·상세가 그대로 호출합니다. 판정을 각자 구현하면 폼 조회·신청과 어긋날 수 있어 도메인 메서드 하나만 쓰도록 했습니다. 마감까지 남은 일수도 같은 이유로 Event.daysUntilDeadline(...)으로 올려 목록·상세가 공유합니다. D-Day는 모집 중일 때만 내려갑니다.

커서는 (event_start_at, event_id) keyset입니다. 정렬 키가 두 개라 nextCursorLong에 담을 수 없어 CursorSliceResult/CursorSliceResponse의 타입을 불투명 문자열로 바꿨습니다. 정렬 키의 문자열 표현은 도메인(EventCursor.format()/from())이, Base64 URL-safe 인코딩은 웹 계층(CursorCodec)이 맡습니다. coding-style.md 2-4절도 함께 갱신했습니다.

목록 조회는 keyset + size + 1 로 다음 페이지 존재 여부를 판정하고, 신청자 수는 페이지의 eventId를 모아 IN 쿼리 한 번으로 집계해 N+1을 피했습니다.

공개 여부events.is_published(V5)와 (is_published, is_deleted, event_start_at) 복합 인덱스를 추가했습니다. 필터 컬럼을 앞, 정렬 컬럼을 뒤에 둔 형태로 flyway-migration.md 3-4절을 따랐고, InnoDB가 세컨더리 인덱스 끝에 PK를 붙이므로 보조 정렬 키(event_id)까지 커버됩니다. 목록·상세 모두 게시된 행사만 내려주며, 상세는 미게시 행사를 EVENT_NOT_FOUND로 막습니다.

상세의 이미지 목록NoticeDetailResponse(#21)의 중첩 Image record 패턴을 따랐습니다.

🧩 이 PR의 한계 & 트레이드오프

1. 운영진이 행사를 공개할 방법이 아직 없습니다. is_publishedDEFAULT 0이라 이 PR만으로는 목록이 항상 비어 있습니다. admin-api에 행사 관리 엔드포인트가 하나도 없어서 당분간 DB에서 직접 켜야 합니다. 운영진 행사 등록·게시 API가 별도 이슈로 필요합니다.

2. 모집 상태 판정 규칙이 자바와 JPQL 두 곳에 있습니다. Event.calculateRecruitStatusEventJpaRepository.findPublishedSlice가 같은 규칙을 각자 구현합니다. 필터를 DB에서 걸지 않으면 페이지 크기를 맞출 수 없어 불가피했습니다. 한쪽을 고치면 반드시 다른 쪽도 고쳐야 하며 양쪽에 주석으로 명시했습니다.

3. 모집 상태 필터에 서브쿼리가 붙습니다. 필터가 주어지면 행마다 COUNT 서브쿼리로 잔여 정원을 따집니다. 페이지당 최대 101행만 평가하고 event_applications(event_id, member_id) 인덱스가 있어 당장은 문제되지 않지만, 신청자 규모가 커지면 events에 신청자 수를 비정규화하는 방안을 다시 봐야 합니다.

4. 이미지 URL이 항상 null입니다. 목록의 thumbnailUrl, 상세의 images[].fileUrl 모두 파일 키 → 공개 URL 조립이 #17에 있어 아직 못 채웁니다. EventListItemResponse.thumbnailUrlOf(...)EventDetailResponse.Image.from(...)만 채우면 되도록 조립 지점을 분리해뒀습니다.

5. JPQL이 런타임 검증되지 않습니다. 저장소에 JPA 통합 테스트 인프라(H2·Testcontainers)가 없어 gradle check로는 쿼리 문법·매핑·new 생성자 표현식이 검증되지 않습니다. 추가한 단위 테스트도 순수 도메인만 덮습니다. 로컬 MySQL로 확인이 필요합니다.

6. 통합/컨트롤러 테스트가 없습니다. 도메인 단위 테스트만 추가했습니다.

⛓️ 기존 기능에 미치는 영향

⚠️ Event.of(...) 시그니처가 바뀝니다. published 파라미터가 recruitStatuscreatedBy 사이에 들어갑니다. 이 때문에 #26의 EventTest 헬퍼가 컴파일되지 않아 함께 수정했습니다(fix: Event.of 시그니처에 게시 여부가 추가된 것을 EventTest 호출부에 반영).

⚠️ CursorSliceResult/CursorSliceResponsenextCursorLongString으로 바뀝니다. 현재 이 타입을 쓰는 API가 없어 깨지는 곳은 없습니다.

⚠️ #21(공지 목록)과 파일 3개가 겹칩니다CursorCodec, CursorSliceResult, CursorSliceResponse. 내용은 완전히 동일합니다. 먼저 머지되는 쪽 기준으로 나중 PR이 해당 커밋을 덜어내면 됩니다. 이번 PR에서 빼지 않은 이유는, 빼면 #21까지 머지되어야 컴파일이 되기 때문입니다.

EventdaysUntilDeadline(now, recruitStatus) 공개 메서드가 추가됩니다. 기존 동작 변화는 없습니다.

서버 타임존 요구사항 — D-Day는 날짜 단위로 계산하고 코드는 LocalDateTime.now()(JVM 기본 타임존)를 씁니다. 운영 서버의 JVM 기본 타임존이 Asia/Seoul이어야 합니다(TZ=Asia/Seoul 또는 -Duser.timezone=Asia/Seoul). JDBC는 .env.exampleserverTimezone=Asia/Seoul로 이미 KST를 전제하고 있습니다. 앱 코드로 못박지 않은 이유는 @PostConstructTimeZone.setDefault가 로깅·DataSource·Flyway가 기본 타임존을 잡은 뒤라 반쪽짜리이고, 배포 설정 영역이기 때문입니다.

🔀 Edge Case & 실패 시나리오

목록 — GET /v1/app/events

상황 처리
잘못된 커서 (Base64 깨짐) CommonErrorCode.INVALID_INPUT (400)
잘못된 커서 (형식·파싱 실패) EventErrorCode.EVENT_INVALID_CURSOR (400)
잘못된 recruitStatus EventErrorCode.EVENT_INVALID_RECRUIT_STATUS (400)
size 범위 밖 (1~100) Bean Validation (400)
size 미지정 20
마지막 페이지 hasNext = false, nextCursor = null
결과 없음 content, hasNext = false
행사 시작 일시가 같은 행사 여러 건 event_id 오름차순으로 갈라 커서가 건너뛰거나 중복되지 않음

상세 — GET /v1/app/events/{eventId}

상황 처리
없는 eventId EventErrorCode.EVENT_NOT_FOUND (404)
삭제된 행사 EVENT_NOT_FOUND (404)
미게시 행사 EVENT_NOT_FOUND (404) — 존재를 드러내지 않음
이미지가 없는 행사 images: [] (null 아님)
event_end_at이 NULL eventEndAt: null (스키마상 NULL 허용)

공통 (모집 상태 판정, Event에 위임)

상황 처리
운영진 강제 마감 신청 기간 안이어도 CLOSED
선착순 모집 정원 충족 CLOSED
상시 모집(recruitType = OPEN) capacity 값과 무관하게 정원으로 마감되지 않음
마감 당일 daysUntilDeadline = 0 (D-DAY)
모집 중이 아님 daysUntilDeadline = null

📋 검토한 대안과 선택 이유

오프셋 페이지네이션 — 무한 스크롤이라 전체 개수·페이지 번호가 필요 없고 뒤 페이지로 갈수록 느려집니다. coding-style.md 2-4절 기준으로 커서를 택했습니다.

모집 상태를 컬럼에 저장하고 스케줄러로 갱신 — 신청 기간 경계와 정원 충족 시점에 목록이 실제와 어긋납니다. 조회 시점 계산이 정확하고 갱신 작업도 필요 없습니다.

모집 상태 필터를 애플리케이션에서 적용size + 1을 읽어 자바에서 거르면 걸러낸 만큼 페이지가 비어 크기를 맞출 수 없습니다.

nextCursor를 인코딩 없이 노출 — 정렬 키 구조가 API 계약이 되어 나중에 바꾸기 어렵습니다. Base64로 감쌌습니다.

recruitStatus를 컨트롤러에서 enum으로 직접 바인딩 — 잘못된 값이 오면 MethodArgumentTypeMismatchException이 나는데 GlobalExceptionHandler에 핸들러가 없어 500이 나갑니다. String으로 받아 RecruitStatus.from으로 걸렀습니다.

엔드포인트를 명세서의 /v1/users/**config-and-auth.md 4절·architecture.md가 학생 앱을 /v1/app/**로 규정하고 SecurityConfig도 이 패턴으로 인가를 겁니다. #23의 폼 조회·신청도 /v1/app/events/**라 경로를 통일했습니다. 명세서 수정이 필요합니다.

상세 응답의 eventDateTime 단일 문자열 — 명세서 표와 JSON 예시가 어긋나는데, 예시 쪽(eventStartAt/eventEndAt 분리)이 목록과 필드명이 일치하고 표시 문구 조립 규칙(종료 일시 없을 때·날짜가 다를 때)을 서버가 떠안지 않아도 됩니다.

💬 리뷰 포인트

  • [r] EventJpaRepository.findPublishedSlice의 JPQL 필터Event.calculateRecruitStatus와 판정이 정확히 일치하는지 봐주세요. 세 필터(BEFORE_OPEN/OPEN/CLOSED)가 겹치거나 빠지는 행 없이 나뉘어야 합니다.
  • [r] Event.of(...) 시그니처 변경 — 파라미터를 끼워 넣는 방식이 맞는지, 아니면 다른 방법이 나은지 의견 주세요. #26의 EventTest를 함께 수정해야 했습니다.
  • [c] EventTest에 D-Day 테스트를 추가한 것Event가 계산 규칙을 소유하게 되어 EventTest에 넣었습니다. EventSummaryTest에 두는 편이 나으면 옮기겠습니다. 같은 이유로 EventSummaryTest에서 모집 상태 판정을 중복 검증하던 테스트는 위임 확인만 남기고 줄였습니다.
  • [c] RecruitStatus.from(String) — 웹 입력 파싱 책임을 도메인 enum에 뒀습니다. api 레이어로 옮기는 게 나을지 의견 주세요.
  • [a] EventSummary/EventDetail을 Response로 옮기는 매핑을 컨트롤러·Response 정적 팩토리에 나눠 뒀습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant