website: dev → main 승격 — 어드민 메일 시간대/도메인 수정과 멤버 상세 모달
멤버 카드를 클릭하면 참여 프로젝트까지 볼 수 있는 상세 모달과, 어드민 메일에서 발견된 도메인·시간대 버그 두 건을 묶어서 main에 올린 승격 PR이다.
이 PR 자체는 dev 브랜치에 이미 머지돼 있던 세 개의 PR(#425, #428, #429)을 main으로 승격시키는 작업이라 새로 짠 코드는 없다. 대신 그 세 건이 각각 왜 필요했는지, 그리고 왜 이번엔 무거운 배포 체크리스트를 다 밟지 않았는지를 정리해둔다.
멤버 상세 모달 (#425, 이슈 #309)
원래 멤버 카드는 클릭하면 카드 자체가 뒤집히면서 참여 이유 텍스트가 보이는 플립 방식이었다. 여기에 "참여 프로젝트도 보여주자"는 요구가 붙으면서, 카드 뒷면에 다 우겨넣기보다는 별도 다이얼로그로 분리하는 쪽으로 방향을 잡았다. 첫 커밋에서 MemberDetailModal을 포털 다이얼로그로 새로 만들었다 — 스크롤 락, 포커스 트랩, 닫을 때 원래 카드로 포커스 복귀, ESC/배경 클릭 닫기, prefers-reduced-motion 대응까지 한 번에 넣었다. 프로젝트가 여러 개면 좌우로 넘겨볼 수 있게 하고, 0개/1개/여러 개/불러오기 실패 네 가지 상태를 각각 구분해서 처리했다.
문제는 그 다음 커밋에서 나왔다. 첫 버전은 세로 레이아웃이었는데 막상 붙여보니 프로젝트 대표 이미지가 object-cover로 잘리는 게 눈에 띄었고, 레이아웃도 가로로 펼치는 편이 나아 보여서 바로 리디자인했다. 모바일에서는 바텀시트로, 데스크톱에서는 좌측에 멤버 정보/우측에 프로젝트가 오는 가로형으로 바꾸고, 이미지는 object-contain으로 바꿔 잘림을 없앴다. 선택한 멤버 카드의 색을 모달에도 써야 했는데, 배경을 통째로 칠하면 다크앰버 톤을 벗어나서 아바타 링·세션 배지·상단 글로우 정도에만 악센트로 얹는 선에서 절제했다. 색 팔레트도 MemberCard.tsx 안에 박혀있던 걸 roster.ts의 cardColor()로 빼서 카드와 모달이 같은 색을 참조하게 했다.
프로젝트 목록은 공개 API에 memberId→프로젝트 매핑을 바로 주는 엔드포인트가 없어서, /members 서버 컴포넌트에서 GET /api/projects 목록과 각 프로젝트 상세(participants 포함)를 가져와 직접 집계했다. 이 집계가 실패하더라도 멤버 그리드 자체는 그대로 보여주고, 모달에서만 "프로젝트 정보를 불러오지 못했어요"로 안내하도록 실패 처리를 분리했다. 학과·학번 같은 필드는 아직 공개 API에 없기도 하고 PM 결정(#423)이 나기 전이라 의도적으로 넣지 않았다.
어드민 메일 도메인/시간대 두 건 (#428, #429)
이건 코드를 새로 짜다가 발견한 버그가 아니라, 운영하다 나온 실제 오류였다. FRONTEND_BASE_URL 기본값이 admin.likelion-khu.com으로 잡혀 있었는데, infra/docs/dns.md를 보면 실제로 존재하는 도메인은 likelion-khu.com 하나뿐이고 어드민은 그 안의 /admin 경로일 뿐이었다. stage/prod .env에 이 값을 따로 오버라이드하지 않았던 탓에 초대·비밀번호 재설정 메일이 존재하지 않는 도메인으로, 게다가 /admin 경로가 중복(.../admin/reset-password/...)된 링크로 나가고 있었다. 이미 같은 패턴으로 잘 쓰고 있던 PUBLIC_SITE_URL을 참고해서 stage/prod .env.example에 명시적으로 값을 채워 넣었다.
두 번째는 시간대 문제였다. 서버/컨테이너에 TZ 설정이 없어서 JVM 기본 타임존이 UTC로 잡혀 있었고, LocalDateTime.now()로 만드는 모든 시각이 UTC 벽시계 값이었다. 그런데 어드민 초대·비밀번호 재설정 메일과 감사로그 화면 둘 다 이 값을 그대로 쓰거나, 혹은 반대로 "이미 KST겠지"라고 잘못 가정한 채 표시하고 있었다. 실제로 비밀번호 재설정 메일을 받아보니 만료시각이 실제 KST보다 9시간 이른 값으로 찍혀 있어서 바로 눈에 띄었다.
여기서 고민한 건 내부 저장값까지 KST로 바꿀지, 아니면 표시하는 지점에서만 변환할지였다. infra/scripts/push-email-failure-metric.py가 호스트에서 sqlite3 datetime('now')(UTC)로 email_log.sent_at을 직접 비교하는 #113 실패 알람 크론이 UTC를 전제로 돌고 있었다. JVM 기본 타임존 자체를 바꾸면 이 알람이 조용히 깨질 수 있어서, 내부 저장은 UTC로 그대로 두고 사람이 보는 지점(이메일 렌더링, 감사로그 프론트)에서만 변환하는 쪽으로 결정했다. 백엔드는 EmailService에서 expiresAt을 포맷하기 직전에 Asia/Seoul로 변환하고, 프론트 AuditLogViewer의 formatKst()는 타임존 없는 문자열을 +09:00이 아니라 Z(UTC)로 파싱하도록 고쳤다 — 이 부분이 실제와 정반대로 가정하고 있던 지점이었다.
승격 자체는 가볍게
이 세 건은 dev에서 이미 리뷰·머지가 끝난 상태였고, 이번 PR은 그걸 main으로 올리는 절차에 가깝다. 직전 dev→main 승격(#424, 2026-08-03)에서 193커밋 격차를 해소하는 큰 컷오버를 이미 했기 때문에, 이번엔 infra/docs/prod-deploy-criteria.md에 있는 무거운 체크리스트를 처음부터 다시 다 밟지는 않기로 판단했다. 대신 실제로 문제였던 부분만 다시 확인했다. cjang6199@gmail.com으로 비밀번호 재설정 메일을 실제 발송해서 링크 도메인(dev.likelion-khu.com, /admin 중복 없음)과 만료시각(30분, KST)이 맞는지 봤고, 감사로그 화면도 DB에 저장된 UTC 값(05:55:23)이 화면에서 정확히 +9시간(14:55:23 KST)으로 보이는지 확인했다.
머지는 생성 4분 만에 이뤄졌다. 다만 이번 머지로 prod의 ADMIN_SEED_ADMINS에 등록된 4명(김우진·안시현·장찬욱·신선우)에게 실제 초대 메일이 자동 발송되기 때문에, 이번에 고친 도메인/시간대 수정이 그 메일에도 제대로 적용됐는지는 배포 후 따로 확인해야 하는 항목으로 남겨뒀다.
같은 값(FRONTEND_BASE_URL, PUBLIC_SITE_URL)을 두 개의 서로 다른 이름과 기본값으로 관리하다 보니 한쪽만 오버라이드하고 다른 쪽을 빠뜨리는 실수가 생겼다. .env.example에 명시적으로 값을 박아두는 것만으로는 근본적인 해결이 아니라서, 다음엔 도메인 관련 환경변수를 한 군데서 파생시키는 구조를 한번 검토해볼 만하다.