← 개발 로그 목록

website: dev→main 승격 — dbclient 권한 사고 수습하며 밀린 기능들 함께 반영

/ 6분 분량 / 개발 로그

dbclient forced command가 permission denied로 죽는 사고를 고치다가, 그 김에 dev에 한동안 쌓여있던 어드민 블로그 관리, 구독 동의, 글쓰기 세션 전환까지 한 번에 main으로 승격한 PR이다.

시작은 #165였다. dbclient-sqlite-guard.sh를 SSH forced command로 물려놨는데, 재배포할 때마다 permission denied가 났다. 원인을 파보니 어이없게도 이 스크립트가 git에 실행권한 없이(100644) 커밋돼 있었던 거였다. 로컬에서 chmod +x를 해도 커밋에 반영이 안 됐으니, CD가 git checkout -f로 브랜치를 되돌릴 때마다 실행권한이 벗겨진 상태로 배포되는 거였다. 고치는 방법은 간단했다 — git 트리 자체에 +x를 커밋하면 된다.

git update-index --chmod=+x infra/dbclient-sqlite-guard.sh

근데 이 진단 과정에서 /home/ubuntu ACL 쪽으로 잘못 짚었던 근접사고가 있어서, 그 경위를 db-access.md에 남겨뒀다. git checkout -f가 브랜치 전체를 되돌린다는 건 CD의 paths 필터 밖에 있는 파일도 전혀 안전하지 않다는 뜻이라, backup-db.sh 학습 문서에도 후속으로 적어뒀다.

이 fix를 dev에서 main으로 올리려고 보니, dev에 이미 머지돼서 대기 중이던 다른 브랜치들이 몇 개 있었다. 굳이 따로따로 승격시킬 이유가 없어서 한 번에 묶기로 했다.

블로그 관리 화면(#96/#120)은 어드민에서 게시된 글을 숨기거나 숨긴 글을 다시 게시하는 기능이다. #74에서 만들어둔 어드민 레이아웃과 세션 가드를 그대로 얹었고, BE 상태 전이 API(PATCH /posts/:id/status)도 이미 있는 걸 썼다. 새로 만든 건 목록 조회 화면과 상태 전환 버튼 정도였고, 401이면 /admin/login으로 보내는 가드 로직은 대시보드 패턴을 그대로 재사용했다.

모집 알림 구독 폼(#68)은 개인정보보호법 제15조가 요구하는 목적·항목·보유기간·거부권리 네 가지 고지를 폼에 붙이고, 동의 체크 전에는 제출을 막는 작업이었다. 버튼에 disabled를 걸어두긴 했지만, 엔터 키 등 다른 경로로 submit이 트리거될 수도 있어서 핸들러 안에도 한 번 더 막아뒀다.

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  // 버튼 disabled로 이미 막지만, 폼 submit이 다른 경로(엔터 등)로 트리거돼도 동의 없인 못 나가게 이중 방어
  if (!agreed) return;
  ...
}

가장 크게 손댄 건 글쓰기(#116)였다. 원래는 운영진이 발급한 매직링크 토큰으로 1회성 글쓰기 권한을 주는 방식이었는데, 이걸 멤버 로그인 세션 기반으로 완전히 바꿨다. TokenState(checking/no-token/valid/expired/used/error)로 나뉘어 있던 상태를 GET /api/member/auth/me 호출 결과로 판단하는 SessionState(checking/ready/error)로 단순화했다. 401이면 returnTo 쿼리를 붙여 로그인 페이지로 보내고, 403(어드민 계정으로 접근한 경우)은 별도 안내 문구를 보여주는 식으로 분기했다. createPost에서 X-Magic-Token 헤더도 걷어냈고, 페이지 경로도 /blog/write에서 /member/write로 옮겼다. 임시저장 키도 토큰 문자열에 묶여있던 feed-draft:${token}을 고정된 feed-write-draft로 바꿨다 — 토큰이 없어졌으니 당연한 변경이지만, 세션이 바뀌어도 로컬 임시저장이 그대로 유지된다는 부수효과가 있어서 나쁘지 않다고 판단했다.

이 글쓰기 전환 과정에서 returnTo 쿼리를 처리하는 MemberLoginForm에 오픈 리다이렉트 취약점이 하나 더 나왔다. isSafeReturnTo가 startsWith('//')만 걸러내고 있었는데, '/\\evil.com' 같은 값은 이 체크를 통과한다. 브라우저가 백슬래시를 슬래시로 정규화해버려서 결국 프로토콜 상대 URL로 튀는 문제였다. 두 번째 문자가 /나 \인 경우까지 거부하도록 보강했다.

마지막으로 CD 워크플로우의 롤백 마커 정리 시점(#162)도 같이 올라갔다. 이건 실제로 사고가 난 뒤 고친 거다. #161로 dev→main을 승격하고 prod에 배포하던 중 SQLite ALTER 제약으로 student_id 컬럼 추가가 실패해서 /api/members가 500을 냈다. 새로 만든 스모크 테스트가 이걸 정확히 잡아 job을 실패시켰는데, 롤백 스텝은 "이전 태그 마커가 없어 건너뜁니다"만 출력하고 아무것도 안 했다. SSH로 직접 들어가 확인해보니 실행 중인 이미지가 방금 배포한 깨진 새 태그 그대로였다. 원인은 헬스체크 스텝이 통과 직후 바로 .prev_backend_tag_* 마커를 지워버리는 데 있었는데, 스모크 테스트는 그 다음 스텝이라 헬스체크만 보고 "배포 확정"으로 판단해버린 셈이었다. 마커 삭제를 스모크 테스트 뒤의 새 스텝으로 옮겼고, 이 스텝에는 일부러 if: 조건을 안 걸었다.

# 스모크 테스트까지 전부 통과한 뒤에만 지운다.
# if: 조건이 없으므로 스모크 테스트가 실패하면(=job 실패) 자동으로 스킵되고,
# 마커가 그대로 남아 롤백 스텝이 정상 작동한다.
- name: 배포 확정 (롤백 마커 정리)
  uses: appleboy/ssh-action@v1
  ...

이 사고는 이전부터 지적돼 있던 #133(ddl-auto가 DDL 실패를 삼킨다) 문제가 실제로 터진 사례였고, Flyway 도입 필요성을 다시 한번 확인시켜준 계기이기도 했다. 당시 조치(수동 롤백, 스키마 수동 패치, 재배포)는 이 PR 이전에 이미 끝나있던 거라 이번 PR에는 재발 방지 코드만 담겼다.

머지 자체는 생성 2분 만에 끝났다. PR 설명에도 적어뒀듯이 이 diff는 backend/**나 shared/**를 건드리지 않아서 머지해도 CD가 자동으로 돌지 않는다. main 대상으로 workflow_dispatch를 별도로 실행해야 서버가 새 커밋을 체크아웃하고, dbclient 스크립트의 755 권한이 실제로 반영된다 — 이번 사고의 진짜 원인이었던 부분이라 이 수동 트리거를 빼먹으면 배포 자체는 성공한 것처럼 보여도 dbclient는 여전히 죽어있게 된다.