From 45f40f3b42bde5702013f12ca859af66bd87f531 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:02:16 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20events=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20=EA=B2=8C=EC=8B=9C=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=EA=B3=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/domain/event/domain/Event.java | 4 +++- .../kookmin/stream/db/event/EventJpaEntity.java | 16 ++++++++++++++-- .../migration/V5__add_is_published_to_events.sql | 7 +++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index d786e3d..ba2b5b7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -25,6 +25,7 @@ public class Event { private List imageIds; private int capacity; private RecruitStatus recruitStatus; + private boolean published; private Long createdBy; public static Event of( @@ -41,11 +42,12 @@ public static Event of( List imageIds, int capacity, RecruitStatus recruitStatus, + boolean published, Long createdBy ) { return new Event( id, title, description, target, place, eventStartAt, eventEndAt, applyStartAt, - applyEndAt, recruitType, imageIds, capacity, recruitStatus, createdBy + applyEndAt, recruitType, imageIds, capacity, recruitStatus, published, createdBy ); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java index 542c3ce..204032a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.LocalDateTime; import java.util.List; @@ -20,7 +21,13 @@ import org.hibernate.type.SqlTypes; @Entity -@Table(name = "events") +@Table( + name = "events", + indexes = @Index( + name = "idx_events_is_published_is_deleted_event_start_at", + columnList = "is_published, is_deleted, event_start_at" + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class EventJpaEntity extends BaseSoftDeleteEntity { @@ -67,6 +74,9 @@ public class EventJpaEntity extends BaseSoftDeleteEntity { @Column(name = "recruit_status", nullable = false, length = 30) private RecruitStatus recruitStatus; + @Column(name = "is_published", nullable = false) + private boolean isPublished; + @Column(name = "created_by", nullable = false) private Long createdBy; @@ -84,6 +94,7 @@ private EventJpaEntity(Event event) { this.imageIds = event.getImageIds(); this.capacity = event.getCapacity(); this.recruitStatus = event.getRecruitStatus(); + this.isPublished = event.isPublished(); this.createdBy = event.getCreatedBy(); } @@ -93,6 +104,7 @@ public static EventJpaEntity from(Event event) { public Event toDomain() { return Event.of(id, title, description, target, place, eventStartAt, eventEndAt, - applyStartAt, applyEndAt, recruitType, imageIds, capacity, recruitStatus, createdBy); + applyStartAt, applyEndAt, recruitType, imageIds, capacity, recruitStatus, isPublished, + createdBy); } } diff --git a/infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql b/infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql new file mode 100644 index 0000000..f26af62 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql @@ -0,0 +1,7 @@ +ALTER TABLE events + ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 0; + +-- 사용자 행사 목록: WHERE is_published = 1 AND is_deleted = 0 ORDER BY event_start_at, event_id +-- (InnoDB가 세컨더리 인덱스 끝에 PK(event_id)를 붙이므로 보조 정렬 키까지 커버된다) +CREATE INDEX idx_events_is_published_is_deleted_event_start_at + ON events (is_published, is_deleted, event_start_at); From 811eb800e0b1eda0065c5731ea1c972a958c5efe Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:02:42 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/EventApplicantCount.java | 6 +++ .../domain/event/domain/EventCursor.java | 35 +++++++++++++ .../domain/event/domain/EventErrorCode.java | 3 +- .../domain/event/domain/EventSummary.java | 49 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java new file mode 100644 index 0000000..015349c --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +/** + * 행사와 그 행사의 유효 신청자 수(status가 APPLIED인 신청). 모집 상태 계산에 필요해 함께 조회한다. + */ +public record EventApplicantCount(Event event, long applicantCount) {} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java new file mode 100644 index 0000000..1703f01 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; + +/** + * 행사 목록의 keyset 커서. 정렬 기준(행사 시작 일시 오름차순 + eventId 오름차순)과 짝을 이룬다. + */ +public record EventCursor(LocalDateTime eventStartAt, Long eventId) { + + private static final String JOIN = "|"; + private static final String SPLIT_REGEX = "\\|"; + private static final int PART_COUNT = 2; + + public static EventCursor of(Event event) { + return new EventCursor(event.getEventStartAt(), event.getId()); + } + + // Base64 인코딩은 웹(Controller) 계층 책임이라 여기서는 순수 문자열 표현만 다룬다 + public static EventCursor from(String raw) { + String[] parts = raw.split(SPLIT_REGEX, -1); + if (parts.length != PART_COUNT) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_CURSOR); + } + try { + return new EventCursor(LocalDateTime.parse(parts[0]), Long.valueOf(parts[1])); + } catch (RuntimeException e) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_CURSOR); + } + } + + public String format() { + return eventStartAt + JOIN + eventId; + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java index e9e0901..46fc2f7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java @@ -15,7 +15,8 @@ public enum EventErrorCode implements ErrorCode { ALREADY_CLOSED(ErrorStatus.CONFLICT, "행사 마감되었습니다."), CAPACITY_FULL(ErrorStatus.CONFLICT, "모집 정원이 마감되었습니다."), ALREADY_APPLIED(ErrorStatus.CONFLICT, "이미 신청한 행사입니다."), - INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."); + INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."), + EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java new file mode 100644 index 0000000..671195d --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** + * 행사 목록 한 건. 모집 상태와 마감까지 남은 일수는 저장값이 아니라 조회 시점 기준으로 계산한다. + */ +public record EventSummary( + Long eventId, + String title, + String target, + LocalDateTime eventStartAt, + Long thumbnailFileId, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitStatus recruitStatus, + Integer daysUntilDeadline +) { + + public static EventSummary of(Event event, long applicantCount, LocalDateTime now) { + // 모집 상태 판정은 폼 조회·신청과 같은 기준을 써야 하므로 Event의 계산을 그대로 쓴다 + RecruitStatus recruitStatus = event.calculateRecruitStatus(now, applicantCount); + return new EventSummary( + event.getId(), + event.getTitle(), + event.getTarget(), + event.getEventStartAt(), + thumbnailFileIdOf(event.getImageIds()), + event.getApplyStartAt(), + event.getApplyEndAt(), + recruitStatus, + calculateDaysUntilDeadline(event, recruitStatus, now) + ); + } + + // D-Day 배지는 모집 중일 때만 노출하므로 그 외 상태에서는 값을 내려보내지 않는다 + private static Integer calculateDaysUntilDeadline(Event event, RecruitStatus recruitStatus, LocalDateTime now) { + if (recruitStatus != RecruitStatus.OPEN) { + return null; + } + return (int) ChronoUnit.DAYS.between(now.toLocalDate(), event.getApplyEndAt().toLocalDate()); + } + + private static Long thumbnailFileIdOf(List imageIds) { + return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); + } +} From 3b23d728bddb4a7c519c9df43276ea9736a36788 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:03:38 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20keyset=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EC=9E=90=20=EC=88=98=20=EC=A7=91=EA=B3=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/repository/EventRepository.java | 16 +++++ .../stream/db/event/EventJpaRepository.java | 67 +++++++++++++++++++ .../stream/db/event/EventRepositoryImpl.java | 57 ++++++++++++++++ 3 files changed, 140 insertions(+) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index 3d0b2d9..3427105 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -1,16 +1,32 @@ package kr.ac.kookmin.stream.event.domain.event.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicantCount; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; public interface EventRepository { Optional findById(Long id); + /** + * 게시되고 삭제되지 않은 행사를 행사 시작 일시 오름차순(동일 시각은 eventId 오름차순)으로 조회한다. + * recruitStatus가 주어지면 {@code now} 기준으로 계산한 모집 상태가 일치하는 행사만 남긴다. + */ + CursorSliceResult findPublishedSlice( + RecruitStatus recruitStatus, + EventCursor cursor, + int size, + LocalDateTime now + ); + List findQuestionsByEventId(Long eventId); long countAppliedByEventId(Long eventId); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 71c6628..37225aa 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -1,9 +1,76 @@ package kr.ac.kookmin.stream.db.event; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface EventJpaRepository extends JpaRepository { Optional findByIdAndIsDeletedFalse(Long id); + + /** + * 게시된 행사를 (event_start_at, event_id) keyset 커서로 조회한다. + *

+ * 모집 상태는 저장값이 아니라 강제 마감 여부·신청 기간·잔여 정원으로 판정하므로, 필터가 주어지면 + * {@code Event.calculateRecruitStatus}와 같은 규칙을 SQL로 옮겨 적용한다. 두 곳의 규칙이 어긋나면 + * 필터로 거른 결과와 응답에 실린 모집 상태가 달라지므로, 한쪽을 고치면 다른 쪽도 함께 고쳐야 한다. + */ + @Query(""" + SELECT e FROM EventJpaEntity e + WHERE e.isDeleted = false + AND e.isPublished = true + AND (:cursorEventStartAt IS NULL + OR e.eventStartAt > :cursorEventStartAt + OR (e.eventStartAt = :cursorEventStartAt AND e.id > :cursorEventId)) + AND (:filterAll = true + OR (:filterBeforeOpen = true + AND e.recruitStatus <> :forceClosedStatus + AND :now <= e.applyEndAt + AND :now < e.applyStartAt) + OR (:filterOpen = true + AND e.recruitStatus <> :forceClosedStatus + AND :now >= e.applyStartAt + AND :now <= e.applyEndAt + AND (e.recruitType <> :capacityLimitedType + OR (SELECT COUNT(a) FROM EventApplicationJpaEntity a + WHERE a.eventId = e.id AND a.status = :appliedStatus) < e.capacity)) + OR (:filterClosed = true + AND (e.recruitStatus = :forceClosedStatus + OR :now > e.applyEndAt + OR (:now >= e.applyStartAt + AND e.recruitType = :capacityLimitedType + AND (SELECT COUNT(a) FROM EventApplicationJpaEntity a + WHERE a.eventId = e.id AND a.status = :appliedStatus) >= e.capacity)))) + ORDER BY e.eventStartAt ASC, e.id ASC + """) + List findPublishedSlice( + @Param("filterAll") boolean filterAll, + @Param("filterBeforeOpen") boolean filterBeforeOpen, + @Param("filterOpen") boolean filterOpen, + @Param("filterClosed") boolean filterClosed, + @Param("forceClosedStatus") RecruitStatus forceClosedStatus, + @Param("capacityLimitedType") RecruitType capacityLimitedType, + @Param("appliedStatus") EventApplicationStatus appliedStatus, + @Param("now") LocalDateTime now, + @Param("cursorEventStartAt") LocalDateTime cursorEventStartAt, + @Param("cursorEventId") Long cursorEventId, + Pageable pageable + ); + + @Query(""" + SELECT a.eventId, COUNT(a) FROM EventApplicationJpaEntity a + WHERE a.eventId IN :eventIds AND a.status = :appliedStatus + GROUP BY a.eventId + """) + List countApplicantsByEventIds( + @Param("eventIds") List eventIds, + @Param("appliedStatus") EventApplicationStatus appliedStatus + ); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index bf182ad..633dcc5 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -1,14 +1,23 @@ package kr.ac.kookmin.stream.db.event; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicantCount; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @Repository @@ -25,6 +34,54 @@ public Optional findById(Long id) { return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain); } + @Override + public CursorSliceResult findPublishedSlice( + RecruitStatus recruitStatus, + EventCursor cursor, + int size, + LocalDateTime now + ) { + // 다음 페이지 존재 여부를 알기 위해 한 건 더 읽는다 + Pageable pageable = Pageable.ofSize(size + 1); + List entities = eventJpaRepository.findPublishedSlice( + recruitStatus == null, + recruitStatus == RecruitStatus.BEFORE_OPEN, + recruitStatus == RecruitStatus.OPEN, + recruitStatus == RecruitStatus.CLOSED, + RecruitStatus.CLOSED, + RecruitType.FIRST_COME, + EventApplicationStatus.APPLIED, + now, + cursor == null ? null : cursor.eventStartAt(), + cursor == null ? null : cursor.eventId(), + pageable + ); + + boolean hasNext = entities.size() > size; + List events = entities.stream() + .limit(size) + .map(EventJpaEntity::toDomain) + .toList(); + + Map applicantCounts = countApplicants(events); + List content = events.stream() + .map(event -> new EventApplicantCount(event, applicantCounts.getOrDefault(event.getId(), 0L))) + .toList(); + + String nextCursor = hasNext ? EventCursor.of(events.getLast()).format() : null; + + return new CursorSliceResult<>(content, hasNext, nextCursor); + } + + private Map countApplicants(List events) { + if (events.isEmpty()) { + return Map.of(); + } + List eventIds = events.stream().map(Event::getId).toList(); + return eventJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED).stream() + .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + } + @Override public List findQuestionsByEventId(Long eventId) { return eventQuestionJpaRepository.findAllByEventIdOrderByDisplayOrderAsc(eventId).stream() From ede0d9080392cf9294f68ba1584df93f85e3a252 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:03:55 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=90=9C=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/service/EventService.java | 6 +++++ .../event/service/impl/EventServiceImpl.java | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index f9bd920..59d6fba 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -1,11 +1,17 @@ package kr.ac.kookmin.stream.event.domain.event.service; +import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; public interface EventService { + CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size); + EventApplicationForm getApplicationForm(Long eventId); EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 286f737..ceba668 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -3,14 +3,18 @@ import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicantCount; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventService; @@ -25,6 +29,25 @@ class EventServiceImpl implements EventService { private final EventRepository eventRepository; private final EventApplyAnswerValidator eventApplyAnswerValidator; + @Override + @Transactional(readOnly = true) + public CursorSliceResult getPublishedEvents( + RecruitStatus recruitStatus, + EventCursor cursor, + int size + ) { + // 필터링(DB)과 응답(도메인)이 같은 시각을 봐야 모집 상태가 어긋나지 않는다 + LocalDateTime now = LocalDateTime.now(); + CursorSliceResult slice = + eventRepository.findPublishedSlice(recruitStatus, cursor, size, now); + + List content = slice.content().stream() + .map(entry -> EventSummary.of(entry.event(), entry.applicantCount(), now)) + .toList(); + + return new CursorSliceResult<>(content, slice.hasNext(), slice.nextCursor()); + } + @Override @Transactional(readOnly = true) public EventApplicationForm getApplicationForm(Long eventId) { From 2e0a5ac241a665c7f6bbf73ae83ff9e39734592d Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:05:09 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=20=EC=95=B1=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/AppEventController.java | 23 +++++++++++++ .../stream/event/EventListItemResponse.java | 34 +++++++++++++++++++ .../stream/event/EventListRequest.java | 32 +++++++++++++++++ .../domain/event/domain/EventErrorCode.java | 3 +- .../domain/event/domain/RecruitStatus.java | 19 ++++++++++- 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java index 7de5744..31efcc1 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java @@ -2,10 +2,15 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.CursorCodec; +import kr.ac.kookmin.stream.CursorSliceResponse; import kr.ac.kookmin.stream.app.AppApiUser; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -19,6 +24,15 @@ public class AppEventController { private final EventService eventService; + @GetMapping + public ApiResponse> getEvents( + @Valid @ModelAttribute EventListRequest request + ) { + CursorSliceResult result = eventService.getPublishedEvents( + request.toRecruitStatus(), request.toCursor(), request.sizeOrDefault()); + return ApiResponse.success(CursorSliceResponse.from(toResponse(result))); + } + @GetMapping("/{eventId}/form") public ApiResponse getApplicationForm(@PathVariable Long eventId) { return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId))); @@ -34,4 +48,13 @@ public ApiResponse apply( EventApplyResponse.from(eventService.apply(eventId, apiUser.userId(), request.toCommand())) ); } + + // 커서는 클라이언트에게 불투명한 토큰이어야 하므로 응답 직전 웹 계층에서 인코딩한다 + private CursorSliceResult toResponse(CursorSliceResult result) { + return new CursorSliceResult<>( + result.content().stream().map(EventListItemResponse::from).toList(), + result.hasNext(), + result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor()) + ); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java new file mode 100644 index 0000000..5f8a953 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java @@ -0,0 +1,34 @@ +package kr.ac.kookmin.stream.event; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; + +public record EventListItemResponse( + Long eventId, + String title, + String target, + LocalDateTime eventStartAt, + String thumbnailUrl, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitStatus recruitStatus, + Integer daysUntilDeadline +) { + + public static EventListItemResponse from(EventSummary summary) { + return new EventListItemResponse( + summary.eventId(), + summary.title(), + summary.target(), + summary.eventStartAt(), + // 대표 이미지 파일 id는 summary.thumbnailFileId()로 알 수 있으나 + // 파일 키 → 공개 URL 조립(#17)이 아직 없어 URL은 내려보내지 않는다 + null, + summary.applyStartAt(), + summary.applyEndAt(), + summary.recruitStatus(), + summary.daysUntilDeadline() + ); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java new file mode 100644 index 0000000..ece4d1b --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java @@ -0,0 +1,32 @@ +package kr.ac.kookmin.stream.event; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import kr.ac.kookmin.stream.CursorCodec; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; + +public record EventListRequest( + String cursor, + + @Min(value = 1, message = "조회 개수는 1 이상 100 이하여야 합니다.") + @Max(value = 100, message = "조회 개수는 1 이상 100 이하여야 합니다.") + Integer size, + + String recruitStatus +) { + + private static final int DEFAULT_SIZE = 20; + + public EventCursor toCursor() { + return cursor == null ? null : EventCursor.from(CursorCodec.decode(cursor)); + } + + public RecruitStatus toRecruitStatus() { + return RecruitStatus.from(recruitStatus); + } + + public int sizeOrDefault() { + return size == null ? DEFAULT_SIZE : size; + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java index 46fc2f7..95b89e7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java @@ -16,7 +16,8 @@ public enum EventErrorCode implements ErrorCode { CAPACITY_FULL(ErrorStatus.CONFLICT, "모집 정원이 마감되었습니다."), ALREADY_APPLIED(ErrorStatus.CONFLICT, "이미 신청한 행사입니다."), INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."), - EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."); + EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), + EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java index 8ebb86d..9c7bea8 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java @@ -1,7 +1,24 @@ package kr.ac.kookmin.stream.event.domain.event.domain; +import kr.ac.kookmin.stream.common.BusinessException; + public enum RecruitStatus { BEFORE_OPEN, OPEN, - CLOSED + CLOSED; + + /** + * 목록 조회 필터로 들어온 문자열을 모집 상태로 바꾼다. 값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다. + * 잘못된 값에 500이 나가지 않도록 여기서 걸러 BusinessException으로 바꾼다. + */ + public static RecruitStatus from(String value) { + if (value == null) { + return null; + } + try { + return RecruitStatus.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_RECRUIT_STATUS); + } + } } From f7183b74f4d8f1024dd1615ac480123f777d5270 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:05:09 +0900 Subject: [PATCH 06/21] =?UTF-8?q?docs:=20=EC=BB=A4=EC=84=9C=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=EC=9D=91=EB=8B=B5=EC=9D=98=20nex?= =?UTF-8?q?tCursor=20=EA=B7=9C=EC=B9=99=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index f464bf3..12f472b 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -131,10 +131,11 @@ public final class ApiResponse { | 오프셋 | 페이지 번호·전체 개수·전체 페이지 필요 | `PageResult` / `PageResponse` | - 커서 기반 응답 필드는 항상 `content`/`hasNext`/`nextCursor`로 통일한다. +- `nextCursor`는 **클라이언트에게 불투명한 문자열**이다. 정렬 키가 여러 개인 keyset 커서를 담아야 하므로 도메인이 `{Domain}Cursor` record로 정렬 키와 그 문자열 표현(`format()`/`from(String)`)을 소유하고, Base64 URL-safe 인코딩은 웹 계층에서 `CursorCodec`(`api:common-api`)으로 처리한다. ```java // core:common -public record CursorSliceResult(List content, boolean hasNext, Long nextCursor) {} +public record CursorSliceResult(List content, boolean hasNext, String nextCursor) {} public record PageResult(List content, int page, int size, long totalCount, int totalPage) {} ``` From f273d64bed4faac5b051fe94ceea2984624df13e Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:24:35 +0900 Subject: [PATCH 07/21] =?UTF-8?q?refactor:=20=EC=8B=A0=EC=B2=AD=EC=9E=90?= =?UTF-8?q?=20=EC=88=98=20=EC=A7=91=EA=B3=84=EB=A5=BC=20Object[]=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20record=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=EC=85=98=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ac/kookmin/stream/db/event/EventApplicantCountRow.java | 6 ++++++ .../kr/ac/kookmin/stream/db/event/EventJpaRepository.java | 5 +++-- .../kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java new file mode 100644 index 0000000..573024a --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.db.event; + +/** + * 행사별 유효 신청자 수 집계 결과. JPQL 생성자 표현식으로 채워지는 조회 전용 프로젝션이다. + */ +public record EventApplicantCountRow(Long eventId, Long applicantCount) {} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 37225aa..87b89cb 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -65,11 +65,12 @@ List findPublishedSlice( ); @Query(""" - SELECT a.eventId, COUNT(a) FROM EventApplicationJpaEntity a + SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountRow(a.eventId, COUNT(a)) + FROM EventApplicationJpaEntity a WHERE a.eventId IN :eventIds AND a.status = :appliedStatus GROUP BY a.eventId """) - List countApplicantsByEventIds( + List countApplicantsByEventIds( @Param("eventIds") List eventIds, @Param("appliedStatus") EventApplicationStatus appliedStatus ); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index 633dcc5..fb7f003 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -79,7 +79,7 @@ private Map countApplicants(List events) { } List eventIds = events.stream().map(Event::getId).toList(); return eventJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED).stream() - .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + .collect(Collectors.toMap(EventApplicantCountRow::eventId, EventApplicantCountRow::applicantCount)); } @Override From 98b720280a459825af308979342d63700c978cf7 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:24:35 +0900 Subject: [PATCH 08/21] =?UTF-8?q?refactor:=20=EB=8C=80=ED=91=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20=EC=A1=B0=EB=A6=BD=20?= =?UTF-8?q?=EC=A7=80=EC=A0=90=EC=9D=84=20=EB=B3=84=EB=8F=84=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/event/EventListItemResponse.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java index 5f8a953..45708e4 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java @@ -22,13 +22,20 @@ public static EventListItemResponse from(EventSummary summary) { summary.title(), summary.target(), summary.eventStartAt(), - // 대표 이미지 파일 id는 summary.thumbnailFileId()로 알 수 있으나 - // 파일 키 → 공개 URL 조립(#17)이 아직 없어 URL은 내려보내지 않는다 - null, + thumbnailUrlOf(summary.thumbnailFileId()), summary.applyStartAt(), summary.applyEndAt(), summary.recruitStatus(), summary.daysUntilDeadline() ); } + + /** + * 대표 이미지 파일 id를 공개 URL로 바꾼다. + *

+ * 파일 키 → 공개 URL 조립(#17)이 아직 없어 현재는 항상 비어 있다. #17이 머지되면 이 메서드만 채우면 된다. + */ + private static String thumbnailUrlOf(Long thumbnailFileId) { + return null; + } } From b907dfbd57292a56d366067abeca39ecbc157f61 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:24:35 +0900 Subject: [PATCH 09/21] =?UTF-8?q?test:=20=ED=96=89=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/domain/EventCursorTest.java | 53 +++++++ .../domain/event/domain/EventSummaryTest.java | 133 ++++++++++++++++++ .../event/domain/RecruitStatusTest.java | 37 +++++ 3 files changed, 223 insertions(+) create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatusTest.java diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java new file mode 100644 index 0000000..108a8e8 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java @@ -0,0 +1,53 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class EventCursorTest { + + private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 9, 12, 10, 0); + + @Test + @DisplayName("문자열로 바꿨다가 되돌리면 같은 커서다") + void roundTrip() { + EventCursor cursor = new EventCursor(EVENT_START_AT, 42L); + + assertEquals(cursor, EventCursor.from(cursor.format())); + } + + @Test + @DisplayName("행사로부터 정렬 키를 그대로 딴다") + void fromEvent() { + Event event = Event.of( + 42L, "행사", "설명", "전교생", "학생회관", + EVENT_START_AT, EVENT_START_AT.plusHours(2), EVENT_START_AT.minusDays(7), EVENT_START_AT.minusDays(1), + RecruitType.OPEN, List.of(), 0, RecruitStatus.OPEN, true, 100L + ); + + assertEquals(new EventCursor(EVENT_START_AT, 42L), EventCursor.of(event)); + } + + @ParameterizedTest + @DisplayName("형식이 어긋나면 커서 오류를 던진다") + @ValueSource(strings = { + "", + "2026-09-12T10:00", + "2026-09-12T10:00|42|extra", + "not-a-date|42", + "2026-09-12T10:00|not-a-number", + "|42" + }) + void invalidFormat(String raw) { + BusinessException e = assertThrows(BusinessException.class, () -> EventCursor.from(raw)); + + assertEquals(EventErrorCode.EVENT_INVALID_CURSOR, e.getErrorCode()); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java new file mode 100644 index 0000000..853d6ef --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java @@ -0,0 +1,133 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventSummaryTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 12, 10, 0); + + private static Event event( + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + int capacity, + RecruitStatus recruitStatus, + List imageIds + ) { + return Event.of( + 1L, "행사", "설명", "전교생", "학생회관", + NOW.plusDays(30), NOW.plusDays(31), applyStartAt, applyEndAt, + recruitType, imageIds, capacity, recruitStatus, true, 100L + ); + } + + private static Event openEvent() { + return event(NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + } + + /** + * 모집 상태 판정 규칙 자체는 {@code EventTest}가 덮는다. + * 여기서는 EventSummary가 저장값을 그대로 베끼지 않고 Event의 계산을 거치는지만 확인한다. + */ + @Nested + @DisplayName("모집 상태 위임") + class RecruitStatusDelegation { + + @Test + @DisplayName("저장된 모집 상태가 아니라 조회 시점 기준으로 계산한 값을 싣는다") + void computesInsteadOfCopying() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.BEFORE_OPEN, List.of()); + + assertEquals(RecruitStatus.BEFORE_OPEN, event.getRecruitStatus()); + assertEquals(RecruitStatus.OPEN, EventSummary.of(event, 0, NOW).recruitStatus()); + } + + @Test + @DisplayName("신청자 수를 함께 넘겨 정원 마감까지 반영한다") + void passesApplicantCount() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.FIRST_COME, 10, RecruitStatus.OPEN, List.of()); + + assertEquals(RecruitStatus.OPEN, EventSummary.of(event, 9, NOW).recruitStatus()); + assertEquals(RecruitStatus.CLOSED, EventSummary.of(event, 10, NOW).recruitStatus()); + } + } + + @Nested + @DisplayName("마감까지 남은 일수") + class DaysUntilDeadline { + + @Test + @DisplayName("모집 중이면 신청 마감일까지 남은 날짜 수를 센다") + void countsDays() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + + assertEquals(3, EventSummary.of(event, 0, NOW).daysUntilDeadline()); + } + + @Test + @DisplayName("마감 당일이면 0이다") + void deadlineToday() { + Event event = event( + NOW.minusDays(1), NOW.withHour(23).withMinute(59), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + + assertEquals(0, EventSummary.of(event, 0, NOW).daysUntilDeadline()); + } + + @Test + @DisplayName("시각이 아니라 날짜로 세므로 23시간 뒤 마감도 하루 뒤면 1이다") + void countsByDateNotByHours() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(1).withHour(9), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + + assertEquals(1, EventSummary.of(event, 0, NOW).daysUntilDeadline()); + } + + @Test + @DisplayName("모집 중이 아니면 내려보내지 않는다") + void nullWhenNotOpen() { + Event beforeOpen = event( + NOW.plusDays(1), NOW.plusDays(5), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + Event closed = event( + NOW.minusDays(5), NOW.minusDays(1), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + + assertNull(EventSummary.of(beforeOpen, 0, NOW).daysUntilDeadline()); + assertNull(EventSummary.of(closed, 0, NOW).daysUntilDeadline()); + } + } + + @Nested + @DisplayName("대표 이미지") + class Thumbnail { + + @Test + @DisplayName("이미지 목록의 첫 번째를 대표로 쓴다") + void firstImage() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of(7L, 8L, 9L)); + + assertEquals(7L, EventSummary.of(event, 0, NOW).thumbnailFileId()); + } + + @Test + @DisplayName("이미지가 없으면 대표 이미지도 없다") + void noImage() { + Event empty = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + Event nullImages = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, null); + + assertNull(EventSummary.of(empty, 0, NOW).thumbnailFileId()); + assertNull(EventSummary.of(nullImages, 0, NOW).thumbnailFileId()); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatusTest.java new file mode 100644 index 0000000..8752719 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatusTest.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import kr.ac.kookmin.stream.common.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class RecruitStatusTest { + + @Test + @DisplayName("값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다") + void nullMeansNoFilter() { + assertNull(RecruitStatus.from(null)); + } + + @ParameterizedTest + @DisplayName("정의된 이름은 그대로 바뀐다") + @EnumSource(RecruitStatus.class) + void parsesDefinedNames(RecruitStatus status) { + assertEquals(status, RecruitStatus.from(status.name())); + } + + @ParameterizedTest + @DisplayName("정의되지 않은 값은 500이 아니라 400으로 걸러진다") + @ValueSource(strings = {"open", " OPEN", "OPENED", "1", "-"}) + void rejectsUndefinedValues(String raw) { + BusinessException e = assertThrows(BusinessException.class, () -> RecruitStatus.from(raw)); + + assertEquals(EventErrorCode.EVENT_INVALID_RECRUIT_STATUS, e.getErrorCode()); + } +} From e55d1641ae8b74aba357162734239a17efdd1c82 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:05:26 +0900 Subject: [PATCH 10/21] =?UTF-8?q?fix:=20Event.of=20=EC=8B=9C=EA=B7=B8?= =?UTF-8?q?=EB=8B=88=EC=B2=98=EC=97=90=20=EA=B2=8C=EC=8B=9C=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EC=B6=94=EA=B0=80=EB=90=9C=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20EventTest=20=ED=98=B8=EC=B6=9C=EB=B6=80=EC=97=90=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ac/kookmin/stream/event/domain/event/domain/EventTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java index 541122d..c2fc0d8 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -21,7 +21,7 @@ private static Event event(RecruitType recruitType, RecruitStatus recruitStatus) 1L, "2026 소융대 개강 행사", "설명", "소융대 재학생", "101호", LocalDateTime.of(2026, 9, 15, 18, 0), LocalDateTime.of(2026, 9, 15, 20, 0), APPLY_START_AT, APPLY_END_AT, - recruitType, List.of(), CAPACITY, recruitStatus, 1L + recruitType, List.of(), CAPACITY, recruitStatus, true, 1L ); } From 0d03b3c36ca7bcdb727e7defe16b0c30e3753ce4 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:05:48 +0900 Subject: [PATCH 11/21] =?UTF-8?q?refactor:=20=EB=A7=88=EA=B0=90=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=82=A8=EC=9D=80=20=EC=9D=BC=EC=88=98=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=9D=84=20Event=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/domain/event/domain/Event.java | 17 +++++++++++++++++ .../event/domain/event/domain/EventSummary.java | 11 +---------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index ba2b5b7..3d52827 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.event.domain; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -72,6 +73,22 @@ public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount return RecruitStatus.OPEN; } + /** + * 신청 마감까지 남은 날짜 수. 목록·상세가 같은 D-Day를 보여야 하므로 도메인에 둔다. + *

+ * 시각이 아니라 날짜 단위로 세므로 마감 당일은 언제든 0이다. D-Day 배지는 모집 중일 때만 노출하기로 해 + * 그 외 상태에서는 값을 내려보내지 않는다. + * + * @param recruitStatus {@link #calculateRecruitStatus}로 이미 계산해둔 모집 상태 + * @return 모집 중이면 남은 날짜 수, 그 외에는 {@code null} + */ + public Integer daysUntilDeadline(LocalDateTime now, RecruitStatus recruitStatus) { + if (recruitStatus != RecruitStatus.OPEN) { + return null; + } + return (int) ChronoUnit.DAYS.between(now.toLocalDate(), applyEndAt.toLocalDate()); + } + /** * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java index 671195d..73abff0 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java @@ -1,7 +1,6 @@ package kr.ac.kookmin.stream.event.domain.event.domain; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; import java.util.List; /** @@ -31,18 +30,10 @@ public static EventSummary of(Event event, long applicantCount, LocalDateTime no event.getApplyStartAt(), event.getApplyEndAt(), recruitStatus, - calculateDaysUntilDeadline(event, recruitStatus, now) + event.daysUntilDeadline(now, recruitStatus) ); } - // D-Day 배지는 모집 중일 때만 노출하므로 그 외 상태에서는 값을 내려보내지 않는다 - private static Integer calculateDaysUntilDeadline(Event event, RecruitStatus recruitStatus, LocalDateTime now) { - if (recruitStatus != RecruitStatus.OPEN) { - return null; - } - return (int) ChronoUnit.DAYS.between(now.toLocalDate(), event.getApplyEndAt().toLocalDate()); - } - private static Long thumbnailFileIdOf(List imageIds) { return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); } From 3026da8ac2bd8c45d3c139f24ee20335bc0e3911 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:08:36 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=90=9C=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=C2=B7=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/domain/EventDetail.java | 42 +++++++++++++++++++ .../event/repository/EventRepository.java | 5 +++ .../domain/event/service/EventService.java | 3 ++ .../event/service/impl/EventServiceImpl.java | 11 +++++ .../stream/db/event/EventJpaRepository.java | 2 + .../stream/db/event/EventRepositoryImpl.java | 5 +++ 6 files changed, 68 insertions(+) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java new file mode 100644 index 0000000..ce6ae46 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 행사 상세 한 건. 목록과 마찬가지로 모집 상태와 마감까지 남은 일수는 저장값이 아니라 조회 시점 기준으로 계산한다. + */ +public record EventDetail( + Long eventId, + String title, + String description, + String target, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + List imageIds, + RecruitStatus recruitStatus, + Integer daysUntilDeadline +) { + + public static EventDetail of(Event event, long applicantCount, LocalDateTime now) { + // 모집 상태 판정은 목록·폼 조회·신청과 같은 기준을 써야 하므로 Event의 계산을 그대로 쓴다 + RecruitStatus recruitStatus = event.calculateRecruitStatus(now, applicantCount); + return new EventDetail( + event.getId(), + event.getTitle(), + event.getDescription(), + event.getTarget(), + event.getPlace(), + event.getEventStartAt(), + event.getEventEndAt(), + event.getApplyStartAt(), + event.getApplyEndAt(), + event.getImageIds() == null ? List.of() : event.getImageIds(), + recruitStatus, + event.daysUntilDeadline(now, recruitStatus) + ); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index 3427105..04f634b 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -16,6 +16,11 @@ public interface EventRepository { Optional findById(Long id); + /** + * 게시되고 삭제되지 않은 행사 한 건을 조회한다. 아직 공개하지 않은 행사는 학생에게 없는 것으로 보여야 한다. + */ + Optional findPublishedById(Long id); + /** * 게시되고 삭제되지 않은 행사를 행사 시작 일시 오름차순(동일 시각은 eventId 오름차순)으로 조회한다. * recruitStatus가 주어지면 {@code now} 기준으로 계산한 모집 상태가 일치하는 행사만 남긴다. diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index 59d6fba..a274958 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -5,6 +5,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; @@ -12,6 +13,8 @@ public interface EventService { CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size); + EventDetail getPublishedEvent(Long eventId); + EventApplicationForm getApplicationForm(Long eventId); EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index ceba668..4f0e720 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -12,6 +12,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; @@ -48,6 +49,16 @@ public CursorSliceResult getPublishedEvents( return new CursorSliceResult<>(content, slice.hasNext(), slice.nextCursor()); } + @Override + @Transactional(readOnly = true) + public EventDetail getPublishedEvent(Long eventId) { + Event event = eventRepository.findPublishedById(eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + + LocalDateTime now = LocalDateTime.now(); + return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now); + } + @Override @Transactional(readOnly = true) public EventApplicationForm getApplicationForm(Long eventId) { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 87b89cb..d387ad0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -15,6 +15,8 @@ public interface EventJpaRepository extends JpaRepository Optional findByIdAndIsDeletedFalse(Long id); + Optional findByIdAndIsDeletedFalseAndIsPublishedTrue(Long id); + /** * 게시된 행사를 (event_start_at, event_id) keyset 커서로 조회한다. *

diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index fb7f003..d176c2a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -34,6 +34,11 @@ public Optional findById(Long id) { return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain); } + @Override + public Optional findPublishedById(Long id) { + return eventJpaRepository.findByIdAndIsDeletedFalseAndIsPublishedTrue(id).map(EventJpaEntity::toDomain); + } + @Override public CursorSliceResult findPublishedSlice( RecruitStatus recruitStatus, From 34d33ef26005756088123bbb26ba813953420b92 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:08:36 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=20=EC=95=B1=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/AppEventController.java | 5 ++ .../stream/event/EventDetailResponse.java | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventDetailResponse.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java index 31efcc1..8b88e32 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java @@ -33,6 +33,11 @@ public ApiResponse> getEvents( return ApiResponse.success(CursorSliceResponse.from(toResponse(result))); } + @GetMapping("/{eventId}") + public ApiResponse getEvent(@PathVariable Long eventId) { + return ApiResponse.success(EventDetailResponse.from(eventService.getPublishedEvent(eventId))); + } + @GetMapping("/{eventId}/form") public ApiResponse getApplicationForm(@PathVariable Long eventId) { return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId))); diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventDetailResponse.java new file mode 100644 index 0000000..4309703 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventDetailResponse.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.event; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; + +public record EventDetailResponse( + Long eventId, + String title, + String description, + String target, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitStatus recruitStatus, + Integer daysUntilDeadline, + List images +) { + + public static EventDetailResponse from(EventDetail detail) { + return new EventDetailResponse( + detail.eventId(), + detail.title(), + detail.description(), + detail.target(), + detail.place(), + detail.eventStartAt(), + detail.eventEndAt(), + detail.applyStartAt(), + detail.applyEndAt(), + detail.recruitStatus(), + detail.daysUntilDeadline(), + detail.imageIds().stream().map(Image::from).toList() + ); + } + + public record Image(Long fileId, String fileUrl) { + + /** + * 파일 키 → 공개 URL 조립(#17)이 아직 없어 URL은 비어 있다. #17이 머지되면 이 팩토리만 채우면 된다. + */ + public static Image from(Long fileId) { + return new Image(fileId, null); + } + } +} From 64a55e8a9629512da77e15a584582017e33cd947 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:08:36 +0900 Subject: [PATCH 14/21] =?UTF-8?q?test:=20=ED=96=89=EC=82=AC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EB=A7=88=EA=B0=90=20?= =?UTF-8?q?=EC=9D=BC=EC=88=98=20=EA=B3=84=EC=82=B0=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/domain/EventDetailTest.java | 116 ++++++++++++++++++ .../domain/event/domain/EventSummaryTest.java | 40 ++---- .../event/domain/event/domain/EventTest.java | 45 +++++++ 3 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java new file mode 100644 index 0000000..bea8235 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java @@ -0,0 +1,116 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventDetailTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 12, 10, 0); + + private static Event event(RecruitType recruitType, RecruitStatus recruitStatus, List imageIds) { + return Event.of( + 15L, "2026 소융대 개강 행사", "소융대 재학생을 위한 개강 행사입니다.", "소융대 재학생", "101호", + NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), + recruitType, imageIds, 30, recruitStatus, true, 1L + ); + } + + private static Event openEvent() { + return event(RecruitType.OPEN, RecruitStatus.OPEN, List.of()); + } + + @Test + @DisplayName("행사의 상세 필드를 그대로 옮긴다") + void copiesDetailFields() { + EventDetail detail = EventDetail.of(openEvent(), 0, NOW); + + assertEquals(15L, detail.eventId()); + assertEquals("2026 소융대 개강 행사", detail.title()); + assertEquals("소융대 재학생을 위한 개강 행사입니다.", detail.description()); + assertEquals("소융대 재학생", detail.target()); + assertEquals("101호", detail.place()); + assertEquals(NOW.plusDays(10), detail.eventStartAt()); + assertEquals(NOW.plusDays(10).plusHours(2), detail.eventEndAt()); + assertEquals(NOW.minusDays(1), detail.applyStartAt()); + assertEquals(NOW.plusDays(3), detail.applyEndAt()); + } + + /** + * 모집 상태 판정 규칙 자체는 {@code EventTest}가 덮는다. + * 여기서는 EventDetail이 저장값을 그대로 베끼지 않고 Event의 계산을 거치는지만 확인한다. + */ + @Nested + @DisplayName("모집 상태 위임") + class RecruitStatusDelegation { + + @Test + @DisplayName("저장된 모집 상태가 아니라 조회 시점 기준으로 계산한 값을 싣는다") + void computesInsteadOfCopying() { + Event event = event(RecruitType.OPEN, RecruitStatus.BEFORE_OPEN, List.of()); + + assertEquals(RecruitStatus.BEFORE_OPEN, event.getRecruitStatus()); + assertEquals(RecruitStatus.OPEN, EventDetail.of(event, 0, NOW).recruitStatus()); + } + + @Test + @DisplayName("신청자 수를 함께 넘겨 정원 마감까지 반영한다") + void passesApplicantCount() { + Event event = event(RecruitType.FIRST_COME, RecruitStatus.OPEN, List.of()); + + assertEquals(RecruitStatus.OPEN, EventDetail.of(event, 29, NOW).recruitStatus()); + assertEquals(RecruitStatus.CLOSED, EventDetail.of(event, 30, NOW).recruitStatus()); + } + } + + @Nested + @DisplayName("마감까지 남은 일수") + class DaysUntilDeadline { + + @Test + @DisplayName("모집 중이면 목록과 같은 값을 싣는다") + void sameAsSummary() { + Event event = openEvent(); + + assertEquals( + EventSummary.of(event, 0, NOW).daysUntilDeadline(), + EventDetail.of(event, 0, NOW).daysUntilDeadline()); + } + + @Test + @DisplayName("모집 중이 아니면 내려보내지 않는다") + void nullWhenNotOpen() { + Event forceClosed = event(RecruitType.OPEN, RecruitStatus.CLOSED, List.of()); + + assertNull(EventDetail.of(forceClosed, 0, NOW).daysUntilDeadline()); + } + } + + @Nested + @DisplayName("이미지 목록") + class Images { + + @Test + @DisplayName("행사 이미지 id를 순서대로 싣는다") + void keepsOrder() { + Event event = event(RecruitType.OPEN, RecruitStatus.OPEN, List.of(31L, 32L)); + + assertEquals(List.of(31L, 32L), EventDetail.of(event, 0, NOW).imageIds()); + } + + @Test + @DisplayName("이미지가 없으면 null이 아니라 빈 목록이다") + void emptyListWhenNoImage() { + assertTrue(EventDetail.of(event(RecruitType.OPEN, RecruitStatus.OPEN, List.of()), 0, NOW) + .imageIds().isEmpty()); + assertTrue(EventDetail.of(event(RecruitType.OPEN, RecruitStatus.OPEN, null), 0, NOW) + .imageIds().isEmpty()); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java index 853d6ef..09ea4c4 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java @@ -61,46 +61,24 @@ void passesApplicantCount() { } } + /** + * 날짜 계산 규칙 자체는 {@code EventTest}가 덮는다. 여기서는 계산 결과가 실리는지만 확인한다. + */ @Nested @DisplayName("마감까지 남은 일수") class DaysUntilDeadline { @Test - @DisplayName("모집 중이면 신청 마감일까지 남은 날짜 수를 센다") - void countsDays() { - Event event = event( + @DisplayName("모집 중이면 Event가 계산한 값을 싣고, 그 외에는 비운다") + void carriesEventCalculation() { + Event open = event( NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); - - assertEquals(3, EventSummary.of(event, 0, NOW).daysUntilDeadline()); - } - - @Test - @DisplayName("마감 당일이면 0이다") - void deadlineToday() { - Event event = event( - NOW.minusDays(1), NOW.withHour(23).withMinute(59), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); - - assertEquals(0, EventSummary.of(event, 0, NOW).daysUntilDeadline()); - } - - @Test - @DisplayName("시각이 아니라 날짜로 세므로 23시간 뒤 마감도 하루 뒤면 1이다") - void countsByDateNotByHours() { - Event event = event( - NOW.minusDays(1), NOW.plusDays(1).withHour(9), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); - - assertEquals(1, EventSummary.of(event, 0, NOW).daysUntilDeadline()); - } - - @Test - @DisplayName("모집 중이 아니면 내려보내지 않는다") - void nullWhenNotOpen() { - Event beforeOpen = event( - NOW.plusDays(1), NOW.plusDays(5), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); Event closed = event( NOW.minusDays(5), NOW.minusDays(1), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); - assertNull(EventSummary.of(beforeOpen, 0, NOW).daysUntilDeadline()); + assertEquals( + open.daysUntilDeadline(NOW, RecruitStatus.OPEN), + EventSummary.of(open, 0, NOW).daysUntilDeadline()); assertNull(EventSummary.of(closed, 0, NOW).daysUntilDeadline()); } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java index c2fc0d8..0a1e82d 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDateTime; @@ -97,6 +98,50 @@ void openRecruitIgnoresCapacity() { } } + @Nested + @DisplayName("마감까지 남은 일수") + class DaysUntilDeadline { + + // 신청 마감은 APPLY_END_AT = 2026-09-10 18:00 + + @Test + @DisplayName("모집 중이면 마감일까지 남은 날짜 수를 센다") + void countsRemainingDays() { + Event event = firstComeEvent(); + + assertEquals(2, event.daysUntilDeadline( + LocalDateTime.of(2026, 9, 8, 10, 0), RecruitStatus.OPEN)); + } + + @Test + @DisplayName("마감 당일이면 0이다") + void deadlineToday() { + Event event = firstComeEvent(); + + assertEquals(0, event.daysUntilDeadline( + LocalDateTime.of(2026, 9, 10, 9, 0), RecruitStatus.OPEN)); + } + + @Test + @DisplayName("시각이 아니라 날짜로 세므로 19시간 뒤 마감이어도 하루 뒤면 1이다") + void countsByDateNotByHours() { + Event event = firstComeEvent(); + + assertEquals(1, event.daysUntilDeadline( + LocalDateTime.of(2026, 9, 9, 23, 0), RecruitStatus.OPEN)); + } + + @Test + @DisplayName("모집 중이 아니면 내려보내지 않는다") + void nullWhenNotOpen() { + Event event = firstComeEvent(); + LocalDateTime now = LocalDateTime.of(2026, 9, 8, 10, 0); + + assertNull(event.daysUntilDeadline(now, RecruitStatus.BEFORE_OPEN)); + assertNull(event.daysUntilDeadline(now, RecruitStatus.CLOSED)); + } + } + @Nested @DisplayName("정원 마감 여부") class CapacityFull { From cb2e8b4bf7a0d33365a383924d9a96034c440d6c Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:21:41 +0900 Subject: [PATCH 15/21] =?UTF-8?q?fix:=20=EB=AF=B8=EA=B2=8C=EC=8B=9C=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=EC=9D=98=20=EC=8B=A0=EC=B2=AD=EC=84=9C=20?= =?UTF-8?q?=ED=8F=BC=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/event/service/impl/EventServiceImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 4f0e720..5a7725d 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -89,10 +89,11 @@ public EventApplicationResult apply(Long eventId, Long memberId, EventApplyComma /** * 모집 중인 행사를 가져온다. 폼 조회와 신청이 같은 기준으로 열려 있어야 하므로 한곳에 둔다. *

+ * 아직 게시하지 않은 행사는 학생에게 없는 것으로 보여야 하므로 목록·상세와 같은 기준으로 거른다. * 닫혀 있으면 사유를 가른다. 강제 마감·기간 종료가 정원 마감보다 앞선 사유다. */ private Event getOpenEvent(Long eventId) { - Event event = eventRepository.findById(eventId) + Event event = eventRepository.findPublishedById(eventId) .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); LocalDateTime now = LocalDateTime.now(); From 1d4b3b315f78c1d4d3cacf974cf7530f70ce2d94 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:39:47 +0900 Subject: [PATCH 16/21] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=EC=99=80?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=EC=9E=90=20=EC=88=98=20=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=EC=9D=84=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/domain/EventApplicantCount.java | 6 ------ .../domain/event/repository/EventRepository.java | 10 ++++++++-- .../event/service/impl/EventServiceImpl.java | 11 +++++++---- .../stream/db/event/EventRepositoryImpl.java | 16 +++++----------- 4 files changed, 20 insertions(+), 23 deletions(-) delete mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java deleted file mode 100644 index 015349c..0000000 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java +++ /dev/null @@ -1,6 +0,0 @@ -package kr.ac.kookmin.stream.event.domain.event.domain; - -/** - * 행사와 그 행사의 유효 신청자 수(status가 APPLIED인 신청). 모집 상태 계산에 필요해 함께 조회한다. - */ -public record EventApplicantCount(Event event, long applicantCount) {} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index 04f634b..87f69e2 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -2,11 +2,11 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicantCount; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; @@ -25,13 +25,19 @@ public interface EventRepository { * 게시되고 삭제되지 않은 행사를 행사 시작 일시 오름차순(동일 시각은 eventId 오름차순)으로 조회한다. * recruitStatus가 주어지면 {@code now} 기준으로 계산한 모집 상태가 일치하는 행사만 남긴다. */ - CursorSliceResult findPublishedSlice( + CursorSliceResult findPublishedSlice( RecruitStatus recruitStatus, EventCursor cursor, int size, LocalDateTime now ); + /** + * 행사별 유효 신청자 수를 한 번에 조회한다. 목록처럼 여러 행사의 신청자 수가 필요할 때 N+1을 피하기 위한 것으로, + * 신청이 한 건도 없는 행사는 결과에 담기지 않는다. + */ + Map countAppliedByEventIds(List eventIds); + List findQuestionsByEventId(Long eventId); long countAppliedByEventId(Long eventId); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 5a7725d..acdc5ca 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -2,10 +2,10 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicantCount; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; @@ -39,11 +39,14 @@ public CursorSliceResult getPublishedEvents( ) { // 필터링(DB)과 응답(도메인)이 같은 시각을 봐야 모집 상태가 어긋나지 않는다 LocalDateTime now = LocalDateTime.now(); - CursorSliceResult slice = - eventRepository.findPublishedSlice(recruitStatus, cursor, size, now); + CursorSliceResult slice = eventRepository.findPublishedSlice(recruitStatus, cursor, size, now); + + // 신청자 수는 한 번에 모아 조회한다. 행사마다 따로 세면 페이지 크기만큼 쿼리가 더 나간다 + Map applicantCounts = eventRepository.countAppliedByEventIds( + slice.content().stream().map(Event::getId).toList()); List content = slice.content().stream() - .map(entry -> EventSummary.of(entry.event(), entry.applicantCount(), now)) + .map(event -> EventSummary.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now)) .toList(); return new CursorSliceResult<>(content, slice.hasNext(), slice.nextCursor()); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index d176c2a..749dff2 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -7,7 +7,6 @@ import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicantCount; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; @@ -40,7 +39,7 @@ public Optional findPublishedById(Long id) { } @Override - public CursorSliceResult findPublishedSlice( + public CursorSliceResult findPublishedSlice( RecruitStatus recruitStatus, EventCursor cursor, int size, @@ -68,21 +67,16 @@ public CursorSliceResult findPublishedSlice( .map(EventJpaEntity::toDomain) .toList(); - Map applicantCounts = countApplicants(events); - List content = events.stream() - .map(event -> new EventApplicantCount(event, applicantCounts.getOrDefault(event.getId(), 0L))) - .toList(); - String nextCursor = hasNext ? EventCursor.of(events.getLast()).format() : null; - return new CursorSliceResult<>(content, hasNext, nextCursor); + return new CursorSliceResult<>(events, hasNext, nextCursor); } - private Map countApplicants(List events) { - if (events.isEmpty()) { + @Override + public Map countAppliedByEventIds(List eventIds) { + if (eventIds.isEmpty()) { return Map.of(); } - List eventIds = events.stream().map(Event::getId).toList(); return eventJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED).stream() .collect(Collectors.toMap(EventApplicantCountRow::eventId, EventApplicantCountRow::applicantCount)); } From 3b6d5425abea7078631e2a6e3f01e58ed336c24b Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:39:47 +0900 Subject: [PATCH 17/21] =?UTF-8?q?test:=20=ED=96=89=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=98=20=EC=8B=A0=EC=B2=AD=EC=9E=90=20=EC=88=98=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/EventServiceImplTest.java | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java new file mode 100644 index 0000000..9f86ce1 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -0,0 +1,176 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 행사 목록은 행사와 신청자 수를 각각 조회해 서비스에서 짝지으므로, 그 조합이 어긋나지 않는지 확인한다. + */ +class EventServiceImplTest { + + private static final LocalDateTime NOW = LocalDateTime.now(); + + private static Event event(Long id) { + return Event.of( + id, "행사 " + id, "설명", "전교생", "101호", + NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), + RecruitType.FIRST_COME, List.of(), 100, RecruitStatus.OPEN, true, 1L + ); + } + + @Test + @DisplayName("행사마다 자기 신청자 수를 붙인다") + void matchesApplicantCountToItsEvent() { + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(event(1L), event(2L)), false, null), + Map.of(1L, 3L, 2L, 7L)); + + List content = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20).content(); + + // 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다 + assertEquals(List.of(1L, 2L), content.stream().map(EventSummary::eventId).toList()); + assertEquals(List.of(1L, 2L), repository.requestedEventIds); + } + + @Test + @DisplayName("신청이 한 건도 없는 행사도 0명으로 채워 목록에서 빠지지 않는다") + void keepsEventsWithoutApplicants() { + // 집계 쿼리는 신청이 없는 행사를 아예 돌려주지 않으므로 2번 행사는 맵에 없다 + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(event(1L), event(2L)), false, null), + Map.of(1L, 3L)); + + List content = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20).content(); + + assertEquals(2, content.size()); + assertEquals(List.of(1L, 2L), content.stream().map(EventSummary::eventId).toList()); + } + + @Test + @DisplayName("정원이 찬 행사만 마감으로 바뀐다") + void appliesCountPerEvent() { + Event full = Event.of( + 1L, "정원 3명", "설명", "전교생", "101호", + NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), + RecruitType.FIRST_COME, List.of(), 3, RecruitStatus.OPEN, true, 1L); + Event roomy = Event.of( + 2L, "정원 100명", "설명", "전교생", "101호", + NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), + RecruitType.FIRST_COME, List.of(), 100, RecruitStatus.OPEN, true, 1L); + + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(full, roomy), false, null), + Map.of(1L, 3L, 2L, 3L)); + + List content = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20).content(); + + assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus()); + assertEquals(RecruitStatus.OPEN, content.get(1).recruitStatus()); + } + + @Test + @DisplayName("hasNext와 nextCursor는 조회 결과를 그대로 싣는다") + void carriesSliceMetadata() { + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(event(1L)), true, "커서"), Map.of()); + + CursorSliceResult result = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20); + + assertTrue(result.hasNext()); + assertEquals("커서", result.nextCursor()); + } + + @Test + @DisplayName("조회 결과가 없으면 신청자 수도 빈 목록으로 물어본다") + void asksNothingWhenSliceIsEmpty() { + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(), false, null), Map.of()); + + CursorSliceResult result = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20); + + assertTrue(result.content().isEmpty()); + assertEquals(List.of(), repository.requestedEventIds); + } + + /** 목록 조회에 쓰이는 두 메서드만 답하고, 나머지는 이 테스트가 건드리지 않는다. */ + private static final class FakeEventRepository implements EventRepository { + + private final CursorSliceResult slice; + private final Map applicantCounts; + private List requestedEventIds; + + private FakeEventRepository(CursorSliceResult slice, Map applicantCounts) { + this.slice = slice; + this.applicantCounts = applicantCounts; + } + + @Override + public CursorSliceResult findPublishedSlice( + RecruitStatus recruitStatus, EventCursor cursor, int size, LocalDateTime now) { + return slice; + } + + @Override + public Map countAppliedByEventIds(List eventIds) { + this.requestedEventIds = eventIds; + return applicantCounts; + } + + @Override + public Optional findById(Long id) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findPublishedById(Long id) { + throw new UnsupportedOperationException(); + } + + @Override + public List findQuestionsByEventId(Long eventId) { + throw new UnsupportedOperationException(); + } + + @Override + public long countAppliedByEventId(Long eventId) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { + throw new UnsupportedOperationException(); + } + + @Override + public EventApplication saveApplication(EventApplication application) { + throw new UnsupportedOperationException(); + } + + @Override + public List saveAnswers(List answers) { + throw new UnsupportedOperationException(); + } + } +} From e30af8d5bb6b58910d59f78e8454fe7be1982dd9 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:55:57 +0900 Subject: [PATCH 18/21] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=8A=AC=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=EC=A1=B0=EB=A6=BD?= =?UTF-8?q?=EC=9D=84=20=EA=B3=B5=ED=86=B5=20=ED=97=AC=ED=8D=BC=20ofSlice?= =?UTF-8?q?=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/db/event/EventRepositoryImpl.java | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index 749dff2..23e48bd 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -61,15 +61,8 @@ public CursorSliceResult findPublishedSlice( pageable ); - boolean hasNext = entities.size() > size; - List events = entities.stream() - .limit(size) - .map(EventJpaEntity::toDomain) - .toList(); - - String nextCursor = hasNext ? EventCursor.of(events.getLast()).format() : null; - - return new CursorSliceResult<>(events, hasNext, nextCursor); + return CursorSliceResult.ofSlice( + entities, size, EventJpaEntity::toDomain, event -> EventCursor.of(event).format()); } @Override From 9c16c3871fa77e21002a453cec6c35ad540d0993 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:56:01 +0900 Subject: [PATCH 19/21] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B3=80=ED=99=98=EC=9D=84=20CursorSliceR?= =?UTF-8?q?esponse=20=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/event/AppEventController.java | 12 +----------- .../ac/kookmin/stream/CursorSliceResponse.java | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java index 8b88e32..35bda0b 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java @@ -2,7 +2,6 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.ApiResponse; -import kr.ac.kookmin.stream.CursorCodec; import kr.ac.kookmin.stream.CursorSliceResponse; import kr.ac.kookmin.stream.app.AppApiUser; import kr.ac.kookmin.stream.common.CursorSliceResult; @@ -30,7 +29,7 @@ public ApiResponse> getEvents( ) { CursorSliceResult result = eventService.getPublishedEvents( request.toRecruitStatus(), request.toCursor(), request.sizeOrDefault()); - return ApiResponse.success(CursorSliceResponse.from(toResponse(result))); + return ApiResponse.success(CursorSliceResponse.of(result, EventListItemResponse::from)); } @GetMapping("/{eventId}") @@ -53,13 +52,4 @@ public ApiResponse apply( EventApplyResponse.from(eventService.apply(eventId, apiUser.userId(), request.toCommand())) ); } - - // 커서는 클라이언트에게 불투명한 토큰이어야 하므로 응답 직전 웹 계층에서 인코딩한다 - private CursorSliceResult toResponse(CursorSliceResult result) { - return new CursorSliceResult<>( - result.content().stream().map(EventListItemResponse::from).toList(), - result.hasNext(), - result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor()) - ); - } } diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java index 019667d..e34872f 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream; import java.util.List; +import java.util.function.Function; import kr.ac.kookmin.stream.common.CursorSliceResult; public record CursorSliceResponse(List content, boolean hasNext, String nextCursor) { @@ -12,4 +13,19 @@ public static CursorSliceResponse from(CursorSliceResult result) { result.nextCursor() ); } + + /** + * 도메인 슬라이스를 응답으로 옮긴다. 커서는 클라이언트에게 불투명한 토큰이어야 하므로 여기서 인코딩한다. + *

+ * 컨트롤러마다 같은 변환·인코딩을 되풀이하지 않도록 웹 계층 공통으로 둔다. + * + * @param mapper 도메인 객체를 응답 DTO로 옮기는 변환 + */ + public static CursorSliceResponse of(CursorSliceResult result, Function mapper) { + return new CursorSliceResponse<>( + result.content().stream().map(mapper).toList(), + result.hasNext(), + result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor()) + ); + } } From db5e456152c6781ff1ab5d246b4a59e3863c58be Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:56:20 +0900 Subject: [PATCH 20/21] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20EventApplicantCountProjection=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...licantCountRow.java => EventApplicantCountProjection.java} | 2 +- .../kr/ac/kookmin/stream/db/event/EventJpaRepository.java | 4 ++-- .../kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) rename infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/{EventApplicantCountRow.java => EventApplicantCountProjection.java} (68%) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountProjection.java similarity index 68% rename from infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java rename to infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountProjection.java index 573024a..1546ac0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountProjection.java @@ -3,4 +3,4 @@ /** * 행사별 유효 신청자 수 집계 결과. JPQL 생성자 표현식으로 채워지는 조회 전용 프로젝션이다. */ -public record EventApplicantCountRow(Long eventId, Long applicantCount) {} +public record EventApplicantCountProjection(Long eventId, Long applicantCount) {} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index d387ad0..268bf3f 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -67,12 +67,12 @@ List findPublishedSlice( ); @Query(""" - SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountRow(a.eventId, COUNT(a)) + SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountProjection(a.eventId, COUNT(a)) FROM EventApplicationJpaEntity a WHERE a.eventId IN :eventIds AND a.status = :appliedStatus GROUP BY a.eventId """) - List countApplicantsByEventIds( + List countApplicantsByEventIds( @Param("eventIds") List eventIds, @Param("appliedStatus") EventApplicationStatus appliedStatus ); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index 23e48bd..3f47240 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -71,7 +71,9 @@ public Map countAppliedByEventIds(List eventIds) { return Map.of(); } return eventJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED).stream() - .collect(Collectors.toMap(EventApplicantCountRow::eventId, EventApplicantCountRow::applicantCount)); + .collect(Collectors.toMap( + EventApplicantCountProjection::eventId, + EventApplicantCountProjection::applicantCount)); } @Override From 723e832d2eb436b2165e98997e788ad5c9be6e0d Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:57:34 +0900 Subject: [PATCH 21/21] =?UTF-8?q?docs:=20=EC=A1=B0=ED=9A=8C=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=ED=94=84=EB=A1=9C=EC=A0=9D=EC=85=98=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=EA=B3=BC=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A1=B0=ED=95=A9=20=EA=B8=88=EC=A7=80=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 12f472b..44e528a 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -21,6 +21,7 @@ Business는 공개 인터페이스 + `service.impl` 구현체로 구성한다. | Data Access | Repository 인터페이스 | `{Domain}Repository` | `core:domain:{모듈}` `domain/{도메인}/repository` (공개) | | Data Access | Repository 구현체 | `{Domain}RepositoryImpl` | `infrastructure:db` | | Data Access | JPA Repository | `{Domain}JpaRepository` | `infrastructure:db` | +| Data Access | 조회 전용 프로젝션 | `{조회내용}Projection` (예: `EventApplicantCountProjection`) | `infrastructure:db` | | Data Access | 외부 API 클라이언트 인터페이스 | `{Domain}Client` | `core:domain:{모듈}` `domain/{도메인}/repository` (공개) | | Data Access | Client 구현체 | `{Domain}ClientImpl` | `infrastructure:client` | @@ -240,6 +241,44 @@ public class MemberRepositoryImpl implements MemberRepository { } ``` +**조회 전용 프로젝션** — 엔티티 전체가 아니라 일부 컬럼·집계 결과만 필요하면 `record`로 프로젝션을 선언하고 JPQL 생성자 표현식으로 채운다. `List`로 받아 인덱스로 꺼내면 무엇이 담겼는지 드러나지 않고 타입 안전하지도 않다. + +- 이름은 **`{조회내용}Projection`**으로 끝낸다. `Row`·`Dto`처럼 담긴 값이 아니라 담는 그릇을 가리키는 이름은 쓰지 않는다. +- `infrastructure:db`에 둔다. 조회 결과를 나르는 기술 세부라 도메인이 알 필요가 없다. +- 도메인 객체로 바꾸는 책임은 `{Domain}RepositoryImpl`이 진다. 프로젝션이 `core:domain` 밖으로 나가지 않는다. + +```java +// infrastructure:db +public record EventApplicantCountProjection(Long eventId, Long applicantCount) {} + +public interface EventJpaRepository extends JpaRepository { + + @Query(""" + SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountProjection(a.eventId, COUNT(a)) + FROM EventApplicationJpaEntity a + WHERE a.eventId IN :eventIds AND a.status = :appliedStatus + GROUP BY a.eventId + """) + List countApplicantsByEventIds( + @Param("eventIds") List eventIds, + @Param("appliedStatus") EventApplicationStatus appliedStatus + ); +} +``` + +**레포지토리는 조합하지 않는다** — 여러 쿼리 결과를 짝지어 하나로 합치는 일은 `{Domain}ServiceImpl`이 한다. 레포지토리는 조회 결과를 각각 그대로 돌려준다. 조합은 DB와 무관한 규칙이라 서비스에 둬야 DB 없이 단위 테스트할 수 있고, 호출부가 필요한 조회만 골라 쓸 수 있다. + +```java +// ❌ 레포지토리가 행사와 신청자 수를 짝지어 반환 +CursorSliceResult findPublishedSlice(...); + +// ✅ 각각 반환하고 서비스가 조합 +CursorSliceResult findPublishedSlice(...); +Map countAppliedByEventIds(List eventIds); +``` + +--- + ### 2-7. Business Layer (Service) - `{Domain}Service`는 공개 인터페이스(`service`), `{Domain}ServiceImpl`는 구현체(`service.impl`). `service.impl` 안의 클래스는 구현체·협력 객체 모두 **package-private**으로 선언한다(package-private `@Service`도 빈 등록됨). ArchUnit(`DomainImplAccessTests`)이 public 클래스와 외부 참조를 잡는다.