Kotlin

[Kotlin] 입출력 방식 비교와 코딩 테스트 빠른 입출력 최적화 방법

yujinius 2024. 12. 26. 15:45


프로그래머스 기초 코딩 테스트 문제 중 문자열 출력하기 문제를 풀게 되면서 입출력의 시간 복잡도를 생각하게 되었다. 코딩 테스트 환경에서 입출력 시간을 단축하는 것이 성능 최적화에 필요하다고 생각해서 Kotlin의 다양한 입출력 방식을 조사하고 정리하게 되었다.


학습 목표

Kotlin의 다양한 입출력 방식(readLine, readln, print, println, System.in.bufferedReader, BufferedWriter)을 비교하고 내부 구현을 분석하여 성능 차이를 이해한다. 특히 코딩 테스트에서 빠른 입출력을 요구하는 경우, BufferedReader와 BufferedWriter를 사용하는 이유와 방법을 함께 정리한다.


▼ 참고 코드: https://github.com/JetBrains/kotlin/blob/rrr/2.1.0/core-docs/libraries/stdlib/jvm/src/kotlin/io/Console.kt#L170

1. 표준 출력 함수 분석

1-1. print와 println

print와 println은 Kotlin 표준 라이브러리에서 제공하는 출력 함수이다. 내부적으로 Java의 System.out 객체를 호출하여 동작한다.

public actual inline fun print(message: Any?) {
    System.out.print(message)
}

public actual inline fun println(message: Any?) {
    System.out.println(message)
}

public actual inline fun println() {
    System.out.println()
}

특징:

  1. print는 주어진 메시지를 출력하지만 개행하지 않는다.
  2. println은 메시지 출력 후 개행을 포함한다.
  3. 단순한 출력 작업에 적합하나, 반복적인 호출 시 비효율적일 수 있다.

2. 표준 입력 함수 분석

2-1. readLine

readLine은 표준 입력 스트림(System.in)에서 한 줄을 읽어 반환한다. 내부적으로 Java의 BufferedReader를 생성하여 동작하며, 한 줄씩 입력을 처리한다.

public fun readLine(): String? = LineReader.readLine(System.`in`, Charset.defaultCharset())

특징:

  1. 데이터를 한 줄씩 읽어 처리한다.
  2. EOF(End of File)에 도달하면 null을 반환한다.
  3. 내부적으로 32바이트 크기의 버퍼를 사용한다.
  4. 간단한 입력 작업에는 적합하나, 대량 데이터 처리에는 비효율적이다.
// Singleton object lazy initializes on the first use, internal for tests
internal object LineReader {
    private const val BUFFER_SIZE: Int = 32
    private lateinit var decoder: CharsetDecoder
    private var directEOL = false
    private val bytes = ByteArray(BUFFER_SIZE)
    private val chars = CharArray(BUFFER_SIZE)
    private val byteBuf: ByteBuffer = ByteBuffer.wrap(bytes)
    private val charBuf: CharBuffer = CharBuffer.wrap(chars)
    private val sb = StringBuilder()
    ...
    (생략, 위의 링크 참고)

 

LineReader 내부 구현: LineReader는 입력 데이터를 읽고 디코딩하는 핵심 클래스이다. 이 클래스는 다음과 같은 방식으로 동작한다:

  1. System.in에서 바이트를 읽어 ByteBuffer에 저장한다.
  2. 읽은 데이터를 CharsetDecoder를 사용해 CharBuffer로 변환한다.
  3. 변환된 문자열 데이터를 반환하거나, EOF를 만나면 null을 반환한다.
  4. 내부적으로 32바이트 크기의 버퍼를 사용하며, 데이터를 효율적으로 처리하기 위해 문자열 빌더를 활용한다.

2-2. readln과 readlnOrNull

readln은 readLine과 유사하지만, EOF 시 예외를 던진다.

public actual fun readln(): String = readlnOrNull() ?: throw ReadAfterEOFException("EOF has already been reached")

public actual fun readlnOrNull(): String? = readLine()

특징:

  1. readlnOrNull은 EOF에 도달하면 null을 반환한다.
  2. readln은 null을 허용하지 않는 입력으로 null 허용하는 입력이 주로 없는 알고리즘 풀이에 편리하게 사용할 수 있다. EOF 시에는 예외를 던진다.

▼ 참고 코드: https://github.com/JetBrains/kotlin/blob/master/libraries/stdlib/jvm/src/kotlin/io/IOStreams.kt

3. System.in.bufferedReader() 분석

System.in.bufferedReader()는 Java의 BufferedReader를 직접 사용하는 방식으로, 표준 입력을 처리한다.

fun InputStream.bufferedReader(charset: Charset = Charsets.UTF_8): BufferedReader {
    return BufferedReader(InputStreamReader(this, charset))
}

  1. InputStreamReader:
    • 바이트 스트림(System.in)을 문자 스트림으로 변환한다.
    • 시스템 기본 문자셋(예: UTF-8)을 사용해 변환한다.
  2. BufferedReader:
    • 문자 스트림에 버퍼를 추가하여 효율적으로 입력을 처리한다.
    • 기본 버퍼 크기는 8KB이며, 필요에 따라 크기를 지정할 수 있다.

4. 성능 비교

방식 처리 방식  버퍼 크기  성능 사용 용도
readLine 한 줄씩 처리 32B 내부 사용 반복 호출 시 성능 저하 간단한 입력 작업
System.in.bufferedReader 버퍼에 모아 한 번에 처리 기본 8KB, 조정 가능 대량 데이터 처리에 최적화 대량 입력 작업
println 즉시 출력 후 플러시 없음 반복 출력 시 성능 저하 간단한 출력 작업
BufferedWriter 버퍼에 모아 한 번에 출력 기본 8KB, 조정 가능 대량 출력 작업에 최적화 반복적인 출력 작업

5. 버퍼링의 원리와 코딩 테스트 최적화

5-1. 버퍼링이란 무엇인가?

버퍼링(buffering)은 데이터를 한 번에 처리하지 않고, 임시 메모리 공간(버퍼)에 저장해 두었다가 일정량이 쌓이면 처리하거나 출력하는 기법이다.

원리:

  1. 데이터를 읽거나 쓸 때, 작은 단위로 I/O 작업을 수행하면 매번 시스템 호출이 발생해 시간이 소요된다.
  2. 버퍼를 사용하면 데이터를 일정 크기만큼 메모리에 저장한 후, 한 번에 처리하여 시스템 호출 횟수를 줄일 수 있다.
  3. 특히 대량 데이터를 다룰 때 I/O 작업의 빈도를 크게 줄여 성능이 향상된다.

5-2. 코딩 테스트에서 BufferedReader와 BufferedWriter 사용 권장 이유

입력 처리:

  1. 코딩 테스트는 대량의 입력 데이터를 다루는 경우가 많아 BufferedReader를 사용하는 것이 효율적이다.
  2. 데이터를 한 번에 읽어 반복 호출을 줄이므로 시간 복잡도가 개선된다.

출력 처리:

  1. println처럼 출력 후 즉시 flush가 발생하는 방식은 반복 호출 시 비효율적이다.
  2. BufferedWriter는 데이터를 버퍼에 저장한 후 한 번에 출력하므로 성능이 향상된다.

6. 코딩 테스트에서의 속도 차이 실험

fun main(args: Array<String>) {
    val startTime = System.currentTimeMillis() // 시작 시간 기록

    val str = readLine()!!
    println(str)

    val endTime = System.currentTimeMillis() // 종료 시간 기록
    println("Execution time: ${endTime - startTime} ms")
    
}

HelloWorld!

Execution time: 3 ms

fun main(args: Array<String>) {
    val startTime2 = System.currentTimeMillis() // 시작 시간 기록
    val br = System.`in`.bufferedReader()
    val bw = System.`out`.bufferedWriter()
    bw.write(br.readLine())
    val endTime2 = System.currentTimeMillis() // 종료 시간 기록
    println("Execution time: ${endTime2 - startTime2} ms")
    br.close()
    bw.close()
}

Execution time: 1 ms

HelloWorld!


7. 결론

  1. 버퍼링의 장점:
    • 데이터를 임시로 저장해 I/O 작업을 최소화하고 성능을 향상시킨다.
    • 반복 작업이 많은 코딩 테스트 환경에서 효율적이다.
  2. 코딩 테스트 권장 방식:
    • 입력: BufferedReader를 사용해 데이터를 한 번에 읽는다.
    • 출력: BufferedWriter를 사용해 데이터를 버퍼에 저장한 후 한 번에 출력한다.
  3. 최적화된 코드 작성:
    • 간단한 문제에서는 readLine과 println을 사용할 수 있지만, 대량 데이터를 다루는 문제에서는 BufferedReader와 BufferedWriter를 사용하는 것이 효율적이다.

이를 통해 코딩 테스트에서 시간 복잡도를 개선하고 효율적으로 문제를 해결할 수 있었다.