If 대신 Throw 를 이용하는 코드는 언제나 정당할까?

김성현·2023년 3월 6일
1
post-thumbnail

현대 CPU에서 try 블록 안의 코드는 Exception이 일어나기 전까지는 Zero Cost라고 한다.

즉 에러가 발생하기 전까지는 try 블록 안에서의 성능 하락이 아예 존재하지 않는다.

하지만 throw가 일어나면 에러 복구를 위해 수백에서 수천 사이클의 CPU 리소스가 낭비되는데 이때문에 throw는 자주 일어나면 성능에 불리할 수도 있다는 이야기가 존재한다.

다만 솔직히 이는 매우 자주 호출되는 경우에나 문제가 될 법한 요소기에 지금까지는 신경쓰지 않고 있었지만...
이러던 와중 우연히 한 코드를 보게 되었는데 문제가 된 코드의 의사코드는 대략 이러했다.

void test(String input){
	try{
    	int a = Integer.parseInt(input);
        // 정수일 경우에 대한 코드
    }catch(Exception e){
    	// 문자열일 경우의 코드
    }
}

java는 위와 같아 try 조건에 걸렸을 때와 안걸렸을 때의 로직이 분기되는, 즉 if 의 역활로 throw를 이용하는데 이런 코드들에 대한 의문이 들었다. 이는 정당한가?

따라서 이런 코드들에 대한 문제점에 대한 분석을 작성하고 이에 대한 결론을 내 나름대로 내 봤다.

블로그에 사용된 코드는 다음 깃허브 링크에 존재합니다.
https://github.com/iamGreedy/java-throw-if-perf

검증 절차

우선 throw로 조건을 검사하는 로직을 작성한다.

로직은 아래와 같다.

    public boolean checkWithThrow(String parameter) {
        try {
            int serverId = Integer.parseInt(parameter);
            if (serverId == 0)
                return false;
            return true;
        } catch (Exception e) {
            return true;
        }
    }

다음으로는 정규식으로 먼저 탐색한 다음 검사하는 로직을 작성한다.

    public static Pattern COMPLEX_NUMERIC = Pattern.compile("^(\\+|\\-)?[0-9]+$");
    public static Pattern SIMPLE_NUMERIC = Pattern.compile("^(\\+|\\-)?[0-9]+$");

    public boolean checkWithComplexRegex(String parameter) {
        if(COMPLEX_NUMERIC.matcher(parameter).matches()){
            int serverId = Integer.parseInt(parameter);
            if (serverId == 0)
                return false;
            return true;
        }else{
            return true;
        }
    }
    public boolean checkWithSimpleRegex(String parameter) {
        if(SIMPLE_NUMERIC.matcher(parameter).matches()){
            int serverId = Integer.parseInt(parameter);
            if (serverId == 0)
                return false;
            return true;
        }else{
            return true;
        }
    }

정규식은 양수 음수를 모두 판단 가능한 complex 버전과 부호가 없는 경우의 양수만 판단 가능한 simple 버전으로 검사했다.

이 모든 경우에서 각각의 함수가 100만번 실행되는데 걸리는 시간을 이용해 평균적인 퍼포먼스 측정과 throw 방식의 단점을 확인해 보고자 한다.

예상되는 결론

만약 parameter값으로 대부분의 경우 숫자로 된 문자열이 넘어온다면 성능은 다음과 같을 것이다.

  1. throw
  2. if simple
  3. if complex

하지만 만약 문자형으로 된 자료가 넘어온다면? 그때는 try/catch문법의 오버헤드에 의해 퍼포먼스에 대한 전반적 하락이 있을 수도 있다.

이 경우에 대한 속도 예상은 다음과 같다.

  1. if simple
  2. if complex
  3. throw

실험 결과

실험은 openjdk/jmh를 이용해 측정되었다.
실험에 사용된 코드는 다음과 같다.

코드 보러가기

Benchmark                            Mode  Cnt     Score    Error  Units
AppTest.checkWithComplexRegexNumber  avgt   10    47.000 ±  3.036  ms/op
AppTest.checkWithComplexRegexString  avgt   10    34.709 ±  2.050  ms/op
AppTest.checkWithSimpleRegexNumber   avgt   10    47.285 ±  3.678  ms/op
AppTest.checkWithSimpleRegexString   avgt   10    32.959 ±  0.309  ms/op
AppTest.checkWithThrowNumber         avgt   10    ≈ 10⁻⁶           ms/op
AppTest.checkWithThrowString         avgt   10  1385.552 ± 11.133  ms/op

원인 분석

직접 분석

우선 몇가지 이야기하면 예상과 큰 그림은 일치했지만 생각과는 다르게 정규식을 좀 더 복잡하게 쓴 정도로는 성능에 유의미한 영향이 거의 없었다는 것이다.

그리고 예상보다 throw를 안걸릴 때는 너무 빠르고 catch에 잡혔을 때는 너무 느리다는 것도 신경쓰였다.

결과를 보면 catch에 걸리면 30배 정도 느려지지만 걸리지 않으면 거의 제로타임이라 부를 정도로 빨랐다.

이런 것들을 보면 정규식이 아니라 좀 더 최적화된 조건 검사를 통하면 더 빠른 결과를 낼 수도 있을지도 모든다는 생각도 들고, 동시에 throw가 예상보다도 느리다는 실감이 들었다.

ChatGPT님의 고견

ChatGPT이 가라사대, 딱히 문제될 법한 건 아닌데 좀 더 느릴 수도 있다 하더라.

If the input to this method mostly consists of non-integer values, then there may be some performance implications.

만약 대다수의 입력이 정수형이 아니라면 약간의 성능에 영향이 있을 수도 있습니다.

The parseInt() method will throw a NumberFormatException if the input string cannot be parsed as an integer.

만약 parseInt() 메서드가 정수형 분석에 실패하면 NumberFormatException에러를 발생시킵니다.

If the input to this method mostly consists of non-integer values, then this method will spend a lot of time catching and handling NumberFormatException exceptions.

만약 비정수형 자료로 메서드를 호출하면 이 메서드는 많은 시간을 NumberFormatException를 처리하는데 할애하게 됩니다.

This could potentially slow down the method, especially if the input strings are very long.

이는 성능 하락의 가능성이 있으며, 특히 입력값이 매우 긴 경우에 더욱 그렇습니다.

In this case, it might be better to check if the input string can be parsed as an integer before actually parsing it.

이 경우 파싱 전 입력값을 사전 검증하는 것이 도움이 될 것입니다.

One way to do this would be to use a regular expression to check if the string consists only of digits. Here's an example:

이런 방법의 하나로, 정규식을 이용하는 것이 있습니다. 아래 예시가 있습니다.

public boolean checkWithThrow(String parameter) {
    if (!parameter.matches("\\d+")) {
        // Input string contains non-numeric characters
        return false;
    }

    int serverId = Integer.parseInt(parameter);
    if (serverId == 0)
        return false;
    return true;
}

This version of the code first checks if the input string contains only digits using the matches() method and a regular expression.

이 버전의 코드는 먼저 입력값이 오직 숫자로만 이뤄졌는지 matches메서드를 이용해 검증합니다.

If the input string contains non-numeric characters, it immediately returns false without trying to parse the string as an integer.

만약 숫자형 문자 이외의 문자가 사용된다면 메서드는 파싱을 하지 않고 즉시 false를 반환합니다.

This should be faster than parsing the string and catching NumberFormatException exceptions for every non-numeric input.

이는 모든 숫자가 아닌 형에 대해 NumberFormatException를 발생시키지 않음으로서 파싱을 더욱 빠르게 만듭니다.

결론

결론적으로, 만약 입력값이 대부분의 경우가 숫자형이 아닐 경우 이 코드는 잠재적인 성능 문제를 발생시킬 가능성이 있습니다.

물론 이 사례는 100만건당 1.4초 정도로 매우 작은 영향만이 있다는 사실은 중요합니다.

따라서 분명 throw는 성능을 낮추지만 만약 아무런 예외도 발생하지 않는 경우의 성능은 생각보다도 더 좋다라고 결론내리고 아래에 각각의 사례가 유효해지는 경우를 대충 생각해 보았습니다.

throw를 사용해 조건을 검사하는 것이 유효한 경우

  • 대부분의 입력값이 예외를 발생시키지 않는다는 것이 알려진 경우
  • 아주 가끔 호출되어 유의미한 호출 횟수를 가지지 않는 경우

throw를 이용하지 않는 것이 유리한 경우

  • 입력값이 예외를 발생시킬지, 아닐지를 예상하기 힘든 경우
  • 라이브러리 형태의 코드라 사용자가 어떤 값을 넣을지 예상하기 힘든 경우
  • 어떤 상황에서든 일관적인 성능 보장이 필요한 경우

한계

이 실험에서는 입력으로 "hello", "1"같은 매우 간단한 자료만 넣었습니다.
이는 더 복잡한 숫자가 들어왔을 때 어떤 영향이 있을지 예측하는 데에 한계가 있습니다.

다만 실험의 목적은 충분히 달성 가능하기에 이정도로 끝내도 지장은 없습니다.

입력값이 길어지면 성능 하락의 영향이 정규식 버전이 더 클지, throw 버전이 더 클지 확인해 보는 것도 재미있을 것 입니다.

또 위의 사례는 매우 간단한 컨텍스트를 가진 경우의 복구 사례입니다. 함수 호출이 깊거나, 상태 변수가 많거나, 다른 비동기 호출이 이 함수에 영향을 받을 수 있는 경우라면
성능은 현재보다 더 낮아질 가능성도 있습니다.

이런 사례에 대해 조사해보는 것도 좋을 것입니다.

profile
수준 높은 기술 포스트를 위해서 노력중...

0개의 댓글