View on Threads
위 글에 요청이 달렸어요. 단순하게 정리해서 답글을 보내드리려고 했는데요.
그렇게 쉽지만은 않네요. 그래서 blog에 정리해봤습니다.
첫째: 제 서버의 crontab에 이렇게 3개의 skill 과 한 개의 명령어를 등록해놓습니다. 물론 더 좋은 방법이 있지만, 일단 저는 우선 이렇게 하나 하나 실행되는 과정을 분리해서 등록해놓았습니다. 나중에 error가 나면 어디서 error가 났는 지 확인하고 싶어서요.
0 7 * * 1 claude -p "커피 업계 주간 뉴스레터를 작성해줘. 웹 검색으로 최신 소식을 수집하고 마크다운 형식으로 출력해줘." --allowedTools "WebSearch" > ~/src/news/this-week.txt
15 7 * * 1 claude -p "/nana_background"
30 7 * * 1 claude -p "다음 내용으로 카드뉴스 만들어서 ~/src/components/common/CardNews.tsx를 덮어써줘. ~/src/news/this-week.txt" --allowedTools "Write,Read,Edit"
45 7 * * 1 cd ~/ && npm run build
둘째: 이제 .claude/commands 디렉토리에
newsletter.md : 업계 소식을 큐레이션하여 구독자에게 가치 있는 마크다운 뉴스레터를 작성하는 스킬.
nano_background.md : this-week.txt를 읽어 10장 카드뉴스용 배경 이미지를 nanobanana로 생성하고 public/images/cards/에 저장하는 스킬. '배경 이미지 생성', '카드 배경 만들어줘', 'nano_background' 요청 시 사용.
card-news.md: 뉴스레터나 글을 10장짜리 인터랙티브 카드뉴스 React 컴포넌트로 변환하는 스킬. 사용자가 "카드뉴스 만들어줘", "슬라이드로 만들어줘", "카드 형식으로 정리해줘", "뉴스레터를 카드뉴스로", "인스타그램 카드뉴스" 같은 요청을 할 때 반드시 이 스킬을 사용하세요. newsletter 스킬로 발행한 뉴스레터를 시각화할 때도 즉시 트리거하세요.
이렇게 3개의 skill 을 만들어 놓았습니다.
1) newsletter.md
# 뉴스레터 작성 스킬
업계 소식을 큐레이션하여 구독자에게 가치 있는 마크다운 뉴스레터를 작성하는 스킬.
---
## 작업 흐름
### 1단계: 정보 수집 (정보가 부족할 경우)
사용자에게 아래 정보를 확인하세요:
- **업계/주제**: 커피 업계 동향
- **발행 주기**: 주간
- **호수/날짜**: 몇 호인지, 발행 날짜는?
- **주요 소식**: 다룰 뉴스나 토픽이 있나요? (없으면 웹 검색으로 수집)
- **독자층**: 일반인, 바리스타 지원자
- **톤**: 친근하게
정보가 충분하다면 바로 2단계로 이동하세요.
---
### 2단계: 소식 수집 (필요 시)
사용자가 소식을 제공하지 않은 경우, 웹 검색으로 최신 정보를 수집하세요:
- 해당 업계 최신 뉴스 3~5개
- 주목할 트렌드 1~2개
- 유용한 리소스나 도구 1개
---
### 3단계: 뉴스레터 작성
아래 **마크다운 템플릿**을 기반으로 작성하세요.
---
## 마크다운 템플릿
```markdown
# 📬 피우 커피 위클리 — 제[N]호
> [발행일 (예: 2026년 3월 1주차)] | 핵심 키워드: [이번 호 전체를 관통하는 해시태그 3~5개]
안녕하세요! 👋
[이번 주 커피 업계를 관통하는 주요 테마와 거시적인 흐름을 4~5문장의 세밀하고 깊이 있는 단락으로 소개하세요. 독자가 이번 호를 통해 무엇을 얻어갈 수 있는지 기대감을 심어주세요.]
---
## 🔥 이번 주 하이라이트 (THIS WEEK)
> **[업계에 가장 큰 파급력을 가진 소식의 강력한 헤드라인]**
* **상세 내용:** [단순 요약이 아닌, 사건의 배경과 구체적인 진행 상황을 육하원칙에 입각하여 4~5문장(최소 150자 이상)으로 상세히 설명하세요. 세로형 카드에 꽉 차도록 분량을 충분히 확보하세요.]
* **임팩트:** [이 이슈가 앞으로 글로벌/국내 커피 시장, 또는 카페 오너들에게 미칠 구조적 변화나 파급력을 1~2문장의 핵심 메시지로 정리하세요.]
---
## 📰 주요 소식 (NEWS)
### 1. [시선을 끄는 구체적인 소식 제목]
* **상세 내용:** [짧은 요약을 피하고, 소식의 전후 맥락과 실제 데이터를 포함해 4~5문장으로 풍성하게 작성하세요.]
* **임팩트:** [이 뉴스가 커피 산업 종사자나 소비자에게 시사하는 바를 1~2문장으로 도출하세요.]
* [원문 보기](URL)
### 2. [시선을 끄는 구체적인 소식 제목]
* **상세 내용:** [소식의 전후 맥락과 실제 데이터를 포함해 4~5문장으로 풍성하게 작성하세요.]
* **임팩트:** [시사점 및 파급력 1~2문장]
* [원문 보기](URL)
### 3. [시선을 끄는 구체적인 소식 제목]
* **상세 내용:** [소식의 전후 맥락과 실제 데이터를 포함해 4~5문장으로 풍성하게 작성하세요.]
* **임팩트:** [시사점 및 파급력 1~2문장]
* [원문 보기](URL)
*(최소 4개 이상의 주요 소식을 위와 같은 형식으로 꽉 채워 작성하세요)*
---
## 💡 에디터 인사이트 (INSIGHT)
### 대주제: [위 뉴스들을 종합한 트렌드 분석 헤드라인]
* [인사이트 포인트 1: 구체적인 설명이 포함된 완전한 문장 (Full sentence)으로 작성]
* [인사이트 포인트 2: 구체적인 설명이 포함된 완전한 문장으로 작성]
* [인사이트 포인트 3: 구체적인 설명이 포함된 완전한 문장으로 작성]
---
## 📊 이번 주 숫자 (DATA)
* **[숫자 1]**: [이 숫자가 의미하는 바를 2~3문장으로 상세히 설명]
* **[숫자 2]**: [이 숫자가 의미하는 바를 2~3문장으로 상세히 설명]
* **[숫자 3]**: [이 숫자가 의미하는 바를 2~3문장으로 상세히 설명]
---
## 👋 마무리
[독자에게 전하는 따뜻한 조언이나, 급변하는 트렌드 속에서 바리스타/카페 창업자가 가져야 할 마인드셋 등을 4~5문장의 긴 단락으로 작성하세요.]
[다음 호 발행 일정 예고 및 구독 유도 멘트]
```
---
## 작성 원칙
### ✅ 반드시 지킬 것
- **스캔 가능하게**: 독자가 훑어봐도 핵심을 파악할 수 있도록 소제목과 볼드 활용
- **큐레이션 가치 강조**: 단순 나열이 아닌, "왜 이 소식이 중요한가"를 설명
- **에디터의 목소리**: 기계적인 요약이 아닌, 에디터의 관점과 해석 포함
- **독자 중심**: "당신에게 이것이 왜 중요한가"를 항상 염두에 두기
- **분량 적절히**: 읽는 데 5~8분이 적당 (소식 3~5개, 총 800~1500자 내외)
### ❌ 피할 것
- 뉴스 제목만 나열하는 단순 목록
- 지나치게 긴 소식 요약 (각 소식당 3문장 이내)
- 딱딱하고 보도자료 같은 문체
- 링크 없이 "~에 따르면" 식의 모호한 출처
---
## 톤 & 문체 가이드
| 독자층 | 권장 톤 | 예시 표현 |
|--------|---------|----------|
| B2B 전문가 | 신뢰감 있되 친근하게 | "이번 주 주목할 움직임이 있었습니다" |
| 스타트업/창업자 | 에너지 있고 실용적 | "빠르게 짚고 넘어갈게요" |
| 일반 독자 | 쉽고 따뜻하게 | "복잡해 보이지만, 사실 이런 의미예요" |
| 테크 종사자 | 정확하고 간결하게 | "핵심만: ..." |
---
## 섹션 커스터마이징 옵션
필요에 따라 아래 섹션을 추가하거나 교체할 수 있습니다:
- **📊 숫자로 보는 한 주**: 업계 주요 통계/데이터 3개
- **🎙️ 이번 주 인용**: 업계 인사의 주목할 발언
- **🤔 생각해볼 질문**: 독자에게 던지는 사색 유도 질문
- **📅 업계 캘린더**: 다가오는 컨퍼런스, 마감, 이벤트
- **🏆 이번 주 Pick**: 에디터가 고른 최고의 콘텐츠 1개
---
## 출력 형식
- 언제나 **마크다운 문서**로 출력
- 파일로 저장 요청 시 `.md` 확장자로 저장
- 이모지는 섹션 제목에만 사용, 본문에서는 절제
2) nano_background.md
---
name: nano_background
description: this-week.txt를 읽어 10장 카드뉴스용 배경 이미지를 nanobanana로 생성하고 public/images/cards/에 저장하는 스킬. '배경 이미지 생성', '카드 배경 만들어줘', 'nano_background' 요청 시 사용.
---
# nano_background — 카드뉴스 배경 이미지 생성 스킬
`src/news/this-week.txt`를 읽어 10장 테마를 추출하고, nanobanana JSON 프롬프트를 작성한 뒤
`scripts/generate_cards.py`로 한 번에 생성합니다.
---
## 실행 순서
### 1단계: 뉴스레터 읽기
Read 툴로 `src/news/this-week.txt`를 읽고 파악하세요:
- 뉴스레터 이름 & 호수
- 발행 날짜 → `YYYYMMDD` 형식 (파일명에 사용)
- 섹션 구성: 표지 / 하이라이트 / 소식 3~5개 / 인사이트 / 데이터 / 마무리
### 2단계: 10장 테마 추출 & JSON 프롬프트 작성
| 카드 # | 슬러그 | 유형 | 이미지 컨셉 |
|--------|--------|------|------------|
| 01 | cover | 표지 | 이번 호 전체 분위기를 대표하는 커피 이미지 |
| 02 | highlight | 하이라이트 | 메인 소식을 시각적으로 표현 |
| 03 | news1 | 소식 1 | 첫 번째 주요 소식의 핵심 시각 요소 |
| 04 | news2 | 소식 2 | 두 번째 주요 소식의 핵심 시각 요소 |
| 05 | news3 | 소식 3 | 세 번째 주요 소식의 핵심 시각 요소 |
| 06 | news4 | 소식 4 | 네 번째 소식 (없으면 인사이트 추가) |
| 07 | news5 | 소식 5 | 다섯 번째 소식 또는 추가 인사이트 |
| 08 | insight1 | 인사이트 1 | 에디터 관점 — 업계 트렌드 추상 시각화 |
| 09 | insight2 | 인사이트 2 / 데이터 | 숫자·통계 분위기 또는 두 번째 인사이트 |
| 10 | closing | 마무리 | 따뜻하고 평화로운 커피 한 잔 마무리 장면 |
각 카드의 JSON을 `prompts/cards/card-{NN}-{슬러그}.json`으로 저장하세요.
**파일명 예시**: `prompts/cards/card-01-cover.json`, `prompts/cards/card-02-highlight.json`
**nanobanana JSON 스키마**:
```json
{
"meta": { "aspect_ratio": "4:5", "resolution": "1K", "thinking_level": "high" },
"subject": [ { "type": "...", "name": "...", "details": "...", "position": "..." } ],
"scene": {
"location": "...",
"lighting": { "type": "...", "direction": "...", "quality": "..." },
"background_blur": "..."
},
"camera": { "model": "...", "lens": "...", "aperture": "...", "angle": "...", "framing": "..." },
"style": {
"aesthetic": "...",
"color_grading": "dark moody ...",
"mood": "...",
"film_stock": "Kodak Portra 400",
"post_processing": "grain, vignette"
},
"negative_prompt": "blurry, bright background, cartoon, text, logos, oversaturated, stock photo feel"
}
```
**공통 스타일 규칙**:
- 다크 무드 (밝은 배경 금지)
- Photorealistic (일러스트·만화 금지)
- 텍스트·로고 없음
- 커피 업계 테마
### 3단계: generate_cards.py CARDS 목록 업데이트
`scripts/generate_cards.py`의 `CARDS` 리스트를 새 프롬프트·출력 경로로 교체하세요.
출력 파일명은 `{YYYYMMDD}_{N}.png` 형식 사용:
```python
CARDS = [
("prompts/cards/card-01-cover.json", "public/images/cards/{YYYYMMDD}_1.png"),
("prompts/cards/card-02-highlight.json","public/images/cards/{YYYYMMDD}_2.png"),
("prompts/cards/card-03-news1.json", "public/images/cards/{YYYYMMDD}_3.png"),
("prompts/cards/card-04-news2.json", "public/images/cards/{YYYYMMDD}_4.png"),
("prompts/cards/card-05-news3.json", "public/images/cards/{YYYYMMDD}_5.png"),
("prompts/cards/card-06-news4.json", "public/images/cards/{YYYYMMDD}_6.png"),
("prompts/cards/card-07-news5.json", "public/images/cards/{YYYYMMDD}_7.png"),
("prompts/cards/card-08-insight1.json", "public/images/cards/{YYYYMMDD}_8.png"),
("prompts/cards/card-09-insight2.json", "public/images/cards/{YYYYMMDD}_9.png"),
("prompts/cards/card-10-closing.json", "public/images/cards/{YYYYMMDD}_10.png"),
]
```
### 4단계: 이미지 생성 실행
```bash
mkdir -p public/images/cards
python3 scripts/generate_cards.py
```
- 이미 존재하는 파일은 자동 스킵됩니다 (`generate_cards.py` 내 Skip 로직)
- 실패 시 stderr 출력 확인 후 해당 카드만 재시도
### 5단계: 완료 보고
생성된 10장을 Read 툴로 미리보기하고 결과를 보고합니다.
---
## 주의사항
- `GOOGLE_AI_KEY` 환경변수 필요 — 없으면 즉시 중단하고 사용자에게 알릴 것
- 생성은 `generate_cards.py`가 **순차 처리** (병렬 금지)
- `public/images/cards/`는 Vite public 폴더 → 앱에서 `/images/cards/{파일명}`으로 직접 접근
- card-news 스킬의 `bgImage` 경로: `images/cards/{YYYYMMDD}_{N}.png` (`import.meta.env.BASE_URL` 접두사 붙여 사용)
3) card-news.md
---
name: card-news
description: 뉴스레터나 글을 10장짜리 인터랙티브 카드뉴스 React 컴포넌트로 변환하는 스킬. 사용자가 "카드뉴스 만들어줘", "슬라이드로 만들어줘", "카드 형식으로 정리해줘", "뉴스레터를 카드뉴스로", "인스타그램 카드뉴스" 같은 요청을 할 때 반드시 이 스킬을 사용하세요. newsletter 스킬로 발행한 뉴스레터를 시각화할 때도 즉시 트리거하세요.
---
# 카드뉴스 생성 스킬 (로컬 이미지 배경 & 고정 레이아웃)
뉴스레터 또는 텍스트 콘텐츠를 **10장짜리 세로형(9:16) 인터랙티브 React 카드뉴스**로 변환하는 스킬.
배경 이미지는 `~/public/images/cards/` 디렉토리에 미리 저장된 PNG 파일을 사용합니다.
---
## 작업 흐름
### 1단계: 콘텐츠 파악 및 10장 구성
입력된 뉴스에서 카테고리, 제목, 본문을 추출하여 10장(표지 1장, 소식 2~7장, 인사이트 8~9장, 마무리 10장)으로 분배하세요. 본문은 한 문장 단위로 줄바꿈(`\n`)하여 4~6줄로 만듭니다.
| 카드 번호 | 유형 | 내용 |
|----------|------|------|
| 1장 | 🎨 표지 | 시리즈명, 발행일, 메인 타이틀 |
| 2장 | 🔥 하이라이트 | 이번 호 가장 중요한 핵심 소식 |
| 3~7장 | 📰 소식 카드 | 주요 소식 각 1개씩 (카테고리 분류 포함) |
| 8~9장 | 💡 인사이트 | 에디터의 깊이 있는 관점 & 트렌드 분석 |
| 10장 | 👋 마무리 | 클로징 멘트 |
### 2단계: `~/public/images/cards/` 이미지 파일 확인
코드 작성 전, Glob 툴로 `~/public/images/cards/` 디렉토리의 파일 목록을 확인하세요.
```
Glob: ~/public/images/cards/*.{png,jpg,webp}
```
- 파일이 10개 이상이면 카드 순서대로 1:1 매핑합니다.
- 파일이 10개 미만이면 순환(index % files.length)하여 재사용합니다.
- 디렉토리가 없거나 파일이 없으면 사용자에게 알리고, `bgImage` 없이 그라디언트 폴백 테마만으로 계속 진행합니다.
### 3단계: React 컴포넌트 작성
확보한 이미지 경로를 `bgImage` 필드에 넣고 아래 디자인 시스템에 맞춰 `.tsx` 코드를 작성하세요.
---
## 디자인 시스템
### 배경 이미지 적용 방식 (텍스트 패널 분리)
배경 이미지가 최대한 선명하게 보이도록 전체 오버레이는 최소화하고,
텍스트 가독성은 콘텐츠 영역 하단 패널로만 확보합니다.
```tsx
{/* 1. 배경 이미지 */}
<img
src={`${import.meta.env.BASE_URL}images/cards/${card.bgImage}`}
alt="" aria-hidden
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500"
style={{ opacity: imgLoaded ? 1 : 0 }}
onLoad={() => setImgLoaded(true)}
/>
{/* 2. 최소 틴트 오버레이 — 이미지가 있으면 거의 투명, 없으면 그라디언트 폴백 */}
<div
className="absolute inset-0"
style={{
background: imgLoaded
? 'linear-gradient(to top, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.08) 100%)'
: 'linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.2) 100%)',
}}
/>
{/* 3. 콘텐츠는 하단 정렬, 텍스트 패널로 가독성 확보 */}
<div className="absolute inset-0 flex flex-col justify-end z-10">
<div
className="px-6 pt-12 pb-6"
style={{
background: 'linear-gradient(to top, rgba(0,0,0,0.88) 60%, rgba(0,0,0,0.60) 85%, transparent 100%)',
}}
>
{/* 카드 콘텐츠 */}
</div>
</div>
{/* bgImage가 없을 때: 그라디언트 폴백 */}
<div className={`absolute inset-0 bg-gradient-to-br ${card.theme.gradient}`} />
```
- 이미지 상단 40~50%가 거의 원본 그대로 보임
- 텍스트 영역(하단)은 `rgba(0,0,0,0.88)`로 가독성 유지
- `pt-12`로 콘텐츠 위 여백 확보 (이미지가 자연스럽게 보이는 영역 형성)
### 컬러 & 타이포그래피
- **포인트 컬러**: 오렌지 (`text-orange-500`, `#F97316`) — 라벨, 진행도, 강조 텍스트, 구분선
- **기본 텍스트**: `text-white` (제목), `text-gray-200` (본문)
- **폰트 웨이트**: 제목 `font-black`, 라벨 `font-bold tracking-widest`, 본문 `font-medium`
### 카드 레이아웃 (9:16 세로형, 좌측 정렬)
```
┌───────────────────────────────────┐
│ 08 / 10 [브랜딩] │ ← 좌: 진행도(포인트 컬러), 우: 발행자 정보(회색)
├───────────────────────────────────┤
│ │
│ [CATEGORY LABEL] │ ← 포인트 컬러, 대문자, 넓은 자간
│ [메인 제목] │ ← White, font-black, 줄바꿈 적극 활용
│ ──── │ ← 짧은 포인트 컬러 구분선
│ │
│ [본문 1] │ ← leading-relaxed, 문장 단위 줄바꿈
│ [본문 2] │
│ [본문 3] │
│ │
│ [강조/결론] │ ← 포인트 컬러, 하단 배치
└───────────────────────────────────┘
```
### 카드 타입별 내용 분량
- **표지**: 뉴스레터 이름(2줄 이상), 호수·발행일, 핵심 해시태그 3~5개
- **하이라이트·소식(2~7장)**: 제목 2~3줄, 본문 최소 100~150자(3~4줄), 임팩트 1~2문장
- **인사이트(8~9장)**: 타이틀 2줄, 완전한 문장의 포인트 3~4개
- **마무리(10장)**: 헤드라인 2~3줄, 감사 인사·다음 호 예고 3~4문장
---
## React 컴포넌트 구현 가이드
### 카드 데이터 구조
```typescript
type CardTheme = { gradient: string; accent: string };
type CardData = CoverCard | HighlightCard | NewsCard | InsightCard | ClosingCard;
// 각 타입은 bgImage: string 필드를 포함 (없으면 빈 문자열 '')
const cards: CardData[] = [
{
id: 1, type: 'cover',
bgImage: '~/public/images/cards/01.png', // 없으면 ''
theme: { gradient: 'from-stone-900 via-stone-800 to-stone-950', accent: '#F97316' },
// ...
},
// ...
];
```
### 필수 기능
```tsx
// 1. 상태 & 네비게이션
const [current, setCurrent] = useState(0);
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
// 2. 키보드 네비게이션 + 반응형 감지
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') goNext();
if (e.key === 'ArrowLeft') goPrev();
};
const onResize = () => setIsMobile(window.innerWidth <= 768);
window.addEventListener('keydown', handleKey);
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('keydown', handleKey);
window.removeEventListener('resize', onResize);
};
}, []);
// 3. 카드 크기 — 모바일: 360×640, 데스크탑: min(1100px, 92vw)×640
const cardStyle = isMobile
? { width: '360px', height: '640px', flexShrink: 0 }
: { width: 'min(1100px, 92vw)', height: '640px', flexShrink: 0 };
// 4. 상단 진행 바
<div style={{ width: `${((current + 1) / cards.length) * 100}%` }}
className="h-0.5 bg-orange-500 transition-all duration-300" />
// 5. 터치/스와이프 — touchstart·touchend 이벤트로 감지
```
### 스타일링 원칙
- **반응형 카드 크기**: `isMobile` state로 분기 — `cardStyle`을 inline style로 지정
- **외부 래퍼**: `width: 'min(1100px, 92vw)', margin: isMobile ? '24px auto 40px' : '34px auto 56px'`
```tsx
// ✅ 올바른 방법
const cardStyle = isMobile
? { width: '360px', height: '640px', flexShrink: 0 }
: { width: 'min(1100px, 92vw)', height: '640px', flexShrink: 0 };
return (
<div style={{ width: 'min(1100px, 92vw)', margin: isMobile ? '24px auto 40px' : '34px auto 56px' }}>
<div className="relative flex flex-col rounded-3xl overflow-hidden" style={cardStyle}>
{/* 카드 콘텐츠 */}
</div>
</div>
);
// ❌ 잘못된 방법
<div className="w-full max-w-sm lg:max-w-none aspect-[9/16] lg:aspect-auto lg:h-full">
```
---
## 출력 형식
- **파일**: `src/components/common/CardNews.tsx`
- **default export**: `export default function CardNews`
- **props**: `className?: string` 하나만 허용
- 모든 카드 데이터는 컴포넌트 내부에 하드코딩
---
## 작성 원칙
### ✅ 반드시 지킬 것
- `~/public/images/cards/` 파일 목록을 먼저 확인하고 실제 존재하는 경로만 `bgImage`에 사용할 것
- `bgImage`가 있으면 이미지 + 오버레이, 없으면 그라디언트 폴백
- `import.meta.env.BASE_URL` 접두사로 이미지 경로 구성
- 텍스트는 배열(`body: string[]`)로 줄바꿈 처리
- 카드 컨테이너 크기는 `isMobile` state + `cardStyle` inline style로 지정 (Tailwind `max-w-*`/`aspect-*` 클래스 혼용 금지)
### ❌ 피할 것
- 존재하지 않는 이미지 경로를 하드코딩하는 것
- nanobanana 스킬 호출 (이미지 생성은 별도 스킬에서 처리)
- Tailwind `max-w-sm`, `aspect-[9/16]`, `lg:max-w-none` 등으로 카드 크기를 지정하는 것 (`isMobile` + `cardStyle` 패턴을 사용할 것)
- 밝은 배경 + 밝은 텍스트 조합 (명도 대비 확보 필수)