앞 글에서 같은 분야 문서를 부으면 코퍼스가 두 배가 될 때마다 nDCG@10이 5.8%p씩 빠진다는 것을 보았고, 그 손해를 줄이는 길로 상위 칸을 다시 가리는 재순위를 들었습니다. 이 글은 그 재순위에 후보를 몇 개 넘길지를 잽니다. 재순위가 무엇이고 왜 cross-encoder가 더 정확한지는 RAG 리랭킹이 맡으므로, 여기서는 깊이 하나만 흔듭니다.
통념은 「많이 넘길수록 좋지만 느려진다」입니다. 느려지는 쪽은 맞았습니다. 질의당 시간이 후보 수에 정확히 정비례해서, 후보를 여섯 배 넘기면 시간도 5.6배가 됐습니다. 좋아지는 쪽은 틀렸습니다. nDCG@10 이득은 후보 다섯 개에서 3.58%p로 다 났고, 그 뒤로 10·20·30개로 늘려도 늘어난 몫은 신뢰구간 안에서 0과 구별되지 않았습니다.
실험 설계
두 단계 검색
1차 검색은 CPU만으로 세우는 검색 실험대의 것을 그대로 씁니다. all-MiniLM-L6-v2가 scifact 5,183편과 test 질의 300개를 따로 임베딩하고 코사인 유사도로 순위를 매깁니다. 질의와 문서를 따로 벡터로 만드는 이 방식을 바이인코더라고 부릅니다. 문서 벡터를 미리 만들어 둘 수 있어 빠르지만, 질의와 문서가 한 번도 서로를 보지 않은 채 점수가 나옵니다.
2단계는 cross-encoder입니다. 질의와 문서 한 쌍을 한 입력으로 이어 붙여 모델에 넣고 관련도 점수 하나를 받습니다. 둘을 함께 읽으므로 더 정확하다고 알려져 있지만, 쌍마다 모델을 한 번씩 돌려야 해서 문서 벡터를 미리 만들어 둘 수가 없습니다. 그래서 1차 검색이 상위 k개를 넘기고, cross-encoder는 그 k개만 다시 채점해 순서를 바꿉니다. 이 k가 이 글이 흔드는 재순위 깊이입니다. k가 10보다 작으면 다시 채점한 k개 아래에 1차 검색의 k+1~10위를 그대로 붙여 상위 10칸을 채웁니다.
재순위 모델은 게이트가 없는 cross-encoder/ms-marco-MiniLM-L-6-v2입니다. 웹 검색 질의(MS MARCO)로 학습된 6층짜리 작은 모델이고, scifact의 과학 주장 질의는 이 모델이 학습 때 본 적 없는 분야입니다.
문서 길이 자르기
계획은 k를 10·20·50·100·200으로 올리는 것이었습니다. 쌍 하나를 채점하는 데 드는 시간을 먼저 재 보니 그대로는 못 돌린다는 것이 드러났습니다.
import time, torch
from sentence_transformers import CrossEncoder
from datasets import load_dataset
torch.set_num_threads(4)
c = load_dataset("BeIR/scifact", "corpus")["corpus"]
docs = [(d["title"] + " " + d["text"]).strip() for d in c][:400]
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
L = [len(ce.tokenizer("what is cancer", d)["input_ids"]) for d in docs]; import numpy as np
print("tok median", np.median(L), "p90", np.percentile(L, 90), ">256", np.mean(np.array(L) > 256))
for ml in (512, 256, 128):
ce.max_seq_length = ml
for bs in (16, 64):
t = time.perf_counter(); ce.predict([("what is cancer", d) for d in docs], batch_size=bs, show_progress_bar=False)
print(ml, bs, round(400 / (time.perf_counter() - t), 1), "pairs/s")
tok median 317.0 p90 498.0 >256 0.7025
512 16 34.4 pairs/s
512 64 28.2 pairs/s
256 16 70.8 pairs/s
256 64 56.2 pairs/s
128 16 133.8 pairs/s
128 64 113.2 pairs/s
scifact 문서 앞 400편으로 잰 값입니다. 모델의 최대 길이 512토큰을 다 쓰면 초당 34쌍이라, 질의 300개 × 후보 200개 = 6만 쌍이 30분 가까이 걸립니다. 한 편의 실행 상한이 5분이므로 규모를 줄여야 했고, 두 가지를 줄였습니다. 최대 길이를 256토큰으로 내려 속도를 두 배로 올렸고(배치 16), 깊이를 30까지로 잘랐습니다.
256토큰으로 자르는 값은 작지 않습니다. 쌍의 토큰 수 중앙값이 317이고 70%가 256을 넘으므로, 대부분의 문서가 초록 뒷부분을 잃은 채 채점됩니다. 이 선택이 결과에 섞여 있다는 것은 한계 절에 다시 적습니다.
깊이별 시간 재는 법
깊이마다 처음부터 다시 채점하면 같은 쌍을 여러 번 채점하게 됩니다. 그래서 질의마다 후보를 15위, 610위, 1120위, 2130위의 네 덩어리로 나눠 차례로 채점하고, 덩어리마다 걸린 시간을 따로 잽니다. 깊이 k의 시간은 k까지의 덩어리 시간을 더한 것이고, 이것은 깊이 k로 재순위할 때 실제로 해야 하는 일과 정확히 같습니다. 덩어리마다 predict를 따로 부르므로 한 번에 부를 때보다 호출 부담이 조금 더 실립니다.
품질 판정은 짝지은 부트스트랩입니다. 같은 질의에서 「재순위 후」와 「재순위 전」, 그리고 「깊이 k」와 「직전 깊이」의 nDCG@10을 빼서 질의 300개를 1,000번 복원 추출합니다. 판정법의 근거는 Recall@k·MRR·nDCG는 언제 서로 다른 결론을 내는가에 있습니다.
재현과 출력
재현 블록
pip install torch sentence-transformers datasets numpy
import time, numpy as np, torch
from datasets import load_dataset
from sentence_transformers import SentenceTransformer, CrossEncoder
torch.manual_seed(0); torch.set_num_threads(4)
corpus = load_dataset("BeIR/scifact", "corpus")["corpus"]
qtext = {str(q["_id"]): q["text"] for q in load_dataset("BeIR/scifact", "queries")["queries"]}
gold = {}
for r in load_dataset("BeIR/scifact-qrels")["test"]:
gold.setdefault(str(r["query-id"]), set()).add(str(r["corpus-id"]))
qids = sorted(gold, key=int)
docs, dids = [(d["title"] + " " + d["text"]).strip() for d in corpus], [str(d["_id"]) for d in corpus]
qs = [qtext[q] for q in qids]
R = np.array([[1.0 if d in gold[q] else 0.0 for d in dids] for q in qids])
NG, DISC = R.sum(1), 1.0 / np.log2(np.arange(2, 12))
IDCG = np.array([DISC[:int(min(n, 10))].sum() for n in NG])
bi = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2"); t = time.perf_counter()
E = bi.encode(docs, batch_size=64, normalize_embeddings=True, show_progress_bar=False)
EQ = bi.encode(qs, batch_size=64, normalize_embeddings=True, show_progress_bar=False)
order = np.argsort(-(EQ @ E.T), axis=1)[:, :200]
print(f"bi-encoder: corpus={len(docs)} queries={len(qs)} encode={time.perf_counter()-t:.1f}s")
print("candidate recall ceiling:", " ".join(f"R@{k}={(np.take_along_axis(R, order[:, :k], 1).sum(1) / NG).mean():.4f}" for k in (5, 10, 20, 30, 50, 100, 200)))
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2"); ce.max_seq_length = 256
KS = (5, 10, 20, 30); S = np.zeros((len(qs), KS[-1])); T = np.zeros((len(qs), len(KS)))
for i, q in enumerate(qs):
lo = 0
for j, k in enumerate(KS):
t = time.perf_counter()
S[i, lo:k] = ce.predict([(q, docs[d]) for d in order[i, lo:k]], batch_size=16, show_progress_bar=False)
T[i, j] = time.perf_counter() - t; lo = k
T = T.cumsum(1) * 1000
def ndcg(top): return (np.take_along_axis(R, top, 1) * DISC).sum(1) / IDCG
base = ndcg(order[:, :10]); BS = np.random.default_rng(0).integers(0, len(qs), size=(1000, len(qs)))
print(f"{'k':>4} {'nDCG@10':>8} {'gain':>8} {'95% CI vs no-rerank':>21} {'step':>8} {'95% CI vs prev k':>20} {'ms/query':>9} {'ms/pair':>8}")
print(f"{0:>4} {base.mean():8.4f} {0:+8.4f} {'':>21} {'':>8} {'':>20} {0:9.1f} {'':>8}")
prev = base
for j, k in enumerate(KS):
rr = np.argsort(-S[:, :k], axis=1)
cur = ndcg(np.hstack([np.take_along_axis(order[:, :k], rr, 1), order[:, k:max(k, 10)]])[:, :10])
d, s = cur - base, cur - prev
a, b = np.percentile(d[BS].mean(1), [2.5, 97.5]); c, e = np.percentile(s[BS].mean(1), [2.5, 97.5])
print(f"{k:>4} {cur.mean():8.4f} {d.mean():+8.4f} [{a:+.4f}, {b:+.4f}] {s.mean():+8.4f} [{c:+.4f}, {e:+.4f}] "
f"{T[:, j].mean():9.1f} {T[:, j].mean()/k:8.2f}")
prev = cur
python3 rerank.py
실제 출력
bi-encoder: corpus=5183 queries=300 encode=91.4s
candidate recall ceiling: R@5=0.7379 R@10=0.7833 R@20=0.8373 R@30=0.8653 R@50=0.8920 R@100=0.9250 R@200=0.9600
k nDCG@10 gain 95% CI vs no-rerank step 95% CI vs prev k ms/query ms/pair
0 0.6451 +0.0000 0.0
5 0.6808 +0.0358 [+0.0159, +0.0568] +0.0358 [+0.0159, +0.0568] 85.3 17.06
10 0.6770 +0.0319 [+0.0100, +0.0554] -0.0038 [-0.0128, +0.0058] 169.7 16.97
20 0.6804 +0.0353 [+0.0100, +0.0630] +0.0034 [-0.0082, +0.0151] 324.1 16.21
30 0.6846 +0.0395 [+0.0116, +0.0700] +0.0042 [-0.0071, +0.0169] 478.7 15.96
첫 줄의 재순위 전 nDCG@10 0.6451은 실험대가 낸 값과 소수점 넷째 자리까지 같습니다. 1차 검색이 실험대와 같은 상태에서 출발한다는 확인입니다. 실행 시간은 4분 7초였고 그중 1분 반이 바이인코더 인코딩, 나머지가 cross-encoder입니다.
예산을 넘긴 첫 실행
처음에는 깊이 50까지 재려고 KS = (5, 10, 20, 30, 50)으로 돌렸고, 그 실행은 5분 38초가 걸려 상한을 넘었습니다. 위의 재현 블록은 50을 뺀 것입니다. 그 첫 실행의 출력을 그대로 싣습니다.
bi-encoder: corpus=5183 queries=300 encode=93.4s
candidate recall ceiling: R@5=0.7379 R@10=0.7833 R@20=0.8373 R@30=0.8653 R@50=0.8920 R@100=0.9250 R@200=0.9600
k nDCG@10 gain 95% CI vs no-rerank step 95% CI vs prev k ms/query ms/pair
0 0.6451 +0.0000 0.0
5 0.6808 +0.0358 [+0.0159, +0.0568] +0.0358 [+0.0159, +0.0568] 82.7 16.54
10 0.6770 +0.0319 [+0.0100, +0.0554] -0.0038 [-0.0128, +0.0058] 164.6 16.46
20 0.6804 +0.0353 [+0.0100, +0.0630] +0.0034 [-0.0082, +0.0151] 317.8 15.89
30 0.6846 +0.0395 [+0.0116, +0.0700] +0.0042 [-0.0071, +0.0169] 469.5 15.65
50 0.6834 +0.0383 [+0.0085, +0.0704] -0.0012 [-0.0100, +0.0100] 769.9 15.40
깊이 30까지의 nDCG와 신뢰구간은 두 실행이 한 자리도 다르지 않습니다. cross-encoder 점수와 부트스트랩 시드가 고정돼 있어서이고, 달라진 것은 ms/query·ms/pair 열뿐입니다. 깊이 50은 30보다 0.12%p 낮고 그 구간도 0을 품습니다. 후보를 30에서 50으로 늘린 300ms가 아무것도 사지 못했다는 한 줄을 이 실행이 더해 줍니다.
품질
다섯 개의 이득
| 깊이 k | nDCG@10 | 재순위 전 대비 | 95% 구간 | 직전 깊이 대비 | 95% 구간 |
|---|---|---|---|---|---|
| 0 | 0.6451 | — | — | — | — |
| 5 | 0.6808 | +3.58%p | [+1.59, +5.68] | +3.58%p | [+1.59, +5.68] |
| 10 | 0.6770 | +3.19%p | [+1.00, +5.54] | −0.38%p | [−1.28, +0.58] |
| 20 | 0.6804 | +3.53%p | [+1.00, +6.30] | +0.34%p | [−0.82, +1.51] |
| 30 | 0.6846 | +3.95%p | [+1.16, +7.00] | +0.42%p | [−0.71, +1.69] |
| 50 | 0.6834 | +3.83%p | [+0.85, +7.04] | −0.12%p | [−1.00, +1.00] |
재순위는 확실히 값을 합니다. 어느 깊이에서나 재순위 전 대비 구간이 0을 배제합니다. 그런데 그 이득은 첫 다섯 개에서 다 납니다. 1차 검색의 1~5위만 다시 줄 세워도 +3.58%p이고, 그 뒤 깊이를 한 단계씩 늘린 네 번의 「직전 대비」는 −0.38·+0.34·+0.42·−0.12%p로 부호도 일정하지 않으며 구간이 전부 0을 품습니다.
깊이 5와 10에서 일어난 일은 순서 바꾸기뿐이라는 점도 짚어 둡니다. 후보가 열 개 이하면 상위 10칸에 드는 문서의 집합은 1차 검색 그대로이고 그 안의 순서만 바뀝니다. 그러니 깊이 5의 +3.58%p는 전부 정답을 몇 칸 위로 올려서 번 몫이고, 새로 찾아낸 정답은 하나도 없습니다. 새 정답을 10칸 안으로 들여올 수 있는 것은 깊이 20과 30부터인데, 그 두 깊이가 깊이 5와 구별되지 않았습니다. 들여올 기회를 줬는데 들여온 몫이 잡히지 않았다는 뜻이고, 그 기회의 크기는 다음 소절이 셉니다.
깊이 5에서 10으로 갈 때 점추정이 오히려 0.38%p 내려간 것도 같은 그림의 한 조각입니다. 6~10위 문서를 함께 채점하면 cross-encoder가 그중 오답 몇 편을 정답 위로 올려 버리는 질의가 생기고, 그만큼을 다른 질의의 개선이 메우지 못했습니다. 구간이 0을 품으므로 「깊게 넘기면 손해」라고 말할 수는 없지만, 「깊게 넘기면 이득」이라는 쪽의 근거도 없습니다.
후보 재현율의 천장
재순위는 1차 검색이 넘겨준 후보 안에서만 순서를 바꿀 수 있습니다. 그래서 출력 둘째 줄이 깊이마다 정답 문서의 몇 %가 후보 안에 들어 있는지, 곧 재순위가 닿을 수 있는 천장을 셉니다.
| 깊이 | 5 | 10 | 20 | 30 | 50 | 100 | 200 |
|---|---|---|---|---|---|---|---|
| 후보 안의 정답 비율 | 0.7379 | 0.7833 | 0.8373 | 0.8653 | 0.8920 | 0.9250 | 0.9600 |
깊이를 5에서 30으로 늘리면 재순위가 손댈 수 있는 정답이 12.74%p 늘어납니다. 계획이 기대한 것이 바로 이것이었습니다 — 후보를 깊게 넘길수록 1차 검색이 놓친 정답을 재순위가 끌어올릴 기회가 생긴다는 것. 기회는 실제로 생겼습니다. 그런데 nDCG@10은 그동안 0.38%p밖에 안 움직였고 그것도 0과 구별되지 않습니다. 6~30위에 있던 정답을 이 cross-encoder가 상위 10칸 안으로 거의 끌어올리지 못했다는 뜻입니다.
왜 그런지는 이 실험이 가르지 못합니다. 후보로 꼽을 만한 원인이 둘 있습니다. 하나는 분야 차이로, 웹 검색 질의로 학습된 모델이 과학 주장과 논문 초록의 관련도를 깊은 순위까지 가려낼 만큼 정확하지 않을 수 있습니다. 다른 하나는 256토큰 자르기로, 문서 70%가 뒷부분을 잃은 채 채점됐습니다. 둘 중 어느 쪽인지, 둘 다인지는 길이나 모델을 바꿔 다시 재야 알 수 있습니다.
비용
후보 수와 시간
| 깊이 k | 질의당 ms | 쌍당 ms | 깊이 5 대비 시간 |
|---|---|---|---|
| 5 | 85.3 | 17.06 | 1.00배 |
| 10 | 169.7 | 16.97 | 1.99배 |
| 20 | 324.1 | 16.21 | 3.80배 |
| 30 | 478.7 | 15.96 | 5.61배 |
시간은 후보 수에 거의 정확히 정비례합니다. 후보가 여섯 배가 되는 동안 시간은 5.61배가 됐고, 쌍당 시간은 17.06ms에서 15.96ms로 6% 남짓 내려갔을 뿐입니다. 덩어리가 클수록 배치 하나에 더 많은 쌍이 들어가 호출 부담이 조금 나뉘는 몫입니다. 계획은 「선형이 아니면 그것이 결론」이라고 적어 두었는데, 선형이었습니다. cross-encoder는 쌍마다 같은 크기의 모델을 한 번씩 도는 계산이라 나눠 쓸 수 있는 몫이 거의 없습니다.
선형이라는 것은 예산을 미리 셀 수 있다는 뜻이기도 합니다. 이 환경에서는 질의당 시간을 「약 16ms × 후보 수」로 잡으면 되고, 다른 하드웨어에서는 쌍 하나의 채점 시간을 한 번 재서 곱하면 됩니다.
0.5%p당 시간
계획이 정한 꺾이는 지점의 자는 「nDCG 이득 0.5%p를 사는 데 드는 ms」입니다.
- 깊이 0 → 5: 85.3ms에 +3.58%p를 샀으니 0.5%p당 11.9ms입니다.
- 깊이 5 → 10: 84.4ms를 더 쓰고 점추정이 0.38%p 내려갔습니다. 이 구간에서는 0.5%p당 비용이 정의되지 않습니다 — 산 것이 없습니다.
- 깊이 5 → 30: 393.4ms를 더 쓰고 점추정으로 +0.38%p를 얻었으니 0.5%p당 518ms입니다. 깊이 5의 43배이고, 그나마 이 0.38%p도 단계마다 구간이 0을 품는 값을 이어 붙인 점추정입니다.
열 배로 뛰는 첫 깊이는 10입니다. 깊이 5를 넘기는 순간부터 쓰는 시간은 이 실험에서 품질로 돌아오지 않았습니다.
결정 규칙
세 규칙
- 이 조합(MiniLM 바이인코더 + ms-marco cross-encoder, scifact)에서는 후보 5
10개를 넘긴다. 이득 +3.58%p가 깊이 5에서 다 나고 깊이 10은 그것과 구별되지 않으며, 질의당 85170ms입니다. - 깊이를 늘리기 전에 「직전 깊이 대비」를 짝지은 부트스트랩으로 잰다. 후보 안의 정답 비율(천장)이 오르는 것만 보고 깊이를 늘리면 안 됩니다. 이 실험에서 천장은 깊이 5→30에 12.74%p 올랐지만 nDCG@10은 0과 구별되지 않았습니다.
- 시간 예산은 「쌍 하나의 채점 시간 × 후보 수」로 센다. 이 환경에서 쌍 하나는 16~17ms(256토큰, 4코어)였고 후보 수에 대해 선형이었으므로, 예산이 200ms면 깊이 12 남짓이 상한입니다.
꺾이는 지점
한 줄로 줄이면 이렇습니다 — 후보 다섯 개까지가 값을 하는 구간이고(85ms에 +3.58%p), 여섯 번째 후보부터는 한 개당 16ms씩 쓰면서 품질은 0과 구별되지 않습니다. 앞 글과 이어 읽으면 무게가 보입니다. 코퍼스를 647편에서 5,183편으로 키워 잃은 17.43%p 중에서 이 재순위가 되찾은 것은 3.58%p, 5분의 1 남짓이고, 깊이를 늘려도 그 몫은 더 커지지 않았습니다.
한계와 측정 환경
한계
- 재순위 모델 하나입니다. 이득이 깊이 5에서 멈춘 것이 이 모델의 성질인지 깊이라는 축의 성질인지는 모델을 바꿔 재야 가를 수 있습니다. 웹 검색 질의로 학습된 모델을 과학 논문 코퍼스에 썼다는 분야 차이가 결과에 섞여 있습니다.
- cross-encoder의 최대 길이를 512에서 256토큰으로 줄였고, 문서 70%가 잘린 채 채점됐습니다. 5분 상한 때문에 고른 값이며, 512토큰이었다면 깊은 후보의 정답을 더 잘 끌어올렸을 가능성을 이 실험은 배제하지 못합니다.
- 깊이는 30까지(한 번은 50까지) 쟀고 계획의 100·200은 재지 못했습니다. 후보 안의 정답 비율은 200까지 셌지만, 그 천장을 재순위가 실제로 쓰는지는 50 너머에서 확인하지 않았습니다.
- 코퍼스 하나(scifact), 질의 300개입니다. 재순위 전 대비 구간 폭이 4~6%p라, 깊이 사이의 1%p 미만 차이는 이 표본으로 가를 수 없습니다.
- 1차 검색을 MiniLM 바이인코더 하나로 고정했습니다. 다른 1차 검색(더 큰 임베딩 모델이나 BM25)이라면 후보 안의 정답 비율 곡선도, 재순위가 그 위에 얹는 이득도 달라질 수 있고, 이 실험은 그 조합을 재지 않았습니다.
- 시간은 4코어 CPU에서 잰 것입니다. GPU에서는 배치를 크게 묶을수록 쌍당 시간이 크게 줄어 선형 관계가 달라질 수 있습니다. 결론으로 쓴 것은 깊이 사이의 배수뿐입니다.
- 압축 인덱스로 후보를 넓게 뽑고 원본 벡터로 다시 채점하는 다른 종류의 2단 검색은 1비트 임베딩 + 재채점이 맡습니다. 거기서는 후보 수가 늘수록 품질이 꾸준히 회복됐는데, 재채점하는 쪽이 원본 임베딩이라 1차 검색과 같은 기준으로 다시 재는 것이기 때문입니다. 이 글의 cross-encoder는 기준 자체가 다른 모델입니다.
측정 환경
| 항목 | 값 |
|---|---|
| OS | Linux 6.18.44 x86_64 (glibc 2.39) |
| CPU | Intel Xeon @ 2.10GHz, 4코어 (torch.set_num_threads(4)) |
| Python | 3.11.17 |
| 패키지 | torch 2.14.1, sentence-transformers 6.1.0, transformers 5.19.0, datasets 5.1.0, numpy 2.4.6 |
| 모델 | sentence-transformers/all-MiniLM-L6-v2 (리비전 1110a243), cross-encoder/ms-marco-MiniLM-L-6-v2 (리비전 233902d2), 최대 길이 256토큰 |
| 데이터 | BEIR scifact 5,183편 (b3b53356) / qrels test 339행 (2938d17d) |
| 측정일 | 2026-10-07 |
| 실행 시간 | 4분 7초 (깊이 50까지 넣은 첫 실행은 5분 38초) |
위 재현 블록을 패키지를 새로 깐 빈 가상환경에서 한 번 더 돌려 대조했습니다. nDCG@10과 두 신뢰구간, 후보 안의 정답 비율은 소수점 넷째 자리까지 전부 같았습니다. 시간은 달랐습니다 — 그 실행은 같은 기계에서 사이트 빌드·테스트와 CPU를 나눠 쓰는 바람에 질의당 ms가 184.6·370.4·650.6·935.4로 두 배 남짓 늘었고 전체가 6분 29초 걸렸습니다. 깊이 5 대비 깊이 30의 배수는 그 실행에서 5.07배로, 조용한 실행의 5.61배와 함께 「후보 수에 대략 정비례」라는 결론 안에 들지만, 절대 ms는 옆에서 무엇이 도는지에 따라 두 배까지 흔들린다는 것을 그대로 보여 줍니다. 이 글이 결론을 배수로만 쓰는 이유입니다.
읽어주셔서 감사합니다. 😊

