PHpullh

KOTLIN · 심층 가이드

Kotlin 파일/IO 완전 정리

java.nio 위에 얹힌 Kotlin 확장 함수로 파일과 디렉터리를 다루고, kotlinx.serialization과 Ktor로 데이터 입출력까지 연결하는 12개 주제입니다.

주제 12개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Kotlin은 독자적인 I/O 스택을 만들지 않았습니다. java.io.Filejava.nio.file.Path 위에 확장 함수 층을 얹어 readText(), writeText(), useLines() 같은 짧은 호출로 바꿔 놓았을 뿐입니다. 그래서 코드는 스크립트 언어처럼 짧아지지만, 밑에서 도는 것은 여전히 JVM의 스트림과 채널입니다. 문자 인코딩, 버퍼 크기, 리소스 닫기 같은 문제는 그대로 남아 있고 오히려 짧은 문법에 가려지기 쉽습니다.

파일 읽기/쓰기 기초에서 확장 함수의 감을 잡고 디렉토리 탐색 (Directory Traversal)로 트리를 다루는 법까지 가면 로컬 파일 작업은 대체로 커버됩니다. 구조화된 데이터로 넘어가는 지점이 JSON 직렬화 (kotlinx.serialization)이고, 같은 data class를 네트워크 경계에서 재사용하는 흐름이 Ktor 클라이언트입니다. 바이트 단위 포맷을 직접 다뤄야 한다면 바이너리 I/O에서 엔디언과 정렬 문제를 따로 확인하세요.

readText()는 파일 전체를 메모리에 올립니다. 수백 MB짜리 로그에 쓰면 그대로 OutOfMemoryError입니다. 줄 단위 처리는 useLines { }를 쓰되, 그 블록 안에서 만든 시퀀스를 밖으로 반환하면 이미 닫힌 스트림을 읽게 되니 주의해야 합니다. 직렬화 쪽에서는 kotlinx.serialization이 리플렉션이 아니라 컴파일러 플러그인으로 동작한다는 점이 중요합니다. @Serializable이 없으면 런타임이 아니라 컴파일 시점에 막히고, 기본값과 같은 필드는 별도 설정 없이는 출력에서 생략됩니다.

01파일 읽기/쓰기 기초

Kotlin에서 파일을 읽고 쓰는 가장 간단한 방법. java.io.File 확장 함수를 활용합니다.

Kotlin code

<span class="kw">import</span> <span class="pk">java.io.File</span>

<span class="kw">fun</span> <span class="fn">main</span>() {
    <span class="kw">val</span> file = File(<span class="str">"hello.txt"</span>)

    <span class="cm">// 쓰기</span>
    file.<span class="fn">writeText</span>(<span class="str">"Hello, Kotlin!
한글도 OK"</span>, Charsets.UTF_8)

    <span class="cm">// 전체 읽기</span>
    <span class="kw">val</span> content = file.<span class="fn">readText</span>(Charsets.UTF_8)
    <span class="fn">println</span>(content)

    <span class="cm">// 줄 단위 읽기</span>
    file.<span class="fn">readLines</span>().<span class="fn">forEach</span> { <span class="fn">println</span>(it) }

    <span class="cm">// 줄 단위 처리 (대용량 파일)</span>
    file.<span class="fn">forEachLine</span> { line ->
        <span class="fn">println</span>(line.<span class="fn">trim</span>())
    }

    <span class="cm">// 추가 쓰기 (append)</span>
    file.<span class="fn">appendText</span>(<span class="str">"
추가 내용"</span>)

    <span class="cm">// 파일 존재 확인 후 삭제</span>
    <span class="kw">if</span> (file.<span class="fn">exists</span>()) file.<span class="fn">delete</span>()
}
알아두면 좋은 점

readText()는 파일 전체를 메모리에 올립니다. 대용량 파일은 forEachLine { }이나 BufferedReader로 스트리밍 처리하세요.

자주 하는 실수

파일 경로를 상대 경로로 쓰면 실행 위치에 따라 달라집니다. File(System.getProperty("user.home"), "file.txt")처럼 절대 경로를 사용하는 것이 안전합니다.

02kotlinx.serialization JSON

Kotlin 공식 직렬화 라이브러리로 data class를 JSON으로 변환합니다.

Kotlin code

<span class="kw">import</span> <span class="pk">kotlinx.serialization.*</span>
<span class="kw">import</span> <span class="pk">kotlinx.serialization.json.*</span>

<span class="an">@Serializable</span>
<span class="kw">data class</span> <span class="ty">User</span>(
    <span class="kw">val</span> id: <span class="ty">Int</span>,
    <span class="kw">val</span> name: <span class="ty">String</span>,
    <span class="an">@SerialName</span>(<span class="str">"email_address"</span>)
    <span class="kw">val</span> email: <span class="ty">String</span>,
    <span class="kw">val</span> active: <span class="ty">Boolean</span> = <span class="kw">true</span>
)

<span class="kw">fun</span> <span class="fn">main</span>() {
    <span class="kw">val</span> json = Json {
        prettyPrint = <span class="kw">true</span>
        ignoreUnknownKeys = <span class="kw">true</span>
        encodeDefaults = <span class="kw">false</span>
    }

    <span class="kw">val</span> user = User(<span class="num">1</span>, <span class="str">"Alice"</span>, <span class="str">"alice@test.com"</span>)

    <span class="cm">// 직렬화</span>
    <span class="kw">val</span> jsonStr = json.<span class="fn">encodeToString</span>(user)
    <span class="fn">println</span>(jsonStr)

    <span class="cm">// 역직렬화</span>
    <span class="kw">val</span> decoded = json.<span class="fn">decodeFromString</span>&lt;<span class="ty">User</span>&gt;(jsonStr)
    <span class="fn">println</span>(decoded.name)

    <span class="cm">// 리스트 직렬화</span>
    <span class="kw">val</span> users = listOf(user, user.copy(id = <span class="num">2</span>, name = <span class="str">"Bob"</span>))
    <span class="fn">println</span>(json.<span class="fn">encodeToString</span>(users))
}
알아두면 좋은 점

ignoreUnknownKeys = true를 설정하면 API 응답에 새 필드가 추가돼도 에러 없이 역직렬화됩니다. 실제 프로젝트에서는 기본으로 켜두는 것을 권장합니다.

자주 하는 실수

@Serializable을 빠뜨리면 SerializationException이 런타임에 발생합니다. KAPT/KSP 설정도 함께 필요합니다: plugins { kotlin("plugin.serialization") }

03파일 읽기/쓰기 심화

kotlin.io의 확장 함수로 파일 I/O를 간결하게 처리합니다. 대용량 파일의 효율적인 라인별 처리를 다룹니다.

Kotlin code

import java.io.File

fun main() {
    val file = File("test_output.txt")

    // 파일 쓰기
    file.writeText("첫 번째 줄
두 번째 줄
")
    file.appendText("세 번째 줄
")

    // 전체 읽기
    println("=== 전체 ===")
    println(file.readText())

    // 줄 단위 읽기
    println("=== 줄 단위 ===")
    file.readLines().forEachIndexed { i, line ->
        println("${i + 1}: $line")
    }

    // 대용량 파일: useLines (자동 스트림 닫기)
    file.useLines { lines ->
        val count = lines.filter { it.isNotBlank() }.count()
        println("비어있지 않은 줄: $count")
    }

    // bufferedReader 활용
    file.bufferedReader().use { reader ->
        val first = reader.readLine()
        println("첫 줄: $first")
    }

    // 임시 파일
    val temp = kotlin.io.path.createTempFile("prefix_", ".tmp")
    println("임시 파일: $temp")

    file.delete()
}
알아두면 좋은 점

useLines는 시퀀스로 처리하여 대용량 파일도 메모리 효율적으로 읽습니다. 블록이 끝나면 자동으로 스트림이 닫힙니다.

자주 하는 실수

readLines()는 전체 파일을 메모리에 올립니다. GB 단위 파일에는 useLinesbufferedReader를 사용하세요.

04디렉토리 탐색 (Directory Traversal)

File.walk()Path API로 디렉토리 트리를 효율적으로 탐색합니다.

Kotlin code

import java.io.File
import java.nio.file.*

fun main() {
    val dir = File(".")

    // walk: 재귀적 탐색
    println("=== .kt 파일 ===")
    dir.walk()
        .filter { it.isFile && it.extension == "kt" }
        .take(5)
        .forEach { println("  ${it.path} (${it.length()} bytes)") }

    // walkTopDown: 방향 지정 + 필터
    println("
=== 디렉토리 구조 ===")
    dir.walkTopDown()
        .maxDepth(2)
        .onEnter { !it.name.startsWith(".") }  // 숨김 폴더 제외
        .filter { it.isDirectory }
        .take(10)
        .forEach { println("  ${it.path}") }

    // NIO Path API
    println("
=== NIO 탐색 ===")
    val path = Paths.get(".")
    Files.walk(path, 2)
        .filter { Files.isRegularFile(it) }
        .limit(5)
        .forEach { println("  $it (${Files.size(it)} bytes)") }

    // 파일 정보
    dir.listFiles()?.filter { it.isFile }?.take(3)?.forEach { f ->
        println("${f.name}: ${f.lastModified()}")
    }
}
알아두면 좋은 점

onEnter 콜백에서 false를 반환하면 해당 디렉토리 하위를 건너뜁니다. node_modules 등을 제외할 때 유용합니다.

자주 하는 실수

walk()은 심볼릭 링크의 순환 참조를 감지하지 못할 수 있습니다. maxDepth를 설정하여 무한 탐색을 방지하세요.

05JSON 직렬화 (kotlinx.serialization)

kotlinx.serialization으로 타입 안전한 JSON 직렬화/역직렬화를 수행합니다.

Kotlin code

import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class User(
    val name: String,
    val age: Int,
    val email: String? = null,
    @SerialName("is_active")
    val isActive: Boolean = true
)

@Serializable
data class ApiResponse<T>(
    val status: String,
    val data: T,
    val timestamp: Long = System.currentTimeMillis()
)

fun main() {
    val json = Json {
        prettyPrint = true
        ignoreUnknownKeys = true
        encodeDefaults = false
    }

    // 직렬화
    val user = User("김철수", 30, "kim@test.com")
    val jsonStr = json.encodeToString(user)
    println(jsonStr)

    // 역직렬화
    val parsed = json.decodeFromString<User>("""
        {"name":"이영희","age":25,"is_active":false,"unknown":"ignored"}
    """.trimIndent())
    println(parsed)

    // 제네릭 타입
    val response = ApiResponse("ok", listOf(user))
    println(json.encodeToString(response))
}
알아두면 좋은 점

ignoreUnknownKeys = true로 설정하면 JSON에 추가 필드가 있어도 예외 없이 무시합니다. API 호환성에 유용합니다.

자주 하는 실수

@Serializable 어노테이션을 누락하면 런타임에 SerializationException이 발생합니다. data class에 반드시 추가하세요.

06XML 파싱

Java의 XML 라이브러리를 Kotlin에서 활용하여 XML 문서를 파싱하고 생성합니다.

Kotlin code

import javax.xml.parsers.DocumentBuilderFactory
import org.xml.sax.InputSource
import java.io.StringReader

fun main() {
    val xml = """
        <users>
            <user id="1">
                <name>김철수</name>
                <email>kim@test.com</email>
            </user>
            <user id="2">
                <name>이영희</name>
                <email>lee@test.com</email>
            </user>
        </users>
    """.trimIndent()

    // DOM 파싱
    val factory = DocumentBuilderFactory.newInstance()
    val builder = factory.newDocumentBuilder()
    val doc = builder.parse(InputSource(StringReader(xml)))

    val users = doc.getElementsByTagName("user")
    for (i in 0 until users.length) {
        val node = users.item(i)
        val id = node.attributes.getNamedItem("id").textContent
        val name = (node as org.w3c.dom.Element)
            .getElementsByTagName("name").item(0).textContent
        val email = node
            .getElementsByTagName("email").item(0).textContent
        println("[$id] $name ($email)")
    }
}
알아두면 좋은 점

간단한 XML에는 DOM이 적합하지만, 대용량 XML에는 SAX나 StAX 파서를 사용하면 메모리 효율적입니다.

자주 하는 실수

XML 파싱 시 외부 엔티티(XXE) 공격에 취약할 수 있습니다. factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)로 방지하세요.

07네트워크 I/O (기본)

Java의 HttpURLConnection과 Kotlin 확장으로 기본적인 HTTP 요청을 수행합니다.

Kotlin code

import java.net.HttpURLConnection
import java.net.URL

fun httpGet(urlStr: String): Result<String> = runCatching {
    val url = URL(urlStr)
    val conn = url.openConnection() as HttpURLConnection
    conn.apply {
        requestMethod = "GET"
        connectTimeout = 5000
        readTimeout = 5000
        setRequestProperty("Accept", "application/json")
    }

    try {
        if (conn.responseCode == 200) {
            conn.inputStream.bufferedReader().use { it.readText() }
        } else {
            throw RuntimeException("HTTP ${conn.responseCode}: ${conn.responseMessage}")
        }
    } finally {
        conn.disconnect()
    }
}

fun httpPost(urlStr: String, body: String): Result<String> = runCatching {
    val conn = URL(urlStr).openConnection() as HttpURLConnection
    conn.apply {
        requestMethod = "POST"
        doOutput = true
        setRequestProperty("Content-Type", "application/json")
    }
    conn.outputStream.use { it.write(body.toByteArray()) }
    conn.inputStream.bufferedReader().use { it.readText() }
}

fun main() {
    httpGet("https://httpbin.org/get").fold(
        onSuccess = { println("응답: ${it.take(200)}...") },
        onFailure = { println("오류: ${it.message}") }
    )
}
알아두면 좋은 점

use 블록으로 스트림을 감싸면 예외 발생 시에도 자동으로 닫힙니다. 리소스 누수를 방지하는 핵심 패턴입니다.

자주 하는 실수

disconnect()를 호출하지 않으면 커넥션이 풀에 반환되지 않아 리소스가 고갈될 수 있습니다. finally 블록에서 반드시 호출하세요.

08Ktor 클라이언트

Ktor HTTP 클라이언트로 비동기 네트워크 요청을 수행합니다. 코루틴 기반의 간결한 API를 제공합니다.

Kotlin code

// build.gradle.kts:
// implementation("io.ktor:ktor-client-core:2.3.7")
// implementation("io.ktor:ktor-client-cio:2.3.7")
// implementation("io.ktor:ktor-client-content-negotiation:2.3.7")

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.statement.*

suspend fun main() {
    val client = HttpClient(CIO) {
        engine {
            requestTimeout = 10_000
        }
    }

    client.use { c ->
        // GET 요청
        val response = c.get("https://httpbin.org/get") {
            headers {
                append("Accept", "application/json")
            }
        }
        println("상태: ${response.status}")
        println("응답: ${response.bodyAsText().take(200)}")

        // POST 요청
        val postResp = c.post("https://httpbin.org/post") {
            setBody("""{"name": "kotlin"}""")
            headers { append("Content-Type", "application/json") }
        }
        println("POST 상태: ${postResp.status}")
    }
}
알아두면 좋은 점

client.use { }로 감싸면 블록 종료 시 HTTP 클라이언트가 자동으로 닫힙니다. 엔진 리소스 누수를 방지합니다.

자주 하는 실수

Ktor 클라이언트를 요청마다 생성하면 성능이 저하됩니다. 앱 생명주기 동안 하나의 클라이언트를 재사용하세요.

09데이터베이스 연결 (JDBC)

Kotlin에서 JDBC를 활용하여 데이터베이스에 연결하고 쿼리를 수행합니다. use로 리소스를 안전하게 관리합니다.

Kotlin code

import java.sql.DriverManager
import java.sql.ResultSet

data class Product(val id: Int, val name: String, val price: Int)

fun <T> ResultSet.toList(mapper: (ResultSet) -> T): List<T> = buildList {
    while (next()) { add(mapper(this@toList)) }
}

fun main() {
    // H2 인메모리 DB 예시
    val url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"

    DriverManager.getConnection(url).use { conn ->
        // 테이블 생성
        conn.createStatement().use { stmt ->
            stmt.execute("""
                CREATE TABLE products (
                    id INT PRIMARY KEY AUTO_INCREMENT,
                    name VARCHAR(100),
                    price INT
                )
            """.trimIndent())
        }

        // 데이터 삽입 (PreparedStatement)
        conn.prepareStatement("INSERT INTO products (name, price) VALUES (?, ?)").use { ps ->
            listOf("노트북" to 1500000, "마우스" to 30000, "키보드" to 80000).forEach { (name, price) ->
                ps.setString(1, name)
                ps.setInt(2, price)
                ps.executeUpdate()
            }
        }

        // 조회
        conn.prepareStatement("SELECT * FROM products WHERE price > ?").use { ps ->
            ps.setInt(1, 50000)
            ps.executeQuery().use { rs ->
                val products = rs.toList { Product(it.getInt("id"), it.getString("name"), it.getInt("price")) }
                products.forEach { println(it) }
            }
        }
    }
}
알아두면 좋은 점

PreparedStatement를 사용하면 SQL 인젝션을 방지하고 쿼리 성능도 향상됩니다. 문자열 연결로 쿼리를 만들지 마세요.

자주 하는 실수

Connection, Statement, ResultSet을 닫지 않으면 커넥션 풀이 고갈됩니다. 항상 use 블록을 사용하세요.

10설정 파일 관리 (Properties)

Properties 파일과 환경 변수를 조합하여 애플리케이션 설정을 관리합니다.

Kotlin code

import java.util.Properties
import java.io.StringReader

class AppConfig {
    private val props = Properties()

    fun loadFromString(content: String) {
        props.load(StringReader(content))
    }

    fun get(key: String): String? =
        System.getenv(key.uppercase().replace(".", "_"))
            ?: props.getProperty(key)

    fun getOrDefault(key: String, default: String): String =
        get(key) ?: default

    fun getInt(key: String, default: Int): Int =
        get(key)?.toIntOrNull() ?: default

    fun getBoolean(key: String, default: Boolean): Boolean =
        get(key)?.toBooleanStrictOrNull() ?: default

    fun getAll(): Map<String, String> =
        props.entries.associate { it.key.toString() to it.value.toString() }
}

fun main() {
    val configContent = """
        app.name=MyKotlinApp
        app.port=8080
        app.debug=true
        db.url=jdbc:postgresql://localhost/mydb
        db.pool.size=10
    """.trimIndent()

    val config = AppConfig()
    config.loadFromString(configContent)

    println("앱: ${config.get("app.name")}")
    println("포트: ${config.getInt("app.port", 3000)}")
    println("디버그: ${config.getBoolean("app.debug", false)}")
    println("DB: ${config.get("db.url")}")
    println("전체: ${config.getAll()}")
}
알아두면 좋은 점

환경 변수를 Properties보다 우선시하면 배포 환경별 설정을 코드 변경 없이 적용할 수 있습니다(12-Factor App 원칙).

자주 하는 실수

설정 파일에 비밀번호나 API 키를 직접 넣지 마세요. 환경 변수나 시크릿 매니저를 사용하고, .gitignore에 설정 파일을 추가하세요.

11로그 파일 분석

시퀀스를 활용하여 대용량 로그 파일을 메모리 효율적으로 분석합니다.

Kotlin code

import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

data class LogEntry(
    val timestamp: String,
    val level: String,
    val message: String
)

fun parseLogLine(line: String): LogEntry? {
    val regex = Regex("""^[(d{4}-d{2}-d{2} d{2}:d{2}:d{2})][(w+)]s(.+)$""")
    return regex.matchEntire(line)?.let {
        val (ts, level, msg) = it.destructured
        LogEntry(ts, level, msg)
    }
}

fun analyzeLog(lines: Sequence<String>) {
    val entries = lines.mapNotNull { parseLogLine(it) }
    val buffer = mutableListOf<LogEntry>()
    entries.forEach { buffer.add(it) }

    // 레벨별 통계
    val byLevel = buffer.groupBy { it.level }
    println("=== 로그 레벨 통계 ===")
    byLevel.forEach { (level, logs) ->
        println("  $level: ${logs.size}건")
    }

    // 에러 메시지 Top 5
    println("
=== ERROR 메시지 ===")
    buffer.filter { it.level == "ERROR" }
        .groupBy { it.message.take(50) }
        .entries.sortedByDescending { it.value.size }
        .take(5)
        .forEach { println("  ${it.value.size}건: ${it.key}") }
}

fun main() {
    // 시뮬레이션 데이터
    val logData = listOf(
        "[2024-01-15 10:00:00][INFO] 서버 시작",
        "[2024-01-15 10:00:01][DEBUG] 설정 로드",
        "[2024-01-15 10:00:05][ERROR] DB 연결 실패",
        "[2024-01-15 10:00:10][ERROR] DB 연결 실패",
        "[2024-01-15 10:00:15][WARN] 메모리 부족",
    )
    analyzeLog(logData.asSequence())
}
알아두면 좋은 점

실제 파일 분석 시 File("log.txt").useLines { analyzeLog(it) }로 호출하면 대용량 파일도 스트리밍 처리됩니다.

자주 하는 실수

시퀀스를 두 번 순회하면 두 번째 순회가 실패할 수 있습니다. 여러 집계가 필요하면 리스트로 변환하거나 한 번의 순회로 모든 통계를 수집하세요.

12바이너리 I/O

바이너리 파일을 읽고 쓰는 방법입니다. DataInputStream/DataOutputStream으로 기본형을 직접 처리합니다.

Kotlin code

import java.io.*

data class Record(val id: Int, val score: Double, val name: String)

fun writeRecords(file: File, records: List<Record>) {
    DataOutputStream(BufferedOutputStream(FileOutputStream(file))).use { out ->
        out.writeInt(records.size)
        for (r in records) {
            out.writeInt(r.id)
            out.writeDouble(r.score)
            out.writeUTF(r.name)
        }
    }
}

fun readRecords(file: File): List<Record> {
    return DataInputStream(BufferedInputStream(FileInputStream(file))).use { input ->
        val count = input.readInt()
        List(count) {
            Record(
                id = input.readInt(),
                score = input.readDouble(),
                name = input.readUTF()
            )
        }
    }
}

fun main() {
    val file = File("data.bin")
    val records = listOf(
        Record(1, 95.5, "김철수"),
        Record(2, 87.3, "이영희"),
        Record(3, 92.1, "박지성"),
    )

    writeRecords(file, records)
    println("파일 크기: ${file.length()} bytes")

    val loaded = readRecords(file)
    loaded.forEach { println(it) }

    // 바이트 배열 직접 조작
    val bytes = file.readBytes()
    println("바이트 수: ${bytes.size}")
    println("헤더(레코드 수): ${bytes.take(4).fold(0) { acc, b -> (acc shl 8) or (b.toInt() and 0xFF) }}")

    file.delete()
}
알아두면 좋은 점

BufferedInputStream/BufferedOutputStream으로 감싸면 시스템 콜 횟수가 줄어 I/O 성능이 크게 향상됩니다.

자주 하는 실수

바이너리 파일의 읽기/쓰기 순서가 일치하지 않으면 데이터가 손상됩니다. 스키마 문서화나 매직 넘버로 형식을 검증하세요.

정리하며

  • 대용량 파일은 readText() 대신 useLines로 스트리밍 처리합니다
  • useLines가 만든 시퀀스는 블록 밖으로 내보내면 닫힌 스트림을 읽게 됩니다
  • File.walk 순회는 심볼릭 링크 순환과 접근 권한 예외를 함께 처리해야 합니다
  • kotlinx.serialization은 컴파일러 플러그인 기반이라 @Serializable 누락은 컴파일 오류입니다

더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.