[CS] 프로그램이 동작해도 안심할 수 없는 이유

Kame·2025년 3월 9일

CS

목록 보기
3/3
post-thumbnail

들어가며

이 글을 끝까지 읽는다면, 아래 사항들을 이해할 수 있게 될 것입니다.

  • 논리 오류
  • 논리 오류 관련 Kotlin의 예외 처리 방식들

논리적인 오류일 때만 예외를 던진다.
논리적인 오류가 아니면 예외를 던지지 말고 null을 반환한다.
실패하는 경우가 복잡해서 null로 처리할 수 없으면 sealed class를 반환한다.

선행 지식

Java, Kotlin의 런타임 오류 관련 클래스 (Error와 Exception)


프로그래밍에서의 오류(Errors)

오류(Errors)
부정확하거나 유효하지 않은 동작(출처 : Wikipedia)

프로그래밍에서 오류는 시스템 레벨에서 심각한 문제가 발생한 상황을 의미합니다.

우리에게 익숙한 오류들로, 컴파일 에러와 런타임 에러가 있습니다.

  • 컴파일 에러 : 컴파일러가 판단하여 코드 실행 전에 감지하는 오류
    • 문법 오류, 타입 불일치, 선언되지 않은 변수 사용 등으로 발생
      fun main() {
         val number: Int = "Hello" // Type mismatch
      }
  • 런타임 에러 : 컴파일 단계에서 예측되지 않고 프로그램 실행 중 발생하는 오류
    • 잘못된 입력값, 배열 인덱스 초과, 메모리 부족 등으로 발생
      fun main() {
         val list = listOf(1, 2, 3)
         println(list[5]) // IndexOutOfBoundsException
      }

이 오류들의 특징은 개발자가 판단하지 않는다는 것입니다. 시스템이나 실행 환경이 오류를 감지합니다.

개발자가 앞서 살펴본 코드들이 오류가 아니라고 생각하더라도, 컴파일러 또는 실행 환경이 이를 오류로 판단하여 실행 자체를 막거나 중단시킵니다. 시스템이 프로그램에 데이터 손상 등의 악영향이 발생할 수 있다고 판단하기 때문입니다.

  • 판단 주체
    • 컴파일 에러 - 컴파일러
    • 런타임 에러 - 실행 환경

논리 오류(Logical Errors)

컴파일 에러와 런타임 에러 둘 중 어느 것도 아닌 오류가 있습니다.

바로 논리 오류입니다.
프로그램이 비정상적으로 동작하지만, 프로그램이 즉시 종료되지는 않는 문제를 의미합니다.

두 정수값의 평균을 구하는 함수를 다음과 같이 정의하고 활용해 보겠습니다.

fun main() {
	val average = averageOfTwoInts(6, 2)
	println(average) // expected : 4, actual : 7
}

fun averageOfTwoInts(a: Int, b: Int): Int {
	return a + b / 2 // Logical Error!
}

컴파일 에러, 런타임 에러가 발생하지 않고 프로그램 실행 자체는 원활히 이뤄졌습니다. 다만 이 함수는 통상적인 의미의 평균(4)을 구하겠다는 개발자의 의도와는 다른 결과(7)를 산출하였습니다. 이것이 바로 논리 오류가 발생한 상황인 것입니다.

논리 오류의 정의와 예시를 접한 현 시점에서, 실생활에서 프로그램을 사용하며 마주치는 논리 오류들을 쉽게 떠올릴 수 있을 것입니다. 대표적으로 게임에서 체력이 0이 되었는데 게임이 종료되지 않는 상황 역시 논리 오류로 생각해볼 수 있습니다.

이제 이것을 Kotlin에서의 예외 처리와 접목해보겠습니다.
논리 오류 관련 예외 처리 방법들을 다시 살펴보겠습니다.

논리적인 오류일 때만 예외(Java/Kotlin Exception)를 던진다.
논리적인 오류가 아니면 예외를 던지지 말고 null을 반환한다.
실패하는 경우가 복잡해서 null로 처리할 수 없으면 sealed class를 반환한다.

개인적으로 예외 처리에 논리 오류를 접목하여 생각해보니 다소 이해가 어려웠습니다. 특히 논리 오류를 이해하는 과정에서 아래와 같은 사항이 궁금했습니다. 이어질 내용에서 이 궁금증을 해결해보도록 하겠습니다.

  • 논리 오류어느 주체가 결정하는가?
  • 논리 오류예외를 던지는 이유는 무엇인가?
  • 판단 기준은 어떻게 가져가야 할까?
  • 논리 오류 대신 정상적인 흐름으로 간주하여 처리하는 방법은 무엇인가?

논리 오류 파헤치기

논리 오류의 결정 주체

앞서 컴파일 오류와 런타임 오류의 경우 시스템이나 실행 환경이 오류를 감지하고 결정한다고 설명하였습니다.

하지만 논리 오류는 그렇지 않습니다. 어떤 상황이 논리 오류인지의 여부는 다른 누구도 아닌, 코드를 작성하는 주체가 결정한다는 것을 이해해야 합니다.

논리 오류인지 아닌지 판단하는 것은 코드를 작성하는 개발자(의 팀)에 달려있다!

앞서 살펴보았던 평균 산출 코드를 다시 살펴보겠습니다.

fun main() {
	val average = averageOfTwoInts(6, 2)
	println(average) // expected : 7, actual : 7
}

fun averageOfTwoInts(a: Int, b: Int): Int {
	return a + b / 2 // Logical Error? Nope!
}

앞선 예시에서는 개발자가 통념에 의한 평균값을 의도했다고 가정하여, 해당 코드가 논리 오류를 가지고 있다고 정의하였습니다.

하지만, 이 판단은 개발자가 의도하는 바에 따라 달라질 수 있습니다. 예를 들어, 다른 개발자는 일반적인 평균 계산 방식인 (a + b) / 2를 사용하지 않고, 특별한 이유로 a + b / 2와 같이 정의할 수도 있습니다. 이 정의에 따르면, 해당 코드에는 논리 오류가 없는 것입니다.

지금까지의 설명을 토대로 생각해보면, 개발자는 자신이 작성한 프로그램의 특정 상황을 다음 둘 중 하나로 판단할 수 있을 것입니다. (컴파일 에러와 런타임 에러가 발생하지 않았다고 가정하겠습니다.)

  • 논리 오류(의도하지 않음, 염두에 두지 않음)
  • 정상적인 흐름(의도함, 염두에 둠)

동일한 코드라도 개발자가 정의한 규칙이나 목표에 맞게 작성되었다면 그것은 정상적인 코드이고, 그렇지 않다면 논리 오류가 있는 코드이다!

논리 오류에 예외를 던지는 이유

논리 오류로 판단되는 상황이 발생하면, 프로그램이 잘못된 상태로 진행되지 않도록 적절한 보호 장치가 필요합니다. 논리 오류도 오류의 일종인 만큼, 오류가 발생한 상태로 실행을 이어가면 더 큰 문제로 이어질 가능성이 있기 때문입니다.

이때 활용할 수 있는 것이 Java/Kotlin의 예외(Exception)입니다. 아래 예시에서, 잔고가 부족한 상황을 의도치 않은 상황으로 판단하여 예외를 던져 논리 오류가 발생할 가능성을 원천 봉쇄하였습니다. 이 코드를 통해, 다음과 같은 추측을 해볼 수 있습니다.

코드 작성자는 해당 프로그램에서 잔고 부족을 비정상적인 상황으로 간주하였다.
잔고 부족 상황 속에서 프로그램이 계속 실행된다면 이후에 심각한 시스템 장애가 발생할 가능성이 있다고 판단하였다.

class BankAccount(private var balance: Int) {
    fun withdraw(amount: Int) {
        if (amount > balance) {
            throw IllegalArgumentException("잔고 부족!")
        }
        balance -= amount
    }
}

fun main() {
    val account = BankAccount(1000)
    account.withdraw(1200) // 예외 발생: 잔고 부족!
    // 프로그램 강제 종료...
}

판단 기준

논리 오류(예외)를 남발하지 말자

논리 오류 발생은 최소화해야 합니다.

너무 많은 상황을 논리 오류로 간주하고 예외를 남발하면 가독성과 유지보수성이 떨어지는 문제가 발생할 수 있기 때문입니다. 예외 처리 구문이 쌓이게 되어 코드가 복잡해지고, 개발자 입장에서 나중에 버그를 추적하는 데 어려움을 겪을 수 있습니다.

특정 문제의 발생이 이후 프로그램과 시스템의 동작에 심각한 문제를 초래하는지 판단해 보면 좋지 않을까?

따라서 예외 사용의 목적을 고려하여, 정말 해당 상황이 논리 오류인지 적절히 판단하는 것이 필요합니다.

  • 비정상적인 상태에서 실행을 강제 종료하여 더 큰 문제를 예방하기 위함
  • 올바른 데이터 무결성을 보장하고, 잘못된 데이터가 전파되지 않도록 차단하기 위함
  • 문제를 빠르게 인지하고, 원인을 즉시 확인하기 위함

예시

앞선 BankAccount 예시에, 초기 금액을 제한하는 기능을 추가해보겠습니다.

require(balance >= 0) 이라는 초기 검증 로직을 추가하여 음수로 초기화되지 않도록 강제합니다. 예외가 발생하면 코드가 즉시 중단되므로, 예외 처리를 위한 추가적인 조치를 진행해주어야 합니다. 이를 위해 Kotlin에서는 보통 try-catch 혹은 runCatching을 사용합니다.

class BankAccount(private var balance: Int) {
    init {
        require(balance >= 0) { "마이너스 통장 아님!" }
    }
    
    fun withdraw(amount: Int) {
        if (amount > balance) {
            throw IllegalArgumentException("잔고 부족!")
        }
        balance -= amount
    }
}

fun main() {
    try {
		val account2 = BankAccount(-100) // 예외 발생 가능 #1
		account2.withdraw(110) // 예외 발생 가능 #2
    } catch (e: IllegalArgumentException) {
		e.printStackTrace()
    }
}

해당 코드에서는 초기에 잔액이 음수인 통장을 발급하고자 하는 시도 역시 논리 오류로 판단하고 있습니다.

지금까지는 비교적 단순한 구조를 가지고 있어, 이런 식으로 예외를 던지는 방식이 크게 문제가 되지 않을 수 있습니다. 그러나 향후 기능이 추가되면서 더 많은 상황을 논리 오류로 간주하고 예외를 던진다면, 유지보수성과 가독성 저하가 발생할 것입니다.

예를 들어, 현재 코드에서는 다른 원인으로 발생한 예외를 동일한 IllegalArgumentException으로 처리하고 있습니다.

  • 음수 잔액으로 계좌를 생성하려는 경우
  • 잔액보다 많은 금액을 출금하려는 경우

두 경우는 다른 원인으로 발생하지만, 같은 IllegalArgumentException을 던지고 같은 catch 블록에서 처리하고 있습니다. 이 상태에서 만약 예외 원인에 따라 다른 처리를 해주도록 변경한다면, 별도로 예외 타입을 추가해야 하므로 코드가 복잡해질 것입니다.

class NegativeBalanceException : IllegalArgumentException("초기 잔액이 음수일 수 없습니다!")
class InsufficientBalanceException : IllegalArgumentException("잔고 부족!")

class BankAccount(private var balance: Int) {
    init {
        if (balance < 0) throw NegativeBalanceException()
    }

    fun withdraw(amount: Int) {
        if (amount > balance) throw InsufficientBalanceException()
        balance -= amount
    }
}

fun main() {
    try {
        val account1 = BankAccount(-100)
    } catch (e: NegativeBalanceException) {
        println("초기 잔액 오류: ${e.message}")
    } catch (e: InsufficientBalanceException) {
        println("출금 오류: ${e.message}")
    }

    try {
        val account2 = BankAccount(100)
        account2.withdraw(150)
    } catch (e: NegativeBalanceException) {
        println("초기 잔액 오류: ${e.message}")
    } catch (e: InsufficientBalanceException) {
        println("출금 오류: ${e.message}")
    }
}

이렇듯 지나치게 많은 상황을 논리 오류로 간주하여 예외를 던지고 처리하는 방식에는 문제점이 있습니다. 따라서 예외를 남발하지 않고, 되도록 정상적인 흐름으로 간주하여 해결할 수 있는 방법을 고려하는 것이 좋을 것입니다.

🧐 특정 상황이 논리 오류인지의 여부를 적절히 판단할 수 있으려면, 작업하고 있는 도메인에 대한 깊은 이해와 많은 경험이 수반되어야 하지 않을까 싶습니다.

정상적인 흐름으로 간주하여 처리하기

null 반환하기

어떤 초기 금액으로 설정하든 모두 정상적인 흐름으로 간주한다면?

만약 BankAccount의 초기 금액이 음수라면, 계좌를 생성할 수 없다는 점을 null 반환으로 표현할 수 있습니다. null을 반환함으로써, 해당 계좌 객체가 유효하지 않음을 나타낼 수 있습니다. 예외를 사용하지 않고 프로그램 흐름을 정상적으로 이어갈 수 있으며, 예외 처리에 따른 코드 복잡성도 피할 수 있습니다.

class BankAccount private constructor(private var balance: Int) {
    fun withdraw(amount: Int) {
        if (amount > balance) {
            throw IllegalArgumentException("잔고 부족!")
        }
        balance -= amount
    }

    companion object {
        fun from(balance: Int): BankAccount? {
            if (balance < 0) return null
            return BankAccount(balance)
        }
    }
}

fun main() {
    val account = BankAccount.from(-100)
    if (account == null) {
        println("마이너스 통장 발급 불가능")
        return
    }
}

sealed class/sealed interface 반환하기

어떤 인출 금액을 요청하든 모두 정상적인 흐름으로 간주한다면?

실패하는 상황이 여러 가지 복잡한 경우로 분기될 수 있고, null을 반환하는 방식으로는 충분히 표현할 수 없을 때, sealed class 혹은 sealed interface를 사용하여 발생 가능한 상태를 명확하게 분리할 수 있습니다. 이 방법은 복잡한 실패 상황을 각 타입으로 명확하게 구분하며, 각 경우에 대해 구체적인 처리를 할 수 있게 해줍니다.

  • 이점
    • 각 상황에 대한 더 구체적인 정보를 제공(예 : 생성자 + 프로퍼티 활용)
    • 타입 시스템의 장점 활용 가능
class BankAccount private constructor(private var balance: Int) {
    fun withdraw(amount: Int): Result {
        if (amount == 0) return Result.None
        if (amount > balance) return Result.Lack
        balance -= amount
        return Result.Success(balance)
    }

    companion object {
        fun from(balance: Int): BankAccount? {
            if (balance < 0) return null
            return BankAccount(balance)
        }
    }
}

sealed interface Result {
    data class Success(val balance: Int) : Result
    data object Lack : Result
    data object None : Result
}

fun main() {
    val account = BankAccount.from(100)
    if (account == null) {
    	println("계좌 생성 실패: 음수 잔액은 허용되지 않습니다.")
        return
    }
  
    val result = account.withdraw(150)
    when (result) {
        is Result.Success -> println("출금 성공! 남은 잔액: ${result.balance}")
        is Result.Lack -> println("잔고 부족으로 출금 실패")
        is Result.None -> println("출금 금액은 0일 수 없습니다")
    }
}

마치며

세 줄 요약

어떤 상황을 논리 오류로 간주할지의 여부는 개발자의 판단에 달려 있다.
논리 오류로 판단될 때만 예외를 던지되, 예외를 남발하는 것은 유지보수를 어렵게 할 수 있으므로 주의한다.
논리 오류로 판단하는 대신, 정상적인 흐름으로 간주하여 null 혹은 sealed class를 반환할 수 있다.

참고 자료

https://en.wikipedia.org/wiki/Error
https://m.blog.naver.com/eludien/221448617790
https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%97%90%EB%9F%ACError-%EC%99%80-%EC%98%88%EC%99%B8-%ED%81%B4%EB%9E%98%EC%8A%A4Exception-%F0%9F%92%AF-%EC%B4%9D%EC%A0%95%EB%A6%AC
https://medium.com/@galcyurio/kotlin%EC%97%90%EC%84%9C%EC%9D%98-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC-%EB%B0%A9%EB%B2%95-48a5cd94a4e6
https://ko.wikipedia.org/wiki/%EC%98%88%EC%99%B8_%EC%B2%98%EB%A6%AC

profile
Software Engineer

0개의 댓글