
생성 AI로 PII를 제거한다! Snowflake의 AI_REDACT로 프리텍스트(Free Text)를 보호하는 도전
요약
Snowflake의 AI_REDACT 기능을 활용하여 비정형 프리텍스트 데이터 내의 개인정보(PII)를 효과적으로 마스킹하는 방법을 소개합니다. 정규 표현식이나 자체 NER 모델 대비 LLM 기반 마스킹이 가진 문맥 이해 능력과 운영 효율성을 비교 분석합니다.
핵심 포인트
- 비정형 텍스트 내 PII는 정규 표현식만으로 탐지하기 어려움
- 자체 NER 모델 구축은 인프라 및 유지보수 비용이 높음
- Snowflake AI_REDACT는 문맥을 이해하여 50종 이상의 PII 탐지 가능
- LLM 기반 마스킹은 개발 시간을 단축하고 운영 부담을 줄임
안녕하세요, Kurashiru의 레시차레(Reshi-Chare) 데이터 엔지니어이자 매일 싱글벙글 웃고 있는 니코입니다! ☀️
최근 일본은 정말 덥네요…. 저는 동남아시아 출신이라 "더위에는 익숙하겠지"라며 여유를 부렸는데——일본의 여름은 완전히 [Beast]였습니다. 😇 얕봤어요.
(그리고 솔직히 말하면, 일본어로 블로그를 쓰는 것은 아직 조금 어렵습니다. 하지만 더 실력을 키우고 싶어서 도전해 봅니다. 따뜻한 시선으로 읽어주시면 감사하겠습니다🙏)
자, 더위 이야기는 이쯤 해두고. 오늘은 Snowflake의 AI_REDACT를 사용하여 데이터 속에 흩어져 있는 PII(개인정보)를 마스킹(Masking)한 이야기를 공유하고자 합니다. 같은 문제로 고민하고 계신 분들에게 힌트가 된다면 최고겠네요. 그럼 바로 시작해 봅시다!
애초에, 왜 할 필요가 있었을까?
PII는 데이터 엔지니어로서 영원히 따라다니는 테마죠.
고객 지원(Customer Support) 데이터를 웨어하우스(Warehouse)에 연결했을 때, 솔직히 "우와..." 소리가 나왔습니다. PII가 여기저기 흩어져 있었거든요. 게다가 까다로운 점은 이름, 이메일, 연령, 은행 계좌처럼 깔끔한 컬럼(Column)에 들어 있는 PII가 아니라는 점입니다. 그런 것들은 위치가 확실하기 때문에 그대로 마스킹하면 됩니다. 다루기 쉬운 상대죠.
정말 힘든 것은 프리텍스트(Free Text) 속에 파묻힌 PII입니다. 사용자와 상담원 사이에 주고받은 메시지, 바로 그것이죠. 메시지 한 통 안에 성함, 전화번호, 주소, 주문 번호가 일반 문장 속에 섞여서 슬쩍 들어가 있기도 합니다.
Kurashiru에서는 항상 고객을 이해하고 그들의 생활을 더 좋게 만들고 싶어 합니다. 그러기 위해서는 고객이 무엇 때문에 곤란해하는지를 실제로 읽고 분석해야 합니다. 하지만 그 안의 PII를 어떻게 처리하기 전까지는 프리텍스트를 함부로 다룰 수 없다는 뜻입니다.
왜 지금, 흩어진 PII가 그렇게 위험한가? AI 시대의 유출은 해커의 침입만을 의미하지 않습니다. 더 흔한 패턴은 누군가가 "잠깐 분석해 볼까" 하며 로우 데이터(Raw Data)를 AI 채팅창에 툭 붙여넣고, 그대로 PII가 외부로 나가는 경우입니다. 프리텍스트는 바로 사람이 LLM에 던지고 싶어 하는 데이터입니다. 그렇기에 마스킹하지 않고 방치하는 것이 가장 위험합니다.
그럼, 어떻게 마스킹할까? (검토한 3가지 방법)
대략 3가지 선택지를 비교해 보았습니다.
| 방법 | 내용 | 장점 | 단점 |
|---|---|---|---|
| 규칙/정책에 의한 마스킹 | 정규 표현식(Regular Expression) + Snowflake 마스킹 정책 | 저렴함, 확실함, 쿼리 시 빠름 | 문맥(Context)을 읽을 수 없음. 이메일/전화번호에는 강하지만 문장 속 성함/주소에는 약함 |
| 개체명 인식 (NER) | 자체 ML 모델로 엔티티(Entity) 태깅 | 조정 가능, 자체 보유 가능 | 모델, 인프라, 유지보수를 전부 직접 책임져야 함 |
AI_REDACT | Snowflake 관리형 LLM 마스킹 | 거친 프리텍스트라도 문맥을 이해, 50종 이상의 PII, 인프라 불필요 | 토큰(Token) 비용, 100% 완벽하지 않음, 1회 4,096 토큰 제한 |
왜 NER을 포기했는가?
문장 속의 이름을 찾는다면 이론적으로는 NER이 "정석"입니다. spaCy와 기성 일본어 모델을 사용하는 방법도 있었습니다. 하지만 실제로 해야 할 일들을 나열해 보니 점점 매력이 떨어져서….
개발 시간. 기성 모델이 있다고 해도 "spaCy를 설치하는 것"이 끝이 아닙니다. 추출과 마스킹 처리를 전부 만들어 넣고, 우리 데이터로 검증하고, 세세한 예외 사항도 해결해야 합니다. 보통 몇 주가 걸리는 개발입니다. 그 시간, 솔직히 다른 곳에 더 쓰고 싶습니다. -
전용 처리와 인프라. 모델을 직접 구동하려면 구동할 장소(컨테이너나 UDF), 계산 리소스, 배포, 모니터링, 버전 관리… 등 한 번으로 끝나는 게 아니라 계속해서 관리해야 하는 시스템을 떠안게 됩니다. -
토탈 비용이 높음. 우리 데이터 양으로 추론(Inference)을 돌리고, 여기에 인프라와 운영 인건비까지 더하면 관리형 함수(Managed Function)보다 훨씬 비싸집니다.
AI_REDACT
AI_REDACT는 이 계산을 뒤집어 놓습니다. 비용은 선불이며, 토큰당 지불하는 비용이 눈에 보이고 명확합니다. 하지만 **구축하거나 관리하는 인프라는 제로(0)**이며, 운영 비용도 비교할 수 없을 정도로 작습니다. "만약 유출된다면"의 데미지가 거대한 문제인 상황에서, 명확한 상한선이 있는 토큰 비용을 매니지드(Managed) 서비스에 지불하는 것. 이것은 이미 간단한 선택이었습니다.
따라서 여러 가지 시도를 거친 끝에, **AI_REDACT를 메인 엔진(Main Engine)**으로 삼고, 그 위에
마스킹 정책(Masking Policy) 계층을 겹치기로 했습니다.
왜 계층을 겹치느냐고 물으신다면, AI_REDACT가 완벽할 수는 없다는 것을 알고 있기 때문입니다. 때때로 PII가 LLM의 검열을 뚫고 나가기도 합니다. 확실한 규칙(Rule) 계층은 절대 놓쳐서는 안 될 "예측 가능한 것" (이메일, 전화번호)을 잡아냅니다. 반면 LLM은 규칙 적용이 어려운 "문맥적인 것" (문장에 섞여 있는 성함이나 주소)을 담당합니다. 이 두 가지는 실패하는 방식이 다릅니다. 이것이야말로 다층 방어(Defense in Depth)에 필요한 요소입니다.
그럼, 어떻게 만들었을까요?
두 계층이 두 가지 타이밍에 작동합니다.
- 저장 시 (staging 모델 내부): 프리텍스트(Free Text)를 처음으로 staging 하는 타이밍에
AI_REDACT가 작동하여, 마스킹된 본문을 실제로 저장합니다. 하류(Downstream)의 mart는 모두 이 마스킹된 컬럼을 참조하므로, 가공되지 않은 PII는 staging 이후로는 흘러가지 않습니다. - 쿼리 시 (사용자와 가장 가까운 곳): 프리텍스트 컬럼에 적용된 Snowflake의 **마스킹 정책(Masking Policy)**이 남은 PII를 라벨로 교체하고, 역할(Role)별 액세스 제어를 수행합니다.
말로만 하면 이해하기 어려우니, 한 통의 문의 메시지가 ①→②→③ 과정을 거치며 어떻게 변하는지 실제로 살펴보겠습니다 (※ 성함, 주소, 번호는 모두 임의의 더미 데이터입니다).
① 가공되지 않은 문의 메시지 (원본 데이터)
다나카 타로입니다. 주문 번호 12345가 아직 도착하지 않았습니다.
전화번호는 090-1234-5678, 이메일은 taro@example.com.
환불은 〇〇은행 계좌 번호 1234567, 주소는 도쿄도 시부야구 도겐자카 1-2-3입니다.
② AI_REDACT로 처리한 후 (실제로 저장되는 텍스트)
[NAME]입니다. 주문 번호 12345가 아직 도착하지 않았습니다.
전화번호는 [PHONE_NUMBER], 이메일은 [EMAIL].
환불은 〇〇은행 계좌 번호 1234567, 주소는 [ADDRESS]입니다.
성함, 전화, 이메일, 주소는 문맥을 판단하여 확실하게 삭제되었습니다. 역시 NLP답습니다. 하지만 "계좌 번호 1234567"은 주변에 단서가 적은 "단순한 숫자"이기 때문에 AI_REDACT가 놓치고 말았습니다. ……네, 여기서 두 번째 계층이 등장합니다.
③ 마스킹 정책으로 처리한 후 (쿼리 시 보이는 텍스트)
[NAME]입니다. 주문 번호 12345가 아직 도착하지 않았습니다.
전화번호는 [PHONE_NUMBER], 이메일은 [EMAIL].
환불은 〇〇은행 계좌 번호 [ACCOUNT], 주소는 [ADDRESS]입니다.
놓쳤던 계좌 번호가 쿼리 시점에 [ACCOUNT]로 변했습니다. 반대로 주문 번호 12345는 PII가 아니므로 그대로 잘 남아 있습니다. 이 "②에서 대부분을 지우고, ③에서 놓친 것을 잡는다"는 조합이야말로 이번에 가장 전달하고 싶은 핵심입니다.
그럼 사용자에게 가장 가까운 계층인 마스킹 정책(③)부터 살펴본 뒤, 이어서 AI_REDACT(②)의 내부로 들어가 보겠습니다.
프리텍스트용 마스킹 정책
마스킹 정책은 데이터가 사람(또는 AI 채팅)에게 전달되기 직전의 최후의 보루입니다. 이곳은 의도적으로 저렴하고 확실하게 구성했습니다. 왜냐하면 마스킹 정책 내부에서 LLM을 호출하는 것은 절대 금물이기 때문입니다. 쿼리가 실행될 때마다 호출된다면 비용이 폭발할 것입니다. 😂
이 정책의 역할은 LLM 계층의 약점을 보완하는 것입니다. AI_REDACT
NLP 모델이기 때문에 문맥이 있는 PII(개인 식별 정보) 문장 속의 이름, 주소, 전화번호, 이메일 등에는 매우 강력합니다. 하지만 문맥이 없는 구조적인 토큰은 꽤 놓치는 부분이 있습니다. 예를 들어, 홀로 놓여 있는 계좌번호, 우편번호, 단순한 숫자 ID 같은 것들이요. 주변에 단어가 없으면 모델도 '이것이 중요한 숫자구나'라고 인지하지 못할 때가 있습니다. 그리고 이런 부분이야말로 결정적인 정규 표현식(regex) 정책의 강점 분야입니다. 즉, 두 계층은 서로를 보완하며, 한쪽이 약한 부분을 다른 쪽이 커버해주는 관계인 거죠.
제가 은근히 마음에 드는 설계가 하나 있는데, 값을 ****로 가리는 것이 아니라, AI_REDACT와 동일한 라벨 형식 (예: [EMAIL], [ACCOUNT], [ZIP_CODE], [NUMBER] 등)으로 대체합니다. 이렇게 하면 본문이 읽기 쉬운 상태로 유지될 뿐만 아니라, 검토하는 사람도 '무엇이 가려졌는지' 알 수 있습니다. 게다가 저장되는 텍스트는 이미 AI_REDACT가 적용된 것이므로, 이 정책은 이름에 대한 사전 규칙을 가지고 있지 않습니다. 구조적인 패턴만으로 충분한 겁니다.
일부러 건드리지 않은 부분: 일본어 이름
'이름도 만약을 위해 정책으로 잡지 않을까?'라고 생각할 수 있습니다. ……잡지 않았습니다. 이것은 의도적입니다. 이름은 어떤 언어에서든 규칙으로 포착하기 가장 어려운 PII 중 하나인데, 특히 일본어가 까다롭습니다.
- 이름의 시작과 끝을 나타내는 공백이 없다
- 이름이 될 수 있는 한자(및 그 읽기)의 종류가 엄청나게 많다
- 많은 이름이 일반 단어와 구별하기 어렵다 (성씨가 일반 명사였던 경우처럼)
이런 대상에 정규 표현식이나 이름 사전을 만들면, 결국 '누락되는 것(실제 이름을 놓치는)'은 물론이고 '비용이 많이 드는(대량의 일반 문장에 반응하고 유지보수도 끝이 없는)' 결과가 됩니다. 음, 가성비가 떨어지죠. 그래서 명확하게 결정했습니다: 일본어 이름은 AI_REDACT만으로 처리한다. 정규 표현식 정책은 애초에 이름에는 손을 대지 않습니다.
이것이야말로 '왜 두 가지 계층을 사용하는지'를 가장 잘 보여주는 부분 같습니다. 구조적인 토큰(계좌번호, 우편번호, ID)에서는 결정적인 정규 표현식 정책이 믿고 의지할 수 있는 존재입니다. 하지만 이름의 경우, NLP 모델이 정말 '유일하게 통하는 도구'인 거죠. 반대로 말하면, 이름에는 결정적인 백업이 없다는 의미이기도 합니다. 그렇기 때문에 저장 시 AI_REDACT를 제대로 작동시키는 것은 우리에게 '해도 되고 안 해도 되는' 수준의 문제가 아니게 됩니다.
CREATE MASKING POLICY IF NOT EXISTS masked_redact AS (val string)
RETURNS string ->
CASE
...
솔직히 말하자면, 이 계층은 NLP가 없는 순수한 정규 표현식이기 때문에, 라벨은 '의미'가 아니라 '형식'을 기반으로 추측하고 있습니다. 그래서 키워드가 없는 7자리 계좌번호는 [ACCOUNT]가 아니라 [NUMBER]가 되기도 합니다. 그렇다고 해도 값 자체는 제대로 가려집니다. 놓치는 부분은 라벨의 정확도 정도입니다. 안전망으로는, 뭐 괜찮은 트레이드오프라고 할 수 있겠습니다.
정책 관리는 dbt_snow_mask에 맡기기
참고로 이것은 수동으로 만들거나 붙여넣은 것이 전혀 아닙니다. dbt_snow_mask라는 패키지를 사용하여 마스킹 정책을 '선언적이며, 버전 관리된 dbt의 메타데이터'로 관리하고 있습니다.
위의 정책 본체는 명명 규칙에 따른 매크로——create_masking_policy_masked_redact——안에 배치되어 있습니다. 그리고 그것을 컬럼에 적용하는 것은 모델의 YAML에 메타데이터를 한 줄 추가하는 것만으로 충분합니다. 그게 전부입니다.
columns:
- name: message_body
description:
**왜 Snowflake에서 직접 DDL을 작성하지 않고, 굳이 패키지를 통하는 걸까요?** 솔직히 말해서, 수동 마스킹 정책 (Masking Policy)과 dbt는 궁합이 최악입니다.
-
**dbt는 실행할 때마다 오브젝트를 다시 만듭니다. 그리고 마스킹 정책은 '모델 (Model)'이 아니라 '컬럼 (Column)'에 붙습니다.** dbt가 `table`이나 `view`를 드롭(Drop)하고 다시 만들면, Snowflake는 컬럼에 붙어 있던 정책도 함께 슬쩍 제거해 버립니다. 만약 수동으로 설정했다면, 누군가가 `ALTER … SET MASKING POLICY`를 다시 실행할 때까지 PII가 그대로 노출됩니다. 무섭죠. 😱 `dbt_snow_mask`는 post-hook을 통해 **매 빌드 시마다** 자동으로 다시 붙여줍니다. 이것만으로도 도입할 가치가 충분합니다. -
**의도가 데이터 바로 옆에 있습니다.** "이 컬럼은 PII → `masked_redact`로 마스킹한다"라는 내용이 모델의 YAML에 작성되어 있고, git에 들어가며, PR(Pull Request)을 통해 리뷰됩니다. 웨어하우스와 리포지토리 사이에 괴리가 생기지 않습니다. -
**한 줄로 어디서든 재사용 가능합니다.** 동일한 이름의 정책을 수많은 컬럼이나 모델에서 `meta.masking_policy:` 한 줄로 참조할 수 있습니다. 수동으로 했다면 작성하고, 추적하고, 계속 동기화해야 하는 `ALTER` 문이 수십 개나 될 텐데... 생각만 해도 힘듭니다. -
**변경 사항이 자동으로 전달됩니다.** 매크로(Macro) 안에서 로직을 수정하고 dbt를 실행하면, `use_force_applying_masking_policy`가 모든 환경(dev / CI / prod)에 재적용해 줍니다. 수동 DDL 작업이나 "어라, staging도 수정했었나?" 같은 고민이 사라집니다.
역할 분담이 깔끔합니다. `dbt_snow_mask`가 *배관* (생성, 적용, 재적용, 강제 적용)을 담당하고, 우리는 *로직*인 정규 표현식 규칙을 작성한 매크로 하나와, PII 컬럼별로 한 줄의 태그를 가질 뿐입니다. 그게 전부입니다.
## AI_REDACT에 대해 조금 더 깊이 알아보기
`AI_REDACT`는 Snowflake가 호스팅하는 LLM을 사용하여 비정형 텍스트에서 PII를 탐지하고 마스킹해 주는 완전 관리형(Fully Managed) Snowflake Cortex 함수입니다. **2025년 12월 8일에 GA (General Availability, 일반 제공)** 되었으며, **50가지 이상의 PII 카테고리**(이름, 이메일, 연령, 결제 정보 등)를 그대로 인식합니다. (Snowflake 문서, GA 릴리스 노트)
모드는 두 가지입니다.
-
탐지된 PII를 `redact` (기본값) 하여 `[NAME]`이나 `[ADDRESS]`와 같은 플레이스홀더(Placeholder)로 교체합니다. -
원문 텍스트는 건드리지 않고, 탐지된 각 PII의 카테고리, 위치, 매칭된 텍스트를 포함하는 `detect` `spans` 배열을 OBJECT로 반환합니다. 허용 목록(Allowlist) 방식의 워크플로우를 만들 때 유용합니다.
### 비용 구조 (중요)
`AI_REDACT`는 다른 Cortex AI 함수와 마찬가지로 **토큰당 AI Credit으로** 과금됩니다. 게다가 **입력 토큰과 출력 토큰 모두**를 카운트합니다. (Cortex AI 비용 문서) 즉, 보내는 텍스트와 돌아오는 마스킹된 텍스트 모두가 청구 대상이라는 뜻입니다.
기억해 두면 좋은 제한 사항 (마스킹 가이드):
- 입력+출력은 1회당 최대 **4,096 토큰**, 출력은 최대 **1,024 토큰**
- 대략적인 기준: **영어라면 1 토큰 ≈ 4자**
### 배포 전에 생각해 두어야 할 점
**1. 무엇보다 드라이 런(Dry Run)이 우선입니다.** 프리텍스트(Free Text)는 길이를 예측할 수 없고, 토큰은 곧 비용입니다. 테이블 전체에 `AI_REDACT`를 적용하기 전에, `AI_COUNT_TOKENS`로 토큰 양을 추정해 보세요.
```sql
-- 아무것도 마스킹하기 전에, 토큰 비용의 합계를 추정함
SELECT SUM(AI_COUNT_TOKENS('ai_redact', message_body)) AS estimated_tokens
FROM raw.customer_service.messages;
이 쿼리 하나로 "일단 실행해 볼까?"가 "관계자들에게 보여줄 수 있는 실제 비용 추정치"로 바뀝니다. 사소해 보이지만 매우 중요합니다.
2. 일본어는 비쌉니다. 저희 경험상, 일본어는 동일한 내용의 영어보다 대략 2배의 토큰을 소비합니다. 비라틴 문자(Non-Latin characters)는 토큰 효율이 매우 좋지 않기 때문입니다. "1토큰 ≈ 4글자"라는 기준은 일본어에서는 거의 믿을 수 없으므로, 동일한 글자 수라도 일본어 지원 메시지 테이블은 영어보다 훨씬 비용이 많이 듭니다. 이 부분은 처음부터 예산에 넣어두어야 합니다.
3. ROI(투자 대비 효과)로 생각하기. 유출 사고의 뒷수습은 예방보다 항상 비용이 더 많이 듭니다. 토큰 청구 금액을 보고 겁이 날 때도, 이를 "PII 사고에 대한 보험"이라고 다시 생각하니 판단이 훨씬 수월해졌습니다.
실제 마스킹 작성 방법
기본 — 모든 카테고리를 모델에 통째로 맡기기:
SELECT AI_REDACT('私の名前は田中太郎です。東京都渋谷区道玄坂1-2-3に住んでいます。');
-- → '私の名前は[NAME]です。[ADDRESS]に住んでいます。'
카테고리 좁히기 — 필요한 것만 지정하기:
SELECT AI_REDACT(
'担当の佐藤(sato@example.com)まで、注文番号12345の件でご連絡ください。',
['NAME', 'EMAIL']
...
detect 모드 — 마스킹하지 않고 PII만 찾기 (허용 목록용):
SELECT AI_REDACT(
input => '田中です。電話番号は090-1234-5678です。',
mode => 'detect'
...
그리고 dbt staging 모델에서의 전체 모습. 이곳이 실제로 AI_REDACT가 작동하는 곳이며, 비용 최적화의 대부분이 여기에 집중되어 있습니다. 마스킹 자체는 정말 단 한 줄입니다. 주변의 코드들은 전부 "지불할 필요 없는 토큰을 지불하지 않기 위해" 존재합니다.
{{ config(
materialized='incremental',
incremental_strategy='delete+insert',
...
6개의 CTE 중 5개는 오직 "AI_REDACT를 호출하지 않기 위해" 존재합니다. ……이 비율이 꽤 본질적이라는 생각이 듭니다. 토큰 과금 방식의 함수는, 승부가 함수의 호출 그 자체보다 **"무엇을 먹일지를 가차 없이 줄이는 것"**에 달려 있기 때문입니다.
마치며
프리텍스트(Free Text) 안에 PII가 숨어 있다면, AI_REDACT는 정말 고마운 존재입니다. 정규 표현식으로는 도저히 불가능한, 난잡하고 문맥적인 PII를 제대로 처리해 주며, 모델 인프라는 Snowflake가 전부 관리해 줍니다. 솔직히 처음에는 "AI로 마스킹을 하는 게 괜찮을까...?"라며 반신반의했지만, 직접 해보니 상상 이상으로 의지가 되었습니다.
그렇다고 완벽한 것은 아니기에, 저희는 2중 레이어로 보호하고 있습니다. 이번에 가장 효과적이었던 것은 결국 이 두 가지였습니다:
- 하나의 레이어를 너무 믿지 말 것.
AI_REDACT(문맥적/저장 시)와 저렴하면서도 확실한 마스킹 정책(쿼리 시)을 조합합니다. 실패하는 방식이 다르기 때문에, 조합하면 커버 범위가 확 넓어집니다. - 토큰 청구 금액을 우습게 보지 말 것.
AI_COUNT_TOKENS로 드라이 런(Dry run)을 수행하고, 일본어는 비용이 약 2배라는 점을 잊지 말며, 함수에 먹일 양을 어떻게든 줄이는 설계로 만듭니다. 우선 중복을 제거하고, 마스킹된 불변(Immutable) 행을 건너뛰며, PII가 아닌 기계 데이터를 걸러냅니다. 그리고 지출은 ROI로 생각합니다. 유출 사고는 언제나 예방보다 비용이 많이 드니까요.
네, 이상입니다! 만약 여러분도 "프리텍스트 PII 처리 문제"로 고민하고 계신다면, 이 글이 조금이라도 힌트가 되었으면 좋겠습니다. "우리는 이렇게 하고 있다"라는 노하우가 있다면 꼭 알려주세요. 🙌
그럼, 저는 에어컨을 찾으러 가보겠습니다.
Have a nice and sunny day!☀️
열사병 조심하세요~
출처: AI_REDACT 함수 레퍼런스 ・ PII 탐지 및 마스킹 가이드 ・ AI_REDACT GA 릴리스 노트(2025-12-08) ・ Cortex AI 비용 고려 사항
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기