Spring에서 테스트 코드를 본격적으로 작성을 시작하고 생긴 의문이 있었다.
Service계층 테스트는 어떤 방식으로 해야하지??
이런 고민이 생긴 이유는 Service는 Repository에 의존하고 있었기 때문이었다.
해당 의존성을 어떤 방식으로든 해결해야 Service의 테스트가 가능했다.
이때 선택지는 크게 세 가지였다.
Repository를 Mock으로 대체한다.Repository를 Fake로 직접 구현한다.Repository와 함께 테스트한다.처음에는 단순히
Service테스트니까Repository는 가짜로 대체해야 하는 것 아닌가?
라고 생각했다.
하지만 직접 세 방식을 모두 써보면서 점점 질문이 바뀌었다.
어떤 방식이 정답인가?
가 아니라
나는 지금 무엇을 검증하고 싶은가?
가 더 중요한 질문이었다.
이 글은 Service 테스트에서 어떤 방식을 쓰는게 더 옳은가에 대한 글이 아닌, 내가 위 3가지 방식으로 전부 테스트를 하고, 우아한 테크코스의 코치와 이야기하며 깨달은 점에 대한 정리이다.
가장 먼저 시도한 방식은 Repository를 Mock 객체로 대체하는 방식이었다.
Service 계층을 테스트하고 싶었기 때문에, 처음에는 Repository까지 실제 구현체를 사용하는 것이 어색하게 느껴졌다.
Service 테스트라면 Service의 로직만 검증해야 하고, Repository는 테스트 대역으로 대체하는 것이 자연스럽다고 생각했다.
@Test
void find_all_theme_test() {
given(themeRepository.findAll()).willReturn(List.of(theme1, theme2));
List<ThemeResult> results = themeQueryService.findAll();
assertThat(results).hasSize(2);
}
Mock을 사용하면 테스트가 빠르다.
DB도 필요 없고, 스프링 컨텍스트도 띄우지 않아도 된다.
Repository가 어떤 값을 반환할지만 미리 정의하면 Service의 흐름을 바로 테스트할 수 있다.
하지만 테스트를 작성하면서 점점 이런 생각이 들었다.
이 테스트는
Service의 동작을 검증하는 걸까?
아니면 내가 정의한Stub값이 그대로 반환되는지 확인하는 걸까?
현재 내 Service 로직은 Repository에서 값을 조회한 뒤 DTO로 변환하거나, Repository의 메서드를 단순히 호출하는 얇은 경우가 많았다.
이런 상황에서 Mock을 사용하면 테스트도 덩달아 얇아졌다.
Repository가 특정 값을 반환하도록 Stub을 정의하고, Service를 호출한 뒤, 그 값이 잘 변환되었는지 확인하는 정말 단순한 테스트이다.
물론 이 테스트도 의미가 아예 없는 것은 아니다.
Service 내부에서 예외를 변환하거나, 특정 조건에 따라 다른 Repository 메서드를 호출하거나, 외부 의존성을 끊어야 하는 경우에는 Mock이 유용할 수 있다.
하지만 내가 당시 검증하고 싶었던 것은 단순히
Service가 Repository를 호출하는가?
가 아니었다.
실제로 데이터가 존재할 때 계산들이 올바르게 수행되는지, 조건이 있다면 해당 조건은 제대로 적용되는지, 정렬이 제대로 되는지를 테스트하고 싶었다.
Mock은 빠른 피드백을 주지만, 실제 동작에 대한 신뢰는 제한적이었다.
| 기준 | 체감 |
|---|---|
| 빠른 피드백 | 높음 |
| 동작 신뢰 | 중간 |
| 유지보수 비용 | 높음 |
특히 Mock 기반 테스트는 내부 구현에 쉽게 묶인다.
테스트에서 given()으로 특정 Repository 메서드의 반환값을 정의하거나,
verify()로 특정 메서드가 호출되었는지 검증하게 되면, 테스트는 자연스럽게 Service의 내부 구현 방식을 알고 있어야만 하게 된다.
여기서 문제는 실제 기능은 그대로인데 내부 구현만 바뀌어도 테스트가 깨질 수 있다는 점이다.
예를 들어 Service가 반환하는 결과는 동일하지만,
Repository 메서드가 바뀌거나,이 일어날 수 있다.
사용자 입장에서 보이는 동작은 변하지 않았지만, Mock 테스트는 특정 메서드 호출과 인자에 기대고 있었기 때문에 위의 수정으로 테스트가 깨질 수 있게되는 것이다.
그리고 이때 깨진 테스트는 실제 버그를 알려준다기보다,
내부 구현 방식이 바뀌었다는 사실을 알려주는 테스트에 가까워진다.
그래서 Mock 테스트는 빠르게 작성하고 빠르게 실행할 수 있다는 장점이 있지만,
서비스의 내부 구현에 강하게 결합되면 유지보수 비용이 생각보다 커질 수 있다고 느꼈다.
두 번째로 시도한 방식은 Repository의 Fake 구현체를 직접 만드는 것이었다.
Mock을 사용했을 때 테스트가 너무 얇게 느껴졌기 때문에, 차라리 Repository처럼 동작하는 테스트용 구현체를 만들면 어떨까 생각했다.
단순한 저장과 조회는 Fake로 꽤 자연스럽게 표현할 수 있었다.
public class FakeThemeRepository implements ThemeRepository {
private final Map<Long, Theme> themes = new LinkedHashMap<>();
private Long idHolder = 1L;
@Override
public Theme save(Theme theme) {
Theme savedTheme = theme.withId(idHolder);
themes.put(idHolder++, savedTheme);
return savedTheme;
}
@Override
public Optional<Theme> findById(Long id) {
return Optional.ofNullable(themes.get(id));
}
@Override
public List<Theme> findAll() {
return themes.values().stream()
.toList();
}
}
이런 형태의 Fake는 꽤 만족스러웠다.
실제 DB를 사용하지 않으면서도, 저장한 값을 다시 조회할 수 있었다.
Mock처럼 매 테스트마다 반환값을 일일이 Stub으로 정의하지 않아도 됐다.
테스트 입장에서는 어느 정도 실제 Repository처럼 사용할 수 있었다.
여기까지는 Theme만 다루는 단순한 저장/조회 기능이었다.
하지만 이후에
최근 7일 동안 예약이 많았던 테마를 인기 테마로 보여준다
는 요구사항이 생겼다.
방탈출 예약 도메인에서 Theme는 예약 가능한 방탈출 테마를 의미하고,
Reservation은 사용자가 특정 날짜와 시간에 특정 테마를 예약한 정보를 의미한다.
따라서 인기 테마 조회는 단순히 저장된 Theme만으로는 조회할 수 없었다.
다른 객체 Reservation을 함께 봐야 했다.
예를 들어 A 테마에 최근 7일 동안 예약이 3개 있고,
B 테마에 예약이 1개 있다면 A 테마가 더 인기 있는 테마로 조회되어야 한다.
그래서 이 기능은 Theme만 저장하고 꺼내는 Fake로는 표현하기 어려웠다.
실제 구현에서 사용한 쿼리는 다음과 같았다.
SELECT t.id, t.name, t.description, t.thumbnail_img_url, COUNT(*) as reserved_count
FROM theme t
JOIN reservation r ON t.id = r.theme_id
WHERE r.date BETWEEN ? AND ?
GROUP BY t.id, t.name, t.description, t.thumbnail_img_url
ORDER BY reserved_count DESC
LIMIT 10
사용한 쿼리
여기에는 여러 동작이 섞여 있다.
theme과 reservation을 조인한다.그리고 이러한 결과를 담을 때는 도메인 객체가 아닌 프로젝션으로 조회했다.
아래 Dao에서 PopularThemeResult는 위 쿼리의 결과를 담을 프로젝션이다.
public interface PopularThemeDao {
List<PopularThemeResult> findTop10PopularThemes(PopularThemePeriod period);
}
인기테마를 프로젝션으로 조회해오는 Dao의 인터페이스
이걸 Fake에서 제대로 흉내 내려면 Fake 내부에 Theme도 저장하고, Reservation도 저장하고, 기간 조건도 적용하고, 그룹화와 정렬도 직접 구현해야 한다.
처음에는 그렇게까지 해야 하나 싶었다..
그래서 결국 Fake에서는 PopularThemeResult를 직접 저장하고, 조회 시 그대로 반환하는 형태가 되었다.
public class FakePopularThemeDao implements PopularThemeDao {
private final List<PopularThemeResult> popularThemes = new ArrayList<>();
@Override
public List<PopularThemeResult> findTop10PopularThemes(PopularThemePeriod period) {
return popularThemes;
}
public void savePopularTheme(PopularThemeResult result) {
popularThemes.add(result);
}
}
실제 구현한 Fake
findTop10PopularThemes메서드에서는 인자로 전달받은PopularThemePeriod를 사용하지 않았다.저장되는 프로젝션 객체
PopularThemeResult에 기간 정보는 담기지 않기 때문이다.
이렇게 만들고 나니 다시 의문이 생겼다.
이 Fake는 실제 구현을 대체하고 있는 걸까?
아니면 테스트에서 기대하는 결과를 미리 넣어두고 다시 꺼내는 Stub에 가까운 걸까?
나는 후자로 생각했다.
도메인 객체를 저장하고 조회하는 Repository라면 Fake를 구현하기 쉬웠고 의미도 있었다.
하지만 SQL의 조인, 집계, 정렬이 핵심인 조회 기능에서는 Fake가 실제 동작을 수행하게 구현하기가 너무 복잡했다.
이때 Fake의 본질에 대해 다시 생각하게 되었다.
Fake는 단순히 Map으로 만든 가짜 객체가 아니다.
중요한 것은 Fake가 실제 의존성의 핵심 동작을 얼마나 의미 있게 흉내 내는가였다.
이 과정에서 느낀 Fake의 장점은 개발자가 직접 형태를 정할 수 있다는 점이었다.
Stub에 가깝게 만들 수도 있다.하지만 실제에 가깝게 만들수록 구현 비용이 커지고,
반대로 단순하게 만들수록 테스트의 신뢰도는 낮아진다.
이런 Fake를 사용한다면 트레이드오프를 고려해서 내가 선택을 해야만 하는 것이었다.
| 기준 | 체감 |
|---|---|
| 빠른 피드백 | 낮음 또는 중간 |
| 동작 신뢰 | 낮음(경우에 따라 높을 수 있음) |
| 유지보수 비용 | 중간 |
이번의 요구사항처럼 DB 쿼리 자체가 중요한 기능에서는 Fake의 장점이 약했다.
Fake를 직접 만드는 비용이 분명히 존재하는데, 그렇게 만든 Fake가 실제로 검증하고 싶은 동작을 충분히 검증해주지 못한다면 굳이 Fake를 사용할 이유가 약해진다고 생각했다.
이 글에서 다룬 요구사항에서는
Fake의 장점이 거의 드러나지 않았다.
SQL의 조인, 집계, 정렬이 핵심인 기능에서는Fake가 실제 동작을 흉내 내기 너무 복잡해지기 때문이다.하지만 요구사항이 모호하고 구현이 자주 바뀌는 상황에서는
Fake가 빛을 발한다.
Mock은 내부 구현에 결합되어 구현이 바뀌면 테스트가 깨지지만,
Fake는 결과 상태를 검증하기 때문에 구현이 바뀌어도 테스트가 흔들리지 않는다.
이번 요구사항에서는 Mock, Fake 두 방식 모두 내가 검증하고 싶은 동작을 충분히 담아내지 못했다. 그래서 다음으로는 아예 실제 Repository와 테스트용 DB를 함께 사용하는 방식으로 넘어가게 되었다.
마지막으로 선택한 방식은 테스트용 DB를 사용해서 Service를 테스트하는 방식이었다.
현재 코드는 @SpringBootTest를 사용해서 실제 Service와 Repository를 스프링 빈으로 등록하고, 테스트를 위해 필요한 데이터는 JdbcTemplate을 통해 직접 넣어준다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Transactional
class ThemeQueryServiceTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ThemeQueryService themeQueryService;
private TestDataHelper testHelper;
@BeforeEach
void setUp() {
testHelper = new TestDataHelper(jdbcTemplate);
}
}
Service 통합 테스트를 위한 설정(약식)
이렇게 테스트용 DB를 사용하자 인기 테마 조회 테스트는 대략 이런 흐름으로 작성할 수 있었다.
@DisplayName("인기 테마 조회를 테스트합니다.")
@Test
void find_popular_themes() {
Long nineTimeId = testHelper.insertReservationTime(LocalTime.of(9, 0));
Long tenTimeId = testHelper.insertReservationTime(LocalTime.of(10, 0));
Long firstThemeId = testHelper.insertTheme(ThemeFixture.themeCreateCommand(1));
Long secondThemeId = testHelper.insertTheme(ThemeFixture.themeCreateCommand(2));
testHelper.insertReservation("테마1 예약자1", CURRENT_DATE.minusDays(1), firstThemeId, nineTimeId);
testHelper.insertReservation("테마1 예약자2", CURRENT_DATE.minusDays(1), firstThemeId, tenTimeId);
testHelper.insertReservation("테마2 예약자1", CURRENT_DATE.minusDays(2), secondThemeId, nineTimeId);
testHelper.insertReservation("기간 밖 예약자", CURRENT_DATE.minusDays(8), secondThemeId, tenTimeId);
List<PopularThemeResult> responses = themeQueryService.findPopularThemes(CURRENT_DATE);
assertThat(responses).containsExactly(
new PopularThemeResult(firstThemeId, "테마 1", "테마 설명 1", "http://img.url", 2),
new PopularThemeResult(secondThemeId, "테마 2", "테마 설명 2", "http://img.url", 1)
);
}
실제 사용한 테스트 코드
코드 상 위쪽은 테스트를 위한 기초 데이터를 넣어놓는 과정이 대부분이다.
아래 코드가 테스트 코드의 핵심 부분이다.
List<PopularThemeResult> responses = themeQueryService.findPopularThemes(CURRENT_DATE);
assertThat(responses).containsExactly(
new PopularThemeResult(firstThemeId, "테마 1", "테마 설명 1", "http://img.url", 2),
new PopularThemeResult(secondThemeId, "테마 2", "테마 설명 2", "http://img.url", 1)
);
핵심 로직
이 방식은 Mock이나 Fake보다 느리다.
스프링 컨텍스트도 필요하고, DB도 필요하기 때문이다.
그리고 처음에는 이 방식이 마음에 걸렸다.
이게 정말
Service테스트일까?
난
Service를 테스트하고 싶었는데Repository와DB까지 함께 검증하고 있으니 통합 테스트를 만든 것 아닐까?
위 질문에 대한 대답은 둘 다 "맞다" 였다.
내가 만든것은 Service 테스트이기는 하지만 순수한 Service 단위 테스트라고 보기는 어렵다.
스프링의 Repository 계층을 주입받아서 테스트하고 있기 때문에 통합 테스트라고 보는 것이 맞다.
하지만 곰곰이 생각해보니, 인기 테마 조회에서 내가 정말 검증하고 싶었던 것은 Service가 단순하게 popularThemeDao.findTop10PopularThemes()를 호출하는지가 아니었다.
내가 검증하고 싶었던 것은 다음과 같았다.
이 질문들은 Mock이나 얕은 Fake로는 검증하기 어렵다.
질문들을 종합해보면 내가 테스트하고자 했던 핵심은 Service 코드보다 DB 조회 로직에 더 가까웠다.
그렇다면 테스트용 DB를 사용해 통합 테스트를 하는 방식이 더 적절하다고 생각했다.
| 기준 | 체감 |
|---|---|
| 빠른 피드백 | 낮음 |
| 동작 신뢰 | 높음 |
| 유지보수 비용 | 중간 |
모든 Service 테스트를 이렇게 작성해야 한다고 결론 내린 것은 아니다.
Service 내부의 순수한 분기 로직을 검증하고 싶은데 매번 DB를 띄우는 것은 과할 수 있다.
하지만 이번 인기 테마 조회처럼 SQL의 조인, 기간 조건, 집계, 정렬이 기능의 핵심이라면 테스트용 DB를 사용하는 편이 더 큰 신뢰를 주며 개발자인 내가 테스트 하고자하는 것을 검증해줄 수 있다.
결국 이 테스트는 Service만 고립해서 테스트하는 코드는 아니었다.
하지만 내가 검증하고 싶은 동작을 가장 잘 검증하는 테스트였다.
처음에는 Mock, Fake, 테스트 DB 중 어떤 방식이 정답인지 알고 싶었다.
하지만 코치와 이야기하면서 질문이 조금 바뀌었다.
내가 지금 검증하고 싶은 것은 무엇인가?
테스트는 결국 검증을 하기 위한 코드다.
그렇다면 테스트 방식보다 먼저 정해야 하는 것은 검증 대상이다.
Mock은 빠르지만 실제 의존성의 동작을 대체하기 때문에 상대적으로 가짜 테스트에 가까워질 수 있다.테스트 DB는 느리지만, 실제 조회 로직을 함께 검증할 수 있어 상대적으로 실제 테스트에 가까워질 수 있다.Fake는 내가 구현하는 방식에 따라 그 사이 어딘가에 위치한다.
그래서 지금은 이렇게 생각한다.
결국 Mock, Fake, 테스트 DB는 모두 테스트를 수행하기 위한 도구다.
어떤 도구를 선택하든, 그 도구가 내가 검증하고 싶은 것을 제대로 검증해줄 수 있다면 충분히 의미 있는 선택이 될 수 있다.
반대로 아무리 빠르고 편한 도구라도, 내가 검증하고 싶은 동작에 대한 신뢰를 주지 못한다면 좋은 선택이라고 보기 어렵다.
그래서 이제는 먼저 테스트 방식을 고르기보다, 다음 질문을 먼저 하려고 한다.
이 테스트를 통해 검증하고자 하는 것이 무엇이지?
이 질문에 따라 선택지는 달라질 수 있다.
| 검증하고 싶은 것 | 선택할 수 있는 방식 |
|---|---|
| Service 내부 분기, 예외 변환, 특정 협력 객체와의 상호작용 | Mock |
| 단순 저장/조회 흐름, 가벼운 상태 기반 테스트 | Fake |
| SQL, 조인, 집계, 정렬, DB 제약 조건 | 테스트 DB |
물론 이 표도 정답은 아니다.
같은 기능이라도 어떤 부분을 검증하고 싶은지에 따라 선택은 달라질 수 있다.
예를 들어 같은 인기 테마 조회 기능이라도,
Service가 PopularThemePeriod를 올바르게 만들어 Dao에 인자로 전달하는지만 보고 싶다면 Mock으로도 충분할 수 있다.
하지만 최근 7일간의 예약만 집계되는지,
테마별 예약 수가 올바르게 계산되는지,
예약 수 기준으로 정렬되는지를 검증하고 싶다면 테스트 DB를 사용하는 편이 더 적절하다.
Service 테스트를 서로 다른 3가지 방식으로 구현해나가며 내가 얻은 결론은 다음과 같다.
이 테스트가 어떤 신뢰를 주는지, 그리고 내가 검증하고 싶은 동작을 정말 검증하고 있는지가 중요하고 그에 따라 필요한 테스트 도구를 선택하면 되는 것이다.