실무에서 배운 테스트 코드 — 단위 테스트와 통합 테스트 정확히 이해하기
TL;DR
테스트의 중요성과 1년간 레거시 코드를 테스트 가능한 구조로 개선하며 직접 경험하고 얻은 인사이트를 정리한 글입니다.
들어가며
불과 1년 전만 해도 회사 코드는 서비스 레이어에 비즈니스 로직이 구현되어 있어 테스트 코드를 작성하기가 어려웠다. 테스트 코드가 없으니, 당연히 배포할 때마다 항상 “버그가 나지 않을까?”라는 걱정이 따라왔다.
그 후 1년 남짓한 시간 동안 인프런의 테스트 강의와 외부 TDD & 클린코드 교육 과정을 들으며 레거시 코드를 하나씩 개선해 나갔다. 이제는 테스트 코드에 꽤 익숙해졌다고 생각했지만, 최근 다른 개발자분들과 이야기를 나누면서 단위 테스트와 통합 테스트에 대해 여전히 헷갈리고 있었다는 걸 깨달았다.
이 글은 그런 나 자신을 위해, 그리고 테스트를 막 배우기 시작한 개발자들을 위해 쓴 글이다. 왜 테스트를 해야 하는지, 좋은 테스트는 무엇인지, 단위 테스트와 통합 테스트는 어떻게 다른지를 지난 1년간의 경험을 바탕으로 정리해 보았다.
테스트 없이 코딩하던 시절
버그가 발생하면 서비스 사용자 경험은 나빠지고, QA나 동료 개발자까지 문제를 함께 추적해야 했다. 운영 중인 서비스에서 생긴 버그는 생각보다 훨씬 비싼 대가를 치르게 만든다.
문제는 테스트가 없으면 이 비싼 비용을 계속 반복해서 지불해야 한다는 점이다. 버그를 고쳐도 비슷한 문제가 다시 생기고, 수정이 점점 두려워졌다.
그러다 테스트 코드를 하나씩 도입하면서 조금씩 달라졌다. 테스트는 가장 빠르고 저렴한 피드백 수단이었다. 코드를 수정한 뒤 기존 테스트를 돌려보며 다른 곳에 문제가 없음을 확인할 수 있었고, 배포 후에도 자연스레 심리적인 안정감이 생겼다.
버그가 생기더라도 한 번 발생한 케이스를 테스트로 남겨두면, 그 문제는 다시는 같은 방식으로 나타나지 않는다. 이 작은 안전망들이 쌓이면서, 수정은 덜 두렵고 리팩토링은 훨씬 더 용감해졌다.
무엇보다 테스트 코드는 단순한 검증 도구가 아니었다. 작성하면서 오히려 서비스의 규칙을 스스로 정리하고 설명하는 과정이 됐다. “이 기능은 어떤 조건에서 동작해야 하지?”를 고민하다 보면 자연스럽게 도메인을 더 깊이 이해하게 된다.
그리고 이렇게 작성된 테스트는 일종의 살아있는 문서가 된다. 동료 개발자도 코드를 보기 전에 테스트만 봐도 “이 서비스가 어떤 규칙으로 움직이는지” 바로 이해할 수 있었다.
좋은 테스트를 만들어가는 과정
처음엔 단순히 “테스트가 성공했네? 끝!”이라고 생각했다. 그땐 테스트가 돌아가기만 하면 충분하다고 믿었다.
하지만 테스트가 늘어나고 유지보수가 많아지면서, ‘좋은 테스트’와 ‘나쁜 테스트’의 차이를 뼈저리게 느꼈다. 특히, 코드 구조에 따라 테스트의 난이도가 극명하게 달라진다는 걸 경험했다.
테스트하기 어려운 코드 vs 테스트하기 쉬운 코드
테스트하기 어려운 코드를 먼저 보자.
Before - 테스트하기 어려운 서비스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 비즈니스 로직이 서비스에 집중되어 있음
public void deleteQuestion(long questionId) {
Question question = repository.findById(questionId); // DB 의존
if (!question.isOwner(user)) { // 권한 검증
throw new CannotDeleteException();
}
for (Answer answer : question.getAnswers()) { // 비즈니스 로직
if (!answer.isOwner(user)) {
throw new CannotDeleteException();
}
}
// ... 삭제 처리
}
이때는 테스트를 작성하려고 해도 막막했다. DB 조회, 권한 검증, 비즈니스 로직이 한 메서드에 얽혀 있어서 로직만 검증하고 싶어도 Repository를 Mock으로 만들어야 했다. “테스트하기 어려운 코드”라는 게 이런 거구나, 그제야 실감했다.
After - 테스트하기 쉬운 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 서비스는 흐름 제어만
public void deleteQuestion(long questionId) {
Question question = repository.findById(questionId);
question.delete(user); // 도메인 객체에 위임
saveDeleteHistory(question);
}
// Question 도메인 객체가 비즈니스 로직 담당
public void delete(User user) {
if (isNotOwner(user)) {
throw new CannotDeleteException();
}
answers.validateOwner(user); // 일급 컬렉션 활용
this.deleted = true;
}
도메인 객체(Question)에 책임을 넘기자 상황이 완전히 달라졌다. 이제 도메인만 두고 DB 없이 순수한 단위 테스트를 돌릴 수 있었고, 서비스 레이어는 Mock 기반의 통합 테스트로 충분했다.
결국 깨달았다. “좋은 테스트는 좋은 설계에서 시작된다.” 비즈니스 로직을 도메인으로 옮기자 테스트는 자연스럽게 쉬워졌다.
이 변화를 그림으로 표현하면 다음과 같다:
💡 자세한 리팩토링 과정은 서비스 계층 다이어트: Thin Service로 개선하기에서 확인하실 수 있습니다.
읽기 어렵고 이해하기 힘든 테스트는 결국 유지보수를 힘들게 한다
지금은 테스트를 간단하고 명확하게 작성하려고 한다. 프로덕션 코드와 달리 테스트는 의도를 드러내는 중복이 있어도 괜찮다고 느낀다. @ParameterizedTest, @DisplayName, 의미 있는 변수명을 적극 활용해 “이 테스트가 무엇을 검증하려는가”를 명확히 표현하려고 한다.
단순히 “잘 동작한다”가 아니라 “이런 상황에서도 잘 동작할까?”를 검증하자
예전엔 기능이 정상적으로 동작하는지 확인하는 해피케이스에 집중해서 테스트를 작성하였다. 지금은 해피케이스는 기본이고, “이런 상황에서도 괜찮을까?”에 집중한다. 특히 경계값이나 예외 상황을 다루는 테스트로 버그를 수정한 적이 한두 번이 아니다. 복잡한 비즈니스 로직에 테스트를 추가할 때마다 “이거 없었으면 진짜 큰일 날 뻔했다”는 생각이 많이 들었다.
테스트는 항상 성공해야 한다
테스트는 신뢰가 생명이다. 실행할 때마다 결과가 달라진다면, 그건 테스트가 아니다. 그래서 LocalDateTime.now()나 Random 같은 값은 테스트 대상에서 분리해 주입(Injection) 하는 습관을 들였다. 이 개념은 테스트는 어떻게 좋은 코드를 만드는가 (feat. 험블 객체 패턴) 글에서도 잘 설명되어 있다.
이런 경험을 바탕으로, 도서 『단위 테스트(Unit Testing)』에서 말하는 좋은 테스트의 네 가지 기준에도 공감하게 됐다.
- 회귀 방지(Regression Prevention) — 기존 기능이 망가지지 않도록 지켜준다.
- 리팩터링 내성(Refactoring Tolerance) — 코드를 고쳐도 테스트가 깨지지 않는다.
- 빠른 피드백(Fast Feedback) — 테스트가 빨라야 자주 돌릴 수 있다.
- 유지보수성(Maintainability) — 명확하고 직관적이어야 한다.
단위 테스트와 통합 테스트, 1년간의 오해
먼저 단위 테스트와 통합 테스트의 정의부터 살펴보자.
단위 테스트(Unit Test)
- 하나의 코드 단위(클래스, 메서드 등)가 올바르게 동작하는지를 검증하는 테스트다.
- 외부 시스템(DB, 네트워크 등)에 의존하지 않고, 필요한 부분은 Mock, Stub 등으로 대체한다.
- 빠르고 독립적으로 실행되며, 로직 자체의 정확성을 보장한다.
통합 테스트(Integration Test)
- 여러 모듈이나 계층이 함께 동작할 때 올바르게 작동하는지를 검증하는 테스트다.
- 실제 데이터베이스, 외부 API 등 외부 의존성과 함께 시스템 전체의 흐름과 결과를 검증한다.
- 단위 테스트보다 느리지만, 현실적인 신뢰도를 확보할 수 있다.
최근까지 단위 테스트와 통합 테스트의 정의를 잘못 이해하고 있었다. 경험적으로 단위 테스트 = 목킹 없이 객체 하나만 테스트라고 생각했다. 그래서 서비스 레이어에서 레포지토리를 목킹하면 “그건 통합 테스트겠지”라고 단정 지었다.
하지만 그건 완전히 잘못된 생각이었다. 레포지토리를 목킹하더라도, 테스트의 목적이 “비즈니스 로직이 올바르게 동작하는가”에 있다면 그건 분명히 단위 테스트다.
반대로, 목킹 없이 실제 의존성과 함께 실행해 시스템 전체의 흐름을 검증한다면, 그때가 바로 통합 테스트다.
단위 테스트와 통합 테스트를 표로 비교해보자.
단위 테스트 vs 통합 테스트 비교
| 구분 | 단위 테스트 | 통합 테스트 |
|---|---|---|
| 목적 | 개별 로직 검증 | 모듈/시스템 연동 검증 |
| 범위 | 작음 | 큼 |
| 속도 | 빠름 | 느림 |
| 의존성 | 없음 (Mock 사용) | 실제 연동 |
| 실패 원인 | 코드 로직 문제 | 설정, 연결, 연동 문제 |
| 예시 | CalculatorTest | UserServiceIntegrationTest |
『단위 테스트(Unit Testing)』에서 말하는 좋은 테스트 4대 요소 관점에서도 비교해보면 아래와 같다.
| 테스트 유형 | 회귀 방지 | 리팩터링 내성 | 빠른 피드백 | 유지보수성 |
|---|---|---|---|---|
| 단위 테스트 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 통합 테스트 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
통합 테스트에 눈을 뜨다
서비스 레이어의 비즈니스 로직을 객체로 분리하고, 서비스는 흐름 제어에 집중하는 구조로 바꾸면서 단위 테스트만으로도 코드의 안정성을 충분히 확인할 수 있었다.
그러던 중, 루퍼스 멘토링에서 들은 한마디가 내 테스트 습관을 바꿔 놓았다. “통합 테스트를 더 많이 작성해보자.”
통합 테스트는 단위 테스트보다 작성이 복잡하고, 실행도 느리며, 유지보수 비용도 크다. 그래서 그동안은 빠른 피드백이 가능한 단위 테스트에 집중해왔다.
하지만 통합 테스트는 단위 테스트로는 검증할 수 없는 모듈 간 상호작용과 실제 환경의 흐름을 다룬다. 실제 데이터베이스나 외부 API와 연결된 상태에서 실행되기 때문에, 더 현실적이고 신뢰할 수 있는 검증이 가능하다.
최근에는 AI를 활용해 테스트 코드를 생성하면서, 통합 테스트를 작성하는 부담도 많이 줄었다. 여전히 단위 테스트보다 느리지만, 배포 후 버그를 잡는 비용을 생각하면 훨씬 효율적이다.
그래서 앞으로는 통합 테스트를 더 많이 작성해보려고 한다.
테스트를 도와주는 친구들, 테스트 더블
앞서 단위 테스트를 설명하면서 목킹(Mock)이라는 단어를 여러 번 언급했다. 테스트를 하다 보면, 실제 객체 대신 가짜 객체가 필요할 때가 있다. 이런 대체 객체들을 테스트 더블(Test Double)이라고 부른다.
말 그대로, 영화 촬영에서 진짜 배우 대신 위험한 장면을 대신하는 대역(Double) 같은 존재다.
그럼 테스트 더블에는 어떤 종류가 있을까? 아래 표로 정리해보면 각각의 역할이 한눈에 보인다.
| 종류 | 역할 | 비유 |
|---|---|---|
| Dummy | 단순히 자리를 채운다. 로직에는 쓰이지 않는다. | 배우 대신 조명 테스트용으로 세워둔 인형 |
| Fake | 간단한 구현체로 실제 동작을 흉내낸다. | 진짜 배우 대신 대충 리허설만 하는 대역 배우 |
| Stub | 미리 정해둔 값을 돌려준다. | “이 질문엔 이 대답만 한다”는 대본 배우 |
| Spy | Stub처럼 동작하지만 호출 기록을 남긴다. | 누가 몇 번 불렀는지 기록하는 감시자 |
| Mock | 호출 자체를 검증한다. | 대사 타이밍과 동작을 체크하는 리허설 감독 |
이 중에서도 실무에서는 Stub, Mock, Fake 세 가지가 가장 자주 등장한다. 아래 예시로 각각의 쓰임을 살펴보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 📌 Stub 예시 - "미리 정해둔 값 반환"
@Test
void 사용자_조회_성공() {
// Given: Repository가 특정 ID에 대해 항상 홍길동을 반환하도록 설정
when(userRepository.findById(1L))
.thenReturn(Optional.of(new User("홍길동")));
// When: 서비스 호출
User user = userService.getUser(1L);
// Then: 로직 검증 (호출 여부는 검증 안 함)
assertThat(user.getName()).isEqualTo("홍길동");
}
// 📌 Mock 예시 - "호출 자체를 검증"
@Test
void 주문_생성_시_이메일_발송() {
// Given
User user = new User("hong@test.com");
Product product = new Product("책");
// When
orderService.createOrder(user, product);
// Then: 메일이 정확히 1번 발송되었는지 검증
verify(emailService, times(1))
.sendOrderConfirmation("hong@test.com");
}
// 📌 Fake 예시 - "간단한 구현체"
class FakeUserRepository implements UserRepository {
private Map<Long, User> data = new HashMap<>();
private AtomicLong idGenerator = new AtomicLong(1L);
@Override
public User save(User user) {
Long id = idGenerator.getAndIncrement();
data.put(id, user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(data.get(id));
}
}
언제 어떤 테스트 더블을 쓸까?
- Stub → 반환값만 필요할 때 (빠른 단위 테스트용)
- Mock → 호출 순서나 횟수가 중요한 테스트
- Fake → 여러 테스트에서 재사용할 수 있는 간단한 구현체
정리하자면 테스트 더블은 단순한 “가짜 객체”가 아니라, 테스트 목적에 맞게 현실을 대체하는 전략 도구다. 적절히 활용하면 테스트가 훨씬 가볍고 명확해진다.
하지만 과하면 독이 된다. 테스트 더블은 분명 테스트를 빠르고 편하게 만들어 주지만, 너무 많이 사용하면 오히려 테스트가 불안정해진다. Mock이 늘어날수록 코드 한 줄만 바꿔도 테스트가 줄줄이 깨지는 일이 생긴다.
예전에는 테스트 작성 시 Mock에 의존하는 경우가 많았다. 하지만 리팩터링을 거듭할수록, Mock이 많을수록 작은 코드 변경에도 테스트가 연쇄적으로 깨지는 상황이 자주 발생했다.
그래서 지금은 통합 테스트에서는 Mock을 최소한으로만 사용하려 한다. 예를 들어, 외부 API나 결제, 메일 발송처럼 실제 호출이 부담되거나 부작용이 큰 부분만 Mock으로 처리하고, 그 외에는 가능한 한 실제 객체를 사용해 흐름 전체를 검증하는 편이 더 믿음직했다.
결국 중요한 건 속도와 신뢰성의 균형이다. 테스트 더블은 빠른 피드백을 위한 도구이지, 실제 시스템 동작을 완전히 대신하는 수단은 아니다. 이 균형을 잡는 순간부터, 테스트는 진짜로 개발을 도와주는 친구가 된다.
마무리
테스트가 없는 환경에서 테스트를 배우고 실무에 적용해 보니, 가장 크게 느낀 건 안정감이었다. 예전엔 버그가 날까 봐 코드 수정이 늘 조심스러웠지만, 테스트 가능한 구조로 바꾸고 나니 훨씬 유연해졌다. 코드의 응집도가 높아지고, 디버깅도 한결 수월해졌다.
지금은 테스트를 단순히 코드를 검증하는 도구로 보지 않는다. 테스트는 코드를 더 잘 이해하고, 구조를 개선하게 만드는 과정이었다. 테스트를 작성하다 보면 “이 로직의 책임은 어디에 있어야 할까?” 같은 질문을 스스로 던지게 되고, 그 과정이 결국 더 나은 설계로 이어졌다.
요즘은 설계의 트레이드오프를 고민할 때 “테스트하기 좋은 구조인가?”를 가장 먼저 떠올린다.
낯설어도 괜찮다. 오늘 작은 테스트 하나를 추가한다면, 그 한 줄이 코드를 바꿀 용기를 만들어 줄 것이라 생각한다.
