요약: 이미 있는 블로그 기능을 조사해 빠진 것만 이슈 10개로 쪼갰고, 워크트리를 나눠 9개를 이틀에 걸쳐
dev에 넣었다. 병렬로 작업하니 같은 파일이 충돌했고, 자동 보안 리뷰는 배열 파라미터 500 오류나 메일 폭탄 경로 같은 실제 구멍을 잡아 냈다. 무엇을 고치고 무엇을 일부러 안 고쳤는지 기준을 남긴다.
글감이 아니라 조사부터 했다
"블로그에 뭘 더 붙일까"를 감으로 정하면 이미 있는 기능을 또 만든다. 그래서 먼저 코드를 훑어 이미 있는 것을 목록으로 만들었다. RSS, sitemap, 검색, 태그, 시리즈, 공감, 댓글, 목차, OG 이미지, 썸네일, JSON-LD, 조회수 컬럼이 이미 있었다.
못 찾은 것만 추려 우선순위를 매겼다.
- 작고 효과 큰 것: 관련 글, 많이 본 글, 예약 발행
- 중간: 태그 목록·클라우드, 월별 아카이브, 임시저장, 공유 버튼
- 큰 것: 이메일 구독, AI 제안, 조회 통계 대시보드
고른 10개를 각각 이슈(#495~#504)로 만들고 요구사항과 완료 기준을 적었다. 이슈에 완료 기준이 있으면 구현 세션이 "어디까지 하면 끝인지" 되묻지 않는다. 뒤에 나오는 "일부러 안 고친" 판단도 대부분 이 문구가 근거였다.

병렬로 돌리니 충돌이 났다
이슈마다 워크트리 하나, 세션 하나로 돌렸다. 각자 origin/dev에서 브랜치를 따고, 테스트를 먼저 쓰고, composer ci를 통과시킨 뒤 PR을 올렸다. feature → dev PR은 CI가 없어서 이 로컬 게이트가 유일한 검증이다.
문제는 기능들이 같은 자리를 건드린다는 점이었다. 아카이브(#499) PR을 올린 사이 태그 기능(#498)이 먼저 dev에 들어갔고, 병합하니 8개 파일이 충돌했다.
Routes.php,BaseController.php,PostModel.php- 사이드바 뷰와 블로그 테마 레이아웃 4개
전부 "두 쪽 다 살리는" 충돌이었다. 라우트와 사이드바 위젯을 각자 추가했기 때문이다. 해결 후 composer ci를 다시 돌려 563개 테스트 통과를 확인한 다음 푸시했다.
여기서 얻은 규칙은 이렇다.
- 라우트·사이드바·
BaseController처럼 모두가 한 줄씩 추가하는 파일은 충돌 1순위다. 병렬 작업 전에 알고 있으면 PR을 올리는 순서를 정할 수 있다. - 워크트리 브랜치가
main기반으로 만들어져 있는 경우가 있었다. 시작할 때origin/dev로 맞추는 단계를 빼먹으면 나중에 충돌이 더 크다. - 병합 후에는 반드시 전체 게이트를 다시 돌린다. 충돌이 없어도 합쳐진 결과가 깨질 수 있다.
기능마다 같은 설계 패턴이 반복됐다
9개를 만들면서 같은 질문을 계속 만났다.
1. "공개 글"의 정의를 한 곳에서 재사용한다. 관련 글, 태그 집계, 아카이브 모두 비밀글·비활성 글·비공개 게시판 글을 빼야 한다. 기능마다 조건을 새로 쓰면 하나가 어긋나 비공개 글이 노출된다. 기존 getLatestPublic과 같은 조건을 따랐다.
2. 집계는 캐시하고, 무효화는 쓰기 훅에 건다. 태그 집계와 아카이브는 1시간 캐시고, 글 insert/update/delete 때 지운다. 관련 글은 한 글의 수정이 다른 글의 목록에도 영향을 주므로 버전 토큰을 올려 전체 캐시를 한 번에 끊는다.
3. 입력 범위는 화이트리스트로 막는다. 아카이브는 월이 1~12, 연도가 2000~올해를 벗어나면 404다. 유효한 달인데 글이 없으면 404 대신 빈 페이지에 noindex를 준다.
4. 외부 연동은 설정이 있을 때만 켠다. 공유 버튼의 카카오톡은 .env의 KAKAO_JS_KEY가 있을 때만 버튼이 나오고, 클릭할 때 SDK를 불러온다.
자동 보안 리뷰가 잡은 것들
커밋 때마다 자동 보안 리뷰가 돌았다. 지적을 전부 고치지는 않았다. 고친 것과 안 고친 것을 나눈 기준이 오히려 남길 만하다.

고친 것
- **
?popular[]=7d한 줄로 사이트 전체가 500이 된다.** 많이 본 글 위젯이(string)으로 쿼리 값을 바꿨는데, 배열이 들어오면 "Array to string conversion"이 난다. 위젯은 모든 페이지의BaseController에서 돌기 때문에 사이트 전체가 영향을 받는다.is_string()으로 검사해 배열이면 기본값 7일을 쓰게 했고,/?popular[]=7d요청 회귀 테스트를 추가했다. - 글쓰기 권한이 있으면 구독자 메일을 일으킬 수 있었다. 뉴스레터는 글이 공개될 때 확인된 구독자에게 메일을 큐에 쌓는다. 회원·비회원이 쓸 수 있는 게시판 글로도 이 경로를 탈 수 있었다. 쓰기 권한이 admin인 게시판 글만 큐에 올리도록 막았다.
- 게시판을 비공개로 돌려도 태그가 남았다. 태그 집계 캐시가 최대 1시간 남아, 비공개로 바꾼 게시판 글의 태그가
/tags와 사이드바에 계속 보일 수 있었다.BoardModel이 게시판 변경 때public_tag_counts도 지우게 했다. - 로그에 외부 응답 본문이 남았다. Groq 오류 응답에 입력 내용이 섞일 수 있어, HTTP 상태 코드만 기록하게 했다. 뉴스레터 발송 실패 로그는 이메일 주소를 마스킹하고 예외 메시지를 뺐다.
- 외부 SDK에 SRI가 없었다. 카카오 SDK를 해시 없이 불러오고 있었다. 기억나는 해시를 쓰면 틀릴 수 있고, 틀리면 SDK가 아예 막힌다. 그래서 승인을 받아 SDK 2.7.4를 한 번 내려받아 sha384를 직접 계산해 넣었다. 버전을 올리면 해시도 갱신해야 한다고 주석으로 남겼다.
일부러 안 고친 것
- 인기 글 캐시 10분 노출. 비공개로 바뀐 글의 제목이 최대 10분 보일 수 있다는 지적이었다. 이슈 요구사항에 "글 수정 시 무효화 불필요, TTL 만료로 충분"이라고 적혀 있어 의도한 동작으로 뒀다.
- 태그 집계를 PHP에서 센다. 캐시가 빌 때 공개 글 전체의
tags컬럼을 읽어 센다는 지적이다. 1시간에 한 번 도는 가벼운 쿼리이고,tags가 쉼표 문자열이라 SQL로 세려면 구조를 바꿔야 한다. 글이 수천 개를 넘기 전에는 필요 없다고 판단했다. - AI 제안에 비밀글 게이트가 없다. 비밀글이면 외부 호출을 막으라는 지적이었다. 그 게이트는 비회원·일반 회원의 자동 호출 비용과 유출을 막으려는 것이다. AI 제안은 운영자만 버튼을 눌러 부르고, 이슈 요구사항도 운영자 전용 명시 호출이다.
- GET 해지 링크. 이슈가 "원클릭 해지"를 요구했고 메일 앱의
List-Unsubscribe도 같은 주소로 처리해야 해서 그대로 뒀다.
기준은 단순하다. 지적이 막으려는 위협이 이 코드 경로에 실제로 있는가를 먼저 보고, 있으면 고친다. 없는데 "원칙상 그렇다"는 이유만 있으면 근거를 적고 둔다. 안 고친 것도 PR 본문이나 보고에 이유와 "나중에 고치는 방법"을 함께 남겼다.
검증하지 못한 것도 적어 둔다
모든 구현 보고에 "확인한 것"과 "못 확인한 것"을 나눠 적게 했다. 이번에 확인 못 한 것은 이렇다.
- 임시저장의 복원·충돌 선택 JS는 브라우저에서 눌러 보지 않았다. 테스트는 키·스크립트·알림창 렌더링만 확인한다.
- 카카오 공유는 실제 키가 없어 눌러 보지 못했다. 키를 쓰려면 Kakao Developers 앱의 Web 플랫폼에 도메인을 등록해야 한다.
newsletter:send는 로컬 DB에 마이그레이션이 없어 직접 실행하지 못했다. 발송 로직은 모델·메일러 테스트로만 확인했다.- 테마별 화면은 눈으로 안 봤다. 기능 테스트가 렌더링만 확인한다.
- 사이드바의 많이 본 글 위젯은
blog테마가blog_sidebar를 안 써서 그 테마에는 없다.
"테스트 통과"를 "동작 확인"으로 보고하지 않는 것이 핵심이다. 통과한 테스트가 무엇을 덮는지 적어 두면 다음에 어디를 눈으로 봐야 하는지 바로 알 수 있다.
배포 전에 남은 일
dev에 합쳐졌을 뿐 운영에는 아직 반영되지 않았다. dev → main 배포 PR을 merge commit으로 합쳐야 한다. 서버에는 crontab 두 줄이 필요하다.
# 예약 발행: 시각이 지난 글을 공개한다
* * * * * cd /path/to/app && php spark posts:publish-scheduled
# 뉴스레터: 큐에 쌓인 메일을 보낸다
* * * * * cd /path/to/app && php spark newsletter:send >> /dev/null 2>&1
크론이 없으면 예약 글은 공개되지 않고, 뉴스레터는 큐에 쌓이기만 한다. 코드만 배포하고 크론을 빼먹는 것이 가장 흔한 실수라서 architecture.md에 등록 방법을 같이 적었다.
배운 점
- 만들기 전에 이미 있는 것의 목록부터 만든다. 10개 후보 중 겹치는 게 없었던 이유다.
- 이슈에 완료 기준을 적어 두면 구현과 리뷰 판단의 근거가 된다. 보안 리뷰 지적을 고칠지 말지도 그 문구로 갈렸다.
- 병렬 작업의 충돌은 모두가 한 줄씩 추가하는 파일에서 난다. 병합 후 전체 게이트를 다시 돌린다.
- 자동 보안 리뷰는 쓸 만하지만, 지적마다 "이 경로에 실제 위협이 있는가"를 따로 판단해야 한다.
- 보고에 검증하지 못한 것을 반드시 적는다.

댓글 0개