561 lines
17 KiB
Markdown
561 lines
17 KiB
Markdown
# Flyway 예시
|
|
|
|
전 예시는 `kubectl apply -f` 가능한 완성 매니페스트다. 1000+ 서비스 규모에서 복사/수정해 쓸 수 있도록 full manifest로 구성했다.
|
|
|
|
---
|
|
|
|
## 좋은 예시 1: 완전한 Flyway Job (Helm hook 패턴)
|
|
|
|
### (1) ConfigMap — migration SQL
|
|
|
|
```yaml
|
|
---
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: auth-flyway-sql
|
|
namespace: auth-prod
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/component: db-migration
|
|
app.kubernetes.io/part-of: auth-platform
|
|
app.kubernetes.io/managed-by: Helm
|
|
data:
|
|
V1__init_auth_schema.sql: |
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id bigserial PRIMARY KEY,
|
|
email text NOT NULL,
|
|
display_name text,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(lower(email));
|
|
V2__add_refresh_tokens.sql: |
|
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
id bigserial PRIMARY KEY,
|
|
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash bytea NOT NULL,
|
|
expires_at timestamptz NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
|
V3__add_last_login_column.sql: |
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at timestamptz;
|
|
V4__create_idx_last_login_concurrently.sql: |
|
|
-- flyway:executeInTransaction=false
|
|
-- Long-running DDL. Schedule in low-traffic window.
|
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login
|
|
ON users(last_login_at);
|
|
R__refresh_active_users_view.sql: |
|
|
CREATE OR REPLACE VIEW active_users AS
|
|
SELECT id, email, display_name, last_login_at
|
|
FROM users
|
|
WHERE last_login_at > now() - interval '30 days';
|
|
```
|
|
|
|
### (2) Vault Secrets Operator — DB password
|
|
|
|
```yaml
|
|
---
|
|
apiVersion: secrets.hashicorp.com/v1beta1
|
|
kind: VaultStaticSecret
|
|
metadata:
|
|
name: auth-pg-app
|
|
namespace: auth-prod
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/component: db-migration
|
|
spec:
|
|
type: kv-v2
|
|
mount: kv
|
|
path: auth-prod/postgres/app
|
|
destination:
|
|
name: auth-pg-app
|
|
create: true
|
|
type: Opaque
|
|
refreshAfter: 1h
|
|
vaultAuthRef: vault-auth-auth-prod
|
|
```
|
|
|
|
### (3) Flyway Job — pre-upgrade / pre-install
|
|
|
|
```yaml
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: auth-flyway
|
|
namespace: auth-prod
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/component: db-migration
|
|
---
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: auth-flyway-migrate
|
|
namespace: auth-prod
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server-prod
|
|
app.kubernetes.io/component: db-migration
|
|
app.kubernetes.io/part-of: auth-platform
|
|
app.kubernetes.io/managed-by: Helm
|
|
app.kubernetes.io/version: "2026.04.16"
|
|
annotations:
|
|
"helm.sh/hook": "pre-upgrade,pre-install"
|
|
"helm.sh/hook-weight": "-10"
|
|
"helm.sh/hook-delete-policy": "before-hook-creation,hook-succeeded"
|
|
spec:
|
|
parallelism: 1
|
|
completions: 1
|
|
backoffLimit: 0
|
|
activeDeadlineSeconds: 1800
|
|
ttlSecondsAfterFinished: 86400
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/component: db-migration
|
|
spec:
|
|
serviceAccountName: auth-flyway
|
|
restartPolicy: Never
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 1000
|
|
runAsGroup: 1000
|
|
fsGroup: 1000
|
|
seccompProfile: {type: RuntimeDefault}
|
|
initContainers:
|
|
- name: flyway-info
|
|
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
|
|
imagePullPolicy: IfNotPresent
|
|
args: ["info"]
|
|
env: &flywayEnv
|
|
- {name: FLYWAY_URL, value: "jdbc:postgresql://auth-pg-rw.data-prod.svc:5432/auth?sslmode=require"}
|
|
- {name: FLYWAY_USER, value: "auth_app"}
|
|
- {name: FLYWAY_LOCATIONS, value: "filesystem:/flyway/sql"}
|
|
- {name: FLYWAY_SCHEMAS, value: "auth_server"}
|
|
- {name: FLYWAY_DEFAULT_SCHEMA, value: "auth_server"}
|
|
- {name: FLYWAY_TABLE, value: "flyway_schema_history"}
|
|
- {name: FLYWAY_VALIDATE_ON_MIGRATE, value: "true"}
|
|
- {name: FLYWAY_BASELINE_ON_MIGRATE, value: "false"}
|
|
- {name: FLYWAY_OUT_OF_ORDER, value: "false"}
|
|
- {name: FLYWAY_MIXED, value: "false"}
|
|
- {name: FLYWAY_CLEAN_DISABLED, value: "true"}
|
|
- name: FLYWAY_PASSWORD
|
|
valueFrom: {secretKeyRef: {name: auth-pg-app, key: password}}
|
|
resources:
|
|
requests: {cpu: "50m", memory: "128Mi"}
|
|
limits: {cpu: "500m", memory: "512Mi"}
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities: {drop: ["ALL"]}
|
|
volumeMounts:
|
|
- {name: sql, mountPath: /flyway/sql, readOnly: true}
|
|
- {name: tmp, mountPath: /tmp}
|
|
- name: flyway-validate
|
|
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
|
|
imagePullPolicy: IfNotPresent
|
|
args: ["validate"]
|
|
env: *flywayEnv
|
|
resources:
|
|
requests: {cpu: "50m", memory: "128Mi"}
|
|
limits: {cpu: "500m", memory: "512Mi"}
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities: {drop: ["ALL"]}
|
|
volumeMounts:
|
|
- {name: sql, mountPath: /flyway/sql, readOnly: true}
|
|
- {name: tmp, mountPath: /tmp}
|
|
containers:
|
|
- name: flyway-migrate
|
|
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
|
|
imagePullPolicy: IfNotPresent
|
|
args: ["-X", "migrate"]
|
|
env: *flywayEnv
|
|
resources:
|
|
requests: {cpu: "100m", memory: "256Mi"}
|
|
limits: {cpu: "1", memory: "1Gi"}
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities: {drop: ["ALL"]}
|
|
volumeMounts:
|
|
- {name: sql, mountPath: /flyway/sql, readOnly: true}
|
|
- {name: tmp, mountPath: /tmp}
|
|
volumes:
|
|
- name: sql
|
|
configMap: {name: auth-flyway-sql}
|
|
- name: tmp
|
|
emptyDir: {}
|
|
```
|
|
|
|
왜 좋은가:
|
|
- Helm hook으로 app Deployment보다 **먼저** 실행 (`pre-upgrade,pre-install`, weight `-10`)
|
|
- `before-hook-creation,hook-succeeded` 삭제 정책으로 과거 Job 정리
|
|
- initContainer로 `info` + `validate`를 먼저 실행해 실패를 앞당김
|
|
- 메인 container에서 `migrate` (advisory lock 덕분에 같은 Job이 중복 실행돼도 직렬화됨)
|
|
- `FLYWAY_CLEAN_DISABLED=true` (production 필수)
|
|
- `FLYWAY_BASELINE_ON_MIGRATE=false`, `FLYWAY_OUT_OF_ORDER=false`
|
|
- digest pinning, restricted PSA, anchor/alias로 env 중복 제거
|
|
- `parallelism: 1`, `backoffLimit: 0`, `activeDeadlineSeconds: 1800`
|
|
|
|
---
|
|
|
|
## 좋은 예시 2: Argo CD sync-wave 패턴
|
|
|
|
```yaml
|
|
---
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: auth-flyway-migrate
|
|
namespace: auth-prod
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/component: db-migration
|
|
app.kubernetes.io/managed-by: argocd
|
|
annotations:
|
|
argocd.argoproj.io/sync-wave: "-1"
|
|
argocd.argoproj.io/hook: Sync
|
|
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
|
|
spec:
|
|
parallelism: 1
|
|
completions: 1
|
|
backoffLimit: 0
|
|
activeDeadlineSeconds: 1800
|
|
ttlSecondsAfterFinished: 86400
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 1000
|
|
runAsGroup: 1000
|
|
fsGroup: 1000
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
containers:
|
|
# (containers 세부는 예시 1과 동일; 요지만 재현)
|
|
- name: flyway
|
|
image: flyway/flyway@sha256:7d9f7c4e2a1b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
|
|
args: ["-X", "migrate"]
|
|
resources:
|
|
requests: { cpu: 100m, memory: 256Mi }
|
|
limits: { memory: 1Gi }
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities:
|
|
drop: ["ALL"]
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: auth-server
|
|
namespace: auth-prod
|
|
annotations:
|
|
argocd.argoproj.io/sync-wave: "0"
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server-prod
|
|
spec:
|
|
replicas: 3
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server-prod
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: auth-server
|
|
app.kubernetes.io/instance: auth-server-prod
|
|
spec:
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 10001
|
|
runAsGroup: 10001
|
|
fsGroup: 10001
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
containers:
|
|
- name: auth-server
|
|
image: registry.example.com/identity/auth-server:1.24.0
|
|
resources:
|
|
requests: { cpu: 500m, memory: 1Gi }
|
|
limits: { memory: 1536Mi }
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities:
|
|
drop: ["ALL"]
|
|
```
|
|
|
|
왜 좋은가:
|
|
- Argo CD가 wave `-1` → `0` 순서로 sync
|
|
- Helm hook과 혼용하지 않음
|
|
- `BeforeHookCreation` 정책으로 이전 Job 정리 후 새 Job 실행
|
|
|
|
---
|
|
|
|
## 좋은 예시 3: non-transactional DDL 전용 migration
|
|
|
|
`V4__create_idx_last_login_concurrently.sql`:
|
|
```sql
|
|
-- flyway:executeInTransaction=false
|
|
-- CREATE INDEX CONCURRENTLY는 Postgres에서 트랜잭션 내 실행 불가.
|
|
-- Flyway 8.2+ directive로 파일 단위 트랜잭션 비활성화.
|
|
-- Runtime estimate: 약 15분 (50M rows 기준).
|
|
-- Deploy window: 주간 트래픽 저점 (예: 화요일 03:00 UTC)
|
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_last_login
|
|
ON users(last_login_at);
|
|
```
|
|
|
|
왜 좋은가:
|
|
- 파일 단독으로 분리 (다른 statement 없음)
|
|
- 주석에 runtime / window 명시
|
|
- `IF NOT EXISTS`로 재실행 안전성 (CREATE INDEX CONCURRENTLY 실패 시 INVALID 인덱스가 남을 수 있음 — 별도 cleanup 필요)
|
|
|
|
❌ 나쁜 예시 1: 트랜잭션 내 CREATE INDEX CONCURRENTLY
|
|
|
|
```sql
|
|
-- V4__.sql (executeInTransaction directive 없음)
|
|
CREATE INDEX CONCURRENTLY idx_users_last_login ON users(last_login_at);
|
|
```
|
|
|
|
문제:
|
|
- Flyway가 자동으로 트랜잭션을 열어 실행 → `ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block`
|
|
- 해결: `-- flyway:executeInTransaction=false` directive
|
|
|
|
---
|
|
|
|
## 좋은 예시 4: history table schema를 명시적으로 분리
|
|
|
|
env:
|
|
```yaml
|
|
- {name: FLYWAY_CREATE_SCHEMAS, value: "false"}
|
|
- {name: FLYWAY_INIT_SQL, value: "CREATE SCHEMA IF NOT EXISTS auth_server; CREATE SCHEMA IF NOT EXISTS flyway_history"}
|
|
- {name: FLYWAY_DEFAULT_SCHEMA, value: "flyway_history"}
|
|
- {name: FLYWAY_SCHEMAS, value: "flyway_history,auth_server"}
|
|
- {name: FLYWAY_TABLE, value: "flyway_schema_history"}
|
|
```
|
|
|
|
왜 좋은가:
|
|
- history table은 `flyway_history.flyway_schema_history`
|
|
- migration 대상 schema는 `auth_server`
|
|
- `createSchemas=false` 조건 하에서 `initSql`로 schema 사전 생성
|
|
|
|
---
|
|
|
|
## 좋은 예시 5: 운영 절차 (Helm + on-demand CNPG backup 연계)
|
|
|
|
```bash
|
|
# 1. pending migration 확인 (로컬)
|
|
docker run --rm -v $PWD/sql:/flyway/sql:ro \
|
|
-e FLYWAY_URL=jdbc:postgresql://stage.../auth \
|
|
-e FLYWAY_USER=auth_app -e FLYWAY_PASSWORD=... \
|
|
flyway/flyway@sha256:... info
|
|
|
|
# 2. PR review + migration 영향 분석
|
|
|
|
# 3. 운영 배포 직전 on-demand backup
|
|
cat <<EOF | kubectl apply -f -
|
|
apiVersion: postgresql.cnpg.io/v1
|
|
kind: Backup
|
|
metadata:
|
|
name: auth-pg-pre-2026-04-16
|
|
namespace: data-prod
|
|
spec:
|
|
cluster: {name: auth-pg}
|
|
method: barmanObjectStore
|
|
EOF
|
|
|
|
# 4. Helm upgrade (pre-upgrade hook이 Flyway Job 실행)
|
|
helm upgrade auth-server ./charts/auth-server \
|
|
--namespace auth-prod \
|
|
--values values/prod.yaml \
|
|
--atomic --timeout 20m
|
|
|
|
# 5. Flyway Job 로그 확인
|
|
kubectl -n auth-prod logs job/auth-flyway-migrate --all-containers
|
|
|
|
# 6. Deployment rollout 확인
|
|
kubectl -n auth-prod rollout status deploy/auth-server --timeout=10m
|
|
|
|
# 7. flyway info 재실행 (post-check)
|
|
```
|
|
|
|
왜 좋은가:
|
|
- migration 직전 on-demand backup으로 PITR 지점 확보
|
|
- `helm upgrade --atomic`으로 실패 시 자동 롤백
|
|
- hook이 `hook-succeeded` 정책으로 정리됨
|
|
|
|
---
|
|
|
|
## 좋은 예시 6: expand → migrate → contract (여러 릴리즈)
|
|
|
|
### Release 1 (V120~V121) — Expand
|
|
|
|
```sql
|
|
-- V120__add_email_canonical_nullable.sql
|
|
-- flyway:executeInTransaction=false
|
|
ALTER TABLE users ADD COLUMN email_canonical text;
|
|
CREATE INDEX CONCURRENTLY idx_users_email_canonical ON users(email_canonical);
|
|
```
|
|
|
|
```sql
|
|
-- V121__backfill_email_canonical.sql
|
|
-- Flyway 기본 트랜잭션 모드 — 소규모 테이블용. 대용량은 별도 배치 Job.
|
|
UPDATE users
|
|
SET email_canonical = lower(trim(email))
|
|
WHERE email_canonical IS NULL
|
|
AND email IS NOT NULL;
|
|
```
|
|
|
|
앱: 쓰기 시 두 컬럼 채움. 읽기는 아직 `email`.
|
|
|
|
### Release 2 (V122) — Migrate
|
|
|
|
```sql
|
|
-- V122__add_email_canonical_constraints.sql
|
|
ALTER TABLE users ALTER COLUMN email_canonical SET NOT NULL;
|
|
ALTER TABLE users ADD CONSTRAINT users_email_canonical_unique UNIQUE (email_canonical);
|
|
```
|
|
|
|
앱: 읽기/쓰기 모두 `email_canonical`. 기존 `email`도 fallback 유지.
|
|
|
|
### Release 3 (V130) — Contract
|
|
|
|
```sql
|
|
-- V130__drop_legacy_email_column.sql
|
|
ALTER TABLE users DROP COLUMN email;
|
|
```
|
|
|
|
왜 좋은가:
|
|
- N-1 ↔ N 동시 배포 허용
|
|
- 각 릴리즈가 독립 롤백 가능 (V130 제외 모두 non-destructive)
|
|
- expand와 contract가 같은 릴리즈에 섞이지 않음
|
|
|
|
---
|
|
|
|
## 좋은 예시 7: repeatable migration은 정의성 오브젝트에만
|
|
|
|
```
|
|
sql/
|
|
V120__add_email_canonical_nullable.sql
|
|
V121__backfill_email_canonical.sql
|
|
V122__add_email_canonical_constraints.sql
|
|
V130__drop_legacy_email_column.sql
|
|
R__refresh_active_users_view.sql
|
|
R__user_signup_function.sql
|
|
```
|
|
|
|
왜 좋은가:
|
|
- 핵심 schema change는 versioned
|
|
- view / function만 repeatable — 체크섬 변경 시 재적용
|
|
|
|
❌ 나쁜 예시 2: 순서 중요한 schema change를 R__로
|
|
|
|
```
|
|
R__create_users_table.sql ← 잘못. 순서 보장 없음
|
|
R__add_refresh_tokens.sql
|
|
```
|
|
|
|
문제:
|
|
- repeatable은 ordering 보장 없음 — 의존성 있는 change에 부적합
|
|
|
|
---
|
|
|
|
## 나쁜 예시 3: app startup에 migration 숨김
|
|
|
|
```properties
|
|
# application.properties
|
|
spring.flyway.enabled=true
|
|
spring.flyway.baseline-on-migrate=true
|
|
spring.flyway.out-of-order=true
|
|
```
|
|
|
|
문제:
|
|
- replicas=3이면 Pod 3개가 동시 migrate 시도 (advisory lock이 직렬화는 하지만 기동 latency 증가)
|
|
- app rollout 실패와 migration 실패가 섞임
|
|
- 신규 Pod 기동마다 validate 수행 → 오차 탐지 시점이 흐려짐
|
|
- `baseline-on-migrate=true` + `out-of-order=true` 조합은 migration history 신뢰도 저하
|
|
|
|
---
|
|
|
|
## 나쁜 예시 4: validate 실패 후 바로 repair
|
|
|
|
```bash
|
|
flyway validate || flyway repair
|
|
flyway migrate
|
|
```
|
|
|
|
문제:
|
|
- history 문제를 원인 분석 없이 덮음
|
|
- repair를 정상 운영 흐름처럼 사용 — 탐지력 저하
|
|
|
|
---
|
|
|
|
## 나쁜 예시 5: 적용된 migration 파일 수정
|
|
|
|
```
|
|
V42__add_refresh_token_column.sql
|
|
# 처음엔 빈 migration
|
|
# prod apply 후 컬럼 타입을 나중에 editor로 수정
|
|
```
|
|
|
|
문제:
|
|
- checksum mismatch → validate 실패
|
|
- 환경 간 재현성 깨짐
|
|
- 대응은 "새 V__ migration으로 교정"
|
|
|
|
---
|
|
|
|
## 나쁜 예시 6: U__ undo migration 작성
|
|
|
|
```
|
|
V120__add_column.sql
|
|
U120__drop_column.sql ← OSS Flyway는 실행 불가
|
|
```
|
|
|
|
문제:
|
|
- `flyway undo`는 Teams/Enterprise 전용
|
|
- OSS 환경에서는 U__ 파일이 실행되지 않아 오해 유발
|
|
- rollback은 forward-only + PITR로
|
|
|
|
---
|
|
|
|
## 나쁜 예시 7: parallelism 누락 + 재시도 무한
|
|
|
|
```yaml
|
|
spec:
|
|
# parallelism, backoffLimit, activeDeadlineSeconds 모두 누락
|
|
template:
|
|
spec:
|
|
restartPolicy: OnFailure # 무한 재시도 유발
|
|
```
|
|
|
|
문제:
|
|
- `backoffLimit` 기본 6 + `restartPolicy: OnFailure` → 실패 시 지수 backoff로 계속 재시도
|
|
- `activeDeadlineSeconds` 없음 → hang된 migration이 영원히 살아있음
|
|
- advisory lock이 걸린 실패 Job이 새 Job을 블록
|
|
|
|
---
|
|
|
|
## 나쁜 예시 8: Helm hook + Argo CD hook 혼용
|
|
|
|
```yaml
|
|
annotations:
|
|
"helm.sh/hook": "pre-upgrade"
|
|
"helm.sh/hook-weight": "-10"
|
|
argocd.argoproj.io/sync-wave: "-1"
|
|
argocd.argoproj.io/hook: Sync
|
|
```
|
|
|
|
문제:
|
|
- Argo CD가 Helm chart를 렌더링할 때 Helm hook annotation을 일반 리소스의 annotation으로 해석
|
|
- 결과적으로 Flyway Job이 일반 리소스로 취급되거나, 두 시스템이 서로 다른 시점에 Job을 만들어 race 발생
|
|
- 하나의 배포 도구에 맞춰 한쪽만 사용할 것
|