인스턴스화를 막으려거든 private 생성자를 사용하라

인스턴스화를 막으려거든 private 생성자를 사용하라

이번 목표도 단순하다. 인스턴스를 만들 이유가 없는 클래스를, 구조적으로 인스턴스화되지 못하게 막는 것이다.

여기서 핵심은 “이유가 없다”가 아니라 “막는다”는 행위에 있다. 인스턴스를 만들지 말자는 관례는 코드 리뷰 없이는 강제되지 않는다. 코드가 직접 막아야 한다.

아이템 3이 “하나만 존재해야 하는 타입을 보증하라”였다면, 아이템 4는 그보다 한 걸음 더 나아가서 “아예 존재해서는 안 되는 인스턴스를 원천 차단하라”는 이야기다. 표면상 반대처럼 보이지만, 두 아이템의 철학은 같다. 설계 의도를 코드로 강제하라.

이 글은 유틸리티 클래스란 무엇인지, 왜 인스턴스화를 막아야 하는지, abstract로 막으면 왜 안 되는지, private 생성자가 어떻게 작동하는지, 그리고 이 패턴이 실무와 JDK 전반에 어떻게 녹아 있는지를 하나씩 짚어간다.


인스턴스화를 막아야 하는 클래스란 무엇인가

모든 클래스가 인스턴스화되어야 하는 것은 아니다. 실무에서 다음과 같은 클래스들은 인스턴스가 존재해야 할 이유가 없다.

1. 정적 메서드와 정적 필드만으로 이루어진 유틸리티 클래스

1
2
3
4
5
6
// 이런 클래스는 인스턴스가 의미 없다
public class StringUtils {
    public static boolean isBlank(String s) { ... }
    public static String capitalize(String s) { ... }
    public static String truncate(String s, int maxLength) { ... }
}

StringUtils utils = new StringUtils()라고 써서 utils.isBlank("hello")를 호출하는 것은 의미도 없고, 동작도 다르지 않다. 그러나 생성자를 막지 않으면 이런 코드가 실제로 쓰인다.

2. 특정 인터페이스를 구현하는 객체를 생성해주는 정적 팩터리 클래스

1
2
3
4
5
6
// Collections처럼 팩터리 메서드만 모은 클래스
public class Collections {
    public static <T> List<T> unmodifiableList(List<T> list) { ... }
    public static <T> Set<T> emptySet() { ... }
    public static <T> List<T> singletonList(T o) { ... }
}

실제 java.util.Collections가 이 구조다. 생성할 이유가 없다.

3. 특정 타입의 객체를 다루는 정적 메서드를 모은 클래스 (연산 유틸리티)

1
2
3
4
5
6
// 배열 관련 연산을 모은 클래스
public class Arrays {
    public static void sort(int[] a) { ... }
    public static int binarySearch(int[] a, int key) { ... }
    public static boolean equals(int[] a, int[] a2) { ... }
}

java.util.Arrays, java.lang.Math가 대표적이다. 이 클래스들은 인스턴스 상태가 없고, 모든 기능이 정적 메서드다. 인스턴스를 만드는 행위 자체가 설계 의도에 반한다.

이렇게 “인스턴스가 의미 없는 클래스”를 보통 유틸리티 클래스(Utility Class) 또는 헬퍼 클래스(Helper Class)라고 부른다.


1. 문제: 기본 생성자는 자동으로 생성된다

유틸리티 클래스를 만들 때 가장 흔한 실수는 아무것도 하지 않는 것이다.

1
2
3
4
public class MathUtils {
    public static int add(int a, int b) { return a + b; }
    public static int multiply(int a, int b) { return a * b; }
}

이 코드에는 생성자가 없다. 그런데 이렇게 하면 안전한가? 그렇지 않다. Java 컴파일러는 생성자가 명시적으로 정의되지 않으면 자동으로 기본 생성자(default constructor)를 만들어준다.

1
2
3
4
// 컴파일러가 자동으로 아래 코드를 삽입한다
public MathUtils() {
    super();  // Object() 호출
}

이 기본 생성자는 public이다. 결과적으로 다음 코드가 완전히 합법적으로 동작한다.

1
2
MathUtils utils = new MathUtils();  // 아무도 막지 않는다
int result = utils.add(1, 2);       // 인스턴스로 정적 메서드 호출

컴파일도 되고, 런타임 오류도 없다. IDE는 경고를 줄 수도 있지만, 강제하지는 않는다. 이 코드가 팀 코드베이스에 스며들면 다음과 같은 문제가 생긴다.

  • 의도를 모르는 개발자가 new MathUtils()로 인스턴스를 만들어 필드에 저장한다
  • 그 인스턴스를 여러 곳에 넘기기 시작한다
  • 어느 순간 누군가 MathUtils에 인스턴스 필드를 추가하기 시작한다
  • 처음 설계 의도였던 “순수 정적 유틸리티 클래스”라는 개념이 흐려진다

기본 생성자가 생성되는 조건

Java 명세에 따라 기본 생성자는 다음 조건이 모두 충족될 때 자동 생성된다.

  • 클래스에 생성자가 단 하나도 명시적으로 선언되지 않은 경우
  • 기본 생성자의 접근 제한자는 클래스의 접근 제한자를 따른다 (public 클래스면 public 생성자)

즉, public class MathUtils에 생성자가 없으면 public MathUtils()가 자동 생성된다. 이걸 막으려면 개발자가 직접 생성자를 선언해야 한다.


2. 잘못된 해법: abstract 클래스로 막으려는 시도

“인스턴스화를 막으면 되니까 추상 클래스로 만들면 되지 않나?”라는 생각이 자연스럽게 든다.

1
2
3
4
public abstract class MathUtils {
    public static int add(int a, int b) { return a + b; }
    public static int multiply(int a, int b) { return a * b; }
}

abstract 클래스는 직접 new MathUtils()를 할 수 없다. 컴파일 오류가 발생한다.

1
MathUtils utils = new MathUtils();  // 컴파일 오류: Cannot instantiate abstract class

언뜻 보면 문제가 해결된 것 같다. 그러나 이 해법에는 두 가지 치명적인 구멍이 있다.

구멍 1: 하위 클래스를 만들면 인스턴스화가 가능하다

1
2
3
4
5
6
7
8
// 아무나 상속할 수 있다
public class ConcreteMathUtils extends MathUtils {
    // 아무 내용 없이 상속만 해도
}

// 그러면 인스턴스화가 된다
ConcreteMathUtils utils = new ConcreteMathUtils();  // 가능!
utils.add(1, 2);  // 동작!

abstract는 직접 인스턴스화를 막을 뿐, 상속을 통한 우회를 막지 않는다. 생성자도 없는 빈 하위 클래스 하나면 막아둔 의도가 완전히 무너진다.

구멍 2: 설계 의도를 오해하게 만든다

abstract 클래스는 Java에서 “이 클래스는 상속을 위해 설계된 불완전한 추상화다”라는 의미를 전달한다. abstract로 표시한 유틸리티 클래스를 보는 다른 개발자는 이렇게 생각할 수 있다.

“아, 이 클래스를 상속해서 쓰라는 뜻이구나. 공통 기능이 여기 있고, 구체적인 기능은 내가 구현하면 되겠네.”

이는 완전히 반대의 설계 의도다. abstract는 “상속해서 완성하라”는 신호인데, 유틸리티 클래스는 “상속하지 마라”가 올바른 의도다. 언어의 의미 체계와 실제 의도가 충돌한다.

요약

측면 abstract 방식
직접 인스턴스화 ❌ 막힘
상속 후 인스턴스화 ✅ 가능 (구멍)
설계 의도 전달 ❌ 오해 유발
올바른 사용 ❌ 부적합

abstract는 인스턴스화를 막기 위한 도구가 아니다. 목적에 맞지 않는 도구를 사용한 셈이다.


3. 올바른 해법: private 생성자

해법은 간단하다. 명시적으로 private 생성자를 선언하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MathUtils {

    // 인스턴스화 방지용 private 생성자
    private MathUtils() {
        throw new AssertionError("MathUtils는 인스턴스화할 수 없습니다.");
    }

    public static int add(int a, int b) {
        return a + b;
    }

    public static int multiply(int a, int b) {
        return a * b;
    }
}

이 코드가 동작하는 원리는 다음과 같다.

생성자를 명시적으로 선언했으므로 기본 생성자가 생성되지 않는다. Java 컴파일러는 명시적 생성자가 하나라도 있으면 기본 생성자를 만들지 않는다.

생성자가 private이므로 클래스 외부에서 호출할 수 없다. new MathUtils()를 시도하면 컴파일 오류가 발생한다.

생성자가 private이므로 하위 클래스를 만들 수 없다. 이 부분은 뒤에서 자세히 설명한다.

1
MathUtils utils = new MathUtils();  // 컴파일 오류: MathUtils() has private access

AssertionError는 왜 넣는가

private 생성자만으로도 외부에서의 인스턴스화는 막힌다. 그런데 throw new AssertionError()를 추가하는 이유는 무엇인가?

클래스 내부에서 실수로 생성자를 호출하는 상황을 막기 위해서다.

1
2
3
4
5
6
7
public class MathUtils {
    private MathUtils() {}  // AssertionError 없는 버전

    private static MathUtils createHelper() {
        return new MathUtils();  // 클래스 내부이므로 컴파일은 된다
    }
}

private 생성자는 클래스 내부에서는 접근할 수 있다. 실수로 내부에서 인스턴스를 만드는 코드가 추가될 수 있다. AssertionError를 던지면 이 실수도 런타임에 잡힌다.

정리하면 AssertionError의 역할은 두 가지다.

  • 실수 방지: 클래스 내부에서도 인스턴스 생성이 불가능하다는 것을 런타임에서 강제한다
  • 의도 명확화: “이 생성자는 절대 호출되면 안 된다”는 코드 수준의 문서화다

AssertionErrorError의 하위 클래스이므로, 애플리케이션 코드에서 일반적으로 잡지 않는다. RuntimeException보다 더 강한 “이것은 프로그래밍 오류다”라는 신호를 준다.

주석의 역할

private 생성자는 보는 사람에게 의아함을 줄 수 있다. “생성자가 있는데 왜 아무 내용이 없지?” 혹은 “이게 왜 여기 있는 거지?”라는 질문이 생긴다. 주석으로 의도를 명시하는 것이 중요하다.

1
2
3
4
5
6
7
8
9
10
11
public class MathUtils {

    /**
     * 이 클래스는 정적 유틸리티 메서드만 포함하며, 인스턴스화할 수 없습니다.
     * 인스턴스를 만들 필요 없이 {@code MathUtils.add(1, 2)}처럼 직접 호출하세요.
     */
    private MathUtils() {
        // 인스턴스화 방지. 이 생성자는 호출되어서는 안 됩니다.
        throw new AssertionError("MathUtils는 인스턴스화할 수 없습니다.");
    }
}

이 주석이 없으면 이 private 생성자가 나중에 “사용되지 않는 코드”처럼 보여 삭제될 수도 있다. 주석은 생성자가 의도적으로 존재한다는 것을 알린다.


4. private 생성자가 상속도 막는 이유

private 생성자는 인스턴스화만 막는 게 아니라 상속도 막는 부수 효과가 있다. 이것이 abstract 방식과 결정적으로 다른 점이다.

1
2
3
4
// 컴파일 오류 발생
public class ExtendedMathUtils extends MathUtils {
    // error: MathUtils() has private access in MathUtils
}

왜 상속이 불가능한가? Java의 클래스 초기화 규칙 때문이다.

하위 클래스의 생성자는 반드시 상위 클래스의 생성자를 호출해야 한다. 명시적으로 super()를 쓰지 않아도, 컴파일러가 자동으로 super()를 첫 줄에 삽입한다. 그런데 상위 클래스의 생성자가 private이면 하위 클래스에서 이 호출이 불가능하다.

1
2
3
4
5
public class ExtendedMathUtils extends MathUtils {
    public ExtendedMathUtils() {
        super();  // 컴파일러가 자동 삽입, 그러나 MathUtils()는 private → 컴파일 오류
    }
}

결론적으로 private 생성자는 클래스를 사실상 final처럼 만든다. (명시적으로 final을 선언하지 않아도 상속이 불가능하다.)

final과 private 생성자의 차이

유틸리티 클래스에 final을 명시하는 경우도 있다.

1
2
3
4
5
public final class MathUtils {
    private MathUtils() {
        throw new AssertionError();
    }
}

final 선언과 private 생성자를 함께 쓰면 “상속 불가”라는 의도가 이중으로 표현된다. 어느 쪽이 더 나은가?

private 생성자만으로도 상속이 불가능해지므로 final은 기술적으로 중복이다. 그러나 final을 함께 선언하면 클래스를 처음 보는 사람이 “이 클래스는 상속하면 안 된다”는 의도를 즉시 알 수 있다는 가독성 이점이 있다. 팀 컨벤션에 따라 선택하면 되지만, private 생성자가 핵심이고 final은 부가적인 명시다.


5. JDK에서의 실제 사용 사례

이 패턴이 이론적인 이야기만은 아니다. Java 표준 라이브러리 전반에 걸쳐 이 패턴이 사용된다.

java.lang.Math

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Math {

    /**
     * Don't let anyone instantiate this class.
     */
    private Math() {}

    public static final double E = 2.718281828459045;
    public static final double PI = 3.141592653589793;

    public static int abs(int a) { ... }
    public static double sqrt(double a) { ... }
    public static double pow(double a, double b) { ... }
    // ...
}

Math 클래스의 private Math() 위 주석이 “Don’t let anyone instantiate this class.”다. Effective Java가 권장하는 패턴을 JDK 자체가 쓰고 있다. AssertionError는 없지만, private 생성자와 명시적 주석의 조합이 의도를 전달한다.

java.util.Arrays

1
2
3
4
5
6
7
8
9
10
public class Arrays {

    // Suppresses default constructor, ensuring non-instantiability.
    private Arrays() {}

    public static void sort(int[] a) { ... }
    public static boolean equals(int[] a, int[] a2) { ... }
    public static int binarySearch(int[] a, int key) { ... }
    // ...
}

Arrays 역시 같은 패턴이다. 주석 “Suppresses default constructor, ensuring non-instantiability.”가 왜 이 생성자가 존재하는지를 명확히 설명한다.

java.util.Collections

1
2
3
4
5
6
7
8
9
10
public class Collections {

    // Suppresses default constructor, ensuring non-instantiability.
    private Collections() {}

    public static <T> List<T> unmodifiableList(List<T> list) { ... }
    public static void shuffle(List<?> list) { ... }
    public static <T> boolean addAll(Collection<? super T> c, T... elements) { ... }
    // ...
}

Collections도 동일하다. 세 클래스 모두 공통적으로 private 생성자 + 인스턴스화 억제 주석 패턴을 쓴다.

패턴이 반복되는 이유

이 세 클래스는 Java의 핵심 설계자들이 만들었다. 그들이 같은 패턴을 반복해서 쓴다는 것은, 이 패턴이 유틸리티 클래스를 표현하는 관용구(idiom)로 정착했다는 뜻이다. “Java 개발자라면 이 패턴을 보면 즉시 유틸리티 클래스임을 알아차린다”는 신호 역할도 한다.


6. 싱글턴과 유틸리티 클래스: 무엇이 다른가

아이템 3(싱글턴)과 아이템 4(유틸리티 클래스)는 모두 private 생성자를 사용한다. 그러나 목적과 구조가 근본적으로 다르다.

싱글턴 (아이템 3)

  • 인스턴스가 정확히 하나 존재해야 한다
  • 그 인스턴스를 통해 상태나 자원을 공유한다
  • private 생성자는 두 번째 인스턴스 생성을 막기 위해 사용한다
  • 클래스 내부에서 인스턴스를 하나 직접 만들어 보관한다
1
2
3
4
public class Elvis {
    public static final Elvis INSTANCE = new Elvis();  // 하나는 존재
    private Elvis() {}  // 두 번째부터는 차단
}

유틸리티 클래스 (아이템 4)

  • 인스턴스가 0개 존재해야 한다
  • 모든 기능이 정적(static) 으로 제공된다
  • private 생성자는 인스턴스 자체가 아예 만들어지지 않도록 막는다
  • 클래스 내부에서도 인스턴스를 만들지 않는다
1
2
3
4
public class MathUtils {
    private MathUtils() { throw new AssertionError(); }  // 어디서도 만들 수 없음
    public static int add(int a, int b) { return a + b; }
}

결정 기준

질문 싱글턴 유틸리티 클래스
인스턴스 상태가 필요한가 ✅ 예 ❌ 아니오
인스턴스가 몇 개 필요한가 정확히 1개 0개
생명주기 관리가 필요한가 ✅ 예 ❌ 아니오
인터페이스로 교체 가능해야 하는가 ✅ 예 ❌ 보통 아니오
적합한 구현 enum 또는 정적 팩터리 private 생성자

7. 리플렉션 공격에 대한 태도

아이템 3에서 싱글턴이 리플렉션 공격에 취약하다는 것을 다뤘다. 유틸리티 클래스도 마찬가지다.

1
2
3
Constructor<MathUtils> c = MathUtils.class.getDeclaredConstructor();
c.setAccessible(true);
MathUtils utils = c.newInstance();  // AssertionError가 없으면 성공!

AssertionError가 생성자 안에 있다면?

1
2
3
4
5
Constructor<MathUtils> c = MathUtils.class.getDeclaredConstructor();
c.setAccessible(true);
MathUtils utils = c.newInstance();
// java.lang.reflect.InvocationTargetException 발생
// Caused by: java.lang.AssertionError: MathUtils는 인스턴스화할 수 없습니다.

AssertionErrorInvocationTargetException으로 감싸여 던져진다. 인스턴스가 만들어지지 않는다.

유틸리티 클래스의 리플렉션 공격은 얼마나 위험한가

싱글턴과 다르게, 유틸리티 클래스의 인스턴스화는 대부분 치명적 버그가 아닌 설계 위반에 가깝다. 유틸리티 클래스는 상태가 없으므로, 여러 인스턴스가 존재해도 동작 결과는 달라지지 않는다. MathUtils 인스턴스 두 개가 생겨도 add(1, 2)의 결과는 같다.

그러나 이것이 “막지 않아도 된다”는 뜻은 아니다. 인스턴스가 만들어지는 순간, 그 객체는 필드에 저장되고, 메서드에 인자로 넘겨지고, 어느 순간 상태가 추가될 수 있다. 설계의 순수성이 무너지는 출발점이 된다. AssertionError는 그 출발점을 차단하는 장치다.


8. 정적 메서드의 한계와 유틸리티 클래스를 쓰면 안 되는 경우

유틸리티 클래스는 편리하지만, 남용하면 객체지향 설계를 훼손한다. 언제 유틸리티 클래스를 쓰면 안 되는지를 아는 것이 중요하다.

문제 1: 테스트가 어렵다

정적 메서드는 모킹(mocking)이 어렵다. 인터페이스가 없으므로 테스트 더블로 교체할 수 없다.

1
2
3
4
5
6
// 이렇게 짜면 PaymentService를 테스트할 때 MathUtils.tax()를 교체할 수 없다
public class PaymentService {
    public int calculateTotal(int price) {
        return price + MathUtils.tax(price);  // 정적 메서드에 강하게 결합
    }
}

Mockito 같은 프레임워크로도 일반 정적 메서드는 모킹하기 어렵다 (PowerMock을 써야 하는데, 이는 설계 문제를 회피하는 방법에 가깝다).

반면 인스턴스 메서드라면 인터페이스로 추상화해서 테스트 시 교체할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
// 이렇게 짜면 테스트에서 TaxCalculator를 교체할 수 있다
public class PaymentService {
    private final TaxCalculator taxCalculator;

    public PaymentService(TaxCalculator taxCalculator) {
        this.taxCalculator = taxCalculator;
    }

    public int calculateTotal(int price) {
        return price + taxCalculator.calculate(price);
    }
}

문제 2: 전략(Strategy) 패턴 적용이 불가능하다

정적 메서드는 런타임에 교체할 수 없다. 같은 연산이지만 상황에 따라 다른 구현이 필요할 때, 정적 유틸리티는 막힌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 정렬 방식이 고정됨 - 전략 교체 불가
public class SortUtils {
    public static void sort(int[] arr) {
        // 항상 퀵정렬만 사용
    }
}

// 정렬 전략을 주입 - 교체 가능
public interface SortStrategy {
    void sort(int[] arr);
}

public class QuickSort implements SortStrategy { ... }
public class MergeSort implements SortStrategy { ... }

문제 3: 상속과 다형성을 활용할 수 없다

정적 메서드는 오버라이드(override)되지 않는다. 하위 클래스에서 재정의해도 실제로는 정적 바인딩(static binding)이 일어난다.

1
2
3
4
5
6
7
8
9
public class Animal {
    public static String sound() { return "..."; }
}
public class Dog extends Animal {
    public static String sound() { return "멍멍"; }
}

Animal a = new Dog();
System.out.println(a.sound());  // "..." — 다형성이 동작하지 않는다

유틸리티 클래스가 적합한 경우

이러한 한계에도 불구하고, 다음 조건을 모두 만족하면 유틸리티 클래스가 적합하다.

  • 기능이 순수 함수(pure function)에 가깝다. 즉, 외부 상태에 의존하지 않고 입력만으로 결과가 결정된다
  • 구현이 교체될 가능성이 없다. 수학 연산, 문자열 파싱처럼 단 하나의 올바른 구현이 있다
  • 동작을 테스트 더블로 대체할 필요가 없다
  • 언어나 런타임의 기본 기능을 얇게 감싸는 역할을 한다

대표적인 예는 다음과 같다.

  • 날짜/시간 포맷 변환 (입력 → 출력이 결정적)
  • 문자열 조작 (null 처리, trim, split 래핑)
  • 타입 변환 및 검증
  • 수학적 계산 (Math 래핑)

이 범위를 벗어나면 유틸리티 클래스보다 서비스 클래스, 전략 패턴, DI를 고려하는 것이 낫다.


9. 함수형 프로그래밍 관점에서 본 유틸리티 클래스

Java 8 이후로 유틸리티 클래스와 함수형 스타일 사이의 연결이 더 자연스러워졌다. 정적 메서드는 사실 순수 함수와 개념적으로 같다.

1
2
3
4
5
6
7
8
9
10
11
12
public class Validators {
    private Validators() { throw new AssertionError(); }

    // 정적 메서드 = 순수 함수
    public static boolean isValidEmail(String email) {
        return email != null && email.contains("@") && email.contains(".");
    }

    public static boolean isNotBlank(String s) {
        return s != null && !s.isBlank();
    }
}

이 정적 메서드들은 Predicate<String>으로 쉽게 연결된다.

1
2
3
4
5
6
7
List<String> emails = List.of("user@example.com", "", null, "admin@test.org");

// 메서드 참조로 Predicate처럼 사용
List<String> validEmails = emails.stream()
        .filter(Validators::isNotBlank)
        .filter(Validators::isValidEmail)
        .collect(Collectors.toList());

정적 유틸리티 메서드를 함수형 인터페이스로 제공하기

정적 메서드 자체를 public으로 두되, 함수형 인터페이스 타입의 상수로도 노출하면 두 가지 방식으로 모두 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Validators {
    private Validators() { throw new AssertionError(); }

    // 정적 메서드 형태
    public static boolean isValidEmail(String email) {
        return email != null && email.contains("@") && email.contains(".");
    }

    // 함수형 인터페이스 형태 (재사용 가능한 싱글턴 Predicate)
    public static final Predicate<String> VALID_EMAIL = Validators::isValidEmail;
}

// 사용 1: 메서드 참조
stream.filter(Validators::isValidEmail)

// 사용 2: 싱글턴 Predicate 재사용
stream.filter(Validators.VALID_EMAIL)

// 사용 3: Predicate 조합
Predicate<String> strictCheck = Validators.VALID_EMAIL
        .and(Validators::isNotBlank)
        .and(s -> s.length() < 100);

이 형태는 아이템 3에서 다룬 “함수형 싱글턴”과도 자연스럽게 연결된다. 유틸리티 클래스의 정적 메서드는 곧 재사용 가능한 순수 함수이고, 이를 Predicate, Function, UnaryOperator 등의 함수형 인터페이스로 표현하면 스트림 API와 조합이 훨씬 유연해진다.


10. 네이밍 컨벤션과 팀 약속

유틸리티 클래스에는 이름 짓는 관례가 있다. 이 관례를 따르면 코드를 읽는 사람이 별도의 설명 없이도 클래스의 성격을 파악한다.

접미사로 의도 전달하기

패턴 예시 의미
XxxUtils StringUtils, DateUtils, FileUtils 특정 타입이나 도메인을 다루는 유틸리티
XxxHelper TestHelper, DatabaseHelper 특정 맥락을 지원하는 보조 기능
Xxxs Collections, Objects, Arrays JDK 관례, 타입 이름의 복수형
XxxFactory BeanFactory, ConnectionFactory 객체 생성에 특화
XxxSupport WebMvcSupport, TestSupport 프레임워크 확장용 지원 클래스

주의: 이름만으로는 부족하다

이름이 Utils로 끝난다고 자동으로 유틸리티 클래스가 되는 건 아니다. private 생성자가 없으면 이름과 달리 인스턴스화가 가능하다. 이름은 의도를 전달하지만, private 생성자가 의도를 강제한다.


11. 실무에서 자주 만나는 안티패턴

안티패턴 1: 생성자 없이 정적 메서드만 나열

1
2
3
4
5
6
7
8
9
// private 생성자 없음 — 기본 생성자가 자동 생성된다
public class UserValidator {
    public static boolean isValidAge(int age) { return age >= 0 && age <= 150; }
    public static boolean isValidName(String name) { return name != null && !name.isBlank(); }
}

// 누군가 이렇게 쓰기 시작한다
UserValidator validator = new UserValidator();
if (validator.isValidAge(25)) { ... }

해결: private UserValidator() { throw new AssertionError(); } 추가

안티패턴 2: 상태를 추가하기 시작하는 유틸리티 클래스

1
2
3
4
5
6
7
8
9
// 처음에는 유틸리티로 시작했지만
public class OrderUtils {
    private static final Logger logger = LoggerFactory.getLogger(OrderUtils.class);
    // ...까지는 괜찮다

    // 그런데 어느 순간 인스턴스 상태가 생기기 시작한다
    private int processedCount = 0;  // 이 순간 유틸리티 클래스가 아니다
    public static int getTotal(List<Order> orders) { ... }
}

인스턴스 상태가 생기는 순간, 그 클래스는 더 이상 유틸리티 클래스가 아니다. 서비스 클래스나 도메인 객체로 분리해야 한다.

해결: 설계를 재검토하고 인스턴스 상태는 별도 클래스로 분리

안티패턴 3: 모든 것을 유틸리티 클래스에 때려 넣기

1
2
3
4
5
6
7
8
9
10
// 도메인 경계가 없는 신 유틸리티 클래스
public class Utils {
    private Utils() { throw new AssertionError(); }

    public static String formatDate(LocalDate date) { ... }
    public static boolean isValidEmail(String email) { ... }
    public static BigDecimal calculateDiscount(Order order) { ... }  // 도메인 로직이 여기에?
    public static void sendEmail(String to, String body) { ... }     // 부수 효과까지?
    public static Connection getDbConnection() { ... }               // 자원 관리까지?
}

이름이 Utils인 모든 것을 하나의 클래스에 넣으면, 유틸리티 클래스가 아니라 잡동사니 클래스(dumping ground)가 된다. 응집도가 사라지고, 의존성이 뒤엉킨다.

해결: 관심사에 따라 클래스를 분리 (DateUtils, EmailValidator, DiscountCalculator, …)


12. 실무 선택 기준 정리

private 생성자를 사용해야 하는 상황

  • 클래스의 모든 메서드와 필드가 static이다
  • 인스턴스 상태가 없고, 앞으로도 생길 이유가 없다
  • 기능이 순수 함수에 가깝고, 외부 상태에 의존하지 않는다
  • 테스트 더블 교체가 필요 없다

private 생성자를 사용하면 안 되는 상황 (다른 방법을 고려해야 하는 상황)

  • 동작을 테스트에서 교체해야 한다 → 인터페이스 + 구현체 분리
  • 여러 구현이 있거나, 런타임에 전략이 바뀐다 → 전략 패턴
  • 인스턴스 상태가 필요하거나 생길 가능성이 있다 → 서비스 클래스 또는 도메인 객체
  • 프레임워크가 인스턴스를 관리한다 (Spring Bean 등) → 컨테이너 스코프로 위임

체크리스트

유틸리티 클래스를 만들었다면 다음을 점검한다.

1
2
3
4
5
6
7
8
✅ private 생성자가 있는가?
✅ 생성자 안에 AssertionError가 있는가?
✅ 생성자에 주석(또는 Javadoc)이 있는가?
✅ 클래스에 final이 선언되어 있는가? (선택, 팀 컨벤션에 따라)
✅ 모든 메서드가 static인가?
✅ 인스턴스 필드가 없는가?
✅ 클래스 이름이 유틸리티임을 알 수 있는가? (Utils, Helper 등)
✅ 너무 많은 역할을 하지 않는가? (단일 책임 원칙)

결론

아이템 4는 짧은 아이템이지만, 전달하는 메시지는 명확하다.

“인스턴스를 만들면 안 되는 클래스는, 인스턴스를 만들 수 없게 구조로 막아라.”

private 생성자는 단 두 글자(private)로 기본 생성자 자동 생성을 막고, 외부에서의 인스턴스화를 막고, 상속을 막는다. 여기에 AssertionError와 주석을 더하면 클래스 내부에서의 실수도 막고, 의도도 명확히 전달된다.

abstract는 이 목적에 적합하지 않다. “상속을 위한 불완전한 추상화”라는 신호를 보내고, 하위 클래스를 통한 우회를 허용한다. 목적에 맞지 않는 도구다.

아이템 3과 아이템 4를 함께 이해하면 private 생성자의 두 가지 용법이 명확해진다.

  • 아이템 3: private 생성자로 두 번째 인스턴스 생성을 막는다 (하나는 존재)
  • 아이템 4: private 생성자로 모든 인스턴스 생성을 막는다 (하나도 없음)

두 경우 모두 “설계 의도를 코드로 강제하라”는 같은 철학의 두 얼굴이다.


다음 글에서는 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 를 정리한다.