3절의 TransferBatch 는 청크 커밋·롤백·체크포인트·멱등성을 한 루프에 담았습니다. 여기서는 같은 원리를 다른 각도에서 다시 씁니다. 골격을 재사용 가능한 클래스로 뽑고, 커밋 비용을 숫자로 재고, "불일치 창"을 실제로 만들어 보고, 정산 테이블의 멱등성 전략을 비교하고, 마지막 청크 커밋에서 죽는 엣지 케이스까지 확인합니다. 모든 코드는 순수 JDK 21 이며 각 파일 하나로 실행됩니다.
ChunkTxTemplate<T> 로 추출예제 3 의 begin → 반복 → commit / rollback 은 이체뿐 아니라 모든 청크 배치에서 똑같이 반복됩니다. 이를 제네릭 클래스로 뽑아 두면 "무엇을 하는가"(Consumer<T>)와 "어떻게 커밋하는가"(정책)가 분리됩니다. 두 정책 — 전부 아니면 전무(allOrNothing)와 건별 세이브포인트(perItemSavepoint, 연습 문제 1) — 를 같은 청크에 적용해 결과 차이를 나란히 봅니다.
import java.util.*;
import java.util.function.Consumer;
public class ChunkTxTemplateDemo {
static class InMemoryDatabase {
private final Map<String, Long> committed = new HashMap<>();
private Map<String, Long> pending;
private final Deque<Map<String, Long>> savepoints = new ArrayDeque<>();
int commitCount, rollbackCount;
void begin() { if (pending != null) throw new IllegalStateException("이미 트랜잭션 진행 중"); pending = new HashMap<>(); }
void commit() { requireTx(); committed.putAll(pending); pending = null; savepoints.clear(); commitCount++; }
void rollback() { requireTx(); pending = null; savepoints.clear(); rollbackCount++; }
void savepoint() { requireTx(); savepoints.push(new HashMap<>(pending)); }
void rollbackToSavepoint() { requireTx(); pending = savepoints.pop(); }
Long get(String k) { if (pending != null && pending.containsKey(k)) return pending.get(k); return committed.get(k); }
long getOrDefault(String k, long d) { Long v = get(k); return v == null ? d : v; }
void put(String k, long v) { requireTx(); pending.put(k, v); }
private void requireTx() { if (pending == null) throw new IllegalStateException("begin() 없이 쓰기/커밋 시도"); }
}
/** 청크 트랜잭션 골격을 재사용 가능한 클래스로 추출. 정책 두 가지를 메서드로 제공 */
static class ChunkTxTemplate<T> {
record Outcome(int ok, int failed, boolean committed) {}
private final InMemoryDatabase db;
ChunkTxTemplate(InMemoryDatabase db) { this.db = db; }
// 전부 아니면 전무: 한 건이라도 실패하면 청크 전체 롤백 (04 레슨 예제 3 의 정책)
Outcome allOrNothing(List<T> chunk, Consumer<T> work) {
db.begin();
try {
for (T t : chunk) work.accept(t);
db.commit();
return new Outcome(chunk.size(), 0, true);
} catch (RuntimeException e) {
db.rollback();
return new Outcome(0, chunk.size(), false);
}
}
// 건별 세이브포인트: 실패 건만 취소하고 나머지를 커밋 (연습 문제 1 의 정책)
Outcome perItemSavepoint(List<T> chunk, Consumer<T> work, Consumer<T> onFail) {
db.begin();
int ok = 0, failed = 0;
try {
for (T t : chunk) {
db.savepoint();
try { work.accept(t); ok++; }
catch (RuntimeException e) { db.rollbackToSavepoint(); failed++; onFail.accept(t); }
}
db.commit();
return new Outcome(ok, failed, true);
} catch (RuntimeException e) { db.rollback(); throw e; }
}
}
record Transfer(String txId, String from, String to, long amount) {}
static void apply(InMemoryDatabase db, Transfer t) {
long fromBal = db.getOrDefault("balance:" + t.from(), 0);
if (fromBal < t.amount()) throw new IllegalStateException(t.txId() + " 잔액 부족");
db.put("balance:" + t.from(), fromBal - t.amount());
db.put("balance:" + t.to(), db.getOrDefault("balance:" + t.to(), 0) + t.amount());
db.put("done:" + t.txId(), 1L);
}
static InMemoryDatabase newBank() {
InMemoryDatabase db = new InMemoryDatabase();
db.begin();
db.put("balance:A", 1000); db.put("balance:B", 1000); db.put("balance:C", 1000);
db.commit();
return db;
}
static String balances(InMemoryDatabase db) {
return "A=" + db.get("balance:A") + " B=" + db.get("balance:B") + " C=" + db.get("balance:C");
}
public static void main(String[] args) {
List<Transfer> chunk = List.of(
new Transfer("T1", "A", "B", 100),
new Transfer("T2", "B", "C", 200),
new Transfer("T3", "C", "A", 99_999), // 잔액 부족
new Transfer("T4", "A", "C", 50));
InMemoryDatabase db1 = newBank();
var r1 = new ChunkTxTemplate<Transfer>(db1).allOrNothing(chunk, t -> apply(db1, t));
System.out.println("allOrNothing : " + r1 + " " + balances(db1));
InMemoryDatabase db2 = newBank();
var r2 = new ChunkTxTemplate<Transfer>(db2).perItemSavepoint(chunk, t -> apply(db2, t),
t -> System.out.println(" skip " + t.txId()));
System.out.println("perItemSavepoint : " + r2 + " " + balances(db2));
System.out.println("commit/rollback : db1=" + db1.commitCount + "/" + db1.rollbackCount
+ ", db2=" + db2.commitCount + "/" + db2.rollbackCount);
}
}
// 출력:
// allOrNothing : Outcome[ok=0, failed=4, committed=false] A=1000 B=1000 C=1000
// skip T3
// perItemSavepoint : Outcome[ok=3, failed=1, committed=true] A=850 B=900 C=1250
// commit/rollback : db1=1/1, db2=2/0allOrNothing 은 T3 하나 때문에 T1·T2·T4 까지 취소되어 잔액이 초기값 그대로입니다. perItemSavepoint 는 T3 만 빼고 세 건을 커밋했고 잔액 총합(3,000)은 두 방식 모두 보존됩니다. 어느 정책이 맞는지는 업무가 정합니다 — "한 건이라도 틀리면 전체 무효"(급여 이체)면 전자, "가능한 건은 처리하고 나머지는 보고"(알림 발송)면 후자입니다.
2.2 절의 "청크 커밋이 균형점"이라는 주장을 숫자로 확인합니다. 커밋마다 fsync 0.5ms 를 스핀 대기로 흉내 내고, 같은 4,000건을 청크 크기별로 처리해 시간과 미커밋 변경 최대 개수(maxPending, 실제 DB 의 undo 로그·락 보유량에 대응)를 함께 잽니다. 시간만 보면 전체 1 트랜잭션이 가장 빠르지만 maxPending 이 입력 크기와 같아지는 것이 함정입니다.
import java.util.*;
public class CommitCostBench {
static final long FSYNC_NANOS = 500_000; // 커밋마다 0.5ms 의 fsync 비용을 흉내
static class Db {
final Map<String, Long> committed = new HashMap<>();
Map<String, Long> pending;
int commits, maxPending; // maxPending = 미커밋 변경 최대 개수 (undo 로그 크기에 대응)
void begin() { pending = new HashMap<>(); }
void put(String k, long v) { pending.put(k, v); }
long get(String k) { Long v = pending != null ? pending.get(k) : null; return v != null ? v : committed.getOrDefault(k, 0L); }
void commit() {
long end = System.nanoTime() + FSYNC_NANOS;
while (System.nanoTime() < end) Thread.onSpinWait(); // 디스크 동기화 대기
maxPending = Math.max(maxPending, pending.size());
committed.putAll(pending); pending = null; commits++;
}
}
static Db run(int items, int chunkSize) {
Db db = new Db();
for (int i = 0; i < items; i += chunkSize) {
db.begin();
for (int j = i; j < Math.min(items, i + chunkSize); j++)
db.put("balance:ACC-" + j, db.get("balance:ACC-" + j) + 100); // 건별로 다른 계좌
db.commit();
}
return db;
}
public static void main(String[] args) {
int items = 4_000;
System.out.printf("%-12s %8s %12s %10s %12s%n", "chunk", "commits", "fsync(계산)", "측정(min)", "maxPending");
for (int chunk : new int[]{1, 10, 100, 1_000, items}) {
long best = Long.MAX_VALUE; Db db = null;
for (int rep = 0; rep < 3; rep++) { // 3회 중 최소값 (노이즈 제거)
long t0 = System.nanoTime();
db = run(items, chunk);
best = Math.min(best, (System.nanoTime() - t0) / 1_000_000);
}
String label = chunk == items ? "전체(1 tx)" : String.valueOf(chunk);
System.out.printf("%-12s %8d %9d ms %7d ms %12d%n", label, db.commits,
db.commits * FSYNC_NANOS / 1_000_000, best, db.maxPending);
}
}
}
// 출력 (측정값은 환경·부하에 따라 다름. fsync(계산) = commits × 0.5ms):
// chunk commits fsync(계산) 측정(min) maxPending
// 1 4000 2000 ms 3960 ms 1
// 10 400 200 ms 249 ms 10
// 100 40 20 ms 32 ms 100
// 1000 4 2 ms 15 ms 1000
// 전체(1 tx) 1 0 ms 7 ms 4000건별 커밋은 fsync 비용이 전체 시간을 지배합니다(4,000번 × 0.5ms). 청크 100 부터는 커밋 비용이 무시할 수준이 되어 더 키워도 이득이 거의 없고, 대신 maxPending(롤백 범위, 락 보유, undo 로그)만 선형으로 커집니다. 03 레슨의 "100~1,000 에서 시작해 측정하라"는 경험칙이 이 표에서 나옵니다.
2.4 절에서 "커밋 직후, 체크포인트 저장 직전에 죽으면 이중 처리"라고 설명했습니다. 그 순간에 정확히 죽는 장애를 세 가지 구성에서 재현해 중복 건수를 셉니다. 파일 체크포인트만 있으면 청크 하나(100건)가 두 번 반영되고, done: 마킹을 더하면 재처리는 하되 반영은 안 되며, 체크포인트를 데이터와 같은 트랜잭션에 넣으면 재처리 자체가 없습니다.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class CheckpointWindowDemo {
static class InMemoryDatabase {
private final Map<String, Long> committed = new HashMap<>();
private Map<String, Long> pending;
void begin() { pending = new HashMap<>(); }
void commit() { committed.putAll(pending); pending = null; }
void rollback() { pending = null; }
boolean inTransaction() { return pending != null; }
Long get(String k) { if (pending != null && pending.containsKey(k)) return pending.get(k); return committed.get(k); }
long getOrDefault(String k, long d) { Long v = get(k); return v == null ? d : v; }
boolean contains(String k) { return get(k) != null; }
void put(String k, long v) { pending.put(k, v); }
}
enum Mode { FILE_CP, FILE_CP_IDEMPOTENT, SAME_TX_CP }
static class CrashAfterCommit extends RuntimeException { CrashAfterCommit(String m) { super(m); } }
/** 300건 입금(각 100원), 청크 100. crashAfterCommitOfChunk 번째 청크의 commit 직후·체크포인트 저장 직전에 죽는다 */
static void run(Mode mode, InMemoryDatabase db, Path cpFile, int crashAfterCommitOfChunk) throws IOException {
int startChunk = switch (mode) {
case SAME_TX_CP -> (int) db.getOrDefault("checkpoint:chunk", 0);
default -> Files.exists(cpFile) ? Integer.parseInt(Files.readString(cpFile).trim()) : 0;
};
for (int chunkNo = startChunk + 1; chunkNo <= 3; chunkNo++) {
db.begin();
for (int i = (chunkNo - 1) * 100 + 1; i <= chunkNo * 100; i++) {
String txId = "TX-" + i;
if (mode == Mode.FILE_CP_IDEMPOTENT && db.contains("done:" + txId)) continue; // 멱등 마킹
db.put("balance:ACC", db.getOrDefault("balance:ACC", 0) + 100);
db.put("done:" + txId, 1L);
}
if (mode == Mode.SAME_TX_CP) db.put("checkpoint:chunk", chunkNo); // 데이터와 같은 tx
db.commit();
if (chunkNo == crashAfterCommitOfChunk) throw new CrashAfterCommit("청크 " + chunkNo + " 커밋 직후, 체크포인트 저장 전 장애");
if (mode != Mode.SAME_TX_CP) Files.writeString(cpFile, String.valueOf(chunkNo), StandardCharsets.UTF_8);
}
}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("cpwindow");
long expected = 300 * 100L;
for (Mode mode : Mode.values()) {
InMemoryDatabase db = new InMemoryDatabase();
Path cp = dir.resolve(mode + ".checkpoint");
try { run(mode, db, cp, 2); }
catch (CrashAfterCommit e) { System.out.println(mode + ": " + e.getMessage()); }
run(mode, db, cp, 0); // 재시작
long actual = db.getOrDefault("balance:ACC", 0);
System.out.printf(" 재시작 후 잔액 %,d (기대 %,d) -> 중복 %d건%n", actual, expected, (actual - expected) / 100);
}
}
}
// 출력:
// FILE_CP: 청크 2 커밋 직후, 체크포인트 저장 전 장애
// 재시작 후 잔액 40,000 (기대 30,000) -> 중복 100건
// FILE_CP_IDEMPOTENT: 청크 2 커밋 직후, 체크포인트 저장 전 장애
// 재시작 후 잔액 30,000 (기대 30,000) -> 중복 0건
// SAME_TX_CP: 청크 2 커밋 직후, 체크포인트 저장 전 장애
// 재시작 후 잔액 30,000 (기대 30,000) -> 중복 0건FILE_CP 는 체크포인트가 1 에 머물러 청크 2 를 다시 반영했습니다. FILE_CP_IDEMPOTENT 는 청크 2 를 다시 읽지만 done: 마킹 때문에 반영하지 않습니다(정확성은 멱등성이 담당).
SAME_TX_CP 는 체크포인트가 데이터와 함께 커밋되어 "커밋 직후·저장 전"이라는 순간 자체가 존재하지 않습니다(연습 문제 2). 같은 DB 를 쓸 수 있다면 세 번째, 아니면 첫 번째 + 두 번째 조합이 정답입니다.
2.5 절의 멱등성 기법을 가맹점 일별 정산에 적용합니다. 같은 정산 배치를 두 번 실행(재시작 시나리오)해 결과가 같은지 비교합니다. settle += amount 는 재실행마다 누적되어 이중 정산이 나고, 입력에서 합계를 계산해 덮어쓰는 upsert 나 "해당 기간을 지우고 다시 넣기"는 몇 번을 돌려도 같습니다.
import java.util.*;
public class SettlementIdempotency {
record Sale(String merchant, long amount) {}
static final List<Sale> SALES = List.of(
new Sale("M-1", 10_000), new Sale("M-2", 5_000), new Sale("M-1", 3_000),
new Sale("M-3", 7_000), new Sale("M-2", 1_000));
// 방식 A: 상대값 갱신 (settle += amount). 재실행하면 누적된다
static void settleRelative(Map<String, Long> table) {
for (Sale s : SALES) table.merge(s.merchant(), s.amount(), Long::sum);
}
// 방식 B: 입력에서 절대값을 계산해 upsert (settle = sum). 몇 번 실행해도 같다
static void settleUpsert(Map<String, Long> table) {
Map<String, Long> sums = new TreeMap<>();
for (Sale s : SALES) sums.merge(s.merchant(), s.amount(), Long::sum);
table.putAll(sums); // MERGE / INSERT ... ON CONFLICT UPDATE 에 해당
}
// 방식 C: 정산 대상 기간을 지우고 다시 넣기 (DELETE WHERE date=? + INSERT). 부분 실패에 대비해 같은 tx 안에서
static void settleDeleteInsert(Map<String, Long> table) {
table.keySet().removeIf(k -> k.startsWith("M-")); // 오늘 정산분 삭제
settleRelative(table); // 빈 상태에서 누적 = 절대값
}
public static void main(String[] args) {
record Strategy(String name, java.util.function.Consumer<Map<String, Long>> run) {}
List<Strategy> strategies = List.of(
new Strategy("상대값 갱신", SettlementIdempotency::settleRelative),
new Strategy("upsert 절대값", SettlementIdempotency::settleUpsert),
new Strategy("삭제 후 재삽입", SettlementIdempotency::settleDeleteInsert));
for (Strategy s : strategies) {
Map<String, Long> table = new TreeMap<>();
s.run().accept(table);
String once = table.toString();
s.run().accept(table); // 재실행 (재시작 시나리오)
String twice = table.toString();
System.out.printf("%-10s 1회: %s%n%-10s 2회: %s -> %s%n", s.name(), once, "", twice,
once.equals(twice) ? "멱등 OK" : "이중 정산!");
}
}
}
// 출력:
// 상대값 갱신 1회: {M-1=13000, M-2=6000, M-3=7000}
// 2회: {M-1=26000, M-2=12000, M-3=14000} -> 이중 정산!
// upsert 절대값 1회: {M-1=13000, M-2=6000, M-3=7000}
// 2회: {M-1=13000, M-2=6000, M-3=7000} -> 멱등 OK
// 삭제 후 재삽입 1회: {M-1=13000, M-2=6000, M-3=7000}
// 2회: {M-1=13000, M-2=6000, M-3=7000} -> 멱등 OK상대값 갱신이 필요한 경우(잔액 이체처럼 "이전 상태 + 변화량"이 본질인 업무)에는 변형 3 의 done: 마킹을 붙여야 합니다. 정산·집계·등급 재산정처럼 입력만으로 결과를 다시 계산할 수 있는 업무는 upsert 나 삭제 후 재삽입이 훨씬 단순하고, 마킹 테이블도 필요 없습니다.
장애가 청크 처리 중이 아니라 commit() 호출 그 자체에서 나면 어떻게 될까요. 실제 DB 는 커밋 중 커넥션이 끊기면 그 트랜잭션을 버립니다. 이 변형은 failNextCommit 플래그로 그 상황을 마지막 청크(꼬리 50건)에 만들고, 체크포인트가 이전 청크에 머무는지, 재시작이 그 청크만 다시 처리해 총액이 맞는지 검증합니다. 덤으로 헤더만 있는 빈 입력도 오류 없이 끝나는지 확인합니다.
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class FinalChunkCrash {
static class ConnectionLost extends RuntimeException { ConnectionLost() { super("커밋 중 커넥션 끊김"); } }
static class InMemoryDatabase {
private final Map<String, Long> committed = new HashMap<>();
private Map<String, Long> pending;
boolean failNextCommit;
void begin() { pending = new HashMap<>(); }
void commit() {
if (failNextCommit) { failNextCommit = false; pending = null; throw new ConnectionLost(); } // 미커밋 변경 소실
committed.putAll(pending); pending = null;
}
void rollback() { pending = null; }
boolean inTransaction() { return pending != null; }
Long get(String k) { if (pending != null && pending.containsKey(k)) return pending.get(k); return committed.get(k); }
long getOrDefault(String k, long d) { Long v = get(k); return v == null ? d : v; }
boolean contains(String k) { return get(k) != null; }
void put(String k, long v) { pending.put(k, v); }
long doneCount() { return committed.keySet().stream().filter(k -> k.startsWith("done:")).count(); }
}
record Checkpoint(long lastLine, int lastChunk) {
static Checkpoint load(Path f) throws IOException {
if (!Files.exists(f)) return new Checkpoint(0, 0);
String[] p = Files.readString(f).trim().split(",");
return new Checkpoint(Long.parseLong(p[0]), Integer.parseInt(p[1]));
}
void save(Path f) throws IOException {
Path tmp = f.resolveSibling(f.getFileName() + ".tmp");
Files.writeString(tmp, lastLine + "," + lastChunk, StandardCharsets.UTF_8);
Files.move(tmp, f, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
}
}
/** 입력 CSV(txId,amount) 를 청크 단위로 입금. 반환: 처리 건수 */
static long run(InMemoryDatabase db, Path input, Path cpFile, int chunkSize, int crashAtCommitOfChunk) throws IOException {
Checkpoint cp = Checkpoint.load(cpFile);
long lineNo = 0, processed = 0;
int chunkNo = cp.lastChunk();
if (cp.lastChunk() > 0) System.out.println(" [restart] 청크 " + cp.lastChunk() + " / " + cp.lastLine() + "줄까지 완료");
try (BufferedReader r = Files.newBufferedReader(input, StandardCharsets.UTF_8)) {
r.readLine();
for (long i = 0; i < cp.lastLine(); i++) { r.readLine(); lineNo++; }
while (true) {
List<String[]> chunk = new ArrayList<>();
String line;
while (chunk.size() < chunkSize && (line = r.readLine()) != null) { lineNo++; chunk.add(line.split(",")); }
if (chunk.isEmpty()) break;
chunkNo++;
db.begin();
for (String[] f : chunk) {
if (db.contains("done:" + f[0])) continue;
db.put("balance:ACC", db.getOrDefault("balance:ACC", 0) + Long.parseLong(f[1]));
db.put("done:" + f[0], 1L);
processed++;
}
if (chunkNo == crashAtCommitOfChunk) db.failNextCommit = true;
db.commit(); // 여기서 끊기면 이 청크는 통째로 사라짐
new Checkpoint(lineNo, chunkNo).save(cpFile);
System.out.println(" 청크 " + chunkNo + " 커밋 (줄 " + lineNo + "까지)");
}
} catch (ConnectionLost e) {
System.out.println(" !!! 청크 " + chunkNo + " " + e.getMessage() + " -> 체크포인트는 청크 " + (chunkNo - 1) + " 에 머묾");
throw e;
}
Files.deleteIfExists(cpFile);
return processed;
}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("finalchunk");
Path input = dir.resolve("deposits.csv"), cp = dir.resolve("job.checkpoint");
List<String> lines = new ArrayList<>(List.of("txId,amount"));
for (int i = 1; i <= 250; i++) lines.add("TX-" + i + "," + 100); // 250건 = 청크 100 × 2 + 꼬리 50
Files.write(input, lines, StandardCharsets.UTF_8);
InMemoryDatabase db = new InMemoryDatabase();
System.out.println("1차 실행 (마지막 청크 커밋에서 장애):");
try { run(db, input, cp, 100, 3); } catch (ConnectionLost ignored) {}
System.out.println(" 잔액 " + db.getOrDefault("balance:ACC", 0) + ", done 마킹 " + db.doneCount() + ", 체크포인트 파일 존재? " + Files.exists(cp));
System.out.println("2차 실행 (재시작):");
long processed = run(db, input, cp, 100, 0);
System.out.println(" 이번 실행 처리 " + processed + "건, 잔액 " + db.getOrDefault("balance:ACC", 0)
+ " (기대 25000), done 마킹 " + db.doneCount() + ", 체크포인트 파일 존재? " + Files.exists(cp));
Path empty = dir.resolve("empty.csv"); // 엣지: 헤더만 있는 입력
Files.write(empty, List.of("txId,amount"), StandardCharsets.UTF_8);
System.out.println("빈 입력 실행: 처리 " + run(new InMemoryDatabase(), empty, cp, 100, 0) + "건, 체크포인트 파일 존재? " + Files.exists(cp));
}
}
// 출력:
// 1차 실행 (마지막 청크 커밋에서 장애):
// 청크 1 커밋 (줄 100까지)
// 청크 2 커밋 (줄 200까지)
// !!! 청크 3 커밋 중 커넥션 끊김 -> 체크포인트는 청크 2 에 머묾
// 잔액 20000, done 마킹 200, 체크포인트 파일 존재? true
// 2차 실행 (재시작):
// [restart] 청크 2 / 200줄까지 완료
// 청크 3 커밋 (줄 250까지)
// 이번 실행 처리 50건, 잔액 25000 (기대 25000), done 마킹 250, 체크포인트 파일 존재? false
// 빈 입력 실행: 처리 0건, 체크포인트 파일 존재? false커밋 중 실패는 "미커밋 = 없던 일"이라는 DB 의 계약 덕분에 청크 처리 중 실패와 똑같이 다뤄집니다. 재시작이 꼬리 50건만 다시 처리했고, 총액 25,000 과 done 마킹 250 이 맞아떨어집니다. 마지막 청크가 꽉 차지 않은 꼬리라는 점, 그리고 정상 종료 시 체크포인트를 지워 다음 날 실행이 처음부터 시작되는 점까지 함께 확인했습니다.