<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://dbgmy.life/</id>
    <title>Debugging My Life</title>
    <updated>2026-11-03T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <author>
        <name>HEISENBUG</name>
        <uri>https://github.com/Debugging-My-Life/blog</uri>
    </author>
    <link rel="alternate" href="https://dbgmy.life/"/>
    <link rel="self" href="https://dbgmy.life/atom.xml"/>
    <subtitle>정보보안, 데이터분석, 개발, 일상에 관한 이야기를 공유하는 블로그입니다.</subtitle>
    <logo>https://dbgmy.life/og.jpg</logo>
    <icon>https://dbgmy.life/favicon.png</icon>
    <rights>© 2026 HEISENBUG. All rights reserved.</rights>
    <category term="Security"/>
    <category term="Data Analysis"/>
    <category term="Development"/>
    <entry>
        <title type="html"><![CDATA[딸아이와 로블록스 게임을 만들다 마주친 악성코드: 무료 템플릿 속 숨겨진 백도어와 공급망 공격 분석]]></title>
        <id>https://dbgmy.life/blog/roblox-toolbox-supply-chain-malware-analysis</id>
        <link href="https://dbgmy.life/blog/roblox-toolbox-supply-chain-malware-analysis"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/roblox-toolbox-supply-chain-malware-analysis.png" type="image/png"/>
        <updated>2026-11-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[딸아이와 함께 로블록스 타워 게임을 개발하던 중 무료 툴박스 모델에서 발견된 10단계 은닉 백도어 스크립트의 구조, Luau 가상머신 샌드박스 보안, 그리고 게임 공급망 공격의 실체를 분석한 기록.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **딸아이와 로블록스 게임을 만들다 마주친 악성코드: 무료 템플릿 속 숨겨진 백도어와 공급망 공격 분석**

<Image
  src="/images/blog/roblox-toolbox-supply-chain-malware-analysis.png"
  alt="Hunting Trojans in Roblox: How a Supply-Chain Backdoor Infiltrated My Daughter's Game"
  width={1200}
  height={675}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
  priority
/>

요즘 7살 딸아이가 로블록스(Roblox)라는 게임에 푹 빠져 시간 가는 줄 모르고 즐겁게 플레이하더군요. 

고사리 같은 손으로 아바타를 조작하며 까르르 웃는 모습을 보다가 문득 생각이 들었습니다.  
*"남들이 만들어둔 게임만 할 게 아니라, 우리가 직접 세상에 하나뿐인 재미있는 로블록스 게임을 만들어보면 어떨까?"*

제 제안에 딸아이는 눈을 반짝이며 자기만의 상상력을 쏟아내기 시작했습니다:

> *"아빠! 내가 직접 문제를 내는 퀴즈 타워 게임 만들자! 발을 헛디디면 무조건 1층 태초마을로 떨어지고, 문제 틀리면 다리가 푹 꺼지는 그런 스릴 넘치는 타워!"*

개발자 아빠로서 가장 큰 로망은 **'아이의 머릿속에 있는 순수한 상상과 아이디어를 타협 없이 그대로 구현해 주는 것'입니다.** 우리는 곧바로 2인 개발팀을 결성했습니다. 게임 기획과 퀴즈 출제 디렉터는 **7살 딸아이**, 게임 엔진과 인프라 구현은 **아빠**. 

게임 제목은 아이가 직접 지은 **'멍청하면 못깨는 타워'**. 로컬 개발 환경인 [Rojo](https://rojo.space/)와 로블록스 스튜디오(Roblox Studio)를 띄우고 신나게 Luau 코드를 작성하기 시작했습니다. 

하지만 인기 있는 한국형 타워의 계단 간격과 낙하 기믹을 참고하고자 툴박스(Toolbox)에서 무료 레퍼런스 모델 하나를 내려받았을 때, 직업병 같은 위화감이 스쳐 지나갔습니다. 그리고 그 예감은 빗나가지 않았습니다.

---

## 1. 평화로운 주말 개발, 그리고 스쳐 지나간 위화감

타워 게임(Obby)을 만들 때 가장 중요한 것은 **'점프 컨트롤의 정교한 물리적 감각'입니다.** 캐릭터가 걸어서 지나갈 수 없도록 발판 사이의 간격(약 6.0 ~ 6.5 스터드)과 수직 고도차(약 2 ~ 3.75 스터드)를 세심하게 맞춰야 합니다.

국내 유명 타워 게임들의 표준 규격을 분석해 보기 위해, 스튜디오 툴박스에서 많은 이용자가 참고용으로 공유해 둔 타워 모델(`Workspace.Model`) 하나를 베이스플레이트에 얹었습니다. 겉보기에는 정면 벽이 반투명 유리(`Transparency = 0.7`)로 마감되어 내부 기믹이 훤히 들여다보이는 전형적인 점프 타워였습니다.

```
Workspace
├── Baseplate
├── SpawnLocation
└── Model (가져온 무료 템플릿 타워)
    ├── Part (외벽, 계단, 발판...)
    ├── TrussPart (사다리)
    └── Bomb (?)
```

그런데 타워 1층 입구 구석에 뜬금없이 분홍색 `Bomb(폭탄)` 오브젝트와 `Slap(따귀 손바닥)`이라는 도구가 덩그러니 놓여 있었습니다. 

*"어? 타워 점프맵에 웬 폭탄 도구가 들어있지?"*

단순한 제작자의 실수나 장식이었을까요? 저는 탐색기(Explorer) 트리의 화살표를 깊숙이 펼쳐보기 시작했습니다.

---

## 2. 악성 스크립트 해부: 10단계 심층 은닉과 의태(Camouflage)

화살표를 열어본 순간, 머릿속에서 보안 경보가 요란하게 울렸습니다. 상용 소프트웨어의 공급망 공격(Supply-Chain Attack)에서나 보던 치밀한 은닉 기법이 아이들이 뛰노는 게임 템플릿 속에 고스란히 숨겨져 있었습니다.

```
Workspace.Model
├── Bomb
│   └── Bomb
│       └── Handle
│           └── x64
│               └── x65
│                   └── x66
│                       └── x67
│                           └── x68
│                               └── x69
│                                   └── x70
│                                       └── x71
│                                           └── x72
│                                               └── x73
│                                                   ├── Material (Script) ⚠️
│                                                   └── CoreTextureSystem (Script) ⚠️
├── Slap
│   └── Handle
│       └── WeldConstraint
│           └── AxisOrientation
│               └── PartAxis (Script) ⚠️
├── TextureUtility
│   └── cTextureManager
│       └── CoreTextureSystem (Script) ⚠️
└── SkyLink (Dont Remove) ⚠️
    └── EZConfig (Script)
```

### 1) 10단계 무의미한 중첩 폴더 트리 (`x64 ... x73`)
일반적인 로블록스 크리에이터나 어린 사용자들은 스튜디오 탐색기에서 2 ~ 3단계 이상 자식 노드를 일일이 열어보지 않습니다. 공격자는 이를 악용해 `x64`부터 `x73`까지 아무런 기능도 없는 빈 파트를 **10단계 연속으로 중첩**시켜 육안 검사를 완벽히 무력화했습니다.

### 2) 시스템 핵심 컴포넌트를 사칭한 의태형 네이밍
가장 깊숙한 `x73` 노드 아래에 숨겨둔 스크립트들의 이름은 결코 `Virus`나 `Backdoor`가 아니었습니다:
- `Material` (엔진의 기본 재질 속성처럼 사칭)
- `CoreTextureSystem` (텍스처 관리 코어 시스템처럼 위장)
- `CoreSkyboxSystem` (하늘 렌더링 시스템처럼 위장)
- `SkyLink (Dont Remove)` (지우면 맵이 망가지는 것처럼 경고문 위장)

개발자가 코드를 보더라도 *"아, 로블록스 내부 그래픽 처리에 필요한 스크립트인가 보다"* 하고 무심코 넘어가도록 심리적 방어선을 구축한 것입니다.

### 3) 런타임 동적 로딩 (`require`)과 백도어의 메커니즘
이 스크립트들의 내부를 들여다보면 로블록스 악성코드의 전형적인 패턴이 드러납니다:

```lua
-- 악성 스크립트의 전형적인 패턴 (개념 재현)
local _payload = require(10482910482) -- 원격 에셋 ID 직접 호출
getfenv()["\108\111\97\100\115\116\114\105\110\103"](_payload)()
```

1. **`require(AssetId)`를 통한 정적 분석 우회**:
   스크립트 본문 안에 악성 행위 코드를 직접 작성하면 로블록스의 기본 텍스트 필터나 스튜디오 스캐너에 쉽게 걸립니다. 따라서 공격자는 악성 로직을 별도의 '비공개 모듈(ModuleScript)'로 로블록스 클라우드 에셋 서버에 올려두고, 런타임에 숫자 ID로만 불러와 실행합니다.
2. **세션 장악 및 스팸 인젝션**:
   이 백도어가 실행되면 게임 서버(ServerScriptService)의 전권을 탈취하여, 특정 외부 유저가 접속했을 때 관리자 권한(`admin`)을 몰래 부여하거나, 게임 내 모든 접속자의 화면에 강제로 팝업(`ScreenGui`, 좋아요 구걸, 사기 링크)을 띄우고, 심지어 `TeleportService`를 호출해 유저들을 공격자의 피싱 게임으로 강제 납치할 수도 있습니다.

---

## 3. 보안 엔지니어의 디버깅: "내 PC는 안전한가?"

발견 즉시 스크립트를 비활성화했지만, 문득 이런 질문이 떠오를 수 있습니다.  
**"로블록스 스튜디오에서 이런 템플릿을 열었을 때, 내 컴퓨터(운영체제)까지 감염될 수 있을까?"**

결론부터 말하자면, **개발자의 로컬 PC(운영체제)는 100% 안전**합니다. 그 이유는 로블록스 엔진이 채택하고 있는 강력한 **Luau 샌드박스(Sandbox) 아키텍처** 덕분입니다.

```mermaid
flowchart TD
    subgraph HostOS ["내 컴퓨터 (macOS / Windows)"]
        FS["로컬 파일 시스템 (내 문서, 사진, 키체인)"]
        Shell["OS 셸 / 명령어 실행기 (bash, zsh)"]
        Net["네이티브 소켓 통신"]
    end

    subgraph LuauVM ["Roblox Studio 엔진 (Luau VM 샌드박스)"]
        Script["악성 스크립트 (Bomb, SkyLink)"]
        DataModel["게임 데이터모델 (Workspace, ServerScript)"]
        Http["HttpService (제한적 도메인 요청)"]
    end

    Script -.->|"접근 원천 차단 (io 없음)"| FS
    Script -.->|"명령어 실행 불가 (os 없음)"| Shell
    Script -.->|"소켓 바인딩 불가"| Net
    Script ==>|"게임 월드 내부만 오염"| DataModel
```

### 1) Luau 가상머신의 원천적 파일 I/O 격리
로블록스가 사용하는 스크립트 언어인 Luau에는 표준 C Lua에 존재하는 파일 읽기/쓰기 라이브러리(`io.open`, `io.read` 등)와 운영체제 명령 실행 함수(`os.execute`, `os.getenv` 등)가 **바이트코드 레벨에서 원천적으로 제거**되어 있습니다. 악성코드가 아무리 발악해도 내 맥북의 하드디스크 파일을 뒤지거나 개인정보를 외부로 유출하는 것은 불가능합니다.

### 2) 실행 시점의 제한: 플레이 모드 이전엔 정적 데이터
스튜디오 내부의 `Script`는 기본적으로 게임을 실행(`Play` 또는 `Run`)하기 전인 **편집(Edit) 모드 상태에서는 코드를 실행하지 않습니다**. 즉, 모델을 불러와 탐색기로 확인하는 시점에는 악성코드가 실행조차 되지 않은 단순한 텍스트 데이터 덩어리에 불과합니다.

### 3) 하지만 게임 내 세계(DataModel)는 완벽히 파괴된다
PC가 안전하다고 해서 방치해도 되는 것은 아닙니다. 이 모델을 그대로 두고 게임을 퍼블리시(Publish)하는 순간:
- 게임의 원작자도 모르는 사이에 방문자들에게 악성 팝업이 노출되고,
- 서버 메모리에 무한 루프 렉(Lag Bomb)이 걸려 게임이 다운되며,
- 애써 만든 게임이 로블록스 운영팀의 검수를 거쳐 **'악성 게임'으로 낙인찍혀 계정 정지(Ban)를** 당할 수 있습니다.

---

## 4. 백도어 소탕 작전: 전수 검사와 클린 아키텍처 재건

손으로 하나하나 지우는 것은 공격자가 파놓은 또 다른 함정에 빠지기 쉽습니다. 우리는 엔지니어답게 **자동화 스크립트로 DataModel 전체를 전수 감사**하기로 했습니다.

### 1) DataModel 전수 스캔 스크립트
로블록스 스튜디오의 커맨드 바(Command Bar)나 Rojo 연동 환경에서 다음 코드를 실행하여 게임 내의 모든 실행 객체를 일괄 추출했습니다:

```lua
-- 게임 전체를 스캔하여 미인가 스크립트 및 이상 노드를 탐지하는 감사 코드
local function auditWorkspaceScripts()
    local dangerousKeywords = { "CoreTexture", "SkyLink", "x64", "x73", "cTexture", "EZConfig" }
    local found = 0

    for _, obj in ipairs(game:GetDescendants()) do
        if obj:IsA("LuaSourceContainer") then -- Script, LocalScript, ModuleScript 검사
            local fullName = obj:GetFullName()
            for _, kw in ipairs(dangerousKeywords) do
                if string.find(fullName, kw) then
                    warn(string.format("[🚨 백도어 탐지] %s (타입: %s)", fullName, obj.ClassName))
                    obj:Destroy() -- 즉시 격리 및 파괴
                    found = found + 1
                    break
                end
            end
        end
    end
    print(string.format("전수 검사 완료: 총 %d개의 악성 요소를 영구 삭제했습니다.", found))
end

auditWorkspaceScripts()
```

이 검사를 통해 `Bomb`, `Slap`, `Rainbow Carpet`, `admin`, `TextureUtility`, `SkyLink`, `Mesh` 등 **총 14개의 악성 스크립트와 치트 장비**를 찾아내어 단 1초 만에 완전히 소멸시켰습니다.

### 2) 오염된 템플릿의 전량 폐기, 그리고 오리지널 재건축
백도어를 모두 지우고 나니 깨끗해졌지만, 찜찜함은 가시지 않았습니다. 딸아이와 둘러앉아 솔직하게 이야기를 나누었습니다.

> *"누군가 만들어둔 무료 템플릿에 나쁜 코드가 잔뜩 숨겨져 있었어. 이 타워를 억지로 고쳐 쓰지 말고, 발판 규격만 참고해서 우리 손으로 바닥부터 100% 새로 지어볼까? 네가 원하는 모양 그대로 말이야."*  
> *"응 아빠! 당연하지! 내 게임이니까 우리가 처음부터 예쁜 무지개 색으로 다 만들어야지!"*

우리는 오염된 무료 템플릿을 통째로 휴지통에 던져 넣었습니다. 그리고 추출한 한국형 타워의 규격을 바탕으로 완전히 독자적인 모듈 코드를 작성했습니다:

```
src/
├── server/
│   ├── init.server.luau       # 서버 진입점 및 스폰 제어
│   ├── TowerBuilder.luau      # 7단계 파스텔 무지개 200층 타워 프로시저럴 생성기
│   ├── TowerGimmicks.luau     # 사라지는 발판, 구름 방방이, 데미지 파트 제어
│   ├── FallZone.luau          # 체크포인트 없는 태초마을 낙하 감지기
│   └── VIPItems.luau          # 딸아이와 아빠만을 위한 무지개 구름 & 점프슈즈
├── client/
│   └── init.client.luau       # 시야를 가리지 않는 초슬림 HUD & 퀴즈 토스트 UI
└── shared/
    └── GameConfig.luau        # 7살 딸아이가 직접 낸 맞춤형 3D O/X 퀴즈 14관문 데이터
```

외부 의존성 제로(Zero Dependency). 단 하나의 외부 스크립트도 허용하지 않은 **100% 순수한 우리만의 코드**로 타워를 완성했습니다. 

1층 태초마을 광장에는 딸아이와 아빠의 실제 로블록스 캐릭터 동상이 우뚝 섰고, 올라가는 길목마다 아이가 직접 아이디어를 낸 OX 퀴즈 문들이 반짝이며 플레이어를 맞이했습니다.

---

## 5. 에필로그: 공급망 공격은 엔터프라이즈의 전유물이 아니다

보안 실무에서 이야기하는 **'공급망 공격(Supply-Chain Attack)'은** 거창한 국가 배후 해킹 그룹이나 대기업의 전유물처럼 느껴지곤 합니다. 오픈소스 라이브러리의 취약점, npm 패키지 타이포스쿼팅, CI/CD 파이프라인 침투 같은 무거운 단어들 말입니다.

하지만 이번 주말, 딸아이와 함께 게임을 만들며 뼈저리게 느꼈습니다:
- **공급망 공격은 개발자가 '신뢰'를 소비하는 모든 지점에 존재합니다.**
- 수백만 명의 어린아이와 초보 개발자들이 별생각 없이 클릭하는 로블록스 툴박스의 무료 모델 버튼 하나가, 곧 소프트웨어 엔지니어링에서의 `npm install`이나 `docker pull`과 본질적으로 완벽히 동일한 위험을 내포하고 있습니다.

인터넷에서 누군가 무료로 베풀어 놓은 달콤한 템플릿 뒤편에는 언제나 보이지 않는 비용이 청구될 수 있습니다. 

비록 작은 로블록스 점프맵 하나였지만, **'신뢰하지 않고 직접 검증하며(Zero Trust), 결국 내 시스템의 주권은 내가 직접 작성한 오리지널 코드에서 나온다'는** 기본 원칙을 딸아이와 함께 다시금 확인한 뜻깊은 디버깅이었습니다.

딸아이와 나란히 앉아 무지개 구름을 타고 200층 꼭대기까지 날아올라 바라본 노을은, 그 어떤 보안 패치 완료 화면보다 평화로웠습니다.
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[3,200km 텔레메트리로 검증한 테슬라의 진짜 효율: 외기 온도별 전비, 충전 손실률, 그리고 유령 방전(Vampire Drain)의 진실]]></title>
        <id>https://dbgmy.life/blog/teslamate-telemetry-data-analysis-efficiency-vampire-drain</id>
        <link href="https://dbgmy.life/blog/teslamate-telemetry-data-analysis-efficiency-vampire-drain"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/teslamate-telemetry-data-analysis.png" type="image/png"/>
        <updated>2026-11-01T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[홈 NAS에 구축한 TeslaMate로 수집한 3,252km의 실전 주행과 600kWh 충전 텔레메트리 데이터를 심층 분석하여, 158.8km 완벽 대조군을 통한 저온 전비 저하율, 완속(AC) vs 급속(DC) 충전 손실률, 센트리 모드 대기전력, 9,600km 배터리 열화도를 실측한 기록.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **카더라가 아닌 '진짜 데이터'로 전기차를 디버깅하다**

<Image
  src="/images/blog/teslamate-telemetry-data-analysis.png"
  alt="Tesla Telemetry Deep Dive: Efficiency, Charging Loss, and Vampire Drain"
  width={1200}
  height={675}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
  priority
/>

지난 포스트([내 차의 데이터는 어디로 갈까? NAS 도커로 구축한 TeslaMate 셀프 호스팅 완벽 가이드](/blog/teslamate-nas-docker-self-hosting))에서는 서드파티 클라우드에 내 차의 위치와 토큰을 넘기지 않고, 홈 NAS의 Docker 환경에 Elixir와 PostgreSQL 기반의 **TeslaMate** 로거를 직접 구축하여 데이터 주권을 확보하는 과정을 다루었습니다.

그로부터 수개월간 제 테슬라 모델 Y(애칭: **테슬이**, Model Y RWD)와 함께 계절을 지나며 약 **3,200km가 넘는 주행 로그**, **600kWh의 충전 세션**, 그리고 **수십 차례의 주차 대기 데이터**가 고스란히 PostgreSQL 시계열 데이터베이스에 쌓였습니다.

인터넷 전기차 커뮤니티에는 수많은 '카더라'와 주관적인 체감들이 넘쳐납니다.

> *"겨울철엔 배터리가 녹아내린다던데, 구체적으로 몇 %나 더 닳을까?"*  
> *"완속 충전이 급속 충전보다 무조건 손실이 적고 알뜰할까?"*  
> *"센트리 모드(감시 모드)를 켜두면 밤새 전기가 얼마나 빠져나갈까?"*  
> *"LFP 배터리는 매일 100% 충전해도 정말 1만 km 가까이 열화가 없을까?"*

엔지니어로서 이런 의문들을 단순한 감각에 맡길 수는 없었습니다. **내가 직접 소유하고 검증한 텔레메트리 데이터(Telemetry Data)를** 꺼내어 파이썬(`pandas`)과 SQL로 집요하게 교차 검증하고 분석해 보았습니다. 그 생생한 실측 분석 결과를 공개합니다.

---

## 1. 분석 대상 데이터셋 및 전처리 파이프라인

이번 분석에 사용된 데이터는 TeslaMate가 차량의 CAN 버스 및 Streaming API로부터 직접 수집하여 NAS 내부의 PostgreSQL에 기록한 원본 데이터입니다.

| 분석 항목 | 수집 데이터 규모 | 비고 |
| :--- | :--- | :--- |
| **총 주행 세션 (Drives)** | **207회** (0.5km 이상 유효 주행 138회) | 단거리 주차/셔틀 이동 제외 |
| **분석 주행 거리 (Distance)** | **3,252.1 km** | 고속도로 80%, 도심 20% |
| **순 전력 소모량 (Net Energy)** | **453.92 kWh** | 회생제동 차감 후 순 소모량 |
| **총 충전 세션 (Charges)** | **27회** (총 **601.15 kWh** 인입) | 완속(AC) 19회, 급속(DC) 5회 등 |
| **주차 대기 세션 (Vampire Drain)** | **40회** (**총 750+ 시간**) | 센트리 모드 및 딥슬립 분리 측정 |
| **차량 누적 적산계 (Odometer)** | **9,622 km** | 배터리 헬스 기준 시점 |
| **분석 환경** | Python 3.12 / pandas / DuckDB | 로컬 환경 정밀 전처리 |

> [!NOTE]
> **프라이버시 및 데이터 마스킹 원칙**:  
> 본 포스트에 수록된 모든 주행 데이터는 출발지/도착지의 상세 지번, 건물명, 위도·경도 좌표를 철저히 비식별화(예: `수도권 남부 ↔ 충북 고속도로 구간 (158.8km)`)하여 분석했습니다.

---

## 2. [주행 편] 외기 온도가 전비에 미치는 충격: 158.8km 완벽 대조군 실측

전기차 오너들이 겨울을 두려워하는 가장 큰 이유는 바로 '저온에서의 주행거리 감소'입니다. 하지만 일반적인 주행 후기는 바람의 세기, 승차 인원, 규정 속도, 고도 차이 등 변수가 제각각이라 온도의 순수한 영향만을 분리해 내기 어렵습니다.

그런데 이번 데이터셋을 정렬하던 중, **완벽한 실험 대조군을 형성하는 두 번의 장거리 주행 로그**를 발견했습니다.

### 1) 158.8km 동일 구간·동일 속도 실측 비교: 20.1℃ vs 7.6℃

충북 대강면에서 출발하여 경부/중부고속도로를 거쳐 수도권 남부 목적지까지 내려오는 **정확히 동일한 158.8km 고속도로 야간 주행** 데이터입니다:

| 비교 항목 | 9월 20일 주행 (초가을 상온) | 10월 31일 주행 (초겨울 저온) | 변동률 (Variance) |
| :--- | :--- | :--- | :--- |
| **주행 경로 및 거리** | 158.8 km (대강면 ➔ 수도권 남부) | 158.8 km (대강면 ➔ 수도권 남부) | **동일 경로 (158.8 km)** |
| **소요 시간 / 평균 속도** | 1.9 시간 / **83 km/h** | 1.9 시간 / **85 km/h** | 오차 ±2 km/h (동일 주행) |
| **최고 속도** | 114 km/h | 122 km/h | 고속도로 정속 주행 |
| **외기 온도 (Temp)** | **20.1 °C** | **7.6 °C** | **-12.5 °C (기온 급감)** |
| **순 소모 전력량** | **20.5 kWh** | **23.6 kWh** | **+3.1 kWh (+15.1% 증가)** |
| **구간 전비 (Efficiency)** | **129 Wh/km** (7.75 km/kWh) | **148 Wh/km** (6.75 km/kWh) | **+14.7% 전력 소모 증가** |
| **배터리 저온 상태** | 정상 | **❄ 눈꽃 아이콘 활성화** | 배터리 보호 및 공조 가동 |

놀랍지 않나요?  
출발지와 목적지, 고도 변화, 주행 속도(83 ~ 85 km/h)가 완벽히 일치하는 상황에서, **단지 외기 온도가 20.1℃에서 7.6℃로 12.5℃ 하강했을 뿐인데 소모 전력은 정확히 3.1 kWh (+15.1%) 증가**했습니다.

이 차이가 발생하는 공학적 원인은 명확합니다:
1. **공기 밀도 증가에 따른 항력(Drag) 상승**: 기온이 내려가면 공기가 무거워져 고속 주행 시 차량이 밀어내야 하는 공기 저항이 커집니다.
2. **배터리 내부 저항 및 화학 반응 둔화**: LFP 배터리 셀 온도가 낮아지면서 전하 이동 효율이 떨어집니다.
3. **히트펌프 및 실내 난방 공조(PTC) 소비 전력**: 탑승자의 쾌적성을 위해 차량 난방에 투입된 순수 에너지가 누적되었습니다.

### 2) 도심 정체 vs 고속도로 주행 전비 비교

전체 138회 유효 주행 데이터를 주행 환경별로 클러스터링해 보면, 전기차의 독특한 특성이 드러납니다:

- **도심 정체 구간 (평균 시속 14.4 km/h, 74회 주행)**:
  - 평균 전비: **225.6 Wh/km (약 4.43 km/kWh)**, 중앙값 201 Wh/km
  - 잦은 가감속과 신호 대기 중 공조 장치 가동 시간이 길어져 전비가 상대적으로 낮게 측정됩니다.
- **고속도로 / 자동차 전용도로 (평균 시속 50.5 km/h, 최고 122 km/h, 38회 주행)**:
  - 평균 전비: **132.9 Wh/km (약 7.52 km/kWh)**, 중앙값 132 Wh/km
  - 전체 주행거리의 약 80%(2,598 km)를 차지하며, 정속 주행과 회생제동의 조화로 **132 Wh/km라는 놀라운 고효율**을 기록했습니다.
- **전체 3,252km 가중 평균 종합 전비**:
  - **139.6 Wh/km (약 7.16 km/kWh)**
  - 환경부 공인 복합 전비인 `5.1 km/kWh` 대비 무려 **+40.4% 뛰어난 실전 전비**를 확인했습니다.

---

## 3. [충전 편] 완속(AC) vs 급속(DC) 충전 손실률의 진실

많은 오너들이 *"완속 충전이 배터리에도 좋고 전기 손실도 적다"*고 막연히 생각합니다. 하지만 27회 세션, 총 **601.15 kWh**의 실제 충전 데이터를 분석해 본 결과는 상식과 달랐습니다.

| 충전 방식 | 세션 수 | 충전기 공급 전력 | 배터리 실제 저장 전력 | **가중 효율** | **손실률 (전력 손실)** |
| :--- | :---: | :---: | :---: | :---: | :---: |
| **완속 (AC / Level 2)** | 19회 | 455.06 kWh | 426.44 kWh | **93.7%** | **6.3% 손실** (28.6 kWh) |
| **급속 (DC / Supercharger)** | 5회 | 146.09 kWh | 142.06 kWh | **97.2%** | **2.8% 손실** (4.0 kWh) |
| **종합 합계/평균** | **27회\*** | **601.15 kWh** | **568.50 kWh** | **94.6%** | **5.4% 손실** (32.7 kWh) |

*(\* 기타 미식별 세션 포함)*

### 1) 왜 급속(DC) 충전이 완속(AC)보다 손실률이 절반 이하일까?

데이터상으로 **급속(DC) 충전 손실률은 2.8%에** 불과한 반면, **완속(AC) 충전 손실률은 6.3%로** 2배 이상 높았습니다. 그 이유는 바로 **차량 내부 On-Board Charger(OBC)의** 유무에 있습니다.

```
[ 완속 (AC) 충전 경로 ]
벽면 220V 교류(AC) ──▶ [차량 내 탑재 OBC (교류 ➔ 직류 변환)] ──▶ 고전압 배터리
                         └─ 열 발생 및 전력 변환 손실 발생!
                         └─ 긴 충전 시간(12시간) 동안 차량 컴퓨터/냉각펌프 지속 전력 소모!

[ 급속 (DC) 충전 경로 ]
충전기 자체 대형 인버터 ──(고전압 직류 DC 직결)──▶ 고전압 배터리
                         └─ OBC를 우회하여 직류 직접 주입!
                         └─ 짧은 충전 시간(30분)으로 베이스라인 소모 극소화!
```

### 2) 완속 충전에서도 '충전기 출력(kW)'이 효율을 좌우한다

완속 충전 19건의 세부 세션을 뜯어보자 더 흥미로운 패턴이 드러났습니다:
- **2.5 ~ 3.1 kW 저속 완속 (콘센트형 충전기)**:
  - 12 ~ 13시간 동안 길게 충전되는 세션들의 효율은 **90% ~ 92% (손실률 8 ~ 10%)로** 떨어졌습니다. 충전기 출력이 약하면 충전기 효율 자체도 떨어지고, 배터리를 충전하는 반나절 내내 차량의 제어 컴퓨터(BMS, 게이트웨이)가 켜져 있어 기저 전력을 갉아먹기 때문입니다.
- **6.3 ~ 6.7 kW 표준 완속 (월박스 충전기)**:
  - 충전 시간이 4 ~ 5시간으로 단축되면서 효율이 **94% ~ 99%까지** 치솟았습니다.

> **💡 데이터 기반 충전 팁**:  
> 집이나 회사에서 완속 충전을 할 때도, 누진세나 시간 제약이 허용된다면 3kW 미만 저출력보다는 **6 ~ 7 kW 완속으로 빠르게 충전하고 차를 슬립 모드로 보내는 것이 전력 손실을 3 ~ 5% 아끼는 가장 현명한 방법**입니다.

---

## 4. [주차 편] 유령 방전(Vampire Drain)의 민낯: 센트리 모드 vs 딥슬립

주차장에 세워둔 테슬라의 배터리가 소리 없이 사라지는 현상, 소위 **유령 방전(Vampire Drain)**. 40회의 장기 주차 세션(총 750시간 이상)을 분석하여 '센트리 모드(Sentry Mode)'와 '딥슬립(Deep Sleep)'의 실제 전력 소모량을 정밀 측정했습니다.

| 주차 상태 구분 | 세션 수 | 평균 소비 전력 | 중앙값 (Median) | 시간당 주행거리 손실 | 비고 |
| :--- | :---: | :---: | :---: | :---: | :--- |
| **Awake (센트리 모드 활성)** | 17회 | **302.3 W** | **239.9 W** | **1.69 km / hour** | 상시 영상 감시 및 컴퓨터 가동 |
| **Deep Sleep (수면 모드)** | 23회 | **19.2 W** | **8.2 W** (최소 4.8W) | **0.05 ~ 0.08 km / hour** | 극저전력 대기 모드 |

```
[ 센트리 모드 vs 딥슬립 시간당 소비 전력 격차 ]

센트리 모드 (Awake) : ████████████████████████████ 240 W
딥슬립 (Deep Sleep) : █ 8.2 W (약 1/30 수준!)
```

### 1) 센트리 모드 On: 시간당 240W, 하루 5.8 kWh가 증발한다

- 센트리 모드가 켜져 있는 동안 차량의 소비 전력은 놀라울 정도로 정밀하게 **239.9W ~ 240.8W**를 유지했습니다. 차량 주변의 카메라 4대와 FSD 오토파일럿 컴퓨터가 상시 영상 인식 딥러닝 모델을 돌리고 있기 때문입니다.
- **20시간 주차 실측 데이터**:
  - `2026. 10. 23. 20시간 주차`: 배터리 **-7% (4.76 kWh 소실, 주행거리 33.5 km 증발)**
  - 환산하면 **하루(24시간) 주차 시 약 5.76 kWh(전체 배터리의 약 9.4%)가** 감시 비용으로 사라집니다.

### 2) 딥슬립(Deep Sleep) 진입 시: 유령 방전은 사실상 '0'에 수렴한다

반면 차량이 딥슬립에 빠지면 상황은 완전히 달라집니다:
- **3.7일 (88.8시간) 연속 수면 실측**:
  - 소모 전력량: **고작 638.9 Wh (0.64 kWh)**
  - 주행거리 손실: **단 4.5 km** (시간당 약 50m)
  - 평균 소비 전력: **7.3 W**!
- **1.3일 (31시간) 연속 수면 실측**:
  - 소모 전력량: **180 Wh**, 평균 소비 전력: **5.9 W**!

> **💡 데이터 기반 결론**:  
> 테슬라의 슬립 알고리즘은 극도로 훌륭합니다. 서드파티 앱의 불필요한 폴링을 차단하고 홈 NAS의 TeslaMate처럼 '수면을 방해하지 않는 로거'를 사용한다면, **딥슬립 상태의 테슬라는 가정용 LED 전구 1개 수준(8W)으로 잠들어 유령 방전 걱정 없이 며칠을 방치해도 안전**합니다. 반면 폐쇄회로(CCTV)가 잘 갖춰진 안전한 지하주차장이라면 센트리 모드를 끄는 것만으로도 연간 수십만 원의 전기 요금을 아낄 수 있습니다.

---

## 5. [수명 편] 9,622km 주행 후 LFP 배터리 열화도: 0.6%의 경이로움

마지막으로 가장 중요한 배터리 건강 상태(Battery Health) 대시보드를 열어보았습니다. 

<Image
  src="/images/blog/teslamate-battery-health-dashboard.jpg"
  alt="TeslaMate Battery Health Dashboard - Model Y SR LFP 9622km Degradation 0.6%"
  width={1200}
  height={675}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

현재 제 차는 누적 적산계 **9,622 km**를 돌파한 시점입니다:

| 지표 항목 | 신차 출고 기준 (New) | 현재 실측치 (Odometer 9,622km) | 변화량 (Delta) |
| :--- | :--- | :--- | :--- |
| **배터리 가용 용량 (Usable Capacity)** | 61.7 kWh | 61.3 kWh | **-377.7 Wh (-0.38 kWh)** |
| **완충 시 표준 주행거리 (Max Range)** | 434.2 km | 430.8 km | **-3.4 km (-0.78%)** |
| **배터리 건강 상태 (Battery Health)** | 100.0 % | **99.4 %** | 잔존 수명 99.4% 유지 |
| **추정 배터리 열화율 (Degradation)** | 0.0 % | **0.6 %** | **누적 9,622km 주행 후 단 0.6% 열화** |

### LFP(인산철) 배터리의 캘리브레이션 출렁임 특성

위의 **'Battery Capacity by Mileage'** 그래프를 유심히 살펴보면 흥미로운 패턴이 보입니다. 용량이 61.7 kWh에서 일직선으로 떨어지는 것이 아니라, **61.2 kWh ~ 61.6 kWh 사이를 오르내리며 물결치는 현상**이 나타납니다.

이는 LFP 배터리의 전형적인 물리적 특성입니다. 삼원계(NCM) 배터리와 달리 LFP 배터리는 방전 구간의 전압 곡선이 매우 평탄(Flat)하여, 차량의 BMS(배터리 관리 시스템)가 전압만으로는 잔여 용량을 정확히 계산하기 어렵습니다. 

따라서 주기적으로 100% 완충을 거치면서 상단 전압 스파이크를 감지해야 BMS가 용량을 재보정(Calibration)하게 되며, 이 과정에서 용량 수치가 오르내리는 정상적인 보정 사이클을 확인할 수 있습니다.

약 1만 km에 육박하는 실전 주행 후에도 **열화율 0.6%, 주행거리 손실 3.4km**라는 수치는, LFP 배터리가 일상 영역에서 얼마나 믿음직한 내구성을 보여주는지를 숫자로 증명해 줍니다.

---

## 6. 에필로그: 데이터를 소유할 때 비로소 보이는 것들

내 차의 텔레메트리를 직접 수집하고 가공해 보면서 느낀 가장 큰 깨달음은, **"내가 직접 측정한 데이터만이 불필요한 불안과 오해를 잠재울 수 있다"는** 사실이었습니다.

- 겨울철 15%의 전비 감소는 막연한 공포가 아닌, 충분히 계산하고 계획할 수 있는 물리적 수치였습니다.
- 완속 충전의 6% 손실과 3kW 충전기의 저효율을 알게 되면서, 더 알뜰하고 최적화된 충전 습관을 설계할 수 있었습니다.
- 센트리 모드의 240W와 딥슬립의 8W를 눈으로 직접 확인하면서, 필요할 때만 켜고 끌 수 있는 명확한 기준을 세웠습니다.

자동차가 바퀴 달린 컴퓨터가 된 시대, 데이터 주권을 지키고 내 시스템을 직접 디버깅해 나가는 여정은 언제나 지적인 즐거움을 줍니다. 홈 서버나 NAS를 보유한 전기차 오너라면, 지금 바로 텔레메트리 데이터 속에 숨겨진 여러분만의 이야기를 발굴해 보시길 권합니다.
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Next.js 15 + Cloudflare Pages 블로그 구글 노출 0건과 애드센스 'ads.txt 찾을 수 없음' 완벽 해결기]]></title>
        <id>https://dbgmy.life/blog/nextjs-cloudflare-pages-seo-adsense-troubleshooting</id>
        <link href="https://dbgmy.life/blog/nextjs-cloudflare-pages-seo-adsense-troubleshooting"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/cloudflare-seo-adsense-troubleshooting.png" type="image/png"/>
        <updated>2026-10-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[분명 브라우저에서는 잘 열리는 ads.txt를 왜 구글 애드센스 봇만 못 읽었을까? Cloudflare GraphQL 방화벽 로그 분석으로 찾아낸 Bot Fight Mode의 함정과 Next.js 정적 블로그의 5가지 기술적 SEO·애드센스 최적화 트러블슈팅 기록.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **"브라우저에선 200 OK인데, 왜 구글 애드센스만 `ads.txt`를 못 찾을까?"**

Next.js 15(`output: 'export'`)로 블로그를 빌드해 Cloudflare Pages에 올리고 한/영 다국어 포스팅을 꾸준히 발행하던 중, 두 가지 기묘한 현상을 마주했습니다.

1. **Cloudflare 대시보드 상의 트래픽 착시**: 월간 총 HTTP 요청(Requests)은 11만 건이 넘고 일평균 고유 방문자(Uniques)도 260명 이상 찍히는데, 정작 블로그가 체감상 너무 조용했습니다.
2. **구글 애드센스(AdSense)의 무한 `ads.txt 찾을 수 없음`**: 분명 루트 경로(`https://dbgmy.life/ads.txt`)에 파일을 올려두었고 브라우저나 `curl`로 접속하면 `HTTP 200`과 함께 정상 텍스트가 뜨는데, 애드센스 관리 화면에서는 2주 넘게 **`Ads.txt 상태: 찾을 수 없음`**에서 요지부동이었습니다.

단순히 "시간이 지나면 해결되겠지"라고 넘기는 대신, **Cloudflare GraphQL Analytics API**와 **방화벽 이벤트 로그(`firewallEventsAdaptive`)**, 그리고 **Next.js 정적 빌드 결과물(`out/`)**을 하나씩 뜯어보며 원인을 추적했습니다. 그 과정에서 발견한 충격적인 범인들과 해결 과정을 공유합니다.

<Image
  src="/images/blog/cloudflare-seo-adsense-troubleshooting.png"
  alt="Cloudflare WAF and Next.js 15 SEO & AdSense Troubleshooting"
  width={800}
  height={450}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

---

## **1. 한눈에 보는 원인 진단 및 해결 요약**

트러블슈팅을 통해 밝혀낸 **유입·승인 누수의 5대 핵심 원인**과 해결책입니다.

| 순위 | 문제 현상 | 근본 원인 (Root Cause) | 해결 방법 (Action Taken) |
| :---: | :--- | :--- | :--- |
| **1** | **애드센스 `ads.txt 찾을 수 없음` 지속** | Cloudflare **`Bot Fight Mode`**와 **`AI 봇 차단`**이 구글 애드센스 봇(`GoogleAdSenseInfeed`)에게 캡차(`managed_challenge`)를 띄우고 `GoogleOther`를 `403 block` 처리함 | • 무료 플랜 `Bot Fight Mode` 비활성화<br/>• `AI 봇 정책 구성`에서 학습/크롤러 허용 및 `robots.txt` 강제 주입 해제<br/>• `/ads.txt` 및 Google ASN(`15169`) WAF `Skip` 규칙 추가 |
| **2** | **구글 검색 유입 0건 & 도메인 점수 분산** | `dbgmy.life`와 `www.dbgmy.life`가 리다이렉트 없이 둘 다 `HTTP 200`으로 열리고, 애드센스 등록 주소(`non-www`)와 코드의 `canonical` 주소(`www`)가 불일치함 | • `siteConfig.url` 및 Cloudflare 환경변수를 `https://dbgmy.life`로 단일화<br/>• `www.dbgmy.life/*` → `https://dbgmy.life/:splat` `301` 리다이렉트 설정 |
| **3** | **애드센스 '가치 없는 인벤토리(Thin Content)' 위험** | 본문 글은 16개뿐인데 태그 목록 페이지(`/tags/[tag]`)가 90개 넘게 생성되어, 전체 사이트의 80% 이상이 '글 1개짜리 빈약한 페이지'로 크롤링됨 | • `/tags/*`, `/en/tags/*`, `/blog/search` 페이지에 `robots: { index: false, follow: true }` 적용<br/>• `sitemap.xml`을 130개에서 핵심 30개 페이지로 압축 |
| **4** | **검색봇의 구조화 데이터(JSON-LD) 누락** | `JsonLdSchema` 컴포넌트가 `"use client"` + `strategy="afterInteractive"`로 작성되어 초기 정적 HTML에 스키마가 존재하지 않음 | • `"use client"` 제거 후 서버 컴포넌트에서 순수 `<script type="application/ld+json">` 정적 렌더링으로 전환 |
| **5** | **자동 광고(Auto Ads)로 인한 블로그 레이아웃 파괴** | 애드센스 자동 광고 활성화 시 상하단 고정 배너와 코드 블록 중간 광고가 무작위로 삽입되어 가독성 저해 | • 자동 광고 OFF 후 본문 최하단 전용 `AdSenseUnit` 컴포넌트 구현 (`unfilled` 시 자동 숨김) |

---

## **2. 범인 검거 ①: Cloudflare GraphQL API로 밝혀낸 트래픽의 진실**

가장 먼저 확인한 것은 *"과연 내 블로그에 실제 사람이 검색엔진을 통해 들어오고 있는가?"*였습니다.
Cloudflare 대시보드의 기본 트래픽 그래프(Edge HTTP Requests)는 검색봇, 보안 스캐너, RSS 리더의 요청까지 모두 포함합니다. 진짜 브라우저를 띄워 접속한 사람만 발라내려면 **Edge HTTP 통계(`httpRequests1dGroups`)**와 **실제 사용자 비콘 통계(`rumPageloadEventsAdaptiveGroups`)**를 대조해 봐야 합니다.

Cloudflare GraphQL API에 직접 쿼리를 날려 최근 30일 데이터를 뽑아보았습니다:

```javascript
// Cloudflare GraphQL API: Edge HTTP 트래픽 vs RUM(실제 브라우저 방문자) 비교 쿼리
const query = `
  query {
    viewer {
      zones(filter: { zoneTag: "<ZONE_ID>" }) {
        httpRequests1dGroups(limit: 30, filter: { date_geq: "2026-09-22" }) {
          dimensions { date }
          sum { requests pageViews }
          uniq { uniques }
        }
      }
      accounts(filter: { accountTag: "<ACCOUNT_ID>" }) {
        rumPageloadEventsAdaptiveGroups(limit: 50, filter: { datetime_geq: "2026-09-22T00:00:00Z" }) {
          count
          dimensions { requestHost requestPath refererHost countryName }
        }
      }
    }
  }
`;
```

결과는 충격적이었습니다:

- **Edge HTTP 총 요청 (30일)**: `111,144건` (페이지뷰 `7,045건`, 고유 IP `8,152개`)
- **RUM 실제 브라우저 방문자 (30일)**: 외부 검색엔진(`google.com`, `naver.com`) 유입 **`0건`**!
- 게다가 RUM 데이터의 `requestHost`를 보니 방문자 트래픽이 **`www.dbgmy.life`와 `dbgmy.life` 두 곳으로 정확히 쪼개져 있었습니다.**

### 🔍 원인: `www`와 `non-www`의 이중 생존과 Canonical 불일치
터미널에서 `curl -I https://dbgmy.life`와 `curl -I https://www.dbgmy.life`를 실행해 보니, 두 도메인 모두 `301 Redirect` 없이 각각 `HTTP/2 200`을 반환하고 있었습니다.
심지어 구글 애드센스와 서치콘솔에는 `dbgmy.life`(`www` 없음)로 등록해 두었는데, 정작 Cloudflare Pages의 프로덕션 환경변수(`NEXT_PUBLIC_SITE_URL`)는 `https://www.dbgmy.life`로 설정되어 있었습니다.

즉, 구글봇이 `https://dbgmy.life`를 크롤링하러 들어오면 모든 페이지의 `<link rel="canonical">`과 `robots.txt`가 아래와 같이 말하고 있었던 것입니다:

```text
# 기존 https://dbgmy.life/robots.txt 출력값
Host: https://www.dbgmy.life
Sitemap: https://www.dbgmy.life/sitemap.xml
```

구글봇 입장에서는 *"아, `dbgmy.life`는 원본 사이트가 아니라 `www.dbgmy.life`의 복제본이구나"*라고 판단해 색인을 보류하고 있었던 셈입니다. 즉시 환경변수와 `siteConfig.url`을 `https://dbgmy.life`로 통일하고, `public/_redirects`에 `301` 리다이렉트 규칙을 추가해 도메인 파편화를 해결했습니다.

---

## **3. 범인 검거 ②: 애드센스 봇에게 캡차(CAPTCHA)를 들이민 Cloudflare 방화벽**

도메인을 통일하고 구글 서치콘솔에 `sitemap.xml`을 제출하자마자 사이트맵은 즉시 **`성공`**이 떴습니다. 그런데 애드센스의 **`Ads.txt 상태`**는 여전히 **`찾을 수 없음`**이었습니다.

*"내 브라우저에서는 `https://dbgmy.life/ads.txt`가 0.1초 만에 열리는데, 왜 구글 애드센스 서버만 못 읽어갈까?"*

혹시 Cloudflare 앞단에서 구글 봇을 막고 있는 건 아닌지 확인하기 위해, Cloudflare GraphQL API의 **`firewallEventsAdaptive` (방화벽 차단/챌린지 이벤트 로그)**를 조회해 보았습니다. 그리고 그곳에서 결정적인 **현행범(Smoking Gun)**을 포착했습니다:

```json
{
  "firewallEventsAdaptive": [
    {
      "action": "managed_challenge",
      "clientAsn": "15169",
      "clientCountryName": "US",
      "clientIP": "66.249.80.39",
      "clientRequestHTTPHost": "dbgmy.life",
      "clientRequestPath": "/",
      "datetime": "2026-10-22T14:46:24Z",
      "source": "botFight",
      "userAgent": "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) ... (KHTML, like Gecko; GoogleAdSenseInfeed) ..."
    },
    {
      "action": "block",
      "clientAsn": "15169",
      "clientCountryName": "US",
      "clientIP": "66.249.64.33",
      "clientRequestHTTPHost": "dbgmy.life",
      "clientRequestPath": "/blog/homeassistant-wallpad-rs485-bento-ui",
      "datetime": "2026-10-22T10:23:34Z",
      "source": "firewallManaged",
      "userAgent": "Mozilla/5.0 ... (compatible; GoogleOther)"
    }
  ]
}
```

로그를 보는 순간 무릎을 쳤습니다.
1. 구글의 공식 네트워크(`ASN 15169`, `66.249.80.39`)에서 **구글 애드센스 봇(`GoogleAdSenseInfeed`)**이 내 블로그를 확인하러 접속하자마자, Cloudflare의 **`Bot Fight Mode`(`source: "botFight"`)가 이를 악성 봇으로 오판해 `managed_challenge` (사람인지 증명하라는 캡차 화면)를 띄워 막아버리고 있었습니다!**
2. 설상가상으로 Cloudflare의 **`AI 봇 정책 구성`(`source: "firewallManaged"`)**에서 '학습용 봇 차단'이 켜져 있다 보니, 구글의 보조 크롤러인 **`GoogleOther`(`66.249.64.33`)는 아예 `block`(`HTTP 403 Forbidden`)**을 맞고 쫓겨나고 있었습니다.

크롤러 봇은 당연히 Turnstile 캡차를 풀 수 없으니, 애드센스 서버 입장에서는 `dbgmy.life`가 접속 불가 상태이거나 `ads.txt`가 없는 것으로 보였던 것입니다.

---

## **4. Cloudflare WAF 설정 시 반드시 피해야 할 2가지 함정**

이 문제를 해결하면서 겪은 Cloudflare 설정상의 중요한 함정 두 가지를 정리합니다.

### 함정 ①: "WAF `Skip` 규칙을 만들었으니 무료 `Bot Fight Mode`는 다시 켜도 되겠지?" (❌ 절대 안 됨)
많은 분들이 `/ads.txt`나 구글 ASN(`15169`)에 대해 WAF 사용자 지정 규칙(Custom Rule)으로 **`건너뛰기(Skip)`**를 설정해 두면 `Bot Fight Mode`를 켜두어도 우회될 것이라 생각합니다.

하지만 **Cloudflare 무료(Free) 플랜의 일반 `Bot Fight Mode`는 WAF Custom Rule의 `Skip`으로 절대 우회되지 않습니다.** (Skip 옵션에 있는 체크박스는 유료 Pro 플랜 이상의 *Super Bot Fight Mode*만 건너뜁니다.)
게다가 Next.js 정적 내보내기(`output: 'export'`)로 만든 블로그는 모든 파일이 Cloudflare Edge CDN에서 바로 서빙되는 정적 HTML이므로, 봇이 아무리 많이 들어와도 DB가 죽거나 서버 과금이 발생할 일이 없습니다. 따라서 정적 블로그에서는 **`Bot Fight Mode`를 과감히 OFF**로 꺼두는 것이 정답입니다.

### 함정 ②: WAF `Skip` 규칙 저장 시 `skip action parameters 'phase' and 'ruleset' cannot be used together` 에러
WAF에서 `/ads.txt`와 Google ASN(`15169`)을 무조건 통과시키기 위해 `Skip(건너뛰기)` 규칙을 만들고 모든 체크박스를 다 켜면 아래와 같은 유효성 검사 에러가 발생합니다:

```text
규칙 유효성 검사 실패:
skip action parameters 'phase' and 'ruleset' cannot be used together
```

이는 Cloudflare 대시보드 UI에 신버전 옵션과 구버전 옵션이 동시에 노출되어 발생하는 충돌입니다. 아래 3가지 중복 체크박스만 해제하면 에러 없이 깔끔하게 배포됩니다:
- ❌ `All remaining custom rules in this phase` 해제 (`나머지 모든 사용자 지정 규칙`과 중복)
- ❌ `속도 제한 규칙(이전 버전)` 해제 (`모든 속도 제한 규칙`과 중복)
- ❌ `관리 규칙(이전 버전)` 해제 (`모든 관리 규칙`과 중복)

설정을 마친 후 터미널에서 직접 구글 애드센스 봇의 User-Agent로 위장해 테스트해 보니, 드디어 모든 경로에서 깔끔한 `HTTP 200`이 떨어졌습니다:

```bash
$ curl -s -o /dev/null -w "ads.txt: %{http_code} | Home: %{http_code}\n" \
  -A "Mediapartners-Google" https://dbgmy.life/ads.txt https://dbgmy.life/
ads.txt: 200 | Home: 200
```

---

## **5. Next.js 15 정적 블로그의 SEO & 애드센스 코드 레벨 패치 3가지**

인프라 방화벽 문제를 해결한 뒤, Next.js 코드베이스 내부에서 검색 노출과 애드센스 심사를 방해하던 구조적 문제 3가지도 함께 수술했습니다.

### ① '16개 본문 vs 90개 빈 태그 페이지' 역전 현상 해결 (`noindex, follow`)
Next.js 빌드를 돌릴 때 `Generating static pages (130/130)`이 찍히는 것을 보고 아차 싶었습니다.
작성한 글은 한/영 합쳐서 16편뿐인데, 각 글에 달린 태그마다 `/tags/[tag]`, `/en/tags/[tag]` 정적 페이지가 생성되면서 **"글이 단 1개만 걸려 있는 텅 빈 목록 페이지"가 90개 넘게 만들어져 있었던 것**입니다.

구글 애드센스 심사 봇이 사이트에 들어왔을 때 본문(16개)보다 내용이 빈약한 태그 목록(90여 개)을 6배나 더 많이 크롤링하면, 높은 확률로 **'가치 없는 인벤토리(Thin Content)'** 거절 판정을 받습니다.
이를 막기 위해 태그 및 검색 결과 페이지의 `metadata`에 `noindex, follow`를 명시하고 `sitemap.ts`에서도 제거했습니다:

```typescript
// src/app/tags/[tag]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { tag } = await params;
  const decodedTag = decodeURIComponent(tag).replace(/-/g, " ");

  return {
    title: `#${decodedTag} - Debugging My Life`,
    robots: {
      index: false, // 검색 색인 및 애드센스 Thin Content 심사 대상에서 제외
      follow: true, // 내부 본문 링크는 정상 추적
    },
  };
}
```

이 패치 직후 구글 서치콘솔에 제출된 `sitemap.xml`의 페이지 수는 130여 개에서 **알짜배기 본문·핵심 페이지 30개**로 정제되었습니다.

### ② `JsonLdSchema`를 클라이언트(`afterInteractive`)에서 서버 정적 렌더링으로 전환
기존에는 블로그의 `BlogPosting` JSON-LD 스키마 컴포넌트가 `"use client"`와 `next/script`의 `strategy="afterInteractive"`로 구현되어 있었습니다.
이렇게 하면 정적 빌드된 HTML 파일(`out/blog/*.html`)에는 구조화 데이터가 전혀 들어있지 않고, 브라우저에서 JS가 실행된 뒤에야 주입됩니다. 즉시 `"use client"`를 걷어내고 서버 컴포넌트에서 정적 HTML에 직접 `<script type="application/ld+json">`을 렌더링하도록 변경했습니다.

### ③ 디자인을 망치는 '자동 광고' 대신 깔끔한 하단 전용 `AdSenseUnit` 컴포넌트 구현
애드센스의 '자동 광고(Auto ads)' 스위치를 켜봤더니, 다크모드로 공들여 꾸민 블로그 상단과 코드 블록 한가운데에 거대한 배너가 마구잡이로 박히며 디자인을 완전히 망쳐놓았습니다.

자동 광고를 즉시 끄고, **오직 글을 끝까지 다 읽은 최하단(태그/공유 버튼 아래, 댓글 위)에만 자연스럽게 녹아드는 인아티클(In-article) 광고 컴포넌트**를 직접 만들었습니다. 특히 광고가 채워지지 않았을 때(`data-ad-status="unfilled"`) 빈 여백이 남지 않도록 CSS로 자동 숨김 처리를 추가했습니다:

```tsx
// src/components/blog/AdSenseUnit.tsx
"use client";

import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";

export function AdSenseUnit({ slot }: { slot: string }) {
  const pathname = usePathname();
  const adRef = useRef<HTMLModElement>(null);

  useEffect(() => {
    const el = adRef.current;
    if (!el || el.getAttribute("data-adsbygoogle-status")) return;
    try {
      (window.adsbygoogle = window.adsbygoogle || []).push({});
    } catch (err) {
      console.error("AdSense push error:", err);
    }
  }, [pathname]);

  return (
    <div className="my-6 sm:my-8 w-full overflow-hidden rounded-lg border border-border/30 bg-card/30 px-2 py-2 empty:hidden has-[ins[data-ad-status=unfilled]]:hidden">
      <div className="mb-1 text-center font-mono text-[10px] tracking-widest text-muted-foreground/50 uppercase">
        Sponsored
      </div>
      <ins
        ref={adRef}
        className="adsbygoogle"
        style={{ display: "block", textAlign: "center" }}
        data-ad-layout="in-article"
        data-ad-format="fluid"
        data-ad-client="ca-pub-XXXXXXXXXXXXXXXX"
        data-ad-slot={slot}
      />
    </div>
  );
}
```

---

## **마치며: "감(Feeling)이 아니라 로그(Log)로 디버깅하라"**

이번 트러블슈팅을 통해 다시 한번 깨달은 점은, 웹 서비스의 SEO와 수익화 역시 막연한 카더라 통신이 아니라 **데이터와 로그로 접근해야 정확한 원인이 보인다**는 사실이었습니다.

만약 Cloudflare의 방화벽 이벤트 로그(`firewallEventsAdaptive`)를 직접 조회해 보지 않았다면, Cloudflare의 `Bot Fight Mode`가 구글 애드센스 심사 봇에게 캡차를 들이밀고 있었다는 사실을 꿈에도 모른 채 수개월 동안 *"왜 `ads.txt`를 못 찾지?"*라며 애만 태웠을 것입니다.

혹시 Next.js와 Cloudflare Pages 조합으로 기술 블로그를 운영하면서 검색 유입이 안 잡히거나 애드센스 `ads.txt` 확인이 지연되고 있다면, 지금 바로 Cloudflare의 **보안(Security) → 이벤트 로그**와 **`Bot Fight Mode` 설정**부터 점검해 보시기 바랍니다!
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[PHP2Shell(CVE-2026-109618·109619) 심층 분석: 패킷 구조부터 WAF·Suricata 탐지 룰까지]]></title>
        <id>https://dbgmy.life/blog/php2shell-cve-2026-109618-109619-analysis-detection</id>
        <link href="https://dbgmy.life/blog/php2shell-cve-2026-109618-109619-analysis-detection"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/php2shell-cve-analysis.png" type="image/png"/>
        <updated>2026-10-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[애플리케이션 코드가 실행되기도 전에 PHP 코어 엔진을 장악하는 Pre-Auth RCE 취약점 'PHP2Shell'의 메모리 오염 메커니즘과 실제 공격 패킷 패턴, 그리고 실무 보안 관제를 위한 Snort/Suricata·ModSecurity·로그 탐지 전략.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **프롤로그: 애플리케이션 코드가 실행조차 되기 전에 뚫린다**

<Image
  src="/images/blog/php2shell-cve-analysis.png"
  alt="PHP2Shell CVE-2026-109618 CVE-2026-109619 Packet Analysis and Detection"
  width={1200}
  height={675}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
  priority
/>

2026년 10월 20일, 정규 패치 일정을 앞두고 치명적인 취약점의 세부 메커니즘과 PoC(개념 증명) 코드가 온라인에 조기 유출되면서 전 세계 웹 생태계에 비상이 걸렸습니다. 바로 다음 날인 10월 21일 PHP 개발팀이 긴급 패치를 배포하며 공개된 **‘PHP2Shell’ (`CVE-2026-109618`, `CVE-2026-109619`)** 취약점입니다.

이번 취약점이 전 세계 인프라와 보안 현장을 긴장시킨 이유는 명확합니다.

1. **사전 인증 불필요 (Unauthenticated Pre-Auth RCE)**: 로그인 세션이나 관리자 권한 없이, 외부에서 조작된 HTTP 요청만으로 원격 코드 실행이 가능합니다 (`CVSS v4.0 9.2 Critical`).
2. **Pre-Script 단계의 엔진 결함**: 워드프레스(WordPress), 라라벨(Laravel), 드루팔(Drupal) 같은 **애플리케이션의 `.php` 코드가 단 한 줄도 실행되기 전**, PHP 코어 엔진이 HTTP 요청 본문을 파싱하는 단계에서 터집니다. 즉, 소스코드 레벨의 시큐어코딩이나 인증 미들웨어로는 방어가 불가능합니다.

이번 글에서는 **PHP2Shell이 내부 메모리를 어떻게 오염시키는지(Root Cause)**, **외부에서 유입되는 실제 공격 패킷은 어떤 형태를 띠고 있는지**, 그리고 **네트워크(IDS/IPS·WAF)와 호스트 로그에서 어떤 키워드와 시그니처로 공격성 트래픽을 탐지·차단해야 하는지** 실무 관점에서 정리해 봅니다.

---

## **1. 취약점 메커니즘 해부: 왜 두 개의 CVE가 사슬처럼 묶여 있는가?**

`PHP2Shell`은 단일 버그가 아니라, PHP 코어의 요청 파싱부와 에러 복구 루틴에 존재하는 두 가지 결함(**`CVE-2026-109618`**, **`CVE-2026-109619`**)이 연쇄 작용하여 완성되는 익스플로잇 체인입니다.

```mermaid
sequenceDiagram
    autonumber
    actor Attacker as 공격자 (Unauthenticated)
    participant Worker as PHP 워커 프로세스<br/>(PHP-FPM / mod_php)
    participant RFC1867 as RFC 1867 파서<br/>(session.upload_progress)
    participant Zend as Zend Engine<br/>(zend_bailout / longjmp)
    participant Script as 웹 애플리케이션 (.php)

    Note over Attacker,Script: [Phase 1] 공격 패킷 #1: OOM 유도 및 전역 포인터 오염
    Attacker->>Worker: POST /index.php (multipart/form-data + PHP_SESSION_UPLOAD_PROGRESS)
    Worker->>RFC1867: 업로드 진행률 구조체 초기화 시작
    Note right of RFC1867: ⚠️ 하위 포인터 할당 완료 전<br/>활성화 플래그 먼저 세팅
    Attacker->>RFC1867: 대용량 필드/배열로 memory_limit(OOM) 강제 도달
    RFC1867->>Zend: 치명적 에러 -> zend_bailout() (C longjmp 호출)
    Zend->>RFC1867: 비정상 종료 클린업(Cleanup) 수행
    Note right of Zend: 💥 CVE-2026-109618: 미초기화 포인터를 유효 구조체로 오인<br/>💥 CVE-2026-109619: 해제된 힙을 가리키는 전역 포인터(Dangling Pointer) 잔류

    Note over Attacker,Script: [Phase 2] 공격 패킷 #2: Cross-Request UAF 및 원격 코드 실행
    Attacker->>Worker: 후속 요청 전송 (동일 워커 프로세스 히트 & Heap Spraying)
    Worker->>Zend: 잔류 전역 포인터 역참조 (Use-After-Free)
    Zend-->>Attacker: 🚨 변조된 zend_object_handlers 호출 ➔ RCE 쉘 획득
```

### **1) `CVE-2026-109618`: OOM(`longjmp`) 시점의 미초기화 메모리 손상**
- **핵심 모듈**: `main/rfc1867.c` (멀티파트 파일 업로드 파서) 및 `ext/session/` 내 `session.upload_progress` 추적 로직.
- **발생 원리**: PHP는 `multipart/form-data` 요청을 받으면 `.php` 스크립트를 실행하기 전에 먼저 파일 업로드와 세션 진행률(`PHP_SESSION_UPLOAD_PROGRESS`)을 파싱합니다. 이때 내부 추적 구조체를 초기화하는 과정에서, **하위 버퍼 메모리 할당(`emalloc`)이 모두 완료되기도 전에 구조체 상태 플래그를 먼저 '활성화(Ready)'로 표시하는 경합 창(Race Window)**이 존재합니다.
- **공격자의 노림수**: 공격자가 정밀하게 계산된 크기의 멀티파트 데이터를 밀어 넣어 정확히 이 시점에 메모리 한도(`memory_limit`) 초과(Out-of-Memory)를 발생시키면, PHP 엔진은 `zend_bailout()`(C 표준 `longjmp()`)을 호출해 실행을 강제 중단합니다. 이때 실행되는 클린업 루틴이 아직 초기화되지 않은 쓰레기 메모리 영역을 '정상 구조체'로 착각하고 건드리면서 **메모리 오염(Memory Corruption)**이 일어납니다.

### **2) `CVE-2026-109619`: 워커 프로세스 내 잔류 전역 상태(Use-After-Free)**
- **핵심 모듈**: SAPI 요청 종료(`RSHUTDOWN`) 및 워커 프로세스 전역 상태 관리 루틴.
- **발생 원리**: `PHP-FPM`이나 Apache `mod_php` 환경에서는 하나의 워커 프로세스가 여러 HTTP 요청을 순차적으로 재사용하며 처리합니다. 정상적인 종료 경로에서는 요청에 쓰인 힙 메모리를 해제한 뒤 전역 포인터를 `NULL`로 초기화하지만, 앞서 언급한 비정상 에러 경로(`longjmp`)를 탈 경우 **메모리는 해제되었는데 전역 포인터는 초기화되지 않고 이전 주소를 계속 가리키는(Stale Global State / Dangling Pointer) 현상**이 남습니다.
- **공격자의 노림수**: 공격자가 곧바로 두 번째 HTTP 요청을 보내 같은 워커 프로세스에 할당된 힙 메모리를 자신이 조작한 가짜 객체(`zend_object` 및 함수 테이블)로 채우면(Heap Grooming), 엔진이 잔류 포인터를 다시 참조하는 순간 **Cross-Request Use-After-Free**가 발생하며 공격자의 쉘 명령어가 실행됩니다.

---

## **2. 실제 공격 패킷 분석: 외부에서 어떤 형태의 트래픽이 날아오는가?**

현재 야생(In-the-Wild)에서 유통 중인 PoC(`php-to-shell.py` 변종 등)와 스캐닝 봇들이 서버로 전송하는 HTTP 요청 패킷에는 매우 뚜렷한 구조적 특징이 있습니다.

### **공격 패킷 재구성 예시 (Annotated HTTP Request)**

```http
POST /index.php HTTP/1.1
Host: target-server.example.com
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)
Cookie: PHPSESSID=p2s_exploit_sess_99182a
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 134215800
Connection: keep-alive

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

RY_HEAP_SPRAY_MARKER_...
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...[수백~수천 바이트 반복]..."
Content-Type: application/octet-stream

[OOM(memory_limit) 도달을 유도하기 위한 정밀 계산된 더미 청크 및 다중 배열(t[][][]) 페이로드]
------WebKitFormBoundary7MA4YWxkTrZu0gW--
```

### **패킷에서 주목해야 할 4가지 핵심 시그니처(Indicators)**

| 탐지 포인트 | 정상 트래픽과의 차이점 및 공격 징후 |
| :--- | :--- |
| **1. `PHP_SESSION_UPLOAD_PROGRESS` 파라미터** | 대용량 파일 업로드 진행률 바를 구현하는 특정 페이지가 아님에도, 일반 엔드포인트(`/index.php`, `/wp-login.php`, `/api` 등)로 이 멀티파트 필드가 유입됨 |
| **2. `PHPSESSID` + 멀티파트 결합** | `session.upload_progress` 루틴을 강제로 활성화하기 위해 인증되지 않은 외부 IP가 임의의 `PHPSESSID` 쿠키를 달고 대용량 `multipart/form-data`를 전송함 |
| **3. 비정상적인 `filename` 길이 및 다차원 배열** | 힙 레이아웃을 정렬(Heap Grooming)하고 정확히 원하는 시점에 OOM(`memory_limit`)을 터뜨리기 위해 `filename="..."`에 수백 바이트 이상의 문자열을 넣거나 `t[][][]` 형태의 폼 필드를 수천 개 반복 전송함 |
| **4. `Keep-Alive` 연쇄 요청 및 `500` ➔ `200` 응답 패턴** | 첫 번째 요청에서 OOM을 유발해 `HTTP 500` (또는 워커 크래시로 인한 `502`)을 발생시킨 직후, **동일한 TCP 세션(`keep-alive`)으로 밀리초 단위의 후속 요청**을 쏘아 오염된 워커 프로세스를 재타격함 |

---

## **3. 탐지 및 차단 전략: 무엇을 잡고 어떻게 막을 것인가?**

패치를 적용하기 전이거나 심층 방어(Defense-in-Depth) 체계를 구축하기 위해, 네트워크 장비(IDS/IPS), 웹 방화벽(WAF), 그리고 SIEM/로그 분석 단에서 즉시 적용할 수 있는 탐지 규칙입니다.

### **1) 네트워크 레벨 탐지: Suricata / Snort IDS·IPS 룰**

외부에서 유입되는 `POST` 멀티파트 패킷 중 `PHP_SESSION_UPLOAD_PROGRESS` 키워드와 비정상적인 파일명/배열 패딩이 결합된 트래픽을 잡아내는 시그니처입니다.

```suricata
# [Rule 1] PHP_SESSION_UPLOAD_PROGRESS를 악용한 멀티파트 RCE 시도 탐지
alert http $EXTERNAL_NET any -> $HTTP_SERVERS $HTTP_PORTS (
    msg:"EXPLOIT-ATTEMPT PHP2Shell Session Upload Progress RCE Vector (CVE-2026-109618 / CVE-2026-109619)";
    flow:to_server,established;
    content:"POST"; http_method;
    content:"multipart/form-data"; http_header;
    content:"PHP_SESSION_UPLOAD_PROGRESS"; http_client_body;
    classtype:web-application-attack;
    reference:cve,2026-109618;
    reference:cve,2026-109619;
    sid:20261096180;
    rev:1;
)

# [Rule 2] 비정상적인 길이의 filename(256바이트 이상)을 동반한 Heap Grooming / OOM 유도 패킷 탐지
alert http $EXTERNAL_NET any -> $HTTP_SERVERS $HTTP_PORTS (
    msg:"EXPLOIT-ATTEMPT PHP2Shell Oversized Filename OOM Trigger (CVE-2026-109618)";
    flow:to_server,established;
    content:"POST"; http_method;
    content:"multipart/form-data"; http_header;
    content:"PHP_SESSION_UPLOAD_PROGRESS"; http_client_body;
    pcre:"/filename=[\"'][^\"']{256,}[\"']/Pi";
    classtype:web-application-attack;
    sid:20261096181;
    rev:1;
)
```

### **2) 웹 방화벽(WAF) 레벨 차단: ModSecurity / 리버스 프록시 룰**

PHP 엔진(`php-fpm` / `mod_php`)으로 요청이 넘어가기 전, 앞단의 리버스 프록시나 WAF에서 `PHP_SESSION_UPLOAD_PROGRESS` 폼 파라미터 자체를 차단하면 가장 대표적인 익스플로잇 경로를 원천 봉쇄할 수 있습니다.

```apache
# ModSecurity / OWASP CRS 커스텀 차단 룰
SecRule ARGS_POST_NAMES "@streq PHP_SESSION_UPLOAD_PROGRESS" \
    "id:2026109618,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    t:none,\
    msg:'Blocked PHP2Shell RCE Attempt via PHP_SESSION_UPLOAD_PROGRESS (CVE-2026-109618/CVE-2026-109619)'"
```

만약 별도의 WAF 없이 **Nginx 리버스 프록시**만 앞단에 두고 있다면, 공격자가 `memory_limit`(기본 `128M`)을 채우기 위해 보내는 대용량 멀티파트 바디가 PHP 워커에 도달하지 못하도록 `client_max_body_size`를 서비스에 필요한 최소치로 조여야 합니다.

```nginx
# Nginx에서 과도한 멀티파트 OOM 유발 페이로드 사전 차단
http {
    # 기본 업로드 허용 크기를 10MB 이하로 엄격히 제한 (PHP memory_limit보다 훨씬 작게 설정)
    client_max_body_size 10M;
}
```

### **3) 호스트 & SIEM 로그 기반 위협 헌팅 (Threat Hunting)**

만약 암호화된 트래픽이나 변형된 벡터로 공격이 시도되었다면, 서버 내부 로그에는 **반드시 OOM 에러와 워커 프로세스 세그멘테이션 폴트(`SIGSEGV`) 흔적**이 남습니다.

#### **점검해야 할 핵심 로그 키워드 3가지**
1. **PHP 에러 로그 (`/var/log/php*-fpm.log` 또는 `error.log`)**:
   - 키워드: `"Allowed memory size of"` 및 `"bytes exhausted"`
   - 공격자가 `CVE-2026-109618`의 `longjmp()`를 트리거하기 위해 의도적으로 OOM을 발생시킨 흔적입니다.
2. **PHP-FPM 마스터 / 커널 로그 (`/var/log/syslog`, `dmesg`)**:
   - 키워드: `"exited on signal 11 (SIGSEGV)"` 또는 `"segfault at"`
   - 힙 레이아웃 오프셋을 맞추는 과정(Brute-force / Heap Spraying)에서 워커 프로세스가 메모리 참조 오류로 죽은 결정적 증거입니다.
3. **웹 액세스 로그 (`access.log`) 상관분석**:
   - 동일 출발지 IP에서 짧은 시간(초 단위) 내에 다수의 `POST` 요청이 발생하며 응답 코드가 `500` / `502`로 찍히다가 갑자기 `200 OK`로 바뀐 세션이 있다면, **익스플로잇이 실제로 성공했을 가능성이 매우 높으므로 즉시 침해사고 대응(IR)에 돌입**해야 합니다.

```bash
# 리눅스 서버에서 즉시 실행해 볼 수 있는 침해 흔적 원라이너 점검 스크립트
grep -E "Allowed memory size of|exited on signal 11|SIGSEGV" /var/log/php*-fpm.log /var/log/syslog /var/log/nginx/error.log 2>/dev/null | tail -n 50
```

---

## **4. 근본적 해결책: 패치 버전 및 `php.ini` 하드닝**

네트워크 시그니처와 WAF 룰은 알려진 PoC(`session.upload_progress` 벡터)를 막아주는 **시간 벌기용 방패**일 뿐입니다. 엔진 코어의 `longjmp()` 클린업과 전역 상태 초기화를 수정한 공식 패치를 반드시 적용해야 합니다.

### **1) 버전별 안전 패치 기준표**

| PHP 브랜치 | 취약 버전 | 안전 패치 버전 (2026-10-21 배포) | 비고 |
| :--- | :--- | :--- | :--- |
| **PHP 8.5** | `8.5.0` ~ `8.5.11` | **`8.5.12` 이상** | 즉시 업그레이드 |
| **PHP 8.4** | `8.4.0` ~ `8.4.26` | **`8.4.27` 이상** | 즉시 업그레이드 |
| **PHP 8.3** | `8.3.0` ~ `8.3.35` | **`8.3.36` 이상** | 즉시 업그레이드 |
| **PHP 8.2** | `8.2.0` ~ `8.2.34` | **`8.2.35` 이상** | 즉시 업그레이드 |
| **PHP 8.1 이하** | 전 버전 (`5.4` ~ `8.1`) | **공식 업스트림 패치 없음 (EOL)** | OS 배포판 백포트 패치 확인 또는 `8.2+` 마이그레이션 필수 |

> [!WARNING]
> **패키지 업데이트 후 워커 프로세스 재시작 필수!**  
> `apt upgrade`나 `dnf update`로 바이너리만 교체하고 데몬을 재시작하지 않으면, 이미 메모리에 올라와 있는 구버전 `php-fpm` / `httpd` 워커 프로세스가 그대로 요청을 처리하므로 100% 취약한 상태로 남습니다. 업데이트 직후 반드시 **`systemctl restart php*-fpm`**을 수행하세요.

### **2) 당장 패치가 불가능한 서버를 위한 `php.ini` 임시 봉쇄**

레거시 의존성 때문에 오늘 당장 PHP 버전을 올릴 수 없다면, `php.ini`에서 거의 쓰이지 않는 기본 활성 기능인 **세션 업로드 진행률 추적 기능을 즉시 비활성화(`Off`)**하여 주 공격 표면을 닫아두어야 합니다.

```ini
; /etc/php/{버전}/fpm/php.ini 수정 후 php-fpm 재시작
session.upload_progress.enabled = Off
session.upload_progress.cleanup = On
```

---

# **마치며: 프레임워크 뒤에 숨은 '런타임 엔진'의 그림자**

많은 조직이 웹 애플리케이션의 SQL Injection이나 XSS, 프레임워크(Laravel, WordPress 플러그인) 취약점에는 민감하게 반응하면서도, 정작 그 바닥에서 돌아가는 **PHP 런타임 엔진 자체의 버전 관리와 메모리 안전성**에는 소홀한 경우가 많습니다.

이번 **PHP2Shell (`CVE-2026-109618` · `CVE-2026-109619`)** 사태는 애플리케이션 코드가 아무리 완벽해도, 인터프리터 엔진의 요청 파싱 단계(`Pre-Script`)가 무너지면 모든 애플리케이션 방어선이 무력화된다는 사실을 다시 한번 일깨워 주었습니다.

운영 중인 서비스 중에 외부로 열린 PHP 서버가 있다면, 지금 바로 터미널을 열어 `php -v`와 `php*-fpm.log`부터 점검해 보시길 권합니다.
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[기술은 성공했지만 프로젝트는 실패했습니다: 아내의 스마트스토어 송장 자동화(FastMCP) 삽질 회고록]]></title>
        <id>https://dbgmy.life/blog/smartstore-invoice-automation-failure-retrospective</id>
        <link href="https://dbgmy.life/blog/smartstore-invoice-automation-failure-retrospective"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/smartstore-mcp-failure.png" type="image/png"/>
        <updated>2026-10-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[맥북을 쓰는 아내를 위해 홈 NAS, FastMCP, AirPrint(IPP) 직출, 그리고 ReportLab 0.1mm 정밀 좌표계까지 동원해 무인 송장 출력 시스템을 구축했지만, 결국 레거시 생태계와 택배 단가(Unit Economics)의 벽 앞에서 접어야 했던 뼈아픈 실패 기록.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **프롤로그: "여보, 그냥 AI한테 말 한마디만 하면 송장이 드르륵 나오게 해줄게!"**

<Image
  src="/images/blog/smartstore-mcp-failure.png"
  alt="SmartStore FastMCP Invoice Automation Failure Retrospective"
  width={1200}
  height={675}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
  priority
/>

모든 비극(?)은 거실 소파에서 맥북을 붙잡고 한숨을 쉬던 아내의 한마디에서 시작되었습니다.

소규모로 네이버 스마트스토어를 운영하며 소소하게 물건을 발송하는 아내에게는 매번 주문이 들어올 때마다 겪는 두 가지 고질적인 고통이 있었습니다.

1. **강제되는 윈도우(Windows) PC 행**: 평소 모든 작업을 맥북(macOS)으로 처리하지만, 스마트스토어에서 제휴 택배(굿스플로 연동 우체국택배) 송장을 뽑으려면 무조건 서재에 있는 윈도우 PC를 켜야만 합니다.
2. **라벨지 여백과 자간 틀어짐의 저주**: 집에 있는 무한잉크 복합기에 **A4 1×3 분할 라벨지(200mm × 93mm)**를 넣고 인쇄하면, 브라우저의 기본 여백과 배율 왜곡 때문에 꼭 두 번째, 세 번째 칸부터 글씨가 칼선 밖으로 밀려나 아까운 라벨지를 버리기 일쑤였습니다.

평소 퇴근 후엔 온갖 홈랩과 사이드 프로젝트를 만지작거리는 남편으로서, 이 광경을 그냥 지나칠 수 없었습니다. 제 머릿속에는 이미 완벽하고 우아한 아키텍처가 그려지고 있었습니다.

> *"여보, 이제 컴퓨터 앞에 앉아서 마우스 클릭할 필요도 없게 해줄게. 그냥 소파에서 Gemini나 Antigravity한테 **'오늘 들어온 주문 송장 다 뽑아줘'**라고 말 한마디만 해. 그러면 우리 집 NAS가 알아서 주문을 긁어와서 프린터로 0.1mm 오차도 없이 칼같이 뽑아줄 거야!"*

결론부터 말씀드리겠습니다. **기술적인 구현은 완벽하게 성공했지만, 이 프로젝트는 처참하게 실패하여 폐기되었습니다.**

오늘 글은 화려한 성공담이 아닙니다. 기술적 호기심과 이상이 **'국내 이커머스 레거시 생태계의 벽'**과 **'비즈니스 단가(Unit Economics)의 현실'** 앞에서 어떻게 산산조각 났는지 기록한 뼈아픈 삽질 회고록입니다.

---

## **1. 기술적 돌파: 라즈베리파이도, 드라이버도 필요 없었다 (쾌속 질주 구간)**

처음 아키텍처를 구상할 때만 해도 기세가 등등했습니다. 목표는 명확했습니다. **"와이프의 사용 경험(UX)은 자연어 한마디로 단순하게, 뒤단의 모든 복잡성은 홈서버(NAS)가 흡수한다."**

### **1) 네트워크 프린터 해부와 IPP Everywhere의 발견**

처음에는 프린터를 제어하기 위해 서랍 속에 놀고 있던 라즈베리파이(Raspberry Pi)에 CUPS 프린트 서버를 띄울 생각이었습니다. 하지만 이미 집 와이파이(`192.168.1.XXX`)에 물려 있는 가정용 무한잉크 복합기의 포트를 먼저 찔러보기로 했습니다.

```bash
# 프린터의 네트워크 인쇄 표준 포트 스캔
nc -zv -w 2 192.168.1.XXX 9100 631
# Connection to 192.168.1.XXX port 9100 [tcp/hp-pdl-datastr] succeeded!
# Connection to 192.168.1.XXX port 631 [tcp/ipp] succeeded!

# IPP(Internet Printing Protocol) 상세 속성 질의
ipptool -tv ipp://192.168.1.XXX:631/ipp/print get-printer-attributes.test
```

결과는 대성공이었습니다. 프린터가 `AirPrint 2.1`과 **`IPP Everywhere`** 표준을 완벽하게 지원하고 있었습니다. 심지어 잉크 잔량(블랙 80%, 컬러 90%)까지 IPP 프로토콜로 실시간 조회가 가능했습니다.

즉, 라즈베리파이도 필요 없고 NAS에 무거운 프린터 제조사 드라이버를 설치할 필요도 없었습니다. 리눅스 컨테이너에서 표준 `lp` 명령어로 631 포트에 PDF를 그대로 쏘아주면(`Direct IPP Print`) 끝나는 구조였습니다.

### **2) 0.1mm 오차도 허용하지 않는 ReportLab 절대 좌표계 엔진**

아내가 겪던 두 번째 고통인 **'A4 1×3 분할 라벨지 밀림 현상'**의 원인도 명확했습니다. 웹 브라우저나 일반 PDF 뷰어에서 인쇄를 누르면, 브라우저가 임의로 넣는 상하좌우 기본 마진(약 12.7mm)과 `용지에 맞춤(Fit to page, 약 94~97% 축소)` 옵션이 작동하면서 93mm 간격의 칼선과 실제 인쇄 좌표가 어긋나는 것이었습니다.

이를 해결하기 위해 Python의 `ReportLab` 라이브러리를 사용해 **물리적 `mm` 단위의 절대 좌표계**로 PDF를 직접 그리는 렌더링 엔진(`printer.py`)을 작성했습니다.

- **용지 규격**: A4 (`210mm × 297mm`)
- **라벨 규격**: `200mm × 93mm` (3칸 = 총 높이 `279mm`)
- **정밀 여백**: 좌·우 여백 정확히 `5mm`, 상·하 여백 정확히 `9mm`

```python
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.graphics.barcode import code128

# 라벨 물리 규격 상수 (0.1mm 단위 정밀 매핑)
LABEL_WIDTH = 200 * mm
LABEL_HEIGHT = 93 * mm
LEFT_MARGIN = 5 * mm
BOTTOM_MARGIN = 9 * mm  # A4(297mm) - 3칸(279mm) = 상하 각 9mm

def render_slot(c: canvas.Canvas, slot: int, order: dict):
    # ReportLab의 Y축은 하단(Bottom)이 0이므로 역산
    # slot 1(상단) -> idx 2, slot 2(중단) -> idx 1, slot 3(하단) -> idx 0
    slot_idx = 3 - slot
    y_bottom = BOTTOM_MARGIN + (slot_idx * LABEL_HEIGHT)
    
    # 칼선 가이드 및 Code128 운송장 바코드 렌더링
    c.rect(LEFT_MARGIN, y_bottom, LABEL_WIDTH, LABEL_HEIGHT)
    barcode = code128.Code128(order["tracking_no"].replace("-", ""), barWidth=1.35, barHeight=35)
    barcode.drawOn(c, LEFT_MARGIN + 124 * mm, y_bottom + 58 * mm)
```

그리고 프린터로 전송할 때는 CUPS/IPP 옵션에 **`fit-to-page=false`**와 **`scaling=100`**을 강제로 박아 넣었습니다.

```python
ipp_cmd = [
    "lp", "-h", f"{self.printer_ip}:631", "-d", "ipp/print",
    "-o", "PageSize=A4",
    "-o", "fit-to-page=false",
    "-o", "scaling=100",
    pdf_path
]
```

테스트 인쇄 명령을 실행하자 서재의 프린터가 웅장하게 돌아가며 라벨지를 뱉어냈습니다. 불빛에 비춰본 결과, **상·중·하 3칸 모두 라벨지 점선 칼선에 0.1mm의 오차도 없이 정확히 박혀 있었습니다.** 심지어 라벨지를 1칸만 쓰고 2칸이 남았을 때를 대비해, `"2번째 칸에 홍길동 고객님 송장 하나만 뽑아줘"`라고 말하면 정확히 중간 칸(`slot=2`)에만 인쇄하는 기능까지 넣었습니다.

### **3) FastMCP 서버 구축 완료**

여기에 최신 AI 에이전트 표준인 **FastMCP(Model Context Protocol)**를 얹어 홈 NAS의 Docker 컨테이너로 패키징했습니다.

```mermaid
flowchart LR
    subgraph Client["사용자 (자연어 명령)"]
        Wife["아내 (Gemini / Antigravity)"]
    end

    subgraph NAS["Home NAS (Docker Container)"]
        MCP["FastMCP SSE Server (:8000)"]
        Engine["ReportLab 1x3 좌표 렌더링"]
        IPP["CUPS IPP Client (scaling=100)"]
    end

    subgraph LAN["홈 네트워크 (192.168.1.X)"]
        Printer["네트워크 프린터 (:631)"]
    end

    Wife -->|"오늘 들어온 주문 송장 뽑아줘"| MCP
    MCP --> Engine
    Engine --> IPP
    IPP -->|"무드라이버 IPP 직출"| Printer
```

이때까지만 해도 저는 세상에서 가장 유능한 남편이 된 기분이었습니다. **진짜 지옥문이 열리기 전까지는 말이죠.**

---

## **2. 첫 번째 벽: 닫혀 있는 API와 'ActiveX의 망령' (.exe 로컬 에이전트)**

프린터 출력 파이프라인을 완벽하게 검증한 뒤, 데모 데이터를 걷어내고 실제 **네이버 커머스 API**와 **굿스플로(Goodsflow)** 송장 발급 시스템을 연결하려던 순간 첫 번째 거대한 벽에 부딪혔습니다.

### **1) 네이버 커머스 API에는 '송장 채번' API가 없다**

네이버 커머스 API 공식 문서(`apicenter.commerce.naver.com`)를 뒤져 주문 조회까지는 금방 구현했습니다(`bcrypt` 서명 기반 OAuth2 토큰 발급). 그런데 정작 가장 중요한 **'택배 송장 번호 발급(채번)'** 엔드포인트가 어디에도 없었습니다.

발송 처리 API(`POST /v1/pay-order/seller/product-orders/dispatch`)의 명세를 자세히 뜯어보니 다음과 같은 구조였습니다:

```json
{
  "deliveryMethod": "GDFW_ISSUE_SVC",
  "deliveryCompanyCode": "EPOST",
  "trackingNumber": "681234567890" // ⚠️ 필수(Required)! 이미 발급된 번호를 넣어야 함
}
```

즉, 네이버 커머스 API는 **이미 다른 곳에서 발급된 송장 번호(`trackingNumber`)를 넘겨받아 주문 상태를 '배송중'으로 바꿔주는 역할**만 할 뿐, 스마트스토어 제휴 택배(굿스플로)의 송장 번호를 새로 채번해 주는 기능이 아예 없었습니다.

### **2) 굿스플로(Goodsflow) Open API의 비용 장벽과 구조적 모순**

*"그렇다면 송장 채번을 담당하는 굿스플로 API를 직접 호출하면 되잖아?"*라고 생각하며 굿스플로 개발자 센터 문서를 열었습니다. 그리고 두 눈을 의심했습니다.

1. **엔터프라이즈 전용 요금제와 세팅비 22만 원**: 일반 소상공인 셀러는 API를 쓸 수 없습니다. Open API를 연동하려면 기업용 엔터프라이즈 요금제에 가입해야 하며, **최초 1회 세팅비만 220,000원(VAT 포함)**에 별도 월 기본료가 부과됩니다.
2. **API를 써도 결국 Windows 에이전트가 필요하다**: 더 기가 막힌 것은 기술 구조였습니다. 백번 양보해 돈을 내고 API(`PUT /api/deliveries/shipping/print-uri`)를 호출하더라도, 서버가 깔끔한 PDF 파일이나 송장 번호 JSON을 바로 내려주는 것이 아니라 **송장 출력용 웹 팝업 URI**를 반환합니다. 그리고 그 웹 팝업이 열리면? 결국 브라우저가 로컬 PC에 깔린 **Windows 전용 `.exe` 프로그램**을 깨워서 인쇄를 시킵니다.

### **3) 왜 2026년에도 대한민국 소상공인 물류는 Windows를 강제하는가?**

문서를 덮으며 잠시 깊은 현타가 찾아왔습니다. *"도대체 다른 신생 기업들도 다 이렇게 윈도우가 강제되나? 왜 이렇게 만들어 둔 걸까?"*

냉정하게 해부해 보면, 이는 대한민국 IT에 여전히 남아있는 **'ActiveX의 망령'** 때문입니다.

- 과거 ActiveX가 폐지될 때, 국내 수많은 금융·공공·물류 솔루션들은 웹 표준으로 전환하는 대신 **로컬 PC 백그라운드에 `.exe` 에이전트를 상주시키고 브라우저와 `localhost:포트`로 통신하는 우회로**를 택했습니다.
- 브라우저는 샌드박스 보안 정책상 사용자 확인 창 없이 프린터로 직접 명령을 쏠 수 없으니, **Win32 Spooler API**를 직접 호출하는 윈도우 전용 실행 파일을 강제한 것입니다.
- 쇼피파이(Shopify) 같은 글로벌 플랫폼이나 최신 풀필먼트 스타트업들은 서버에서 표준 4×6인치 PDF나 ZPL(라벨 프린터 제어어)을 생성해 맥북이든 아이패드든 OS에 구애받지 않고 출력하게 만든 지 오래입니다.
- 하지만 국내 오픈마켓 택배 연동 시장은 특정 미들웨어가 90% 이상을 사실상 독점하고 있고, 고객사의 95%가 윈도우 PC를 쓰다 보니 굳이 비용을 들여 macOS를 지원하거나 아키텍처를 현대화할 유인이 없었던 것입니다.

---

## **3. 두 번째 벽: 우체국 공공데이터 API 우회 시도, 그리고 '단가(Unit Economics)'라는 결정타**

오기가 생겼습니다. *"좋아, 굿스플로가 안 열어준다면 아내가 우체국 창구에 접수할 때 쓰는 **우체국택배**를 공공데이터포털(`data.go.kr`)의 공식 Open API로 직접 뚫어버리자!"*

정부 공공데이터포털에서 **우정사업본부 택배신청 저장 서비스(`RegisterDeliveryService`)** 활용 신청을 넣고 인증키(`[EPOST_SERVICE_KEY_MASKED]`)를 발급받았습니다. REST/JSON이 아니라 2000년대 스타일의 **SOAP 1.2 XML** 규격이었지만, 명세서를 뜯어가며 `addDelivery` 오퍼레이션으로 사전접수 번호(`reqstNo`)를 받아와 바코드로 찍어내는 `epost_api.py` 모듈까지 순식간에 통합했습니다.

하지만 모든 코드를 다 짜고 나서, 아내가 툭 던진 질문 하나에 저는 망치로 머리를 맞은 듯 멈춰 섰습니다.

> **아내**: *"그런데 여보, 굿스플로로 뽑으면 택배비가 2,900원인데, 지금 만든 걸로 직접 우체국 예약하면 얼마야?"*

등골이 서늘해졌습니다. 부랴부랴 두 방식의 실제 **택배 단가(Unit Economics)**를 1원 단위까지 비교해 보았습니다.

| 비교 항목 | 1. 스마트스토어 굿스플로 (기존 방식) | 2. 우체국 오픈API 간편사전접수 (자체 개발) |
| :--- | :--- | :--- |
| **적용 요금 체계** | 스마트스토어 × 우체국/제휴 계약 단가 | 일반 창구 소포 기본요금 (4,000원) |
| **할인 혜택** | 소상공인 제휴 특가 즉시 적용 | 사전접수 1~2개 3% / 3~9개 5% 할인 |
| **최종 건당 단가** | **2,900원 ~ 3,200원** 🏆 | **3,800원 ~ 3,880원** |
| **스마트스토어 연동** | 송장 출력 즉시 **등기번호 자동 등록** | API로 별도 발송 처리 구현 필요 |
| **전산 환경 제약** | **Windows PC + 굿스플로 `.exe` 필수** | **Mac / 홈 NAS / AI 100% 무인 가능** |

계산 결과는 참담했습니다.

제가 만든 순수 API 자동화 파이프라인을 쓰면, 아내는 윈도우 PC를 안 켜도 되고 AI에게 말 한마디로 우아하게 송장을 뽑을 수 있지만, **택배 1건을 보낼 때마다 약 900원씩 돈을 더 내야 했습니다.**

한 달에 30~50건만 보내도 매달 수만 원의 순이익이 그냥 증발하는 셈입니다. 마진율 몇 퍼센트를 아끼려고 포장 부자재 하나까지 최저가를 비교하는 소상공인 아내에게, **"남편이 만든 멋진 AI 자동화 시스템을 쓰기 위해 건당 900원의 기술세를 내라"**고 할 수는 없었습니다.

---

## **4. 왜 가상 윈도우(VM) + 봇(Playwright)으로 끝까지 밀어붙이지 않았나?**

물론 기술적으로 마지막 우회로가 남아있긴 했습니다.

1. **홈 NAS의 Docker에 경량 윈도우(`dockurr/windows`)**를 띄우거나,
2. 이미 서재에 있는 **물리 윈도우 PC에 백그라운드 워커와 브라우저 자동화 봇(`Playwright`)**을 심어놓고, AI에게 명령을 내리면 봇이 사람 대신 스마트스토어에 로그인해 굿스플로 버튼을 클릭하게 만드는 방법입니다.

하지만 깊은 고민 끝에 여기서 멈추기로 결정했습니다. 이유는 명확했습니다.

- **이미 집에 윈도우 PC가 있다**: 아내가 원하는 것은 '어려운 관리 포인트가 늘어나는 것'이 아니라 '단순하고 확실하게 일하는 것'이었습니다.
- **UI 스크래핑 봇의 취약성(Fragility)**: 네이버 스마트스토어의 2차 인증, 캡차(CAPTCHA), 주기적인 팝업 공지, 그리고 굿스플로 로컬 에이전트의 업데이트 창까지... 이를 헤드리스 봇으로 묶어두면 평소엔 돌아가는 것처럼 보여도 **가장 바쁜 발송 마감 시간(오후 2시)에 꼭 고장이 납니다.**
- **유지보수 부채(Maintenance Debt)**: 제가 회사에서 일하고 있는 낮 시간에 봇이 멈추면, 아내는 송장 하나를 못 뽑아 발을 동동 구르게 됩니다. **만든 사람 없이는 유지될 수 없는 자동화는 가족에게 선물이 아니라 족쇄**라는 생각이 들었습니다.

---

## **5. 실패를 통해 배운 3가지 엔지니어링 교훈 (Retrospective)**

비록 프로젝트 폴더(`/Dev/smartstore-mcp`)는 실서비스에 투입되지 못하고 박제되었지만, 이번 실패는 그 어떤 성공한 토이 프로젝트보다 깊은 고민과 교훈을 남겨주었습니다.

### **교훈 1. 코딩을 시작하기 전에 '비즈니스 단가(Unit Economics)'부터 계산하라**
문제가 보이면 터미널부터 열고 코드를 짜고 싶어집니다. 프린터의 631 포트를 뚫고 ReportLab으로 0.1mm 칼선을 맞추며 도파민에 취해 있었지만, 정작 가장 먼저 확인했어야 할 질문은 **"기존의 2,900원짜리 계약 단가를 외부 API에서도 똑같이 누릴 수 있는가?"**였습니다. 기술 아키텍처보다 도메인 정책과 비용 구조가 항상 위에 있음을 다시금 뼈저리게 배웠습니다.

### **교훈 2. 레거시 생태계의 해자(Moat)는 생각보다 깊고 단단하다**
겉으로 보기엔 단순해 보이는 '택배 송장 한 장 뽑기' 뒤에는 플랫폼(네이버), 미들웨어(굿스플로), 물류사(우체국/한진) 간의 복잡한 B2B 계약과 요금 체계, 그리고 20년 넘게 굳어진 Windows `.exe` 종속성이 거미줄처럼 얽혀 있었습니다. 왜 수많은 셀러들이 불편함을 감수하면서도 울며 겨자 먹기로 윈도우 PC를 쓰는지 몸소 부딪쳐보고서야 이해할 수 있었습니다.

### **교훈 3. 실패한 사이드 프로젝트에도 남는 기술 자산은 있다**
비록 스마트스토어 송장 자동화는 접었지만, 그 과정에서 만든 부산물들은 꽤나 훌륭한 무기들로 남았습니다:
- 드라이버 설치 없이 홈 네트워크 프린터로 PDF를 직출하는 **IPP Everywhere 파이프라인**
- A4 1×3 분할 라벨지(`200mm × 93mm`)의 원하는 칸(`slot`)에 0.1mm 오차 없이 바코드와 한글 텍스트를 찍어내는 **`ReportLab` 렌더링 모듈**
- 언제든 다른 홈 오토메이션 도구로 재활용할 수 있는 **홈 NAS 기반 FastMCP SSE 서버 템플릿**

---

# **에필로그: 완벽한 자동화보다 소중한 것**

아내는 오늘도 서재의 윈도우 PC를 켜서 마우스로 굿스플로 버튼을 누르고 2,900원짜리 우체국 송장을 뽑습니다.

달라진 점이 하나 있다면, 제가 윈도우 PC의 브라우저 인쇄 설정에서 기본 여백(Margin)을 '없음'으로 고정하고 배율을 `100% 실제 크기`로 세팅해 두어 **더 이상 200×93mm 라벨지가 밀려서 버려지는 일은 없어졌다**는 것입니다.

거창했던 "AI 말 한마디로 끝내는 무인 송장 시스템"이라는 남편의 큰소리는 조금 머쓱한 실패로 끝났습니다. 하지만 아내의 불편함을 덜어주려고 프린터 패킷을 뜯고 자로 라벨지 밀리미터를 재며 씨름했던 그 주말 오후의 시간만큼은, 내 삶을 디버깅하는 또 하나의 따뜻하고 유쾌한 기록으로 남았습니다.
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[내 차의 데이터는 어디로 갈까? NAS 도커(Docker)로 구축한 TeslaMate 셀프 호스팅 완벽 가이드]]></title>
        <id>https://dbgmy.life/blog/teslamate-nas-docker-self-hosting</id>
        <link href="https://dbgmy.life/blog/teslamate-nas-docker-self-hosting"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/teslamate-nas-docker.png" type="image/png"/>
        <updated>2026-10-12T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[서드파티 클라우드에 테슬라 계정과 위치 데이터를 맡기는 대신, 홈 NAS에 Elixir 기반 TeslaMate와 Grafana를 직접 구축하여 데이터 주권을 지키고 8,700km의 실전 텔레메트리를 분석한 기록.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **바퀴 달린 컴퓨터, 그 안에서 뿜어져 나오는 수많은 데이터**

<Image
  src="/images/blog/teslamate-nas-docker.png"
  alt="TeslaMate NAS Docker Grafana Architecture"
  width={1200}
  height={675}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
  priority
/>

퇴근길 주차장에 차를 대고 충전 케이블을 꽂을 때마다 문득 이런 생각이 들곤 했습니다. 

> *"내 차는 지금 어떤 속도로 충전되고 있을까? 오늘 출퇴근길 배터리 효율(전비)은 정확히 몇 Wh/km였을까? 겨울철 유령 방전(Vampire Drain)으로 사라지는 전력은 얼마나 될까?"*

테슬라(Tesla)는 단순한 내연기관 차량의 대체재가 아니라, 문자 그대로 **'네트워크에 항상 연결된 거대한 IoT 기기'**입니다. 바퀴와 모터가 달렸을 뿐, 수십 개의 센서와 컴퓨터가 끊임없이 텔레메트리(Telemetry) 데이터를 실시간으로 클라우드로 쏘아 올립니다.

시중에는 테슬라의 운행 기록을 관리해 주는 수많은 서드파티 유료 스마트폰 앱이나 클라우드 서비스들이 있습니다. 편하고 UI도 예쁩니다. 하지만 보안과 인프라를 다루는 시선에서는 한 가지 찜찜한 질문을 떨쳐낼 수 없었습니다.

> **"내 차의 실시간 GPS 위치, 주행 경로, 충전소 방문 이력, 심지어 테슬라 API 토큰까지 남의 회사 서버에 넘겨도 정말 괜찮은 걸까?"**

이 질문에 대한 저의 답은 언제나 같았습니다. 
**"데이터 주권(Data Sovereignty)은 내 손으로 지킨다. 내 집 구석에서 조용히 돌아가는 홈 NAS에 직접 로거(Logger)를 올리자."**

그렇게 제 테슬라 모델Y(애칭: **테슬이**)의 모든 생애주기 데이터를 기록하기 위해 시작된 오픈소스 텔레메트리 수집기, **TeslaMate 셀프 호스팅 구축기**를 공유합니다.

---

## 1. 왜 수많은 로거 중 TeslaMate인가?

테슬라 오픈소스 로거 생태계에는 몇 가지 선택지가 있지만, **TeslaMate**는 그중에서도 압도적인 완성도와 가벼운 리소스 점유율을 자랑합니다.

```
       [ Tesla Fleet / Streaming API ]
                      │ (HTTPS / WSS)
                      ▼
   ┌─────────────────────────────────────┐
   │        TeslaMate Core (Elixir)      │ ──▶ [ Mosquitto MQTT ]
   │      - 차량 상태 폴링 및 수면 제어   │     (실시간 상태 브로커)
   └─────────────────────────────────────┘
                      │
                      ▼ (SQL Insert)
   ┌─────────────────────────────────────┐
   │    PostgreSQL + PostGIS Database    │
   │      - 지리 좌표 및 시계열 데이터 저장│
   └─────────────────────────────────────┘
                      │
                      ▼ (Data Query)
   ┌─────────────────────────────────────┐
   │         Grafana Dashboards          │
   │      - 30여 종의 전문 시각화 대시보드 │
   └─────────────────────────────────────┘
```

1. **Elixir / Phoenix 기반의 극강의 효율성**
   - Elixir 언어의 경량 프로세스(Actor 모델) 기반으로 설계되어 메모리 사용량이 고작 수십 MB 수준입니다. NAS나 라즈베리 파이 같은 저전력 홈 서버에서도 부담 없이 24시간 백그라운드로 돌아갑니다.
2. **PostgreSQL + PostGIS를 통한 정확한 지리 공간 분석**
   - 주행 경로의 위도/경도 좌표를 단순 텍스트가 아닌 전문 지리정보(GIS) 엔진으로 다룹니다.
3. **독립된 Mosquitto MQTT 브로커 연동**
   - 차량의 충전 상태, 도어 개폐, 배터리 잔량 등을 MQTT 토픽으로 실시간 발행합니다. 이를 홈어시스턴트(Home Assistant)와 연동하면 스마트홈 자동화의 무궁무진한 트리거로 활용할 수 있습니다.
4. **미려한 Grafana 시각화 대시보드 기본 탑재**
   - 전비, 배터리 수명 열화도, 충전 통계, 속도 분포, 고도 변화 등 30개 이상의 완성도 높은 대시보드를 즉시 제공합니다.

---

## 2. 홈 NAS Docker 환경에서 실전 구축하기

현재 제 홈랩 환경은 내부 사설망의 NAS 서버에서 Docker Compose를 통해 구동되고 있습니다. 설치 과정은 생각보다 매우 단순하며 깔끔합니다.

### Step 1. `docker-compose.yml` 작성

NAS의 원하는 디렉토리(예: `/volume1/docker/teslamate`)에 아래와 같이 `docker-compose.yml` 파일을 작성합니다.

```yaml
version: "3"

services:
  teslamate:
    image: teslamate/teslamate:latest
    restart: unless-stopped
    environment:
      - ENCRYPTION_KEY=YOUR_SECURE_RANDOM_KEY_HERE # openssl rand -base64 32
      - DATABASE_USER=teslamate
      - DATABASE_PASS=secret_db_password
      - DATABASE_NAME=teslamate
      - DATABASE_HOST=database
      - MQTT_HOST=mosquitto
    ports:
      - 4000:4000
    volumes:
      - ./import:/opt/app/import
    cap_drop:
      - all

  database:
    image: postgres:17
    restart: unless-stopped
    environment:
      - POSTGRES_USER=teslamate
      - POSTGRES_PASSWORD=secret_db_password
      - POSTGRES_DB=teslamate
    volumes:
      - teslamate-db:/var/lib/postgresql/data

  grafana:
    image: teslamate/grafana:latest
    restart: unless-stopped
    environment:
      - DATABASE_USER=teslamate
      - DATABASE_PASS=secret_db_password
      - DATABASE_NAME=teslamate
      - DATABASE_HOST=database
    ports:
      - 3000:3000
    volumes:
      - teslamate-grafana-data:/var/lib/grafana

  mosquitto:
    image: eclipse-mosquitto:2
    restart: unless-stopped
    command: mosquitto -c /mosquitto-no-auth.conf
    # ports:
    #   - 1883:1883 # 홈어시스턴트 등 외부 연동 시 포트 노출
    volumes:
      - mosquitto-conf:/mosquitto/config
      - mosquitto-data:/mosquitto/data

volumes:
  teslamate-db:
  teslamate-grafana-data:
  mosquitto-conf:
  mosquitto-data:
```

> [!IMPORTANT]
> **보안 키(Encryption Key) 생성 팁**:
> `ENCRYPTION_KEY`는 테슬라 토큰을 데이터베이스에 안전하게 암호화하여 저장할 때 사용되는 비밀키입니다. 터미널에서 `openssl rand -base64 32` 명령어로 무작위 32바이트 문자열을 생성하여 반드시 고유한 키로 입력해야 합니다.

### Step 2. 컨테이너 기동

작성한 디렉토리에서 컨테이너를 올립니다.

```bash
docker compose up -d
```

명령어 한 줄이면 4개의 컨테이너(`teslamate`, `database`, `grafana`, `mosquitto`)가 브릿지 네트워크로 안전하게 묶이며 즉시 기동됩니다.

---

## 3. 놓치기 쉬운 핵심 운영 및 보안 체크리스트

호기심 많은 메이커로서 무언가를 새로 띄울 때, 제 머릿속에서는 항상 보안 엔지니어로서의 경보 장치가 함께 돌아갑니다. 편리함과 멋진 대시보드 뒤에 숨겨진 보안 리스크를 통제해야 비로소 안심하고 데이터를 모을 수 있습니다.

### 1) 테슬라 토큰 발급 및 안전한 관리
TeslaMate가 차량 데이터를 긁어오려면 테슬라 계정의 **API Refresh Token**이 필요합니다. 
- 과거와 달리 공식 테슬라 Auth는 보안이 강화되어 전용 오픈소스 토큰 생성기(예: `Auth for Tesla` 앱 또는 `TeslaMateAuth` 컨테이너)를 통해 일회성으로 토큰을 추출한 뒤 TeslaMate 웹 UI에 등록합니다.
- 발급받은 토큰은 `ENCRYPTION_KEY`로 로컬 DB에 AES 암호화 저장되므로, NAS 내부 볼륨이 통째로 유출되지 않는 한 안전합니다.

### 2) 외부 접속 시 '포트포워딩' 절대 금지
차량의 이동 경로와 집/직장 위치, 운행 시간대는 가장 민감한 물리적 프라이버시 정보입니다.
- **공유기에서 4000번, 3000번 포트를 외부로 포트포워딩(Port Forwarding)하지 마세요.**
- 외부(외출 중인 스마트폰, 노트북)에서 대시보드를 열어보고 싶다면, 반드시 **Tailscale**이나 사설 **WireGuard VPN** 터널을 통과해서만 내부 IP(`192.168.X.X`)로 접근하도록 구성해야 안전합니다.

### 3) 차량의 '수면(Sleep)'을 방해하지 않는 똑똑한 폴링
테슬라 오너들에게 가장 두려운 것 중 하나가 **유령 방전(Vampire Drain)**입니다. 
서투르게 만들어진 써드파티 앱들은 차가 잠들려고 할 때마다 1분 간격으로 "너 깨어있니?" 하고 API를 찔러댑니다. 그러면 차량의 고전압 배터리와 인포테인먼트 컴퓨터가 잠들지 못해 밤새 배터리가 3~5%씩 줄줄 새어나갑니다.

TeslaMate의 진가는 바로 이 **수면 대기(Time to try sleeping) 알고리즘**에 있습니다:
- 차량 운행이나 충전이 끝나면 기본 `21분` 동안 차량 상태를 관찰합니다.
- 차량이 유휴(Idle) 상태에 들어가면 폴링을 일시 중단하고 차가 딥슬립(Deep Sleep)에 빠질 수 있도록 기다립니다.
- 실제로 현재 제 NAS 대시보드를 확인해보면 **'offline for 10 h, 57 min'** 상태로 완벽하게 수면 모드를 유지하며 배터리 소모를 0에 가깝게 지켜주고 있습니다.

---

## 4. 실전 대시보드 엿보기: Model Y '테슬이'의 8,700km 기록

이렇게 구축한 뒤 일상 속에서 테슬라 모델Y를 타며 쌓인 8,700여 km의 실전 데이터는 어떤 모습일까요?

```
[ TeslaMate Status Card ]
─────────────────────────────────────────────────
 차량 이름: 테슬이
 모델 구분: Tesla Model Y Standard Range (RWD)
 펌웨어   : 2026.26.6.2
 상태     : Offline (Sleeping for 11 hrs)
 배터리   : 77% (표준 주행가능거리: 331.3 km)
 누적주행 : 8,796 km
─────────────────────────────────────────────────
```

### 1) Drives (주행 및 전비 분석)
어디서 출발해서 어디로 갔는지, 고도 변화에 따라 회생제동(Regen)이 얼마나 걸렸는지, 바깥 기온이 전비에 미친 영향이 얼마인지를 한눈에 확인할 수 있습니다. 같은 고속도로 구간이라도 비가 오거나 바람이 불 때 Wh/km 소모량이 얼마나 치솟는지 그래프로 직접 확인하는 재미가 쏠쏠합니다.

### 2) Charges (충전 곡선과 실제 충전 효율)
완속(AC 7kW) 충전기와 슈퍼차저(DC 급속)에서 충전할 때 배터리 잔량(SoC)에 따른 충전 속도 테이퍼링(Tapering) 곡선이 정밀하게 그려집니다. 특히 **'계량기에서 공급된 전력' 대비 '실제 배터리에 흡수된 전력'의 손실률(효율 ~90-94%)**을 숫자로 정확히 파악할 수 있어 경제적입니다.

### 3) Battery Health (배터리 열화도 모니터링)
신차 출고 당시의 최대 완충 가능 주행거리와 현재 누적 8,700km 시점의 열화 곡선을 대조해 볼 수 있습니다. LFP(인산철) 배터리의 완충 특성과 수명 변화를 장기적으로 관찰할 수 있는 가장 든든한 헬스케어 차트입니다.

---

## 5. 마치며: 내 삶을 디버깅하는 또 하나의 즐거움

많은 사람들이 자동차를 단순한 이동 수단으로 소비하지만, 엔지니어의 눈으로 바라본 전기차는 **'디버깅하고 관찰할 수 있는 가장 거대하고 매력적인 소프트웨어 머신'**입니다.

서드파티 플랫폼의 클라우드 종속에서 벗어나, 내 집 NAS에 나만의 데이터를 차곡차곡 쌓아 올리는 과정. 그것은 거창한 빅데이터 분석이 아니라도, **나의 이동과 일상을 내 손으로 기록하고 해석하는 소소하지만 확실한 엔지니어링의 기쁨**이었습니다.

홈 서버나 NAS를 굴리고 계신 테슬라 오너라면, 주말 반나절을 투자해 TeslaMate 컨테이너를 한번 올려보시는 것을 강력하게 추천합니다. 여러분의 차가 매일 어떤 길을 달리고 어떻게 잠드는지, 지금까지 보지 못했던 새로운 데이터의 세계가 열릴 테니까요.
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[월패드 RS485 패킷 역공학부터 스마트홈까지: AI와 함께 완성한 Home Assistant와 Bento UI 구축기]]></title>
        <id>https://dbgmy.life/blog/homeassistant-wallpad-rs485-bento-ui</id>
        <link href="https://dbgmy.life/blog/homeassistant-wallpad-rs485-bento-ui"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/homeassistant_bento_ui.png" type="image/png"/>
        <updated>2026-10-10T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[월패드 RS485 통신 패킷을 직접 역공학하던 고된 수작업부터, AI 에이전트와 함께 해결한 USB 콜드 재부팅 버그와 Bento Grid 대시보드 구축기.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **"거실 벽면을 뜯고 터미널을 열었던 날이 있었습니다"**

거실 벽면에 매립된 월패드를 조심스레 분리하고, 뒷단 RS485 통신 라인에 시리얼 컨버터를 물려 터미널을 열었던 주말이 있었습니다.

시리얼 모니터 화면으로 쏟아져 나오는 끝없는 16진수(HEX) 바이트 스트림을 멍하니 바라보던 기억이 생생합니다.  
거실 조명 스위치를 껐다 켜보며 터미널 창에서 어떤 바이트가 바뀌는지 눈으로 대조하고, 덤프 로그를 한 줄씩 엑셀에 붙여넣으며 패킷 구조를 역공학(Reverse Engineering)하던 시간.

명령어 하나, 상태 패킷 하나를 알아낼 때마다 기뻤지만, 솔직히 말하면 지독한 노가다와 피로의 연속이었습니다.

하지만 최근, 그동안 묵혀두었던 홈어시스턴트(Home Assistant) 인프라를 대대적으로 정비하면서 완전히 새로운 경험을 했습니다. 과거에 며칠 밤을 새우며 패킷을 뜯고 코드를 짜맞추던 그 지난한 과정들이, 이제는 **AI 코딩 에이전트와의 협업을 통해 믿을 수 없을 만큼 빠르고 완성도 높게 정리**되었기 때문입니다.

수년간 골머리를 앓게 했던 USB 콜드 재부팅 버그를 영구 해결하고, 투박했던 대시보드를 레딧 감성의 모던한 **Bento Grid UI**로 탈바꿈시킨 최근의 홈랩(HomeLab) 구축기를 기록해 둡니다.

<Image
  src="/images/blog/homeassistant_bento_ui.png"
  alt="Home Assistant Wallpad to Bento UI"
  width={800}
  height={450}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

---

## **1. 한땀한땀 빚어낸 RS485 패킷 분석과 월패드의 추억**

기본 설치된 월패드는 폐쇄적입니다. 제조사는 통신 프로토콜 규격을 공개하지 않으며, 외부 연동 API 따위는 존재하지 않습니다. 결국 홈오토메이션에 진심인 사람들은 RS485 시리얼 버스에 직접 올라타 패킷을 덤프하고 분석하는 수밖에 없습니다.

저 역시 시리얼-이더넷 컨버터를 통해 수집한 통신 로그를 바탕으로, 패킷 프레임 규격을 하나씩 역공학했습니다:

```text
[RS485 통신 프레임 분석 예시 (보안상 가상화/마스킹 처리)]
>> [HEADER] [CMD: 0x1X] [TARGET_ID] [DATA: ON]  [CHECKSUM: XOR] (조명 제어 명령)
<< [HEADER] [RES: 0x9X] [TARGET_ID] [STATE: ON] [CHECKSUM: XOR] (조명 상태 응답)
>> [HEADER] [CMD: 0x2X] [TARGET_ID] [DATA: 22℃] [CHECKSUM: XOR] (난방 희망온도 설정)
<< [HEADER] [RES: 0xAX] [TARGET_ID] [STATE: 22℃] [CHECKSUM: XOR] (난방 상태 응답)
>> [HEADER] [CMD: 0xFX] [TARGET_ID] [DATA: LV2] [CHECKSUM: XOR] (환기팬 2단 가동 명령)
```

*(※ 실제 하드웨어의 프로토콜 오피코드 및 고유 페이로드는 보안 및 리버스 엔지니어링 남용 방지를 위해 가상화/마스킹 처리했습니다.)*

- **헤더 & 커맨드**: 기기 카테고리별 제어 명령(CMD)과 상태 응답(RES) 바이트 식별
- **데이터 필드**: 조작 대상 디바이스 ID, 상태 플래그, 설정값(온도, 레벨 등) 매핑
- **체크섬 검증**: 데이터 프레임의 바이트들을 XOR 연산하여 패킷 무결성 검증

각 기기별 명령 바이트와 응답 상태를 하나하나 매핑하고, Node.js 기반 브릿지 데몬(`wallpad_bridge.js`)을 작성해 MQTT 브로커(Mosquitto)와 홈어시스턴트 사이를 잇는 통신 파이프라인을 만들었습니다.

월패드를 거치지 않고 내 스마트폰에서 거실 불이 켜지고 보일러가 돌아가던 순간의 희열은 대단했습니다. 하지만 진짜 문제는 **'유지보수'**였습니다.

---

## **2. HA 메이저 업데이트의 폭풍, 그리고 AI 에이전트의 등장**

홈어시스턴트는 발전 속도가 엄청나게 빠른 오픈소스입니다.  
한동안 바쁜 일상에 치여 방치해두었던 홈어시스턴트를 최신 버전(`2026.9+`)으로 판올림하는 순간, 예상대로 수많은 기능이 깨져나갔습니다.

- 대시보드와 자동화 스크립트 전반에서 `service:` 호출이 `action:`으로 전면 개편
- 최신 규격에 맞지 않는 레거시 MQTT 엔티티 스키마 오류
- 580여 개에 달하는 엔티티와 템플릿 센서(`templates.yaml`)의 의존성 충돌

예전 같았으면 공식 릴리스 노트를 한 줄씩 독해하며 수십 개의 YAML 설정 파일을 밤새 붙잡고 디버깅하느라 주말을 통째로 날렸을 것입니다.

하지만 이번에는 **AI 코딩 에이전트**에게 홈어시스턴트 로컬 설정과 덤프 로그를 함께 맡겼습니다:

1. **엔티티 의존성 일괄 스캔**: 580여 개 엔티티 목록과 에러 로그를 대조해 깨진 참조를 즉시 식별.
2. **최신 스키마 자동 변환**: 레거시 `service: automation.trigger` 구문들을 최신 권장 규격인 `action:`으로 일괄 리팩토링.
3. **MQTT 디바이스 정의 동기화**: `mqtt.yaml`과 템플릿 센서들의 유효성을 점검하고 누락된 상태 토픽을 정확하게 보정.

과거에는 수동으로 패킷 바이트를 세어가며 코드를 짰다면, 이제는 **"월패드 환기팬 2단 제어 토픽이 바뀐 스키마에 맞게 동작하도록 `mqtt.yaml`을 고쳐줘"**라는 한마디에 AI가 완벽한 문법과 아키텍처 규칙을 적용해 줍니다. 며칠이 걸렸을 마이그레이션이 단 몇 시간 만에 마무리되는 경험은 가히 충격적이었습니다.

---

## **3. 몇 년 묵은 고질병: 콜드 재부팅 USB 포트 바뀜 완벽 해결**

홈랩이나 미니 PC로 스마트홈을 운영해 본 분이라면 누구나 공감할 악몽이 있습니다.  
바로 **'콜드 재부팅(Cold Reboot) 시 USB 장치 경로가 뒤바뀌는 문제'**입니다.

제 홈랩 서버에는 두 개의 USB 시리얼 동글이 연결되어 있습니다:
- **Zigbee 코디네이터**: Sonoff Zigbee 3.0 USB Dongle Plus
- **월패드 통신용**: RS485 to USB 시리얼 어댑터

운영체제(Linux)는 부팅 시 하드웨어가 감지되는 미세한 타이밍 차이에 따라 포트를 부여합니다. 평소 소프트 리부팅 때는 괜찮다가도, 정전 후 복구나 전원 플러그를 뽑았다 꽂는 '콜드 부팅'이 발생하면 귀신같이 포트가 뒤바뀝니다:

```text
[정상 부팅 시]
Sonoff Zigbee Dongle  ──▶ /dev/ttyUSB0
RS485 월패드 어댑터   ──▶ /dev/ttyUSB1

[콜드 재부팅 후 대참사]
Sonoff Zigbee Dongle  ──▶ /dev/ttyUSB1  💥 (Zigbee2MQTT 시작 실패!)
RS485 월패드 어댑터   ──▶ /dev/ttyUSB0  💥 (월패드 통신 먹통!)
```

이 문제가 터지면 집안의 모든 스마트 조명, 모션 센서, 온습도 센서가 일제히 오프라인으로 떨어집니다.

이를 해결하기 위해 불안정한 동적 가상 포트(`/dev/ttyUSB*`) 대신, **하드웨어 고유 시리얼 번호 기반의 고정 심볼릭 링크**를 적용했습니다:

```yaml
# ha_config/zigbee2mqtt/configuration.yaml 수정
serial:
  # 변경 전: port: /dev/ttyUSB1 (불안정)
  # 변경 후: 고유 하드웨어 ID 기반 영구 고정 경로 (시리얼 해시 일부 마스킹)
  port: /dev/serial/by-id/usb-ITead_Sonoff_Zigbee_3.0_USB_Dongle_Plus_xxxxxxxxxxxxxxxx-if00-port0
  adapter: zstack
```

리눅스 커널이 장치를 어떤 순서로 인식하든, 칩셋 제조사와 고유 시리얼 해시가 들어간 `/dev/serial/by-id/` 심볼릭 링크는 영원히 고정됩니다.  
수년 동안 "재부팅하고 나면 또 집안 센서 다 죽는 거 아니야?"라는 불안감에 시달렸던 고질적인 버그가 마침내 영구적으로 종결된 순간이었습니다.

---

## **4. 투박함을 벗고 완성한 Bento Grid 스타일 모던 대시보드**

기능이 완벽하게 살아나고 나니, 마지막 갈증은 **'UI 디자인'**이었습니다.

홈어시스턴트의 기본 대시보드는 실용적이지만 다소 투박합니다. 마치 90년대 산업용 제어판을 보는 듯한 느낌을 지우기 어려웠죠.

레딧의 r/homeassistant 커뮤니티에서 영감을 받아, 최신 UI 트렌드인 **Bento Grid(도시락 격자형) 대시보드**를 구축하기로 했습니다:

```text
┌─────────────────────────────────────────────────────────────────┐
│                      [ BENTO SMART HOME ]                       │
├──────────────────┬──────────────────┬───────────────────────────┤
│  ⛅ CLIMATE      │  💡 LIGHTS       │  ⚡ POWER MONITOR         │
│  - 거실/안방 온도 │  - 구역별 조명    │  - 실시간 전력 사용량      │
│  - 환기팬/난방   │  - 원터치 일괄소등│  - 가전 기기별 소비 전력  │
├──────────────────┴──────────────────┼───────────────────────────┤
│  🚗 EV STATUS (Tesla)               │  📱 FAMILY PRESENCE       │
│  - 배터리 잔량 & 실시간 주행거리    │  - 가족 재실 여부 모니터링│
│  - 충전 플러그 연결 상태            │  - 기기별 배터리 잔량 & 충전│
├─────────────────────────────────────┴───────────────────────────┤
│  🏠 SMART HOME (월패드 연동)                                    │
│  - 엘리베이터 실시간 위치 모니터링 & 원터치 호출 기능            │
│  - 스마트 가전 (세탁기/건조기/식세기/공청기 잔여시간 및 제어)   │
└─────────────────────────────────────────────────────────────────┘
```

Mushroom Cards와 `custom:mushroom-chips-card`, 그리고 커스텀 CSS 테마를 조합해 다음과 같은 디테일을 채워 넣었습니다:

1. **상황별 원터치 칩스(Chips)**: 대시보드 상단에 '굿모닝', '굿나잇', '외출' 모드 칩을 배치해 복잡한 조건문 없이 한 번의 탭으로 전체 자동화 실행.
2. **실시간 엘리베이터 연동**: 월패드와 연동하여 엘리베이터의 운행 상태를 실시간으로 확인하고, 외출 준비 시 터치 한 번으로 엘리베이터를 사전 호출.
3. **가족 재실 및 기기 상태 트래커**: 모바일 앱 센서 데이터를 연동해 가족 구성원의 재실 여부와 스마트폰 배터리 잔량 및 충전 상태를 직관적으로 표시.
4. **차량 현황 통합**: 텔레메트리 센서 데이터를 대시보드 카드에 연동해 차량의 현재 주행 가능 거리와 배터리 SoC를 일체감 있게 통합.
5. **가전제품 세부 컨트롤**: 온/오프 토글뿐만 아니라 선풍기 풍량, 공기청정기 세부 모드, 세탁/건조 가전의 동작 상태를 모던한 팝업 카드 형태로 깔끔하게 구성.

가족들도 이제는 "이거 복잡해서 어떻게 써?"라고 묻지 않고, 거실 태블릿 화면을 톡톡 터치하며 자연스럽게 스마트홈을 누리게 되었습니다.

---

## **마치며: 홈오토메이션의 진짜 즐거움을 되찾다**

처음 벽면을 뜯고 RS485 통신 패킷을 분석하던 시절을 돌아보면, 그 모든 고생이 있었기에 지금의 홈 네트워크 구조를 밑바닥부터 이해할 수 있었다고 생각합니다.

하지만 하드웨어 패킷을 일일이 해석하고, 버전 충돌을 디버깅하고, 복잡한 YAML 문법을 씨름하는 일은 분명 엄청난 에너지를 소모하게 만듭니다. 지쳐서 결국 스마트홈 구축을 중도 포기하게 되는 이유이기도 하죠.

이번 프로젝트를 통해 경험한 **AI와의 협업**은 단순한 코드 생성을 넘어섰습니다.
- 밑바닥 패킷 구조와 하드웨어 맥락을 이해하고 있는 내가 **설계의 방향과 의도**를 제시하면,
- AI 에이전트가 최신 프레임워크 표준에 맞게 **복잡한 설정과 연동 코드를 막힘없이 조립**해 주었습니다.

그 덕분에 몇 년 동안 미뤄두었던 고질적인 USB 버그를 마침내 털어내고, 내가 매일 머무는 공간을 진정으로 아름답고 편리하게 만드는 **'메이커(Maker)로서의 순수한 즐거움'**에 오롯이 집중할 수 있었습니다.

벽면의 배선을 보며 한숨 쉬고 계신 전국의 홈오토메이션 동료분들이 계시다면, 이번 주말에는 AI 파트너와 함께 밀려있던 스마트홈 정비를 시작해보는 건 어떨까요? ☕
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[코드보다 '맥락'이 중요한 시대: 옵시디언으로 개발 이력을 자산화하는 4가지 실전 팁]]></title>
        <id>https://dbgmy.life/blog/obsidian-developer-second-brain-workflow</id>
        <link href="https://dbgmy.life/blog/obsidian-developer-second-brain-workflow"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/obsidian_dev_workflow.png" type="image/png"/>
        <updated>2026-09-16T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[바이브 코딩(Vibe Coding) 시대, 본업은 정보보안이지만 사이드 프로젝트에 진심인 취미 개발자가 전하는 이야기. AI 에이전트와 옵시디언을 결합해 디버깅의 '맥락'을 온전히 내 지식 자산으로 만드는 실전 워크플로우.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **"내가 짠 코드인데, 왜 이렇게 짰는지 기억이 안 난다"**

본업인 정보보안 분야에서 일하면서도, 퇴근 후에는 사이드 프로젝트와 앱 개발에 진심인 '취미 개발자'로서 수년간 코드를 만지작거리다 보면 누구나 한 번쯤 겪는 싸한 순간이 있습니다.

몇 달 전 내가 작성했던 복잡한 비즈니스 로직이나 묘하게 꼬여 있는 예외 처리 코드를 마주쳤을 때입니다.  
*"도대체 어떤 녀석이 타이머를 두 번이나 감싸고 레이스 컨디션 방어 코드를 이렇게 괴상하게 넣어둔 거지?"*  
짜증을 내며 `git blame`을 쳐보는 순간, 화면에 선명하게 찍히는 내 영문 이니셜... 등골이 서늘해지는 그 기분, 다들 아실 겁니다.

Git 커밋 로그나 PR 설명은 코드의 **'결과(What & How)'**만 보여줍니다.  
하지만 그 코드가 그 모양이 될 수밖에 없었던 수많은 실패의 과정, OS 프레임워크의 숨겨진 제약 사항, 기획과 사용자 경험 사이에서 치열하게 고민했던 **'맥락(Why)'**은 깃허브 diff 어디에도 온전히 남지 않습니다.

최근 개발 씬(Scene)에서는 자연어로 의도를 던지며 개발하는 이른바 **'바이브 코딩(Vibe Coding)'**이 대세가 되었습니다. AI의 도움으로 코드를 작성하는 속도는 10배 빨라졌지만, 역설적으로 **코드가 왜 그렇게 작성되었는지에 대한 맥락은 그 어느 때보다 빠르게 휘발**되고 있습니다.

에버노트, 노션, 컨플루언스, 맥 기본 메모장까지 수많은 도구를 전전하다가, 저는 결국 **옵시디언(Obsidian)**을 제 모든 개발 이력과 AI 코딩의 '세컨드 브레인(Second Brain)'으로 삼게 되었습니다.

최근 까다로운 시스템 백그라운드 제어와 스케줄러를 다루는 모바일 사이드 프로젝트를 AI 에이전트와 함께 진행하면서 체득한, **개발과 기록을 사랑하는 엔지니어가 옵시디언을 개발 허브로 200% 활용하는 4가지 실전 팁**을 나눠볼까 합니다.

<Image
  src="/images/blog/obsidian_dev_workflow.png"
  alt="Obsidian Developer Second Brain Workflow"
  width={800}
  height={450}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

---

## **1. 복잡한 런타임 흐름, 텍스트 대신 'Mermaid 다이어그램'으로 박제하기**

아무리 텍스트로 자세히 설명해 놔도, 비동기 이벤트나 프로세스 간 통신(IPC)은 나중에 다시 읽으면 머리에 바로 그려지지 않습니다.

최근 프로젝트에서 메인 앱과 OS 백그라운드 모니터 확장 프로그램(Extension), 그리고 시스템 차단 쉴드 간의 상호작용을 구현할 때가 그랬습니다. 시간 만료 이벤트가 발생하는 시점과 쉴드가 걸리는 타이밍이 얽혀 디버깅이 꽤나 골치 아팠죠.

이때 저는 옵시디언 문서 안에 별도의 드로잉 툴 없이 **Mermaid 코드 블록**으로 시퀀스 흐름을 그대로 그려 넣었습니다:

````markdown
```mermaid
sequenceDiagram
    autonumber
    actor User as 사용자
    participant App as 메인 애플리케이션
    participant OS as 시스템 스케줄러 (OS Center)
    participant Worker as 백그라운드 모니터 Extension
    participant Shield as 시스템 쉴드 (Policy Shield)

    User->>App: 특정 이벤트 완료 및 사용 언락 요청
    App->>OS: 24시간 상시 감시 스케줄 등록
    App->>OS: 사용 누적 임계치 이벤트(threshold) 등록
    App->>Shield: 일시적 실드 해제 (Unshield)
    
    Note over User,Worker: 백그라운드 환경에서 작업 진행
    
    User->>Worker: 허용 시간 만료 (Event Threshold 도달)
    Worker->>Worker: threshold 이벤트 트리거 감지
    Worker->>Shield: 즉시 쉴드 재적용 (Shielded Applications)
    Note over User,Shield: 즉시 접근 차단 화면 활성화!
```
````

### 왜 이 방식이 강력할까요?
- **문서와 다이어그램의 일체화**: 피그마나 Draw.io 같은 외부 툴을 켜서 이미지로 내보내고 첨부하는 번거로움이 전혀 없습니다.
- **수정의 용이성**: 아키텍처나 파라미터가 바뀌면 마크다운 텍스트 몇 줄만 고치면 다이어그램이 즉시 새로 렌더링됩니다.
- **순간 소환**: 2주 뒤 코드를 다시 열었을 때, 다이어그램 한 컷만 보면 머릿속에 런타임 흐름이 3초 만에 완벽히 복원됩니다.

---

## **2. 'Why'를 기록하는 트러블슈팅 테이블 (현상 - 원인 - 해결)**

버그를 밤새 잡고 마침내 해결했을 때의 도파민은 엄청납니다. 하지만 대부분의 개발자는 여기서 `git push`를 때리고 침대로 다이빙하죠. 그리고 6개월 뒤 비슷한 버그를 만나면 똑같이 3일을 날립니다.

저는 버그를 잡은 직후, 아무리 피곤해도 옵시디언 작업 일지에 **3단 트러블슈팅 테이블**을 반드시 남깁니다:

| 이슈 현상 | 근본 원인 | 최종 해결 방법 |
| :--- | :--- | :--- |
| **특정 시간 이하로 스케줄 등록 시 이벤트 무시됨** | OS 프레임워크 내부에서 15분 미만 간격의 짧은 스케줄을 거부하고 에러를 뱉음 | 특정 시점 스케줄 대신 '24시간 상시 감시 + 동적 누적 Threshold' 방식으로 아키텍처 전면 개편 |
| **언락 직후 잔여시간 0초로 즉시 재차단되는 버그** | 언락 시점과 모니터 프로세스 기동 간 타이밍 레이스 컨디션 | 언락 타임스탬프를 공유 스토리지에 기록하여 3초 이내 중복 트리거 무시 가드 구축 |
| **사용자 UX 피로도 누적** | 단 1번 실수로 세션이 강제 종료되어 지나친 긴장감 유발 | 2차 기회 모드를 도입하되, 첫 시도 정답자만 통계에 반영하는 어뷰징 방지 로직 분리 |

이 표의 핵심은 **"왜 이 방식을 선택했는가"**에 대한 기술적 합리화입니다.  
나중에 팀원이나 미래의 나 자신이 *"이거 코드 왜 이렇게 복잡하게 짰어? 그냥 스케줄러 쓰면 안 돼?"* 라고 의문을 가질 때, 이 표 하나면 모든 반론이 종결됩니다.

---

## **3. 폴더의 무덤을 탈출하는 MOC (Map of Content)와 백링크**

노트 앱을 오래 쓰다 보면 누구나 **'분류의 저주'**에 빠집니다.  
`Project > Mobile > Bugfix > 2026 > Q3...` 이런 식으로 폴더를 파고 들어가다 보면, 정작 내가 쓴 노트를 내가 못 찾는 '노트의 무덤'이 탄생하죠.

옵시디언에서는 폴더를 깊게 파지 않고, **MOC(Map of Content, 지식 지도)**와 **양방향 링크(`[[...]]`)**를 활용합니다:

```text
000_Index.md (메인 대시보드)
   └── Project_MOC.md (프로젝트 총괄 지식 지도)
          ├── [[Project/모바일앱/Prompt]] (기획 및 프롬프트 규격)
          ├── [[Project/모바일앱/아키텍처설계]]
          └── [[Work_Log/2026-09-12_백그라운드시청차단_버그픽스]] (실제 작업 일지)
```

- 모든 작업 일지의 프론트매터에는 상위 MOC 백링크(`up: "[[Project_MOC]]"`)와 표준 태그(`#domain/project`, `#type/worklog`, `#platform/ios`)를 붙입니다.
- 이렇게 하면 특정 폴더에 노트를 가두지 않아도, **MOC를 통해 위에서 아래로 내려갈 수도 있고 태그 검색이나 그래프 뷰를 통해 거미줄처럼 지식을 넘나들 수 있습니다.**

---

## **4. 바이브 코딩의 완성: AI와 옵시디언을 잇는 'Skill 기반 자동 기록 & 지식 피드백 루프'**

요즘 Cursor, Windsurf, Antigravity 같은 AI 코딩 어시스턴트와 함께 일하는 개발자가 정말 많습니다.  
자연어로 "이 기능 만들어줘", "이 버그 고쳐줘" 하며 빠르게 결과물을 뽑아내는 **바이브 코딩(Vibe Coding)**의 생산성은 경이롭기까지 합니다.

하지만 바이브 코딩을 깊게 활용해 본 개발자라면 누구나 다음의 **뼈아픈 한계**를 경험하게 됩니다:

### ⚠️ 바이브 코딩의 치명적 함정: 세션이 끝나면 날아가는 '맥락'
1. **대화창의 한계 (Context Amnesia)**: 채팅 세션이 길어지거나 새 대화창을 여는 순간, AI는 방금 전까지 나와 피땀 흘려 논의했던 아키텍처 제약 사항을 몽땅 잊어버립니다.
2. **환각과 반복되는 삽질**: AI에게 *"이 스케줄 5분 간격으로 동작하게 바꿔줘"*라고 하면, 예전에 OS 제약으로 실패했던 방식을 천연덕스럽게 다시 제안합니다.
3. **기록의 부재**: 코드는 AI가 짜줬는데, 왜 코드가 이렇게 생겼는지 정작 사람이 정리해 두지 않아 프로젝트가 거대한 블랙박스가 되어버립니다.

### 🤖 해결책: AI 에이전트에게 옵시디언을 '외장 뇌'로 쥐어주기
이 문제를 해결하는 열쇠는 **옵시디언의 본질**에 있습니다.  
노션이나 에버노트와 달리, **옵시디언은 내 로컬 파일시스템에 저장되는 순수 마크다운(`.md`) 파일**입니다. 즉, 터미널과 로컬 파일에 접근할 수 있는 AI 코딩 에이전트에게는 **가장 읽고 쓰기 쉬운 최고의 데이터베이스**인 셈이죠.

저는 개발자가 수작업으로 일지를 쓰는 대신, **AI 에이전트에게 전용 Skill(`obsidian-worklog`)과 규칙(Rule)**을 정의해 두었습니다:

```yaml
# .agent/skills/obsidian-worklog/SKILL.md (에이전트 규칙 예시)
name: obsidian-worklog
description: 개발 작업(기능 구현, 버그 픽스, 리팩토링) 완료 시 로컬 Obsidian Vault에 자동 기록

rules:
  vault_root: "/Users/pukoni/Documents/Obsidian/PUKONI"
  output_path: "Work_Log/YYYY-MM-DD/<Topic>.md"
  requirements:
    - 표준 YAML 프론트매터 태그 및 상위 MOC 백링크 필수 포함
    - 이슈 현상, 근본 원인, 해결 방법을 3단 트러블슈팅 테이블로 정리
    - 복잡한 프로세스 통신은 Mermaid 시퀀스 다이어그램으로 시각화
    - 작업 브랜치, 변경 파일 목록, 후속 조치(Next Steps) 체크리스트 작성
```

이제 제가 AI에게 일일이 *"노트 정리해"*라고 시킬 필요가 없습니다.  
새로운 기능을 개발하거나 복잡한 버그를 잡고 나면, **AI 에이전트가 알아서 제 옵시디언 볼트에 당일 날짜의 작업 일지를 표준 양식대로 생성**해 둡니다.

```text
[개발자: Vibe 요청] ─────▶ [AI 에이전트: 코드 작성 & 테스트]
                                     │
                                     ▼ 작업 완료 시 자동 트리거 (Skill)
                           ┌───────────────────────────────────┐
                           │   Obsidian Vault (로컬 마크다운)   │
                           │  - 3단 트러블슈팅 테이블           │
                           │  - Mermaid 아키텍처 다이어그램     │
                           │  - 표준 태그 & MOC 백링크         │
                           └───────────────────────────────────┘
                                     │
                                     ▼ 다음 개발 세션 시작 시 컨텍스트 주입
[새 AI 세션: "이전 일지 참조해줘"] ──▶ [AI 에이전트: 도메인 맥락 완벽 동기화]
```

### 🔄 지식의 재활용: 나만의 지식 피드백 루프 (Feedback Loop)
이 워크플로우의 진정한 마법은 **다음 개발 세션**에서 일어납니다.

며칠 뒤 백그라운드 모듈에 새로운 기능을 추가해야 할 때, 저는 AI에게 길게 설명하지 않습니다. 단 한 줄만 입력하면 됩니다:

> *"옵시디언 볼트의 `Work_Log/2026-09-12_백그라운드시청차단_버그픽스.md` 읽어보고, 그때 구축한 24시간 상시 감시 스케줄 구조 기반으로 통계 집계 기능 추가해줘."*

AI는 로컬 옵시디언 문서를 순식간에 파싱하여, **과거의 실패 경험(15분 스케줄 제약), 데이터 흐름(Mermaid 다이어그램), 아키텍처 의사결정 이유를 완벽히 인지한 상태에서 새로운 코드를 작성**합니다.

내가 AI와 나눈 모든 치열한 디버깅 이력이 휘발되지 않고 옵시디언에 쌓이며, 그 지식이 다시 다음 AI 코딩의 품질을 끌어올리는 **완벽한 지식 플라이휠(Flywheel)**이 완성되는 것입니다.

---

## **마치며: "바이브 코딩 시대, 엔지니어의 진짜 자산은 맥락이다"**

누구나 프롬프트 몇 줄로 수백 줄의 코드를 찍어낼 수 있는 시대가 되었습니다.  
그렇다면 미래의 개발자에게 남는 진짜 경쟁력은 무엇일까요?

저는 **'생성된 코드의 양'이 아니라, '그 코드를 지탱하는 맥락(Why)을 얼마나 깊이 있게 이해하고 통제할 수 있는가'**에 있다고 믿습니다.

AI에게 단순히 코드를 짜게 하는 것을 넘어, 나의 세컨드 브레인을 함께 가꾸는 파트너로 만들어보세요.  
AI가 남겨준 오늘의 디버깅 기록이, 내일의 여러분과 AI에게 가장 든든한 날개가 되어줄 것입니다. ☕
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Vercel에서 Cloudflare Pages로: Next.js 15 정적 블로그 마이그레이션 기록]]></title>
        <id>https://dbgmy.life/blog/migrating-nextjs15-blog-from-vercel-to-cloudflare-pages</id>
        <link href="https://dbgmy.life/blog/migrating-nextjs15-blog-from-vercel-to-cloudflare-pages"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/cloudflare_migration.png" type="image/png"/>
        <updated>2026-09-12T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[서버리스의 편리함 뒤에 숨겨진 복잡성을 걷어내고, Next.js 15를 완전한 정적 HTML로 구워내어 Cloudflare 엣지 CDN에 안착시킨 실전 마이그레이션 기록.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **"블로그 하나 띄우는데, 굳이 서버리스 함수가 필요할까요?"**

인프라를 설계하고 운영하며 뼈저리게 배운 철칙이 하나 있습니다.  
**"구조가 단순할수록 장애가 나지 않고, 기본에 충실할수록 유지보수가 편하다."**

그동안 제 블로그는 [Vercel](https://vercel.com) 위에서 잘 돌아가고 있었습니다. Vercel, 솔직히 정말 좋은 플랫폼입니다. Git 푸시 한 번이면 알아서 빌드되고, 프리뷰 링크 던져주고, 서버리스 함수까지 뚝딱 만들어주니까요.

그런데 문득 퇴근길 지하철에서 이런 생각이 들더군요.  
*"내가 쓰는 글이라곤 마크다운 파일 몇 개가 전부인데, 방문자가 들어올 때마다 서버리스 함수가 깨어나고 Node.js 런타임이 돌아야 할 이유가 대체 뭘까?"*

게다가 간혹 들려오는 Vercel 대역폭 초과 요금 폭탄 사례나 무료 티어의 한계들을 보면서, 언제 터질지 모르는 불씨를 안고 있는 기분이었습니다. 그래서 결심했습니다.  

**"Next.js 15를 순수 정적 HTML(`output: 'export'`)로 완전히 구워내어, 전 세계 300여 개 엣지 데이터센터를 가진 Cloudflare Pages로 탈출하자."**

<Image
  src="/images/blog/cloudflare_migration.png"
  alt="Vercel to Cloudflare Pages Migration Architecture"
  width={800}
  height={450}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

---

## **왜 Cloudflare Pages 정적 호스팅인가?**

선택지는 크게 두 가지였습니다:
1. **OpenNext (`@opennextjs/cloudflare`)**: Cloudflare Workers 위에서 Next.js SSR/서버리스 환경을 흉내 내는 방식
2. **Cloudflare Pages Static Export**: Next.js 빌드 시점에 모든 페이지를 HTML/CSS/JS로 뽑아내어 글로벌 CDN에 바로 올리는 방식

망설임 없이 **2번 정적 내보내기(Static Export)**를 택했습니다.

- **비용 0원 & 무제한 대역폭**: Cloudflare Pages 정적 호스팅은 트래픽이 수백만 건이 터져도 대역폭 비용이 청구되지 않습니다.
- **초고속 TTFB (Time to First Byte)**: 서버리스 함수의 콜드 스타트(Cold Start) 따위는 존재하지 않습니다. 지구상 어디서 접속하든 10~30ms 만에 첫 패킷이 떨어집니다.
- **장애 포인트 제로**: 데이터베이스 연결도, 런타임 Node.js 프로세스도 없으니 새벽에 서버 죽었다고 알람 울릴 일이 원천적으로 차단됩니다.

하지만 막상 `next.config.js`에 `output: 'export'` 한 줄 넣고 빌드를 돌려보니... 역시 현실은 호락호락하지 않더군요.

---

## **실전 마이그레이션: 마주친 4가지 복병과 트러블슈팅**

### **복병 1: searchParams와 CSR Bailout 에러**

가장 먼저 맞닥뜨린 에러는 블로그 검색 페이지였습니다:
```bash
⨯ useSearchParams() should be wrapped in a suspense boundary at page "/blog/search"
Error occurred prerendering page "/blog/search".
Export encountered an error on /blog/search/page
```

Next.js 15의 App Router에서는 서버 컴포넌트가 `searchParams`를 비동기(`await searchParams`)로 직접 조회할 수 있습니다. Vercel SSR 환경에서는 매 요청마다 서버가 파라미터를 읽어 HTML을 렌더링해주니 잘 작동했지만, **정적 빌드 시점에는 브라우저 쿼리 스트링(`?q=keyword`)을 알 수 없기 때문에** 빌드가 뻗어버리는 것입니다.

#### 💡 해결 방법:
서버 컴포넌트와 클라이언트 검색 컴포넌트를 깔끔하게 분리했습니다:

```tsx
// src/app/blog/search/page.tsx (서버 컴포넌트)
export default async function SearchPage() {
  // 빌드 타임에 모든 글의 메타데이터를 정적으로 로드
  const allPosts = await getAllPosts();

  return (
    <div className="container mx-auto max-w-screen-xl">
      {/* 쿼리 처리는 Suspense로 감싼 클라이언트 컴포넌트에 위임 */}
      <SearchClient allPosts={allPosts} />
    </div>
  );
}
```

그리고 검색 입력과 필터링을 담당하는 클라이언트 컴포넌트를 `Suspense` 경계로 감싸주었습니다:

```tsx
// src/components/blog/SearchClient.tsx (클라이언트 컴포넌트)
export function SearchClient({ allPosts }: SearchClientProps) {
  return (
    <Suspense fallback={<SearchClientFallback />}>
      <SearchClientContent allPosts={allPosts} />
    </Suspense>
  );
}
```

이렇게 바꾸니 빌드 시점에는 전체 글 목록을 가진 온전한 정적 HTML이 구워지고, 사용자가 검색창에 타이핑할 때는 페이지 새로고침 없이 **브라우저 메모리 상에서 실시간으로 즉시 필터링**되는 보너스(UX 대폭 향상!)까지 얻었습니다.

---

### **복병 2: 사라진 서버리스 API 라우트와 RSS 피드의 행방**

기존 블로그는 `/api/rss`, `/api/atom`, `/api/feed` 경로의 Next.js Route Handler(`route.ts`)가 서버에서 XML 파일을 읽어 스트리밍 응답을 해주는 구조였습니다.

정적 export 모드에서는 이 Route Handler들이 빌드 에러를 내거나 확장자 없는 바이너리 파일로 덤프되어 정적 웹서버에서 MIME 타입(`application/rss+xml`)이 망가질 위험이 있었습니다.

#### 💡 해결 방법:
1. **빌드 순서 역전**: `npm run build` 스크립트를 수정하여, Next.js 빌드가 시작되기 전에 RSS 생성 스크립트가 먼저 실행되도록 했습니다:
   ```json
   "build": "node scripts/generate-rss.mjs && next build"
   ```
   이렇게 하면 `public/rss.xml`, `public/atom.xml`, `public/feed.json`이 먼저 생성되고, Next.js가 빌드할 때 이 파일들이 자동으로 `out/` 정적 디렉터리로 복사됩니다.

2. **Cloudflare 200 Rewrite 규칙 적용**:
   기존에 제 블로그 피드를 구독 중이던 독자분들의 RSS 리더기가 끊기면 안 되겠죠?  
   Cloudflare Pages의 `public/_redirects` 파일에 200 Rewrite 규칙을 선언했습니다:
   ```text
   /api/rss    /rss.xml    200
   /api/atom   /atom.xml   200
   /api/feed   /feed.json  200
   ```
   이제 누군가 예전 주소인 `/api/rss`로 요청을 보내도, Cloudflare 엣지가 URL 변경 없이 정적 `rss.xml` 파일의 내용과 헤더를 즉시 내려줍니다.

---

### **복병 3: robots.txt와 sitemap.xml의 `force-static` 선언**

Next.js 15의 App Router 메타데이터 파일(`robots.ts`, `sitemap.ts`)을 쓸 때 정적 빌드에서 빠뜨리기 쉬운 함정이 있습니다.

```bash
Error: export const dynamic = "force-static" not configured on route "/robots.txt" with "output: export"
```

Next.js에게 이 파일들이 런타임 요청 시 동적으로 만들어지는 것이 아니라, 빌드 타임에 완전히 고정되는 정적 파일임을 명시해 주어야 합니다:

```ts
// src/app/robots.ts & src/app/sitemap.ts
export const dynamic = 'force-static';
```

이 한 줄을 추가하고, 기존 `public/robots.txt` 중복 파일을 정리해 주니 깔끔하게 `out/robots.txt`와 `out/sitemap.xml`이 생성되었습니다.

---

### **복병 4: Giscus 댓글창과 빌드 타임 환경 변수 타이밍**

블로그 댓글은 GitHub Discussions 기반의 [Giscus](https://giscus.app)를 사용합니다.  
그런데 첫 배포 후 글 상세 페이지에 들어가니 빨간 박스로 에러가 뜨더군요:

> *"댓글 시스템을 로드하는데 필요한 환경 변수가 설정되지 않았습니다."*

정적 사이트(`output: 'export'`)에서 `NEXT_PUBLIC_*` 환경 변수는 서버 런타임에 읽히는 것이 아니라, **빌드 시점에 JS 코드 안으로 문자열 치환(Inlining)**되어 들어갑니다.  
Cloudflare Pages 대시보드에서 환경 변수를 넣을 때 사소한 오타가 나거나 필드 길이가 잘리면 바로 댓글창이 뻗어버리는 취약한 구조였던 것이죠.

#### 💡 해결 방법:
Giscus의 Repo ID나 Category ID는 비밀번호나 API Secret이 아닌 **공개된 GitHub 식별자**입니다.  
환경 변수에 의존하는 대신, 코드 레벨에서 안전한 fallback 기본값을 내장하도록 방어적으로 리팩터링했습니다:

```tsx
// src/components/blog/Giscus.tsx
const DEFAULT_GISCUS = {
  repo: "Debugging-My-Life/Discussions",
  repoId: "R_kgDOO1ZMHw",
  category: "General",
  categoryId: "DIC_kwDOO1ZMH84Cq_Oh",
};

export function Giscus({
  repo = process.env.NEXT_PUBLIC_GISCUS_REPO || DEFAULT_GISCUS.repo,
  repoId = process.env.NEXT_PUBLIC_GISCUS_REPO_ID || DEFAULT_GISCUS.repoId,
  // ...
})
```
이제 Cloudflare 대시보드에서 환경 변수 주입이 꼬이더라도 댓글 시스템은 항상 100% 견고하게 작동합니다.

---

## **무중단 도메인 이전: Vercel에서 Cloudflare로 스위칭**

인프라 엔지니어에게 서비스 중단(다운타임)은 자존심의 문제입니다. 개인 블로그라도 예외는 없죠.

1. **Cloudflare Pages 배포 완료 확인**: 도메인을 건드리기 전에 임시 주소(`blog-dt6.pages.dev`)에서 사이트 기능이 100% 정상 작동하는지 먼저 검증했습니다.
2. **Custom Domains 등록**: Cloudflare Pages 대시보드에서 `www.dbgmy.life`와 루트 도메인 `dbgmy.life`를 추가했습니다.
3. **DNS 스위칭**: Cloudflare가 발급한 SSL 인증서가 **`🟢 활성`**으로 바뀌는 것을 확인했습니다. DNS 전파 시간 동안 일부 사용자는 Vercel로, 일부는 Cloudflare로 접속되므로 **단 1초의 접속 끊김도 발생하지 않았습니다.**
4. **Vercel 정리**: Cloudflare로 트래픽이 완전히 넘어간 뒤, Vercel 대시보드에서 도메인 연결을 깔끔하게 해제했습니다.

---

## **마치며: "결국 기본으로 돌아가는 게 가장 강력합니다"**

마이그레이션을 마치고 터미널에서 `curl`로 도메인을 찔러보았습니다.

```bash
$ curl -s -I https://www.dbgmy.life
HTTP/2 200 
server: cloudflare
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
cf-cache-status: DYNAMIC
```

Cloudflare 엣지에서 뿜어져 나오는 저 군더더기 없는 헤더를 보는데 묘한 카타르시스가 느껴졌습니다.

요즘 프론트엔드 생태계는 점점 더 복잡해지고 있습니다. SSR, RSC, 스트리밍, 엣지 런타임... 화려한 기술들이 쏟아져 나오지만, **변하지 않는 콘텐츠를 다루는 블로그의 본질은 결국 '정적 파일'**입니다.

과도한 엔지니어링의 거품을 걷어내고 가장 단순한 정적 아키텍처로 회귀했을 때, 우리는 비로소 속도와 비용, 그리고 안정성이라는 세 마리 토끼를 모두 잡을 수 있습니다.

이제 마음 편히 글 쓰는 일에만 집중할 수 있을 것 같습니다. 오늘 밤 퇴근길은 유난히 가벼울 것 같네요. ☕
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[2025년 대형 보안 사고 결산 및 기술적 복기: BPFDoor, 랜섬웨어 재감염, 그리고 기본기의 부재]]></title>
        <id>https://dbgmy.life/blog/2025-security-review</id>
        <link href="https://dbgmy.life/blog/2025-security-review"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/security_2025_header_v2.png" type="image/png"/>
        <updated>2025-11-22T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[크리덴셜 스터핑, VPN 제로데이와 BPFDoor 은닉 백도어, 랜섬웨어 재감염 사태까지. 유난히 다사다난했던 2025년 주요 침해사고의 공격 메커니즘과 실무 방어 아키텍처를 복기합니다.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **"유독 퇴근길이 무거웠던 한 해를 돌아보며"**

처음 보안과 인프라 영역에 발을 들였을 때는 밤새 패킷 덤프를 뜯어보고 서버 로그 사이에서 공격자의 흔적을 찾아내는 과정 자체가 마냥 흥미로웠습니다. 하지만 현업에서 시스템을 지키고 운영하는 시간이 쌓일수록, 아침 뉴스에서 대형 침해사고 소식을 접할 때마다 남 일 같지 않은 무거운 한숨이 먼저 나옵니다.

2025년은 국내외를 막론하고 유난히 굵직한 보안 사고가 연달아 터져 나온 해였습니다. 이커머스, 통신사, 대형 플랫폼까지 산업군을 가리지 않고 뚫렸고, 그 이면을 들여다보면 화려한 제로데이 공격 못지않게 **가장 기초적인 인증·백업·망 분리 원칙이 무너진 사례**가 뼈아프게 다가왔습니다.

이 글에서는 2025년을 뒤흔들었던 4가지 핵심 침해사고 유형을 단순한 뉴스 요약이 아닌 **실무 엔지니어의 시각에서 공격 메커니즘과 탐지·방어 아키텍처로 복기**해 보고자 합니다.

<Image
  src="/images/blog/security_2025_header_v2.png"
  alt="2025 Security Timeline and Threat Landscape"
  width={800}
  height={450}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

---

## **1. 한눈에 보는 2025년 4대 침해사고 유형 및 방어 대책 요약**

본격적인 기술 분석에 앞서, 올해 발생한 주요 사고의 핵심 원인과 이를 막기 위해 인프라에 반드시 적용해야 할 기술적 통제 대책을 표로 정리했습니다.

| 시기 / 사례 | 핵심 공격 벡터 (Attack Vector) | 기존 방어 체계가 뚫린 근본 원인 | 실무 아키텍처 개선 및 탐지 대책 |
| :--- | :--- | :--- | :--- |
| **2월 (이커머스 개인정보 유출)** | **분산 크리덴셜 스터핑 (Credential Stuffing)** | 단일 IP 기반 Rate Limiting에만 의존하여 가정용 프록시(Residential Proxy) 봇넷 우회 허용 | • **FIDO2 / Passkey 및 적응형 MFA** 의무화<br/>• JA3/JA4 TLS 핑거프린팅 및 행위 기반 봇 탐지 |
| **4월 (통신/인프라 침해)** | **경계 장비(Ivanti VPN 등) 취약점 + BPFDoor 백도어** | 가용성 부담으로 인한 VPN 패치 지연 및 리스닝 포트(`ss`/`netstat`) 위주의 구식 점검 | • `/proc/net/packet` 기반 **Raw Socket(BPF) 전수 헌팅**<br/>• 경계 VPN을 **ZTNA(Zero Trust Network Access)**로 전환 |
| **6월·8월 (대형 서점 2차례 마비)** | **랜섬웨어 중복 감염 및 백업 저장소 동시 파괴** | 백업 서버가 운영 AD(Active Directory) 및 동일 관리망에 묶여 있어 측면 이동 시 함께 암호화됨 | • **3-2-1-1-0 백업 원칙** 및 **WORM(Object Lock) 불변 스토리지**<br/>• 초기 침투 경로(웹쉘/VPN 토큰) 완전 소거 후 복구 |
| **11월 (통신 엣지 장비 침해)** | **초소형 기지국(Femtocell) 변조 및 장기 잠복** | 물리적 통제권 밖에 배치된 엣지 하드웨어에 대한 암묵적 신뢰(Implicit Trust) 및 은폐 문화 | • 엣지 단말 **TPM 하드웨어 무결성 검증 및 mTLS 상호 인증**<br/>• 코어망 진입 전 마이크로 세그멘테이션 격리 |

---

## **2. 사고별 심층 복기: 어디서부터 잘못되었고 어떻게 막아야 하는가**

### **① 2월: 158만 건 유출이 남긴 숙제 — 크리덴셜 스터핑의 진화**
새해 벽두부터 대형 홈쇼핑·이커머스 플랫폼에서 158만 건에 달하는 개인정보가 유출되었습니다. 공격 기법은 고전적인 **크리덴셜 스터핑(Credential Stuffing)**이었습니다. 다크웹에 유출된 타 사이트의 아이디·비밀번호 조합을 자동화 도구에 넣어 무차별 대입한 것입니다.

현업 방어자 입장에서 가장 허탈한 순간이 바로 이 지점입니다. 방화벽과 시큐어 코딩을 아무리 단단히 해두어도, 공격자는 취약점을 익스플로잇하는 대신 **정상 사용자의 계정으로 '로그인'해서 정문으로 걸어 들어오기 때문**입니다.

#### 🔍 왜 기존 WAF의 IP 차단(Rate Limiting)이 무력화되었을까?
과거에는 공격자가 소수의 클라우드 서버 IP에서 초당 수백 번의 로그인을 시도했기 때문에 WAF에서 `1분당 10회 이상 실패 시 IP 차단` 룰만 걸어도 쉽게 막혔습니다.
하지만 최근 공격 조직은 수십만 대의 감염된 가정용 공유기·IoT 기기로 구성된 **레지덴셜 프록시(Residential Proxy) 봇넷**을 사용합니다. IP 하나당 딱 1~2번만 로그인을 시도하고 다음 IP로 넘어가기 때문에 전통적인 IP 임계치 기반 차단은 완전히 무력화됩니다.

- **실무 대응 전략**:
  1. **JA3 / JA4 TLS 핑거프린팅 적용**: IP가 바뀌더라도 자동화 도구(Python Requests, Go HTTP, Headless Chrome 변종)가 맺는 TLS Client Hello 패킷의 암호화 스위트 조합은 일정한 패턴을 가집니다. 앞단 CDN/WAF에서 비정상 JA4 핑거프린트를 차단해야 합니다.
  2. **Risk-Based MFA & Passkey 도입**: 모든 로그인에 2차 인증을 걸면 UX가 나빠진다는 반발이 있다면, 최소한 **새로운 기기/국가/ASN에서 접속하거나 개인정보 조회·수정 페이지에 접근할 때만이라도 Step-up MFA**를 강제해야 합니다.

---

### **② 4월: 경계 보안 장비(VPN)의 배신과 'BPFDoor' 리눅스 은닉 백도어**
봄에는 통신 및 주요 인프라 업계를 뒤흔든 VPN 취약점 사태와 함께, 공격자가 내부 리눅스 서버에 심어둔 **BPFDoor** 악성코드가 다시금 수면 위로 떠올랐습니다.

보안실무자들 사이에서 *"가장 위험한 장비는 패치 안 된 보안 장비"*라는 자조 섞인 농담이 있습니다. SSL VPN, 방화벽, 망연계 장비는 외부 인터넷과 내부 코어망의 경계(Perimeter)에 위치해 있고 `root` 권한으로 데몬이 돌아가는 경우가 많습니다. 가용성(무중단 운영) 핑계로 패치를 미루는 순간, 방패가 오히려 공격자의 고속도로가 됩니다.

#### 🔍 BPFDoor는 어떻게 1년 넘게 관제 시스템을 속였는가?
특히 이번 사태에서 주목해야 할 기술적 포인트는 **BPFDoor의 극단적인 은닉성(Stealth)**입니다. 일반적인 백도어는 특정 TCP/UDP 포트를 열어두고(`listen()`) 공격자의 접속을 기다리므로, 관리자가 `ss -antp`나 `netstat`만 쳐봐도 금방 발각됩니다. 또한 방화벽(iptables)에서 인바운드 포트를 막아두면 접속할 수 없습니다.

반면 BPFDoor는 **포트를 전혀 열지 않습니다.** 대신 리눅스 커널의 **Raw Socket (`AF_PACKET`)과 cBPF/eBPF(Berkeley Packet Filter)**를 악용합니다:

```text
[외부 공격자] ──(Magic Packet: 평범한 HTTP/SSH/ICMP 패킷 내부에 특정 바이트 시그니처 삽입)──▶ [NIC 네트워크 카드]
                                                                                              │
      ┌───────────────────────────────────────────────────────────────────────────────────────┘
      ▼
[리눅스 커널 레이어 (Netfilter / iptables 방화벽 도달 이전!)]
  ├─▶ 1. BPFDoor가 설치한 BPF 필터가 패킷 사본을 가로채어 매직 바이트(예: 0x7255) 검사
  │     └─▶ 일치할 경우에만 순간적으로 리버스 쉘 또는 바인드 쉘 활성화!
  └─▶ 2. 일반 트래픽은 정상 웹 서버(Nginx/Apache) 및 iptables로 그대로 통과
```

방화벽(`iptables`/`nftables`)보다 더 아래 계층인 네트워크 드라이버 직후 단계에서 패킷을 훔쳐보기 때문에, **서버 방화벽이 막혀 있어도, 심지어 이미 사용 중인 정상 80/443 포트로 들어오는 패킷을 통해서도 백도어 명령을 수신**할 수 있습니다.

#### 🛡️ 실무 리눅스 서버 BPFDoor 즉시 점검 커맨드
현재 운영 중인 리눅스 인프라에서 BPFDoor 계열의 Raw Socket 스니핑 프로세스가 떠 있는지 확인하려면 `netstat`가 아니라 아래 3가지 커맨드로 헌팅해야 합니다:

```bash
# 1. Raw Socket (AF_PACKET, type=3)을 열고 패킷을 스니핑 중인 프로세스 및 Inode 추적
cat /proc/net/packet
# 출력 예시에서 'sk'와 'inode' 번호를 확인한 후 어떤 프로세스(PID)가 잡고 있는지 매핑:
sudo lsof -i -n -P | grep -E "RAW|PACKET"
sudo find /proc/[0-9]*/fd -lname "socket:\[*" -exec ls -l {} + 2>/dev/null

# 2. 소켓에 BPF 필터(SO_ATTACH_FILTER)가 부착된 프로세스 전수 스캔 (ss -0bp)
sudo ss -0bp

# 3. 메모리 상에서 실행 파일이 삭제된 채(fileless) 돌아가는 위장 프로세스 탐지
sudo ls -l /proc/*/exe 2>/dev/null | grep "(deleted)"
```

---

### **③ 6월 & 8월: 두 달 만에 반복된 랜섬웨어 재감염 — 왜 백업이 소용없었나?**
대형 온라인 서점 플랫폼이 6월에 랜섬웨어로 전면 마비된 데 이어, 불과 두 달 만인 8월에 또다시 랜섬웨어 공격을 받아 서비스가 멈추는 초유의 사태가 벌어졌습니다.

두 달 만에 재감염이 발생했다는 것은 침해사고 대응(IR) 관점에서 두 가지 치명적인 실패가 있었음을 시사합니다:
1. **초기 침투 경로(Initial Access) 및 잔존 백도어 미제거**: 1차 감염 당시 단순히 암호화된 서버를 백업본으로 덮어쓰기(Restore)만 했을 뿐, 공격자가 처음 들어온 취약점이나 탈취해 둔 도메인 관리자(Domain Admin) 자격증명, 혹은 다른 세그먼트에 숨겨둔 슬리퍼 웹쉘(Sleeper Webshell)을 완전히 뿌리 뽑지(Eradication) 못한 상태에서 서비스를 재개한 것입니다.
2. **백업 인프라의 관리 영역(Control Plane) 분리 실패**: 많은 기업이 "우리는 매일 백업을 받습니다"라고 말하지만, 정작 그 백업 스토리지(NAS, 백업 어플라이언스)가 **운영 서버와 동일한 Active Directory(AD) 도메인에 묶여 있거나 동일한 관리자 서브넷에 연결**되어 있습니다. 공격자는 운영 서버를 암호화하기 직전에 백업 서버의 스냅샷부터 먼저 삭제합니다.

#### 🛡️ 랜섬웨어 생존을 위한 '3-2-1-1-0' 불변(Immutable) 백업 아키텍처
현대 랜섬웨어 방어의 핵심은 "감염되지 않는 것"을 넘어 **"도메인 관리자 권한이 털려도 백업본은 절대 지울 수 없게 만드는 것"**입니다:

- **Object Lock (WORM - Write Once, Read Many) 강제 활성화**: AWS S3 Object Lock(Compliance Mode)이나 엔터프라이즈 스토리지의 불변 스냅샷 기능을 켜서, 설령 루트(Root) 계정이나 클라우드 최고 관리자 계정이 탈취되더라도 지정된 보존 기간(예: 14일~30일) 동안은 데이터 삭제·수정이 물리적으로 불가능하게 잠가야 합니다.
- **Pull 방식의 백업 망 분리**: 운영 서버에서 백업 서버로 데이터를 밀어넣는(Push) 구조가 아니라, 별도의 완전히 분리된 보안 영역에 위치한 백업 서버가 단방향으로 데이터를 가져오도록(Pull) 구성하여 운영망 침해가 백업망으로 전이되는 것을 차단해야 합니다.

---

### **④ 11월: 초소형 기지국(Femtocell) 침해와 '은폐'라는 더 큰 취약점**
연말에 가장 씁쓸함을 남긴 사건은 통신 인프라의 말단에 위치한 펨토셀(초소형 기지국) 장비가 해킹되어 소액결제 인증 가로채기에 악용되고, 심지어 내부 서버의 BPFDoor 감염 정황을 장기간 은폐하려 했다는 의혹이 불거진 사태였습니다.

기술적으로 펨토셀이나 지점 라우터, IoT 게이트웨이 같은 **엣지(Edge) 장비는 공격자가 물리적으로 장비를 입수해 펌웨어를 리버스 엔지니어링하고 내부 인증서를 추출할 수 있는 가장 취약한 고리**입니다. 따라서 *"우리 장비가 설치한 기지국이니 안전한 트래픽이겠지"*라고 코어망 연결을 허용하는 암묵적 신뢰(Implicit Trust)는 반드시 폐기되어야 합니다. 모든 엣지 장비는 하드웨어 TPM 기반의 기기 증명(Device Attestation)과 최소 권한 마이크로 세그멘테이션(Micro-segmentation)을 거쳐야만 합니다.

하지만 기술적 취약점보다 현업 엔지니어들의 어깨를 더 처지게 만든 것은 **사고를 대하는 조직의 태도**였습니다. 보안 사고는 날 수 있습니다. 사람이 설계하고 운영하는 이상 100% 완벽한 시스템은 존재하지 않기 때문입니다. 그러나 사고가 났을 때 이를 투명하게 공유하고 근본 원인을 도려내는 대신, *"외부에 알려지지만 않으면 된다"*는 식의 은폐 문화가 작동하는 순간 기술적 방어선은 안에서부터 무너집니다.

---

## **3. 퇴근길에 되새기는 3가지 보안 원칙**

<Image
  src="/images/blog/ai_security_defense_v2.png"
  alt="Modern Cyber Defense Architecture Concept"
  width={800}
  height={450}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

### **1. 화려한 'AI 보안' 이전에 지루한 '기본기(Cyber Hygiene)'부터**
요즘 보안 컨퍼런스에 가보면 온통 "AI 기반 자율 보안", "생성형 AI 관제" 이야기뿐입니다. 물론 AI는 훌륭한 도구입니다. 하지만 당장 외부에 노출된 VPN 장비의 펌웨어 패치도 안 되어 있고, 백업 스토리지가 운영망과 한통속으로 묶여 있으며, 관리자 계정에 2차 인증조차 걸려 있지 않은 상태에서 수억 원짜리 AI 솔루션을 도입한들 무슨 소용이 있을까요?
공격자를 막아내는 진짜 힘은 화려한 신기술이 아니라 **패치 관리, 최소 권한 원칙, 망 분리, 불변 백업이라는 지루하고 티 안 나는 기본기**를 우직하게 지키는 데서 나옵니다.

### **2. '경계(Perimeter)'는 이미 뚫렸다고 가정하라 (Assume Breach)**
올해 터진 거의 모든 대형 사고는 "VPN만 통과하면 내부망은 안전하다"는 경계 기반 보안 모델의 파산을 증명했습니다. 공격자가 크리덴셜 스터핑으로 들어오든, VPN 제로데이로 들어오든, **내부망에 발을 디딘 직후 옆 서버로 이동(Lateral Movement)하지 못하도록 막는 내부 통제와 가시성 확보**가 무엇보다 시급합니다.

### **3. 보안은 담당자 개인의 독박이 아니라 '경영진의 아키텍처'다**
사고가 터지면 화살은 늘 실무 보안 담당자에게 쏟아집니다. 하지만 패치 작업을 위해 새벽 점검 시간을 잡으려 해도 서비스 중단 우려로 반려되고, 백업 스토리지 분리 예산이 삭감되는 환경에서는 그 어떤 뛰어난 엔지니어도 사고를 막을 수 없습니다. 보안은 비용(Cost)이 아니라 비즈니스가 한순간에 증발하지 않도록 지탱하는 **핵심 신뢰성 엔지니어링(Reliability Engineering)**이라는 인식이 자리 잡아야 합니다.

---

## **마치며**

돌아보니 올 한 해 정말 숨 가쁘고 아픈 사건들이 많았습니다. 그럼에도 불구하고 지금 이 순간에도 수많은 인프라와 보안 엔지니어들이 보이지 않는 터미널 뒤에서 묵묵히 패치를 적용하고, 오탐 로그를 걸러내며 방어선을 지키고 있습니다.

다가오는 해에는 부디 대형 침해사고 속보 대신, *"올해는 참 평온하고 조용했다"*라고 웃으며 회고록을 쓸 수 있기를 진심으로 바라봅니다. 오늘도 현장에서 방패를 들고 계신 모든 엔지니어분들, 고생 많으셨습니다. 🍻
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[OCSF(Open Cybersecurity Schema Framework) 심층 분석: 파편화된 보안 로그 표준화와 데이터 레이크 설계]]></title>
        <id>https://dbgmy.life/blog/ocsf</id>
        <link href="https://dbgmy.life/blog/ocsf"/>
        <link rel="enclosure" href="https://dbgmy.life/images/blog/ocsf.png" type="image/png"/>
        <updated>2025-06-04T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[벤더마다 제각각인 SIEM 로그 파서(Parser) 유지보수 지옥을 끝내기 위해 등장한 OCSF의 4계층 스키마 구조, CEF/ECS와의 차이점, 그리고 실무 JSON 매핑 및 위협 헌팅 쿼리까지 상세히 알아봅니다.]]></summary>
        <content type="html"><![CDATA[
import Image from "next/image";

# **보안 관제(SecOps)의 가장 값비싼 병목: '로그 파서(Parser) 지옥'**

기업 인프라에 방화벽, EDR, 클라우드 감사 로그(AWS CloudTrail), 웹 방화벽(WAF), 인증 서버(IdP)를 연동해 본 보안 엔지니어라면 누구나 똑같은 벽에 부딪힙니다. **모든 벤더가 똑같은 보안 이벤트를 전혀 다른 필드명과 구조로 기록한다는 점**입니다.

예를 들어 *"외부 IP에서 내부 서버로 접속을 시도했다"*는 단 하나의 네트워크 이벤트를 기록할 때도 벤더마다 표현 방식이 완전히 다릅니다:

- **방화벽 A사 (Syslog / Key-Value)**: `src=203.0.113.45 spt=54321 dst=10.10.20.15 dpt=443 act=blocked`
- **클라우드 B사 (JSON)**: `{"sourceIPAddress": "203.0.113.45", "requestParameters": {"destPort": 443}}`
- **EDR C사 (Proprietary XML/JSON)**: `{"network": {"client": {"ip": "203.0.113.45"}, "direction": "inbound"}}`

이 파편화된 데이터를 단일 SIEM이나 보안 데이터 레이크(Security Data Lake)에 적재하려면 수백 줄의 정규표현식(Regex) 파서와 ETL(Extract, Transform, Load) 파이프라인을 직접 짜야 합니다. 더 심각한 문제는 벤더가 펌웨어나 에이전트 버전을 올리면서 로그 포맷을 살짝 바꾸는 순간, 공들여 만든 탐지 룰(Detection Rules)과 대시보드가 조용히 먹통이 된다는 것입니다.

이 고질적인 파서 유지보수 부채(Parser Maintenance Debt)와 특정 SIEM 벤더 종속(Vendor Lock-in) 문제를 근본적으로 해결하기 위해 등장한 오픈소스 표준이 바로 **OCSF(Open Cybersecurity Schema Framework)**입니다.

<Image
  src="/images/blog/ocsf.png"
  alt="OCSF Open Cybersecurity Schema Framework Architecture"
  width={800}
  height={450}
  className="rounded-lg mx-auto my-8 border border-primary/20 shadow-lg shadow-primary/10"
/>

---

## **1. 기존 로그 표준(CEF, LEEF, ECS)의 한계와 OCSF의 등장 배경**

OCSF 이전에도 로그 포맷을 통일하려는 시도는 여러 번 있었습니다. 대표적으로 ArcSight의 **CEF(Common Event Format)**, QRadar의 **LEEF**, Elastic의 **ECS(Elastic Common Schema)**, Splunk의 **CIM(Common Information Model)** 등이 있습니다.

하지만 기존 스키마들은 두 가지 명확한 구조적 한계를 안고 있었습니다:

1. **특정 SIEM 벤더 종속성**: CEF는 ArcSight, CIM은 Splunk, ECS는 Elastic 생태계에 최적화되어 있어, 타사 솔루션이나 클라우드 네이티브 데이터 레이크(Apache Iceberg, Parquet 기반)로 데이터를 이관할 때 스키마를 처음부터 다시 매핑해야 했습니다.
2. **클라우드·컨테이너·아이덴티티 텔레메트리 표현력 부족**: 과거 온프레미스 네트워크 장비 위주로 설계된 평면적(Flat) 키-값 구조로는 최신 쿠버네티스(Kubernetes) 포드 컨텍스트, IAM 역할 체인(Role Chaining), API 호출 메타데이터처럼 깊게 중첩된 현대적 보안 이벤트를 손실 없이 담아내기 어려웠습니다.

이에 AWS, Splunk, CrowdStrike, Palo Alto Networks, Rapid7, Cloudflare, Okta 등 100여 개 이상의 글로벌 보안·클라우드 기업이 컨소시엄을 구성해 **"데이터가 수집된 후 SIEM에서 변환(Schema-on-Read)하는 것이 아니라, 보안 솔루션이 로그를 생성하는 시점부터 벤더 중립적인 오픈 스키마(Schema-on-Write)로 출력하자"**는 합의를 이끌어냈고, 그 결과물이 바로 **OCSF**입니다.

| 비교 항목 | 레거시 표준 (CEF / LEEF) | 벤더 전용 스키마 (CIM / ECS) | **OCSF (Open Cybersecurity Schema Framework)** |
| :--- | :--- | :--- | :--- |
| **주도 주체** | 단일 SIEM 벤더 (ArcSight, IBM) | 특정 플랫폼 벤더 (Splunk, Elastic) | **글로벌 오픈소스 컨소시엄 (AWS, Splunk, CrowdStrike 등 100+ 기업)** |
| **데이터 구조** | 평면적 문자열 (Key-Value String) | JSON 기반 (특정 검색엔진에 종속) | **계층적(Hierarchical) 강타입 스키마 (JSON, Parquet, Avro 완벽 호환)** |
| **정규화 시점** | 수집 후 ETL 파서에서 변환 | 수집 파이프라인(Ingest Node)에서 변환 | **로그 생성 원천(Source) 또는 경량 파이프라인에서 표준 포맷으로 즉시 출력** |
| **스토리지 최적화** | 텍스트 위주 (압축률·쿼리 성능 낮음) | 검색 인덱스 중심 (장기 보관 비용 높음) | **컬럼형 스토리지(Apache Parquet / Iceberg) 및 SQL 엔진에 최적화** |
| **확장성** | `cs1`, `cn1` 등 모호한 커스텀 필드 남발 | 벤더 스펙 외 확장 시 호환성 저하 | **Profile 및 Extension 메커니즘으로 코어 스키마 훼손 없이 확장 가능** |

---

## **2. OCSF 스키마의 4계층 핵심 아키텍처 해부**

OCSF가 방대한 보안 도메인의 데이터를 일관되게 표현할 수 있는 비결은 엄격하게 모듈화된 **4가지 핵심 구성 요소(Taxonomy)** 덕분입니다.

```text
[1. Category (카테고리: 대분류)]  ── 예: System Activity (1), Network Activity (4), IAM (3)
       │
       ▼
[2. Event Class (이벤트 클래스)] ── 예: Process Activity (1007), Network Traffic (4001), Authentication (3002)
       │
       ▼
[3. Objects & Attributes (객체·속성)] ── 재사용 가능한 데이터 블록 (Actor, Device, Endpoint, File, Malware)
       │
       ▼
[4. Profiles & Extensions (프로파일·확장)] ── 환경별 부가 컨텍스트 주입 (Cloud Profile, Host Profile, Linux Extension)
```

### ① Data Types, Attributes & Objects (기본 벽돌과 재사용 객체)
OCSF는 가장 작은 단위인 **Attribute(속성, 예: `ip`, `port`, `uid`)**의 데이터 타입과 제약 조건을 엄격하게 정의합니다. 그리고 이 속성들을 묶어 **Object(객체)**라는 재사용 가능한 복합 구조체를 만듭니다.
예를 들어 `Device`(단말), `User`(사용자), `Process`(프로세스), `File`(파일), `Network Endpoint`(네트워크 종단점) 객체는 한 번 정의되면 인증 로그든, EDR 프로세스 생성 로그든, 방화벽 로그든 **모든 이벤트 클래스에서 100% 동일한 구조와 필드명**으로 재사용됩니다.

### ② Categories & Event Classes (카테고리와 이벤트 클래스)
모든 보안 이벤트는 고유한 숫자 ID(`category_uid` 및 `class_uid`)를 갖습니다. 문자열 대신 정수형 ID(`uid`)를 함께 사용하는 이유는 **Parquet 같은 컬럼형 데이터 레이크에서 수십 테라바이트를 스캔할 때 문자열 비교보다 정수 필터링(`WHERE class_uid = 3002`)이 압도적으로 빠르고 스토리지 용량을 적게 차지하기 때문**입니다.

대표적인 OCSF 주요 카테고리와 실무 핵심 Event Class는 다음과 같습니다:

| Category UID | 카테고리명 | 대표 Event Class (`class_uid`) | 주요 수집 대상 솔루션 |
| :---: | :--- | :--- | :--- |
| **1** | **System Activity** | `1001` File System Activity<br/>`1007` Process Activity | EDR (CrowdStrike, SentinelOne), Sysmon, Auditd |
| **2** | **Findings** | `2001` Security Finding<br/>`2002` Vulnerability Finding<br/>`2004` Detection Finding | CSPM, 취약점 스캐너, SIEM/SOAR 경보, AWS GuardDuty |
| **3** | **Identity & Access Management (IAM)** | `3001` Account Change<br/>`3002` Authentication<br/>`3003` Authorize Session | Okta, Entra ID (Azure AD), AWS IAM, Linux PAM / Windows AD |
| **4** | **Network Activity** | `4001` Network Activity<br/>`4002` HTTP Activity<br/>`4003` DNS Activity | 차세대 방화벽(NGFW), VPC Flow Logs, WAF, 리버스 프록시, DNS |
| **6** | **Application Activity** | `6003` API Activity<br/>`6001` Web Resources Activity | AWS CloudTrail, Kubernetes Audit Logs, SaaS 감사 로그 |

### ③ Profiles (교차 속성 주입기)
똑같은 `Network Activity (4001)` 이벤트라도 온프레미스 방화벽에서 발생한 로그와 AWS 클라우드 환경의 보안 장비에서 발생한 로그는 필요한 부가 정보가 다릅니다.
이때 새로운 이벤트 클래스를 억지로 만드는 대신, 기존 클래스 위에 **`cloud` 프로파일**이나 **`container` 프로파일**을 덧씌우면(Overlay), 해당 이벤트에 `cloud.account.uid`, `cloud.region`, `container.image.tag` 같은 표준 속성이 자연스럽게 합쳐집니다.

---

## **3. 실무 예제: 비정형 원본 로그를 OCSF JSON으로 정규화하기**

실제 현업에서 가장 빈번하게 분석하는 **SSH/IAM 로그인 실패(Authentication Failure)** 로그가 레거시 환경과 OCSF 환경에서 어떻게 달라지는지 비교해 보겠습니다.

### Before: 벤더별 원본 로그 (Raw Logs)
```text
# 1. Linux SSHD Syslog
Oct 22 14:22:01 prod-web-01 sshd[19284]: Failed password for invalid user admin from 198.51.100.77 port 49152 ssh2

# 2. Okta / Cloud IdP JSON Log
{"eventType": "user.session.start", "outcome": {"result": "FAILURE", "reason": "INVALID_CREDENTIALS"}, "actor": {"alternateId": "admin@example.com"}, "client": {"ipAddress": "198.51.100.77", "geographicalContext": {"country": "US"}}}
```

### After: OCSF `Authentication (class_uid: 3002)` 표준 스키마 변환본
위 두 로그는 원천 시스템이 완전히 다르지만, OCSF 파이프라인을 거치면 아래와 같이 **동일한 구조의 `class_uid: 3002` 객체**로 정규화됩니다:

```json
{
  "category_uid": 3,
  "category_name": "Identity & Access Management",
  "class_uid": 3002,
  "class_name": "Authentication",
  "activity_id": 1,
  "activity_name": "Logon",
  "time": 1761142921000,
  "severity_id": 3,
  "severity": "Medium",
  "status_id": 2,
  "status": "Failure",
  "status_detail": "INVALID_CREDENTIALS",
  "type_uid": 300201,
  "actor": {
    "user": {
      "name": "admin",
      "uid": "admin@example.com",
      "type_id": 1
    }
  },
  "src_endpoint": {
    "ip": "198.51.100.77",
    "port": 49152,
    "location": {
      "country": "US"
    }
  },
  "dst_endpoint": {
    "hostname": "prod-web-01",
    "svc_name": "sshd"
  },
  "metadata": {
    "version": "1.3.0",
    "product": {
      "name": "OpenSSH / Okta",
      "vendor_name": "OpenBSD / Okta"
    }
  }
}
```

#### 💡 보안 엔지니어가 주목해야 할 OCSF 설계의 디테일
1. **`type_uid` (`300201`)의 마법**:
   - `class_uid (3002)` * 100 + `activity_id (1: Logon)` = `300201`입니다.
   - 관제 엔지니어가 *"로그온 시도 이벤트만 가져와"*라고 쿼리할 때, 문자열 파싱 없이 `WHERE type_uid = 300201 AND status_id = 2` 조건 하나로 초고속 조회가 가능합니다.
2. **예측 가능한 엔드포인트 네이밍 (`src_endpoint.ip`, `dst_endpoint.ip`)**:
   - 인증(IAM)이든 네트워크(방화벽)든 API 호출이든, 출발지 IP는 언제나 `src_endpoint.ip`에 위치합니다.

---

## **4. OCSF 기반 보안 데이터 레이크(Security Data Lake) 위협 헌팅 실전**

OCSF의 진가는 데이터가 **Apache Parquet** 포맷으로 변환되어 **Amazon Security Lake, Snowflake, Databricks, ClickHouse** 같은 컬럼형 데이터 레이크에 적재되었을 때 폭발합니다.

과거에는 방화벽 로그와 인증 로그, EDR 프로세스 로그를 교차 분석(Correlation)하려면 각 벤더의 필드명을 모두 외워서 복잡한 `coalesce()`나 정규식을 써야 했습니다. 반면 OCSF로 표준화된 환경에서는 아래처럼 직관적인 ANSI SQL(Athena / Trino) 하나로 **크리덴셜 스터핑 공격 후 내부 측면 이동(Lateral Movement)까지 성공한 공격자 IP**를 단 몇 초 만에 찾아낼 수 있습니다:

```sql
-- [실전 위협 헌팅 쿼리]
-- 최근 24시간 내 10회 이상 인증 실패(Brute-force)를 일으킨 후,
-- 실제로 로그인에 성공(status_id = 1)하여 외부 C2 서버로 아웃바운드 연결까지 발생시킨 공격자 추적

WITH brute_force_ips AS (
    SELECT
        src_endpoint.ip AS attacker_ip,
        COUNT(*) AS fail_count,
        COUNT(DISTINCT actor.user.name) AS targeted_users
    FROM ocsf_security_lake.iam_authentication
    WHERE class_uid = 3002           -- Authentication
      AND activity_id = 1            -- Logon
      AND status_id = 2              -- Failure
      AND time >= to_unixtime(now() - interval '24' hour) * 1000
    GROUP BY src_endpoint.ip
    HAVING COUNT(*) >= 10
),
compromised_sessions AS (
    SELECT
        a.src_endpoint.ip AS attacker_ip,
        a.actor.user.name AS compromised_user,
        a.dst_endpoint.hostname AS victim_host,
        a.time AS logon_time
    FROM ocsf_security_lake.iam_authentication a
    INNER JOIN brute_force_ips b
        ON a.src_endpoint.ip = b.attacker_ip
    WHERE a.class_uid = 3002
      AND a.activity_id = 1
      AND a.status_id = 1            -- Success!
      AND a.time >= to_unixtime(now() - interval '24' hour) * 1000
)
SELECT
    c.attacker_ip,
    c.compromised_user,
    c.victim_host,
    n.dst_endpoint.ip AS outbound_c2_ip,
    n.dst_endpoint.port AS outbound_port,
    n.traffic.bytes_out
FROM compromised_sessions c
INNER JOIN ocsf_security_lake.network_activity n
    ON c.victim_host = n.src_endpoint.hostname
WHERE n.class_uid = 4001             -- Network Activity
  AND n.time >= c.logon_time
ORDER BY n.traffic.bytes_out DESC;
```

Linux 서버의 SSH 로그든, Windows AD의 이벤트 로그든, Okta나 AWS IAM의 클라우드 인증 로그든 상관없이 **단 하나의 SQL 쿼리로 전사 인프라 전체의 공격 체인을 관통하여 헌팅**할 수 있다는 것—이것이 바로 OCSF가 가져온 패러다임 전환입니다.

---

## **5. 실무 도입 시 체크리스트 및 주의사항**

조직에 OCSF 기반 파이프라인(예: Vector, Fluent Bit, Cribl Stream, AWS Security Lake)을 도입할 때 반드시 챙겨야 할 3가지 실무 팁입니다:

1. **매핑되지 않은 원본 필드는 `unmapped` 객체에 보존할 것**:
   - 모든 사내 커스텀 애플리케이션 로그가 OCSF 표준 필드에 1:1로 맞아떨어지지는 않습니다.
   - 표준 속성에 들어가지 않는 벤더 고유 필드를 그냥 버리지 말고, OCSF가 공식 제공하는 `unmapped` (JSON/Map 타입) 또는 `raw_data` 필드에 담아두어야 사후 포렌식 분석 시 데이터 유실을 막을 수 있습니다.
2. **스키마 버전(`metadata.version`) 고정 및 호환성 관리**:
   - OCSF는 빠르게 진화하고 있습니다(`v1.1.0` → `v1.2.0` → `v1.3.0`). 데이터 파이프라인마다 사용하는 OCSF 버전을 `metadata.version`에 명시하고, 테이블 스키마 진화(Schema Evolution)를 지원하는 **Apache Iceberg** 포맷을 스토리지 레이어로 선택하는 것이 안전합니다.
3. **고주파(High-Volume) 로그부터 단계적 전환**:
   - 처음부터 수십 종의 로그를 한꺼번에 변환하려 하면 프로젝트가 표류합니다. 스토리지 비용과 파싱 부하의 80%를 차지하는 **VPC Flow Logs / 방화벽(`4001`), DNS(`4003`), CloudTrail API(`6003`), 통합 인증(`3002`)** 4대 로그부터 먼저 OCSF로 전환하는 것이 ROI가 가장 높습니다.

---

## **마치며: 데이터 주권을 되찾는 첫걸음**

그동안 많은 기업이 SIEM 솔루션을 교체하고 싶어도, 수년간 특정 SIEM 문법으로 짜놓은 수천 개의 파서와 탐지 룰 때문에 울며 겨자 먹기로 매년 수억 원의 라이선스 비용 인상을 감내해야 했습니다.

**OCSF는 보안 데이터의 소유권과 주도권을 벤더가 아닌 '보안 팀'에게 돌려주는 표준**입니다. 데이터를 개방형 표준 스키마(OCSF)와 개방형 테이블 포맷(Parquet/Iceberg)으로 저장해 두면, 그 위에 얹는 분석 엔진은 SIEM이든, 오픈소스 SQL 엔진(Trino/DuckDB)이든, 차세대 AI 에이전트든 언제든 자유롭게 갈아끼울 수 있습니다. 파서 유지보수에 쏟던 에너지를 이제 진짜 '위협 탐지와 아키텍처 방어'에 집중해야 할 때입니다.

## **참고 자료**
- [OCSF Schema Browser 공식 문서 (schema.ocsf.io)](https://schema.ocsf.io/)
- [OCSF GitHub Organization & 오픈소스 스키마 저장소](https://github.com/ocsf)
- [Amazon Security Lake & OCSF 통합 가이드](https://docs.aws.amazon.com/ko_kr/security-lake/latest/userguide/what-is-security-lake.html)
]]></content>
        <author>
            <name>HEISENBUG</name>
            <uri>https://github.com/Debugging-My-Life/blog</uri>
        </author>
    </entry>
</feed>