DB 세팅에 들어가는 시간들
기존에 TDD 기반 테스트 코드를 작성 시 테스트용 DB를 따로 도커 컨테이너에 올려놓았다. 하지만 이 방법은 프로젝트 개발 기간이 늘어나고 코드양이 많아지고 DB에 생성되는 테이블의 갯수가 많아질수록 불편해졌다.
1. deleteAll()의 지옥
모든 테스트는 독립적으로 진행되어야 한다, 는 원칙에 맞춰 테스트를 할 때 마다 db의 데이터를 초기화 시켜주어야 했다. 때문에 모든 테스트에서 테이블 당 deleteAll()을 진행해주어야 했는데 이게 여간 불편했다. 먼저, 테이블 간 제약 조건이 늘어날수록 deleteAll 의 순서가 충돌을 일으키기도 하고 예외가 발생했을 때 삭제도 제대로 되지 않았다. 또 비즈니스 로직이 변경됨에 따라 기존에 테이블에서 구조가 확장될 때 기존 테스트가 에러를 일으키기도 했다.
2. 디버깅 복잡도 증가
내게 단위 테스트의 기준은 '비즈니스 로직을 표현하는 가장 최소한의 단위로 작성된 문장' 을 만들고 그것에 대한 테스트 코드를 작성하는 것이었다. 그런데 아무래도 실제 DB에 연결하고 테스트를 진행하다 보니 복잡한 로직에서는 디버깅에 시간이 걸리기도 했다. 봐야하는 코드와 파일의 양이 늘어나니 당연한 결과였다.
위와 같은 이유로 비즈니스 로직 작성 외에도 테스트를 통과시키는데 투여하는 시간의 양이 점점 늘어났다. 그래서 테스트 방식을 수정하기로 마음먹었다.
1. 모듈화
먼저 각 계층의 역할을 모듈화시켜 명확하게 눈에 들어오게 했다. 기존 패키지의 구조는 다음과 같았다.
Controller
l
Service
l
Repository
l
Domain
여기서 Service-Repository의 클래스 파일을 확인하면

Jpa의 구현체를 상속받은 Repository 인터페이스를 Service 클래스가 의존하고 있다. 모듈별로 구분한다면 상위 모듈이 하위 모듈에 직접적으로 의존하고 있는 DIP 위반이다. 이게 왜 문제냐면 서비스 로직이 Jpa를 의존하고 있으므로 Jpa의 로직에 의해 오염될 수 있고 나중에 JDBC나 다른 DB 연결 인터페이스를 사용할 때 서비스 로직까지 고쳐야 한다는 위험성이 있다. 나같은 경우는 다른 목적이 있지만.

그림이 많이 바뀌었지만 사실 바뀐건 인터페이스 하나 더 만든거 밖에 없다. 먼저 기존 Repository 인터페이스를 JpaRepository로 이름을 변경하여 Jpa의 기능을 확장하는 인터페이스라는 명확한 이름을 지어주었다. 그리고 기존 서비스에 의존하는 인터페이스를 새로 만든다. 이렇게 의존관계를 역전시키면 더 이상 서비스 클래스가 JPA에 의존하지 않는다. 코드로 확인해보면
@Repository
public interface SampleJpaRepository
extends JpaRepository<Sample, Long>, SampleRepository {
다음과 같이 SampleJpaRepository 에서 SampleRepository 인터페이스를 상속받고 있으며
public interface SampleRepository {
Optional<Sample> findById(Long id);
Sample save(Sample sample);
void deleteAll();
}
SampleRepository는 Service에서 필요한 기능을 추상화(findById, save, deleteAll)하는 역할을 맡고 있다.
인메모리 리파지토리 생성
다음과 같이 인터페이스로 추상화를 했다면 테스트에서 JPA를 분리할 수 있다.

구조는 테스트가 추가되어 다음과 같이 확장되었다. 바뀐 것은 테스트 관련 파일들 뿐이다. Repository 인터페이스는 JpaRepository를 구현하지 않고 InMemoryRepository 구현체를 통해 데이터를 저장하고 확인할 수 있도록 수정했다. 코드로 보면 다음과 같다.
public class SampleInmemoryRepository implements SampleRepository {
private final Map<Long, Sample> store = new HashMap<>();
private final AtomicLong sequence = new AtomicLong(1);
@Override
public Optional<Sample> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Sample save(Sample sample) {
if (sample.getId() == null) {
sample.setId(sequence.getAndIncrement());
}
store.put(sample.getId(), sample);
return sample;
}
@Override
public void deleteAll() {
store.clear();
}
}
요새는 AI를 활용하여 테스트에 필요한 코드를 자동으로 코드를 뽑아주니 참으로 편리한 세상이다.
다음은 구현체인 InMemoryRepository를 스프링 컨테이너가 관리하도록 빈으로 등록하는 절차가 필요하다. 왜냐하면 스프링은 싱글톤의 원리를 이용하여 애플리케이션 로드 시 빈으로 생성한 객체를 메모리에 올리고 그것을 재사용하기 때문이다. 등록하는 방법은 여러가지가 있겠지만 내가 자주 사용하는 것은 클래스 위에 @Component, @Service 등의 어노테이션을 붙히는것, 혹은 별도의 Config 파일을 만들어서 생성하는 방법이다. 이번에는 후자의 방법을 사용했다.
@Configuration
public class testConfig {
@Bean
@Primary
public SampleService sampleService() {
return new SampleService(sampleRepository());
}
@Bean
@Primary
public SampleRepository sampleRepository() {
return new SampleInmemoryRepository();
}
}
다음과 같이 만든 빈을 등록했다면 테스트 애플리케이션을 로드할때 빈을 사용한다. 여기서 잠깐, @Primary 어노테이션을 붙히는 이유는 무엇일까? @Primary 빈은 컨테이너에 올라갈때 가장 우선권을 가진다. 예를 들어, 기본 프로젝트에서 동일한 빈 객체가 있을 경우 @Primary의 객체를 등록한다. 그 동일한 객체는 테스트 패키지가 아닌 기본 패키지의 config 파일에 정의되어 있다.
@Configuration
public class SampleConfig {
@Bean
public SampleService sampleService(SampleRepository sampleRepository) {
return new SampleService(sampleRepository);
}
}
다음과 같이 동일한 sampleService 객체는 테스트 환경에서 실행할 경우 @Primary가 붙은 객체의 우선권을 가지는 것이다. 그럼 왜 sampleConfig에 sampleRepository 구현체는 지정을 하지 않는 것일까? InMemoryRepository는 테스트 패키지 내부에 있기 때문에 지정해줄 필요가 없는 것이다. 추후에 다른 repository 구현체를 생성하게 된다면 빈을 등록해줘야 할 것이다.
예제에 사용한 코드는 아래 깃허브에서 확인이 가능하다. 다음 포스팅은 아무래도 JPA를 테스트에서 분리했으니 이쪽을 테스트하는 방법을 작성해야 할 것 같다.
GitHub - ystepanie/moduleTestExample: 리파지토리 테스트 영역 분할
리파지토리 테스트 영역 분할. Contribute to ystepanie/moduleTestExample development by creating an account on GitHub.
github.com
내가 작성에 참고한 블로그는 백명석님의 블로그이다.
AI 시대, JPA의 복잡성 대신 본질에 집중하기
spring-data-jpa vs spring-data-jdbc | 들어가며 JPA와 Hibernate는 10년 넘게 Java 영속성의 표준이었다. 처음 배울 때는 “마법” 같았다. 객체만 수정하면 알아서 DB에 반영되고, 지연 로딩으로 성능도 최적화
brunch.co.kr
'JAVA > 요점정리' 카테고리의 다른 글
| Spring AOP @Aspect를 활용하여 Logging 기능을 적용해보자. (2) | 2025.01.10 |
|---|---|
| [Spring] ControllerAdvice, ExceptionHandler, static factory method pattern을 사용하여 예외 처리하기 (3) | 2024.12.08 |
| [Spring] MapStruct 라이브러리 사용해서 dto to entity 매핑 간단화시키기 (3) | 2024.11.18 |
| CopyOnWriteArrayList vs SynchronizedList (2) | 2024.09.29 |
| 헥사고날 아키텍처를 사용한 스프링 부트 로그인 API 구현해보기 (0) | 2024.07.12 |
댓글