[토스 NEXT] 2022년 안드로이드 코딩테스트 과제 풀이

SSY·2024년 5월 11일
0

Algorithm

목록 보기
5/6
post-thumbnail

시작하며

해당 문제는 Toss 공식 홈페이지에서 공개하고 있는 안드로이드 기출 문제의 풀이이다. 나 또한 풀어보았고, 모범답안과 비교한 글이다.

문제

토스의 자산관리팀 김토스는 토스 창립 월인 13년도 4월부터 현재인 22년도 8월 까지 등록된 자산을 자산번호에 따라 오름차순으로 정렬하고 싶어 합니다.
토스의 자산번호 규칙은 반드시 아래의 9자리 문자열 순서로 구성되어 있으며,

[등록 연도 2자리]-[취급 자산 코드][등록 월 2자리][등록 순서 번호 2자리]

1.등록 연도 > 2. 취급 자산 코드 > 3. 등록 월 > 4. 등록 순서 번호 순서대로 정렬 우선순위를 가지고 있습니다. 각 구성요소의 설명은 아래 표를 참고 부탁드립니다.

문제를 읽으며 든 생각

위 표에 나와있는 '구성'과 '유효성'은 관심사가 비슷해보인다. 따라서 Asset클래스의 유효성 검증 로직 작성 시, 두 부분을 혼합해서 작성해야할것 같은 생각이 들었고, 난 그렇게 작성했었다. 내 코드는 아래와 같다.

나의 풀이

@Test
fun hello3() {
    assertEquals(
        arrayOf("19-SP0404", "19-KE1204", "19-MO0794", "19-CO0404", "19-DE0401", "20-SP1102", "20-KE0511", "20-MO0901", "20-CO1299", "20-DE0815", "21-SP0404", "21-KE0704", "21-MO0794", "21-CO0404", "21-DE0401"),
        solution3(
            arrayOf("20-DE0815", "20-CO1299", "20-MO0901", "20-KE0511", "20-SP1102", "21-DE0401", "21-CO0404", "21-MO0794", "21-KE0704", "21-SP0404", "19-DE0401", "19-CO0404", "19-MO0794", "19-KE1204", "19-SP0404")
        )
    )
    assertEquals(
        arrayOf("13-DE0401", "14-DE0511", "17-CO0901", "19-KE1102", "19-MO1299", "20-SP0404", "20-CO0794"),
        solution3(
            arrayOf("2-MO0915", "19-MO1299", "17-CO0901", "14-DE0511", "19-KE1102", "13-DE0101", "20-SP0404", "20-CO0794")
        )
    )
    assertEquals(
        arrayOf("13-DE0401", "14-DE0511", "17-CO0901", "19-KE1102", "19-MO1299", "20-SP0404", "20-CO0794", "22-MO0815"),
        solution3(
            arrayOf("13-DE0401", "13-DE0401", "22-MO0815", "19-MO1299", "17-CO0901", "14-DE0511", "19-KE1102", "20-SP0404", "20-CO0794"),
        )
    )
}

fun solution3(assets: Array<String>): Array<String> {
    return assets
        .asSequence()
        .filter { runCatching { Asset.checkValid(it) }.getOrElse { false } }
        .map(Asset::convertAsset)
        .distinct()
        .sorted()
        .map(Asset::convertStr)
        .toList()
        .toTypedArray()
}
data class Asset(
    val year: Int,
    val code: Code,
    val month: Int,
    val order: Int,
): Comparable<Asset> {
    companion object {
        fun checkValid(assetStr: String): Boolean {
            return when {
                assetStr.length != 9 -> false
                assetStr.slice(0..1).toInt() !in 13..22 -> false
                assetStr.slice(2..2) != "-" -> false
                !Code.entries.map { it.name }.contains(assetStr.slice(3..4)) -> false
                assetStr.slice(5..6).toInt() !in 1..12 -> false
                assetStr.slice(7..8).toInt() !in 1..99 -> false
                else -> true
            }
        }
        fun convertAsset(str: String): Asset {
            return Asset(
                year = str.slice(0..1).toInt(),
                code = Code.valueOf(str.substring(3, 5)),
                month = str.slice(5..6).toInt(),
                order = str.slice(7..8).toInt(),
            )
        }
        fun convertStr(asset: Asset): String {
            val month = if (asset.month < 10) "0${asset.month}" else "${asset.month}"
            val order = if (asset.order < 10) "0${asset.order}" else "${asset.order}"
            return "${asset.year}-${asset.code}$month$order"
        }
    }
    enum class Code { SP, KE, MO, CO, DE, }
    override fun compareTo(other: Asset): Int {
        return compareValuesBy(this, other, Asset::year, Asset::code, Asset::month, Asset::order)
    }
}

내가 작성한 코드는, Mock데이터 테스트는 통과한다. 또한 모법 답안과 비교했을 때, 어찌저찌 비슷하기도 하다. 하지만 요구사항을 분석하고 구조화 하고 이를 코드에 깔끔하게 녹여내는 부분에선 모범답안이 더 깔끔하다 생각한다.

모범 답안

class Solution {

    fun solution(assets: Array<String>): Array<String> =
        assets.mapNotNull { asset -> runCatching { asset.toAssetResult() }.getOrNull() }
            .sorted()
            .filter(Asset::isValid)
            .map(Asset::text)
            .distinct()
            .toTypedArray()

}

object ValidationError : Throwable()


fun String.toAssetResult(): Asset =
    when {
        length != 9 -> throw ValidationError
        slice(0..1).toIntOrNull() == null -> throw ValidationError
        get(2) != '-' -> throw ValidationError
        runCatching { Asset.Type.valueOf(slice(3..4)) }.isFailure -> throw ValidationError
        slice(5..6).toIntOrNull() == null -> throw ValidationError
        slice(7..8).toIntOrNull() == null -> throw ValidationError
        else -> Asset(
            text = this,
            yy = slice(0..1).toInt(),
            type = Asset.Type.valueOf(slice(3..4)),
            mm = slice(5..6).toInt(),
            no = slice(7..8).toInt()
        )
    }

data class Asset(
    val text: String,
    val yy: Int,
    val type: Type,
    val mm: Int,
    val no: Int
) : Comparable<Asset> {

    enum class Type {
        SP, KE, MO, CO, DE
    }

    val isValid: Boolean
        get() {
            return (yy in 13..22) && (mm in 1..12) && when {
                yy == 13 && mm < 4 -> false
                yy == 22 && mm > 8 -> false
                else -> true
            } && (no in 1..99)
        }

    override fun compareTo(other: Asset): Int {
        return when {
            yy > other.yy -> 1
            yy < other.yy -> -1
            type.ordinal > other.type.ordinal -> 1
            type.ordinal < other.type.ordinal -> -1
            mm > other.mm -> 1
            mm < other.mm -> -1
            no > other.no -> 1
            no < other.no -> -1
            else -> 0
        }
    }

}

나의 생각

천천히 분석해보며 든 생각.

'아 역시 모범답안은 다르구나. 저 코드가 왜 모범답안이라 하는지 알겠다'

였다. 우선, 문제에서 주어진 표를 보자.

유효성 검증 로직의 관심사와 상당한 교집합이 있어 보이는 '구성'과 '유효성'탭을 모범답안에선 알맞게 분리해 놓았다. (toAssetResult()isValid()에서 나눔)

모범답안의 경우, toAssetResult 메서드를 정의할 때, String형식의 Asset파싱 시, 위 표의 '구성'부분의 요구사항만 딱 들어있는걸 확인할 수 있다.

또한 그 밑에 isValid메서드에서 4개의 조건문을 정의하고 있는데,

val isValid: Boolean
        get() {
            return (yy in 13..22) && (mm in 1..12) && when {
                yy == 13 && mm < 4 -> false
                yy == 22 && mm > 8 -> false
                else -> true
            } && (no in 1..99)
        }

이 또한 위 표의 '유효성' 요구사항만 딱 들어있는걸 확인할 수 있다. 즉, 과제 요구사항을 명확히 분석하고 이를 구조화해서 이를 코드에 잘 녹여낸 것이다.

[요구사항의 분석 및 코드에 잘 녹여낸 부분]

  • 구성 -> toAssetResult()
  • 유효성 -> isValid()

또한 예외처리도 깔끔했다. 입력되는 자산의 각 자리에 올바르지 않은 char가 들어올 수 있다. 각 자리는 반드시 int 또는 char여야 한다. 이때 숫자 입력만 허용하는 부분은 toIntOrNull()메서드를 사용하여 null을 반환하게 만든다. 그 후, 만약 null을 반환(=char가 껴있을 경우 등...)할 경우, ValidationError예외를 던진다. 더 나아가 이를 runCatching으로 래핑하고 getOrNull()메서드를 사용하여 예외가 발생했을 시, null을 최종적으로 반환하게 만든다.

assets.mapNotNull { asset -> runCatching { asset.toAssetResult() }.getOrNull() }

runCatching 또는 getOrNull로 반환된 값은 mapNotNull을 사용한 추가적인 래핑을 진행한다. 이때 반환값이 null일시, 해당 데이터를 반환을 막는다.

위의 코드를 거치게 되면 '구조'의 특성에 맞는 Asset객체가 내려오게 된다. 그 후, isValid()메서드를 거치게 된다.

val isValid: Boolean
        get() {
            return (yy in 13..22) && (mm in 1..12) && when {
                yy == 13 && mm < 4 -> false
                yy == 22 && mm > 8 -> false
                else -> true
            } && (no in 1..99)
        }

'유효성' 요구사항에 맞게 4가지의 조건이 알맞게 정의되어 있으며, 필터링을 진행한다.

즉, 형식에 맞지 않는 데이터는 null을 반환하게 만들고, 이때 exception을 던진다. 그 후, 이를 runCatching으로 래핑하고 에러 발생 시, null을 반환하게 만든다. 그 후, mapNotNull을 통해 null 반환을 중단하는 구조(=Asset형식에 안맞는 데이터 필터링)를 사용하여, 요구사항을 잘 분리했다고 볼 수 있다.

하지만, Comparable처리에 있어선 내 코드가 좀 더 깔끔하지 않나? 란 생각이 든다. 모범답안은 아래와 같다.

override fun compareTo(other: Asset): Int {
        return when {
            yy > other.yy -> 1
            yy < other.yy -> -1
            type.ordinal > other.type.ordinal -> 1
            type.ordinal < other.type.ordinal -> -1
            mm > other.mm -> 1
            mm < other.mm -> -1
            no > other.no -> 1
            no < other.no -> -1
            else -> 0
        }
    }

나의 답안은 아래와 같다.

override fun compareTo(other: Asset): Int {
    return compareValuesBy(this, other, Asset::year, Asset::code, Asset::month, Asset::order)
}

문제의 요구사항은 결국, [연도 -> 자산코드 -> 월 -> 자산번호]의 순으로 정렬하라는 것이다. 하지만 compareValuesBy를 사용하고, 바운드 연산자를 사용 및 객체 필드값을 적어줌으로써 정렬의 우선순위를 좀 더 가독성 좋게 보여줄 수 있다고 생각한다.

하지만 모범답안의 의도가 있을 수 있다. compareValuesBy를 사용하는데 있어 성능상 손실이 있기에 사용하지 않은걸까? 란 생각이 든다.

마치며

알고리즘이든, 과제든, 실무 프로젝트든, 문제를 풀 때 요구사항의 명확한 이해가 가장 중요하다. 그러기에 단순 한번만 슥~ 읽고 경미한 이해 수준에서 코드 작성을 시작하는 것은, 기능은 구현할 지언정 코드가 너저분해질 수 있다고 생각한다.

요구사항을 명확히 파악하는데 있어 시간이 좀 더 걸릴 수 있다. 그리고 코드 작성 시간이 그만큼 줄어들어, 로직 작성에 시간 할애를 못한다는 점이 걱정될 수 있다.

하지만 그럼에도 위 모범답안의 경우, 요구사항을 명확히 이해하였기에, 이를 코드에 잘 녹여냈다고 생각한다. (요구사항 관심사가 잘 녹여짐)

이를 통해 또 다시 느낀 점은, 요구사항을 받았을 때, 경미한 이해 수준에서 코드 작성을 시작하는게 아니라 2번, 3번 이상을 읽고 요구사항을 명확히 이해한 후, 로직 작성을 시작해야 한다는 것이다. 그럼에도 늦지 않을 것이라 생각한다.

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글