각 패턴의 코드는 java-src/batch/06_method_patterns/에서 실행해 볼 수 있습니다. Main은 임시 디렉터리를 만들어 입력 파일을 생성하고 처리한 뒤 정리합니다. BatchUtils가 공통 유틸, JobParams/BatchReport/DailyFileJob/JobRunner가 실무 예제입니다.
Path.resolvebase + "/" + "input" + "/" + name은 OS별 구분자와 중복 슬래시 문제가 있습니다. Path.resolve를 가변 인자로 감싸면 어떤 깊이든 한 줄로 조립되고 OS에 맞게 처리됩니다.
public static Path resolve(Path base, String... parts) {
Path p = base;
for (String part : parts) p = p.resolve(part);
return p;
}
Path in = BatchUtils.resolve(base, "input", "orders.csv");
System.out.println(in.getParent().getFileName() + "/" + in.getFileName()); // 출력: input/orders.csv
System.out.println(in.getFileName().toString().endsWith(".csv")); // 출력: true주의: resolve에 절대 경로를 넘기면 앞의 경로가 무시됩니다(base.resolve("/etc") → /etc). 외부 입력을 경로에 쓸 때는 normalize() 후 startsWith(base)로 탈출을 막습니다.
일자별 파일은 prefix_yyyyMMdd.ext 형식이 표준입니다. 포맷터를 상수로 두고 만들기·파싱하기를 한 쌍으로 제공하면 형식이 바뀌어도 한 곳만 고칩니다.
private static final DateTimeFormatter YMD = DateTimeFormatter.ofPattern("yyyyMMdd");
public static String dailyFileName(String prefix, LocalDate date, String ext) {
return prefix + "_" + date.format(YMD) + "." + ext;
}
public static LocalDate parseYmd(String ymd) { return LocalDate.parse(ymd, YMD); }
LocalDate day = LocalDate.of(2024, 3, 15);
System.out.println(BatchUtils.dailyFileName("orders", day, "csv")); // 출력: orders_20240315.csv
System.out.println(BatchUtils.dailyFileName("summary", day.plusDays(1), "txt")); // 출력: summary_20240316.txt
System.out.println(BatchUtils.parseYmd("20240301")); // 출력: 2024-03-01주의: LocalDate.now()를 유틸 안에서 부르지 마세요. 날짜는 파라미터로 받아야 재실행·테스트가 가능합니다.
Optional.empty입력 파일이 없는 것은 배치에서 흔한 정상 상황입니다(휴일에는 주문 파일이 없음). 예외 대신 Optional로 돌려주면 호출자가 "없으면 스킵"을 자연스럽게 씁니다.
public static Optional<BufferedReader> openIfExists(Path file) {
if (!Files.isRegularFile(file)) return Optional.empty();
try {
return Optional.of(Files.newBufferedReader(file, StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
System.out.println(BatchUtils.openIfExists(base.resolve("nope.csv")).isPresent()); // 출력: false
Path hello = base.resolve("hello.txt");
BatchUtils.withWriter(hello, w -> BatchUtils.io(() -> { w.write("hi"); return null; }));
System.out.println(BatchUtils.openIfExists(hello).isPresent()); // 출력: true주의: Optional 안의 BufferedReader는 호출자가 닫아야 합니다. 그래서 실무에서는 패턴 4의 withReader에 존재 검사를 합치는 편이 안전합니다.
withReader — 열고 닫기는 헬퍼, 본문은 람다파일을 열고, UTF-8을 지정하고, 다 쓰면 닫고, IOException을 언체크로 바꾸는 일을 한 곳에 모읍니다. 호출자는 BufferedReader로 무엇을 할지만 람다로 넘기고 결과를 돌려받습니다. 스트림을 세든 첫 줄을 읽든 같은 헬퍼입니다.
public static <T> T withReader(Path file, Function<BufferedReader, T> body) {
try (BufferedReader r = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
return body.apply(r);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
long count = BatchUtils.withReader(data, r -> r.lines().count());
String first = BatchUtils.withReader(data, r -> BatchUtils.io(r::readLine));
System.out.println(count + " " + first); // 출력: 5 line1주의: 람다 안에서 r.lines() 스트림을 밖으로 돌려주면 안 됩니다. 람다가 끝나는 순간 파일이 닫혀 스트림을 소비할 때 예외가 납니다. 결과(숫자, 리스트)로 바꿔서 돌려주세요.
withWriter읽기와 대칭입니다. Consumer<BufferedWriter>를 받아 쓰기가 끝나면 flush + close를 보장합니다. 쓰기 도중 예외가 나도 닫힙니다.
public static void withWriter(Path file, Consumer<BufferedWriter> body) {
try (BufferedWriter w = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
body.accept(w);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
Path out = base.resolve("out.txt");
BatchUtils.withWriter(out, w -> BatchUtils.io(() -> { w.write("a,b,c"); w.newLine(); return null; }));
String line = BatchUtils.withReader(out, r -> BatchUtils.io(r::readLine));
System.out.println(line); // 출력: a,b,c주의: System.out.println(withReader(...))처럼 제네릭 메서드 결과를 오버로딩된 메서드에 바로 넣으면 println(char[])와 println(String) 사이에서 추론이 모호해 컴파일 오류가 납니다. 변수에 먼저 받으세요.
w.write()는 IOException을 던지는데 Consumer는 체크 예외를 허용하지 않습니다. throws IOException이 있는 자체 함수형 인터페이스 IOAction과 그것을 실행하며 UncheckedIOException으로 바꾸는 io()를 두면 람다 안에서 io(() -> ...)로 감쌀 수 있습니다.
@FunctionalInterface
public interface IOAction<T> { T run() throws IOException; }
public static <T> T io(IOAction<T> action) {
try { return action.run(); }
catch (IOException e) { throw new UncheckedIOException(e); }
}
long size = BatchUtils.io(() -> Files.size(out));
System.out.println(size > 0); // 출력: true
try {
BatchUtils.io(() -> Files.size(base.resolve("missing")));
} catch (UncheckedIOException e) {
System.out.println("UncheckedIOException: " + e.getCause().getClass().getSimpleName());
// 출력: UncheckedIOException: NoSuchFileException
}주의: 반환값이 없는 w.write(...)를 감쌀 때는 { w.write(x); return null; }처럼 명시적으로 return null이 필요합니다. 귀찮다면 void용 IORunnable 오버로딩을 하나 더 둡니다.
forEachChunk — 리스트를 n개씩DB 배치 insert, API 일괄 호출처럼 "한 번에 n건"으로 보내야 할 때 subList로 잘라 핸들러를 부릅니다. 마지막 자투리도 빠짐없이 처리하고 청크 수를 돌려줍니다.
public static <T> int forEachChunk(List<T> items, int size, Consumer<List<T>> handler) {
int chunks = 0;
for (int i = 0; i < items.size(); i += size) {
handler.accept(items.subList(i, Math.min(i + size, items.size())));
chunks++;
}
return chunks;
}
int chunks = BatchUtils.forEachChunk(List.of(1, 2, 3, 4, 5, 6, 7), 3, c -> System.out.println(" chunk " + c));
// 출력:
// chunk [1, 2, 3]
// chunk [4, 5, 6]
// chunk [7]
System.out.println("chunks=" + chunks); // 출력: chunks=3주의: subList는 원본의 뷰입니다. 핸들러가 청크를 보관하거나 수정하면 원본에 영향을 줍니다. 보관하려면 new ArrayList<>(chunk).
파일이 수백만 줄이면 Files.readAllLines는 OOM입니다. Files.lines로 한 줄씩 받아 버퍼가 차면 핸들러를 부르고 새 버퍼를 만듭니다. 메모리는 항상 청크 하나 분량입니다.
public static int forEachLineChunk(Path file, int size, Consumer<List<String>> handler) {
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
List<String> buf = new ArrayList<>(size);
int chunks = 0;
for (String line : (Iterable<String>) lines::iterator) {
buf.add(line);
if (buf.size() == size) { handler.accept(buf); buf = new ArrayList<>(size); chunks++; }
}
if (!buf.isEmpty()) { handler.accept(buf); chunks++; } // 자투리
return chunks;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
int lc = BatchUtils.forEachLineChunk(data, 2, c -> System.out.println(" " + c));
// 출력:
// [line1, line2]
// [line3, line4]
// [line5]
System.out.println("chunks=" + lc); // 출력: chunks=3주의: buf.clear()로 재사용하면 핸들러가 청크를 비동기로 처리할 때 내용이 바뀝니다. 새 리스트를 만드는 것이 안전합니다.
timed — 실행 시간 측정 데코레이터Supplier<T>를 받아 실행하고 걸린 시간을 찍은 뒤 결과를 그대로 돌려줍니다. finally에 두어 예외가 나도 시간이 기록됩니다. Runnable 오버로딩을 추가하면 반환값 없는 작업에도 씁니다.
public static <T> T timed(String label, Supplier<T> action) {
long start = System.nanoTime();
try {
return action.get();
} finally {
System.out.printf(" [%s] %d ms%n", label, (System.nanoTime() - start) / 1_000_000);
}
}
int sum = BatchUtils.timed("sum", () -> Stream.iterate(1, i -> i + 1).limit(1000).mapToInt(i -> i).sum());
// 출력: [sum] 37 ms (숫자는 환경마다 다름)
System.out.println(sum); // 출력: 500500주의: System.currentTimeMillis()는 시스템 시각이 조정되면 음수가 될 수 있습니다. 경과 시간은 nanoTime으로 잽니다.
100만 건을 처리하면서 매 건 로그를 찍으면 로그가 처리보다 느립니다. "every건마다, 그리고 마지막에" 한 줄 찍는 Consumer<Integer>를 만들어 루프에서 accept(done)만 부릅니다. 총건수와 간격을 캡처한 함수 팩토리입니다.
public static Consumer<Integer> progress(int total, int every) {
return done -> {
if (done % every == 0 || done == total)
System.out.printf(" 진행 %d/%d (%d%%)%n", done, total, done * 100 / total);
};
}
Consumer<Integer> progress = BatchUtils.progress(10, 4);
for (int i = 1; i <= 10; i++) progress.accept(i);
// 출력:
// 진행 4/10 (40%)
// 진행 8/10 (80%)
// 진행 10/10 (100%)주의: 총건수를 모르는 스트리밍 입력이면 퍼센트 대신 "처리 N건 / 경과 시간"으로 바꿉니다.
retry — 일시적 실패 재시도네트워크·DB 연결처럼 잠깐 실패했다 회복되는 작업을 감쌉니다. 최대 횟수까지 시도하고 모두 실패하면 마지막 예외를 원인으로 담아 던집니다. 05 레슨의 RetryTemplate을 메서드 하나로 줄인 형태입니다.
public static <T> T retry(int maxAttempts, Supplier<T> action) {
RuntimeException last = null;
for (int i = 1; i <= maxAttempts; i++) {
try { return action.get(); }
catch (RuntimeException e) {
last = e;
System.out.println(" 재시도 " + i + "/" + maxAttempts + ": " + e.getMessage());
}
}
throw new IllegalStateException("재시도 소진", last);
}
AtomicInteger n = new AtomicInteger();
String r = BatchUtils.retry(3, () -> {
if (n.incrementAndGet() < 2) throw new RuntimeException("connection reset");
return "connected";
});
// 출력: 재시도 1/3: connection reset
System.out.println(r); // 출력: connected주의: 파싱 오류처럼 다시 해도 같은 결과인 실패를 재시도하면 시간만 낭비합니다. 재시도할 예외 종류를 인자로 받는 것이 실무 버전입니다. 대기(백오프)도 필요합니다.
한 건이 잘못됐다고 전체 잡을 죽이면 안 되는 경우, 실패 건을 실패 목록에 적고 계속 갑니다. 단, 실패가 한도를 넘으면 입력 자체가 잘못된 것이므로 중단합니다. 성공한 건만 돌려줍니다.
public static <T> List<T> processWithSkip(List<T> items, Consumer<T> handler, List<String> failures, int maxSkip) {
List<T> ok = new ArrayList<>();
for (T item : items) {
try { handler.accept(item); ok.add(item); }
catch (RuntimeException e) {
failures.add(item + " → " + e.getMessage());
if (failures.size() > maxSkip) throw new IllegalStateException("스킵 한도 초과: " + failures.size());
}
}
return ok;
}
List<String> failures = new ArrayList<>();
List<String> ok = BatchUtils.processWithSkip(List.of("10", "x", "30", "y", "50"), s -> Integer.parseInt(s), failures, 5);
System.out.println(ok); // 출력: [10, 30, 50]
System.out.println(failures.size() + " " + failures.get(0)); // 출력: 2 x → For input string: "x"
try {
BatchUtils.processWithSkip(List.of("a", "b"), s -> Integer.parseInt(s), new ArrayList<>(), 1);
} catch (IllegalStateException e) {
System.out.println(e.getMessage()); // 출력: 스킵 한도 초과: 2
}주의: 실패 목록을 인자로 받아 채우는 방식은 호출자가 목록을 소유하므로 여러 단계의 실패를 한 목록에 모을 수 있습니다. 대신 메서드가 인자를 변경한다는 점을 이름이나 문서에 드러내야 합니다.
args → record--key=value 형식의 args를 Map으로 모은 뒤 record로 변환합니다. 이후 코드는 params.date()처럼 타입 있는 값을 쓰고, toString이 실행 로그에 그대로 남습니다.
public record JobParams(String job, LocalDate date, int chunkSize, boolean dryRun) {
public static JobParams parse(String[] args) {
Map<String, String> kv = new HashMap<>();
for (String a : args) {
if (!a.startsWith("--")) throw new IllegalArgumentException("형식 오류: " + a);
int eq = a.indexOf('=');
if (eq < 0) kv.put(a.substring(2), "true");
else kv.put(a.substring(2, eq), a.substring(eq + 1));
}
return new JobParams(
kv.get("job"),
kv.containsKey("date") ? BatchUtils.parseYmd(kv.get("date")) : LocalDate.of(2024, 3, 15),
Integer.parseInt(kv.getOrDefault("chunk", "100")),
Boolean.parseBoolean(kv.getOrDefault("dryRun", "false")));
}
}
JobParams p = JobParams.parse(new String[]{"--job=daily", "--date=20240316", "--chunk=50"});
System.out.println(p); // 출력: JobParams[job=daily, date=2024-03-16, chunkSize=50, dryRun=false]
System.out.println(p.date().getDayOfWeek()); // 출력: SATURDAY주의: 예제는 기본 날짜를 고정값으로 두었지만 실무에서는 "어제"(LocalDate.now().minusDays(1))가 흔합니다. 그래도 파싱 메서드 안이 아니라 호출자가 넘기게 하여 테스트 가능하게 유지합니다.
없는 값의 기본값은 parse에서, 값의 유효성은 record의 compact 생성자에서 검사합니다. --dryRun처럼 값 없는 플래그는 true로 해석합니다. 잘못된 파라미터는 잡을 시작하기 전에 명확한 메시지로 실패시킵니다.
public record JobParams(String job, LocalDate date, int chunkSize, boolean dryRun) {
public JobParams { // compact 생성자: 검증
if (job == null || job.isBlank()) throw new IllegalArgumentException("--job 필수");
if (chunkSize <= 0) throw new IllegalArgumentException("--chunk는 양수: " + chunkSize);
}
}
System.out.println(JobParams.parse(new String[]{"--job=daily", "--dryRun"}));
// 출력: JobParams[job=daily, date=2024-03-15, chunkSize=100, dryRun=true]
try {
JobParams.parse(new String[]{"--chunk=10"});
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // 출력: --job 필수
}
try {
JobParams.parse(new String[]{"--job=x", "--chunk=0"});
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // 출력: --chunk는 양수: 0
}주의: 검증을 parse가 아니라 생성자에 두는 이유는 new JobParams(...)로 직접 만드는 경로(테스트 등)에서도 검증되게 하기 위해서입니다.
처리 중 읽은 건수·쓴 건수·스킵·오류를 누적하고, 마지막에 한 줄 요약을 만듭니다. return this 체이닝으로 누적하고, 오류 목록은 List.copyOf로 불변 반환합니다. 잡의 반환 타입을 BatchReport로 통일하면 실행기가 결과를 일관되게 다룹니다.
public class BatchReport {
private final String jobName;
private int read, written, skipped;
private final List<String> errors = new ArrayList<>();
public static BatchReport of(String jobName) { return new BatchReport(jobName); }
public BatchReport read(int n) { read += n; return this; }
public BatchReport written(int n) { written += n; return this; }
public BatchReport skipped(int n) { skipped += n; return this; }
public BatchReport error(String msg) { errors.add(msg); return this; }
public boolean hasErrors() { return !errors.isEmpty(); }
public List<String> errors() { return List.copyOf(errors); }
public String summary() {
String status = errors.isEmpty() ? "SUCCESS" : "COMPLETED_WITH_ERRORS";
return String.format("[%s] %s read=%d written=%d skipped=%d errors=%d", jobName, status, read, written, skipped, errors.size());
}
}
BatchReport rep = BatchReport.of("demo").read(100).written(95).skipped(5).error("row 7 bad").error("row 9 bad");
System.out.println(rep.summary()); // 출력: [demo] COMPLETED_WITH_ERRORS read=100 written=95 skipped=5 errors=2
System.out.println(rep.hasErrors() + " " + rep.errors()); // 출력: true [row 7 bad, row 9 bad]주의: 오류 메시지가 수만 건이면 메모리를 먹습니다. 상위 N건만 보관하고 나머지는 패턴 25의 에러 파일로 보냅니다.
finally — 정상/예외 모두 마지막 정리잡의 최상위에서 try { runAll } finally { 정리 }를 두면 어떤 예외로 끝나든 정리 코드가 실행됩니다. 락 해제, 진행 상태 기록, 종료 로그가 여기 들어갑니다.
public static void main(String[] args) throws IOException {
Path base = Files.createTempDirectory("batch06");
try {
runAll(base);
} finally {
System.out.println("\n[finally] 잡 종료"); // 출력: [finally] 잡 종료 (프로그램 마지막)
}
}주의: finally 안에서 예외가 나면 원래 예외를 덮어씁니다. 정리 코드는 자체적으로 try-catch로 감싸 조용히 실패하게 합니다.
스케줄러가 SIGTERM으로 잡을 죽이거나 System.exit가 호출되면 finally까지 도달하지 못할 수 있습니다. Runtime.addShutdownHook에 등록한 스레드는 JVM이 종료될 때 실행되므로 임시 디렉터리 삭제, 커넥션 반납 같은 최후 정리를 여기 둡니다.
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
deleteRecursively(base);
System.out.println("\n[shutdown hook] 임시 디렉터리 정리 완료"); // 출력: [shutdown hook] 임시 디렉터리 정리 완료
}));주의: 훅은 짧고 확실하게 끝나야 합니다. 훅 안에서 오래 걸리는 I/O나 다른 스레드를 기다리면 종료가 멈춥니다. kill -9에는 실행되지 않습니다.
move출력 파일을 직접 쓰다가 중간에 죽으면 반쪽짜리 파일이 남고, 다음 실행은 "이미 처리됨"으로 오판할 수 있습니다. .tmp에 다 쓴 뒤 ATOMIC_MOVE로 이름을 바꾸면 파일은 "없음" 아니면 "완전함" 둘 중 하나입니다.
public static void writeAtomically(Path target, Consumer<BufferedWriter> body) {
Path tmp = target.resolveSibling(target.getFileName() + ".tmp");
withWriter(tmp, body);
io(() -> Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE));
}
Path atomic = base.resolve("atomic.txt");
BatchUtils.writeAtomically(atomic, w -> BatchUtils.io(() -> { w.write("done"); return null; }));
System.out.println(Files.exists(atomic) + " " + Files.exists(base.resolve("atomic.txt.tmp"))); // 출력: true false주의: ATOMIC_MOVE는 같은 파일 시스템 안에서만 보장됩니다. 임시 파일은 반드시 대상과 같은 디렉터리(resolveSibling)에 만듭니다.
같은 날짜로 잡이 두 번 돌아도 결과가 두 번 쌓이면 안 됩니다. 출력 파일이 있으면 처리하지 않고 빈 리포트를 돌려줍니다. 패턴 18과 합치면 "출력 파일이 있다 = 완전히 처리됐다"가 보장됩니다.
Path out = outputDir.resolve(BatchUtils.dailyFileName("summary", date, "txt"));
if (Files.exists(out)) {
System.out.println(" 이미 처리됨: " + out.getFileName());
return report;
}
for (LocalDate d : days) {
Path o = base.resolve(BatchUtils.dailyFileName("summary", d, "txt"));
System.out.println(" " + o.getFileName() + " 처리됨=" + Files.exists(o));
}
// 출력:
// summary_20240315.txt 처리됨=false
// summary_20240316.txt 처리됨=false
// summary_20240317.txt 처리됨=false주의: 재처리가 필요한 경우(입력이 정정됨)를 위해 --force 파라미터로 스킵을 끄는 옵션을 둡니다.
"3월 1일부터 15일까지 밀린 잡을 돌려라"는 요청은 날짜 목록을 만들어 하루씩 잡을 부르면 됩니다. LocalDate.datesUntil이 스트림을 만들어 주므로 끝 날짜 포함 여부만 조정합니다.
public static List<LocalDate> dateRange(LocalDate from, LocalDate toInclusive) {
return from.datesUntil(toInclusive.plusDays(1)).toList();
}
List<LocalDate> days = BatchUtils.dateRange(day, day.plusDays(2));
System.out.println(days); // 출력: [2024-03-15, 2024-03-16, 2024-03-17]주의: from > to면 빈 목록이 아니라 IllegalArgumentException입니다. 파라미터 검증에서 순서를 확인하세요.
BatchUtils 클래스패턴 1~12, 18, 20을 private 생성자 + static 메서드로 한 클래스에 모은 것이 BatchUtils입니다. 상태가 없고 입력만으로 결과가 정해지므로 static이 맞습니다. 잡은 이 클래스만 import하면 리소스 관리·청크·재시도를 신경 쓰지 않습니다.
public final class BatchUtils {
private BatchUtils() {}
public static Path resolve(Path base, String... parts) { ... }
public static String dailyFileName(String prefix, LocalDate date, String ext) { ... }
public static <T> T withReader(Path file, Function<BufferedReader, T> body) { ... }
public static void withWriter(Path file, Consumer<BufferedWriter> body) { ... }
public static <T> T io(IOAction<T> action) { ... }
public static <T> int forEachChunk(List<T> items, int size, Consumer<List<T>> handler) { ... }
public static int forEachLineChunk(Path file, int size, Consumer<List<String>> handler) { ... }
public static <T> T timed(String label, Supplier<T> action) { ... }
public static <T> T retry(int maxAttempts, Supplier<T> action) { ... }
public static <T> List<T> processWithSkip(...) { ... }
public static void writeAtomically(Path target, Consumer<BufferedWriter> body) { ... }
public static List<LocalDate> dateRange(LocalDate from, LocalDate toInclusive) { ... }
}주의: 유틸이 커지면 PathUtils, IoUtils, RetryUtils로 나눕니다. 한 클래스에 30개가 넘으면 찾기 어렵습니다.
input/orders_YYYYMMDD.csv를 청크로 읽어 파싱하고, 실패 줄은 스킵·기록하며, 합계를 output/summary_YYYYMMDD.txt에 원자적으로 씁니다. 헬퍼 덕분에 잡 본문은 "무엇을 하는가"만 남습니다.
public BatchReport run(LocalDate date, int chunkSize) {
BatchReport report = BatchReport.of("daily-" + date);
Path in = inputDir.resolve(BatchUtils.dailyFileName("orders", date, "csv"));
Path out = outputDir.resolve(BatchUtils.dailyFileName("summary", date, "txt"));
if (Files.exists(out)) { System.out.println(" 이미 처리됨: " + out.getFileName()); return report; }
if (!Files.exists(in)) return report.error("입력 없음: " + in.getFileName());
List<Order> valid = new ArrayList<>();
List<String> failures = new ArrayList<>();
int chunks = BatchUtils.forEachLineChunk(in, chunkSize, lines -> {
report.read(lines.size());
for (String line : lines) {
Optional<Order> o = parseLine(line);
if (o.isPresent()) valid.add(o.get()); else failures.add(line);
}
});
report.skipped(failures.size());
failures.forEach(f -> report.error("파싱 실패: " + f));
long total = valid.stream().mapToLong(Order::amount).sum();
BatchUtils.writeAtomically(out, w -> BatchUtils.io(() -> {
w.write("date=" + date); w.newLine();
w.write("orders=" + valid.size()); w.newLine();
w.write("total=" + total); w.newLine();
return null;
}));
report.written(3);
System.out.println(" 청크 " + chunks + "개, 출력 " + out.getFileName());
return report;
}
// 입력: o1,Kim,12000 / o2,Lee,8000 / broken line / o3,Park,abc / o4,Choi,30000
DailyFileJob job = new DailyFileJob(base);
BatchReport r1 = job.run(day, 2);
// 출력: 청크 3개, 출력 summary_20240315.txt
System.out.println(r1.summary()); // 출력: [daily-2024-03-15] COMPLETED_WITH_ERRORS read=5 written=3 skipped=2 errors=2
List<String> summary = BatchUtils.withReader(outputDir.resolve("summary_20240315.txt"), br -> br.lines().toList());
System.out.println(summary); // 출력: [date=2024-03-15, orders=3, total=50000]
BatchReport r2 = job.run(day, 2); // 출력: 이미 처리됨: summary_20240315.txt
System.out.println(r2.summary()); // 출력: [daily-2024-03-15] SUCCESS read=0 written=0 skipped=0 errors=0
BatchReport r3 = job.run(day.plusDays(1), 2);
System.out.println(r3.summary()); // 출력: [daily-2024-03-16] COMPLETED_WITH_ERRORS read=0 written=0 skipped=0 errors=1주의: valid를 메모리에 다 모으는 것은 합계만 필요한 이 예제에서 낭비입니다. 실무에서는 청크마다 합계를 누적하고 리스트는 버립니다.
잡 이름 → Function<JobParams, BatchReport> 맵에 잡을 등록하고, args를 파싱해 해당 잡에 파라미터를 넘깁니다. dryRun이면 실행하지 않고, 실행 시에는 timed로 감쌉니다. 새 잡 추가 = register 한 줄입니다.
public class JobRunner {
private final Map<String, Function<JobParams, BatchReport>> jobs = new LinkedHashMap<>();
public JobRunner register(String name, Function<JobParams, BatchReport> job) { jobs.put(name, job); return this; }
public BatchReport run(String[] args) {
JobParams params = JobParams.parse(args);
Function<JobParams, BatchReport> job = jobs.get(params.job());
if (job == null) throw new IllegalArgumentException("등록되지 않은 잡: " + params.job() + " (가능: " + jobs.keySet() + ")");
System.out.println(" 실행: " + params);
if (params.dryRun()) return BatchReport.of(params.job() + "(dry-run)");
return BatchUtils.timed(params.job(), () -> job.apply(params));
}
}
JobRunner runner = new JobRunner()
.register("daily", params -> job.run(params.date(), params.chunkSize()))
.register("noop", params -> BatchReport.of("noop"));
System.out.println(runner.run(new String[]{"--job=noop"}).summary());
// 출력:
// 실행: JobParams[job=noop, date=2024-03-15, chunkSize=100, dryRun=false]
// [noop] 0 ms
// [noop] SUCCESS read=0 written=0 skipped=0 errors=0
System.out.println(runner.run(new String[]{"--job=daily", "--date=20240315", "--dryRun"}).summary());
// 출력:
// 실행: JobParams[job=daily, date=2024-03-15, chunkSize=100, dryRun=true]
// [daily(dry-run)] SUCCESS read=0 written=0 skipped=0 errors=0
try {
runner.run(new String[]{"--job=weekly"});
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // 출력: 등록되지 않은 잡: weekly (가능: [daily, noop])
}주의: 실제 main은 runner.run(args)의 결과로 종료 코드를 정해야 합니다(System.exit(report.hasErrors() ? 1 : 0)). 스케줄러는 종료 코드로 성공/실패를 판단합니다.
Optional.emptyCSV 한 줄을 record로 바꾸는 메서드는 형식 오류가 흔합니다. 예외를 던지면 호출자가 try-catch를 매 줄 써야 하므로 Optional로 돌려주고 호출자는 isPresent로 분기합니다. 파서를 static으로 분리하면 잡 없이 단독 테스트가 됩니다.
static Optional<Order> parseLine(String line) {
String[] p = line.split(",");
if (p.length != 3) return Optional.empty();
try {
return Optional.of(new Order(p[0].trim(), p[1].trim(), Long.parseLong(p[2].trim())));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
System.out.println(DailyFileJob.parseLine("o9, Han, 700").isPresent()); // 출력: true
System.out.println(DailyFileJob.parseLine("o9, Han").isPresent()); // 출력: false
System.out.println(DailyFileJob.parseLine("o9, Han, 7x").isPresent()); // 출력: false주의: 실패 이유가 필요하면 중급 06의 Result 타입으로 바꿉니다. 실무 CSV는 따옴표 안의 콤마를 처리해야 하므로 split(",")로는 부족합니다.
실패 건을 콘솔 로그에 섞어 두면 나중에 찾기 어렵습니다. 리포트의 오류 목록을 별도 파일로 쓰면 운영자가 그 파일만 보고 정정·재처리할 수 있습니다. withWriter + forEach로 세 줄입니다.
Path errLog = base.resolve("errors.log");
BatchUtils.withWriter(errLog, w -> r1.errors().forEach(e -> BatchUtils.io(() -> { w.write(e); w.newLine(); return null; })));
List<String> logged = BatchUtils.withReader(errLog, br -> br.lines().toList());
System.out.println(logged); // 출력: [파싱 실패: broken line, 파싱 실패: o3, Park, abc]주의: 에러 파일 이름에도 날짜를 붙여(errors_20240315.log) 실행마다 덮어쓰지 않게 합니다.