Java에서 유틸리티 클래스(Utility Class)는 static 메서드만 제공하는 클래스를 의미합니다. 따라서 일반적으로 인스턴스를 생성하지 않습니다.

회사에서 사용할 라이브러리를 개발하면서 유틸리티 클래스에 private 생성자를 별도로 정의하지 않았습니다. 어차피 인스턴스를 생성할 필요가 없으니 생성자도 정의할 필요가 없다고 생각했기 때문입니다.

Java 로고

하지만 SonarQube로 소스코드를 정적 분석하는 과정에서 잘못된 생각이었다는 사실을 확인할 수 있었습니다. 유틸리티 클래스에는 private 생성자를 명시적으로 정의해야 한다는 권고가 다수 존재했기 때문입니다.

필요도 없는 private 생성자를 왜 정의하라는 것이지?라는 궁금증이 생겨서 어떤 내용인지 알아보게 되었습니다.


1. Java에서 생성자를 정의하지 않으면 어떻게 될까?

대부분의 Java 코딩 컨벤션에서는 유틸리티 클래스에 private 생성자를 정의하도록 권장합니다. 클래스에 생성자를 명시적으로 정의하지 않으면 Java 컴파일러가 기본 생성자를 자동으로 생성하기 때문입니다.

public class SomeUtils {
    // 생성자를 정의하지 않음
}

위의 SomeUtils 클래스와 같이 생성자가 정의되지 않는 경우, 컴파일러에 의해 내부적으로 다음과 같은 코드가 만들어지게 됩니다.

public class SomeUtils {

    public SomeUtils() {
        super();
    }
}

즉, public 생성자가 자동으로 생성되는 것입니다.


2. public 기본 생성자가 생성되면 어떤 문제가 있을까?

겉보기에는 별 문제가 없어 보일 수 있습니다. 하지만 생성자를 정의하지 않으면 public 생성자가 자동으로 생성되고, 그 결과 잘못된 사용이 아무 제약 없이 허용된다는 문제가 있습니다.

즉, “잘못된 사용을 막지 못하는 구조”가 되는 것입니다.


2.1. 의미 없는 객체의 생성

SomeUtils utils = new SomeUtils();

생성자가 정의되지 않으면 public 생성자가 자동으로 생성되기 때문에 위의 코드는 정상 동작하게 됩니다. 즉, SomeUtils 클래스의 인스턴스가 생성되는 것입니다. 하지만 아래와 같은 이유들로 존재 이유가 없는 불필요한 객체가 됩니다.

  • 상태 없음
  • 필드 없음
  • 인스턴스 메서드 없음

이건 잘못된 사용을 허용하는 설계에 해당되는 경우입니다.


2.2. 일관되지 않는 사용 방식 허용

정상적인 사용

SomeUtils.isEmpty(str);

비정상적인 사용, 하지만 허용

new SomeUtils().isEmpty(str); // static 메서드를 인스턴스로 호출하는 것은 권장되지 않는 방식입니다.

동일한 기능을 두 가지 방식으로 사용할 수 있게 됩니다. 시간이 지나면서 두 가지 방식이 혼용되면 코드의 일관성이 깨지고, 결과적으로 유지보수 비용이 증가할 수 있습니다.


2.3. 불필요한 자원의 낭비

크리티컬한 문제는 아니지만, 자원의 낭비가 분명하게 발생합니다.

  • GC 대상 증가
  • 객체 생성 비용 증가

따라서 반복적으로 잘못된 객체 생성이 발생하면 불필요한 객체 생성과 GC 부담이 누적될 수 있습니다.


3. 해결 방법: private 생성자 선언

public class SomeUtils {

    private SomeUtils() {
        throw new AssertionError("유틸리티 클래스는 인스턴스화 할 수 없습니다.");
    }

    public static boolean isEmpty(String str) {
        return str == null || str.isEmpty();
    }
}

SonarQube에서도 유틸리티 클래스에 private 생성자를 명시적으로 정의할 것을 권고하고 있습니다. 이는 잘못된 사용을 사전에 차단하고, 코드의 설계 의도를 명확히 하기 위한 목적입니다.

이러한 패턴은 단순히 불필요한 객체 생성을 막기 위한 문법만은 아닙니다.

  • 이 클래스는 상태를 가지지 않는다.
  • 인스턴스가 필요 없다.
  • static 메서드만 사용해야 한다.

이러한 의도들을 코드로 강제하는 것입니다. SonarQube에서는 Utility classes should not have public constructors라는 Code Smell 규칙을 통해 유틸리티 클래스의 public 생성자를 private으로 변경할 것을 권고합니다.

또한 private 생성자를 선언하면 인스턴스 생성을 막을 수 있습니다. 자식 클래스는 부모 클래스의 private 생성자를 호출할 수 없으므로 해당 클래스를 상속할 수 없습니다.

public class MyUtils extends SomeUtils

위의 코드는 부모 생성자를 호출할 수 없으므로 컴파일되지 않습니다.


4. AssertionError를 던지는 이유

생성자를 private으로 막았는데 왜 굳이 예외까지 던져야 할까요? 이유는 리플렉션 때문입니다.

Constructor<?> constructor = SomeUtils.class.getDeclaredConstructor();
constructor.setAccessible(true);
constructor.newInstance();

리플렉션을 사용하면 private 생성자도 호출할 수 있습니다. 따라서 생성자 내부에서 AssertionError를 발생시키면 이러한 호출이 성공적으로 객체를 생성하지 못하도록 한 번 더 방어할 수 있습니다.

다만 일반적인 애플리케이션에서는 리플렉션으로 유틸리티 클래스의 생성자를 직접 호출하는 경우는 거의 없습니다. 따라서 이는 방어적 프로그래밍(Defensive Programming) 관점에서 사용하는 관례에 가깝습니다.


5. Lombok의 @UtilityClass 어노테이션

Lombok을 사용하는 경우에는 @UtilityClass 애노테이션을 사용할 수도 있습니다.

이 애노테이션은 클래스를 final로 만들고 private 생성자를 자동으로 생성하며, 선언된 메서드와 필드를 자동으로 static으로 변환합니다. 따라서 유틸리티 클래스를 간편하게 작성할 수 있습니다.

다만 Lombok에 의존하게 되므로 프로젝트의 코딩 컨벤션에 따라 사용 여부를 결정하는 것이 좋습니다.


6. 마무리

Java 유틸리티 클래스에 private 생성자를 선언하는 것은 잘못된 객체 생성을 방지하기 위한 것만이 아니라 클래스의 설계 의도를 코드로 명확하게 표현하는 방법입니다. 사소하게 보일 수 있지만, 결국 이런 작은 습관들이 모여 코드의 품질을 지키고 더 안정적인 시스템을 만들어가는 토대가 되는 것입니다.

SonarQube의 권고사항처럼 작은 Code Smell이라도 그 이유를 이해하고 적용하면 코드의 일관성과 유지보수성을 높이는 데 큰 도움이 됩니다.