Skip to content

[Feat/#19] 게시판(공지) 목록/상세 조회 API 추가 - #21

Open
jjunh33 wants to merge 8 commits into
mainfrom
feat/#19-notice-apis
Open

[Feat/#19] 게시판(공지) 목록/상세 조회 API 추가#21
jjunh33 wants to merge 8 commits into
mainfrom
feat/#19-notice-apis

Conversation

@jjunh33

@jjunh33 jjunh33 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

#️⃣연관된 이슈

Base 브랜치를 feat/#18-global-exception-handler로 잡았습니다. #18(GlobalExceptionHandler)이 없으면 NOTICE_NOT_FOUND 등 에러 응답이 ApiResponse 포맷으로 안 나가서 의존 관계가 있습니다. #18이 먼저 main에 머지되면 이 PR의 base도 main으로 바뀝니다(diff는 #19 커밋 4개만 남음).

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

학생 앱(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 없으면 전체, 잘못된 값이면 400 INVALID_INPUT
  • GET /v1/app/notices/{noticeId} — 상세 조회 (STUDENT 인증), 없거나 삭제된 공지면 404 NOTICE_NOT_FOUND
  • core:commonCursorSliceResult(+api:common-apiCursorSliceResponse) nextCursor 타입을 LongString으로 변경 (복합 정렬 커서 지원 목적, 다른 곳에서 아직 안 쓰고 있어 영향 없음)
  • 커밋은 레이어별로 분리했습니다: 공유타입 변경 → Notice Service/Repository → JPA 구현 → Controller/DTO

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

thumbnailUrl(목록), images[].fileUrl, attachments[].fileUrl/fileName(상세)은 File 도메인(#17, 아직 Service/Repository 미구현) 연동이 필요해서 이번 스코프에서 제외하고 전부 null로 응답합니다. File 도메인이 붙으면 후속 PR에서 채울 예정입니다.

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

CursorSliceResult/CursorSliceResponse 타입 변경(Long→String)이 있지만 현재 이걸 쓰는 곳이 이 PR의 Notice 목록 조회뿐이라 다른 기능에 영향 없습니다.

🔀 Edge Case & 실패 시나리오

  • soft delete된 공지(is_deleted=true)는 목록/상세 모두에서 제외
  • category 쿼리 파라미터가 enum에 없는 값이면 400
  • 존재하지 않는 noticeId → 404
  • 인증 없이 호출 → 401 (/v1/app/** SecurityConfig 규칙)

Docker MySQL 8.0에 마이그레이션 적용 후 테스트 데이터로 위 시나리오 전부 직접 호출해서 확인했습니다. 이 과정에서 @RequestParam/@PathVariable에 이름을 명시 안 해 500 나던 버그(-parameters 컴파일 플래그 없음)를 발견해서 같이 고쳤습니다.

📋 검토한 대안과 선택 이유

  • 커서를 단순 Long id가 아니라 base64(pinned|createdAt|id) 문자열로 설계 — 고정글이 항상 상단에 오면서 최신순 정렬도 유지해야 해서 단일 컬럼 커서로는 불가능했습니다.
  • 목록 응답 필드명은 API 설계 문서상 notices였지만, 프로젝트 컨벤션(CursorSliceResponsecontent)을 따르는 쪽으로 정했습니다.

💬 리뷰 포인트

  • [r] CursorSliceResult 타입 변경(Long→String)이 공유 커널 첫 실사용 사례라 이 설계가 맞는지 확인 부탁드립니다
  • [c] File 도메인 연동 전까지 fileUrl 등을 null로 내려주는 방식이 프론트 입장에서 괜찮은지

공지 목록처럼 여러 컬럼(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<>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CursorSliceResponse에 정적 팩토리 메서드가 있는 거 같은데 그걸 사용하는 쪽으로 수정해보시면 좋을 거 같아요~

return ApiResponse.success(NoticeDetailResponse.from(notice));
}

private NoticeCategory parseCategory(String category) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NoticeCategory 클래스 안에 static 메소드로 두어서 응집을 높여도 좋을 거 같습니다.

String title,
String content,
NoticeCategory category,
List<NoticeImageResponse> images,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NoticeImageResponseNoticeAttachmentResponseNoticeDetailResponse에서만 사용된다면 record 안에 record를 선언하는 중첩 record 방식으로 선언하는 건 어떤가요?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

중첩으로 선언하면 한눈에 파악하기도 좋고 응집도도 높아져서 좋은 방안이라고 생각합니다. 앞으로도 하나의 Response에서만 쓰이는 하위 DTO는 중첩 레코드로 선언하는 것으로 진행하면 될까요? 그렇다면 컨벤션 문서 수정도 필요할지 여쭤봅니다!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그렇게 진행하시면 될 거 같아요! 컨벤션 문서도 아마 그렇게 이미 되어있을 거 같은데 안 되어 있다면 업데이트 부탁드립니다!

return noticeJpaRepository.findNextSlice(categoryName, cursorPinned, cursorCreatedAt, cursorId, limit);
}

private String encodeCursor(Notice notice) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

커서 인코딩/디코딩은 웹 단에서 탈취되었을 경우를 대비해 조치하는 것이기 때문에 지금 구현하신 DB 부분보다는 웹 영역과 가깝다고 생각해요.
Base64 인코딩이나 구분자를 웹(Controller) 영역에서 사용할 수 있도록 필터를 만들거나, 공통으로 처리하는 클래스를 따로 만드는 건 어떨까요?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 코멘트 주신 거 바탕으로 구현해보겠습니다!

ORDER BY pinned DESC, created_at DESC, notice_id DESC
LIMIT :limit
""",
nativeQuery = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

두 개의 쿼리 전부 nativeQuery로 짜신 이유가 있을까요?!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pinned < :cursorPinned OR (pinned = :cursorPinned AND ...) 같은 OR 묶음 튜플 비교가 JPA 문법으로는 불가능한 것으로 알고 있어서 @Query를 이용한 JPQL 또는 nativeQuery를 사용해야했는데, 현재 프로젝트에서 MySQL을 이미 사용하고 있기 때문에 그대로 nativeQuery로 짜는 것이 더 좋다고 판단했습니다. 혹시 더 좋은 방법이 있다면 알려주시면 반영해보겠습니다!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 쿼리를 보면 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 페이지네이션은
정렬·필터 조건이 바뀌면 커서가 가리키는 경계가 더 이상 유효하지 않다.
@jjunh33

jjunh33 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

리뷰해주신 부분 반영해서 커밋했습니다.

  1. CursorSliceResponse 정적 팩토리 메서드 사용
  2. category 파싱 NoticeCategory 안에 static 메서드로 구현
  3. 중첩 레코드 사용, coding-style.md에 반영 완료
  4. 커서 인코딩/디코딩은 CursorCodec 범용 유틸을 만들어 컨트롤러 영역에서 사용할 수 있도록 함
  5. nativeQuery JPQL로 수정

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<>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CursorSliceResult는 core 모듈에서 사용하기 위한 객체입니다. 안에 들어가는 타입은 가급적이면 api용 dto가 아니라 도메인 객체나 vo가 되면 좋을 거 같아요.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants