2026년 11월 4일 수요일
미단재

하루 만에 구현한 9가지 블로그 운영 기능과 설계 기준

시리즈 블로그 기능 추가 현재 위치 13 / 13
  1. 블로그 기능을 하나씩 직접 만들기 ① 시리즈 내비게이션
  2. 블로그 글 목차(TOC)를 JS 70줄로 직접 만들기
  3. 블로그 기능을 하나씩 직접 만들기 3 — CodeIgniter 4로 RSS 피드 만들기
  4. 글마다 다른 OG 썸네일을 GD로 자동 생성하기 (CodeIgniter 4)
  5. 스캐너 IP 자동 차단: 404 3회 일배치와 즉시 차단, 오차단 방어까지
  6. 워드프레스 이관 후 깨진 이미지, 태그째 정리하기 (CodeIgniter 4)
  7. Groq로 글 태그·시리즈 자동 추천: 환각 필터와 호출 제한
  8. CodeIgniter 4 테마 폴백으로 애드센스 자동 삽입하기
  9. 정규식 기반 마크다운 렌더러 구현기와 보안 고찰
  10. 블로그 개선 10개를 이슈로 쪼개 병렬로 만들며 배운 것
  11. Impeccable 스킬 적용한 게시글 상세 화면 구조 개선
  12. CMS 에디터가 내 CSS를 지웠다 — 소개 페이지 삽질기
  13. 하루 만에 구현한 9가지 블로그 운영 기능과 설계 기준

시리즈 소개 보기

요약: 1인 웹에이전시용 CodeIgniter 4 블로그(AICreo)에 2단계 인증, 활동 로그, 수정 이력, 휴지통, 댓글 스팸 방지, 미리보기 링크, 리다이렉트 관리, 이동 경로 통계, 다국어 기반을 하루 만에 올렸다. 기능마다 "실수·침입·스팸에서 글을 지킨다"는 기준을 두고 민감 값 미기록, 복원 가능한 삭제, 공통 진입점 같은 결정을 내렸다.

2026년 11월 3일 하루 동안 dev에 머지한 블로그 개선을 정리한다. 기능이 많아 보이지만 하나로 묶인다. 관리자 계정이 뚫리거나, 글을 잘못 지우거나, 스팸 댓글이 달리거나, 옛 주소가 깨져도 운영자가 되돌릴 수 있게 하는 것이다.

보안·감사, 콘텐츠 안전망, 트래픽·확장 세 묶음으로 나눈 9개 기능 지도

1. 보안·감사

관리자 2단계 인증(TOTP)

관리자별로 /admin/security에서 켠다. QR을 스캔하고 앱의 6자리 코드를 확인하면 복구 코드 8개가 나온다. 설계에서 신경 쓴 점은 이렇다.

  • QR을 서버에서 SVG로 생성한다. 시크릿이 외부 QR 서비스로 나가지 않는다.
  • 시크릿은 CI4 Encrypter로 암호화해 저장하고, 복구 코드는 SHA-256 해시만 저장한다. 1회용이다.
  • 같은 코드는 재사용할 수 없고(totp_last_step), 연속 5회 실패하면 15분 잠긴다.
  • **비밀번호 로그인과 소셜 로그인이 모두 BaseController::finishLogin()을 거친다.** 2FA를 켠 관리자는 여기서 세션(user_id)을 만들지 않고 twofa_user_id만 둔 채 /auth/2fa로 보낸다. 코드가 맞아야 startSession()이 세션을 완성한다.

비밀번호·소셜 로그인이 finishLogin()으로 모이고 2FA 여부에 따라 세션 생성이 갈리는 흐름

로그인 경로를 새로 만들 때 이 함수를 빼먹으면 2FA가 우회된다. 그래서 architecture.md에 "로그인 경로를 새로 만들면 반드시 finishLogin()을 거칠 것"이라고 적어 두었다.

배포 전 주의점도 있다. 서버 .env에 encryption.key가 없으면 설정 화면이 켜기를 막는다. 키를 바꾸면 저장된 시크릿을 복호화할 수 없으므로 Config\Encryption::$previousKeys로 이전 키를 유지해야 한다.

관리자 활동 로그

글·페이지·설정·회원·차단 IP를 바꾼 내역을 admin_logs에 남기고 /admin/logs에서 대상·동작으로 거르거나 검색한다. 핵심 결정은 민감 값을 남기지 않는다는 것이다.

  • 설정은 값이 아니라 바뀐 항목 이름만 기록한다. 값이 노출되지 않는지 테스트로 확인했다.
  • 회원은 이메일과 "무엇을 바꿨는지"만 적는다. 비밀번호나 2FA 시크릿은 없다.
  • 이후 만든 미리보기 링크 로그에도 토큰 값은 제외했다.
  • 오래된 로그는 php spark admin-logs:purge [--days 180]로 지운다. 하루 1회 crontab 등록이 필요하다.

댓글 스팸 방지

기존 허니팟·1분 제한·캡차는 그대로 두고 두 겹을 더했다. 비회원 댓글에 링크가 3개 이상이거나 금칙어가 있으면 바로 공개하지 않고 승인 대기(is_pending=1, is_active=0)로 저장한다. 운영자는 /admin/comments?status=pending에서 승인하거나 삭제한다. 회원·비회원 공통으로 IP당 시간당 20건까지만 쓸 수 있다.

2. 콘텐츠 안전망

글 수정 이력

수정 저장으로 덮어쓰이기 직전 상태를 post_revisions에 남긴다. 제목·본문·메타 설명이 실제로 바뀐 경우에만 저장하고, 글당 50개를 넘으면 오래된 것부터 지운다. 운영자는 현재 글과 줄 단위로 비교(LineDiff)한 뒤 복구할 수 있다. 복구도 복구 직전 글을 새 이력으로 남기므로, 복구를 잘못 눌러도 되돌릴 수 있다.

휴지통

글·댓글은 이미 소프트 삭제였고 페이지에 deleted_at을 더했다. TrashManager가 복원·영구 삭제·보관 기간(기본 30일) 만료 비우기를 맡는다. 만들면서 발견한 문제가 둘 있었다.

  • 삭제할 때 첨부 파일까지 지우던 컨트롤러 3곳 때문에, 복원해도 첨부가 없었다. 영구 삭제 때만 첨부·댓글·공감·조회 기록·시리즈 소속을 지우도록 고쳤다.
  • 글 목록의 댓글 수 서브쿼리가 삭제된 댓글도 세고 있었다.

삭제된 페이지의 슬러그는 유니크 키 때문에 휴지통에 있는 동안 재사용할 수 없다. 이 한계는 PR에 명시했다.

비공개·예약 글 미리보기 링크

/preview/{토큰}으로 비공개·예약 글을 읽기 전용으로 보여 준다. 만료는 1~90일이다. 이 주소가 새어도 피해가 작도록 응답 헤더를 신경 썼다.

X-Robots-Tag: noindex
Referrer-Policy: no-referrer
Cache-Control: no-store

댓글·공감·공유·첨부는 없고 조회수도 올라가지 않는다. 주소에 토큰이 있으므로 방문 로그에서도 제외했다. 만료·폐기·삭제된 글의 토큰은 404다. 공개 목록·RSS·sitemap은 기존 is_active=1 조건을 그대로 쓴다.

3. 트래픽·확장

리다이렉트 관리

/admin/redirects에서 원 주소·대상·301/302·사용 여부를 관리하고 적중 횟수를 본다. 저장할 때 막는 것들이 요점이다.

  • 원 주소 중복
  • 순환: 자기 자신, A→B→A 연쇄, 꺼진 매핑까지 포함
  • 대상은 /경로 또는 http(s)://만 허용

최근 30일 404 주소 목록과 일별 방문기록의 404 행에서 원 주소가 채워진 채로 바로 등록할 수 있다.

이동 경로 통계

접속 통계에 진입·이탈·페이지 간 이동을 더했다. 순수 클래스 PathAnalyzer가 page_visits를 IP+UA 기준으로 묶고, 30분 무활동이면 새 세션으로 본다. 스키마 변경은 없고 결과는 10분 캐시한다.

다국어 1단계

기능은 .env의 i18n.enabled = ko,en으로 켠다. 기본값은 ko만이라 기능이 꺼져 있고 기존 동작과 같다. 공개 화면 라우트를 /en/... 그룹에 다시 등록하고 LocaleFilter가 요청 로케일을 정하며 /ko/...는 301로 보낸다. posts·pages·menus에 locale, posts·pages에 translation_group_id 컬럼을 더했다. 언어 코드와 겹치는 페이지 슬러그는 금지한다. 모델 조회, hreflang·sitemap, 관리자, 전환 UI는 다음 단계다.

배운 점

  • 민감 값은 처음부터 기록 대상에서 뺀다. 나중에 마스킹하는 것보다 테스트로 "값이 안 나온다"를 고정하는 편이 안전했다.
  • 삭제는 복원 가능해야 한다. 휴지통을 만들다 보니 삭제 시 첨부를 지우는 기존 코드가 복원을 막고 있었다. 소프트 삭제는 연관 데이터를 언제 지울지까지 같이 설계해야 한다.
  • 우회될 수 있는 경로는 한 곳으로 모은다. 2FA는 finishLogin() 하나만 막으면 되도록 구조를 잡았다.
  • crontab이 필요한 기능은 문서에 박아 둔다. 활동 로그 정리, 휴지통 비우기 모두 서버 crontab 등록이 필요하다. 배포 PR과 architecture.md에 적었다.
  • 한계도 있다. 여러 PR에서 feature → dev는 CI 없이 머지되므로 로컬 composer ci가 유일한 관문이다. 한 PR(수정 이력)은 로컬 vendor 버전 차이로 PHPStan 4건이 실패해 훅을 우회해 푸시했다. 변경하지 않은 파일에서 난 오류였다.

이어 읽을 글

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.