공공부하자개발 · 영어 학습 노트
자바
실무 배치대용량 데이터 처리0/6 완료
  • 01배치 프로세스 개념과 아키텍처
  • 02대용량 파일 I/O (NIO, Buffered)
  • 03Chunk 단위 처리와 OOM 방지
  • 04트랜잭션: 커밋과 롤백 시뮬레이션
  • 05Skip과 Retry 로직 구현
  • 06메서드 활용 패턴 (배치 유틸)
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 배치 › 06 / 6

메서드 활용 패턴 (배치 유틸)

섹션 6진행 0 / 6
1왜 배우는가2핵심 원리3패턴 모음4자주 하는 실수 (Tip)5연습 문제6정리‹ 이전다음 ›

3. 패턴 모음

각 패턴의 코드는 java-src/batch/06_method_patterns/에서 실행해 볼 수 있습니다. Main은 임시 디렉터리를 만들어 입력 파일을 생성하고 처리한 뒤 정리합니다. BatchUtils가 공통 유틸, JobParams/BatchReport/DailyFileJob/JobRunner가 실무 예제입니다.

패턴 1: 파일 경로 조립 — 문자열 연결 대신 Path.resolve

base + "/" + "input" + "/" + name은 OS별 구분자와 중복 슬래시 문제가 있습니다. Path.resolve를 가변 인자로 감싸면 어떤 깊이든 한 줄로 조립되고 OS에 맞게 처리됩니다.

java
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)로 탈출을 막습니다.

패턴 2: 날짜 기반 파일명

일자별 파일은 prefix_yyyyMMdd.ext 형식이 표준입니다. 포맷터를 상수로 두고 만들기·파싱하기를 한 쌍으로 제공하면 형식이 바뀌어도 한 곳만 고칩니다.

java
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()를 유틸 안에서 부르지 마세요. 날짜는 파라미터로 받아야 재실행·테스트가 가능합니다.

패턴 3: 안전한 파일 열기 — 없으면 Optional.empty

입력 파일이 없는 것은 배치에서 흔한 정상 상황입니다(휴일에는 주문 파일이 없음). 예외 대신 Optional로 돌려주면 호출자가 "없으면 스킵"을 자연스럽게 씁니다.

java
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에 존재 검사를 합치는 편이 안전합니다.

패턴 4: withReader — 열고 닫기는 헬퍼, 본문은 람다

파일을 열고, UTF-8을 지정하고, 다 쓰면 닫고, IOException을 언체크로 바꾸는 일을 한 곳에 모읍니다. 호출자는 BufferedReader로 무엇을 할지만 람다로 넘기고 결과를 돌려받습니다. 스트림을 세든 첫 줄을 읽든 같은 헬퍼입니다.

java
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() 스트림을 밖으로 돌려주면 안 됩니다. 람다가 끝나는 순간 파일이 닫혀 스트림을 소비할 때 예외가 납니다. 결과(숫자, 리스트)로 바꿔서 돌려주세요.

패턴 5: withWriter

읽기와 대칭입니다. Consumer<BufferedWriter>를 받아 쓰기가 끝나면 flush + close를 보장합니다. 쓰기 도중 예외가 나도 닫힙니다.

java
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) 사이에서 추론이 모호해 컴파일 오류가 납니다. 변수에 먼저 받으세요.

패턴 6: 체크 예외를 언체크로 감싸기

w.write()는 IOException을 던지는데 Consumer는 체크 예외를 허용하지 않습니다. throws IOException이 있는 자체 함수형 인터페이스 IOAction과 그것을 실행하며 UncheckedIOException으로 바꾸는 io()를 두면 람다 안에서 io(() -> ...)로 감쌀 수 있습니다.

java
@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 오버로딩을 하나 더 둡니다.

패턴 7: forEachChunk — 리스트를 n개씩

DB 배치 insert, API 일괄 호출처럼 "한 번에 n건"으로 보내야 할 때 subList로 잘라 핸들러를 부릅니다. 마지막 자투리도 빠짐없이 처리하고 청크 수를 돌려줍니다.

java
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).

패턴 8: 라인 스트림을 청크로 — 전체를 메모리에 올리지 않기

파일이 수백만 줄이면 Files.readAllLines는 OOM입니다. Files.lines로 한 줄씩 받아 버퍼가 차면 핸들러를 부르고 새 버퍼를 만듭니다. 메모리는 항상 청크 하나 분량입니다.

java
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()로 재사용하면 핸들러가 청크를 비동기로 처리할 때 내용이 바뀝니다. 새 리스트를 만드는 것이 안전합니다.

패턴 9: timed — 실행 시간 측정 데코레이터

Supplier<T>를 받아 실행하고 걸린 시간을 찍은 뒤 결과를 그대로 돌려줍니다. finally에 두어 예외가 나도 시간이 기록됩니다. Runnable 오버로딩을 추가하면 반환값 없는 작업에도 씁니다.

java
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으로 잽니다.

패턴 10: 진행률 리포터

100만 건을 처리하면서 매 건 로그를 찍으면 로그가 처리보다 느립니다. "every건마다, 그리고 마지막에" 한 줄 찍는 Consumer<Integer>를 만들어 루프에서 accept(done)만 부릅니다. 총건수와 간격을 캡처한 함수 팩토리입니다.

java
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건 / 경과 시간"으로 바꿉니다.

패턴 11: retry — 일시적 실패 재시도

네트워크·DB 연결처럼 잠깐 실패했다 회복되는 작업을 감쌉니다. 최대 횟수까지 시도하고 모두 실패하면 마지막 예외를 원인으로 담아 던집니다. 05 레슨의 RetryTemplate을 메서드 하나로 줄인 형태입니다.

java
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

주의: 파싱 오류처럼 다시 해도 같은 결과인 실패를 재시도하면 시간만 낭비합니다. 재시도할 예외 종류를 인자로 받는 것이 실무 버전입니다. 대기(백오프)도 필요합니다.

패턴 12: skip 감싸기 — 실패 건은 건너뛰고 기록

한 건이 잘못됐다고 전체 잡을 죽이면 안 되는 경우, 실패 건을 실패 목록에 적고 계속 갑니다. 단, 실패가 한도를 넘으면 입력 자체가 잘못된 것이므로 중단합니다. 성공한 건만 돌려줍니다.

java
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
}

주의: 실패 목록을 인자로 받아 채우는 방식은 호출자가 목록을 소유하므로 여러 단계의 실패를 한 목록에 모을 수 있습니다. 대신 메서드가 인자를 변경한다는 점을 이름이나 문서에 드러내야 합니다.

패턴 13: 배치 파라미터 파싱 — args → record

--key=value 형식의 args를 Map으로 모은 뒤 record로 변환합니다. 이후 코드는 params.date()처럼 타입 있는 값을 쓰고, toString이 실행 로그에 그대로 남습니다.

java
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))가 흔합니다. 그래도 파싱 메서드 안이 아니라 호출자가 넘기게 하여 테스트 가능하게 유지합니다.

패턴 14: 기본값과 검증 — compact 생성자

없는 값의 기본값은 parse에서, 값의 유효성은 record의 compact 생성자에서 검사합니다. --dryRun처럼 값 없는 플래그는 true로 해석합니다. 잘못된 파라미터는 잡을 시작하기 전에 명확한 메시지로 실패시킵니다.

java
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(...)로 직접 만드는 경로(테스트 등)에서도 검증되게 하기 위해서입니다.

패턴 15: 결과 리포트 빌더

처리 중 읽은 건수·쓴 건수·스킵·오류를 누적하고, 마지막에 한 줄 요약을 만듭니다. return this 체이닝으로 누적하고, 오류 목록은 List.copyOf로 불변 반환합니다. 잡의 반환 타입을 BatchReport로 통일하면 실행기가 결과를 일관되게 다룹니다.

java
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의 에러 파일로 보냅니다.

패턴 16: finally — 정상/예외 모두 마지막 정리

잡의 최상위에서 try { runAll } finally { 정리 }를 두면 어떤 예외로 끝나든 정리 코드가 실행됩니다. 락 해제, 진행 상태 기록, 종료 로그가 여기 들어갑니다.

java
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로 감싸 조용히 실패하게 합니다.

패턴 17: shutdown hook — JVM 종료 시 정리

스케줄러가 SIGTERM으로 잡을 죽이거나 System.exit가 호출되면 finally까지 도달하지 못할 수 있습니다. Runtime.addShutdownHook에 등록한 스레드는 JVM이 종료될 때 실행되므로 임시 디렉터리 삭제, 커넥션 반납 같은 최후 정리를 여기 둡니다.

java
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    deleteRecursively(base);
    System.out.println("\n[shutdown hook] 임시 디렉터리 정리 완료");   // 출력: [shutdown hook] 임시 디렉터리 정리 완료
}));

주의: 훅은 짧고 확실하게 끝나야 합니다. 훅 안에서 오래 걸리는 I/O나 다른 스레드를 기다리면 종료가 멈춥니다. kill -9에는 실행되지 않습니다.

패턴 18: 원자적 쓰기 — 임시 파일에 쓰고 move

출력 파일을 직접 쓰다가 중간에 죽으면 반쪽짜리 파일이 남고, 다음 실행은 "이미 처리됨"으로 오판할 수 있습니다. .tmp에 다 쓴 뒤 ATOMIC_MOVE로 이름을 바꾸면 파일은 "없음" 아니면 "완전함" 둘 중 하나입니다.

java
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)에 만듭니다.

패턴 19: 이미 처리된 날짜 스킵 — 멱등 실행

같은 날짜로 잡이 두 번 돌아도 결과가 두 번 쌓이면 안 됩니다. 출력 파일이 있으면 처리하지 않고 빈 리포트를 돌려줍니다. 패턴 18과 합치면 "출력 파일이 있다 = 완전히 처리됐다"가 보장됩니다.

java
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 파라미터로 스킵을 끄는 옵션을 둡니다.

패턴 20: 날짜 범위 → 일자 목록

"3월 1일부터 15일까지 밀린 잡을 돌려라"는 요청은 날짜 목록을 만들어 하루씩 잡을 부르면 됩니다. LocalDate.datesUntil이 스트림을 만들어 주므로 끝 날짜 포함 여부만 조정합니다.

java
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입니다. 파라미터 검증에서 순서를 확인하세요.

패턴 21: 실무 예제 — 공통 BatchUtils 클래스

패턴 1~12, 18, 20을 private 생성자 + static 메서드로 한 클래스에 모은 것이 BatchUtils입니다. 상태가 없고 입력만으로 결과가 정해지므로 static이 맞습니다. 잡은 이 클래스만 import하면 리소스 관리·청크·재시도를 신경 쓰지 않습니다.

java
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개가 넘으면 찾기 어렵습니다.

패턴 22: 실무 예제 — 일자별 파일 처리 잡

input/orders_YYYYMMDD.csv를 청크로 읽어 파싱하고, 실패 줄은 스킵·기록하며, 합계를 output/summary_YYYYMMDD.txt에 원자적으로 씁니다. 헬퍼 덕분에 잡 본문은 "무엇을 하는가"만 남습니다.

java
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를 메모리에 다 모으는 것은 합계만 필요한 이 예제에서 낭비입니다. 실무에서는 청크마다 합계를 누적하고 리스트는 버립니다.

패턴 23: 실무 예제 — 파라미터화된 잡 실행기

잡 이름 → Function<JobParams, BatchReport> 맵에 잡을 등록하고, args를 파싱해 해당 잡에 파라미터를 넘깁니다. dryRun이면 실행하지 않고, 실행 시에는 timed로 감쌉니다. 새 잡 추가 = register 한 줄입니다.

java
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)). 스케줄러는 종료 코드로 성공/실패를 판단합니다.

패턴 24: 라인 파서 — 실패는 Optional.empty

CSV 한 줄을 record로 바꾸는 메서드는 형식 오류가 흔합니다. 예외를 던지면 호출자가 try-catch를 매 줄 써야 하므로 Optional로 돌려주고 호출자는 isPresent로 분기합니다. 파서를 static으로 분리하면 잡 없이 단독 테스트가 됩니다.

java
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(",")로는 부족합니다.

패턴 25: 에러 로그 파일 분리

실패 건을 콘솔 로그에 섞어 두면 나중에 찾기 어렵습니다. 리포트의 오류 목록을 별도 파일로 쓰면 운영자가 그 파일만 보고 정정·재처리할 수 있습니다. withWriter + forEach로 세 줄입니다.

java
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) 실행마다 덮어쓰지 않게 합니다.

패턴 모음
  • 패턴 1: 파일 경로 조립 — 문자열 연결 대신 Path.resolve
  • 패턴 2: 날짜 기반 파일명
  • 패턴 3: 안전한 파일 열기 — 없으면 Optional.empty
  • 패턴 4: withReader — 열고 닫기는 헬퍼, 본문은 람다
  • 패턴 5: withWriter
  • 패턴 6: 체크 예외를 언체크로 감싸기
  • 패턴 7: forEachChunk — 리스트를 n개씩
  • 패턴 8: 라인 스트림을 청크로 — 전체를 메모리에 올리지 않기
  • 패턴 9: timed — 실행 시간 측정 데코레이터
  • 패턴 10: 진행률 리포터
  • 패턴 11: retry — 일시적 실패 재시도
  • 패턴 12: skip 감싸기 — 실패 건은 건너뛰고 기록
  • 패턴 13: 배치 파라미터 파싱 — args → record
  • 패턴 14: 기본값과 검증 — compact 생성자
  • 패턴 15: 결과 리포트 빌더
  • 패턴 16: finally — 정상/예외 모두 마지막 정리
  • 패턴 17: shutdown hook — JVM 종료 시 정리
  • 패턴 18: 원자적 쓰기 — 임시 파일에 쓰고 move
  • 패턴 19: 이미 처리된 날짜 스킵 — 멱등 실행
  • 패턴 20: 날짜 범위 → 일자 목록
  • 패턴 21: 실무 예제 — 공통 BatchUtils 클래스
  • 패턴 22: 실무 예제 — 일자별 파일 처리 잡
  • 패턴 23: 실무 예제 — 파라미터화된 잡 실행기
  • 패턴 24: 라인 파서 — 실패는 Optional.empty
  • 패턴 25: 에러 로그 파일 분리
이전 섹션2 핵심 원리3 / 6다음 섹션4 자주 하는 실수 (Tip)