(5/5) 내부문서 RAG 챗봇 — 재구축 가능한 캐시, GPU 비용, 모니터링
🧠 내부문서 RAG 챗봇 — Hermes·Codex·ES·Redis (전체 6편)
- 아키텍처 검증 — 이 구성, 논리적으로 맞나
- Q&A 굽기 — 커버리지와 정지 조건
- 신뢰도 게이트 — 그라운드니스와 골든셋
- 서빙 경로 — ES 하이브리드 검색과 Redis 3단 게이트
- 운영 — 재구축 가능한 캐시, GPU 비용, 모니터링 ← 지금 글
- 8.5점에서 9.4점으로 — 2차 채점
Summary
4편까지 오면서 답변 경로는 완성됐어요. 남은 건 이걸 매일 굴리는 일입니다.
1편 채점표에서 유일하게 점수가 떨어진 축이 운영성이었죠(2.0 → 1.5). 정직하게 적어둔 대가를 이번 편에서 치릅니다. 부품이 늘었으니 운영이 어려워진 건 사실이고, 그걸 감당 가능한 수준으로 낮추는 게 이번 글의 목표예요.
기준은 하나로 잡았습니다.
모든 상태는 언제든 다시 만들 수 있어야 한다. Redis 가 날아가도, ES 가 날아가도, 인스턴스가 통째로 사라져도 S3 의 JSONL 하나에서 전부 복원된다.
이게 되면 운영이 “사고 대응”에서 “작업 실행”으로 바뀝니다.
💡 이 글에서 다루는 것
- 집 ↔ AWS 경계 — S3 단방향 적재와 최소 권한
- 멱등 로더와 ES 별칭 전환으로 무중단 재색인
- 재구축 — Redis 가 통째로 날아갔을 때 30분 복구
- GPU 층 분리 — 상주층과 배치층을 나눠 비용 잡기
- 모니터링 — 경로별로 봐야 하는 이유
- 조용히 나빠지는 실패를 잡는 지표 세 개
- 롤백과 크론 배치
- 시리즈 회고 — 8.5점이 실제로 지켜졌나
1. 집과 AWS 사이 — 단방향으로
1편 결함 ⑦의 처방입니다. 원칙은 짧아요.
집에서 AWS 로 나가는 것만 있고, AWS 에서 집으로 들어오는 건 없습니다.
집 미니서버가 하는 일은 2편의 생성과 3편의 검증이고, 결과는 JSONL 파일이에요. 그걸 S3 에 올리면 끝입니다.
| 열려 있는 것 | 열려 있지 않은 것 |
|---|---|
집 → S3 PutObject |
Redis 포트 |
| — | ES 포트 |
| — | AWS → 집 방향 전부 |
Redis 를 인터넷에 노출하지 않는 게 이 설계의 가장 큰 보안적 이득이에요. 1편에서 말했듯 무인증 Redis 노출은 사고 빈도 1위 품목입니다.
IAM 은 진짜 최소로 잡습니다.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::my-rag-artifacts/incoming/*"
}]
}
GetObject 도 ListBucket 도 없습니다. 집 서버는 자기가 올린 것도 못 읽어요. 집이 털려도 유출될 게 없습니다.
업로드는 날짜·배치별로 경로를 나눠서 올립니다.
DAY=$(date +%Y-%m-%d)
aws s3 cp qa_verified.jsonl \
"s3://my-rag-artifacts/incoming/${DAY}/qa_verified.jsonl" \
--content-type application/x-ndjson
aws s3 cp manifest.json \
"s3://my-rag-artifacts/incoming/${DAY}/manifest.json"
manifest.json 을 맨 마지막에 올리는 게 중요해요. 로더는 매니페스트가 보여야 처리를 시작하니까, 큰 파일이 다 올라간 뒤에 신호가 떨어집니다.
{
"day": "2026-08-20",
"files": [{"name": "qa_verified.jsonl", "rows": 15027,
"sha256": "9c1f...", "bytes": 18446210}],
"embedding_model": "local-multilingual-v1",
"gate_version": "g1",
"golden_version": "g1",
"coverage": 0.964
}
sha256 과 rows 는 로더가 검증에 씁니다. 잘린 파일을 색인하는 사고가 생각보다 흔해요.
2. 로더 — 멱등하게, 그리고 별칭으로 바꿔치기
AWS 쪽 로더는 두 가지를 지켜야 합니다.
| 요구 | 왜 |
|---|---|
| 멱등성 | 같은 매니페스트를 두 번 처리해도 결과가 같아야 함 |
| 무중단 전환 | 재색인 중에도 서비스가 계속돼야 함 |
첫 번째는 문서 ID 를 내용 기반으로 잡으면 저절로 됩니다.
def load_jsonl(es, index, path, manifest):
got = 0
actions = []
for row in read_jsonl(path):
actions.append({"_op_type": "index", "_index": index,
"_id": row["id"], "_source": row})
got += 1
if len(actions) >= 1000:
bulk(es, actions)
actions = []
if actions:
bulk(es, actions)
if got != manifest["rows"]:
raise ValueError(f"row mismatch: {got} != {manifest['rows']}")
return got
_op_type: index 에 명시적 _id 를 주면 같은 문서는 덮어쓰기가 됩니다. 두 번 돌려도 같은 결과예요.
두 번째가 별칭(alias) 입니다. ES 의 아주 좋은 기능이에요.
def reindex_behind_alias(es, alias, mapping, path, manifest, stamp):
new_index = f"{alias}_{stamp}"
es.indices.create(index=new_index, body=mapping)
load_jsonl(es, new_index, path, manifest)
es.indices.refresh(index=new_index)
# 별칭을 원자적으로 옮긴다
# 이 순간까지 서비스는 옛 인덱스를 본다
old = list(es.indices.get_alias(name=alias).keys())
es.indices.update_aliases(body={"actions": (
[{"add": {"index": new_index, "alias": alias}}]
+ [{"remove": {"index": i, "alias": alias}} for i in old]
)})
return new_index, old
앱은 docs_chunks 라는 별칭만 바라봅니다. 실제 인덱스는 docs_chunks_20260820 이고요. 새 인덱스를 다 채운 뒤 별칭을 옮기면 전환이 한순간에 일어나요. 반쯤 채워진 인덱스가 서비스에 노출되는 일이 없습니다.
그리고 옛 인덱스를 바로 안 지웁니다. 롤백에 씁니다(6절).
def prune_old(es, alias, keep=3):
idx = sorted(i for i in es.indices.get(f"{alias}_*") if i.startswith(alias + "_"))
for i in idx[:-keep]:
es.indices.delete(index=i)
세 세대를 남겨두는 정도면 충분해요.
3. 재구축 — 다 날아가도 30분
1편 결함 ⑥의 핵심이었죠. Redis 는 파생물이고, 원본은 S3 의 JSONL 입니다.
그래서 이 표가 성립합니다.
| 날아간 것 | 복구 방법 | 대략 소요 |
|---|---|---|
| Redis | 안 해도 됨. 비워두고 다시 채워짐 | 0분 |
| Redis (워밍업까지) | 인기 질문 상위 N개 미리 채우기 | 5분 |
| ES 인덱스 하나 | S3 JSONL 에서 재색인 | 20~30분 |
| 전부 | 위 둘을 순서대로 | 30분 |
Redis 가 0분인 게 재밌어요. 비어 있어도 서비스가 됩니다. 4편의 게이트에서 캐시 miss 는 그냥 RAG 로 흐르니까요. 느려질 뿐이에요.
그래도 워밍업은 해두면 좋습니다.
def warm_cache(es, rd, emb, top_n=2000):
# 실제로 많이 들어온 질문 순으로 미리 채운다
for q in popular_questions(top_n):
key = cache_key(q, GATE["gate_version"], GATE["embedding_model"])
if cache_get(rd, key):
continue
out = answer(q, es, rd, emb, reranker, llm)
cache_put(rd, key, out)
여기서 사전생성 Q&A 전체를 밀어넣지 않는 게 포인트예요. 4편 5절에서 봤듯 Redis 는 exact 캐시라 실제로 들어온 표현이어야 hit 이 납니다. 15,000개를 다 넣어봐야 아무도 그 표현 그대로 안 물어요. 실제 로그에서 나온 질문을 채워야 의미가 있습니다.
전체 복구 절차는 이렇습니다.
# 1. 최신 매니페스트 확인
aws s3 ls s3://my-rag-artifacts/incoming/ | tail -3
# 2. 인덱스 재구축 (별칭 뒤에서)
python3 scripts/loader.py --day 2026-08-20 --rebuild-all
# 3. 헬스체크
python3 scripts/healthcheck.py --golden tests/fixtures/golden_v1.jsonl
# 4. 캐시 워밍업 (선택)
python3 scripts/warm_cache.py --top 2000
3번이 중요해요. 3편의 골든셋을 복구 검증에 그대로 씁니다. 하나 만들어서 네 번 쓴다고 했던 그 골든셋의 다섯 번째 쓸모예요.
def healthcheck(golden, backend, floor=0.90):
got = recall_at_k(load_queries(golden), backend.search, 10)
if got < floor:
raise SystemExit(f"FAIL recall@10={got:.3f} < {floor}")
print(f"OK recall@10={got:.3f}")
“색인이 끝났다”가 아니라 “검색이 예전만큼 된다” 를 확인하는 겁니다. 문서 수만 맞고 벡터가 엉망인 경우가 실제로 있어요.
4. GPU 층 분리와 비용
1편 8절에서 계산이 한 번 뒤집혔던 부분이에요. 정리하면 이렇습니다.
| 워크로드 | 언제 도나 | 무게 |
|---|---|---|
| 질문 임베딩 | 질의마다 | 가벼움 |
| 리랭킹 top-50 | miss 마다 | 중간 |
| Q&A 대량 생성 | 야간 배치 | 무거움 |
| fallback 답변 생성 | miss 마다 | 무거움 |
위 둘은 상시, 아래 둘은 간헐이에요. 성격이 다르니 인스턴스를 나눕니다.
| 층 | 담는 것 | 인스턴스 | 켜는 시간 |
|---|---|---|---|
| 상주 | 임베딩 + 리랭커 | 작은 GPU | 24시간 |
| 배치 | 생성 + 검증 | 큰 GPU (Spot) | 야간 몇 시간 |
| 생성 | fallback 답변 | 배치와 공유 또는 관리형 | 필요할 때 |
원안처럼 큰 GPU 하나에 다 얹으면 1편에서 계산한 월 $700 대가 그대로 나가는데, 나누면 상주 부분만 작게 유지하면 됩니다. 정확한 금액은 리전·인스턴스·시점에 따라 달라지니 여기 숫자를 박아두진 않을게요 — 구조가 요점입니다.
배치층은 Spot 으로 돌리되 중단에 대비해야 해요. 다행히 2편에서 이미 만들어뒀습니다.
# 2편의 재개 패턴이 그대로 Spot 중단 대비가 된다
state = load_state(state_path) # 어디까지 했는지
budget = Budget(max_calls) # 이번 실행 예산
Spot 이 회수돼도 상태 파일이 남아 있으니 다음 실행이 이어받아요. 중단 가능한 워크로드로 설계해두면 Spot 을 쓸 수 있습니다.
그리고 1편 원칙을 다시 확인하면, 비용 구조가 이렇게 갈립니다.
| 무엇 | 어디서 | 왜 |
|---|---|---|
| 생성·검증 | 외부 좋은 모델 (배치) | 품질이 자산으로 굳음. 지연 무관 |
| 임베딩 | 로컬 | 색인에 묶임. 재색인이 공짜여야 함 |
| 리랭킹 | 로컬 | 죽어도 degrade 가능 |
| fallback 생성 | 로컬 or 관리형 | 트래픽 따라 선택 |
색인에 묶이는 것만 로컬로 붙잡고, 나머지는 유연하게. 이게 이 하이브리드의 비용 원칙이에요.
5. 모니터링 — 경로별로 봐야 합니다
4편에서 응답에 path 를 넣어둔 게 여기서 값어치를 합니다. 경로가 넷이었죠.
| path | 뜻 |
|---|---|
cache_exact |
Redis 완전 일치 |
gate_instant |
유사도 ≥ 0.95, 캐시 답 즉답 |
gate_hint |
0.86~0.95, 힌트 주입 RAG |
gate_rag |
순수 RAG |
전체 평균만 보면 절대 안 됩니다. 이유가 명확해요.
| 상황 | 전체 정확도 | 실제 문제 |
|---|---|---|
gate_instant 정확도 0.80, 비중 5% |
0.96 → 0.95 | 즉답 경로가 망가짐. 평균에 안 보임 |
gate_rag 지연 8초, 비중 10% |
p50 정상 | 꼬리 지연 폭발 |
첫 줄이 특히 위험합니다. 즉답 경로는 검증 없이 나가는 유일한 경로라 여기가 나빠지면 1편 결함 ②가 되살아난 거예요. 그런데 비중이 작으면 평균에서는 안 보입니다.
def emit(metrics, out, elapsed_ms):
p = out["path"]
metrics.incr(f"answer.{p}.count")
metrics.timing(f"answer.{p}.latency_ms", elapsed_ms)
metrics.gauge(f"answer.{p}.similarity", out.get("similarity", 0.0))
if out.get("no_answer"):
metrics.incr(f"answer.{p}.no_answer")
조용히 나빠지는 것들
터지는 실패는 알아채기 쉬워요. 문제는 조용히 나빠지는 것들입니다. 세 개만 챙기면 대부분 잡힙니다.
① 즉답 경로의 유사도 분포
def watch_instant_similarity(samples, floor=0.96):
p10 = percentile([s for s in samples], 10)
if p10 < floor:
alert(f"gate_instant p10 similarity dropped to {p10:.3f}")
즉답 경로의 하위 10% 유사도가 임계값(0.95) 바로 위에 몰리기 시작하면, 경계선에서 아슬아슬하게 통과하는 게 늘고 있다는 뜻이에요. 3편의 캘리브레이션을 다시 할 때가 된 겁니다.
② “모르겠습니다” 비율
def watch_no_answer(rate, ceiling=0.15):
if rate > ceiling:
alert(f"no_answer rate {rate:.1%} - retrieval may be degraded")
이게 올라가면 검색이 근거를 못 찾고 있는 거예요. 색인이 깨졌거나, 개정 무효화(4편 8절)에서 새 걸 안 넣고 옛 걸 꺼버렸거나.
③ 임베딩 모델 태그 정합성
def watch_embedding_drift(es, alias, expected):
agg = es.search(index=alias, size=0, aggs={
"models": {"terms": {"field": "embedding_model"}}
})
seen = {b["key"] for b in agg["aggregations"]["models"]["buckets"]}
if seen != {expected}:
alert(f"mixed embedding models in {alias}: {seen}")
1편에서 말한 이음매가 여기서 실제로 작동합니다. 벡터가 섞이면 검색이 조용히 망가져요 — 에러는 안 나고 결과만 이상해집니다. 이 검사가 없으면 몇 주씩 모르고 지나갈 수 있어요.
골든셋 정기 실행
그리고 3편의 회귀 테스트를 운영에서도 주기적으로 돌립니다.
# 매일 새벽, 재색인 직후
python3 -m unittest discover -s tests 2>&1 | tail -5
python3 scripts/healthcheck.py --golden tests/fixtures/golden_v1.jsonl
지표는 사후에 알려주고 골든셋은 사전에 알려줍니다. 둘 다 필요해요.
6. 롤백
뭔가 잘못됐을 때 되돌리는 길을 미리 뚫어둡니다. 다행히 앞에서 만든 것들이 그대로 롤백 수단이에요.
| 문제 | 되돌리는 법 | 소요 |
|---|---|---|
| 새 색인이 이상함 | 별칭을 옛 인덱스로 (2절) | 초 단위 |
| 임계값 조정이 실패 | gate_version 되돌리기 |
초 단위 |
| 개정 무효화가 잘못됨 | active=true 로 되돌림 (4편 8절) |
분 단위 |
| 임베딩 모델 교체 실패 | 옛 인덱스 별칭 복귀 + GATE 되돌림 |
초 단위 |
def rollback_alias(es, alias, to_index):
cur = list(es.indices.get_alias(name=alias).keys())
es.indices.update_aliases(body={"actions": (
[{"add": {"index": to_index, "alias": alias}}]
+ [{"remove": {"index": i, "alias": alias}} for i in cur if i != to_index]
)})
전부 초 단위인 게 중요해요. 되돌리는 게 비싸면 사람들은 안 되돌리고 앞으로 고치려 들고, 그게 사고를 키웁니다.
한 가지만 주의하세요. 임베딩 모델을 되돌릴 때는 인덱스와 임계값을 같이 되돌려야 합니다.
def rollback_embedding(es, to_index, to_gate):
rollback_alias(es, "docs_chunks", to_index)
GATE.update(to_gate)
assert GATE["embedding_model"] == current_encoder().name
assert 한 줄이 3편 9절의 회귀 테스트와 같은 일을 런타임에서 합니다. 인덱스는 옛 모델인데 인코더는 새 모델인 상태가 제일 나쁜 상태예요.
7. 크론 배치
이 저장소의 다른 파이프라인들과 같은 방식으로 묶습니다. 이 블로그의 부동산 수집기가 이미 그렇게 돌고 있어요.
| 시각 | 무엇 | 어디서 |
|---|---|---|
| 매일 01:00 | Q&A 생성 배치 (2편) | 집 |
| 매일 03:00 | 검증 게이트 (3편) | 집 |
| 매일 04:00 | S3 업로드 | 집 |
| 매일 04:30 | 로더 + 별칭 전환 (2절) | AWS |
| 매일 05:00 | 헬스체크 + 골든셋 (5절) | AWS |
| 매주 월 06:00 | 캐시 워밍업 (3절) | AWS |
flock 은 반드시 씌웁니다.
0 1 * * * flock -w 7200 ~/.cache/qagen.lock ~/scripts/qa_generate.sh
0 3 * * * flock -w 7200 ~/.cache/qagen.lock ~/scripts/qa_verify.sh
0 4 * * * flock -w 7200 ~/.cache/qagen.lock ~/scripts/qa_upload.sh
같은 락을 씁니다. 생성이 길어져서 03:00 을 넘겼는데 검증이 시작되면, 아직 검증 안 된 Q&A 를 반쯤 처리하고 상태가 꼬여요. 락 하나로 줄을 세웁니다.
로그는 한곳에 모아두세요.
exec >> ~/.cache/rag-qagen.log 2>&1
echo "=== $(date -Is) $0 ==="
8. 자주 밟는 지뢰
① 로더가 매일 같은 파일을 다시 색인합니다.
매니페스트 처리 기록을 안 남긴 경우예요. 처리한 day 를 어딘가에 적고 건너뛰게 하세요. 멱등이라 결과는 맞지만 시간과 GPU 를 낭비합니다.
② 별칭 전환 후에도 옛 결과가 나옵니다.
refresh 를 안 했습니다(2절). ES 는 색인 직후 바로 검색에 반영되지 않아요.
③ Redis 를 비웠더니 응답이 전부 느려졌습니다.
정상입니다. 워밍업 전까지는 전부 RAG 경로예요(3절). 문제는 이게 정상인지 사고인지 구분이 안 되는 것이라, cache_exact 비중을 대시보드에 꼭 올려두세요.
④ 디스크가 찼습니다.
옛 인덱스를 안 지우고 있어요(2절 prune_old). 그리고 S3 에는 수명 주기 규칙을 걸어두세요.
⑤ 골든셋 테스트가 통과하는데 실제 답이 이상합니다.
골든셋이 오래된 겁니다. 규정이 개정됐으면 골든셋의 정답도 바뀌어야 해요. golden_version 을 올리고 개정 조항 관련 항목을 다시 라벨링하세요. 이게 이 시스템에서 유일하게 사람 손이 계속 들어가야 하는 일입니다.
⑥ 배치가 겹쳐 돌아서 상태 파일이 꼬였습니다.
flock 을 빠뜨렸거나 락 파일을 따로 썼습니다(7절).
9. 1차 회고 — 8.5점은 지켜졌나
1편에서 원안을 6점으로 채점하고 보정안을 8.5점으로 잡았어요. 다섯 편을 지나온 지금 다시 봅니다.
| 결함 | 처방 | 어디서 닫혔나 |
|---|---|---|
| ① 무한 생성 | 커버리지 + 신규율 정지 | 2편 ✅ |
| ② Redis 1차 답변자 | 3단 게이트 | 4편 ✅ |
| ③ 검증 루프 부재 | groundedness 게이트 | 3편 ✅ |
| ④ 벡터 이중화 | 벡터는 ES 에만 | 4편 ✅ |
| ⑤ 모델 품질 단차 | 힌트 주입 + 스타일 규격 | 4편 ✅ |
| ⑥ 휘발하는 본대 | S3 원본 + 30분 재구축 | 이 편 ✅ |
| ⑦ 네트워크 경계 | S3 단방향 | 이 편 ✅ |
| ⑧ 임베딩 미결정 | 측정 후 로컬+리랭커 | 3편·4편 ✅ |
여덟 개가 다 닫혔습니다. 그런데 1편에서 예고한 대가도 그대로 나왔어요.
| 늘어난 것 | 구체적으로 |
|---|---|
| 파이프라인 단계 | 생성 → 중복억제 → 검증 → 업로드 → 로드 → 전환 |
| 튜닝할 값 | tau_fast, tau_slow, min_score, top, bottom, RRF_K |
| 사람 손 | 골든셋 라벨링, 개정 시 재라벨링 |
| 인프라 | ES 별칭, S3, 로더, 상주 GPU + 배치 GPU |
1편에서 운영성을 2.0 → 1.5 로 깎아서 적은 게 이거였습니다. 정직하게 예상했고 정직하게 발생했어요.
아직 약한 곳
마무리로, 이 설계에서 여전히 불안한 데를 적어둡니다. 감추면 나중에 더 비싸지니까요.
골든셋이 병목입니다. 이 시스템의 모든 임계값이 골든셋 하나에서 나오는데, 골든셋은 사람이 만들고 사람이 갱신해야 해요. 규정이 자주 개정되는 조직이면 이게 계속 부채로 쌓입니다. 라벨링을 나눠 맡을 사람이 없으면 이 구조는 유지가 안 됩니다.
임계값이 여섯 개입니다. 서로 얽혀 있어서 하나 바꾸면 다른 게 흔들려요. 3편의 회귀 테스트가 방패인데, 방패가 골든셋에 의존하니 결국 위 문제로 돌아옵니다.
즉답 경로는 여전히 검증 없이 나갑니다. 오탐 0 구간으로 좁혔고 모니터링도 붙였지만, 구조적으로는 검증을 건너뛰는 경로가 남아 있는 것이 맞아요. 사고가 난다면 십중팔구 여기입니다. 그래서 5절의 ① 지표를 제일 위에 올려두라고 한 거예요.
마지막으로
이 시리즈에서 제일 크게 남은 건 이거예요.
처음 그림에서 바꾼 건 부품이 아니라 순서였습니다. Redis 도, ES 도, GPU 도, Codex 도 그대로 씁니다. 캐시를 검증 앞에서 뒤로 옮겼을 뿐인데 시스템의 성격이 반대가 됐어요.
그리고 1편 7절에서 나온 두 번째 교훈도 같이 적어둡니다.
이 시스템의 상한은 LLM 이 아니라 임베딩이 정합니다. 그런데 임베딩은 미리 맞힐 수 없고 재야 아는 것이라, 좋은 모델을 고르는 것보다 바꾸기 쉽게 짓는 게 먼저입니다.
“이거 그럴듯한데?” 로 시작한 그림을 앉아서 채점해본 게 결국 제일 값어치 있었어요. 6점짜리를 그대로 지었으면 빠르고 확신에 찬 오답 기계가 나왔을 겁니다. 지어보고 알았으면 훨씬 비쌌겠죠.
그런데 여기서 멈추지 않았습니다
위에 적은 “아직 약한 곳” 세 가지를 다시 읽어보니 걸리더라고요. 약점을 정직하게 나열한 것과 그 약점을 고친 것은 다른 일이니까요.
특히 이 두 줄이요.
gate_version을 잊으면 옛 답이 계속 나갑니다. (4편 지뢰 ⑥) 재캘리브레이션을 잊지 마세요. (3편)
지뢰 목록에 적어뒀다는 건 자주 일어난다는 걸 이미 알고 있었다는 뜻입니다. 알면서 “잊지 마세요”로 둔 건 설계가 아니라 미룬 거예요.
6편에서 그 자리들을 구조로 바꿉니다 — 즉답 게이트를 서로 다른 두 자로 이중화하고, 근거가 죽으면 즉답이 자동으로 멈추게 하고, 사람이 정하는 임계값을 6개에서 1개로 줄여요. 2차 채점 결과는 9.4점이고, 왜 10점은 살 수 없는지도 거기서 다룹니다.