[Feat/#19] 게시판(공지) 목록/상세 조회 API 추가 - #21
Conversation
공지 목록처럼 여러 컬럼(pinned+createdAt+id) 기준 keyset 페이지네이션은 단순 Long id로 커서를 표현할 수 없어 String으로 바꾼다.
Notice에 createdAt을 추가하고, NoticeErrorCode·NoticeRepository(공개 인터페이스)·NoticeService/NoticeServiceImpl(조회 전용)을 추가한다.
pinned DESC, created_at DESC, notice_id DESC 기준 keyset 페이지네이션을 native query로 구현하고, 커서는 base64(pinned|createdAt|id)로 인코딩한다.
/v1/app/notices(목록, category 필터+커서 페이지네이션)와
/v1/app/notices/{noticeId}(상세)를 추가한다. 썸네일·첨부파일 URL은
File 도메인 연동 전이라 우선 null로 응답한다.
| @RequestParam(name = "size", defaultValue = "20") int size | ||
| ) { | ||
| CursorSliceResult<Notice> result = noticeService.getNotices(parseCategory(category), cursor, size); | ||
| CursorSliceResponse<NoticeListItemResponse> response = new CursorSliceResponse<>( |
There was a problem hiding this comment.
CursorSliceResponse에 정적 팩토리 메서드가 있는 거 같은데 그걸 사용하는 쪽으로 수정해보시면 좋을 거 같아요~
| return ApiResponse.success(NoticeDetailResponse.from(notice)); | ||
| } | ||
|
|
||
| private NoticeCategory parseCategory(String category) { |
There was a problem hiding this comment.
NoticeCategory 클래스 안에 static 메소드로 두어서 응집을 높여도 좋을 거 같습니다.
| String title, | ||
| String content, | ||
| NoticeCategory category, | ||
| List<NoticeImageResponse> images, |
There was a problem hiding this comment.
NoticeImageResponse나 NoticeAttachmentResponse가 NoticeDetailResponse에서만 사용된다면 record 안에 record를 선언하는 중첩 record 방식으로 선언하는 건 어떤가요?
There was a problem hiding this comment.
중첩으로 선언하면 한눈에 파악하기도 좋고 응집도도 높아져서 좋은 방안이라고 생각합니다. 앞으로도 하나의 Response에서만 쓰이는 하위 DTO는 중첩 레코드로 선언하는 것으로 진행하면 될까요? 그렇다면 컨벤션 문서 수정도 필요할지 여쭤봅니다!
There was a problem hiding this comment.
그렇게 진행하시면 될 거 같아요! 컨벤션 문서도 아마 그렇게 이미 되어있을 거 같은데 안 되어 있다면 업데이트 부탁드립니다!
| return noticeJpaRepository.findNextSlice(categoryName, cursorPinned, cursorCreatedAt, cursorId, limit); | ||
| } | ||
|
|
||
| private String encodeCursor(Notice notice) { |
There was a problem hiding this comment.
커서 인코딩/디코딩은 웹 단에서 탈취되었을 경우를 대비해 조치하는 것이기 때문에 지금 구현하신 DB 부분보다는 웹 영역과 가깝다고 생각해요.
Base64 인코딩이나 구분자를 웹(Controller) 영역에서 사용할 수 있도록 필터를 만들거나, 공통으로 처리하는 클래스를 따로 만드는 건 어떨까요?
There was a problem hiding this comment.
넵 코멘트 주신 거 바탕으로 구현해보겠습니다!
| ORDER BY pinned DESC, created_at DESC, notice_id DESC | ||
| LIMIT :limit | ||
| """, | ||
| nativeQuery = true |
There was a problem hiding this comment.
두 개의 쿼리 전부 nativeQuery로 짜신 이유가 있을까요?!
There was a problem hiding this comment.
pinned < :cursorPinned OR (pinned = :cursorPinned AND ...) 같은 OR 묶음 튜플 비교가 JPA 문법으로는 불가능한 것으로 알고 있어서 @Query를 이용한 JPQL 또는 nativeQuery를 사용해야했는데, 현재 프로젝트에서 MySQL을 이미 사용하고 있기 때문에 그대로 nativeQuery로 짜는 것이 더 좋다고 판단했습니다. 혹시 더 좋은 방법이 있다면 알려주시면 반영해보겠습니다!
There was a problem hiding this comment.
지금 쿼리를 보면 Mysql의 고유한 쿼리문을 사용하고 있지는 않아서 JPA 엔티티 기반으로 다른 데이터베이스에서도 적용이 가능하고 IDE에서 디버깅이 가능한 JPQL을 추천드려요!
Controller에 있던 문자열→enum 변환 로직을 NoticeCategory.from()으로 옮겨 응집도를 높인다.
NoticeDetailResponse에서만 쓰이는 하위 DTO라 별도 파일 대신 중첩 record(Image, Attachment)로 선언한다. 관련 컨벤션을 coding-style.md 2-2절에 문서화한다.
MySQL 전용 문법을 쓰지 않는 쿼리라 엔티티/필드 기준 JPQL로 바꾼다. boolean 정렬 비교는 CASE WHEN으로 0/1 캐스팅해서 처리하고, LIMIT은 Pageable로 대체한다.
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 페이지네이션은 정렬·필터 조건이 바뀌면 커서가 가리키는 경계가 더 이상 유효하지 않다.
|
리뷰해주신 부분 반영해서 커밋했습니다.
4번의 경우 보내주신 링크 참고해서 구현했는데 확인 한 번 부탁드리겠습니다! |
| CursorSliceResponse<NoticeListItemResponse> response = new CursorSliceResponse<>( | ||
| NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor)); | ||
| CursorSliceResult<Notice> result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size); | ||
| CursorSliceResult<NoticeListItemResponse> mapped = new CursorSliceResult<>( |
There was a problem hiding this comment.
CursorSliceResult는 core 모듈에서 사용하기 위한 객체입니다. 안에 들어가는 타입은 가급적이면 api용 dto가 아니라 도메인 객체나 vo가 되면 좋을 거 같아요.
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
학생 앱(app-api)에 게시판(공지) 조회 API가 하나도 없습니다. 공지 목록 조회, 공지 상세 조회 2개를 추가합니다.
❓ 왜 해결해야 하나요?
학생 앱 게시판 화면 개발을 위해 필요한 API입니다.
core:domain:welfare에는 이미Notice도메인 객체·JPA 엔티티·notices테이블(#13)이 있어 Service/Repository/Controller 레이어만 붙이면 되는 상태였습니다.⭐ 어떻게 해결했나요?
GET /v1/app/notices?category=&cursor=&size=— 목록 조회 (STUDENT 인증)pinned DESC → createdAt DESC → id DESC기준 keyset 커서 페이지네이션, 커서는base64(pinned|createdAt|id)category없으면 전체, 잘못된 값이면 400INVALID_INPUTGET /v1/app/notices/{noticeId}— 상세 조회 (STUDENT 인증), 없거나 삭제된 공지면 404NOTICE_NOT_FOUNDcore:common의CursorSliceResult(+api:common-api의CursorSliceResponse)nextCursor타입을Long→String으로 변경 (복합 정렬 커서 지원 목적, 다른 곳에서 아직 안 쓰고 있어 영향 없음)🧩 이 PR의 한계 & 트레이드오프
thumbnailUrl(목록),images[].fileUrl,attachments[].fileUrl/fileName(상세)은 File 도메인(#17, 아직 Service/Repository 미구현) 연동이 필요해서 이번 스코프에서 제외하고 전부null로 응답합니다. File 도메인이 붙으면 후속 PR에서 채울 예정입니다.⛓️ 기존 기능에 미치는 영향
CursorSliceResult/CursorSliceResponse타입 변경(Long→String)이 있지만 현재 이걸 쓰는 곳이 이 PR의 Notice 목록 조회뿐이라 다른 기능에 영향 없습니다.🔀 Edge Case & 실패 시나리오
is_deleted=true)는 목록/상세 모두에서 제외category쿼리 파라미터가 enum에 없는 값이면 400noticeId→ 404/v1/app/**SecurityConfig 규칙)Docker MySQL 8.0에 마이그레이션 적용 후 테스트 데이터로 위 시나리오 전부 직접 호출해서 확인했습니다. 이 과정에서
@RequestParam/@PathVariable에 이름을 명시 안 해 500 나던 버그(-parameters컴파일 플래그 없음)를 발견해서 같이 고쳤습니다.📋 검토한 대안과 선택 이유
Long id가 아니라base64(pinned|createdAt|id)문자열로 설계 — 고정글이 항상 상단에 오면서 최신순 정렬도 유지해야 해서 단일 컬럼 커서로는 불가능했습니다.notices였지만, 프로젝트 컨벤션(CursorSliceResponse의content)을 따르는 쪽으로 정했습니다.💬 리뷰 포인트
[r]CursorSliceResult타입 변경(Long→String)이 공유 커널 첫 실사용 사례라 이 설계가 맞는지 확인 부탁드립니다[c]File 도메인 연동 전까지fileUrl등을null로 내려주는 방식이 프론트 입장에서 괜찮은지