Kotlin - Write 'Reading text from file' code efficiently

WindSekirun (wind.seo)·2022년 4월 26일
0

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2017-08-23

자바에서는 지금까지 파일의 텍스트를 읽으려면 아래와 비슷한 코드를 사용했어야 했었다.

StringBuilder text = new StringBuilder();
BufferedReader br = null;
try {
   File sdcard = Environment.getExternalStorageDirectory();
   File file = new File(sdcard, "testFile.txt");
   br = new BufferedReader(new FileReader(file));
   String line;
   while ((line = br.readLine()) != null) {
     text.append(line);
     text.append('\\n');
   }
} catch (IOException e) {
   e.printStackTrace();
} finally {
   if (br != null) {
      br.close();
   }
}

물론 JDK7부터 Try-with-resources statement 등으로 위와 같이 긴 코드를 작성하지 않아도 되지만, 코틀린에서는 이 코드를 어떻게 하면 효율적으로 작성할 수 있는지 정리하려고 한다.

1. 단순 변환

자바에서 코틀린으로 변환하는 기능 을 사용해보자.

val text = StringBuilder()
    var br: BufferedReader? = null
    try {
        val sdcard = Environment.getExternalStorageDirectory()
        val file = File(sdcard, "testFile.txt")
        br = BufferedReader(FileReader(file))
        var line: String
        while ((line = br.readLine()) != null) {
            text.append(line)
            text.append('\\n')
        }
    } catch (e: IOException) {
        e.printStackTrace()
    } finally {
        if (br != null) {
            br.close()
        }
    }

이대로 컴파일 하면 컴파일러가 'Assignment not allow in while expression' 라며 오류를 발생시킬텐데,

while (line != null) {
            text.append(line)
            text.append('\\n')
            line = br.readLine()
}

이런 식으로 while statement에는 line != null 로 체크하고, while의 최하단에는 다음 줄을 읽으면 된다.

2. 확장 메소드 사용

코틀린을 사용할 수 밖에 없는 막강한 기능중인 하나인 확장 메소드(Extension Methods)를 이용해보면 좀 더 줄일 수 있다.

한 줄 마다를 리스트의 원소(Elements) 로 생각하고, 다음 줄을 부를 때 마다 한 줄씩 할당해나가는 구조면 작동할 것 같다.

도식도로 표현하면 이런 식이다. (흔한 Iterator 방식이다.)

val BufferedReader.lines: Iterator<String>
    get() = object : Iterator<String> {
        var line = this@lines.readLine()
        override fun next(): String {
            val result = line
            line = this@lines.readLine()
            return result
        }
        override fun hasNext() = line != null
}

 val text = StringBuilder()
    var br: BufferedReader? = null
    try {
        val sdcard = Environment.getExternalStorageDirectory()
        val file = File(sdcard, "testFile.txt")
        br = BufferedReader(FileReader(file))
        for (line in br.lines) {
            text.append(line)
            text.append('\\n')
        }
    } catch (e: IOException) {
        e.printStackTrace()
    } finally {
        if (br != null) {
            br.close()
        }
    }

별로 줄어들지 않은 느낌이 든다. 정확히 말하면, 저 try-catch가 매우 신경쓰인다.

3. use 메소드 사용

그래서 코틀린의 표준 라이브러리인 stdlib 에는 closeable를 구현하고 있는 객체의 확장 메소드로 use 란 것을 제공한다.


/**
 * Executes the given [block] function on this resource and then closes it down correctly whether an exception
 * is thrown or not.
 * 이 리소스에 주어진 [block] 함수를 실행한 다음 예외가 발생하는지에 관계없이 올바르게 닫습니다.
 *
 * @param block a function to process this [Closeable] resource. [Closeable] 리소스로 수행할 동작 (Higher-Order Functions, 고차함수)
 * @return the result of [block] function invoked on this resource. 파라미터로 주어진 [block] 의 실행 결과
 */
@InlineOnly 
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R { // inline function 사용
    var closed = false
    try {
        return block(this) //  [block] 를 실행한다. 파라미터로는 T 
    } catch (e: Exception) { // 예외 발생시
        closed = true
        try {
            this?.close() // 일단 닫고
        } catch (closeException: Exception) {
        }
        throw e // 예외를 발생시킨다.
    } finally {
        if (!closed) { // 닫히지 않았으면,
            this?.close() // 닫는다.
        }
    }
}

try-catch 를 자동으로 처리해주는 것 만으로도, 코드가 확실히 줄어들 것 같다.

val text = StringBuilder()
val sdcard = Environment.getExternalStorageDirectory()
val file = File(sdcard, "testFile.txt")
val br = BufferedReader(FileReader(file))
br.use {
    for (line in it.lines) {
        text.append(it)
        text.append('\\n')
    }
}

그리고, 정말로 반 정도 줄어들었다. 라인 수로는 7줄 줄었지만,  Depth가 줄어든 것 만으로도 많이 깔끔해보인다. 그리고 이제 드디어 자바 같이 안 보인다.

그런데, 과연 이것이 최선일까?

4. Sequence 를 사용해보자.

Sequence란 반복이 허용되는 개체의 열거할 수 있는 모음집이다.

Kotlin의 Sequence 는 iterator 를 구현하고 있어, kotlin.sequences 패키지를 사용하여 조건에 맞는 개체를 필터할 수 있는 등 다양한 기능을 가지고 있다.

여기에서는 1회만 반복하는 것을 보증으로 하는 wrapper reference를 반환하는 constrainOnce 메소드를 사용하려고 한다.

private class Lines(private val reader: BufferedReader) : Sequence<String> { // Lines 클래스 선언
    override public fun iterator(): Iterator<String> { // Sequence는 iterator() 메소드를 구현해야 한다.
        return object : Iterator<String> { // 새 iterator 객체 리턴
            private var nextValue: String? = null

            override fun hasNext(): Boolean {
                nextValue = reader.readLine() // 다음 줄 로드
                return nextValue != null // nextValue가 null이 아닐 경우, 다음 줄이 있음
            }

            override fun next(): String {
                val answer = nextValue
                nextValue = null
                return answer!! // answer는 nextValue의 타입이 String?(Null-able String) 이기 때문에 체크가 필요함.
            }
        }
    }
}

쉽게 사용하기 위해서는 메소드 하나가 필요할 것 같다. 이것도 역시 확장 메소드로.

fun BufferedReader.linesWithSequence(): Sequence<String> = Lines(this).constrainOnce()

BufferedReader를 사용하였으니 사용 후에는 닫아야 하는데, use를 사용해서 한번 더 확장 메소드로 만든다.

inline fun <T> BufferedReader.useLines(block: (Sequence<String>) -> T): T = this.use { block(it.linesWithSequence()) }

남은 것은 한 줄 마다 useLines를 사용하는 것인데, Sequence 에는 forEach라는 확장 메소드가 있다.

/**
 * Performs the given [action] on each element.
 * 각 원소마다 [action] 을 실행함
 *
 * The operation is _terminal_.
 */
public inline fun <T> Sequence<T>.forEach(action: (T) -> Unit): Unit {
    for (element in this) action(element)
}

Sequence가 iterator를 구현하고 있기에 가능한 일이다.

Sequence.forEach를 사용해서 최종적으로 구현한 메소드와 코드는 아래와 같다.

fun BufferedReader.forEachLine(action: (String) -> Unit) : Unit = useLines { it.forEach(action) }

val text = StringBuilder()
val file = File(Environment.getExternalStorageDirectory(), "testFile.txt")
val br = BufferedReader(FileReader(file))
br.forEachLine {
        text.append(it)
        text.append('\\n')
}

실제 stdlib 에는 forEachLine가 Reader.forEachLine 로 존재하기 때문에, 위의 과정을 모두 거치지 않아도 쉽게 사용이 가능하다.

5. 조금 더 줄일 수 있지 않을까?

답은 YES이다.

일일히 StringBuilder 로 append 하는 것 보다는 List 로 받아 쓰는 것이 더 간편할 것이다.

합치는 것도 TextUtils.join("\n", list) 면 되니까, 다른 건 신경 안써도 된다.

fun File.readLines(): List<String> {
    val result = ArrayList<String>()
    BufferedReader(InputStreamReader(FileInputStream(this))).forEachLine({result.add(it)})
    return result
}

File를 FileInputStream 으로 만들고, 그걸 InputStreamReader로 읽어 최종적으로 BufferedReader로 만들어 각 줄마다 useLines를 작동시킨다.

결과적으로 반환된 리스트에는 한 줄씩 데이터가 들어가 있을 것이다.

6. 마무리

결과적으로 파일을 텍스트로 가져오기 위해 사용해야 하는 코드는 아래와 같다.

자바: 18 Lines

StringBuilder text = new StringBuilder();
BufferedReader br = null;
try {
   File sdcard = Environment.getExternalStorageDirectory();
   File file = new File(sdcard, "testFile.txt");
   br = new BufferedReader(new FileReader(file));
   String line;
   while ((line = br.readLine()) != null) {
     text.append(line);
     text.append('\\n');
   }
} catch (IOException e) {
   e.printStackTrace();
} finally {
   if (br != null) {
      br.close();
   }
}

코틀린: 1 Lines fun File.readText() : String = TextUtils.join("\n", readLines())

이정도만 해도, 코틀린의 장점이 많이 살아나지 않았을까 생각한다. 물론 이런 접근방법은 파일 읽기 뿐만 아닌 저장 등에도 활용할 수 있어서,  I/O와 관련된 행동을 신경쓰지 않아도 된다.

profile
Android Developer @kakaobank

0개의 댓글