← 개발 로그 목록

website: dev → main 승격 — KST 표시 버그 수정과 벨로그식 에디터 개편

/ 6분 분량 / 개발 로그

dev 브랜치에 쌓여 있던 작업들을 모아 main으로 승격한 PR이다. 새 마이그레이션은 없고 코드 변경만 올라가는 배포라, 이번 사이클에 담긴 변경들을 하나씩 짚어보면서 남긴다.

가장 먼저 처리한 건 KST 표시 버그였다. 얼마 전 PR#429에서 메일 발송이랑 감사로그 쪽 타임존 문제를 고쳤는데, 이번에 다시 훑어보니 화면 쪽에 같은 문제가 6곳이나 더 남아있었다. formatDate.ts가 블로그 공개일, 멤버 마이페이지, 지원서 목록, 블로그 관리, 댓글 모더레이션까지 다섯 군데에서 공용으로 쓰이는 헬퍼인데, 백엔드가 타임존 정보 없이 내려주는 값을 프론트에서 new Date()로 그냥 파싱하고 있었다. AdminAccountManagement의 초대 만료일, CommentSection의 공개 댓글, BlogAnalyticsPanel의 발행일도 마찬가지였다. 결과적으로 UTC 15:00~23:59(KST 자정 이후) 사이에 생성된 데이터가 하루 이른 날짜로 보이는 증상이었는데, 값을 UTC로 명시 파싱한 뒤 Asia/Seoul로 변환해 표시하는 패턴으로 전부 통일했다. 비슷한 문제가 알림 구독 시각 저장 로직(NotificationSignupAnalyticsService)에서도 발견됐는데, 이쪽은 표시가 아니라 저장 자체가 문제였다. LocalDateTime.now()로 저장하면 JVM 기본 타임존(컨테이너는 보통 UTC)을 그대로 타는데, "오늘" 범위 판정은 Seoul 기준으로 하다 보니 GitHub Actions(UTC 러너)에서만 간헐적으로 테스트가 실패했다. 같은 analytics 패키지의 AnalyticsPageView가 LocalDateTime.now(ANALYTICS_ZONE)로 저장하는 관례를 그대로 따라 맞췄다. 어드민 메일·감사로그 쪽은 "저장은 UTC, 표시만 KST 변환" 방식을 쓰는데, 그건 외부 크론 스크립트가 UTC 저장을 전제하고 있어서 그런 거고, 여기는 그런 외부 의존이 없어서 형제 엔티티 관례를 따르는 쪽을 선택했다.

에디터 쪽은 이번 사이클에서 제일 손이 많이 간 부분이다. 기존 WriteForm은 제목/요약/본문을 순서대로 채우고 미리보기 토글로 확인하는 단순한 폼이었는데, 이번에 벨로그식 풀스크린 레이아웃으로 갈아엎었다. 좌측 에디터·우측 실시간 미리보기 분할 구조로 바꾸고, 모바일에서는 편집/미리보기 탭 토글로 대응했다. 이미지 삽입 방식도 바뀌었는데, 원래는 파일을 고르면 업로드가 끝난 뒤 본문 맨 끝에 마크다운을 붙이는 식이었다면, 이번엔 붙여넣기(Ctrl/⌘+V)·드래그드롭·툴바 버튼 모두 커서 위치에 바로 삽입되도록 했다. 업로드가 끝나기 전에는 ![업로드 중…](uploading:token) 형태의 자리표시자를 커서 자리에 먼저 끼워 넣고, 업로드가 끝나면 해당 토큰을 실제 마크다운으로 치환하는 방식을 썼다. 이렇게 하면 업로드가 진행되는 동안에도 사용자가 계속 타이핑할 수 있다. 다만 이 방식을 쓰려면 툴바 버튼을 눌렀을 때 textarea 포커스가 빠진 뒤에도 마지막 커서 위치를 기억하고 있어야 해서, onMouseDown에서 preventDefault로 포커스 이탈을 막고 selectionRef에 마지막 선택 범위를 따로 저장해두는 처리가 필요했다. 썸네일·한 줄 소개 입력은 본문 작성 흐름에서 분리해 출간 시점에 뜨는 모달로 옮겼는데, 이것도 벨로그 에디터의 구조를 참고한 판단이었다.

멤버 쪽 기능도 두 개 들어갔다. 하나는 /member를 프로젝트 목록으로 바로 리다이렉트시키던 걸 대시보드로 바꾼 것(#283)이고, 다른 하나는 로그인한 멤버가 자기 사진과 입부계기를 직접 수정할 수 있게 한 것(#195)이다. 대시보드는 새 집계 API를 따로 만들지 않고 기존 getCurrentMember/getAllMembers/getMemberPosts/getMemberProjects 호출을 조합해서 정체성 카드, 글/프로젝트/프로필 현황 카드로 구성했다. 프로필 셀프 편집은 GET/PUT /api/members/me를 새로 추가했는데, PUT 요청 DTO(MemberProfileReplaceRequest)에 이름·역할·기수·이모지 필드를 아예 넣지 않는 방식으로 관리자 소유 필드 보호를 구현했다. 요청 본문에 그 필드들을 실어 보내도 애초에 매핑될 자리가 없으니 못 바꾼다. null을 보내면 명시적으로 값을 지우는 관례(PostReplaceRequest와 동일)를 따랐고, 관리자용 update()(null이면 유지)와는 메서드를 분리했다. 사진 업로드는 /api/feed/images를 그대로 재사용해서 형식·용량 검증을 새로 만들지 않았다. 테스트는 본인만 수정 가능한지, 다른 멤버 프로필에 영향이 없는지, 오프보딩된 멤버는 404가 나는지, 비밀번호 미변경 상태에서는 403이 나는지까지 9건을 추가했다.

이미지 업로드 관련해서는 Content-Type 스푸핑 차단과 rate limiting을 추가했고, 운영진 삭제 시 OCI에 남아있던 이미지도 함께 삭제되도록 고쳤다. 이 작업 브랜치를 머지하는 과정에서 backend/SECURITY.md에 충돌이 났는데, 그만큼 이 시기에 보안 관련 문서를 여러 브랜치에서 동시에 건드리고 있었다는 뜻이기도 하다. 지원 질문 쪽은 PM 결정에 따라 데이터 분류(A/B/C)를 추가했는데(#152), 파기 자동화 자체는 보관기간이 아직 확정 안 돼서(#217) 범위 밖으로 남겨두고, 일단 질문마다 분류값을 스냅샷에 함께 저장해서 나중에 소급 분류가 가능하게만 해뒀다. 백엔드는 질문 스키마를 JSON째로 저장하는 구조라 코드 변경 없이 계약(shared 타입) 쪽만 리뷰하면 됐다.

이 PR 자체는 dev에 쌓인 걸 main으로 올리는 승격이라 새로 짠 코드는 없고, 대신 infra/docs/prod-deploy-criteria.md에 정리해둔 통과 기준을 체크하는 절차를 거쳤다. CI 통과와 QA(이번 범위인 KST 표시는 stage에서 수동 검증 완료), 마이그레이션 안전성(새 마이그레이션이 없어 dry-run은 생략)까지는 체크했지만, 롤백 준비와 .env.prod 값 확인은 배포 직전에 별도로 해야 하는 항목이라 PR 본문에도 미체크로 남겨뒀다. 여러 기능을 한 번에 승격하는 구조라 개별 기능 단위 커밋 흐름과 배포 단위 체크리스트를 분리해서 보는 게 유용했고, 다음에도 이런 승격 PR을 쓸 때는 체크리스트 항목을 배포 직전에 다시 확인하는 걸 잊지 말아야겠다.