JAVA · 심층 가이드
Java 파일/IO 완전 정리
NIO.2의 Path와 Files를 축으로 인코딩, Jackson 직렬화, java.net.http 클라이언트까지 Java의 입출력 코드를 12개 주제로 현대화합니다.
Java에는 파일을 다루는 API가 두 세대 겹쳐 있습니다. java.io.File은 삭제 실패를 boolean false로 돌려주고 원인을 알려주지 않으며, 심볼릭 링크나 파일 속성 개념이 빈약합니다. NIO.2의 Path/Files는 실패하면 예외를 던지고, 원자적 이동·디렉터리 스트림·파일 시스템 추상화를 제공합니다. 새 코드에서 File을 먼저 떠올린다면 그건 검색 결과가 오래됐다는 신호에 가깝습니다. 두 API는 toPath()로 오갈 수 있으니 경계에서만 변환하면 됩니다.
NIO.2 Path와 Files로 경로 조작과 파일 읽기의 기본형을 잡은 뒤 BufferedReader/Writer 활용에서 왜 버퍼가 성능을 좌우하는지 확인하고, Charset과 인코딩으로 바이트와 문자의 경계를 명확히 하는 흐름이 자연스럽습니다. 여기까지가 로컬 파일이라면 Jackson ObjectMapper와 HTTP Client (java.net.http)는 같은 스트림 개념을 네트워크로 확장한 것이고, Serialization과 역직렬화는 왜 그 자리를 JSON에 내주었는지를 설명하는 장입니다.
인코딩은 지금이 오히려 헷갈리는 시기입니다. Java 18에서 표준 자바 API의 기본 문자셋이 UTF-8로 통일됐지만, 그 이전 런타임에서는 OS 로케일을 따랐기 때문에 같은 코드가 개발자 노트북과 서버에서 다르게 동작할 수 있습니다. new FileReader(f)처럼 인코딩을 생략하는 호출은 지금도 피하고, Files.newBufferedReader(path, UTF_8)처럼 명시하는 습관이 안전합니다. Files.lines()가 반환하는 스트림도 파일 핸들을 쥐고 있으므로 반드시 try-with-resources로 닫아야 합니다.
01NIO2 — Path & Files & WatchService
Java 7+ NIO2의 Path와 Files로 파일 처리를 현대적으로 합니다.
Java code
import java.nio.file.*;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
import java.util.stream.Stream;
public class NIO2Demo {
public static void main(String[] args) throws IOException {
Path dir = Path.of("temp");
Path file = dir.resolve("hello.txt");
// 디렉토리 & 파일 생성
Files.createDirectories(dir);
Files.writeString(file, "Hello, NIO2!\n한글도 OK",
StandardCharsets.UTF_8);
// 읽기
String content = Files.readString(file, StandardCharsets.UTF_8);
System.out.println(content);
// 줄 단위 — Stream<String> (대용량에 적합)
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
lines.filter(l -> !l.isBlank())
.map(String::trim)
.forEach(System.out::println);
}
// 파일 복사, 이동, 삭제
Path copy = dir.resolve("copy.txt");
Files.copy(file, copy, StandardCopyOption.REPLACE_EXISTING);
Files.move(copy, dir.resolve("moved.txt"),
StandardCopyOption.REPLACE_EXISTING);
Files.deleteIfExists(dir.resolve("moved.txt"));
// 디렉토리 탐색
try (Stream<Path> paths = Files.walk(dir)) {
paths.filter(Files::isRegularFile)
.forEach(p -> System.out.println(p.getFileName()));
}
// 파일 속성
var attr = Files.readAttributes(file,
java.nio.file.attribute.BasicFileAttributes.class);
System.out.println("크기: " + attr.size() + "B");
System.out.println("생성: " + attr.creationTime());
// Path 조작
System.out.println(file.getFileName()); // hello.txt
System.out.println(file.getParent()); // temp
System.out.println(file.toAbsolutePath());
// 정리
Files.deleteIfExists(file);
Files.deleteIfExists(dir);
}
}Files.lines()는 Stream을 반환하므로 반드시 try-with-resources로 닫아야 합니다. 닫지 않으면 파일 핸들 누수가 발생합니다.
new File("path") 대신 Path.of("path")를 사용하세요. NIO2 Path는 더 많은 기능을 제공하고 플랫폼 독립적입니다.
02Jackson JSON 처리
Java 생태계의 표준 JSON 라이브러리 Jackson으로 직렬화/역직렬화를 마스터합니다.
Java code
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import com.fasterxml.jackson.annotation.*;
// Jackson 어노테이션
@JsonIgnoreProperties(ignoreUnknown = true)
record User(
@JsonProperty("user_id") int id,
@JsonProperty("full_name") String name,
@JsonIgnore String password,
@JsonInclude(JsonInclude.Include.NON_NULL) String email
) {}
// 상속 처리
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = Dog.class, name = "dog"),
@JsonSubTypes.Type(value = Cat.class, name = "cat")
})
abstract class Animal { public abstract String sound(); }
class Dog extends Animal { public String sound() { return "멍멍"; } }
class Cat extends Animal { public String sound() { return "야옹"; } }
public class JacksonDemo {
public static void main(String[] args) throws Exception {
var mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
// 직렬화
var user = new User(1, "Alice", "secret", "alice@test.com");
String json = mapper.writeValueAsString(user);
System.out.println(json);
// 역직렬화
String input = """
{"user_id": 2, "full_name": "Bob", "unknown_field": "ignored"}
""";
User decoded = mapper.readValue(input, User.class);
System.out.println(decoded.name()); // Bob
// 컬렉션
String listJson = "[1, 2, 3]";
var list = mapper.readValue(listJson,
mapper.getTypeFactory().constructCollectionType(
java.util.List.class, Integer.class));
// JsonNode — 동적 JSON
JsonNode root = mapper.readTree(json);
System.out.println(root.get("full_name").asText());
System.out.println(root.has("password")); // false
// ObjectNode 빌드
ObjectNode node = mapper.createObjectNode();
node.put("name", "Java");
node.put("version", 21);
node.putArray("features").add("Records").add("Sealed");
System.out.println(mapper.writeValueAsString(node));
}
}ObjectMapper는 스레드 안전하며 생성 비용이 큽니다. 싱글턴으로 관리하거나 Spring의 자동 주입을 사용하세요.
@JsonIgnoreProperties(ignoreUnknown = true) 없이 알 수 없는 필드가 포함된 JSON을 역직렬화하면 UnrecognizedPropertyException이 발생합니다.
03NIO.2 Path와 Files
java.nio.file API로 파일과 디렉토리를 현대적으로 다룹니다.
Java code
import java.nio.file.*;
import java.io.IOException;
import java.util.List;
public class NioPathFiles {
public static void main(String[] args) throws IOException {
// Path 생성
Path path = Path.of("src", "main", "java");
Path home = Path.of(System.getProperty("user.home"));
// Path 조작
System.out.println(path.getFileName()); // java
System.out.println(path.getParent()); // src/main
System.out.println(path.toAbsolutePath());
System.out.println(path.resolve("App.java")); // src/main/java/App.java
System.out.println(path.normalize());
// Files 유틸리티
Path tempFile = Files.createTempFile("test", ".txt");
Files.writeString(tempFile, "Hello NIO.2!");
String content = Files.readString(tempFile);
System.out.println(content);
// 한 줄씩 읽기
List<String> lines = Files.readAllLines(tempFile);
// 파일 정보
System.out.println("크기: " + Files.size(tempFile));
System.out.println("존재: " + Files.exists(tempFile));
System.out.println("읽기가능: " + Files.isReadable(tempFile));
// 디렉토리 순회
// try (var stream = Files.walk(Path.of("."))) {
// stream.filter(Files::isRegularFile)
// .forEach(System.out::println);
// }
Files.deleteIfExists(tempFile);
}
}Files.readString()과 Files.writeString()(Java 11+)은 작은 파일에 가장 간편합니다. 대용량은 BufferedReader를 사용하세요.
Files.walk()는 스트림을 반환하므로 반드시 try-with-resources로 닫아야 합니다. 안 닫으면 파일 핸들 누수가 발생합니다.
04BufferedReader/Writer 활용
버퍼링된 I/O로 파일 읽기/쓰기 성능을 최적화합니다.
Java code
import java.io.*;
import java.nio.file.*;
import java.nio.charset.StandardCharsets;
public class BufferedIO {
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("buffered", ".txt");
// BufferedWriter — 쓰기
try (BufferedWriter writer = Files.newBufferedWriter(
file, StandardCharsets.UTF_8)) {
for (int i = 1; i <= 5; i++) {
writer.write("라인 " + i);
writer.newLine(); // OS에 맞는 줄바꿈
}
}
// BufferedReader — 읽기
try (BufferedReader reader = Files.newBufferedReader(
file, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
// Stream으로 읽기 (대용량에 적합)
try (var lines = Files.lines(file, StandardCharsets.UTF_8)) {
long count = lines
.filter(l -> l.contains("라인"))
.count();
System.out.println("매칭 라인 수: " + count);
}
// PrintWriter — 편의 메서드
try (var pw = new PrintWriter(
Files.newBufferedWriter(file))) {
pw.println("printf 스타일");
pw.printf("이름: %s, 나이: %d%n", "홍길동", 30);
}
Files.deleteIfExists(file);
}
}Files.lines()은 지연(lazy) 스트림이라 대용량 파일도 메모리 효율적입니다. 전체를 메모리에 올리지 않습니다.
BufferedWriter.write()는 자동 줄바꿈을 하지 않습니다. newLine()을 직접 호출하거나 PrintWriter를 사용하세요.
05Charset과 인코딩
문자 인코딩 처리와 Charset API를 올바르게 사용합니다.
Java code
import java.nio.charset.*;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.util.Arrays;
public class CharsetDemo {
public static void main(String[] args) {
String text = "안녕하세요 Java!";
// 인코딩: String -> byte[]
byte[] utf8 = text.getBytes(StandardCharsets.UTF_8);
byte[] euckr = text.getBytes(Charset.forName("EUC-KR"));
System.out.println("UTF-8 바이트: " + utf8.length); // 19
System.out.println("EUC-KR 바이트: " + euckr.length); // 16
// 디코딩: byte[] -> String
String decoded = new String(utf8, StandardCharsets.UTF_8);
System.out.println(decoded);
// 잘못된 인코딩으로 디코딩
String broken = new String(utf8, StandardCharsets.ISO_8859_1);
System.out.println("깨진 문자: " + broken);
// CharsetEncoder/Decoder — 세밀한 제어
CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
// 사용 가능한 Charset 확인
System.out.println("기본 Charset: " + Charset.defaultCharset());
System.out.println("UTF-8 지원: " +
Charset.isSupported("UTF-8"));
}
}항상 StandardCharsets.UTF_8을 명시적으로 지정하세요. 시스템 기본 인코딩에 의존하면 환경마다 결과가 달라집니다.
"한글".getBytes()는 시스템 기본 인코딩을 사용합니다. Windows에서는 EUC-KR, Linux에서는 UTF-8이 될 수 있어 이식성이 떨어집니다.
06Serialization과 역직렬화
Java 직렬화의 동작 원리, 보안 위험, 대안을 알아봅니다.
Java code
import java.io.*;
public class SerializationDemo {
// Serializable 구현
static class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient String password; // 직렬화 제외
private int age;
User(String name, String password, int age) {
this.name = name;
this.password = password;
this.age = age;
}
@Override
public String toString() {
return String.format("User{name=%s, pw=%s, age=%d}",
name, password, age);
}
}
public static void main(String[] args) throws Exception {
User user = new User("홍길동", "secret123", 30);
// 직렬화
byte[] bytes;
try (var baos = new ByteArrayOutputStream();
var oos = new ObjectOutputStream(baos)) {
oos.writeObject(user);
bytes = baos.toByteArray();
}
System.out.println("직렬화 크기: " + bytes.length + " bytes");
// 역직렬화
try (var bais = new ByteArrayInputStream(bytes);
var ois = new ObjectInputStream(bais)) {
User restored = (User) ois.readObject();
System.out.println(restored);
// password는 null (transient)
}
// 권장: JSON 직렬화 (Jackson)를 대신 사용
// ObjectMapper mapper = new ObjectMapper();
// String json = mapper.writeValueAsString(user);
}
}Java 직렬화보다 JSON(Jackson, Gson)이나 Protocol Buffers를 사용하세요. 직렬화는 보안 취약점의 주요 원인입니다.
serialVersionUID를 선언하지 않으면 클래스 변경 시 역직렬화가 실패합니다. 항상 명시적으로 선언하세요.
07Jackson ObjectMapper
Jackson 라이브러리로 JSON 직렬화/역직렬화를 수행합니다.
Java code
// Jackson 의존성: com.fasterxml.jackson.core:jackson-databind:2.16+
// import com.fasterxml.jackson.databind.ObjectMapper;
// import com.fasterxml.jackson.databind.SerializationFeature;
// Jackson 사용법 (의사코드 — 실행 시 Jackson 의존성 필요)
class JacksonDemo {
record Product(String name, int price, boolean available) {}
public static void main(String[] args) throws Exception {
// ObjectMapper 생성 (재사용 권장)
// ObjectMapper mapper = new ObjectMapper()
// .enable(SerializationFeature.INDENT_OUTPUT);
// 직렬화: 객체 -> JSON
// Product product = new Product("노트북", 1500000, true);
// String json = mapper.writeValueAsString(product);
// {"name":"노트북","price":1500000,"available":true}
// 역직렬화: JSON -> 객체
// Product restored = mapper.readValue(json, Product.class);
// 리스트 역직렬화
// String jsonArray = "[{...}, {...}]";
// List<Product> list = mapper.readValue(jsonArray,
// new TypeReference<List<Product>>() {});
// 파일에서 읽기
// Product fromFile = mapper.readValue(new File("p.json"), Product.class);
// record는 Jackson 2.12+에서 지원
System.out.println("Jackson은 Java JSON 처리의 표준입니다.");
System.out.println("record, sealed, Optional 등 최신 기능을 지원합니다.");
}
}ObjectMapper는 스레드 안전하므로 싱글턴으로 재사용하세요. 매번 생성하면 성능이 떨어집니다.
제네릭 타입(List<Product>)을 역직렬화할 때 TypeReference를 사용하지 않으면 LinkedHashMap으로 역직렬화됩니다.
08JDBC 심화
JDBC로 데이터베이스를 안전하고 효율적으로 다루는 방법입니다.
Java code
import java.sql.*;
public class JDBCAdvanced {
public static void main(String[] args) {
String url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1";
try (Connection conn = DriverManager.getConnection(url);
Statement stmt = conn.createStatement()) {
// 테이블 생성
stmt.execute(
"CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50), email VARCHAR(100))");
// PreparedStatement — SQL 인젝션 방지
String sql = "INSERT INTO users (id, name, email) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, 1);
ps.setString(2, "홍길동");
ps.setString(3, "hong@test.com");
ps.executeUpdate();
// 배치 삽입
for (int i = 2; i <= 5; i++) {
ps.setInt(1, i);
ps.setString(2, "사용자" + i);
ps.setString(3, "user" + i + "@test.com");
ps.addBatch();
}
int[] counts = ps.executeBatch();
}
// 조회
try (ResultSet rs = stmt.executeQuery("SELECT * FROM users")) {
while (rs.next()) {
System.out.printf("ID:%d, 이름:%s, 이메일:%s%n",
rs.getInt("id"),
rs.getString("name"),
rs.getString("email"));
}
}
} catch (SQLException e) {
System.err.println("SQL 에러: " + e.getMessage());
}
}
}반드시 PreparedStatement를 사용하세요. 문자열 연결로 SQL을 만들면 SQL 인젝션 공격에 노출됩니다.
Connection, Statement, ResultSet을 닫지 않으면 커넥션 풀이 고갈됩니다. try-with-resources를 사용하세요.
09Connection Pool (HikariCP)
HikariCP를 사용한 커넥션 풀 관리와 설정 최적화입니다.
Java code
// HikariCP 의존성: com.zaxxer:HikariCP:5.1.0
// import com.zaxxer.hikari.HikariConfig;
// import com.zaxxer.hikari.HikariDataSource;
// HikariCP 설정 (의사코드)
class ConnectionPoolDemo {
public static void main(String[] args) {
// HikariConfig config = new HikariConfig();
// config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
// config.setUsername("user");
// config.setPassword("pass");
//
// // 풀 설정
// config.setMaximumPoolSize(10); // 최대 커넥션 수
// config.setMinimumIdle(5); // 최소 유휴 커넥션
// config.setIdleTimeout(300000); // 유휴 타임아웃 (5분)
// config.setConnectionTimeout(30000); // 커넥션 획득 타임아웃
// config.setMaxLifetime(1800000); // 최대 수명 (30분)
//
// // 성능 최적화
// config.addDataSourceProperty("cachePrepStmts", "true");
// config.addDataSourceProperty("prepStmtCacheSize", "250");
// config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
//
// HikariDataSource ds = new HikariDataSource(config);
//
// try (Connection conn = ds.getConnection()) {
// // 커넥션 사용
// }
//
// // 종료 시
// ds.close();
System.out.println("HikariCP 최적 풀 크기 공식:");
int cpuCores = Runtime.getRuntime().availableProcessors();
System.out.printf("connections = CPU코어(%d) * 2 + 디스크수(1) = %d%n",
cpuCores, cpuCores * 2 + 1);
}
}최적 풀 크기 공식: 커넥션 수 = CPU 코어 * 2 + 디스크 수. 대부분의 경우 10~20이면 충분합니다.
커넥션 풀 크기를 100으로 설정하면 오히려 성능이 떨어집니다. 데이터베이스 동시 연결 수에는 최적 값이 있습니다.
10HTTP Client (java.net.http)
Java 11+의 내장 HTTP 클라이언트로 REST API를 호출합니다.
Java code
import java.net.http.*;
import java.net.URI;
import java.time.Duration;
public class HttpClientDemo {
public static void main(String[] args) throws Exception {
// HttpClient 생성 (재사용 권장)
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// GET 요청
HttpRequest getReq = HttpRequest.newBuilder()
.uri(URI.create("https://httpbin.org/get"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response = client.send(
getReq, HttpResponse.BodyHandlers.ofString());
System.out.println("상태: " + response.statusCode());
System.out.println("본문: " + response.body().substring(0, 100));
// POST 요청
String json = "{\"name\":\"test\",\"value\":42}";
HttpRequest postReq = HttpRequest.newBuilder()
.uri(URI.create("https://httpbin.org/post"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
// 비동기 요청
client.sendAsync(postReq, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(body -> System.out.println("비동기: " + body.length()))
.join();
}
}HttpClient는 스레드 안전하고 HTTP/2를 지원합니다. sendAsync()로 비동기 요청을 보내면 CompletableFuture가 반환됩니다.
HttpClient 인스턴스를 매 요청마다 생성하면 커넥션 풀링 이점을 잃습니다. 싱글턴으로 재사용하세요.
11WebSocket 클라이언트
Java 11+의 WebSocket API로 실시간 양방향 통신을 구현합니다.
Java code
import java.net.http.*;
import java.net.URI;
import java.util.concurrent.*;
public class WebSocketDemo {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
CompletableFuture<Void> done = new CompletableFuture<>();
// WebSocket 연결
WebSocket ws = client.newWebSocketBuilder()
.buildAsync(
URI.create("wss://echo.websocket.events"),
new WebSocket.Listener() {
@Override
public void onOpen(WebSocket webSocket) {
System.out.println("연결됨!");
webSocket.request(1); // 메시지 요청
}
@Override
public CompletionStage<?> onText(WebSocket webSocket,
CharSequence data, boolean last) {
System.out.println("수신: " + data);
webSocket.request(1);
if (data.toString().contains("종료")) {
done.complete(null);
}
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket,
int statusCode, String reason) {
System.out.printf("닫힘: %d %s%n", statusCode, reason);
done.complete(null);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
System.err.println("에러: " + error.getMessage());
done.complete(null);
}
}).join();
// 메시지 전송
ws.sendText("Hello WebSocket!", true);
ws.sendText("종료", true);
done.get(10, TimeUnit.SECONDS);
ws.sendClose(WebSocket.NORMAL_CLOSURE, "정상 종료");
}
}request(1)로 배압을 제어합니다. 메시지를 처리한 후 다음 메시지를 요청하면 과부하를 방지할 수 있습니다.
WebSocket Listener의 메서드에서 예외가 발생하면 연결이 끊어집니다. 모든 콜백에서 예외 처리를 하세요.
12비동기 파일 I/O (AsynchronousFileChannel)
AsynchronousFileChannel로 논블로킹 파일 I/O를 수행합니다.
Java code
import java.nio.channels.*;
import java.nio.file.*;
import java.nio.ByteBuffer;
import java.util.concurrent.Future;
public class AsyncFileIO {
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("async", ".txt");
// 비동기 쓰기
try (var channel = AsynchronousFileChannel.open(file,
StandardOpenOption.WRITE)) {
byte[] data = "비동기 파일 I/O 테스트 데이터입니다.".getBytes();
ByteBuffer buffer = ByteBuffer.wrap(data);
// Future 기반
Future<Integer> writeFuture = channel.write(buffer, 0);
int bytesWritten = writeFuture.get(); // 완료 대기
System.out.println("쓴 바이트: " + bytesWritten);
}
// 비동기 읽기 — CompletionHandler 콜백
try (var channel = AsynchronousFileChannel.open(file,
StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer,
new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
buf.flip();
byte[] bytes = new byte[buf.remaining()];
buf.get(bytes);
System.out.println("읽은 내용: " + new String(bytes));
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
System.err.println("읽기 실패: " + exc.getMessage());
}
});
Thread.sleep(500); // 콜백 대기
}
Files.deleteIfExists(file);
}
}비동기 파일 I/O는 대용량 파일이나 다수의 파일을 동시에 처리할 때 유리합니다. 작은 파일은 동기 Files API가 더 간단합니다.
ByteBuffer의 flip()을 호출하지 않으면 읽기 모드로 전환되지 않아 빈 데이터를 읽습니다.
정리하며
- 새로 쓰는 코드는 java.io.File 대신 Path와 Files를 기본으로 두고 경계에서만 변환합니다
- 파일과 스트림을 열 때 Charset을 항상 명시해 런타임 기본값 의존을 없앱니다
- Files.lines()나 Files.list()가 준 스트림은 try-with-resources로 닫아 핸들 누수를 막습니다
- 신뢰할 수 없는 입력에 Java 직렬화를 쓰지 말고 Jackson 기반 포맷으로 대체합니다
더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.