테스트를 위해 접근 제어자를 넓히지 말자

ds_chanin

·

2026. 3. 16. 00:38


들어가며

테스트 코드를 작성하다보면 한 번쯤은 이런 유혹에 빠진다.

"이 메서드를 테스트하고 싶은데 private이라 접근이 안 되네.. public으로 바꿀까?"

마음은 이해한다.

그런데 테스트를 위해 접근 제어자를 넓히는 순간, 캡슐화가 깨지고 설계가 무너지기 시작한다.

테스트하기 어렵다는 것은 접근 제어자의 문제가 아니라 설계의 문제일 확률이 높다.

이 부분에 대해 이야기해보자.

문제 상황

아래와 같이 주문 금액을 계산하는 객체가 있다고 하자.

public class Order {
    private final List<OrderItem> items;

    public Order(List<OrderItem> items) {
        this.items = new ArrayList<>(items);
    }

    public int calculateTotalPrice() {
        int total = calculateItemsPrice();
        return applyDiscount(total);
    }

    private int calculateItemsPrice() {
        return items.stream()
                .mapToInt(OrderItem::getPrice)
                .sum();
    }

    private int applyDiscount(int price) {
        if (price >= 50000) {
            return (int) (price * 0.9);
        }
        return price;
    }
}

정책은 다음과 같다.

  1. 주문 항목들의 가격을 합산한다.
  2. 합산 금액이 50000원 이상이면 10% 할인을 적용한다.

이때 applyDiscount를 단독으로 테스트하고 싶다는 생각이 들 수 있다.

"할인 로직만 따로 검증하고 싶은데 private이라 접근이 안 되네?"

그래서 아래와 같이 접근 제어자를 package-private으로 바꿔버리는 경우가 있다.

int applyDiscount(int price) { // private -> package-private
    if (price >= 50000) {
        return (int) (price * 0.9);
    }
    return price;
}
class OrderTest {

    @Test
    void 할인이_적용된다() {
        Order order = new Order(List.of(new OrderItem("상품", 50000)));
        int result = order.applyDiscount(50000);
        assertThat(result).isEqualTo(45000);
    }
}

테스트는 통과한다. 그런데 이게 좋은 테스트일까?

왜 문제인가

1. 캡슐화가 깨진다

applyDiscount는 Order 내부에서만 사용되는 구현 세부사항이다.

이걸 외부에서 접근 가능하게 열어두면 다른 개발자가 "아 이거 외부에서 써도 되는 메서드구나" 하고 직접 호출할 수 있다.

의도치 않은 사용이 발생하는 것이다.

2. 리팩터링 내성이 떨어진다

나중에 할인 정책이 바뀌어서 applyDiscount의 시그니처를 변경하거나 메서드를 제거하면

테스트 코드의 컴파일도 함께 깨진다. 노출된 내부 구현을 테스트했기 때문이다.

공개 API인 calculateTotalPrice의 동작은 변하지 않았는데도 테스트가 깨지는 것은

테스트가 구현에 결합되어 있다는 신호이다.

3. 설계 개선의 기회를 놓친다

"테스트하기 어렵다"는 것은 사실 설계가 보내는 피드백이다.

접근 제어자를 바꿔서 억지로 테스트하면 이 피드백을 무시하게 된다.

대안 1 - 공개 API를 통해 테스트하기

가장 먼저 고려해볼 방법은 공개된 메서드를 통해 검증하는 것이다.

applyDiscount를 직접 테스트하지 않아도 calculateTotalPrice를 통해 할인 로직을 충분히 검증할 수 있다.

class OrderTest {

    @Test
    void 총_금액이_50000원_이상이면_10퍼센트_할인이_적용된다() {
        Order order = new Order(List.of(new OrderItem("상품", 50000)));
        assertThat(order.calculateTotalPrice()).isEqualTo(45000);
    }

    @Test
    void 총_금액이_50000원_미만이면_할인이_적용되지_않는다() {
        Order order = new Order(List.of(new OrderItem("상품", 49999)));
        assertThat(order.calculateTotalPrice()).isEqualTo(49999);
    }
}

접근 제어자를 건드리지 않고도 할인 로직이 올바르게 동작하는지 검증할 수 있다.

테스트는 객체의 행위를 검증하는 것이지, 내부 구현을 검증하는 것이 아니다.

대안 2 - 정말 독립적으로 테스트하고 싶다면 객체를 분리하자

그런데 할인 정책이 점점 복잡해져서 정말 독립적으로 테스트해야 할 필요가 생겼다면?

그때는 접근 제어자를 바꾸는 것이 아니라 해당 로직을 별도 객체로 분리하는 것이 맞다.

public class DiscountPolicy {

    public int apply(int price) {
        if (price >= 50000) {
            return (int) (price * 0.9);
        }
        return price;
    }
}
public class Order {
    private final List<OrderItem> items;
    private final DiscountPolicy discountPolicy;

    public Order(List<OrderItem> items, DiscountPolicy discountPolicy) {
        this.items = new ArrayList<>(items);
        this.discountPolicy = discountPolicy;
    }

    public int calculateTotalPrice() {
        int total = items.stream()
                .mapToInt(OrderItem::getPrice)
                .sum();
        return discountPolicy.apply(total);
    }
}

이제 DiscountPolicy는 독립적인 객체이므로 자연스럽게 public 메서드를 가지고 있고

별도로 테스트할 수 있다.

class DiscountPolicyTest {

    @Test
    void 금액이_50000원_이상이면_10퍼센트_할인이_적용된다() {
        DiscountPolicy policy = new DiscountPolicy();
        assertThat(policy.apply(50000)).isEqualTo(45000);
    }

    @Test
    void 금액이_50000원_미만이면_할인이_적용되지_않는다() {
        DiscountPolicy policy = new DiscountPolicy();
        assertThat(policy.apply(49999)).isEqualTo(49999);
    }
}

접근 제어자를 억지로 넓히지 않아도 되고, 캡슐화도 유지되며, 각 객체의 책임도 명확해졌다.

마치며

테스트를 위해 접근 제어자를 넓히고 싶은 충동이 든다면 잠시 멈추고 생각해보자.

"이 로직이 지금 이 객체에 있는 게 맞는가?"

대부분의 경우 공개 API를 통해 충분히 검증할 수 있고

정말 독립적인 테스트가 필요하다면 그건 별도 객체로 분리해야 한다는 신호이다.

접근 제어자를 바꾸는 것은 해결이 아니라 회피이다.

테스트하기 어려운 코드는 설계를 다시 생각해볼 기회라고 받아들이자!