Programming/Java

[Effective Java] item6 - 불필요한 객체 생성을 피하라.

VSFe 2021. 1. 28. 23:13
불필요한 객체 생성을 피하라!

앞에서도 여러번 이야기 했지만, 객체를 새로 만들바엔 이미 있는 객체를 재활용 하는 것이 성능이 훨씬 좋다. (정적 팩토리 메소드에서 다뤘듯이, 굳이 Boolean 생성자를 일일히 만들 바엔 valueOf를 써서 있는걸 그대로 리턴하는 것이 좋다.)

public long TestCaseOne() {
    long beforeTime;
    long afterTime;

    beforeTime = System.currentTimeMillis();
    for(int i = 0; i < 1000000; i++) {
        String s = new String("I'm always learning...");
        s.toUpperCase();
    }
    afterTime = System.currentTimeMillis();
    return afterTime - beforeTime;
}

public long TestCaseTwo() {
    long beforeTime;
    long afterTime;

    beforeTime = System.currentTimeMillis();

    for(int i = 0; i < 1000000; i++) {
        String s = "I'm always learning...";
        s.toUpperCase();
    }
    afterTime = System.currentTimeMillis();
    return afterTime - beforeTime;
}

제발 위는 하지 말자...

특히 생산 비용이 아주 비싼 객체도 있을 것이다. 문제는 우리는 어떤것이 비싸고, 싼지 확인할 방법이 없다.

가장 좋은 예시가 책에 있으니 이번엔 그 예시를 보도록 하자. 주어진 문자열이 유효한지 아닌지 확인하는 메소드를 만든다고 한다.

public class RomanNumerals {
    public static boolean isRomanNumeral(String s) {
        return s.matches("^(?=.)M*(C[MD]|D?C{0,3})"
                + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
    }
}

음... 내부에서 생성하는 Pattern 인스턴스는 Finite-State-Machine을 생성하기 때문에 생성 비용이 비싼데, 이걸 일회성으로 쓰고 버리기 때문에 문제가 있는 코드이다.

public class FixedRomanNumerals {
    private static final Pattern ROMAN = Pattern.compile
            ("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

    public static boolean isRomanNumeral(String s) {
        return ROMAN.matcher(s).matches();
    }
}

이런식으로 인스턴스를 재사용할 수 있게 수정하면, 성능 측면에서 큰 이득을 볼 수 있다.

실제로 테스트 코드를 작성해서 돌려보면 (10000번 반복 실행 기준) 전자는 52ms, 후자는 8ms 정도의 시간이 소요된다. 책에서 약 6.5배 정도 빨라졌다고 하는데, 내가 돌려봐도 6.5배가 나와서 쫌 신기했다.

다만 FixedRomanNumeral이 초기화 되었는데 ROMAN을 사용하는 메소드를 한 번도 호출하지 않았다면? 그럼 뭐 쓸데없이 초기화 된거지... 이런 경우에 뒤에서 배우는 지연 초기화 (Lazy Initialization)을 사용할 수도 있지만, 오히려 코드가 복잡해지기도 하고 성능이 그렇게 커지는 것도 아니라 안 쓰는게 낫다.

final이 붙으면 뭔가 재사용하기 딱 좋을 것 같다. 그치만 모든 객체가 인스턴스는 final이 아니기 때문에 조금 애매하다... 디자인 패턴 중 어댑터 패턴을 떠올려 보자. 서로 다른 인터페이스를 이어주기 위해 만든 객체이기 때문에, 많을 필요가 없다. 이런 경우엔 딱 하나만 쓰는게 의미 있다는 것이 보장된다.

책에서는 불필요한 객체를 만들어내는 또 다른 예시로 오토박싱 (auto boxing)을 들고 있다. 이것은 프로그래머가 기본 타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 변환해주는 기술이다.

오토박싱을 이해하기 위해선 래퍼 클래스 (Wrapper Class)에 대해 이해해야 한다. 우리가 흔히 쓰는 int, float, double, boolean... 같은 기본 타입 (Primitive Type)에 대해 떠올려보자. 사실 자바를 조금만 써봐도 알겠지만, Integer.parseInt() 같은 문법에선 우리가 일상적으로 쓰는 그런 타입을 쓰지 않는다. 기본 타입은 객체가 아니지만, 객체처럼 사용해야 하는 경우가 있는데, 이런 경우에 래퍼 클래스를 사용한다. (Integer, Float, Double, Boolean...)

문제는 래퍼 클래스는 산술 연산을 위해 정의 된 클래스가 아니라 저장된 값이 변경되지 않는다. 즉, 필요할 때 기본 타입의 데이터가 래퍼 클래스의 인스턴스로 바뀌고 또 반대로 되고 그런다. 각각 박싱, 언박싱이라고 부른다.

편의성 증대를 위해, 최근에는 Integer가 사용되어야 할 곳에 int가 들어오게 되면 자동으로 박싱을 해주고, 반대의 경우 자동으로 언박싱을 해주는데, 이를 오토박싱/오토언박싱이라고 한다. 문제는... 의도치 않게 오토박싱이 발생할 수 있다는 점이다.

Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++) {
    sum += i;
}

자... sum이 Long으로 선언되었기 때문에, 세번째 줄은

  • sum이 오토 언박싱 되어 long으로 변환
  • 언박싱 된 값과 i가 더해짐
  • 다시 해당 결과물을 Long으로 오토 박싱함.

이러니 효율성이 땅바닥을 때린다...

그러니 타입 체킹을 잘 해서 의도치 않은 오토박싱을 꼭 막도록 하자.

물론 현대의 JVM은 작은 객체를 생성하고 해제하는 것에 성능 저하가 잘 발생하지 않는다. 그러니 너무 객체을 생성하지 않으려고 하고 아끼려고 하진 말고, 정말 의도치 않게 필요없는 객체를 생성하는 것만 조심하면 될 것이다.