Skip to content

[Feat/#142] 게시판(공지) 관리자 API 보완 - 고정 여부 반영 및 동시 수정 제어 - #143

Merged
jjunh33 merged 15 commits into
mainfrom
feat/#142-admin-notice-read-pin
Oct 22, 2026
Merged

jjunh33 merged 15 commits into
mainfrom
feat/#142-admin-notice-read-pin

Conversation

@jjunh33

@jjunh33 jjunh33 commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

#️⃣연관된 이슈

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

관리자 웹(stream-admin-web)의 공지 화면과 공지 관리자 API(#104)가 맞지 않습니다.

  • 등록·수정 폼에는 "상단 고정" 스위치가 있어 고정 여부를 저장과 함께 보내는데, API는 작성에서 항상 비고정으로 만들고 수정(PUT)은 pinned를 읽지 않으며 고정은 PATCH /{noticeId}/pin으로만 바꿉니다. 지금 연결하면 스위치가 오류 없이 무시됩니다.
  • 관리자 웹에는 종류 필터, 제목 검색, 페이지 번호 이동이 있는 공지 목록이 필요한데 관리자용 목록 API가 없습니다.
  • 수정 화면은 기존 공지를 불러와 폼을 채워야 하는데, 학생 앱 상세 응답에는 pinned가 없어 값을 얻을 방법이 없습니다.
  • 수정 API가 updatedAt을 요구해서 수정 화면이 이를 들고 있다가 되돌려 보내야 합니다. 관리자가 몇 명뿐인 공지 수정에서는 "원래 글이 그 사이 바뀌었는지"보다 "수정한 내용이 반영되는 것"이 중요한데, 이 검사가 폼과 API를 복잡하게 만듭니다.

❓ 왜 해결해야 하나요?

관리자 웹이 공지 API를 연동하려면 pinned 반영, 목록 조회, 수정 화면용 상세 조회가 먼저 있어야 합니다. 연동 전에 요청·응답 계약을 확정해 두면 이후 변경 비용이 없습니다.

⭐ 어떻게 해결했나요?

  • pinned를 작성·수정 요청에 추가(필수). NoticeCreateCommand·NoticeUpdateCommand·Notice가 함께 받습니다. 고정은 폼의 스위치로만 바뀌고 관리자 웹 목록에 고정 토글이 없어서 PATCH /{noticeId}/pin은 삭제했습니다.
  • 관리자 목록 조회 GET /v1/admin/notices: category(GENERAL/PARTNERSHIP, 종류 필터), keyword(제목 검색), page(0부터), size(1~100, 기본 20). category와 keyword는 선택이고 함께 쓸 수 있으며, category가 잘못된 값이면 400입니다(학생용 목록과 같은 변환). 고정 우선, 최신순이고 항목은 noticeId, title, category, pinned, createdAt이며 전체 개수와 페이지 정보를 함께 내려줍니다(오프셋 방식, PageParams/PageResponse). 검색은 n.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!'로 제목에 검색어가 포함된 공지를 찾습니다. 공백 검색어는 서비스가 조건 없음으로 취급하고(AdminNoticeServiceImpl.search), 저장소 구현이 검색어의 %·_·!를 LikePatterns.escape로 이스케이프해서 넘기므로 이 문자들도 글자 그대로 검색됩니다. 종류 조건은 데이터 쿼리와 countQuery 양쪽에 넣어 목록과 전체 개수가 같은 필터를 따릅니다. 목록과 전체 개수 두 문장을 같은 스냅샷에서 읽도록 읽기 전용 트랜잭션으로 묶었습니다(저장소의 @Query 메서드에는 트랜잭션이 붙지 않습니다).
  • 상세 조회는 학생 앱 공지 API를 공용으로 사용: 관리자 토큰도 STUDENT 권한을 가지므로 GET /v1/app/notices/{noticeId}를 그대로 씁니다. NoticeDetailResponse에 pinned를 추가했고 이미지·첨부의 fileId는 이미 내려줍니다. 학생용 목록은 바꾸지 않았습니다.
  • 수정 시 동시 수정 감지를 제거: 수정 요청의 updatedAt, NoticeUpdateCommand.expectedUpdatedAt, 서버의 비교(validateNotModified), 에러 코드 NOTICE_UPDATE_CONFLICT를 없앴습니다. 두 관리자가 겹쳐 수정하면 나중에 저장한 내용이 반영됩니다.
  • NoticeJpaEntity에 @DynamicUpdate: 바뀐 컬럼만 UPDATE합니다. 수정과 삭제가 겹쳤을 때 읽어 둔 나머지 컬럼까지 되써서 삭제된 공지가 되살아나거나(is_deleted) 다른 관리자가 방금 고친 값이 되돌아가는 일을 막습니다. 락이 아니라 UPDATE 범위만 줄입니다.
  • 본문 형식을 일반 텍스트로 명시: 관리자 웹은 TextArea로 입력받고 학생 웹은 텍스트로 렌더링하므로 요청 @Schema의 "본문(HTML)"을 "일반 텍스트"로 고쳤습니다. 서버 동작은 그대로입니다.
  • 학생용 조회의 읽기 전용 트랜잭션 제거: NoticeServiceImpl.getNotices/getNotice는 SELECT 한 문장뿐이라 트랜잭션이 더하는 것이 없어서 @Transactional(readOnly = true)를 뺐습니다(한 문장은 트랜잭션 없이도 일관되게 읽힙니다). 쿼리가 둘 이상으로 늘면 readOnly를 다시 붙입니다.

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

  • 동시 수정은 마지막 저장이 이깁니다. 수정이 PUT(전체 교체)이라 폼에 든 값이 전부 저장됩니다. A와 B가 같은 공지를 열어 두고 B가 제목을, 이어서 A가 본문만 고쳐 저장하면 A가 건드리지 않은 제목도 A의 옛 값으로 돌아갑니다. 같은 공지를 동시에 편집할 일이 드문 소수의 관리자 도구라는 가정에서 받아들인 선택입니다.
  • 제목 검색은 포함 검색이라 인덱스를 타지 못합니다. LIKE '%키워드%'는 앞에 와일드카드가 있어 삭제되지 않은 행을 훑습니다. 공지 수가 많지 않은 규모를 가정했고, 늘면 FULLTEXT(ngram)를 검토해야 합니다.
  • LIKE 이스케이프 문자는 !입니다. 참고한 구현은 \를 쓰지만 MySQL은 문자열 리터럴에서 \를 이스케이프로 먹어 ESCAPE '\'를 쓰려면 SQL을 '\\'로 적어야 하고, JPQL을 거치면 이 이중 이스케이프가 어긋나기 쉬워서 !를 골랐습니다. 쿼리의 ESCAPE '!'와 LikePatterns.ESCAPE가 같아야 합니다.
  • 새 JPQL을 MySQL로 확인하지 못했습니다. LIKE ... ESCAPE '!'는 표준 문법이라 위험이 낮지만 MySQL에서 실행한 것은 아닙니다. 로컬에 Docker가 없어 H2 임시 테스트(커밋하지 않음)로 오프셋 페이지, 전체 개수, 키워드 검색(LIKE + 이스케이프: %·_·!가 글자로 검색됨, 앞뒤 공백 제거), 종류 필터(키워드와 조합, 페이지를 넘길 때 개수 반영)까지만 확인했습니다. 쿼리가 틀리면 앱 기동이 실패하므로 dev 배포 후 가장 먼저 확인이 필요합니다.
  • 상세 조회가 학생 API와 결합됩니다. 학생 공지 API가 나중에 학생 전용 검증을 받으면 관리자 수정 화면이 같이 영향을 받습니다. 관리자 Swagger에는 상세 조회가 나오지 않고 앱 Swagger에서 확인해야 합니다.
  • 수정 응답은 noticeId만 줍니다. 저장 뒤 화면 갱신이 필요하면 상세를 다시 조회하면 됩니다.
  • 새 테스트는 추가하지 않았습니다. 로컬에 Docker가 없어 H2 임시 테스트(커밋하지 않음)로 @DynamicUpdate 동작만 확인했고 MySQL에서는 확인하지 못했습니다. 확인한 것: 바뀐 컬럼만 SET에 붙는 것, 같은 컬럼 동시 수정은 나중 저장이 반영되는 것, 수정이 삭제된 공지를 되살리지 않는 것, 삭제가 동시 수정을 덮어쓰지 않는 것.

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

  • 요청 호환이 깨집니다. 작성·수정 요청에 pinned가 필수로 추가되고, 수정 요청의 updatedAt이 사라지며(보내도 무시됩니다), PATCH /v1/admin/notices/{noticeId}/pin과 409 NOTICE_UPDATE_CONFLICT가 사라집니다. [Feat/#103] 게시판(공지) 관리자 API 추가 #104 이후 이 API를 호출하는 클라이언트는 없습니다(관리자 웹은 아직 목업입니다).
  • 학생 앱 공지 상세 응답에 pinned 필드가 추가됩니다(하위 호환). 학생용 목록은 그대로입니다.
  • NoticeService·NoticeRepository에 search가 추가됩니다(관리자 목록 전용, AdminNoticeService로 이동은 [Refactor/#144] 공지 관리자 기능을 AdminNoticeService로 분리 #145).
  • 공지 UPDATE가 바뀐 컬럼만 보냅니다. 스키마 변경·마이그레이션은 없습니다.

🔀 Edge Case & 실패 시나리오

상황 응답
작성·수정에서 pinned 누락 400 INVALID_INPUT ("고정 여부를 입력해 주세요.")
두 관리자가 같은 공지를 겹쳐 수정 둘 다 200, 나중에 저장한 내용이 반영
수정과 삭제가 겹침 삭제 상태가 유지되고 수정은 성공 응답
같은 공지를 겹쳐 삭제 한 번만 반영되고, 이미 삭제된 뒤에 온 요청은 404 NOTICE_NOT_FOUND
목록 size가 1~100 밖 / page 음수 400 INVALID_INPUT
목록 category가 없음 종류 구분 없이 전체 조회
목록 category가 GENERAL/PARTNERSHIP이 아님 400 INVALID_INPUT
목록 category와 keyword를 함께 지정 두 조건을 모두 만족하는 공지만 조회, 전체 개수도 같은 조건
목록 keyword가 비었거나 공백 검색하지 않고 전체 조회
목록 keyword가 %, _, ! 등을 포함 와일드카드가 아니라 글자 그대로 검색 (예: %는 제목에 %가 든 공지만)
없거나 삭제된 공지 수정·삭제·상세 404 NOTICE_NOT_FOUND
공지가 연결한 파일이 삭제됨 상세에서 그 항목만 조용히 빠짐

📋 검토한 대안과 선택 이유

  • 동시 수정 감지 유지(updatedAt 비교 + 낙관적 락): 이 PR의 이전 커밋에서 구현했으나, 수정 요청 시점에는 원래 글이 바뀌었는지보다 수정 내용이 반영되는 것이 중요하다고 판단해 제거했습니다. 폼과 API의 updatedAt 왕복, 409 처리, 예외 변환, MySQL 검증이 필요한 부분(JSON 컬럼 비교, 콜레이션)이 모두 사라집니다. 화면 단위 보호가 필요해지면 @Version을 도입할 수 있습니다.
  • 고정을 PATCH로만 두기: 호출이 두 번이 되어 폼 한 번 저장이 부분 반영될 수 있고, 목록에 고정 토글이 없어 쓸 곳이 없어서 삭제했습니다.
  • pinned를 선택값으로 받기: 수정이 전체 교체(PUT)라 누락이 의도 불명확한 해제가 될 수 있어서 필수로 했습니다.
  • 관리자 목록을 학생용 목록(커서 방식)에 keyword를 붙여 공용으로: 이 PR의 중간 커밋에서 구현했으나, 관리자 화면에는 페이지 번호 이동과 전체 개수가 필요해서 되돌리고 오프셋 방식의 관리자 전용 목록으로 만들었습니다.
  • 관리자 전용 상세 API: 학생용 상세에 pinned 하나만 더하면 같은 일을 해서 중복(응답·UseCase)을 줄이려 공용으로 했습니다.

💬 리뷰 포인트

  • [r] 동시 수정을 막지 않고 마지막 저장이 이기게 한 정책. PUT 전체 교체라 안 건드린 필드도 되돌아갈 수 있다는 점을 받아들일 수 있는지
  • [r] NoticeJpaRepository.search의 JPQL(countQuery 포함)이 dev에서 정상 기동·조회되는지
  • [c] 상세 조회를 학생 API로 공용화한 것
  • [c] pinned를 필수로 둔 것과 PATCH /pin 삭제
  • [a] 본문 @Schema 문구

관리자 웹의 공지 폼이 고정 여부를 등록·수정과 함께 보내고, 수정 화면이 기존 공지와 수정 시각을 불러와야 해서 맞춘다.
@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jjunh33 jjunh33 changed the title [Feat/#142] 공지 관리자 API 고정 여부 반영 및 목록·상세 조회 추가 [Feat/#142] 공지 수정 충돌 감지 보강 및 관리자 목록·상세를 일반 공지 API로 통합 Oct 22, 2026
@jjunh33 jjunh33 changed the title [Feat/#142] 공지 수정 충돌 감지 보강 및 관리자 목록·상세를 일반 공지 API로 통합 [Feat/#142] 게시판(공지) 관리자 API 보완 - 고정 여부 반영, 공지 수정 충돌 감지 보강 Oct 22, 2026
@jjunh33 jjunh33 changed the title [Feat/#142] 게시판(공지) 관리자 API 보완 - 고정 여부 반영, 공지 수정 충돌 감지 보강 [Feat/#142] 공지 작성·수정에 고정 여부 반영 및 관리자 목록·상세를 일반 공지 API로 통합 Oct 22, 2026
@jjunh33 jjunh33 changed the title [Feat/#142] 공지 작성·수정에 고정 여부 반영 및 관리자 목록·상세를 일반 공지 API로 통합 [Feat/#142] 게시판(공지) 관리자 API 보완 - 고정 여부 반영 및 동시 수정 제어 Oct 22, 2026
@jjunh33
jjunh33 merged commit 692f034 into main Oct 22, 2026
1 check passed
@jjunh33
jjunh33 deleted the feat/#142-admin-notice-read-pin branch October 22, 2026 11:52
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.

게시판(공지) 관리자 API 보완 — 고정 여부 반영, 목록·상세 조회 추가

2 participants