Fixture와 Builder 패턴으로 테스트 코드 가독성 높이기

2025. 7. 25. 22:59·개발/Java & Spring

프로젝트 규모가 커지고 도메인이 복잡해질수록, 테스트 데이터를 준비하는 given 절은 점점 비대해져 테스트의 본질을 가리게 됩니다. 테스트 작성이 번거로워지고, 유지보수 비용은 증가하며, 새로운 팀원이 코드를 이해하는 데 더 많은 시간이 소요됩니다.

 
본 글에서는 우아한테크코스 레벨3 팀 프로젝트 '모아온'을 진행하며 마주한 실제 문제와 Fixture, Builder Pattern, 그리고 Test Helper를 통해 해결한 경험을 공유하고자 합니다. 모든 내용은 모아온 팀의 백엔드 크루 포포, 말론, 다로와 함께 경험한 내용입니다.

🚨 테스트의 given 절이 미친듯이 길어지는 문제가 발생했습니다

모아온 서비스의 핵심 도메인인 Project 객체는 사용자의 프로젝트 정보를 담는 중요한 역할을 합니다. 초기 설계 단계부터 다양한 비즈니스 요구사항을 반영하다 보니, Project 엔티티는 많은 필드와 연관관계를 가지게 됐습니다. 문제는 이 객체를 테스트에 사용할 때 발생했습니다. 이 복잡한 Project 객체를 테스트에 사용할 때, 테스트 시나리오를 준비하는 given절이 테스트의 의도를 파악하기 어려울 정도로 길고 장황해졌습니다.

Project Entity

예시를 통해 문제 상황을 파악해 보겠습니다. 실제 Project 엔티티는 9개의 필드와 6개의 연관 관계를 갖고 있지만, 이해를 돕기 위해 4개의 필드와 2개의 연관관계를 가지는 간략한 버전으로 살펴보겠습니다.

 

아래는 핵심 도메인 객체인 Project 엔티티의 예시 코드입니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Project extends {

    @Id
    private Long id;

    @Column(nullable = false, unique = true) 
    private String title; // title은 unique 제약 조건이 있습니다

    @Column
    private String summary; // summary는 필수 값이 아닙니다

    @Column(nullable = false)
    private int views; 

    @ManyToMany
    private List<TechStack> techStacks;

    @ManyToMany
    private List<Category> categories;

    public Project(
            String title,
            String summary
            List<TechStack> techStacks,
            List<Category> categories
    ) {
        this.title = title;
        this.summary = summary;
        this.views = 0; // views는 생성자에서 주입을 받지 않고, 직접 0을 할당합니다
        this.techStacks = techStacks;
        this.categories = categories;
    }

    public void addViewCount() { // view를 증가시키는 메서드입니다
        views++;
    }
}

Test

이제 위 Project 엔티티를 사용해 조회수 내림차순으로 정렬하기 기능을 테스트하는 코드를 보겠습니다. 정확한 정렬을 검증하기 위해 3개의 프로젝트를 저장하고, 각기 다른 조회수를 설정하려는 상황입니다.

@DataJpaTest
class ProjectRepositoryTest {

    // 연관 관계 수만큼 Repository 의존성이 추가됩니다.
    @Autowired
    private ProjectRepository projectRepository;

    @Autowired
    private TechStackRepository techStackRepository;

    @Autowired
    private CategoryRepository categoryRepository;

    @DisplayName("프로젝트를 조회순을 기준으로 정렬한다.")
    @Test
    void toOrderByViews() {
        // given
        // 연관 객체를 먼저 생성하고 저장해야 합니다.
        TechStack techStack = techStackRepository.save(new TechStack("java"));
        Category category = categoryRepository.save(new Category("book"));

        // 테스트의 핵심 관심사(views)와 무관한 필드들(title, summary, techStack, category)까지 모두 채워야 합니다
        // title은 unique 제약조건 때문에 매번 다른 값을 넣어야 합니다.
        Project project1 = projectRepository.save(new Project(
                "title1",
                "summary",
                List.of(techStack),
                List.of(category)
        ));
        Project project2 = projectRepository.save(new Project(
                "title2",
                null,
                List.of(techStack),
                List.of(category)
        ));
        Project project3 = projectRepository.save(new Project(
                "title3",
                null,
                List.of(techStack),
                List.of(category)
        ));

        // `views` 필드를 직접 제어할 수 없어, 원하는 값만큼 메서드를 반복 호출해야 합니다.
              project1.addViewCont();

              project2.addViewCont();
              project2.addViewCont();

              project3.addViewCont();
              project3.addViewCont();
              project3.addViewCont();

        // when
        List<Project> projects = projectRepository.findAllOrderByViews();

        // then
        assertThat(projects).containsExactly(project3, project2, project1);
    }
}

딱 이 코드를 봤을때, 리팩터링이 필요한 코드라는 생각이 들지 않나요? 코드가 길어지고, 가독성이 떨어져서 테스트 내용을 파악하기 어렵습니다. 테스트가 실패했을 때 로직이 실패한건지, 테스트를 실패한건지 파악할 수 있을까요?
 
실제 제가 했던 프로젝트에서는, 필드의 개수가 이 예시의 2배가 넘었습니다. 얼마나 끔찍했을지 상상이 가시죠?

📝 문제점을 정리해봅시다.

이 테스트의 문제점을 정리해보자면 다음과 같습니다

테스트 의도를 가리는 거대한 생성자

Project 객체를 생성하려면 테스트 시나리오와 전혀 무관한 필드까지 모두 채워야 합니다. 이처럼 테스트의 핵심 관심사와 무관한 코드는 '어떤 것을 검증하려 하는가'라는 테스트의 본질적인 목적을 흐리게 만듭니다. 테스트를 보는 사람은 어떤 파라미터가 중요한지 파악하기 위해 불필요한 비용을 소모해야 합니다.

// views 정렬 테스트에 title, summary, techStack, category 필드는 중요하지 않습니다
Project project = projectRepository.save(new Project(
        "title1",
        "summary",
        List.of(techStack),
        List.of(category)
));

도메인 규칙 때문에 제어하기 어려운 필드

도메인 객체의 일관성을 지키기 위해 views 필드는 생성자에서 0으로 초기화되고, addViewCount() 메서드를 통해서만 증가하도록 설계되었습니다. 이는 좋은 캡슐화 전략이지만, 테스트 환경에서는 오히려 불편함을 초래합니다. 조회수가 3인 상태를 만들기 위해 addViewCount()를 세 번 호출하는 것은 매우 번거롭습니다. 이처럼 테스트 데이터 세팅이 객체의 내부 구현에 깊이 의존하게 되면, 테스트 준비 과정이 복잡해지고 직관적이지 않게 됩니다.

// 단순히 views = 3 이라는 상태가 필요할 뿐인데,
// 구현 방식(메서드 호출)에 의존해야 합니다.
project3.addViewCount();
project3.addViewCount();
project3.addViewCount();

반복적인 연관 객체 설정

Project를 데이터베이스에 저장하기 전에, 연관 관계가 설정된 TechStack과 Category 객체를 먼저 생성하고 저장해야 합니다. 이러한 코드는 given 절을 불필요하게 늘리고, 테스트 클래스가 자신의 주된 책임과 무관한 다른 Repository들에 의존하게 만듭니다.

// Project를 테스트하기 위해 다른 Repository들까지 주입받고,
@Autowired private TechStackRepository techStackRepository;
@Autowired private CategoryRepository categoryRepository;

// 매번 save()를 호출해야 합니다.
TechStack techStack = techStackRepository.save(new TechStack("java"));
Category category = categoryRepository.save(new Category("book"));

👩‍💻 어떻게 개선할 수 있을까요?

앞서 정의한 세 가지 문제점을 해결하기 위해 Fixture, ProjectFixtureBuilder, RepositoryHelper 클래스를 만들었습니다.

Fixture로 any 객체 생성하기

가장 먼저, 테스트에서 중요하지 않은 기본 데이터들을 손쉽게 생성하기 위해 Fixture 클래스를 도입했습니다. unique 제약조건을 만족시키거나 단순히 객체 필드를 채우기 위한 데이터를 메서드 호출 한 번으로 생성합니다. new TechStack("java"), new Category("book")처럼 매번 다른 값을 고민하며 생성할 필요 없이, Fixture.anyTechStack() 호출만으로 필요한 객체를 얻을 수 있습니다.

public class Fixture {

    private static final AtomicLong SEQUENCE = new AtomicLong(0L);

    public static String nameWithSequence(String name) { // 중복 값을 피하기 위해 사용합니다
        return name + SEQUENCE.incrementAndGet();
    }

    public static TechStack anyTechStack() {
        return new TechStack(nameWithSequence("기술스택")); 
    }

    public static Category anyCategory() {
        return new Category(nameWithSequence("카테고리"));
    }
}

빌더 패턴으로 객체 생성 로직 캡슐화하기

다음으로, 복잡한 Project 객체의 생성 과정을 빌더 패턴을 적용한 ProjectFixtureBuilder로 캡슐화했습니다. 이 빌더를 사용하면 테스트 코드에서 검증에 필요한 값만 명시적으로 지정하고, 관심 없는 값들은 빌더가 제공하는 기본 값에 맡길 수 있게 됩니다. 또한 기존 Project 생성자로는 직접 다룰 수 없었던 views 필드까지 제어할 수 있도록 구현했습니다.

public class ProjectFixtureBuilder {

    // 빌더 내부에서만 사용할 필드들
    private String title;
    private String summary;
    private List<TechStack> techStacks;
    private List<Category> categories;
    private int views = 0; // 빌더는 view를 멤버변수로 가짐

    // 생성자에서 모든 필드를 기본값으로 초기화
    public ProjectFixtureBuilder() {
        this.title = Fixture.nameWithSequence("테스트 프로젝트 제목");
        this.summary = Fixture.nameWithSequence("테스트 프로젝트 요약");
        this.techStacks = new ArrayList<>(List.of(Fixture.anyTechStack()));
        this.categories = new ArrayList<>(List.of(Fixture.anyCategory()));
    }

    // 필요한 필드만 선택적으로 변경하는 메서드들
    public ProjectFixtureBuilder title(String title) {
        this.title = title;
        return this;
    }

    public ProjectFixtureBuilder summary(String summary) {
        this.summary = summary;
        return this;
    }

    public ProjectFixtureBuilder techStacks(TechStack... techStacks) {
        this.techStacks = new ArrayList<>(Arrays.asList(techStacks));
        return this;
    }

    public ProjectFixtureBuilder categories(Category... categories) {
        this.categories = new ArrayList<>(Arrays.asList(categories));
        return this;
    }

    // `views` 필드도 다른 필드와 동일한 방식으로 값을 지정
    public ProjectFixtureBuilder views(int views) {
        this.views = views;
        return this;
    }

    public Project build() {
        Project project = new Project(
                this.title,
                this.summary,
                this.techStacks,
                this.categories
        );
        // views 값을 기반으로 addViewCount() 메서드를 내부적으로 호출
        for (int i = 0; i < views; i++) {
            project.addViewCount();
        }
        return project;
    }
}

RepositoryHelper로 DB 저장 로직 추상화하기

마지막으로, 객체를 데이터베이스에 저장하는 반복적인 과정을 @TestComponent를 활용한 RepositoryHelper로 추상화했습니다. 이 RepositoryHelper 덕분에, 이제 테스트 클래스는 더 이상 TechStackRepository나 CategoryRepository에 직접 의존할 필요가 없습니다. 오직 RepositoryHelper 하나만 주입받으면 모든 영속성 처리가 가능해져 테스트 코드의 응집도가 높아집니다.

@TestComponent // 테스트 환경에서만 Bean으로 등록
public class RepositoryHelper {

    @Autowired
    private ProjectRepository projectRepository;

    @Autowired
    private TechStackRepository techStackRepository;

    @Autowired
    private CategoryRepository categoryRepository;

    // Project 저장에 필요한 모든 Repository 로직을 이 메서드가 담당
    public Project save(Project project) {
        techStackRepository.saveAll(project.getTechStacks());
        categoryRepository.saveAll(project.getCategories());
        return projectRepository.save(project);
    }
}

🧹 테스트 리팩터링 결과

Fixture, ProjectFixtureBuilder, RepositoryHelper를 적용한 최종 테스트 코드는 다음과 같습니다. 복잡한 준비 과정이 단순해지고, 오직 테스트의 핵심 의도만이 선명하게 드러납니다.

@DataJpaTest
@Import(RepositoryHelper.class)
class ProjectRepositoryTest {

        // 의존성이 깔끔해졌습니다
    @Autowired
    private RepositoryHelper repositoryHelper;

    @DisplayName("프로젝트를 조회순을 기준으로 정렬한다.")
    @Test
    void toOrderByViews() {
        // given
        Project project1 = repositoryHelper.save(new ProjectFixtureBuilder()
                .views(3) // 내가 관심있는 필드만 설정합니다
                .build()
        );
        Project project2 = repositoryHelper.save(new ProjectFixtureBuilder()
                .views(2)
                .build()
        );
        Project project3 = repositoryHelper.save(new ProjectFixtureBuilder()
                .views(3)
                .build()
        );

        // when
        List<Project> projects = projectRepository.findAllOrderByViews();

        // then
        assertThat(projects).containsExactly(project3, project2, project1);
    }
}

이전의 given절과 비교하면 테스트의 의도가 훨씬 잘 들어나는 것을 확인할 수 있습니다.

// given
// 연관 관계에 있는 객체들을 먼저 저장합니다
TechStack techStack = techStackRepository.save(new TechStack("java"));
Category category = categoryRepository.save(new Category("book"));

// 3개의 프로젝트를 생성합니다
Project project1 = projectRepository.save(new Project(
        "title1",
        "summary",
        List.of(techStack),
        List.of(category)
));
Project project2 = projectRepository.save(new Project(
        "title2", // title은 unique 한 값을 사용해야 합니다.
        null, // summary, techStack, category는 사실 이 테스트와 무관한 값이지만, 생성자에 있기 때문에 세팅이 필요합니다
        List.of(techStack),
        List.of(category)
));
Project project3 = projectRepository.save(new Project(
        "title3",
        null,
        List.of(techStack),
        List.of(category)
));

// 조회수를 직접 세팅할 수 없기 때문에, addViewCount() 메서드를 사용합니다.
project1.addViewCont();

project2.addViewCont();
project2.addViewCont();

project3.addViewCont();
project3.addViewCont();
project3.addViewCont();

😊 마치며

이번 리팩터링을 통해 테스트 코드의 가독성이 크게 향상되었고, 테스트 작성 및 유지보수 비용도 대폭 줄일 수 있었습니다. given 절에서 테스트의 핵심 의도가 명확히 드러나게 되었고, 복잡한 객체 생성 로직을 재사용 가능한 빌더로 캡슐화함으로써 새로운 테스트 케이스 추가가 훨씬 쉬워졌습니다.

 
물론 Fixture, Builder, Helper 클래스를 설계하고 구현하는 데 초기 비용이 들고, 팀원들의 학습 비용도 발생합니다. 하지만 복잡한 도메인을 다루는 프로젝트에서는 이러한 투자가 충분히 가치 있다고 판단됩니다. 여러분의 프로젝트에서도 비슷한 문제를 겪고 계신다면, 이 글에서 소개한 패턴들을 참고하여 테스트 코드의 품질을 한 단계 높여보시기 바랍니다.
 
관련해서 추가적인 의견이나, 다른 좋은 예제들이 있다면 댓글 남겨주세요 😊

Test code is just as important as production code. It is not a second-class citizen. It requires thought, design and care. It must be kept as clean as production code.

테스트 코드는 프로덕션 코드만큼이나 중요하다. 2등 시민이 아니다. 테스트 코드 역시 사고와 설계, 그리고 관리가 필요하며, 프로덕션 코드처럼 깨끗하게 유지되어야 한다.

— Robert C. Martin

'개발 > Java & Spring' 카테고리의 다른 글

DispatcherServlet의 service() 메서드는 어디에?  (0) 2025.09.29
[@MVC 구현하기] Spring MVC의 HandlerMapping 등록 원리 파헤치기  (4) 2025.09.22
Tomcat 구현하기 삽질기 (feat.acceptCount)  (2) 2025.09.15
'개발/Java & Spring' 카테고리의 다른 글
  • DispatcherServlet의 service() 메서드는 어디에?
  • [@MVC 구현하기] Spring MVC의 HandlerMapping 등록 원리 파헤치기
  • Tomcat 구현하기 삽질기 (feat.acceptCount)
yesjuhee
yesjuhee
Dopamine Driven Developer
  • yesjuhee
    나랑 노랑
    yesjuhee
  • 전체
    오늘
    어제
    • 분류 전체보기 (29)
      • 개발 (11)
        • DevOps (2)
        • Java & Spring (4)
        • AI (1)
        • DB (1)
        • 기타 (3)
      • 후기 or 회고 (15)
        • 우아한테크코스 (11)
        • 기타 (4)
      • 독서 (2)
      • 기타 (1)
      • 초록 스터디 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    우아콘
    DispatcherServlet
    초록 스터디
    SCG
    바킹독
    spring
    mysql
    coderabbit
    우테코
    claude code
    QueryDSL
    독서
    레벨3
    초록 밋업
    모아온
    레벨2
    소프티어 부트캠프
    Ai
    후기
    레벨4
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
yesjuhee
Fixture와 Builder 패턴으로 테스트 코드 가독성 높이기
상단으로

티스토리툴바