🗣️ 배경
Database를 사용하지 않고, Collection을 사용하여 InMemory 형태로 객체 저장소를 만들었다.
해당 저장소를 구현한 코드는 아래와 같다.
@Repository
public class ReservationInMemoryRepository {
private static final AtomicLong AUTO_INCREMENT = new AtomicLong(1);
private static final Map<Long, Reservation> REPOSITORY = new ConcurrentHashMap<>();
//....
저장소 역할을 맡을 Map 컬렉션을 `static final` 키워드를 사용하였다.
그 이유는 이 애플리케이션이 실행되는 동안 저장소 역할을 맡을 Repository는 단 하나로 공유되어야 한다고 생각했기 때문이다.
위 Repository를 사용한 전체 API를 테스트하는 과정에서 에러가 발생하였다.
하나의 테스트만 실행했을 때 성공하지만, 여러 개의 테스트를 실행했을 때 실패하는 경우였다.
그 이유가 Repository가 공유되어 사용되기 때문이라는 것은 쉽게 알아차릴 수 있었다.
하지만 그 이유가 궁금하였다.
아래는 테스트 클래스의 실행 환경 코드이다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public class MissionStepTest {
@SpringBootTest 는 우리가 프로덕션 코드에서 사용하는 실제 스프링 컨텍스트를 로드하여 테스트시 우리가 구현한 프로덕션 클래스를 직접 주입해서 사용하겠다는 것이다.
@DirtiesContext 는 테스트 메서드가 스프링 컨텍스트의 상태를 변경하여 이후 테스트에 영향을 주는 역할을 한다. classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD 이와 같은 조건을 지정함으로써, 테스트 메서드가 실행될 때 마다 새로운 스프링 컨텍스트를 생성하게 된다.
실제 테스트 3개를 한번에 돌려보니 계속해서 생성하는 것을 확인할 수 있었다!
나는 당연히 위 @DirtiesContext 이 계속해서 스프링 컨텍스트를 생성해준다면, Repository의 상태가 공유되지 않을 것이라 생각했다..!
하지만, 이것은 나의 짧은 생각이라는 것.
☝️ 이유
그 이유는 아래의 그림으로 한방에 알 수 있다.
지금까지 자바를 실컷 공부해왔것만, 스프링을 배우니 바로 자바를 까먹고 스프링만 생각한 것이다.
JVM을 생각하니 스프링 컨텍스트 또한 런타임에 실행되는 것이다 보니 JVM 메모리 영역 중 Heap 메모리에서 관리된다는 것을 인지할 수 있었다.
우리가 SpringApplication을 실행하면 순서는 아래와 같다.
- Java -jar 또는 java클래스를 통해 JVM을 실행
- 애플리케이션의 메인 클래스와 필요한 클래스들 및 Static 멤버 등이 Method Area에 로드된다.
- main() 실행
- SpringBootApplication.run() 실행
@DirtiesContext 은 4번의 과정 중 스프링 컨텍스트 재생성 및 초기화를 해주는 역할이었던 것이다.
그러니 당연히 메모리를 공유하지!!
🙇 해결 방법
1. Repository 클래스에 clear() 구현 (BAD)
저장소를 초기화 해주는 clear()를 구현하고, 테스트 실행마다 clear()를 호출하면 된다.
@Repository
public class ReservationInMemoryRepository {
private static final AtomicLong AUTO_INCREMENT = new AtomicLong(1);
private static final Map<Long, Reservation> REPOSITORY = new ConcurrentHashMap<>();
//....
public static void clear() {
AUTO_INCREMENT.set(1);
REPOSITORY.clear();
}
@BeforeEach
void setUp() {
ReservationInMemoryRepository.clear();
}
처음에는 이 방법을 사용했지만 커다란 문제점이 있었다.
테스트를 위한 메서드가 생성된 것이고, 해당 메서드가 데이터를 전체 초기화시켜버리는 clear()라는 것이었다.
그것도 어디서든 사용할 수 있는 static키워드로!!
해당 메서드가 열려있고, 누군가 잘못 호출할 수 있다는 가능성만으로 위험하다는 것을 알 수 있다.
그래서 적절하지 않은 방법이라 생각했다.
2. static 키워드 제거 (GOOD)
내가 만든 Repository의 상태를 다시보자
@Repository
public class ReservationInMemoryRepository {
private static final AtomicLong AUTO_INCREMENT = new AtomicLong(1);
private static final Map<Long, Reservation> REPOSITORY = new ConcurrentHashMap<>();
//....
위 코드를 보고, IOC 컨테이너를 인지하고 있다면 의문점이 들 수 있다.
“@Repository 키워드를 붙인 것을 보아 어차피 빈으로 사용될 텐데, IoC 컨테이너 내에 싱글톤으로 관리되어 인스턴스는 하나뿐이잖아! stiatic 키워드를 제거해도 괜찮은 거 아니야?”
이것도 좋은 방법이라 생각한다. 어쩌면, 이것이 더 적절한 방법일 수 있다.
하지만 현재 내가 생각하는 이 방법의 문제는 다음과 같다.
첫 번째로 개발자가 인스턴스를 직접 생성하는 경우, IoC 컨테이너가 관리하는 빈과 전혀 다른 인스턴스가 되기 때문에 데이터를 공유하지 못하는 상황이 발생할 수 있다는 것이다.
하지만 보통 스프링을 사용하는 환경에서 Repository를 직접 인스턴스를 생성하여 사용할 경우는 거의 없고, 스프링의 DI로 의존성을 주입해주기 때문에 큰 문제는 되지 않는다.
또한, Repository클래스의 인스턴스 생성을 1개로 막으면 될일이다.
두 번째로 스프링 컨텍스트를 여러 번 사용하는 환경일 경우이다.
데이터베이스를 사용안하고 InMemory 저장소를 사용하는 지금 단계에서 이런 것을 고려한다고!? 라고 생각할 수 있지만, 지금 다루는 것은 static의 메모리 공유와 스프링 컨텍스트 내 메모리 공유를 다루고 있는 거니 양해바랄게요!
이 경우의 사례로는 어드민 환경과 사용자 환경을 완전히 분리하고 싶을 때 사용한다.
아래는 GPT가 만들어준 예시이다
// Admin 전용 DispatcherServlet
@Bean
public ServletRegistrationBean<DispatcherServlet> adminServlet(ApplicationContext context) {
AnnotationConfigWebApplicationContext adminContext = new AnnotationConfigWebApplicationContext();
adminContext.register(AdminWebConfig.class);
adminContext.setParent(context); // Root context 공유
DispatcherServlet servlet = new DispatcherServlet(adminContext);
ServletRegistrationBean<DispatcherServlet> registration = new ServletRegistrationBean<>(servlet, "/admin/*");
registration.setName("adminServlet");
return registration;
}
// 사용자 전용 DispatcherServlet
@Bean
public ServletRegistrationBean<DispatcherServlet> userServlet(ApplicationContext context) {
AnnotationConfigWebApplicationContext userContext = new AnnotationConfigWebApplicationContext();
userContext.register(UserWebConfig.class);
userContext.setParent(context);
DispatcherServlet servlet = new DispatcherServlet(userContext);
ServletRegistrationBean<DispatcherServlet> registration = new ServletRegistrationBean<>(servlet, "/user/*");
registration.setName("userServlet");
return registration;
}
결론!!
어쩌면 현 단계에서 static키워드를 제거하고 스프링에서 관리해주는 빈으로 사용하는 것이 더 옳을지도 모른다. 어차피 IoC 컨테이너의 빈으로 관리된다면, Repository는 하나의 인스턴스를 공유할 것이고 Repository 클래스 내 실제 데이터가 저장되는 Map컬렉션도 하나의 인스턴스만 유지할 것이기 때문이다.
하지만 현재 나의 생각으로는 위에서 말한 두 문제점으로 인해, 하나의 Application에서 저장소 역할을 하는 Map 컬렉션이 여러 개 생성될 가능성을 가지는 것은 알맞지 않다고 생각한다. 단 하나만 존재해야 된다고 생각하기 때문에, 이 방법을 사용하지 않았다.
3. 프로덕션 Repository, 테스트 Repository 분리 (GOOD)
Q. static 키워드를 유지하고 clear()를 생성하지 않으면서, 각 테스트 메서드간 Repository 영향을 받지 않게 하는 방법은 무엇이 있을까?
먼저 나는 static키워드를 사용한다면 테스트 실행마다 clear()를 호출해줌으로써 저장소 상태를 초기화 시켜주는 방법밖에 떠오르지 않았다.
그래서 프로덕션 Repository와 테스트 Repository를 분리하기로 하였다. 테스트 Repository는 프로덕션 Repository와 동일한 로직을 가지되, clear()를 가지고 있게 하는 것이다.
먼저 유연한 테스트를 위해 ReservationRepository 인터페이스를 생성하여 의존성을 주입받도록 하였다.
@Repository
public interface ReservationRepository {
List<Reservation> getAll();
Reservation save(Reservation reservation);
Optional<Reservation> findById(Long id);
void remove(Long id);
}
@RestController
@RequiredArgsConstructor
public class UserReservationController {
private final ReservationRepository reservationRepository;
프로덕션 코드에는 ReservationRepository 인터페이스를 구현한 클래스가 ReservationInMemoryRepository 밖에 없기 때문에 자동으로 해당 클래스로 의존성이 주입될 것이다.
//프로덕션 Repository
@Repository
public class ReservationInMemoryRepository implements ReservationRepository {
private static final AtomicLong AUTO_INCREMENT = new AtomicLong(1);
private static final Map<Long, Reservation> REPOSITORY = new ConcurrentHashMap<>();
//....
아래는 프로덕션 Repository 로직을 그대로 가져오고 clear()만 추가한 클래스이다.
test패키지에 위치시킨다. 마찬가지로 ReservationRepository 를 구현한다.
public class FakeReservationRepository implements ReservationRepository {
private static final AtomicLong AUTO_INCREMENT = new AtomicLong(1);
private static final Map<Long, Reservation> REPOSITORY = new ConcurrentHashMap<>();
//...
// 추가된 메서드
public static void clear() {
AUTO_INCREMENT.set(1);
REPOSITORY.clear();
}
이후에 @TestConfiguration 를 활용해 테스트에서 우선적으로 사용할 빈을 지정해준다.
아래의 설정 클래스를 스프링 컨텍스트에 반영하면, 실제 프로덕션에서 사용되는 ReservationInMemoryRepository 로 의존성을 주입하지 않고 FakeReservationRepository 로 의존성을 주입하게된다.
@TestConfiguration
public class TestConfig {
@Bean
public ReservationRepository reservationRepository() {
return new FakeReservationRepository();
}
}
@Import 를 활용해 해당 테스트 클래스에 사용되는 스프링 컨텍스트에 이전에 만든 설정 클래스를 등록해준다.
그 다음 각 테스트 실행 전마다 clear()를 호출해준다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@Import(TestConfig.class)
public class MissionStepTest {
@BeforeEach
void setUp() {
FakeReservationRepository.clear();
}
그렇게 하면!? 성공!
마무리..
사소한 개념이지만 긴 글이 된 것 같다.
위 글에 나온 개념이 모두 맞다고 생각하진 않는다. 나의 주관적인 생각이 많이 들어갔기 때문이다.
내가 해결한 방법중 1번, 2번, 3번을 상황에 따라 적절하게 사용해야 한다는 것만 알면 되고, 이외의 해결 방법도 있을 수 있으니 더 찾아봐도 좋을 것 같다.
재밌다
'Spring' 카테고리의 다른 글
Gradle 개념 다지기 (0) | 2025.04.16 |
---|---|
DTO의 생성 위치(with 장점) (3) | 2025.01.19 |
Spring Security의 흐름과 개념 설명 (1) | 2024.08.11 |
Layered Architecture (1) | 2023.10.05 |
HTTP요청부터 응답까지의 과정 (0) | 2023.09.25 |