올해로 개발 4년 차이지만 TDD에 대한 경험은 부끄럽게도 처음이다. 보통 내가 근무해왔던 서비스 환경은 빠르게 기능 개발을 하고 배포해야 했었기에 내가 경험한 개발 플로우는 아래와 같았다.
기능 (controller / service 등 end to end 일괄) 작업 -> swagger-ui를 통해서 api 기능 검증 -> DB 데이터 확인 및 예외 케이스 테스트 -> 개발 브랜치에 반영 -> 프론트엔드에서 제공된 개발 서버 api 확인 및 적용 -> fix/release
이었다.
보통 이러한 개발 방식은 빠르게 기능을 개발하고 수정할 수 있지만, 거기에 따른 트레이드오프로써 코드 품질과 서비스의 안정성을 보장할 수 없게 된다.
그래서 내가 공부하면서 처음 이해한 TDD란
TDD(테스트 주도 개발) 이란
실제 코드를 작성하기 전에 테스트 코드로 기능을 명세하고, 테스트 코드를 통과시키면서 코드를 작성해 나가는 방식. 위 내용을 봤을 때는 테스트 기능 사항을 먼저 적고, 코드를 작성한다.. TDD라고 들어는 봤지만 이렇게 전체적인 개발 방법론인 것은 이번에 공부하면서 처음 알았다.
뭔가 새로운 프레임워크, 인터페이스, 라이브러리 등을 공부하거나 tool을 사용하는 것은 비교적 쉬운 편. 새로운 언어나 웹 백엔드 -> 프론트엔드 / 안드로이드 개발 같은 개발 분야를 바꾸는 것은 비교적 어려운 편이라고 느꼈는데
4년 동안 내가 해왔던 개발의 방법론 자체를 바꾼다는 것은 어려움 그 이상의 어색한 느낌이었다.

실패하는 기능 명세의 테스트를 먼저 작성(RED) -> 테스트 성공을 위한 코드 작성 -> 테스트 성공(GREEN) -> 리팩토링(Refactor) 으로써 코드 품질을 향상하고 메서드 단위의 기능이 수정될 경우 기존의 케이스를 한 번 더 테스트를 거치는 번거로움을 막아준다.
4년간 개발해온 개발 방식 이외로 새로운 방법론을 적용한다는 것이 몹시 낯설었다. 테스트 코드를 작성할 때 IDE에서 보여주는 빨간불은 습관적으로 불편했고, 테스트 코드를 작성하다가 중간에 자꾸 로직을 적기 십상이었다.
또한, TDD를 하게 되면 개발 속도가 느려지거나 너무 번거롭지 않을까 싶었지만, 내가 예상하는 장점들도 존재했다.
1. 당연하게도, 테스트 케이스를 정의해 놓고 작성하게 되면, 명시된 로직은 반드시 지켜질 것이며, 코드 품질이 올라갈 것이다.
2. 추가적인 개발 사항에 있어서 기존 요구 사항들과 충돌하는 일이 없어질 것이다.
TDD 의 테스트 피라미드

E2E = (end to end 테스트) controller 에서 부터 api 의 비즈니스 로직 전체 단위의 테스트
Integration = (통합 테스트) db 를 이용하여 실제 데이터를 통한 테스트 service/facade 계층 단위까지 테스트
Unit = (단위 테스트) 도메인 모델 단위의 테스트, 의존성 주입이 없이 jvm 단위의 테스트
로 학습하게 되었다. 일단 엔티티 데이터의 생성, 수정, 삭제에 있어서는 Unit 테스트를 기준으로, repository의 조회나 중복 여부 확인 등 서비스 로직적인 부분은 Integration, 그리고 api 전체적인 안정성에 대한 체크를 E2E로 작성할 예정이다.
진행하면서..
가장 큰 건 테스트 코드를 위한 메서드와 코드 포맷이 익숙하지 않아서, 테스트 코드를 작성하기 어려웠다. 그리고 기존에 나의 개발 방식에서는 멤버를 생성할 때 요구 조건을 Service에서 처리했는데 이렇게 되면 단위 테스트에서 service의 로직에 의존을 하기 때문에, 이것을 엔티티에서 처리할 수 있도록 변경하였다.
@RequiredArgsConstructor
@Service
@Slf4j
public class AuthService {
private final MemberRepository memberRepository;
private final PasswordValidator passwordValidator;
private final JwtTokenProvider jwtTokenProvider;
public LoginResponseDto matchIdAndPassword(LoginRequestDto loginRequestDto) {
Member loginUser = memberRepository.findByUserId(loginRequestDto.userId()).orElseThrow(
()-> new CustomException(ErrorCode.LOGIN_INVALID));
if (!passwordValidator.validatePassword(loginRequestDto.password(), loginUser.getPassword())) {
throw new CustomException(PASSWORD_INVALID);
}
String accessToken = jwtTokenProvider.createToken(loginUser, Token.ACCESS);
String refreshToken = jwtTokenProvider.createToken(loginUser, Token.REFRESH);
return new LoginResponseDto(accessToken, refreshToken, loginRequestDto.userId(),
loginUser.getEmail(), loginUser.getUsername(), loginUser.getRole());
}
public void createMember(MemberJoinRequestDto memberJoinRequestDto) {
try {
Member newMember = Member.builder()
.userId(memberJoinRequestDto.userId())
.password(passwordValidator.createHash(memberJoinRequestDto.password()))
.email(memberJoinRequestDto.email())
.username(memberJoinRequestDto.username())
.role(MemberRole.MEMBER)
.build();
memberRepository.save(newMember);
}catch (DataIntegrityViolationException e) {
throw new CustomException(MEMBER_CREATION_EXCEPTION);
} catch (ConstraintViolationException e) {
throw new CustomException(INVALID_INPUT_EXCEPTION);
} catch (Exception e) {
// 기타 예외
log.error("Unexpected error occurred during member registration", e);
throw new CustomException(MEMBER_CREATION_UNEXPECTED_EXCEPTION);
}
}
public void memberCreateValidator(MemberJoinRequestDto memberJoinRequestDto) {
//정규식 추가
if (memberRepository.existsByUserId(memberJoinRequestDto.userId())) {
throw new CustomException(EXIST_USERID_EXCEPTION);
}
if (memberRepository.existsByEmail(memberJoinRequestDto.email())) {
throw new CustomException(EXIST_EMAIL_EXCEPTION);
}
}
}
@Entity
@Table(name = "member")
public class MemberModel extends BaseEntity {
private static final String EMAIL_PATTERN = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
private static final String BIRTHDATE_PATTERN = "^\\d{8}$";
private static final String PASSWORD_PATTERN = "^[A-Za-z0-9\\p{Punct}]{8,16}$";
private String userId;
private String password;
private String email;
private String username;
private String birthDate;
protected MemberModel() {}
public MemberModel(String userId, String password, String email, String username, String birthDate) {
if (userId == null || userId.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다.");
}
if (!userId.matches("^[A-Za-z0-9]+$")) {
throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다.");
}
if (username == null || username.isBlank()) {
throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다.");
}
if (email == null || !email.matches(EMAIL_PATTERN)) {
throw new CoreException(ErrorType.BAD_REQUEST, "올바른 이메일 형식이 아닙니다.");
}
if (birthDate == null || !birthDate.matches(BIRTHDATE_PATTERN)) {
throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 YYYYMMDD 형식이어야 합니다.");
}
validatePassword(password, birthDate);
this.userId = userId;
this.password = hashPassword(password);
this.email = email;
this.username = username;
this.birthDate = birthDate;
}
테스트 규격을 각각 짜보면서, 너무 자신이 없었다. 내가 작성하는 테스트 코드가 의미가 있는 건지, 맞는 건지 잘 모르겠다. 일단 내가 아는 선까지 작성해보고, 외부 피드백 및 학습을 해보면서 고치고 리팩토링을 진행해야겠다.
그래도 내가 TDD를 작성하면서 이해하고 최대한 적용하려고 한 부분들은
Unit Test : 무조건 entity, service, dto 등 다른 것을 참조하지 않고 딱 하나의 계층 단위로만 분리해서 테스트 코드를 작성한다. E2E : controller to DB까지 요청의 시작부터 끝까지 테스트한다.
이 두 가지 개념은 확실했기에, 단위 테스트는 다른 서비스 레이어와 관여되지 않도록 테스트를 하였다.
처음 계획은 Unit 테스트를 모두 작성해보고 Integration -> E2E로 넘어가려다가 멘토님의 탑다운 방식 세션을 듣게 되어 E2E 작성을 하면서 Green을 만들고 -> 서비스 레이어로 넘어가 보는 것을 진행해보기로 했다. 진행하다 보니, Unit E2E 테스트 코드를 적고 Green 과정을 넘어가 보니 Integration 작성 전에 모든 로직이 작성되어 있어서, 조금 TDD 방식에 어긋난 생각은 들었다. 확실히 느낀 건 테스트 통과 요구 사항에 맞춰 개발하다 보니, 요구 사항을 놓칠 일이 없었다는 점이 와닿았다. 그리고 RED -> GREEN 단계로 조건에 맞게 개발을 진행하더라도, 이건 어디까지나 요구 사항에 충족할 뿐이지, Refactor의 과정이 무조건 필요한 완성도가 나오게 된다는 것도 알게 되었다.
AI 개발시대에서 왜 우리는 tdd를 하는지
클로드 코드 같은 AI 개발을 진행하게 되면, 항상 AI의 코드를 100퍼센트 신뢰하지 않기 때문에, 작업물을 내가 눈으로 보고 커밋을 하고 있다. 하지만 이제 TDD를 도입하게 되면서, 에이전트로 개발 요구 사항을 넣고 그 개발 내용을 TDD로 최소 요건을 통과시킨다는 것으로 요즘 같은 개발 시대에 더 중요하지 않나 하는 생각을 하게 된다.
'개발이야기 > 개발로그' 카테고리의 다른 글
| 인덱스 설계와 카디널리티 (0) | 2026.06.19 |
|---|---|
| 주문 기능에 숨어있는 트랜잭션 설계 이야기 (0) | 2026.06.12 |
| VLink 서비스 운영 1년간의 회고록 -2- (6) | 2025.09.01 |
| 서비스 운영중 이슈 대응 방안 (0) | 2025.08.28 |
| VLink 서비스 운영 1년간의 회고록 (8) | 2025.08.21 |
