[Java] 불필요한 객체 생성을 피하라 (아이템 6)

우노구나·2025년 8월 4일

조슈아 블로크의 Effective Java3의 6번째 아이템은 객체 재사용에 관한 내용을 다룬다.

똑같은 기능의 객체는 매번 생성하는 것 보다는 재사용하는 것이 더 나을 때가 많다. 하지만 알게 모르게 코드를 작성하다 불필요한 객체를 생성하는 경우가 많다.

예시로

String 문자열 생성

String s = new String("bikini"); // 불필요한 객체를 생성하는 코드
// s(스택) -> String 객체(힙) -> "bikini"(CSP)

String s = "bikini"	//개선된 버전
// s(스택) -> "bikini"(CSP)

이 두 줄의 코드가 있다.

알다시피 Java는 문자열 리터럴의 경우 JVMConstant String Pool(CSP)에 저장하여 이용한다. String 저장방식

첫번째 코드는 new로 인해 CSP에 저장된 문자열을 복사한 String 객체를 힙에 만들고 이 객체를 String s가 참조한다.

반면 두번째 코드는 String s가 중간관계자(?) 없이 바로 CSP에 있는 문자열을 참조하는 구조이다.

이 차이는 단순히 객체를 하나 더 생성하는 차이 뿐만 아니라, 반복하는 루프안에 이 코드가 있을 경우, 두번째 코드의 경우 CSP에 최초 한번만 객체를 생성하는 반면 첫번째 코드는 반복할 때마다 힙에 String객체를 생성하기 때문에 비효율성이 극대화된다.



정적 팩터리 메서드

아이템1에서 나온 내용인 정적 팩터리 메서드의 여러 장점중 하나는 불필요한 객체 생성을 피한다는 것이다.

public class Color {
    private final int r, g, b;
    private static final Color RED = new Color(255, 0, 0);

	//생성자
    private Color(int r, int g, int b) {
        this.r = r; this.g = g; this.b = b;
    }

	//정적 팩터리 메서드
    public static Color red() {
        return RED; // 항상 같은 객체 반환
    }
}

이처럼 Color를 선언한 경우

Color RED1 = new Color(255, 0, 0); //생성자를 사용하여 객체를 새로 생성

Color RED2 = Color.red();	//정적 팩터리 메서르를 사용하여 기존의 RED를 재사용

Color RED1은 일반적인 생성자를 사용하여 Color객체를 새로 생성해야 하지만
Color RED2는 정적 팩터리 메서드를 사용하여 기존에 정의한 RED를 재사용 하기 때문에 객체를 생성하지 않는다.

이처럼 Color같은 가변 객체도 사용중에 변경되지 않을 것임을 안다면 재사용할 수 있다.



정적 캐싱

생성비용이 아주 비싼 객체의 경우 반복해서 필요한 경우 캐싱을하여 재사용 하는게 좋다.

다음 코드는 주어진 문자열이 로마 숫자인지를 확인하는 메서드이다.

public class RomanNumerals {
    static boolean isRomanNumeral(String s) {
        return s.matches("^(?i)M{0,4}(CM|CD|D?C{0,3})"
                        + "(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$");
    }
}

보기에는 큰 문제가 없어 보이지만 s.matches(regex)는 내부적으로 다음과 같이 동작한다.

Pattern pattern = Pattern.compile(regex);  // 정규식 객체 생성
Matcher matcher = pattern.matcher(s);      // 문자열에 적용
return matcher.matches();                  // 결과 반환

이렇게 되면 isRomanNumeral()을 사용할 때마다 Pattern객체를 매번 생성하게 되는데, 이 Pattern객체가 비용자체가 커서 비효율적이다.

하지만 다음과 같이 코드를 개선하면

public class RomanNumerals {
    private static final Pattern ROMAN = Pattern.compile(
        "^(?i)M{0,4}(CM|CD|D?C{0,3})"
      + "(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$"
    );

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

Pattern 객체를 캐싱하여 한 번만 생성되고 재사용된다.

이처럼 보이지 않는 곳에서 객체가 생성될 수 있고,
그걸 정적 필드로 캐싱함으로써 성능을 크게 개선할 수 있다.



오토박싱

오토박싱은 기본 타입과 래퍼클래스를 섞어서 쓸 때 자동으로 상호 변환해주는 기술이다.

이 코드를 살펴보면

Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++) {
sum += i; //오토박싱 발생 내부적으로 sum = Long.valueOf(sum.longValue() + i);로 동작
}
System.out.println(sum);

sum += i;sum이 래퍼클래스인 Long이고 i는 기본 타입이기 때문에 내부적으로 sum = Long.valueOf(sum.longValue() + i);로 동작한다.
즉, 매 루프마다 새로운 Long 객체가 생성되기 때문에
Integer.MAX_VALUE(약 21억)개의 Long 객체가 만들어지는 비효율성이 생긴다.

이러한 코드는 아래와 같이 래퍼클래스를 기본 타입으로 바꾸면 해결이 된다.

long sum = 0L; // 기본 타입 사용
for (long i = 0; i <= Integer.MAX_VALUE; i++) {
    sum += i;
}
System.out.println(sum);

래퍼클래스 정리한 글에도 느꼈지만 래퍼클래스는 편리하지만, 성능이 중요한 코드에서는 조심해서 써야 한다. 특히 루프 속 숫자 누적, 비교, 연산은 기본 타입을 쓰는 게 훨씬 낫다.



어댑터 재사용

어댑터는 기존 객체의 기능을 새 인터페이스에 맞게 바꿔주는 객체로 다른 인터페이스를 흉내 내는 포장지 같은 역할을 한다.

MapkeySet()이나 entrySet() 같은 메서드가 반환하는 뷰(view)
매번 호출할 때마다 새로운 객체(어댑터)를 반환하는 것처럼 보이지만,
사실은 캐싱된 하나의 어댑터 객체를 재사용한다.

Map<String, String> map = new HashMap<>();
Set<String> keys1 = map.keySet();
Set<String> keys2 = map.keySet();

System.out.println(keys1 == keys2); // true

위의 코드처럼 map.keySet()을 두번 호출했지만 재사용을 하여 둘이 같은 객체임을 확인 할 수 있다.



주의사항

이 챕터의 주의사항으로 객체 생성은 비싸니 피해야 한다로 오해하면 안된다고 강조한다.

현재 JVM은 작은 객체를 생성하고 회수하는 일이 크게 부담되지 않는다. 따라서 프로그램의 명확성, 간결성, 기능을 위해서 객체를 추가로 생성하는 것은 일반적으로 좋은 일이다.

또한, 아주 무거운 객체가 아닌 이상 객체 생성을 피하고자 자체 객체 풀(객체 캐싱)을 만들지 말라고 강조한다.

요즘 JVMGC는 상당히 최적화되어서 가벼운 객체를 다룰 때 직접 만든 객체 풀보다 훨씬 빠르게 작동한다. 그래서 오히려 자체 객체 풀이 코드를 헷갈리게 만들고 성능을 떨어뜨리는 문제를 야기할 수 있다.



마무리

String 생성과 정적 팩터리 메서드, 오토박싱, 정적캐싱은 사실 전에 정리했던 내용이였는데, 다시금 내용을 상기시키고 이렇게 객체 재사용성이라는 주제로 모아서 정리를 하니 좀 더 이해를 잘 하게되는 계기가 된 것 같다.
또한, 어댑터는 이번에 처음 공부하는 개념이었는데, 이 내용은 추후에 따로 공부하여 정리해야할 것 같다.

profile
기술 블로그

0개의 댓글