From ee10c574bdcf58c9ceb2438c15e531efbf8065f3 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 9 Sep 2026 11:54:47 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20CursorSliceResult=EC=9D=98=20nextCu?= =?UTF-8?q?rsor=20=ED=83=80=EC=9E=85=EC=9D=84=20Long=EC=97=90=EC=84=9C=20S?= =?UTF-8?q?tring=EC=9C=BC=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 공지 목록처럼 여러 컬럼(pinned+createdAt+id) 기준 keyset 페이지네이션은 단순 Long id로 커서를 표현할 수 없어 String으로 바꾼다. --- .../src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java | 2 +- .../java/kr/ac/kookmin/stream/common/CursorSliceResult.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 21e6277..019667d 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 @@ -3,7 +3,7 @@ import java.util.List; import kr.ac.kookmin.stream.common.CursorSliceResult; -public record CursorSliceResponse(List content, boolean hasNext, Long nextCursor) { +public record CursorSliceResponse(List content, boolean hasNext, String nextCursor) { public static CursorSliceResponse from(CursorSliceResult result) { return new CursorSliceResponse<>( diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java index 4bd0d3d..556170c 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java @@ -2,4 +2,4 @@ import java.util.List; -public record CursorSliceResult(List content, boolean hasNext, Long nextCursor) {} +public record CursorSliceResult(List content, boolean hasNext, String nextCursor) {} From e8643f280ceadb6872cb2ab301150ee2c607c7f6 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 9 Sep 2026 11:54:52 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20Notice=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20Service/Repo?= =?UTF-8?q?sitory=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notice에 createdAt을 추가하고, NoticeErrorCode·NoticeRepository(공개 인터페이스)·NoticeService/NoticeServiceImpl(조회 전용)을 추가한다. --- .../welfare/domain/notice/domain/Notice.java | 7 ++-- .../domain/notice/domain/NoticeErrorCode.java | 18 +++++++++++ .../notice/repository/NoticeRepository.java | 11 +++++++ .../domain/notice/service/NoticeService.java | 10 ++++++ .../service/impl/NoticeServiceImpl.java | 32 +++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java index af2e2b2..cfbbd39 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/Notice.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.welfare.domain.notice.domain; +import java.time.LocalDateTime; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -19,6 +20,7 @@ public class Notice { private Long createdBy; private List attachmentIds; private List imageIds; + private LocalDateTime createdAt; public static Notice of( Long id, @@ -28,8 +30,9 @@ public static Notice of( boolean pinned, Long createdBy, List attachmentIds, - List imageIds + List imageIds, + LocalDateTime createdAt ) { - return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds); + return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java new file mode 100644 index 0000000..8e047de --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum NoticeErrorCode implements ErrorCode { + + NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."); + + private final int status; + private final String message; +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..43215f3 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; + +public interface NoticeRepository { + CursorSliceResult findAll(NoticeCategory category, String cursor, int size); + Optional findById(Long id); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java new file mode 100644 index 0000000..582e05e --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.service; + +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; + +public interface NoticeService { + CursorSliceResult getNotices(NoticeCategory category, String cursor, int size); + Notice getNotice(Long id); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java new file mode 100644 index 0000000..a4bf02a --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java @@ -0,0 +1,32 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.service.impl; + +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeErrorCode; +import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; +import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class NoticeServiceImpl implements NoticeService { + + private final NoticeRepository noticeRepository; + + @Override + @Transactional(readOnly = true) + public CursorSliceResult getNotices(NoticeCategory category, String cursor, int size) { + return noticeRepository.findAll(category, cursor, size); + } + + @Override + @Transactional(readOnly = true) + public Notice getNotice(Long id) { + return noticeRepository.findById(id) + .orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND)); + } +} From 9abdc1fb7964b17a08bf656b109fabd948728d1a Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 9 Sep 2026 11:54:57 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20NoticeRepositoryImpl=EC=97=90=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pinned DESC, created_at DESC, notice_id DESC 기준 keyset 페이지네이션을 native query로 구현하고, 커서는 base64(pinned|createdAt|id)로 인코딩한다. --- .../stream/db/welfare/NoticeJpaEntity.java | 2 +- .../db/welfare/NoticeJpaRepository.java | 48 ++++++++++++++ .../db/welfare/NoticeRepositoryImpl.java | 62 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java index 3e80da0..8d8b739 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaEntity.java @@ -73,6 +73,6 @@ public static NoticeJpaEntity from(Notice notice) { } public Notice toDomain() { - return Notice.of(id, title, content, category, pinned, createdBy, attachmentIds, imageIds); + return Notice.of(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, getCreatedAt()); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java new file mode 100644 index 0000000..85d952d --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java @@ -0,0 +1,48 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NoticeJpaRepository extends JpaRepository { + + Optional findByIdAndIsDeletedFalse(Long id); + + @Query( + value = """ + SELECT * FROM notices + WHERE is_deleted = false + AND (:category IS NULL OR category = :category) + ORDER BY pinned DESC, created_at DESC, notice_id DESC + LIMIT :limit + """, + nativeQuery = true + ) + List findFirstSlice(@Param("category") String category, @Param("limit") int limit); + + @Query( + value = """ + SELECT * FROM notices + WHERE is_deleted = false + AND (:category IS NULL OR category = :category) + AND ( + pinned < :cursorPinned + OR (pinned = :cursorPinned AND created_at < :cursorCreatedAt) + OR (pinned = :cursorPinned AND created_at = :cursorCreatedAt AND notice_id < :cursorId) + ) + ORDER BY pinned DESC, created_at DESC, notice_id DESC + LIMIT :limit + """, + nativeQuery = true + ) + List findNextSlice( + @Param("category") String category, + @Param("cursorPinned") boolean cursorPinned, + @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + @Param("limit") int limit + ); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java new file mode 100644 index 0000000..1084912 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java @@ -0,0 +1,62 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class NoticeRepositoryImpl implements NoticeRepository { + + private static final String CURSOR_JOIN = "|"; + private static final String CURSOR_SPLIT_REGEX = "\\|"; + + private final NoticeJpaRepository noticeJpaRepository; + + @Override + public CursorSliceResult findAll(NoticeCategory category, String cursor, int size) { + String categoryName = category == null ? null : category.name(); + List entities = cursor == null + ? noticeJpaRepository.findFirstSlice(categoryName, size + 1) + : findNextSlice(categoryName, cursor, size + 1); + + boolean hasNext = entities.size() > size; + List content = entities.stream() + .limit(size) + .map(NoticeJpaEntity::toDomain) + .toList(); + String nextCursor = hasNext ? encodeCursor(content.get(content.size() - 1)) : null; + + return new CursorSliceResult<>(content, hasNext, nextCursor); + } + + @Override + public Optional findById(Long id) { + return noticeJpaRepository.findByIdAndIsDeletedFalse(id).map(NoticeJpaEntity::toDomain); + } + + private List findNextSlice(String categoryName, String cursor, int limit) { + String[] parts = decodeCursor(cursor); + boolean cursorPinned = Boolean.parseBoolean(parts[0]); + LocalDateTime cursorCreatedAt = LocalDateTime.parse(parts[1]); + Long cursorId = Long.valueOf(parts[2]); + return noticeJpaRepository.findNextSlice(categoryName, cursorPinned, cursorCreatedAt, cursorId, limit); + } + + private String encodeCursor(Notice notice) { + String raw = notice.isPinned() + CURSOR_JOIN + notice.getCreatedAt() + CURSOR_JOIN + notice.getId(); + return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + private String[] decodeCursor(String cursor) { + return new String(Base64.getDecoder().decode(cursor), StandardCharsets.UTF_8).split(CURSOR_SPLIT_REGEX); + } +} From 7694ff93dc3724cc9ea1846e9ca0b1ef08fcc241 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Wed, 9 Sep 2026 11:55:02 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D/=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=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 /v1/app/notices(목록, category 필터+커서 페이지네이션)와 /v1/app/notices/{noticeId}(상세)를 추가한다. 썸네일·첨부파일 URL은 File 도메인 연동 전이라 우선 null로 응답한다. --- api/app-api/build.gradle.kts | 1 + .../stream/welfare/AppNoticeController.java | 56 +++++++++++++++++++ .../welfare/NoticeAttachmentResponse.java | 8 +++ .../stream/welfare/NoticeDetailResponse.java | 36 ++++++++++++ .../stream/welfare/NoticeImageResponse.java | 8 +++ .../welfare/NoticeListItemResponse.java | 26 +++++++++ 6 files changed, 135 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeListItemResponse.java diff --git a/api/app-api/build.gradle.kts b/api/app-api/build.gradle.kts index 42bcd18..6b53d86 100644 --- a/api/app-api/build.gradle.kts +++ b/api/app-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "학생 앱 — STUDENT, /v1/app/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:welfare")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java new file mode 100644 index 0000000..9c07dde --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.welfare; + +import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.CursorSliceResponse; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/app/notices") +@RequiredArgsConstructor +public class AppNoticeController { + + private final NoticeService noticeService; + + @GetMapping + public ApiResponse> getNotices( + @RequestParam(name = "category", required = false) String category, + @RequestParam(name = "cursor", required = false) String cursor, + @RequestParam(name = "size", defaultValue = "20") int size + ) { + CursorSliceResult result = noticeService.getNotices(parseCategory(category), cursor, size); + CursorSliceResponse response = new CursorSliceResponse<>( + result.content().stream().map(NoticeListItemResponse::from).toList(), + result.hasNext(), + result.nextCursor() + ); + return ApiResponse.success(response); + } + + @GetMapping("/{noticeId}") + public ApiResponse getNotice(@PathVariable("noticeId") Long noticeId) { + Notice notice = noticeService.getNotice(noticeId); + return ApiResponse.success(NoticeDetailResponse.from(notice)); + } + + private NoticeCategory parseCategory(String category) { + if (category == null) { + return null; + } + try { + return NoticeCategory.valueOf(category); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java new file mode 100644 index 0000000..bbe3e85 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.welfare; + +public record NoticeAttachmentResponse(Long fileId, String fileName, String fileUrl) { + + public static NoticeAttachmentResponse from(Long fileId) { + return new NoticeAttachmentResponse(fileId, null, null); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java new file mode 100644 index 0000000..17c0cd0 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java @@ -0,0 +1,36 @@ +package kr.ac.kookmin.stream.welfare; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; + +public record NoticeDetailResponse( + Long noticeId, + String title, + String content, + NoticeCategory category, + List images, + List attachments, + LocalDateTime createdAt +) { + + public static NoticeDetailResponse from(Notice notice) { + List images = notice.getImageIds() == null + ? List.of() + : notice.getImageIds().stream().map(NoticeImageResponse::from).toList(); + List attachments = notice.getAttachmentIds() == null + ? List.of() + : notice.getAttachmentIds().stream().map(NoticeAttachmentResponse::from).toList(); + + return new NoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getCategory(), + images, + attachments, + notice.getCreatedAt() + ); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java new file mode 100644 index 0000000..c8b207c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.welfare; + +public record NoticeImageResponse(Long fileId, String fileUrl) { + + public static NoticeImageResponse from(Long fileId) { + return new NoticeImageResponse(fileId, null); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeListItemResponse.java new file mode 100644 index 0000000..b70ca14 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeListItemResponse.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.welfare; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; + +public record NoticeListItemResponse( + Long noticeId, + String title, + NoticeCategory category, + LocalDateTime createdAt, + String thumbnailUrl, + boolean pinned +) { + + public static NoticeListItemResponse from(Notice notice) { + return new NoticeListItemResponse( + notice.getId(), + notice.getTitle(), + notice.getCategory(), + notice.getCreatedAt(), + null, + notice.isPinned() + ); + } +} From 3207de1707f93f2b9054536cfbe9f0240db09559 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 10 Sep 2026 13:06:15 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20category=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=EC=9D=84=20NoticeCategory=20=EC=A0=95=EC=A0=81=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=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 Controller에 있던 문자열→enum 변환 로직을 NoticeCategory.from()으로 옮겨 응집도를 높인다. --- .../domain/notice/domain/NoticeCategory.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java index e241a79..e30fc66 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCategory.java @@ -1,6 +1,20 @@ package kr.ac.kookmin.stream.welfare.domain.notice.domain; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; + public enum NoticeCategory { GENERAL, - PARTNERSHIP + PARTNERSHIP; + + public static NoticeCategory from(String value) { + if (value == null) { + return null; + } + try { + return NoticeCategory.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } } From d2109347597f330b3ca7322c5be1f3e62b0f0fd1 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 10 Sep 2026 13:06:20 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20NoticeImageResponse/NoticeAttac?= =?UTF-8?q?hmentResponse=EB=A5=BC=20=EC=A4=91=EC=B2=A9=20record=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoticeDetailResponse에서만 쓰이는 하위 DTO라 별도 파일 대신 중첩 record(Image, Attachment)로 선언한다. 관련 컨벤션을 coding-style.md 2-2절에 문서화한다. --- .../welfare/NoticeAttachmentResponse.java | 8 ------ .../stream/welfare/NoticeDetailResponse.java | 26 ++++++++++++++----- .../stream/welfare/NoticeImageResponse.java | 8 ------ docs/conventions/coding-style.md | 16 ++++++++++++ 4 files changed, 36 insertions(+), 22 deletions(-) delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java delete mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java deleted file mode 100644 index bbe3e85..0000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeAttachmentResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.ac.kookmin.stream.welfare; - -public record NoticeAttachmentResponse(Long fileId, String fileName, String fileUrl) { - - public static NoticeAttachmentResponse from(Long fileId) { - return new NoticeAttachmentResponse(fileId, null, null); - } -} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java index 17c0cd0..63aa62e 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeDetailResponse.java @@ -10,18 +10,18 @@ public record NoticeDetailResponse( String title, String content, NoticeCategory category, - List images, - List attachments, + List images, + List attachments, LocalDateTime createdAt ) { public static NoticeDetailResponse from(Notice notice) { - List images = notice.getImageIds() == null + List images = notice.getImageIds() == null ? List.of() - : notice.getImageIds().stream().map(NoticeImageResponse::from).toList(); - List attachments = notice.getAttachmentIds() == null + : notice.getImageIds().stream().map(Image::from).toList(); + List attachments = notice.getAttachmentIds() == null ? List.of() - : notice.getAttachmentIds().stream().map(NoticeAttachmentResponse::from).toList(); + : notice.getAttachmentIds().stream().map(Attachment::from).toList(); return new NoticeDetailResponse( notice.getId(), @@ -33,4 +33,18 @@ public static NoticeDetailResponse from(Notice notice) { notice.getCreatedAt() ); } + + public record Image(Long fileId, String fileUrl) { + + public static Image from(Long fileId) { + return new Image(fileId, null); + } + } + + public record Attachment(Long fileId, String fileName, String fileUrl) { + + public static Attachment from(Long fileId) { + return new Attachment(fileId, null, null); + } + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java deleted file mode 100644 index c8b207c..0000000 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/NoticeImageResponse.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.ac.kookmin.stream.welfare; - -public record NoticeImageResponse(Long fileId, String fileUrl) { - - public static NoticeImageResponse from(Long fileId) { - return new NoticeImageResponse(fileId, null); - } -} diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index ccc5d9b..f464bf3 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -72,6 +72,22 @@ public record MemberResponse( public record MemberRegisterCommand(String studentNo, String name) {} ``` +**오직 하나의 Response에서만 쓰이는 하위 DTO**는 별도 파일로 빼지 않고 그 Response 안에 중첩 `record`로 선언한다. 다른 곳에서도 쓰이게 되면 그 시점에 최상위 파일로 승격한다. + +```java +// api:app-api +public record NoticeDetailResponse( + Long noticeId, + List images +) { + public record Image(Long fileId, String fileUrl) { + public static Image from(Long fileId) { + return new Image(fileId, null); + } + } +} +``` + **공통 응답 래퍼 (`ApiResponse`, `api:common-api`)** — `private` 생성자 + 정적 팩토리. ```java From 5b10b13a083dd7e6f94e63b02161a373dd2002c8 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 10 Sep 2026 13:06:25 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20NoticeJpaRepository=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20native=EC=97=90=EC=84=9C=20JPQL=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL 전용 문법을 쓰지 않는 쿼리라 엔티티/필드 기준 JPQL로 바꾼다. boolean 정렬 비교는 CASE WHEN으로 0/1 캐스팅해서 처리하고, LIMIT은 Pageable로 대체한다. --- .../db/welfare/NoticeJpaRepository.java | 50 ++++++++----------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java index 85d952d..9ee415e 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeJpaRepository.java @@ -3,6 +3,8 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +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; @@ -11,38 +13,30 @@ public interface NoticeJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id); - @Query( - value = """ - SELECT * FROM notices - WHERE is_deleted = false - AND (:category IS NULL OR category = :category) - ORDER BY pinned DESC, created_at DESC, notice_id DESC - LIMIT :limit - """, - nativeQuery = true - ) - List findFirstSlice(@Param("category") String category, @Param("limit") int limit); + @Query(""" + SELECT n FROM NoticeJpaEntity n + WHERE n.isDeleted = false + AND (:category IS NULL OR n.category = :category) + ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC + """) + List findFirstSlice(@Param("category") NoticeCategory category, Pageable pageable); - @Query( - value = """ - SELECT * FROM notices - WHERE is_deleted = false - AND (:category IS NULL OR category = :category) - AND ( - pinned < :cursorPinned - OR (pinned = :cursorPinned AND created_at < :cursorCreatedAt) - OR (pinned = :cursorPinned AND created_at = :cursorCreatedAt AND notice_id < :cursorId) - ) - ORDER BY pinned DESC, created_at DESC, notice_id DESC - LIMIT :limit - """, - nativeQuery = true - ) + @Query(""" + SELECT n FROM NoticeJpaEntity n + WHERE n.isDeleted = false + AND (:category IS NULL OR n.category = :category) + AND ( + (CASE WHEN n.pinned = true THEN 1 ELSE 0 END) < (CASE WHEN :cursorPinned = true THEN 1 ELSE 0 END) + OR (n.pinned = :cursorPinned AND n.createdAt < :cursorCreatedAt) + OR (n.pinned = :cursorPinned AND n.createdAt = :cursorCreatedAt AND n.id < :cursorId) + ) + ORDER BY n.pinned DESC, n.createdAt DESC, n.id DESC + """) List findNextSlice( - @Param("category") String category, + @Param("category") NoticeCategory category, @Param("cursorPinned") boolean cursorPinned, @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, @Param("cursorId") Long cursorId, - @Param("limit") int limit + Pageable pageable ); } From 7eff2fc26943f041c361b5c4b96556ab28042e36 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Thu, 10 Sep 2026 13:06:34 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=84=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=BD=94=EB=94=A9/=EB=94=94=EC=BD=94=EB=94=A9=EC=9D=84=20?= =?UTF-8?q?=EC=9B=B9=20=EA=B3=84=EC=B8=B5(CursorCodec)=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base64 인코딩·구분자 파싱은 웹(HTTP) 전송 과정에서 다루는 관심사라 infrastructure:db의 NoticeRepositoryImpl에서 걷어낸다. NoticeCursor를 새로 만들어 core:domain:welfare는 순수 문자열 표현(format/from)만 다루게 하고, Base64 변환은 api:common-api의 CursorCodec(범용, 다른 도메인의 커서 페이지네이션에도 재사용 가능)이 담당한다. 디코딩·인코딩은 AppNoticeController에서 수행한다. 추가로 NoticeCursor에 발급 시점의 category 필터를 함께 담아, 다른 category로 발급된 커서를 재사용하면 NOTICE_INVALID_CURSOR(400)로 거부하도록 NoticeServiceImpl에 검증을 추가한다 — keyset 페이지네이션은 정렬·필터 조건이 바뀌면 커서가 가리키는 경계가 더 이상 유효하지 않다. --- .../stream/welfare/AppNoticeController.java | 24 ++++-------- .../kr/ac/kookmin/stream/CursorCodec.java | 25 +++++++++++++ .../domain/notice/domain/NoticeCursor.java | 37 +++++++++++++++++++ .../domain/notice/domain/NoticeErrorCode.java | 3 +- .../notice/repository/NoticeRepository.java | 3 +- .../domain/notice/service/NoticeService.java | 3 +- .../service/impl/NoticeServiceImpl.java | 7 +++- .../db/welfare/NoticeRepositoryImpl.java | 35 ++++-------------- 8 files changed, 88 insertions(+), 49 deletions(-) create mode 100644 api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCursor.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java index 9c07dde..cf6fb37 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java @@ -1,12 +1,12 @@ package kr.ac.kookmin.stream.welfare; import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.CursorCodec; import kr.ac.kookmin.stream.CursorSliceResponse; -import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -28,13 +28,14 @@ public ApiResponse> getNotices( @RequestParam(name = "cursor", required = false) String cursor, @RequestParam(name = "size", defaultValue = "20") int size ) { - CursorSliceResult result = noticeService.getNotices(parseCategory(category), cursor, size); - CursorSliceResponse response = new CursorSliceResponse<>( + NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor)); + CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size); + CursorSliceResult mapped = new CursorSliceResult<>( result.content().stream().map(NoticeListItemResponse::from).toList(), result.hasNext(), - result.nextCursor() + result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor()) ); - return ApiResponse.success(response); + return ApiResponse.success(CursorSliceResponse.from(mapped)); } @GetMapping("/{noticeId}") @@ -42,15 +43,4 @@ public ApiResponse getNotice(@PathVariable("noticeId") Lon Notice notice = noticeService.getNotice(noticeId); return ApiResponse.success(NoticeDetailResponse.from(notice)); } - - private NoticeCategory parseCategory(String category) { - if (category == null) { - return null; - } - try { - return NoticeCategory.valueOf(category); - } catch (IllegalArgumentException e) { - throw new BusinessException(CommonErrorCode.INVALID_INPUT); - } - } } diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java new file mode 100644 index 0000000..51e5245 --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +// 커서 문자열을 클라이언트에게 불투명한 토큰으로 감싼다. 실제 정렬 키 파싱은 각 도메인이 담당하고, +// 여기서는 웹(쿼리 파라미터)으로 오가는 형태(Base64 URL-safe)만 다룬다. +public final class CursorCodec { + + private CursorCodec() {} + + public static String encode(String raw) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + public static String decode(String cursor) { + try { + return new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCursor.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCursor.java new file mode 100644 index 0000000..4623c47 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeCursor.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.welfare.domain.notice.domain; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; + +public record NoticeCursor(boolean pinned, LocalDateTime createdAt, Long id, NoticeCategory category) { + + private static final String JOIN = "|"; + private static final String SPLIT_REGEX = "\\|"; + private static final String NO_CATEGORY = "-"; + + public static NoticeCursor of(Notice notice, NoticeCategory category) { + return new NoticeCursor(notice.isPinned(), notice.getCreatedAt(), notice.getId(), category); + } + + // Base64 인코딩은 웹(Controller) 계층 책임이라 여기서는 순수 문자열 표현만 다룬다 + public static NoticeCursor from(String raw) { + String[] parts = raw.split(SPLIT_REGEX, -1); + if (parts.length != 4) { + throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); + } + try { + boolean pinned = Boolean.parseBoolean(parts[0]); + LocalDateTime createdAt = LocalDateTime.parse(parts[1]); + Long id = Long.valueOf(parts[2]); + NoticeCategory category = NO_CATEGORY.equals(parts[3]) ? null : NoticeCategory.valueOf(parts[3]); + return new NoticeCursor(pinned, createdAt, id, category); + } catch (RuntimeException e) { + throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); + } + } + + public String format() { + String categoryPart = category == null ? NO_CATEGORY : category.name(); + return pinned + JOIN + createdAt + JOIN + id + JOIN + categoryPart; + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java index 8e047de..dd50ba7 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/domain/NoticeErrorCode.java @@ -11,7 +11,8 @@ @AllArgsConstructor public enum NoticeErrorCode implements ErrorCode { - NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."); + NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."), + NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."); private final int status; private final String message; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java index 43215f3..5e9b101 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/repository/NoticeRepository.java @@ -4,8 +4,9 @@ import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; public interface NoticeRepository { - CursorSliceResult findAll(NoticeCategory category, String cursor, int size); + CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size); Optional findById(Long id); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java index 582e05e..881d912 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/NoticeService.java @@ -3,8 +3,9 @@ import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; public interface NoticeService { - CursorSliceResult getNotices(NoticeCategory category, String cursor, int size); + CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size); Notice getNotice(Long id); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java index a4bf02a..21ed908 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/notice/service/impl/NoticeServiceImpl.java @@ -4,6 +4,7 @@ import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeErrorCode; import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; @@ -19,7 +20,11 @@ class NoticeServiceImpl implements NoticeService { @Override @Transactional(readOnly = true) - public CursorSliceResult getNotices(NoticeCategory category, String cursor, int size) { + public CursorSliceResult getNotices(NoticeCategory category, NoticeCursor cursor, int size) { + // 커서가 다른 category 필터에서 발급됐다면 keyset 경계가 다른 정렬 결과를 가리키므로 거부한다 + if (cursor != null && cursor.category() != category) { + throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR); + } return noticeRepository.findAll(category, cursor, size); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java index 1084912..837b59c 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/NoticeRepositoryImpl.java @@ -1,39 +1,35 @@ package kr.ac.kookmin.stream.db.welfare; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.Base64; import java.util.List; import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class NoticeRepositoryImpl implements NoticeRepository { - private static final String CURSOR_JOIN = "|"; - private static final String CURSOR_SPLIT_REGEX = "\\|"; - private final NoticeJpaRepository noticeJpaRepository; @Override - public CursorSliceResult findAll(NoticeCategory category, String cursor, int size) { - String categoryName = category == null ? null : category.name(); + public CursorSliceResult findAll(NoticeCategory category, NoticeCursor cursor, int size) { + Pageable pageable = Pageable.ofSize(size + 1); List entities = cursor == null - ? noticeJpaRepository.findFirstSlice(categoryName, size + 1) - : findNextSlice(categoryName, cursor, size + 1); + ? noticeJpaRepository.findFirstSlice(category, pageable) + : noticeJpaRepository.findNextSlice(category, cursor.pinned(), cursor.createdAt(), cursor.id(), pageable); boolean hasNext = entities.size() > size; List content = entities.stream() .limit(size) .map(NoticeJpaEntity::toDomain) .toList(); - String nextCursor = hasNext ? encodeCursor(content.get(content.size() - 1)) : null; + String nextCursor = hasNext ? NoticeCursor.of(content.get(content.size() - 1), category).format() : null; return new CursorSliceResult<>(content, hasNext, nextCursor); } @@ -42,21 +38,4 @@ public CursorSliceResult findAll(NoticeCategory category, String cursor, public Optional findById(Long id) { return noticeJpaRepository.findByIdAndIsDeletedFalse(id).map(NoticeJpaEntity::toDomain); } - - private List findNextSlice(String categoryName, String cursor, int limit) { - String[] parts = decodeCursor(cursor); - boolean cursorPinned = Boolean.parseBoolean(parts[0]); - LocalDateTime cursorCreatedAt = LocalDateTime.parse(parts[1]); - Long cursorId = Long.valueOf(parts[2]); - return noticeJpaRepository.findNextSlice(categoryName, cursorPinned, cursorCreatedAt, cursorId, limit); - } - - private String encodeCursor(Notice notice) { - String raw = notice.isPinned() + CURSOR_JOIN + notice.getCreatedAt() + CURSOR_JOIN + notice.getId(); - return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); - } - - private String[] decodeCursor(String cursor) { - return new String(Base64.getDecoder().decode(cursor), StandardCharsets.UTF_8).split(CURSOR_SPLIT_REGEX); - } } From e7449f30e2eb7df0b1c97461bbc8f8c698e1f38f Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Sat, 12 Sep 2026 23:15:07 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=EB=AA=A9=EB=A1=9D=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20CursorSliceResult=20=EB=8C=80=EC=8B=A0=20C?= =?UTF-8?q?ursorSliceResponse=EB=A1=9C=20=EC=A7=81=EC=A0=91=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CursorSliceResult는 core 모듈이 도메인 객체를 담아 주고받는 용도인데, Controller에서 DTO(NoticeListItemResponse)를 담아 재구성하고 있었다. CursorSliceResult(도메인 객체, 올바른 사용)는 값만 꺼내 쓰고, 최종 응답은 DTO 전용 타입인 CursorSliceResponse를 바로 생성해서 만든다. --- .../kr/ac/kookmin/stream/welfare/AppNoticeController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java index cf6fb37..d91ef06 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/welfare/AppNoticeController.java @@ -30,12 +30,12 @@ public ApiResponse> getNotices( ) { NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor)); CursorSliceResult result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size); - CursorSliceResult mapped = new CursorSliceResult<>( + CursorSliceResponse response = new CursorSliceResponse<>( result.content().stream().map(NoticeListItemResponse::from).toList(), result.hasNext(), result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor()) ); - return ApiResponse.success(CursorSliceResponse.from(mapped)); + return ApiResponse.success(response); } @GetMapping("/{noticeId}")