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

대용량 파일 I/O (NIO, Buffered)

섹션 7진행 0 / 6
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

3절의 예제는 "한 줄씩 읽고, 버퍼로 쓰고, 임시 파일로 옮긴다"는 원칙을 보여 줬습니다. 여기서는 같은 원칙을 버퍼 크기 실측, 재사용 가능한 제네릭 Reader, 인코딩 왕복, 원자적 쓰기 헬퍼, 깨진 입력 방어라는 다섯 가지 각도로 다시 씁니다. 모든 예제는 임시 디렉터리에 파일을 만들고 지우므로 그대로 실행할 수 있습니다.

변형 1: 쓰기 버퍼 크기별 실측 — 1KB / 8KB / 64KB / 1MB

예제 1 이 읽기 방식을 비교했다면, 이번에는 쓰기 쪽에서 버퍼 크기가 시스템 콜 횟수를 어떻게 바꾸는지 같은 데이터로 측정합니다. FileWriter 단독과 BufferedWriter 를 크기별로 비교하면 "8KB 기본값이면 충분하고, 64KB 이후는 평탄해진다"는 것을 숫자로 확인할 수 있습니다. 대용량 출력 배치에서 버퍼를 64KB 정도로 키우는 것은 비용이 없는 최적화입니다.

java
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class WriteBufferBench {
    static final int LINES = 200_000;

    static long write(Writer w) throws IOException {
        long t0 = System.nanoTime();
        try (w) {
            for (int i = 0; i < LINES; i++) {
                w.write("ORD-" + i + ",2026-09-08,cust-" + (i % 500) + "," + (i * 7 % 100_000));
                w.write('\n');
            }
        }
        return (System.nanoTime() - t0) / 1_000_000;
    }

    public static void main(String[] args) throws IOException {
        Path dir = Files.createTempDirectory("wbuf");
        Path p = dir.resolve("out.csv");
        write(new FileWriter(p.toFile(), StandardCharsets.UTF_8));                 // 워밍업 (JIT, 페이지 캐시)

        System.out.printf("%-24s %8s%n", "방식", "ms");
        System.out.printf("%-24s %8d%n", "FileWriter 단독", write(new FileWriter(p.toFile(), StandardCharsets.UTF_8)));
        for (int size : new int[]{1024, 8192, 65536, 1 << 20}) {
            long ms = write(new BufferedWriter(new FileWriter(p.toFile(), StandardCharsets.UTF_8), size));
            System.out.printf("%-24s %8d%n", "BufferedWriter " + (size >= 1 << 20 ? "1MB" : size / 1024 + "KB"), ms);
        }
        System.out.printf("파일 %,d bytes / %,d 줄%n", Files.size(p), LINES);
        Files.delete(p); Files.delete(dir);
    }
}
// 출력 (ms 값은 디스크·OS 에 따라 다름. 순서와 배율이 핵심):
// 방식                           ms
// FileWriter 단독               30484
// BufferedWriter 1KB           8707
// BufferedWriter 8KB           3424
// BufferedWriter 64KB          2104
// BufferedWriter 1MB           2052
// 파일 7,222,670 bytes / 200,000 줄

FileWriter 단독은 write 호출마다 인코더와 커널 경계를 넘나들어 8KB 버퍼보다 약 9배 느립니다. 64KB 와 1MB 는 차이가 없습니다. 시스템 콜 횟수가 이미 충분히 줄어 디스크 자체가 병목이 되었기 때문입니다.

변형 2: 제네릭 CsvReader<T> — 파일 → 레코드 변환을 파라미터로

예제 3 은 Transaction.fromCsv 를 직접 호출하는 루프였습니다. 이것을 Iterator<T> + AutoCloseable 로 추출하고 "필드 리스트 → T" 변환 함수를 생성자로 받으면, 주문 파일이든 정산 파일이든 매퍼 한 줄만 바꿔 같은 Reader 를 씁니다. 필드 수 검증과 줄 번호 추적을 Reader 안에 한 번만 구현하므로 파일 종류가 늘어도 검증 코드가 복제되지 않습니다.

java
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.function.Function;

public class GenericCsvReader {
    /** 파일 → 레코드 변환을 파라미터화한 스트리밍 Reader. 어떤 CSV 든 매퍼만 바꿔 재사용 */
    static class CsvReader<T> implements Iterator<T>, AutoCloseable {
        private final BufferedReader reader;
        private final Function<List<String>, T> mapper;
        private final int expectedFields;
        private T next;
        private long lineNo;

        CsvReader(Path path, int expectedFields, Function<List<String>, T> mapper) throws IOException {
            this.reader = Files.newBufferedReader(path, StandardCharsets.UTF_8);
            this.mapper = mapper;
            this.expectedFields = expectedFields;
            reader.readLine(); lineNo = 1;                                  // 헤더
        }

        @Override public boolean hasNext() {
            if (next != null) return true;
            try {
                String line = reader.readLine();
                if (line == null) return false;
                lineNo++;
                List<String> f = parseLine(line);
                if (f.size() != expectedFields)
                    throw new IllegalArgumentException("line " + lineNo + ": 필드 수 " + f.size() + " (" + expectedFields + " 필요)");
                next = mapper.apply(f);
                return true;
            } catch (IOException e) { throw new UncheckedIOException(e); }
        }
        @Override public T next() {
            if (!hasNext()) throw new NoSuchElementException();
            T t = next; next = null; return t;
        }
        public long lineNo() { return lineNo; }
        @Override public void close() throws IOException { reader.close(); }
    }

    static List<String> parseLine(String line) {                            // 02 예제 2 와 동일
        List<String> fields = new ArrayList<>();
        StringBuilder cur = new StringBuilder();
        boolean inQuotes = false;
        for (int i = 0; i < line.length(); i++) {
            char c = line.charAt(i);
            if (inQuotes) {
                if (c == '"') {
                    if (i + 1 < line.length() && line.charAt(i + 1) == '"') { cur.append('"'); i++; }
                    else inQuotes = false;
                } else cur.append(c);
            } else {
                if (c == '"') inQuotes = true;
                else if (c == ',') { fields.add(cur.toString()); cur.setLength(0); }
                else cur.append(c);
            }
        }
        fields.add(cur.toString());
        return fields;
    }

    record Order(String id, String customer, long amount) {}
    record Settlement(String merchant, long gross, long fee) { long net() { return gross - fee; } }

    public static void main(String[] args) throws IOException {
        Path dir = Files.createTempDirectory("csvr");
        Path orders = dir.resolve("orders.csv");
        Files.write(orders, List.of("id,customer,amount",
                "O1,\"Kim, Minsu\",12000", "O2,lee,8000", "O3,\"Kim, Minsu\",30000"), StandardCharsets.UTF_8);
        Path settle = dir.resolve("settlement.csv");
        Files.write(settle, List.of("merchant,gross,fee",
                "M-01,1500000,45000", "M-02,820000,24600", "M-03,0,0"), StandardCharsets.UTF_8);

        // 같은 Reader, 다른 매퍼
        Map<String, Long> byCustomer = new TreeMap<>();
        try (var r = new CsvReader<>(orders, 3, f -> new Order(f.get(0), f.get(1), Long.parseLong(f.get(2))))) {
            while (r.hasNext()) { Order o = r.next(); byCustomer.merge(o.customer(), o.amount(), Long::sum); }
        }
        System.out.println("고객별 합계: " + byCustomer);

        long totalNet = 0; int count = 0;
        try (var r = new CsvReader<>(settle, 3, f -> new Settlement(f.get(0), Long.parseLong(f.get(1)), Long.parseLong(f.get(2))))) {
            while (r.hasNext()) { totalNet += r.next().net(); count++; }
        }
        System.out.printf("정산 %d건, 순지급액 %,d원%n", count, totalNet);

        // 필드 수가 다른 줄 → 줄 번호가 포함된 예외
        Path bad = dir.resolve("bad.csv");
        Files.write(bad, List.of("id,customer,amount", "O1,kim,100", "O2,lee"), StandardCharsets.UTF_8);
        try (var r = new CsvReader<>(bad, 3, f -> f.get(0))) {
            while (r.hasNext()) r.next();
        } catch (IllegalArgumentException e) {
            System.out.println("검증 실패: " + e.getMessage());
        }
    }
}
// 출력:
// 고객별 합계: {Kim, Minsu=42000, lee=8000}
// 정산 3건, 순지급액 2,250,400원
// 검증 실패: line 3: 필드 수 2 (3 필요)

hasNext() 가 한 줄을 미리 읽어 두는 look-ahead 구조입니다(03 레슨 2.5 와 같은 원리). 예외 메시지에 줄 번호가 들어가므로 새벽에 죽어도 어느 줄이 문제인지 로그만 보고 알 수 있습니다.

변형 3: 인코딩 왕복 — 같은 "홍길동" 이 UTF-8 과 MS949 에서 어떻게 다른가

2.4 절의 표를 코드로 재현합니다.

바이트를 직접 찍어 보고, 반대 인코딩으로 읽었을 때 어떤 글자가 나오는지, Files.readAllLines 가 깨진 바이트에 예외를 던지는 반면 new String(bytes, cs) 는 대체 문자로 조용히 넘어간다는 차이, BOM 이 첫 필드 이름을 오염시키는 문제, 고정길이 한글 필드를 substring 으로 자르면 어떻게 틀리는지까지 한 번에 봅니다.

인코딩 사고를 겪기 전에 이 출력을 한 번 눈에 익혀 두면 운영에서 깨진 파일을 보는 순간 원인을 짚을 수 있습니다.

java
import java.nio.charset.*;
import java.nio.file.*;
import java.util.*;

public class EncodingRoundTrip {
    public static void main(String[] args) throws Exception {
        Charset ms949 = Charset.forName("MS949");
        HexFormat hex = HexFormat.ofDelimiter(" ").withUpperCase();
        String name = "홍길동";

        byte[] u8 = name.getBytes(StandardCharsets.UTF_8), k = name.getBytes(ms949);
        System.out.println("UTF-8 : " + hex.formatHex(u8) + "  (" + u8.length + " bytes)");
        System.out.println("MS949 : " + hex.formatHex(k) + "  (" + k.length + " bytes)");

        Path dir = Files.createTempDirectory("enc");
        Path excel = dir.resolve("members_excel.csv");                       // Excel 이 저장한 CSV 가정
        Files.writeString(excel, "id,name\n1,홍길동\n", ms949);
        try {
            Files.readAllLines(excel, StandardCharsets.UTF_8);               // Files.* 는 엄격 디코더: 깨진 바이트에 예외
        } catch (java.nio.charset.MalformedInputException e) {
            System.out.println("MS949 파일 → Files.readAllLines(UTF-8): " + e);
        }
        System.out.println("MS949 파일 → new String(bytes, UTF-8): " + new String(Files.readAllBytes(excel), StandardCharsets.UTF_8).split("\n")[1]);
        System.out.println("MS949 파일 → MS949 로 읽음: " + Files.readAllLines(excel, ms949).get(1));

        Path utf = dir.resolve("members_utf8.csv");
        Files.writeString(utf, "id,name\n1,홍길동\n", StandardCharsets.UTF_8);
        System.out.println("UTF-8 파일 → new String(bytes, MS949): " + new String(Files.readAllBytes(utf), ms949).split("\n")[1]);

        // Excel 용 UTF-8 BOM: 읽는 쪽에서는 첫 글자  를 떼어내야 한다
        Path bom = dir.resolve("members_bom.csv");
        try (var out = Files.newOutputStream(bom)) {
            out.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
            out.write("id,name\n".getBytes(StandardCharsets.UTF_8));
        }
        String header = Files.readAllLines(bom, StandardCharsets.UTF_8).get(0);
        System.out.println("BOM 헤더: 길이 " + header.length() + ", 첫 필드 \"" + header.split(",")[0] + "\" 는 \"id\" 와 "
                + (header.split(",")[0].equals("id") ? "같다" : "다르다") + " → strip 후 " + header.replace("", "").length());

        // 고정길이 전문의 한글 필드: 문자가 아니라 바이트로 자른다
        // 레이아웃: id(4) name(10) city(4) memo(4) = 22 바이트. "홍길동" 은 MS949 로 6바이트 + 공백 4
        byte[] line = ("0001" + name + "    " + "서울" + "MEMO").getBytes(ms949);
        String asChars = new String(line, ms949);
        System.out.println("바이트 " + line.length + ", 문자 " + asChars.length());
        System.out.println("substring(4,14) = [" + asChars.substring(4, 14) + "]  ← city 가 섞여 들어옴");
        System.out.println("bytes[4..14)    = [" + new String(Arrays.copyOfRange(line, 4, 14), ms949) + "]  ← 정확");

        for (Path p : List.of(excel, utf, bom)) Files.delete(p);
        Files.delete(dir);
    }
}
// 출력:
// UTF-8 : ED 99 8D EA B8 B8 EB 8F 99  (9 bytes)
// MS949 : C8 AB B1 E6 B5 BF  (6 bytes)
// MS949 파일 → Files.readAllLines(UTF-8): java.nio.charset.MalformedInputException: Input length = 1
// MS949 파일 → new String(bytes, UTF-8): 1,ȫ�浿
// MS949 파일 → MS949 로 읽음: 1,홍길동
// UTF-8 파일 → new String(bytes, MS949): 1,�솉湲몃룞
// BOM 헤더: 길이 8, 첫 필드 "id" 는 "id" 와 다르다 → strip 후 7
// 바이트 22, 문자 17
// substring(4,14) = [홍길동    서울M]  ← city 가 섞여 들어옴
// bytes[4..14)    = [홍길동    ]  ← 정확

Files.newBufferedReader / Files.readAllLines 는 잘못된 바이트를 만나면 MalformedInputException 을 던집니다. 배치에서는 이것이 오히려 좋습니다. 조용히 � 로 바뀐 파일이 은행으로 나가는 것보다 첫 줄에서 죽는 편이 낫기 때문입니다. BOM 이 있는 파일은 헤더의 첫 필드 이름이 "id" 와 equals 가 실패하므로  를 제거하는 코드가 필요합니다.

변형 4: writeAtomically(Path, body) — 원자적 쓰기를 헬퍼로 추출

예제 5 는 특정 리포트 하나를 원자적으로 썼습니다. "임시 파일 → 본문 실행 → 이동" 골격을 IOConsumer<BufferedWriter> 를 받는 헬퍼로 빼내면 모든 출력 파일이 같은 보호를 받습니다. 여기에 두 가지를 더합니다.

본문이 예외를 던지면 임시 파일을 지우고, REPLACE_EXISTING 덕분에 어제 파일은 새 파일이 완성될 때까지 그대로 남습니다. "재생성 도중 죽어도 이전 정상 파일이 살아 있다"는 점이 실무에서 가장 중요한 성질입니다.

java
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;

public class AtomicWriteHelper {
    @FunctionalInterface interface IOConsumer<T> { void accept(T t) throws IOException; }

    /** 임시 파일에 쓰고 성공 시에만 원자적으로 교체. 실패하면 임시 파일을 지우고 기존 파일은 그대로 */
    static void writeAtomically(Path dest, IOConsumer<BufferedWriter> body) throws IOException {
        Path tmp = dest.resolveSibling(dest.getFileName() + ".tmp");       // 반드시 같은 디렉터리
        try (BufferedWriter w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
            body.accept(w);
        } catch (IOException | RuntimeException e) {
            Files.deleteIfExists(tmp);                                       // 반쪽 파일 정리
            throw e;
        }
        Files.move(tmp, dest, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
    }

    static String status(Path dest) throws IOException {
        Path tmp = dest.resolveSibling(dest.getFileName() + ".tmp");
        return dest.getFileName() + (Files.exists(dest) ? "=" + Files.readAllLines(dest).size() + "줄" : " 없음")
                + ", tmp " + (Files.exists(tmp) ? "있음" : "없음");
    }

    public static void main(String[] args) throws IOException {
        Path dir = Files.createTempDirectory("atomic");
        Path report = dir.resolve("settlement.csv");
        Map<String, Long> balances = new TreeMap<>(Map.of("ACC-1", 1000L, "ACC-2", 2000L, "ACC-3", 3000L));

        // 1) 정상 생성
        writeAtomically(report, w -> {
            w.write("account,balance"); w.newLine();
            for (var e : balances.entrySet()) { w.write(e.getKey() + "," + e.getValue()); w.newLine(); }
        });
        System.out.println("1차 정상: " + status(report));

        // 2) 재생성 도중 장애 → 어제 파일이 그대로 살아 있고 tmp 는 정리됨
        try {
            writeAtomically(report, w -> {
                w.write("account,balance"); w.newLine();
                w.write("ACC-1,999"); w.newLine();
                throw new IllegalStateException("DB 커넥션 끊김");
            });
        } catch (IllegalStateException e) {
            System.out.println("2차 장애(" + e.getMessage() + "): " + status(report));
        }

        // 3) 같은 헬퍼를 다른 형식(고정길이 전문)에 재사용
        Path fixed = dir.resolve("daily.txt");
        writeAtomically(fixed, w -> {
            for (var e : balances.entrySet()) { w.write(String.format("%-8s%012d", e.getKey(), e.getValue())); w.newLine(); }
        });
        System.out.println("3차 재사용: " + status(fixed) + ", 첫 줄 [" + Files.readAllLines(fixed).get(0) + "]");

        try (var s = Files.list(dir)) { for (Path p : s.toList()) Files.delete(p); }
        Files.delete(dir);
    }
}
// 출력:
// 1차 정상: settlement.csv=4줄, tmp 없음
// 2차 장애(DB 커넥션 끊김): settlement.csv=4줄, tmp 없음
// 3차 재사용: daily.txt=3줄, tmp 없음, 첫 줄 [ACC-1   000000001000]

2차 실행에서 본문이 두 줄을 쓰고 죽었지만 settlement.csv 는 여전히 1차의 4줄입니다. 임시 파일도 남지 않아 다음 실행이 깨끗하게 시작됩니다. 임시 파일을 지우지 않고 .failed 로 이름을 바꿔 보관하면 장애 분석에 쓸 수 있습니다.

변형 5: 깨진 입력 방어 — 빈 파일, 끝 개행 없음, CRLF, 빈 줄, 따옴표 안 개행

예제들은 정상 파일을 가정했습니다. 운영 입력은 그렇지 않습니다. 대표적인 일곱 가지 상황을 한 파일씩 만들어 같은 scan 함수에 넣고, 어떤 것은 readLine 이 알아서 처리하고(CRLF, 끝 개행 없음) 어떤 것은 직접 검사해야 하는지(빈 파일, 빈 줄, 필드 수, 따옴표 균형) 확인합니다. 특히 "따옴표 안 개행"은 줄 단위 파서의 한계이므로 탐지해서 거부하는 것이 이 레슨 파서의 올바른 대응입니다.

java
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;

public class CsvEdgeCases {
    record Stat(int records, int blank, int badFieldCount, int openQuote) {}

    /** 헤더 유무·줄바꿈 종류·빈 줄·필드 수·따옴표 균형까지 검사하는 방어적 읽기 */
    static Stat scan(Path p, int expectedFields) throws IOException {
        int records = 0, blank = 0, bad = 0, openQuote = 0;
        try (BufferedReader r = Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
            String header = r.readLine();
            if (header == null) return new Stat(0, 0, 0, 0);               // 빈 파일: 헤더조차 없음
            String line;
            while ((line = r.readLine()) != null) {
                if (line.isBlank()) { blank++; continue; }                  // 중간 빈 줄, 끝 개행 뒤 빈 줄
                long quotes = line.chars().filter(c -> c == '"').count();
                if (quotes % 2 == 1) { openQuote++; continue; }             // 따옴표가 안 닫힘 = 필드 안 개행
                if (line.split(",", -1).length != expectedFields) { bad++; continue; }
                records++;
            }
        }
        return new Stat(records, blank, bad, openQuote);
    }

    public static void main(String[] args) throws IOException {
        Path dir = Files.createTempDirectory("edge");
        Map<String, String> cases = new LinkedHashMap<>();
        cases.put("빈 파일",              "");
        cases.put("헤더만",               "id,name,amount\n");
        cases.put("끝 개행 없음",         "id,name,amount\n1,kim,100\n2,lee,200");
        cases.put("CRLF",                 "id,name,amount\r\n1,kim,100\r\n2,lee,200\r\n");
        cases.put("중간 빈 줄",           "id,name,amount\n1,kim,100\n\n2,lee,200\n");
        cases.put("필드 수 불일치",       "id,name,amount\n1,kim,100\n2,lee\n3,park,300,extra\n");
        cases.put("따옴표 안 개행",       "id,name,amount\n1,\"kim\nminsu\",100\n2,lee,200\n");

        for (var e : cases.entrySet()) {
            Path p = dir.resolve("t.csv");
            Files.writeString(p, e.getValue(), StandardCharsets.UTF_8);
            System.out.println(e.getKey() + " → " + scan(p, 3));
        }

        // CRLF 파일: readLine 은 \r 을 떼어 주지만, 바이트로 읽으면 남아 있다
        Path crlf = dir.resolve("crlf.csv");
        Files.writeString(crlf, "a,b\r\n1,2\r\n", StandardCharsets.UTF_8);
        try (BufferedReader r = Files.newBufferedReader(crlf)) {
            String l = r.readLine();
            System.out.println("readLine 결과 끝에 \\r 있음? " + l.endsWith("\r") + ", 길이 " + l.length());
        }
        System.out.println("바이트 수 " + Files.size(crlf) + " (LF 였다면 " + "a,b\n1,2\n".length() + ")");

        try (var s = Files.list(dir)) { for (Path p : s.toList()) Files.delete(p); }
        Files.delete(dir);
    }
}
// 출력:
// 빈 파일 → Stat[records=0, blank=0, badFieldCount=0, openQuote=0]
// 헤더만 → Stat[records=0, blank=0, badFieldCount=0, openQuote=0]
// 끝 개행 없음 → Stat[records=2, blank=0, badFieldCount=0, openQuote=0]
// CRLF → Stat[records=2, blank=0, badFieldCount=0, openQuote=0]
// 중간 빈 줄 → Stat[records=2, blank=1, badFieldCount=0, openQuote=0]
// 필드 수 불일치 → Stat[records=1, blank=0, badFieldCount=2, openQuote=0]
// 따옴표 안 개행 → Stat[records=1, blank=0, badFieldCount=0, openQuote=2]
// readLine 결과 끝에 \r 있음? false, 길이 3
// 바이트 수 10 (LF 였다면 8)

"따옴표 안 개행" 파일에서 openQuote=2 인 이유는 한 레코드가 두 줄로 쪼개져 각각 따옴표가 하나씩만 남기 때문입니다. 이 카운트가 0 이 아니면 줄 단위 파서를 쓸 수 없는 파일이므로, 05 레슨의 스킵이 아니라 배치 중단 + 파일 형식 확인이 맞는 대응입니다. split(",", -1) 의 -1 은 끝의 빈 필드를 버리지 않게 하는 옵션으로, 이것이 없으면 "1,kim," 이 2개 필드로 세어집니다.

응용 변형 예제
  • 변형 1: 쓰기 버퍼 크기별 실측 — 1KB / 8KB / 64KB / 1MB
  • 변형 2: 제네릭 CsvReader<T> — 파일 → 레코드 변환을 파라미터로
  • 변형 3: 인코딩 왕복 — 같은 "홍길동" 이 UTF-8 과 MS949 에서 어떻게 다른가
  • 변형 4: writeAtomically(Path, body) — 원자적 쓰기를 헬퍼로 추출
  • 변형 5: 깨진 입력 방어 — 빈 파일, 끝 개행 없음, CRLF, 빈 줄, 따옴표 안 개행
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)