Skip to content

release: v1.1.0 출시 - #398

Merged
kangkyunghyun merged 21 commits into
mainfrom
release/v1.1.0
Sep 7, 2026
Merged

kangkyunghyun merged 21 commits into
mainfrom
release/v1.1.0

Conversation

@kangkyunghyun

@kangkyunghyun kangkyunghyun commented Sep 7, 2026 •

Copy link
Copy Markdown
Member

변경 사항

기능

화면

배포 범위

  • API + Web 모두 변경
  • DB 마이그레이션 5개 — Flyway V16(post_comments), V17(notice_likes), V18(note_likes), V19(post_likes), V20(photo_likes). 배포 시 자동 적용
  • 인프라(Terraform) 변경 없음
  • 법적 문안 변경 없음 — 시행일 그대로

검증

  • 각 PR의 CI (api·web·templates·title) 통과
  • Web 전체 테스트 976개 (feat: 자유게시판에 좋아요를 붙인다 #394 기준 943, 이후 보류 PR 제외)
  • Web lint·build
  • 각 PR 로컬 검수 (Codex) — 타입 스케일 이후 PR은 3티어(작성 → 검수 → 판단)로 진행
  • 픽스처 화면으로 공지·자료·게시판·갤러리 좋아요 눈으로 확인
  • GitHub Actions 전체 CI (이 PR)
  • Vercel Production 배포 확인
  • API 배포 후 마이그레이션 V16~V20 적용 확인
  • S3 정리 잡 첫 실행 로그 확인 (runbook)

출시

  • 버전: v1.1.0
  • 기준: develop@9450f9a
  • 병합 방식: Merge commit

sumin0218 and others added 21 commits September 1, 2026 15:53
* feat: 자유 게시판에 댓글 기능을 신설한다 (#347)

수정·삭제 권한을 게시글과 동일하게 맞췄다(3-3 결정 23 D2) — 게시글의 삭제
권한이 관리자 전용에서 작성자 본인까지 넓어진 전례를 그대로 물려받아, 댓글은
처음부터 그 최종 모양으로 만든다. 게시글 삭제 시 댓글은 CASCADE로 함께
지운다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor: 게시글·댓글 서비스의 작성자 조회 중복을 없앤다

PostService와 PostCommentService가 배치 작성자 조회 로직을 각자 두고
있었다 — 두 서비스가 공유하는 AuthorLookup으로 모은다. write()·edit()도
저장 직전에 이미 잠근 요청자 행을 그대로 쓰도록 고쳐, 응답을 만들 때마다
같은 행을 다시 조회하지 않는다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
bookmarks와 같은 (user_id, notice_id) 복합 PK로 만들었다 — 좋아요는
"지금 눌렀는가"가 상태라 취소가 자연스러운 기대이고, 그러려면 카운터가
아니라 누가 눌렀는지 남기는 방식이 필요하다. 공지는 자료 갈래가 아니라
INACTIVE도 누를 수 있고, 관리자 전용 콘텐츠라도 부원이 반응을 남길 수
있게 열었다(3-3 결정 24 D4).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: 공지사항 좋아요 화면을 구현한다

목록은 개수만 읽기 전용으로 보여주고 토글은 상세에만 둔다. 행 전체가 상세로
가는 링크라 그 안에 버튼을 두면 읽으러 온 사람이 잘못 눌러 좋아요를 남긴다.

상세는 낙관적으로 먼저 반영한다. 응답이 204라 최신 개수가 오지 않아 다시
GET하지 않으면 화면이 직접 세야 하고, 재조회를 넣으면 왕복이 하나 늘면서
그 사이 숫자가 멈춰 있어 누른 것 같지가 않다. 실패하면 되돌린다.

* fix: 픽스처가 만들 수 없는 좋아요 상태를 내려주지 않는다

likeCount와 likedByMe를 서로 독립인 식으로 만들어 "내가 눌렀는데 개수가 0"인
공지가 시드에 있었다. 서버는 그런 응답을 만들 수 없다. 그 공지에서 좋아요를
취소하면 화면이 "좋아요 -1"을 그렸다.

likedByMe를 likeCount에서 파생시킨다. 화면에 클램프를 두지 않는다 — 응답이
일관되면 음수가 나올 자리가 없고 클램프는 죽은 코드가 된다.

* design: 좋아요 아이콘을 따봉으로 바꾼다

하트는 빨강을 잃으면 채움과 비움이 잘 안 읽힌다. 이 팔레트는 무채색이라 색으로
가를 수 없어 채움/비움에 전부를 걸고 있는데, 그 구분이 가장 약한 모양이었다.

별은 이미 자료 즐겨찾기가 쓰고 있어 성격이 겹친다 — "담기"와 "반응하기"가
같은 아이콘 계열이면 부원이 두 기능을 구분하지 못한다.

3-3 결정 24 D4가 적은 이 기능의 의미도 애정이 아니라 동의다 —
"봤다·동의한다·중요하게 여긴다".

* design: 공지 목록을 표로 바꾸고 좋아요를 열로 뺀다

행 안에 아이콘과 숫자를 끼워 넣으니 훑을 때 어느 값이 무엇인지 읽히지 않았다.
자료게시판이 이미 쓰는 표 구조를 그대로 따른다 — 열은 제목·작성자·좋아요·
등록일이고, 좋아요는 자료의 조회수 열과 같은 우측 정렬 숫자다.

아이콘은 두지 않는다. 열 제목이 이미 "좋아요"라고 말하고, 내가 눌렀는지는
상세가 따봉의 채움/비움으로 보여준다.

행 전체를 링크로 만들지 않고 제목 셀만 링크로 둔다. 고정 공지의 좌측 세로
바는 링크가 들고 있었으므로 제목 셀로 옮긴다 — 핀 아이콘과 NEW 배지는
그대로다. 무채색 팔레트라 이 표시들이 유일한 구분 수단이다.

조회수 열은 아직 없다. 공지 응답에 필드가 없다 (#375).

* refactor: 목록 좋아요 표시를 이 브랜치에서 뺀다

이 브랜치의 목적은 공지 좋아요 화면이고, 목록 마크업을 표로 갈아엎는 것은
그 범위를 넘는다. 목록 표 전환과 좋아요 열은 #373이 두 게시판을 함께 다룬다.

지우는 게 아니라 옮기는 것이다. 계약(likeCount·likedByMe)과 setNoticeLike,
픽스처는 그대로 두고 목록 화면만 되돌린다 — #373이 그 위에서 열을 붙인다.

상세의 따봉 토글과 낙관적 업데이트는 그대로다. 그게 이 브랜치의 본체다.
* design: 자유게시판 목록을 표로 바꾼다

열 제목이 없어 오른쪽에 붙은 이름과 날짜가 각각 무엇인지 화면만 보고는 알 수
없었다. 자료·공지 목록이 이미 쓰는 표 구조를 그대로 따른다.

"열이 셋뿐이라 표로 그리면 헤더가 내용보다 무거워진다"고 적어 뒀던 판단을
뒤집는다. 같은 사이트 안에서 목록의 생김새가 갈리는 값이 그 무게보다 크다.

행 전체를 링크로 만들지 않고 제목 셀만 링크로 둔다. 작성자·날짜 열까지 링크
안에 들어가면 어디를 눌러야 상세로 가는지 알 수 없다.

조회수와 댓글 수 열은 아직 없다. 목록 응답에 필드가 없다 (#375, #374).

* design: 공지 목록도 표로 바꾸고 좋아요를 열로 뺀다

자유 게시판과 같은 이유다 — 열 제목이 없으면 오른쪽에 붙은 숫자와 날짜가
각각 무엇인지 화면만 보고는 알 수 없다. 두 목록을 한 브랜치에서 함께 바꿔야
열 폭과 정렬이 갈리지 않는다.

좋아요는 자료의 조회수 열과 같은 우측 정렬 숫자다. 아이콘은 두지 않는다 —
열 제목이 이미 "좋아요"라고 말하고, 내가 눌렀는지는 상세가 따봉의 채움/비움
으로 보여준다.

고정 공지의 좌측 세로 바는 행을 감싸던 링크가 들고 있었으므로 제목 셀로
옮긴다. 핀 아이콘과 NEW 배지는 그대로다 — 무채색 팔레트라 이 표시들이
유일한 구분 수단이다.

조회수 열은 아직 없다. 공지 응답에 필드가 없다 (#375).

* design: 목록 표의 열 제목과 셀을 가운데로 맞춘다

헤더는 왼쪽인데 숫자 셀은 오른쪽이라 같은 열의 위아래가 서로 어긋나 있었다.
열 제목과 그 아래 셀이 같은 정렬을 갖게 한다.

자료게시판도 함께 맞춘다. 하나만 바꾸면 그 목록만 왼쪽 정렬로 남아 세 목록이
갈린다 — 어긋남은 화면을 나란히 놓고 봐야 드러난다.

정렬은 표 한 자리에서 준다. 셀마다 유틸리티를 흩뿌리면 지금 같은 어긋남이
다시 생긴다. tabular-nums는 그대로 둔다 — 자릿수가 흔들리면 가운데 정렬이
더 지저분해진다.

회원 관리 표는 건드리지 않는다. 체크박스·배지·액션 메뉴가 섞여 있어 판단이
다르다.

* design: 목록 표의 제목 열만 왼쪽으로 되돌린다

제목이 가운데로 가니 글 길이마다 왼쪽 끝이 달라져, 목록을 훑는 눈이 행마다
자리를 다시 잡아야 했다. 게시판 목록이 제목만 왼쪽에 두는 이유가 이것이다.

나머지 열은 가운데 그대로다. 자료게시판의 즐겨찾기 열도 아이콘 하나뿐이라
가운데로 둔다.

표에 준 text-center는 셀로 상속되게 두고, TableHead가 들고 있는 text-left는
열마다 덮는다 — 제목 열만 그 기본값을 그대로 쓴다. 후손 선택자로 한 번에
덮으면 특이도가 높아 제목 열에서 다시 되돌릴 수 없다.

* design: 목록 표의 제목 열 이름을 가운데로 둔다

열 이름은 세 표 전부 가운데로 서고, 내용은 제목만 왼쪽이다. 한국어 게시판
목록의 흔한 모양이다.

헤더는 한 낱말이라 길이가 흔들리지 않아 가운데로 둬도 눈이 자리를 잃지
않는다. 내용은 다르다 — 제목 길이가 행마다 달라 가운데로 두면 왼쪽 끝이
매번 옮겨 다닌다. 그래서 되돌리는 자리는 셀뿐이다.
* feat: 자유게시판 댓글 화면을 구현한다

정렬도 페이지네이션도 화면이 정하지 않는다. 서버가 오래된순 배열로 내려주고
페이지가 없다 — 받은 순서를 그대로 그린다. 화면이 다시 정렬하면 서버가 순서를
바꿨을 때 클라이언트 정렬이 남아 서버를 덮어쓴다.

입력 폼은 목록 아래에 둔다. 오래된순이라 마지막 댓글 다음이 내가 쓸 자리다.
등록에 성공하면 폼을 비우고 목록을 다시 부른다.

수정 진입점은 작성자 본인에게만, 삭제는 활성 관리자 또는 작성자 본인에게만
보인다. 관리자도 남의 댓글은 고칠 수 없다 — 진입점이 계약과 어긋나면 눌러서
403을 받는 자리가 생긴다.

* fix: 댓글 삭제 판정을 게시글과 같은 규칙으로 맞춘다

노출 판정에서 status 검사를 뺀다. INACTIVE ADMIN은 커밋 불변식으로 존재하지
않고(3-1 §3-1-2 MUST) SUSPENDED는 세션이 active에 이르지 못한다 — ADMIN이면
이미 활성이라 도달하지 않는 조건이었다. 게시글 상세와 같은 모양으로 둔다.

픽스처의 댓글 삭제는 요청자를 명부에서 다시 읽는다. 불변 USERS.admin을 보면
권한 회수·정지 뒤에도 남의 댓글을 지울 수 있어, 게시글 삭제 픽스처가 이미
막아 둔 것과 정반대가 된다.

* design: 이름과 시간 사이의 가운뎃점을 뺀다

작성자와 작성일은 서로 다른 종류의 정보라 나란히 놓인 것만으로 이미 갈린다.
가운뎃점은 그 사이에 읽을 것을 하나 더 놓을 뿐이었다.

간격은 남긴다. 게시글 상세와 댓글은 부모의 gap이 그대로 받아 주고, 사진
갤러리는 한 줄 truncate라 gap을 줄 부모가 없어 날짜가 왼쪽 여백을 들고 간다.

과목과 시험 유형, 업로드 안내, 검색 placeholder의 가운뎃점은 그대로 둔다 —
그것들은 이름과 시간을 잇는 자리가 아니다.
같은 자리를 공지는 "작성자", 자료는 "업로더"로 불러 한 사이트 안에서 말이
갈렸다. 자료 목록의 열 제목과 상세 메타의 항목 이름 둘을 맞춘다.

식별자와 API 필드는 그대로다. `uploaderName`·`uploader_id`는 계약에 박혀
있어 바꾸면 백엔드까지 번진다. 주석과 스펙의 "업로더"도 계약 용어라 남긴다.
"권승원 회원의 계정을 지웁니다"는 권승원이 둘이면 어느 쪽인지 알려주지 않는다.
목록에 학번 열이 있지만 확인 창이 열리는 순간 행이 가려지고, 스크린리더는
애초에 그 열을 읽지 않는다.

이름과 학번을 한 문구로 만드는 자리를 하나 두고 확인 문구·조작 뒤 알림·
aria-label이 그것만 쓴다. 형식을 각자 만들면 자리마다 갈린다.

학번은 자르지 않는다. 관리자는 이미 목록에서 전체를 보고 있다. 신청 전 계정은
학번이 없으므로 이름만 쓴다 — 표의 "—"를 문장에 넣으면 문구가 깨진다.

되돌릴 수 있는 조작도 같은 형식으로 통일한다. 제거만 다르게 쓰면 관리자가
문구를 두 벌 읽어야 하고, 정지도 그 사람은 즉시 로그인할 수 없다.
IntersectionObserver로 등장을 감지하고 CSS transition으로 나타낸다. 라이브러리를
넣지 않는다 — 레퍼런스도 라이브러리 없이 하고 있고, 랜딩은 비로그인 방문자가
처음 만나는 화면이라 번들을 늘릴 자리가 아니다.

감추는 것은 관찰을 실제로 걸 수 있을 때만 한다. 초기 상태를 CSS로 감춤에 두면
IntersectionObserver가 없거나 스크립트가 죽은 환경에서 내용이 영영 보이지
않는다. 되돌릴 수 없는 상태로 두지 않는다.

모션을 줄이는 설정이면 처음부터 보인다. 감췄다가 보여주는 것이 아니라 감추지
않는다. 설정을 도중에 바꾸는 경우까지 CSS가 한 번 더 막는다.

히어로와 활동 marquee에는 걸지 않는다. 첫 화면이 잠깐 비면 로딩이 느린 것으로
읽히고, 이미 움직이는 것에 등장까지 붙이면 산만하다.
bookmarks·notice_likes·post_likes와 같은 (user_id, photo_id) 복합 PK로
만들었다. 업로드·삭제는 ADMIN 전용이지만 좋아요는 사진 조회와 같은 권한
(ACTIVE·INACTIVE)이다(3-3 결정 27 D3) — 콘텐츠를 만드는 것과 반응하는
것은 다른 질문이다.

구현 중 SecurityConfig의 필터가 POST/DELETE /api/v1/photos/**를
와일드카드로 ADMIN 전용에 묶어 둔 것을 발견했다 — 좋아요 경로까지 막혀
일반 부원이 컨트롤러의 @PreAuthorize에 닿기도 전에 403을 받았다. 정확한
경로(/photos, /photos/upload-url, /photos/{id})로 좁혀 고쳤다.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: SeungwonKwon <101116557+SeungwonKwon@users.noreply.github.com>
* feat: 자료게시판에 좋아요 기능을 신설한다 (#344)

즐겨찾기와 완전히 별개 자원으로 만들었다(3-3 결정 25 D1) — 즐겨찾기는
"다시 보려고 담아둔다", 좋아요는 "품질에 공감한다"로 뜻이 다르다.
bookmarks와 같은 (user_id, note_id) 복합 PK를 쓰고, 권한도 자료 갈래와
같아 INACTIVE는 막힌다(D4) — 공지 좋아요와 정반대다. §3-2-4 표의
INACTIVE 차단 경로가 11개에서 13개로 늘어 InactiveAccessIntegrationTest의
손으로 옮긴 목록도 함께 갱신했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: 자료 좋아요의 조회 일관성·인덱스·문서를 보완한다

개수와 내 상태를 각각 질의하면 READ COMMITTED에서 스냅샷이 갈려 0개인데
내가 눌렀다고 답하는 응답이 나갈 수 있었다 — 한 문장에서 함께 읽는다.
복합 PK의 선두 열이 user_id라 note_id로 들어오는 집계·CASCADE가 전체
스캔이 되던 것도 인덱스로 받친다. 좋아요 API의 401·403을 OpenAPI에 적고,
ERD에 좋아요 표의 관계를 넣고, 등록·수정 응답의 좋아요 필드에 회귀
테스트를 둔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: 자유 게시판에 좋아요 기능을 신설한다 (#345)

bookmarks·notice_likes와 같은 (user_id, post_id) 복합 PK로 만들었다.
권한은 게시판 조회와 같아 INACTIVE도 열린다(3-3 결정 26 D4) — 게시판은
자료 갈래가 아니다. 게시글이 삭제되면 좋아요도 CASCADE로 함께 지운다
(D5, post_comments와 같은 판단). 좋아요순 정렬은 더하지 않는다(D3) —
목록의 고정 정렬 원칙과 공존하는 방법을 새로 정해야 해 완료 조건 밖으로
미뤘다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: 게시글 좋아요의 조회 일관성·인덱스·문서를 보완한다

개수와 내 상태를 각각 질의하면 READ COMMITTED에서 스냅샷이 갈려 0개인데
내가 눌렀다고 답하는 응답이 나갈 수 있었다 — 한 문장에서 함께 읽는다
(자료 좋아요와 같은 수정). 복합 PK의 선두 열이 user_id라 post_id로
들어오는 집계·CASCADE가 전체 스캔이 되던 것도 인덱스로 받친다. 좋아요
API의 401·403을 OpenAPI에 적고, 등록 응답 예시·ERD를 실제 계약에 맞추고,
댓글의 제외 범위가 게시글 좋아요까지 부정하던 문장을 좁힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat: S3에 남는 고아 오브젝트를 정리한다

보상 삭제(compensating delete)가 실패하면 오브젝트가 S3 최종 위치에
영원히 남는 자리가 있었다 — StagedUploads.discardStored/discardDetached,
PhotoService.delete 모두 실패를 로그 한 줄로만 남기고 끝났다. 최종
위치(notes/…, photos/…)에는 lifecycle 규칙이 없다(임시 위치만 하루 뒤
걷어간다).

매일 새벽 4시 도는 OrphanObjectCleanupJob을 추가한다 — 이 저장소의
유일한 @scheduled 사용처다. notes/·photos/의 최종 위치를 나열해 DB가
참조하지 않고(NoteRepository.findAllFileStoredPaths,
PhotoRepository.findAllStoredPaths + 유도한 썸네일 키) 안전 여유(기본
1시간, 설정값)를 지난 오브젝트만 지운다. 판단 순서(임시 위치 →
참조 → 안전 여유)를 순수 함수(classify)로 뽑아 MinIO 없이 단위
테스트로 경계를 재고, 실제 나열·삭제 왕복은 MinIO 통합 테스트로
확인한다.

여러 태스크가 배포 중 잠깐 겹쳐도 한 번에 하나만 돌게
pg_advisory_xact_lock을 쓴다 (#144와 같은 패턴, 잠금 키는 이슈
번호 339).

완전 자동 정리로 간다는 결정과 이유는 이슈 본문에 남겼다 — 관리자가
수동으로 트리거하는 경로는 이번 범위가 아니다.

docs/ops/runbook.md에 정리 작업의 존재·주기·확인 방법을 문서화한다.

* fix: 고아 정리 작업의 시간대·설정 검증을 바로잡는다

컨테이너에 TZ가 없어 JVM 기본이 UTC라, "새벽 4시" cron이 실제로는 한국
시간 오후 1시에 돌고 있었다 — @scheduled에 zone을 못박는다. safetyMargin은
@NotNull만 있어 음수도 바인딩됐는데, 그러면 기준 시각이 미래로 가서 방금
올라온 최종 오브젝트까지 지운다 — 음수는 기동에서 막고 회귀 테스트를 둔다.
통합 테스트의 exists()는 HEAD 404가 NoSuchKeyException이 아닌 밋밋한
S3Exception으로 올 수 있어 assertion 전에 터질 수 있었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: 고아 정리 실행 시각을 한국 시간으로 명시한다

@scheduled에 zone = "Asia/Seoul"을 박아 두었는데 런북은 "서버 시간대"라고
안내하고 있었다 — 운영 이미지는 TZ를 설정하지 않아 JVM 기본이 UTC라, 그
문구대로 읽으면 실행 시각을 오후 1시로 오해한다 (#342 리뷰).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
별도 화면이 아니라 기존 목록의 파라미터로 넣었다(3-3 결정 28) —
즐겨찾기를 자료게시판 토글로 접은 것(#261)과 같은 판단이라 응답·정렬·
페이징·권한이 그대로 따라온다. 기준은 인증 주체이고 작성자·업로더 id를
요청으로 받지 않는다 — 받으면 남의 것을 "내 것"으로 조회할 수 있다.

NoteSearch.mine은 래퍼 타입으로 받는다. 원시 boolean으로 두면 파라미터를
보내지 않은 요청의 바인딩이 실패해 mine과 무관한 목록 전체가 400이 된다.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: 내가 쓴 자료·게시글을 목록 필터로 모아 본다 (#353)

별도 화면이 아니라 기존 목록의 파라미터로 넣었다(3-3 결정 28) —
즐겨찾기를 자료게시판 토글로 접은 것(#261)과 같은 판단이라 응답·정렬·
페이징·권한이 그대로 따라온다. 기준은 인증 주체이고 작성자·업로더 id를
요청으로 받지 않는다 — 받으면 남의 것을 "내 것"으로 조회할 수 있다.

NoteSearch.mine은 래퍼 타입으로 받는다. 원시 boolean으로 두면 파라미터를
보내지 않은 요청의 바인딩이 실패해 mine과 무관한 목록 전체가 400이 된다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: 내가 좋아요한 자료·공지·사진·게시글을 목록 필터로 모아 본다 (#355)

좋아요를 누른 뒤 그것을 다시 찾을 방법이 없었다. 개수와 내 상태는 보이지만
모아 볼 수는 없다. 결정 28 D1·D4대로 별도 화면·통합 API가 아니라 기존 네
목록의 쿼리 파라미터로 연다 — 응답·정렬·페이징·권한이 그대로 따라오고,
도메인마다 다른 좋아요 권한(자료는 INACTIVE 차단, 나머지는 열림)도 저절로
지켜진다.

좋아요는 조인이 아니라 EXISTS로 본다. 조인하면 누른 사람 수만큼 같은 행이
중복돼 페이지 크기와 totalElements가 어긋나고, distinct로 덮으면 카운트
질의까지 함께 손봐야 한다 — 여기서 필요한 것은 "그 조합이 있나"뿐이다.

- notes: NoteSpecifications에 Criteria EXISTS 서브쿼리. mine·검색어와 AND
- posts: findFiltered 하나가 mine·liked 네 조합을 모두 받는다
- notices: findLikedBy. 작성자 LEFT JOIN FETCH를 그대로 물려받는다
- photos: findCompletedLikedBy. 올리다 만 임시 키 행은 여기서도 감춘다

회원 id는 어느 목록도 받지 않는다 — 기준은 언제나 인증 주체다. 받으면 남이
좋아요한 목록을 그대로 들여다볼 수 있다.

스펙: 3-2 §3-2-4·§3-2-5, 2-1 §2-1-1·5·6·7·8, 5-TESTING T-593~T-603.
결정 28이 #353에서 이미 liked까지 포함해 적어 두어 새 결정은 만들지 않았다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: 좋아요 사진 목록도 업로더를 함께 읽는다

Codex 리뷰 반영 (#387).

findCompletedLikedBy에 @EntityGraph가 빠져 있었다. uploader는 LAZY라
PhotoService.toResponse()가 이름·id를 읽는 순간 페이지에 실린 사진 수만큼
사용자 조회가 더 나간다 — 전체 목록(findCompleted)에만 걸어 두면 필터를
켰을 때만 조용히 N+1이 된다.

목록의 페이지 선택과 좋아요 요약이 서로 다른 스냅샷을 읽는 것은 고치지
않고 계약으로 적었다. 조회 중에 본인이 좋아요를 떼면 그 글이 목록에 남은
채 likedByMe=false로 그려질 수 있는데, 두 문장을 한 스냅샷으로 묶으려면
목록 요청마다 격리 수준을 올리는 왕복이 붙는다 — 되돌아오는 것은 본인이
방금 한 취소가 한 번 늦게 반영되는 화면 하나이고, 새로고침이 곧바로
바로잡는다. 남에게 잘못된 상태를 보여주지도 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* refactor: 작동하지 않는 safe area 보정을 걷어낸다

`env(safe-area-inset-*)`가 0이 아닌 값을 가지려면 뷰포트가 `viewport-fit=cover`이거나
PWA가 standalone으로 떠야 한다. 우리는 둘 다 아니라 그 값이 언제나 0이었고,
`max(0.75rem, env(...))`는 늘 `0.75rem`으로 계산됐다.

기본 뷰포트에서는 브라우저가 레이아웃 뷰포트를 이미 안전 영역 안쪽으로 잘라 준다.
보정할 것이 없는데 보정하는 코드를 두면 다음 사람이 그것을 믿게 된다.

`cover`를 켜서 살리지 않는 이유는 그것이 브라우저에게 보호를 그만두라고 말하는 것이라,
헤더·라이트박스·랜딩까지 함께 다뤄야 하기 때문이다. 대상 환경 밖이다 (spec 3-3-7).

계산 결과는 그대로라 화면은 바뀌지 않는다.

* test: 안전 영역 전제가 깨지는 순간을 잡는다

"env()는 언제나 0"은 과한 주장이었다. iOS MobileSafari는 viewport-fit=auto인데도
좌우 인셋에 0이 아닌 값을 돌려주는 버그가 있다 (WebKit 272779, 미해결). 주장을
대상 환경(데스크톱 Chrome·Edge)으로 좁힌다.

그 환경에서도 뷰포트는 이미 잘려 있어 알림이 노치에 걸리지는 않는다. 다만 이 값들이
"브라우저가 뷰포트를 잘라 준다"는 전제 위에 서 있다는 사실은 그대로다.

viewport-fit이 index.html에 들어오면 그 전제가 꺼진다. 그 순간 테스트가 실패해
결정을 다시 보게 한다.
* refactor: 화면 정렬선을 한 곳에서 정한다

헤더·<main>·푸터·랜딩이 1152px과 px-6을 각자 적고 있었다. 이 넷은 같아야만 하는
값이다 — 헤더 로고와 본문 첫 글자와 푸터 주소가 한 줄로 서는 것이 그 조건이다.

같은 정렬선이 이미 세 번 어긋났다. #247은 헤더에 px-4가 남아 8px, #258·#264는
스크롤바가 생겼다 없어지며 7.5px이었다. 매번 눈으로는 잘 안 보였다.

값이 네 벌이면 한 곳만 고쳐지는 일이 남는다. PAGE_CONTAINER 하나로 모으고,
그 밖에 폭이 다시 생기면 잡히는 테스트를 둔다. 렌더 결과 클래스는 그대로다.

* test: 정렬선을 버리고 다른 값을 적는 경우까지 잡는다
* design: 화면 전체가 같은 글씨 크기 스케일을 쓴다

랜딩만 위계가 있고 나머지 화면은 제목 24px과 본문 14px 두 단계뿐이었다. 부제도
안내도 각주도 같은 크기라 무엇부터 읽어야 하는지가 크기로 드러나지 않았다.

랜딩이 이미 쓰던 값(본문 16 / 소제목 20)을 앱 기본값으로 올린다. 제목 30, 보조 14,
라벨 12를 더해 다섯 칸으로 고정하고 README에 적는다. 적어 두지 않아서 다음 화면이
각자 정하게 된 것이 이 문제의 원인이다.

본문은 크기를 적지 않는다. 클래스가 없다는 것이 곧 "여기는 본문"이라는 표시다.

표도 본문이다. 목록 다섯이 ui/table.tsx 한 줄을 지나므로 거기서 한 번 올린다.
행이 커지는 만큼 한 화면에 보이는 행은 준다 - 읽는 크기를 밀도보다 앞에 뒀다.

입력·선택 상자의 md:text-sm을 걷었다. 그것은 모바일만 16px로 올리고 데스크톱에서
14px로 되돌리는 값인데 우리 대상 환경이 그 데스크톱이다. 쓰는 자리와 읽는 자리의
크기가 다르면 작성자가 보는 줄바꿈과 독자가 보는 줄바꿈이 갈린다.

화면 안 구획 제목이 14·18·14로 갈려 있던 것도 20으로 모았다. 로그인 제목의
text-[32px]은 24와 30 사이에 칸이 없어 나간 값이라 30으로 들어온다.

spec 3-1이 구글 버튼을 "사이트 본문 16px" 전제로 16/14배 키워 놨는데 실제 본문은
14px이라 그 버튼 혼자 크던 상태도 이 변경으로 맞는다.

* design: 스케일에서 빠져 있던 자리를 마저 맞춘다

검수가 일곱 곳을 짚었다. 넷은 놓친 자리고 셋은 문서 문구가 과했던 것이다.

신청 화면이 select 클래스를 사본으로 들고 있어 공용 쪽만 16px로 올라가고 그 화면은
데스크톱에서 14px로 남았다. 사본을 지우고 공용 상수를 쓴다 - 두 벌이면 한 곳만
고쳐진다는 것이 오늘 #389에서 고친 것과 같은 병이다.

법적 문서의 구획 제목 18px, NEW 배지의 10px, 파일 입력의 14px도 스케일 안으로
들어온다. 배지의 10px은 12px 아래에 칸이 없어서 나갔던 값이다.

문서에서는 "주 동작은 본문 크기"라는 문장을 걷었다. 그렇게 적으면 화면마다 다시
판단하게 된다 - 16px인 버튼은 둘뿐이고 각각 브랜드 규격과 헤더라는 자기 이유가
있으므로 예외로 적는다. 폼 라벨이 14px이라는 것과 AlertDialog가 자기 위계를 갖는
것도 표에 없어 오해를 샀다.

* design: 표와 그 조작 줄을 보조 단에 남긴다

표 셀을 본문 16px로 올렸다가 되돌린다. 화면에서 보니 크기가 줄었다 다시 커졌다 -
목록은 제목 30 아래로 필터 라벨·입력·안내문 14가 이어지는데 그 끝의 표만 16이면
위에서 아래로 내려가던 위계가 거기서 한 번 되돌아간다.

표는 읽는 자리이기 전에 고르는 자리다. 스물다섯 행을 한 화면에 놓고 훑어 찾는 것이
목적이고 행이 커지면 그만큼 덜 보인다. 필터 라벨·검색 상자·선택 버튼도 그 줄 전체가
표를 다루는 도구라 같은 단에 둔다.

textarea만 본문 크기로 남긴다. 거기 쓴 글이 상세에서 16px로 읽히는 바로 그 본문이라
크기가 갈리면 작성자와 독자의 줄바꿈이 달라진다. 한 줄짜리 input·select는 줄바꿈이
없으므로 조작 쪽이다.

제목 30·소제목 20·본문 16·배지 12는 그대로다.

* docs: 타입 스케일 판단을 결정 기록으로 옮긴다

근거와 버린 것은 spec/3-3이 맡는 자리다. README에 적으면 왜 그렇게 정했는지가
구현 대응표에 섞여, 다음에 뒤집으려는 사람이 무엇을 이미 시도했는지 알 수 없다.

결정 29에 여덟 논점을 남겼다. 특히 표를 16px로 올렸다가 화면을 보고 되돌린 D5와,
"주 동작은 본문 크기"라는 문장을 규칙으로 적지 않기로 한 D7이 이 문서에 있어야
할 것들이다.

README에는 어느 클래스를 쓰는지와 되돌린 자리의 경고만 남기고 결정으로 링크한다.
spec 3-1의 16/14 배율도 그 전제의 출처를 결정 29로 가리킨다.

목차에 빠져 있던 결정 28도 함께 채운다.
즐겨찾기와 품질에 대한 반응을 별개로 표현하기 위해 상세에 좋아요를 둔다. 목록은 열이 많고 좋아요순 정렬도 없어 개수 열을 늘리지 않는다.

204 응답 뒤 별도 조회로 조회수를 올리지 않도록 낙관적으로 반영하고 실패하면 되돌린다. 픽스처와 테스트에서도 멱등성, 즐겨찾기 독립성, 비활동 부원 차단을 확인한다.
* feat: 자유게시판에 좋아요를 붙인다

글을 읽고 반응을 남기도록 상세에 좋아요 버튼을 두고 목록에는 개수만 보여준다. 공지와 같은 방식으로 반영해 부원이 화면마다 조작을 다시 배우지 않게 한다.

204 응답을 기다리는 동안 반응을 바로 보여주고 실패하면 원래 상태로 복원한다. 비활동 부원도 이용하는 게시판 권한과 수정 시 반응 보존을 테스트로 확인한다.

* test: 게시글 좋아요 픽스처의 거부 경로를 잠근다

허용 시나리오만 검사하면 인증·승인 대기·정지 거부 분기가 사라져도 테스트가 통과한다.

추가와 취소 모두에서 상태 코드와 오류 코드를 확인한다. 제거된 관리 계정의 옛 시나리오도 거부되는지 확인한다.
@kangkyunghyun kangkyunghyun self-assigned this Sep 7, 2026
@vercel

vercel Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
khuhacker Ready Ready Preview Sep 7, 2026 12:17pm UTC

@kangkyunghyun
kangkyunghyun merged commit 1f77dac into main Sep 7, 2026
6 checks passed
@kangkyunghyun
kangkyunghyun deleted the release/v1.1.0 branch September 7, 2026 12:20
@kangkyunghyun

Copy link
Copy Markdown
Member Author

배포 확인 (2026-09-07):

  • Web — www.khuhacker.com 번들에 이번 릴리스 문자열(좋아요·댓글·타입 스케일) 확인
  • API — Deploy API 워크플로 success, /actuator/health UP, 새 엔드포인트(/notes/{id}/like, /posts/{id}/comments)가 404가 아닌 인증 오류로 응답 → 새 버전 + V16~V20 적용
  • 남은 것 하나: S3 고아 오브젝트 정리 잡 첫 운영 실행 — @sumin0218 runbook의 CloudWatch 로그 패턴으로 첫 실행 결과 확인 부탁. 실제 삭제가 일어나는 잡이라 첫 회차는 눈으로 보는 게 맞다.
  • release 브랜치는 추가 커밋이 없어 develop 동기화 PR 불필요. 삭제는 ruleset상 관리자 몫.

This branch was successfully deployed

1 active deployment
Preview — 9450f9aa Deployed Sep 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants