의존성 역전을 활용하여 테스트하기 쉬운 구조를 만들자

ds_chanin

·

2026. 3. 16. 00:59


들어가며

테스트 코드를 작성하다보면 "이건 어떻게 테스트하지?" 싶은 순간이 온다.

대표적으로 랜덤, 시간, 외부 API처럼 개발자가 제어할 수 없는 요소에 의존하는 코드가 그렇다.

이런 코드를 테스트하려고 mock 라이브러리를 꺼내드는 경우를 종종 보는데

mock 없이도 충분히 테스트할 수 있는 방법이 있다.

의존성 역전 원칙(DIP)을 활용하면 된다.

mock 을 무분별하게 사용하면 코드에서 악취가 나는 경우가 많다.

가능하면 사용하지 말고 적저잭소에만 쓰자

문제 상황

아래와 같이 슬롯머신 게임을 구현한 객체가 있다고 하자.

public class SlotMachine {
    private static final int REEL_COUNT = 3;
    private static final int MAX_SYMBOL = 9;

    public String play() {
        Random random = new Random();
        int[] reels = new int[REEL_COUNT];

        for (int i = 0; i < REEL_COUNT; i++) {
            reels[i] = random.nextInt(MAX_SYMBOL + 1);
        }

        if (reels[0] == 7 && reels[1] == 7 && reels[2] == 7) {
            return "JACKPOT";
        }
        if (reels[0] == reels[1] && reels[1] == reels[2]) {
            return "SMALL_WIN";
        }
        return "LOSE";
    }
}

배열이랑 문자열로 표현해둔건 지금 포스트에서 중요한게 아니니 넘어가자.

정책은 간단하다.

  1. 3개의 릴에서 각각 0~9 사이의 숫자를 하나씩 뽑는다.
  2. 세 숫자가 모두 7이면 잭팟(JACKPOT)이다.
  3. 세 숫자가 같으면 소잭팟(SMALL_WIN)이다.
  4. 그 외에는 꽝(LOSE)이다.

이 코드를 테스트하려면 어떻게 해야할까?

class SlotMachineTest {

    @Test
    void 슬롯머신_결과가_반환된다() {
        SlotMachine machine = new SlotMachine();
        String result = machine.play();
        assertThat(result).isIn("JACKPOT", "SMALL_WIN", "LOSE");
    }
}

결과가 세 가지 중 하나인지 정도는 확인할 수 있다.

그런데 "777이 나왔을 때 잭팟인지"를 테스트하고 싶다면?

잭팟 판정 로직을 테스트하려면 릴 결과가 [7, 7, 7]로 고정되어야 한다.

그런데 Randomplay 메서드 내부에서 직접 생성되고 있어서 외부에서 제어할 방법이 없다.

이때 흔히 하는 실수가 두 가지 있다.

실수 1: Mockito로 Random을 mock한다

@Test
void 슬롯머신_잭팟을_mock으로_테스트() {
    Random mockRandom = mock(Random.class);
    when(mockRandom.nextInt(10))
        .thenReturn(7, 7, 7);
    // ... Random을 어떻게 주입하지?
}

Random을 mock하려면 결국 Random을 외부에서 주입받아야 하는데

그러면 mock을 쓸 이유가 사라진다. 그리고 mock을 사용하면 내부 구현에 테스트가 결합되어 버린다.

nextInt를 몇 번 호출하는지, 어떤 순서로 호출하는지까지 알아야 테스트를 작성할 수 있게 되는 것이다.

그럼 어떻게 해야할까?

대안 - 인터페이스로 추상화하기

핵심은 "릴을 돌리는 행위"를 추상화하는 것이다.

public interface ReelSpinner {
    int[] spin(int reelCount, int maxSymbol);
}

그리고 실제 랜덤으로 릴을 돌리는 구현체를 만든다.

public class RandomReelSpinner implements ReelSpinner {

    @Override
    public int[] spin(int reelCount, int maxSymbol) {
        Random random = new Random();
        int[] reels = new int[reelCount];

        for (int i = 0; i < reelCount; i++) {
            reels[i] = random.nextInt(maxSymbol + 1);
        }

        return reels;
    }
}

이제 SlotMachineReelSpinner에 의존하도록 변경한다.

public class SlotMachine {
    private static final int REEL_COUNT = 3;
    private static final int MAX_SYMBOL = 9;

    private final ReelSpinner reelSpinner;

    public SlotMachine(ReelSpinner reelSpinner) {
        this.reelSpinner = reelSpinner;
    }

    public String play() {
        int[] reels = reelSpinner.spin(REEL_COUNT, MAX_SYMBOL);

        if (reels[0] == 7 && reels[1] == 7 && reels[2] == 7) {
            return "JACKPOT";
        }
        if (reels[0] == reels[1] && reels[1] == reels[2]) {
            return "SMALL_WIN";
        }
        return "LOSE";
    }
}

달라진 점이 보이는가?

SlotMachine은 더 이상 Random이라는 구체 클래스에 의존하지 않는다.

"릴을 돌려주는 무언가"에 의존할 뿐이다.

프로덕션 코드에서는 RandomReelSpinner를 넣어주면 되고

SlotMachine machine = new SlotMachine(new RandomReelSpinner());

테스트에서는 원하는 릴 결과를 반환하는 구현체를 만들어 넣어주면 된다.

class SlotMachineTest {

    @Test
    void 세_릴이_모두_7이면_잭팟이다() {
        ReelSpinner fixedSpinner = (reelCount, maxSymbol) -> new int[]{7, 7, 7};

        SlotMachine machine = new SlotMachine(fixedSpinner);
        String result = machine.play();

        assertThat(result).isEqualTo("JACKPOT");
    }

    @Test
    void 세_릴이_같지만_7이_아니면_소잭팟이다() {
        ReelSpinner fixedSpinner = (reelCount, maxSymbol) -> new int[]{3, 3, 3};

        SlotMachine machine = new SlotMachine(fixedSpinner);
        String result = machine.play();

        assertThat(result).isEqualTo("SMALL_WIN");
    }

    @Test
    void 세_릴이_모두_다르면_꽝이다() {
        ReelSpinner fixedSpinner = (reelCount, maxSymbol) -> new int[]{1, 5, 9};

        SlotMachine machine = new SlotMachine(fixedSpinner);
        String result = machine.play();

        assertThat(result).isEqualTo("LOSE");
    }
}

mock 라이브러리 없이도 원하는 값을 주입하여 테스트할 수 있게 되었다.

람다로 간단하게 구현체를 만들어 넣어주면 끝이다.

무엇이 달라졌는가

기존 구조를 다시 살펴보면

SlotMachine → Random (구체 클래스)

SlotMachineRandom이라는 구체 클래스에 직접 의존하고 있었다.

변경 후에는

SlotMachine → ReelSpinner (인터페이스)
                   ↑
           RandomReelSpinner (구현체)

SlotMachine은 인터페이스에 의존하고, 구현체는 외부에서 주입받는다.

이것이 의존성 역전이다.

고수준 모듈(SlotMachine)이 저수준 모듈(Random)에 직접 의존하는 것이 아니라

추상화(ReelSpinner)에 의존하도록 방향을 뒤집은 것이다.

마치며

테스트하기 어려운 코드를 만났을 때 mock부터 꺼내들기 전에

"이 의존성을 인터페이스로 추상화할 수 있지 않을까?" 를 먼저 생각해보자.

제어할 수 없는 요소를 인터페이스 뒤로 숨기면

프로덕션 코드는 유연해지고, 테스트 코드는 간결해진다.

그리고 이렇게 설계된 코드는 나중에 요구사항이 변경되었을 때도 훨씬 대응하기 수월하다.

랜덤이 아니라 특정 규칙으로 릴을 돌려야 하는 요구사항이 추가되더라도

ReelSpinner의 새로운 구현체를 만들면 그만이니까.

테스트하기 어려운 코드는 설계가 보내는 신호라고 했다.

그 신호를 무시하지 말고 의존성 역전으로 해결해보면 좋을거 같다.