[Feat/#27] 행사 목록·상세 조회 API 추가 - #29
Open
leegain1 wants to merge 16 commits into
Open
Conversation
github-actions
Bot
requested review from
jjunh33,
sangrae2325 and
tnals0924
September 12, 2026 09:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
학생 앱에서 행사를 훑고 고를 수 있는 조회 API가 없습니다. 목록에서 행사를 찾고 상세에서 내용을 확인해야 #23의 신청서 폼 조회·신청으로 넘어갈 수 있는데, 그 앞단이 비어 있습니다.
두 화면 모두 "지금 신청할 수 있는지"와 "마감까지 며칠 남았는지"를 보여줘야 합니다. 이 값들은
events.recruit_status에 저장된 값만으로는 알 수 없습니다. 저장값은 운영진의 강제 마감만 뜻하고, 실제 상태는 신청 기간과 잔여 정원까지 봐야 나옵니다.또한 운영진이 준비 중인 행사가 학생에게 노출되면 안 되는데,
events에 공개 여부를 나타내는 컬럼이 없습니다.GET/v1/app/eventsGET/v1/app/events/{eventId}❓ 왜 해결해야 하나요?
행사 신청 플로우(#23)의 진입점입니다. 목록·상세가 없으면 이미 만든 폼 조회·신청 API를 쓸 방법이 없습니다.
⭐ 어떻게 해결했나요?
모집 상태와 D-Day는 조회 시점에 계산합니다. #23이 추가한
Event.calculateRecruitStatus(now, appliedCount)를 목록·상세가 그대로 호출합니다. 판정을 각자 구현하면 폼 조회·신청과 어긋날 수 있어 도메인 메서드 하나만 쓰도록 했습니다. 마감까지 남은 일수도 같은 이유로Event.daysUntilDeadline(...)으로 올려 목록·상세가 공유합니다. D-Day는 모집 중일 때만 내려갑니다.커서는
(event_start_at, event_id)keyset입니다. 정렬 키가 두 개라nextCursor를Long에 담을 수 없어CursorSliceResult/CursorSliceResponse의 타입을 불투명 문자열로 바꿨습니다. 정렬 키의 문자열 표현은 도메인(EventCursor.format()/from())이, Base64 URL-safe 인코딩은 웹 계층(CursorCodec)이 맡습니다.coding-style.md2-4절도 함께 갱신했습니다.목록 조회는 keyset +
size + 1로 다음 페이지 존재 여부를 판정하고, 신청자 수는 페이지의eventId를 모아IN쿼리 한 번으로 집계해 N+1을 피했습니다.공개 여부 —
events.is_published(V5)와(is_published, is_deleted, event_start_at)복합 인덱스를 추가했습니다. 필터 컬럼을 앞, 정렬 컬럼을 뒤에 둔 형태로flyway-migration.md3-4절을 따랐고, InnoDB가 세컨더리 인덱스 끝에 PK를 붙이므로 보조 정렬 키(event_id)까지 커버됩니다. 목록·상세 모두 게시된 행사만 내려주며, 상세는 미게시 행사를EVENT_NOT_FOUND로 막습니다.상세의 이미지 목록은
NoticeDetailResponse(#21)의 중첩Imagerecord 패턴을 따랐습니다.🧩 이 PR의 한계 & 트레이드오프
1. 운영진이 행사를 공개할 방법이 아직 없습니다.
is_published는DEFAULT 0이라 이 PR만으로는 목록이 항상 비어 있습니다.admin-api에 행사 관리 엔드포인트가 하나도 없어서 당분간 DB에서 직접 켜야 합니다. 운영진 행사 등록·게시 API가 별도 이슈로 필요합니다.2. 모집 상태 판정 규칙이 자바와 JPQL 두 곳에 있습니다.
Event.calculateRecruitStatus와EventJpaRepository.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파라미터가recruitStatus와createdBy사이에 들어갑니다. 이 때문에 #26의EventTest헬퍼가 컴파일되지 않아 함께 수정했습니다(fix: Event.of 시그니처에 게시 여부가 추가된 것을 EventTest 호출부에 반영).CursorSliceResult/CursorSliceResponse의nextCursor가Long→String으로 바뀝니다. 현재 이 타입을 쓰는 API가 없어 깨지는 곳은 없습니다.CursorCodec,CursorSliceResult,CursorSliceResponse. 내용은 완전히 동일합니다. 먼저 머지되는 쪽 기준으로 나중 PR이 해당 커밋을 덜어내면 됩니다. 이번 PR에서 빼지 않은 이유는, 빼면 #21까지 머지되어야 컴파일이 되기 때문입니다.Event에daysUntilDeadline(now, recruitStatus)공개 메서드가 추가됩니다. 기존 동작 변화는 없습니다.서버 타임존 요구사항 — D-Day는 날짜 단위로 계산하고 코드는
LocalDateTime.now()(JVM 기본 타임존)를 씁니다. 운영 서버의 JVM 기본 타임존이Asia/Seoul이어야 합니다(TZ=Asia/Seoul또는-Duser.timezone=Asia/Seoul). JDBC는.env.example의serverTimezone=Asia/Seoul로 이미 KST를 전제하고 있습니다. 앱 코드로 못박지 않은 이유는@PostConstruct의TimeZone.setDefault가 로깅·DataSource·Flyway가 기본 타임존을 잡은 뒤라 반쪽짜리이고, 배포 설정 영역이기 때문입니다.🔀 Edge Case & 실패 시나리오
목록 —
GET /v1/app/eventsCommonErrorCode.INVALID_INPUT(400)EventErrorCode.EVENT_INVALID_CURSOR(400)recruitStatus값EventErrorCode.EVENT_INVALID_RECRUIT_STATUS(400)size범위 밖 (1~100)size미지정hasNext = false,nextCursor = nullcontent,hasNext = falseevent_id오름차순으로 갈라 커서가 건너뛰거나 중복되지 않음상세 —
GET /v1/app/events/{eventId}eventIdEventErrorCode.EVENT_NOT_FOUND(404)EVENT_NOT_FOUND(404)EVENT_NOT_FOUND(404) — 존재를 드러내지 않음images: [](null 아님)event_end_at이 NULLeventEndAt: null(스키마상 NULL 허용)공통 (모집 상태 판정,
Event에 위임)CLOSEDCLOSEDrecruitType = OPEN)capacity값과 무관하게 정원으로 마감되지 않음daysUntilDeadline = 0(D-DAY)daysUntilDeadline = null📋 검토한 대안과 선택 이유
오프셋 페이지네이션 — 무한 스크롤이라 전체 개수·페이지 번호가 필요 없고 뒤 페이지로 갈수록 느려집니다.
coding-style.md2-4절 기준으로 커서를 택했습니다.모집 상태를 컬럼에 저장하고 스케줄러로 갱신 — 신청 기간 경계와 정원 충족 시점에 목록이 실제와 어긋납니다. 조회 시점 계산이 정확하고 갱신 작업도 필요 없습니다.
모집 상태 필터를 애플리케이션에서 적용 —
size + 1을 읽어 자바에서 거르면 걸러낸 만큼 페이지가 비어 크기를 맞출 수 없습니다.nextCursor를 인코딩 없이 노출 — 정렬 키 구조가 API 계약이 되어 나중에 바꾸기 어렵습니다. Base64로 감쌌습니다.recruitStatus를 컨트롤러에서 enum으로 직접 바인딩 — 잘못된 값이 오면MethodArgumentTypeMismatchException이 나는데GlobalExceptionHandler에 핸들러가 없어 500이 나갑니다.String으로 받아RecruitStatus.from으로 걸렀습니다.엔드포인트를 명세서의
/v1/users/**로 —config-and-auth.md4절·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 정적 팩토리에 나눠 뒀습니다.