주제에 대해 학습한 이유
구름 단기 KDT, 프로펙트 과정을 참여하며 이제 고도화를 해야 할 기간이 다가왔다.
고도화를 위해 자동화 테스트를 이제 도입해야 한다.
시작은 물론 단위 테스트(유닛 테스트)이다.
단위 테스트(유닛 테스트)를 작성하는 데 있어서 어려움이 있어서 어떻게 해야 좋은 테스트를 작성할 수 있을지, 코드를 어떻게 리팩토링해야 할 지 고민했다.
위의 과정을 여러 글과 강의, 책을 통해 조금이나마 배운 것 같다.
문제 정의
- 테스트를 어떻게 할 수 있는가?
- 그래서 단위 테스트로 어디까지 검증해야 하나?
- 현재 테스트 코드를 짜기 어려운 이유가 무엇일까?
문제 분석
- 먼저 나에게는 Junit, Mockito, Spring Test 등을 사용해 본 경험이 없다.
(하지만 대충 보니 Jest와 크게 차이가 없어 보여서 비슷하게 사용할 수 있을 것 같다.
물론 내부 구현이나 원리는 크게 다를 수 있지만.)
⇒ 이 부분은 Jest에서의 기능을 생각해서 Claude에 질문하거나 검색을 바탕으로 해결할 수 있을 것 같다. 그래서 단위 테스트로 어디까지 검증해야 하나?
이 질문은 이 글을 정리하기 전에 책을 보다가 문득 든 생각이다.
처음에 막연하게 생각한 것은 메서드의 동작을 검증, 검사하고 엣지 케이스(경계 값)들을 테스트하면 되지 않을까? 싶었다.- 그럼에도 불구하고 메서드의 동작을 예측하기가 어려웠다. 부스트캠프에서 모킹 같은 것들을 봐서(찍먹이지만)
이 많은 것들을 모킹해야 하나?
생각도 들었다.
테스트 코드는 왜 작성하기 어려울까?
테스트 공부 <책>
먼저 테스트에 대해 학습한 내용부터 정리를 해야 할 것 같다.
테스트의 과정을 알기 위해 이펙티브 소프트웨어 테스팅
이라는 책을 읽었다.
부스트캠프에서 같이 학습했던 캠퍼가 추천을 해줘서 접하게 되었는데 3달 전에 언젠간 읽겠지라며 사놓고 이제야 읽게 되었다.
1장
1장에서는 테스트에 대한 가상 시나리오와 단위 테스트/통합 테스트/시스템 테스트/수동 테스트의 테스트 피라미드 같은 것들을 이야기하며 테스트의 중요성을 상기시켜준다.

여기에 더해 저자가 강조하는 것은 테스트로 애플리케이션의 모든 버그를 없애려고 하지 말라는 것이었다.
물론 버그가 없으면 좋지만 이미 완벽한 테스트는 불가능한 것이 증명되어 있으며,
공학자의 시선으로 절충안을 마련하는 것이 중요하다고 강조하고 있다.
나는 이 부분을 보면서 모든 버그를 막으려고 하는 건 손바닥으로 하늘을 가리려고 한 일임을 깨달았다.
2장
2장에서는 명세 기반 테스트 기법을 적용해서 테스트 케이스를 작성하는 방법을 배운다.
이 방법은 유저 스토리, UML의 유스 케이스 같은 프로그램 요구사항을 테스트의 입력으로 사용하는 것이다.
우리 팀도 기획할 때 사용자 요구사항 명세서를 작성해서 이것을 활용하면 될 거라 생각했다.
요구사항은 세 부분으로 나눠진다.
- 프로그램이나 메서드는 무엇을 수행해야 하는가? → 비즈니스 로직
- 프로그램의 입력 데이터 → 입력은 추론의 기본
- 출력에 대한 추론은 프로그램이 무엇을 수행하고 입력이 어떻게 기대하는 출력으로 변환되는지 이해를 도움
그래서 가장 처음으로 해야 할 일은 프로그램이 잘 돌아가는 케이스를 확인해보는 것이다.
직접 요구사항에서 정의한 입력을 넣었을 때, 요구사항에서 정의한 출력(내가 기대한 출력)이 나오는지 확인하면 된다.
여기서부터 새로운 고민이 생겼다.
그래서 단위 테스트로 어디까지 검증해야 하나?
책의 예제는 입력된 구분자를 통해서 원하는 문자열을 자르는 메서드에 대한 아주 간단한 것이었다.
반면 내가 해야하는 테스트는 Repository와 다른 Service 계층 혹은 외부 API 호출이 섞여있는 코드였다.
내가 헷갈렸던 것은 내 메서드를 테스트하기 위해 Repository, 다른 Service 계층의 코드, 외부 API 호출까지 어떻게 테스트하지?
였다.
생각을 하면서 예전에 스쳐 지나갔던 정진욱님의 Testing, Oh my!
라는 글과 향로님의 테스트에 대한 글이 생각났다.
왜 어려웠을까?
코드를 한 번 살펴보자.
검색어를 저장하는 SearchHistoryService의 메서드 일부를 가져왔다.
// SearchHistoryService
public void recordUserSearch(SearchHistoryCreateRequestDto requestDto) {
User user = authService.getCurrentUser();
if (user == null) {
return;
}
Optional<SearchHistory> processed = processSearchKeyword(requestDto, user);
processed.ifPresent(searchHistoryRepository::save);
}
private Optional<SearchHistory> processSearchKeyword(SearchHistoryCreateRequestDto requestDto, User user) {
String keyword = requestDto.getKeyword().trim();
if (keyword.isEmpty()) {
return Optional.empty();
}
SearchHistory existingSearch = searchHistoryRepository.findByKeywordAndUserId(keyword, user.getId());
if (existingSearch != null) {
if (existingSearch.getIsDeleted()) {
existingSearch.setIsDeleted(false);
}
existingSearch.incrementSearchCount();
return Optional.of(existingSearch);
}
return Optional.of(SearchHistory.builder()
.keyword(keyword)
.isDeleted(false)
.user(user)
.searchCount(1L)
.build());
}
위 코드를 보면 private 함수인 processSearchKeyword를 recordUserSearch 메서드가 호출하고 있다.
그리고 각각의 함수는 SearchHistoryRepository나 AuthService를 호출하고 있다.
이 메서드를 테스트해보려면 가장 먼저 드는 생각이 무엇인가?
내가 들었던 의문들을 나열해보겠다.
- User가 있는지 테스트 해야 하나? 그러면 User가 있을 때 함수의 동작이 어떻게 되고, User가 없으면 어떻게 되는지를 넣어야 하는건가?
- SearchHistoryRepository는 모킹을 한다고 치자. 그러면 저 동작을 정의하고 반환하는 값을 내가 다 설정해줘야 하나?
- private으로 작성되어 있는 processSearchKeyword는 어떻게 하지? 저 로직도 검사를 해야하는 건가?
private은 어떻게 검사해야 하는거야?
지금이야 조건이 조금이니까 상관이 없는데 만약 비즈니스 로직에서 필요한 입력이 10개라 가정해보자.
각각의 입력이 있을 수도, 없을 수도 있으니까 2^10개의 상황을 모두 테스트해야 하는 너무 무서운 상황이 발생한다.
고민을 하던 찰나에 정진욱님의 Testing, Oh my!를 보게 되었다.
글을 보면서 테스트하기 어려운 코드에 대한 개념이 잡히기 시작했다.
물론 이 코드를 모킹으로 빡세게 처리하려면 할 수 있다.
하지만 나는 테스트 초보다. 이제 시작하는 새싹이 그런 레벨을 생각할 수 있다면 천재가 아닐까?
여러 강의와 글을 참고해서 내 코드에서 테스트 하기 어려웠던 이유를 추출해봤다.
- 외부와 연결된 부분을 테스트하려고 했다. 예를 들어 SearchHistoryRepository와 AuthService가 있다.
내가 작성한 코드가 제대로 돌아가는 지 확인하려면 앞서서 의존성을 주입하고 있는 상단의 2개 빈이 제대로 동작하는지 확인해야 한다.
그러면 나는 코드를 모킹해야 하고 뭐부터 테스트를 해야 할 지 우선순위가 시작부터 어지러워지는 것이다.
비슷한 이야기로 외부와 연결된 부분이 생기는 것은 테스트하기 어려운 지점이 생기는 것이다.

직접 그려서 좀 삐뚤삐뚤하지만, 이해는 될 것이라 생각이 든다.
위의 그림에서 D → C → B → A 순으로 로직이 진행된다고 가정하자.

만약 그림처럼 A가 외부 API를 호출하거나, DB와 연결되어 있다거나, 다른 계층에 존재하는 코드라면 테스트하기가 어려워진다.
그 어렵다는 특성이 A에게만 존재하는 게 아니라, A를 호출한 B부터 C,D에게까지 전파가 된다.
결국 아래와 같이 변해버리는 것이다.

그러면 방법이 없느냐?
그런 것도 아니다.
아래처럼 고치면 된다.

테스트하기 어려운 녀석을 추출하고 D와 A만 어렵게 둔 뒤, B와 C를 테스트하면 된다.
이렇게 하기 위해서 함수형 프로그래밍의 아이디어를 얻어왔다.
순수함수는 입력과 출력으로 모든 데이터를 받고 준다. 데이터가 변해야 하면 새로 선언하고 선언된 녀석을 업데이트 시켜 사이드 이펙트를 방지한다.
(물론 모든 메서드를 위처럼 만들 수는 없다. 그때는 모킹을 하거나 통합 테스트 레벨로 넘겨버리면 된다. 나같은 경우는 테스트하기 어려운 코드를 제외하니 단순 매핑 로직만 남아서 단위 테스트를 따로 작성하지 않고 통합 테스트로 넘겨버리는 코드가 나오기도 했다.)
이 개념을 지켜서 위의 엉망진창 로직을 수정해보자.
나는 아래의 코드처럼 수정했다.
//Function Root
public void recordUserSearch(SearchHistoryCreateRequestDto requestDto) {
User user = getAuthenticatedUser(authService.getCurrentUser());
String keyword = requestDto.getKeyword().trim();
if (keyword.isEmpty()) {
throw new SearchHistoryBadRequestException(ErrorCode.SEARCH_HISTORY_BAD_REQUEST);
}
//테스트하기 어려운 코드
Optional<SearchHistory> existingSearch = searchHistoryRepository.findByKeywordAndUserId(keyword,
user.getId());
// 비즈니스 로직
SearchHistory result = recordSearchHistory(existingSearch, keyword, user);
//테스트하기 어려운 코드
searchHistoryRepository.save(result);
}
// 비즈니스 로직
public SearchHistory recordSearchHistory(Optional<SearchHistory> existingSearch, String keyword,
User user) {
return existingSearch.map(existing -> incrementExistingSearchCount(existing, keyword, user))
.orElse(createNewSearchHistory(keyword, user));
}
private SearchHistory incrementExistingSearchCount(SearchHistory existing, String keyword, User user) {
return SearchHistory.builder()
.id(existing.getId())
.keyword(keyword)
.createdAt(existing.getCreatedAt())
.updatedAt(existing.getUpdatedAt())
.user(user)
.searchCount(existing.getSearchCount() + 1)
.isDeleted(false)
.build();
}
private SearchHistory createNewSearchHistory(String keyword, User user) {
return SearchHistory.builder()
.keyword(keyword)
.isDeleted(false)
.user(user)
.searchCount(1L)
.build();
}
public User getAuthenticatedUser(User user) {
if (user == null) {
throw new UserUnauthorizedException();
}
return user;
}
private Boolean hasPermission(SearchHistory targetSearchHistory, User user) {
return targetSearchHistory.getUser().getId().equals(user.getId());
}
이 코드에서 테스트하기 어려운 다른 Service 계층이나 Repository를 사용하는 부분과 비즈니스 로직을 분리했다. 그러자 이펙티브 소프트웨어 테스팅 책의 예시처럼 테스트 코드를 작성하기가 너무 쉬워졌다.
@ExtendWith(MockitoExtension.class)
@DisplayName("SearchHistoryService 유닛 테스트")
class SearchHistoryServiceTest {
@Mock
private SearchHistoryRepository searchHistoryRepository;
@Mock
private AuthService authService;
@InjectMocks
private SearchHistoryService searchHistoryService;
// recordUserSearch 유닛 테스트
@Test
@DisplayName("기존 검색 기록이 없으면 새로 생성한다.")
void Given_SearchHistory_When_NotExistedSearchHistory_Then_CreateNewSearchHistory() {
// Given
User user = new User();
String keyword = "test";
Optional<SearchHistory> searchHistory = Optional.empty();
// When
SearchHistory result = searchHistoryService.recordSearchHistory(searchHistory, keyword, user);
// Then
assertThat(result.getKeyword()).isEqualTo(keyword);
assertThat(result.getUser()).isEqualTo(user);
assertThat(result.getSearchCount()).isEqualTo(1L);
assertThat(result.getIsDeleted()).isFalse();
}
@Test
@DisplayName("기존 검색 기록이 있으면 search_count를 증가시킨다.")
void Given_SearchHistory_When_ExistedSearchHistory_Then_IncrementSearchCount() {
// Given
User user = new User();
String keyword = "test";
Optional<SearchHistory> searchHistory = Optional.of(
SearchHistory.builder()
.id(1L)
.keyword(keyword)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.user(user)
.searchCount(1L)
.isDeleted(false)
.build());
// When
SearchHistory result = searchHistoryService.recordSearchHistory(searchHistory, keyword, user);
// Then
assertThat(result.getKeyword()).isEqualTo(keyword);
assertThat(result.getUser()).isEqualTo(user);
assertThat(result.getSearchCount()).isEqualTo(2L);
assertThat(result.getIsDeleted()).isFalse();
}
@Test
@DisplayName("삭제된 검색 기록도 검색되면 search_count를 증가시킨다.")
void Given_SearchHistory_When_ExistedDeletedSearchHistory_Then_IncrementSearchCount() {
// Given
User user = new User();
String keyword = "test";
Optional<SearchHistory> searchHistory = Optional.of(
SearchHistory.builder()
.id(1L)
.keyword(keyword)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.user(user)
.searchCount(1L)
.isDeleted(true)
.build());
// When
SearchHistory result = searchHistoryService.recordSearchHistory(searchHistory, keyword, user);
// Then
assertThat(result.getKeyword()).isEqualTo(keyword);
assertThat(result.getUser()).isEqualTo(user);
assertThat(result.getSearchCount()).isEqualTo(2L);
assertThat(result.getIsDeleted()).isFalse();
}
}
가벼운 회고 및 정리
먼저 제미니의 개발실무에서 재민님의 영상을 통해 용기를 얻어서 일단 테스트 코드를 작성해보려고 했다.
(테스트 코드도 짜봐야 늘어난다는 말씀 덕분에 ㅎㅎ)
그리고 기왕 작성하는거 체계적인 과정을 배우면 좋으니 "이펙티브 소프트웨어 테스팅" 책을 곁들였다.
하지만 곧바로 벽에 부딪혔다. 위에 적었던 것처럼 내부 구현을 검증해야 하는지 정말 어려웠다.
핵심 깨달음
1.Mock vs 최종 결과 검증의 딜레마
- 처음에는
repository.save()
호출 여부를verify()
로 확인하려 했음 - 향로님의 "내부 구현 검증 피하기" 글을 통해 최종 결과 검증의 중요성을 깨달음
- 요구사항이 변경되면 내부 구현 검증 테스트는 쉽게 깨진다는 점 이해
- 테스트하기 쉬운 코드 vs 어려운 코드 분리
- 정진욱님의 테스트하기 어려운 코드에 대한 특징 이해
- 순수 함수(비즈니스 로직)와 IO 작업의 분리가 핵심
- 테스트 복잡도가 2^n 조합에서 순수함수 4-5개 + 통합테스트 2개로 감소
- 요구사항 기반 테스트의 중요성
- "로그인 사용자만 검색기록 저장"이라는 요구사항이 변경될 가능성 고려
- 이건 프로펙트 강사님의 요구사항은 언제든 변경될 수 있으며 코드는 변경이 용이해야 한다는 말씀이 기억아 남아서 고려해봤다. (사실 우리 프로젝트에서는 고려할 필요가 없다. 요구사항이 바뀔 일이 없어서…)
- 내부 구현이 아닌 비즈니스 요구사항의 본질을 테스트해야 함
- "로그인 사용자만 검색기록 저장"이라는 요구사항이 변경될 가능성 고려
기술적 학습 포인트
- JUnit 5 기본 사용법:
@Test
,@DisplayName
,@ExtendWith
- Mockito 활용:
@Mock
,@InjectMocks
,@ExtendWith(MockitoExtension.class)
- AssertJ 문법:
assertThat().isEqualTo()
,hasSize()
등
이 과정도 부스트캠프에서 학습했던 (깊게는 아니지만) 함수형 프로그래밍의 정의와 특성, 각종 테스트 코드 강의와 글을 통해 넘어갈 수 있었다. (물론 클로드의 도움도 크게 받았다.)
이번 어려움을 겪으면서 내가 그동안 로직을 얼마나 엉망으로 짰는지 깨달았다.
그리고 테스트 코드를 통해 코드 품질을 향상시킨다는 것에 대해서도 많이 배울 수 있었다.
앞으로의 다짐
- 처음부터 테스트하기 쉬운 코드 구조로 설계하기
- 비즈니스 로직과 IO 작업의 명확한 분리 의식하기
- 요구사항의 본질을 테스트하되, 내부 구현에 의존하지 않기
참고 자료
https://product.kyobobook.co.kr/detail/S000201055864
https://jwchung.github.io/testing-oh-my
https://www.youtube.com/watch?v=YdtknE_yPk4
https://www.youtube.com/watch?v=txYAmWRnbBg
'개발 > 개발 공부' 카테고리의 다른 글
[AWS] RDS로 데이터 삽입 삽질 (0) | 2025.07.03 |
---|---|
잘못된 리팩토링, OOP와 순수 함수 (0) | 2025.06.07 |
검색 구현을 위한 기초 공부 (0) | 2025.05.05 |
[밑바닥부터] 8일차 - 2장 불 연산: ALU 구현 및 테스트 (0) | 2025.04.13 |
[밑바닥부터] 7일차 - 2장 불 연산: 가산기와 증분기 구현 (0) | 2025.04.13 |