1. 개요

3월 말에 6일 집중해서 만들고 배포한 뒤 4개월을 비웠다. 8월에 돌아와 감사를 시작했는데 첫날 발견한 것이 문의 접수가 100% 막혀 있다 였다. 아무도 몰랐다. 이 글은 그 감사 라운드에서 나온 것들을 모았다. 각각은 작은데 합치면 “조용히 망가지는 시스템” 의 전형이다.


2. 핵심 내용

2-1. 문의 접수 100% 차단

세 겹이었다.

  1. SMTP 거부: 메일 서비스의 앱 비밀번호가 만료됐다. 접수 확인 메일이 나가지 않는다.
  2. 컨트롤러가 예외를 못 잡았다: 메일 실패가 500 으로 번져 문의 자체가 저장되지 않았다. 메일은 부수 효과인데 본 작업을 죽였다.
  3. 자격증명이 이미지에 구워져 있었다: 비밀번호를 바꾸려면 이미지를 다시 빌드해야 했다.

수정은 각 겹마다다. 메일과 텔레그램 알림을 각각 try 로 감싸 접수는 성공 처리하고 알림 실패만 로그로 남긴다(처음엔 하나의 try 였고, SMTP 예외가 텔레그램까지 삼켰다). SMTP 자격증명은 이미지에서 떼어 k8s Secret 으로 옮겼다. 그리고 액추에이터 헬스에서 mail indicator 를 뺐다. 메일 서버가 죽었다고 API 가 죽은 것처럼 보이면 안 된다.

이 마지막 수정이 딜레마를 남겼다. 이제 메일 실패는 어디서도 관측되지 않는다. 로그는 파드 안에만 있고 재시작하면 사라진다. “언제부터 막혔나” 를 끝내 특정하지 못한 이유다. 외부 업타임 모니터나 로그 알림이 없으면, 부수 효과의 실패는 존재하지 않는 것과 같다.

2-2. 레이트리밋이 무력화돼 있었다

클라이언트 IP 를 X-Forwarded-For 의 최우측 값으로 잡고 있었다. 최우측은 마지막 프록시가 붙인 값, 즉 Cloudflare 엣지 서버의 IP 다. 엣지 IP 는 요청마다 회전한다. 같은 공격자가 매 요청 다른 IP 로 보였고, IP 단위 레이트리밋은 아무것도 막지 않았다.

CF-Connecting-IP 를 1순위로, XFF 는 신뢰 홉 수만큼 오른쪽에서 건너뛴 값을 2순위로, remoteAddr 을 마지막으로 바꿨다. 그리고 채택 전에 IP 리터럴 형식을 검사한다. 헤더에 IP 가 아닌 문자열이 들어오면 버린다.

2-3. Redis 가 죽으면 60초

Redis 를 쓰는 곳이 셋이다. JWT 화이트리스트(관리자 요청, fail-closed), 레이트리밋(공개 POST, fail-open), ShedLock. Lettuce 의 기본 타임아웃은 connect 10초, command 60초 다. 설정하지 않았으니 기본값이다.

Redis 노드가 죽는 방식은 둘이다. connection refused 면 즉시 실패해 fail-open/closed 정책대로 간다. 그런데 TCP 블랙홀(노드가 응답 없이 사라짐)이면 요청 스레드가 60초를 기다린다. 톰캣 스레드가 다 물리면 Redis 를 안 쓰는 공개 API 까지 멈춘다.

운영 Redis 는 키 5개, 1.2MB 다. 응답은 ms 단위다. connect·command 타임아웃을 3초로 내렸다. “Redis 가 죽으면” 을 논할 때는 refused 와 blackhole 두 형태를 갈라야 타임아웃 논의가 성립한다.

2-4. 두 화면이 서로의 설정을 되돌린다

관리자 화면 ‘설정’ 과 ‘About 관리’ 가 같은 SiteSettings 엔티티를 편집한다. 둘을 나란히 열고 각각 저장하면 나중 저장이 먼저 저장을 되돌린다. 화면이 엔티티 전체 를 PUT 하기 때문이다.

Kotlin 생성자 역직렬화 + @RequestBody 엔티티 조합에서 JSON 에 없는 필드는 “변경 없음” 이 아니라 기본값 이다. 그래서 부분 객체를 보내는 건 더 위험하다. 없는 필드가 전부 기본값으로 덮인다.

해법은 소유 필드 목록 이다. 화면마다 자기가 편집하는 필드 목록을 코드 상수로 두고, 두 목록이 겹치지 않음을 테스트로 고정한다. 저장 직전에 최신 설정을 다시 읽어 자기 필드만 얹은 뒤 PUT 한다. 같은 방식으로 홈 히어로 이미지가 ‘설정’ 에 있어 관리자가 헤매던 것을 ‘콘텐츠 관리’ 로 옮겼다. 나눠 편집하는 한 리소스는 “성격” 으로 가른다.

App Router 에서 beforeunload 는 클라이언트 내비게이션에 발화하지 않는다. 입력 중 사이드바를 누르면 확인 없이 사라진다. 캡처 단계 document 클릭 리스너로 같은 origin 링크를 가로채되, 통과 조건(새 탭, 수정키)은 next/link 의 isModifiedEvent 를 그대로 베꼈다.

2-5. 리뷰 연동: 플랫폼 API 지형

지점별 리뷰를 사이트에 노출하고 싶었다. Google·Airbnb·Agoda 셋 중 자동 수집이 가능한 건 Google 만 이다. Airbnb 공식 API 는 PMS(숙박관리시스템) 파트너 전용이고, Agoda 는 셀프서브 신청이 없다. Google 도 Places API 는 리뷰 본문 저장이 정책상 금지라 Business Profile API 를 써야 한다.

그래서 플랫폼 enum 에 autoSyncSupported 를 박아 Google 만 true, 나머지는 관리자 수동 입력이다. 자동 수집은 synced=true 행만 건드리고, iCal 처럼 전량 교체가 아니라 externalId upsert 로 관리자가 정한 노출 여부·정렬을 보존한다. 수집이 비어 오면 정리를 건너뛴다. API 가 잠깐 빈 배열을 주면 리뷰가 전부 사라지는 사고를 막기 위해서다.

고객 페이지는 DB 만 읽고, 데이터가 없으면 섹션 자체가 안 그려진다. 그래서 피처 플래그 없이 머지할 수 있었다. “데이터 없으면 안 보인다” 가 가장 단순한 플래그다.

2-6. 배포는 짝으로, 백업은 홀더로

Turnstile 때문에 프론트와 백엔드 이미지는 짝으로만 유효하다. 배포 순서는 프론트 → 백엔드. 배포는 같은 홈랩의 다른 프로젝트와 같은 수동 deploy.yml 로 갔고, PR 빌드는 이미지를 push 하지 않는다. ArgoCD 는 이 앱만 selfHeal: true 로 켰다. kubectl 로 손댄 건 되돌아간다. 긴급 조치 절차가 전부 git 경로로 바뀐다는 뜻이다.

PostgreSQL 덤프 볼륨은 pause 컨테이너가 상시 마운트해 Longhorn 백업 대상이 되게 했고, 복원 리허설에서 테이블 11개 행 수가 일치했다. DB 와 Redis 는 nodeSelector 로 워커 하나에 고정했다. PVC 가 다른 노드에 새로 만들어져 빈 DB 로 뜨는 경로를 막기 위해서다.


3. 마무리

요약

  • 부수 효과(메일)의 실패가 본 작업(접수)을 죽이면 안 된다. 각각 try.
  • XFF 최우측은 엣지 IP 다. 회전한다. CF-Connecting-IP 를 1순위로.
  • Redis 장애는 refused 와 blackhole 두 형태. 후자는 기본 60초를 기다린다.
  • 엔티티 전체 PUT 은 다른 화면의 변경을 되돌린다. 소유 필드 목록 + 재조회 후 얹기.
  • 리뷰 자동 수집은 Google 만. “데이터 없으면 안 그린다” 가 가장 단순한 플래그.

시리즈는 여기까지다. 4개월의 방치가 준 교훈은 하나다. 조용히 망가지는 시스템은 관측이 없으면 망가진 줄도 모른다.