티스토리 뷰
테스트 코드의 필요성은 많이 들어왔지만 막상 "그래서 어떻게 하는 건데?"라는 질문에 명확하게 답하기 어려웠다. 그래서 이번에 TDD에 대해 공부하면서 정리해봤다. TDD가 뭔지, 어떤 종류의 테스트가 있는지, 그리고 실제로 어떤 순서로 개발하면 되는지를 회원가입 예시와 함께 정리했다.
TDD란?
Test Driven Development (테스트 주도 개발) 의 약자로, 테스트 코드를 먼저 작성하고, 테스트를 통과할 정도의 실제 코드를 작성하며 개발하는 방식이다.
TDD의 3단계 사이클

TDD는 아래 3단계를 계속 반복하며 개발한다.
| 단계 | 설명 |
|---|---|
| Red | 구현하려는 기능에 대한 테스트 코드를 먼저 작성한다. 아직 기능이 없으니 당연히 실패한다. |
| Green | 테스트를 통과할 수 있을 정도의 최소한의 코드만 작성해서 테스트를 성공시킨다. |
| Refactor | 테스트 통과를 유지하면서 코드의 중복을 제거하고, 가독성을 높이는 등 코드를 개선한다. |
TDD의 장점
- 시스템 신뢰성 강화 : 테스트 코드 덕분에 버그를 사전에 잡아내서 시스템 결함이 줄어든다.
- 자신감 부여 : 테스트 코드가 안전망 역할을 해주니까 코드 수정할 때 심리적으로 편하다.
- 깔끔한 설계 : 테스트를 먼저 생각하다 보면 자연스럽게 모듈화가 잘 된 설계를 고민하게 된다.
- 문서 역할 : 테스트 코드 자체가 "이 코드가 어떻게 동작해야 하는지"를 보여주는 살아있는 문서가 된다.
TDD의 단점
물론 TDD가 만능은 아니다.
- 초기 개발 속도 저하 : 테스트 코드를 먼저 작성해야 하니 초기엔 느리다. 다만 장기적으로는 버그 수정 비용이 줄어들어 전체 비용은 오히려 감소하는 경향이 있다.
- 프로토타이핑 단계에서의 부담 : 요구사항이 자주 바뀌는 초기 탐색 단계에서는 테스트 코드까지 함께 수정해야 해서 부담이 될 수 있다.
- 테스트 코드 유지보수 비용 : 테스트 코드도 결국 코드다. 프로덕션 코드가 바뀌면 테스트 코드도 함께 관리해야 한다.
- 학습 곡선 : 좋은 테스트를 작성하는 것 자체가 별도의 스킬이라 팀 전체가 익숙해지기까지 시간이 필요하다.
예시: VIP 할인 로직으로 TDD 설계 이점 느껴보기
상황
주문 서비스에 VIP 할인 로직을 추가한다고 해보자.
전통적 방식 — 기능 구현 후 테스트 작성
가장 간단하게 주문하기 로직에 할인 정책을 넣을 수 있다.
public class OrderService {
private OrderRepository orderRepository;
public void 주문하기(int orderId, int amount, boolean isVip) {
int discount = 0;
if (isVip) {
discount = (int)(amount * 0.2);
}
int finalPrice = amount - discount;
orderRepository.saveOrder(orderId, finalPrice);
}
}
할인 로직이 잘 적용됐는지 테스트하고 싶으면?
@Test
void VIP_20퍼센트_할인_적용() {
// Given
OrderRepository mockRepo = mock(OrderRepository.class);
OrderService service = new OrderService(mockRepo);
// When
service.주문하기(1, 10000, true);
// Then
verify(mockRepo).saveOrder(1, 8000); // 8000이 저장됐는지 확인
}
이 방식의 문제점:
- 할인 계산 로직(
amount * 0.2)을 직접 테스트할 수 없다 - Service 메서드를 호출해야만 간접적으로 검증 가능하다
- 할인 로직만 변경하고 싶어도 Service를 수정해야 한다
- 할인 로직을 다른 곳에서 재사용할 수 없다
TDD 방식 — 테스트를 먼저 작성
테스트를 먼저 작성하면서 "할인 로직을 어디서 처리할지" 고민하게 된다.
@Test
void VIP_20퍼센트_할인() {
// Given
DiscountPolicy policy = new VipDiscountPolicy();
// When
int discount = policy.calculate(10000);
// Then
assertThat(discount).isEqualTo(2000);
}
이 테스트를 통과시키기 위한 최소한의 코드:
public interface DiscountPolicy {
int calculate(int amount);
}
public class VipDiscountPolicy implements DiscountPolicy {
@Override
public int calculate(int amount) {
return (int)(amount * 0.2);
}
}
Service는 할인 정책에 위임하는 구조가 된다 (생성자 주입으로 DI 연결):
public class OrderService {
private final OrderRepository orderRepository;
private final DiscountPolicy discountPolicy;
public OrderService(OrderRepository orderRepository, DiscountPolicy discountPolicy) {
this.orderRepository = orderRepository;
this.discountPolicy = discountPolicy;
}
public void 주문하기(int orderId, int amount) {
int discount = discountPolicy.calculate(amount); // 위임
int finalPrice = amount - discount;
orderRepository.saveOrder(orderId, finalPrice);
}
}
TDD를 하다 보니 자연스럽게 이런 결과가 나왔다:
- 검증할 대상만 검증하는 테스트 코드를 작성하게 됐고 (할인 정책)
- 자연스럽게 로직이 분리됐고 (주문 로직과 할인 정책 분리)
- 결과적으로 책임이 잘 분리되고 모듈화가 잘 된 설계가 됐다
TDD는 단순히 버그를 줄이는 것을 넘어, 테스트 가능한 코드를 만들려는 과정에서 자연스럽게 좋은 설계 원칙들을 따르게 만든다.
테스트 종류
테스트를 작성하기 전에, 어떤 종류의 테스트가 있는지 알아두자.
테스트 피라미드
테스트는 아래와 같은 피라미드 구조로 구성하는 것이 이상적이다. 아래로 갈수록 많이, 위로 갈수록 적게 작성한다.

단위 테스트를 가장 많이 작성해서 빠른 피드백을 확보하고, 통합 테스트로 컴포넌트 간 정합성을 검증하고, E2E 테스트는 핵심 시나리오 위주로 최소한으로 유지하는 게 비용 대비 효과적이다.
단위 테스트 (Unit Test)
메서드 단위로 로직을 검증하는 테스트다. 테스트하려는 대상 외의 의존성은 Mock이나 Stub으로 대체해서 검증하고 싶은 단위만 테스트한다. 실행 속도가 ms 단위로 매우 빠르고, 문제가 어디서 발생했는지 정확히 알 수 있다는 게 장점이다. JUnit, AssertJ, Mockito 같은 도구를 사용한다.
Domain 단위 테스트 - 엔티티 로직만 검증
@Test
void 회원_생성시_상태는_ACTIVE이다() {
Member member = new Member("loginId", "encodedPassword", "홍길동", "19950101", "test@test.com");
assertThat(member.getStatus()).isEqualTo(MemberStatus.ACTIVE);
}
Service 단위 테스트 - Repository는 Mock
@Test
void 회원가입_성공시_회원이_저장된다() {
given(memberRepository.existsByLoginId("testId")).willReturn(false);
signupService.signup(request);
verify(memberRepository).save(any(Member.class));
}
Controller 단위 테스트 - Service는 Mock
@Test
void 회원가입_성공시_201을_반환한다() throws Exception {
given(signupService.signup(any())).willReturn(new SignupResult(1L));
mockMvc.perform(post("/api/members/signup")...)
.andExpect(status().isCreated());
}
통합 테스트 (Integration Test)
여러 컴포넌트가 함께 연동되어 동작하는 것을 검증하는 테스트다. 실제 의존성을 사용하되, 외부 시스템은 테스트용 환경으로 대체할 수 있다. 단위 테스트만으로는 발견하기 어려운 컴포넌트 간 정합성 문제를 찾을 수 있다는 게 장점이다. @SpringBootTest, @DataJpaTest, Testcontainers 같은 도구를 사용한다.
@SpringBootTest
@Transactional
class SignupIntegrationTest {
@Autowired SignupService signupService;
@Autowired MemberRepository memberRepository;
@Test
void 회원가입_전체_흐름() {
// given
SignupRequest request = new SignupRequest(
"testId", "Pass1234!", "홍길동", "19950101", "test@test.com"
);
// when
signupService.signup(request);
// then - 실제 DB에서 조회
Member saved = memberRepository.findByLoginId("testId").orElseThrow();
assertThat(saved.getName()).isEqualTo("홍길동");
assertThat(saved.getPassword()).isNotEqualTo("Pass1234!"); // 암호화 확인
}
@Test
void 중복_아이디로_가입시_예외발생() {
// given - 먼저 한 명 가입
signupService.signup(new SignupRequest("testId", "Pass1234!", "홍길동", "19950101", "a@a.com"));
// when & then - 같은 아이디로 다시 가입
assertThatThrownBy(() ->
signupService.signup(new SignupRequest("testId", "Pass1234!", "김철수", "19900101", "b@b.com"))
).isInstanceOf(DuplicateLoginIdException.class);
}
}
E2E 테스트 (End-to-End Test)
사용자 관점에서 전체 시스템을 테스트한다. 실제 API 호출, 실제 DB, 실제 외부 시스템 연동까지 포함해서 사용자가 실제로 겪을 수 있는 오류를 사전에 방지하는 게 목적이다. TestRestTemplate, Selenium, Cypress 같은 도구를 사용한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SignupE2ETest {
@Autowired TestRestTemplate restTemplate;
@Autowired MemberRepository memberRepository;
@Test
void 회원가입_성공시_201과_회원ID를_반환한다() {
// given
SignupRequest request = new SignupRequest(
"testId", "Pass1234!", "홍길동", "19950101", "test@test.com"
);
// when - 실제 HTTP 요청
ResponseEntity<SignupResponse> response = restTemplate.postForEntity(
"/api/members/signup",
request,
SignupResponse.class
);
// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getMemberId()).isNotNull();
// DB에도 실제로 저장되었는지 확인
assertThat(memberRepository.findByLoginId("testId")).isPresent();
}
@Test
void 중복_아이디면_409를_반환한다() {
// given - 먼저 가입
restTemplate.postForEntity("/api/members/signup",
new SignupRequest("testId", "Pass1234!", "홍길동", "19950101", "a@a.com"),
SignupResponse.class);
// when - 같은 아이디로 다시 요청
ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
"/api/members/signup",
new SignupRequest("testId", "Pass1234!", "김철수", "19900101", "b@b.com"),
ErrorResponse.class
);
// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
}
테스트 작성 전략

Inside-Out 방식이 TDD를 처음 제안한 Kent Beck의 접근에 가깝다고 한다. 가장 작은 단위부터 테스트를 작성하고 점차 바깥 레이어로 조합해나가는 방식이다.
TDD 개발 가이드 (Inside-Out)
1단계: 요구사항 각 레이어별 담당 책임 분리
먼저 요구사항을 어떤 레이어에서 처리해야 하는지 설계한다.
예시 : 회원가입
요구사항
| 대상 | 규칙 |
|---|---|
| 아이디 (ID) | 중복될 수 없다 |
| 아이디 (ID) | 영어 소문자・숫자로만 구성되어야 한다 |
| 아이디 (ID) | 2~12자여야 한다 |
| 비밀번호 (PW) | 8~20자여야 한다 |
| 비밀번호 (PW) | 영어・특수문자・숫자만 포함되어야 한다 |
| 비밀번호 (PW) | 암호화해서 저장해야 한다 |
| 이름 (Name) | 필수값 |
| 이메일 (Email) | 필수값 |
각 레이어별 책임 분리
| 레이어 | 담당 책임 | 역할 |
|---|---|---|
| Controller | ID: 2~12자, PW: 8~20자 검증 | 빠른 실패(Fail-Fast)를 위한 1차 방어선. Bean Validation(@Valid) 활용 |
| Service | ID 중복 체크, PW 암호화 실행 | 외부 의존성(DB, Encoder)이 필요한 비즈니스 로직 처리 |
| Domain | ID: 2~12자, 소문자·숫자만 PW: 8~20자, 영어·특수문자·숫자만 Name, Email: 필수값 |
도메인 무결성 보장을 위한 최종 방어선. Controller를 거치지 않는 경로(배치, 내부 호출)에서도 보호 |
2단계: 요구사항을 테스트 목록으로 분해
책임을 분리했으면 테스트 목록을 추출한다. 하나의 테스트 케이스가 하나의 검증만 수행하도록 작게 분리하는 게 핵심이다.
Domain
| 검증 대상 | 테스트 케이스 |
|---|---|
| ID 길이 | ID가 1자이면 예외 발생 |
| ID 길이 | ID가 13자이면 예외 발생 |
| ID 길이 | ID가 2자이면 정상 생성 |
| ID 길이 | ID가 12자이면 정상 생성 |
| ID 구성 | ID에 한글이 포함되면 예외 발생 |
| ID 구성 | ID에 대문자가 포함되면 예외 발생 |
| ID 구성 | ID에 특수문자가 포함되면 예외 발생 |
| PW 길이 | PW가 7자이면 예외 발생 |
| PW 길이 | PW가 21자이면 예외 발생 |
| PW 길이 | PW가 8자이면 정상 생성 |
| PW 길이 | PW가 20자이면 정상 생성 |
| PW 구성 | PW에 한글이 포함되면 예외 발생 |
| PW 구성 | PW에 공백이 포함되면 예외 발생 |
| Name 필수 | Name이 null이면 예외 발생 |
| Name 필수 | Name이 빈 문자열이면 예외 발생 |
| Email 필수 | Email이 null이면 예외 발생 |
| Email 필수 | Email이 빈 문자열이면 예외 발생 |
Service
| 검증 대상 | 테스트 케이스 |
|---|---|
| ID 중복 | 이미 존재하는 ID로 가입 시 예외 발생 |
| ID 중복 | 존재하지 않는 ID로 가입 시 정상 저장 |
| PW 암호화 | 저장된 비밀번호가 원본과 다름 (암호화 확인) |
| PW 암호화 | PasswordEncoder.encode()가 호출됨 |
Controller
| 검증 대상 | 테스트 케이스 |
|---|---|
| ID 유효성 | ID가 1자이면 400 반환 |
| ID 유효성 | ID가 13자이면 400 반환 |
| PW 유효성 | PW가 7자이면 400 반환 |
| PW 유효성 | PW가 21자이면 400 반환 |
| 정상 요청 | 유효한 요청 시 201 반환 |
| 정상 요청 | 응답에 회원 ID 포함 |
3단계: 테스트 작성 (Red)
테스트 목록을 바탕으로 테스트 코드를 작성한다. 아직 구현 코드가 없으니 당연히 실패한다.
@Test
void 비밀번호가_8자_미만이면_실패한다() {
// given
String password = "Short1!";
// when & then
assertThatThrownBy(() -> PasswordValidator.validate(password))
.isInstanceOf(IllegalArgumentException.class);
}
4단계: 최소 구현 (Green)
테스트를 통과할 정도의 코드만 작성한다. 완벽하지 않아도 된다. 일단 통과만 시키면 된다.
public class PasswordValidator {
public static void validate(String password) {
if (password.length() < 8) {
throw new IllegalArgumentException("비밀번호는 8자 이상이어야 합니다.");
}
}
}
5단계: 리팩토링 (Refactor)
테스트가 통과하는 상태를 유지하면서 코드를 개선한다. 여기서는 매직 넘버를 상수로 추출했다.
public class PasswordValidator {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 20;
public static void validate(String password) {
if (password.length() < MIN_LENGTH) {
throw new IllegalArgumentException("비밀번호는 " + MIN_LENGTH + "자 이상이어야 합니다.");
}
}
}
6단계: 반복
다음 테스트 케이스로 이동해서 Red → Green → Refactor 사이클을 반복한다. 모든 요구사항이 구현될 때까지 진행하면 된다.
한 줄 요약
지금까지 TDD의 전반적인 개념에 대해서 알아봤다. TDD는 테스트를 먼저 작성(Red) 하고 → 최소한의 코드로 통과(Green) 시킨 뒤 → 리팩토링(Refactor) 하는 사이클을 반복하며 점진적으로 기능을 완성하는 개발 방식이다.
⚠️ 주의해야하는 점은 테스트 코드 작성에만 집중한 나머지 설계를 생략하지 말자. 요구사항 분석과 레이어별 책임 분리 없이 바로 테스트를 작성하면 중구난방의 테스트 코드가 만들어진다.
'언어 > Java' 카테고리의 다른 글
| VO는 왜 불변이어야 하는가? (0) | 2026.02.08 |
|---|---|
| VO, 언제 쓰고 언제 쓰지 말아야 할까 (0) | 2026.02.08 |
| CompletableFuture 가이드 (0) | 2026.01.01 |
| 애플리케이션 로그 분석 가이드 (0) | 2025.12.28 |
| Record 살펴보기 (0) | 2025.09.14 |
