본문 바로가기
CS/스프링

spring 프로젝트에서 더미 데이터 사용 & 테스트용 fixture 사용하기

by LDY3838 2024. 6. 16.
반응형

spring 프레임워크를 이용하여 프로젝트를 진행하다 보면 만든 코드가 정상적으로 동작하는지 확인할 필요가 있습니다.

이때 테스트 코드를 이용하여 비즈니스 로직이 잘 작동하는지, Controller 부분이 정상적으로 동작하는지 확인하는 것도 중요하고 실제로 요청을 보냈을 때도 테스트 결과와 같이 의도한 대로 잘 동작하는지 확인하는 것이 필요합니다.

이번 포스트에서는 개발 환경에서 실제 api 요청을 보냈을 때 응답을 위한 데이터를 넣는 방식과 테스트 코드를 위한 데이터를 넣는 방식에 대해서 다루도록 하겠습니다.


DB에 더미 데이터 넣기

서버를 작동시킨 후 실제 요청에 맞는 데이터를 응답으로 보내주기 위해서는 DB에 실제로 데이터를 넣어야 합니다.

이때 서버를 실행시킨 다음에 sql을 이용하여 DB에 데이터를 넣어주는 방식이 존재는 합니다. 하지만 이렇게 데이터를 넣으면 entity의 구조가 변경되어서 더미 데이터를 수정해야 하는 경우 다시 처음부터 모든 데이터들을 넣어주어야 합니다.

위와 같은 방식으로 개발을 진행한다면 엔티티를 수정할 때마다 너무 많은 작업을 해주어야 할 것입니다.

물론 sql 파일을 만들어놓고 이 파일을 통해서 더미 데이터를 넣는 방법도 있지만 sql에 의존적인 개발을 피하고 실제 객체에 기반한 개발을 하는 것이 더 객체지향적인 개발을 할 수 있다 생각하여 이 방식은 사용하지 않고 코드를 이용하여 더미 데이터를 넣어보도록 하겠습니다.


@EventListener 사용하기

우선 더미 데이터를 넣는 방식에는 @EventListener를 사용하는 방식이 있습니다. 이때 ApplicationReadyEvent.class를 매개변수로 넣어주시면 프록시를 사용하는 경우에도 문제가 발생하지 않습니다.

이 방식은 어떻게 사용하면 되는지 간단하게 알아보고 가도록 하겠습니다.

@SpringBootApplication
public class ServerApplication {

    public static void main(String[] args) {
       SpringApplication.run(ServerApplication.class, args);
    }

    @EventListener(ApplicationReadyEvent.class)
    public void init(){
       System.out.println("Ready To Init Data!");
    }
}

위와 같이 @EventListener를 등록하면 해당 메서드 안에 있는 함수들이 작동하게 됩니다.

위와 같은 방식을 사용하고 init() 메서드 안에 데이터를 초기화하는 로직을 넣으면 더미 데이터를 넣을 수 있습니다.


ApplicationRunner 사용하기

저는 프로젝트를 진행할 때 @EventListener를 사용하기 보다는 ApplicationRunner 인터페이스를 구현하는 방법으로 더미 데이터를 입력하였습니다.

위와 같이 작업을 한 이유로는 각각의 엔티티마다 더미 데이터를 넣으려고 하는데 위와 같이 코드를 짜려면 하나의 클래스에서 모든 더미 데이터를 넣기에는 너무 양이 많아집니다.

따라서 클래스들을 엔티티마다 분리해주어야 하는데 이때 각각의 데이터 초기화 클래스는 객체가 1개씩만 존재해야 합니다. 따라서 저는 이를 singleton scope를 가진 스프링 빈으로 등록을 해주어야 하는데 이때 @EvnetListner를 사용해도 어차피 프록시 설정이 모두 들어갈 것이기 때문에 상관은 없겠지만 ApplcationRunner는 runtime에 다른 설정들이 완료된 후 시작하는 것으로 알고 있어 @EventListner와 똑같은 역할을 해주는데 annotation이 없이 메서드를 하나만 override 하면 되어 코드가 조금 더 깔끔해 보이기도 하고 ApplicationRunner를 구현한다는 것 자체가 실행 시점에 이 빈을 생성 & 수행하겠다는 의도를 보여준다고 생각하여 더미 데이터를 넣기에 더 적합해 보였기 때문입니다.

※조심해야 하는 것은 @PostConstruct는 왠만하면 사용하지 않는 것을 권장합니다. 초기화 코드가 먼저 작동하고 AOP가 적용되어 프록시 설정이 제대로 동작하지 않을 수 있기 때문입니다.

결과적으로 제가 사용한 코드는 아래와 같습니다.

@Slf4j
@RequiredArgsConstructor
@LocalDummyDataInit
@Order(1)
public class UserInitializer implements ApplicationRunner {

    private final UserRepository userRepository;

    @Override
    public void run(ApplicationArguments args) {

        if (userRepository.count() > 0) {
            log.info("[User]더미 데이터 존재");
        } else {
            List<User> memberList = new ArrayList<>();

            User DUMMY_GUEST = User.builder()
                    .email("guestEmail")
                    .role(Role.GUEST)
                    .nickname("guest")
                    .goal("guest go home")
                    .provider(OauthProvider.KAKAO)
                    .build();

            User DUMMY_USER = User.builder()
                    .email("userEmail")
                    .role(Role.USER)
                    .nickname("user")
                    .goal("user go home")
                    .provider(OauthProvider.KAKAO)
                    .build();

            User DUMMY_ADMIN = User.builder()
                    .email("adminEmail")
                    .role(Role.ADMIN)
                    .nickname("admin")
                    .goal("admin go home")
                    .provider(OauthProvider.KAKAO)
                    .build();

            memberList.add(DUMMY_GUEST);
            memberList.add(DUMMY_USER);
            memberList.add(DUMMY_ADMIN);

            userRepository.saveAll(memberList);
        }
    }
}

위와 같은 코드를 이용하여 DB가 초기화된 경우 실행 시점에서 자동으로 더미 데이터를 넣게 만들었고 이미 더미 데이터가 존재하는 경우 추가로 데이터를 넣지 않게 했습니다.

이때 @LocalDummyDataInit의 코드는 아래와 같습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Profile("dev")
@Transactional
@Component
public @interface LocalDummyDataInit {
}

위와 같은 annotaion을 만든 이유는 @Profile, @Component 등 공통적으로 initializer 들에 들어가야 할 annotation들을 모아서 관리하기 위함입니다.

더미 데이터는 우선 개발 환경에서만 사용해야 하는 데이터입니다. 따라서 테스트 환경, 실제 배포 환경에 문제가 없도록 하기 위해서 profile을 dev로 설정해 주고 위와 같이 공통적인 annotaion들을 모아서 관리하게 만들었습니다.

또한 @Order(1)과 같이 코드를 작성한 이유는 스프링 빈의 작동 순서가 중요하기 때문입니다.

만약 Directory라는 entity가 User 엔티티를 참조하고 있다면 User 엔티티를 등록한 다음에 Directory 엔티티가 이를 참조해서 DB에 저장해야 할 것입니다. 따라서 Directory를 담당하는 더미 데이터 initiailizer가 먼저 실행되지 않게 순서를 정해주었습니다.

Directory를 담당하는 더미 데이터 initiailizer는 아래와 같습니다.

@Slf4j
@RequiredArgsConstructor
@LocalDummyDataInit
@Order(2)
public class DirectoryInitializer implements ApplicationRunner {

    private final DirectoryRepository directoryRepository;
    private final UserRepository userRepository;

    @Override
    public void run(ApplicationArguments args) {

        if (directoryRepository.count() > 0) {
            log.info("[Directory]더미 데이터 존재");
        } else {
            User admin = userRepository.findByEmail("adminEmail")
                    .orElseThrow(() -> new UserNotFoundException(MypageErrorCode.USER_NOT_FOUND));
            User user = userRepository.findByEmail("userEmail")
                    .orElseThrow(() -> new UserNotFoundException(MypageErrorCode.USER_NOT_FOUND));
            User guest = userRepository.findByEmail("guestEmail")
                    .orElseThrow(() -> new UserNotFoundException(MypageErrorCode.USER_NOT_FOUND));

            List<Directory> directoryList = new ArrayList<>();

            Directory DUMMY_TRASH_DIRECTORY1 = Directory.builder()
                    .title("trash_directory1")
                    .directoryColor("#FF00FF")
                    .depth(1L)
                    .parentDirectory(null)
                    .user(admin)
                    .build();
            Directory DUMMY_TRASH_DIRECTORY2 = Directory.builder()
                    .title("trash_directory2")
                    .directoryColor("#FF00FF")
                    .depth(1L)
                    .parentDirectory(null)
                    .user(user)
                    .build();
            Directory DUMMY_TRASH_DIRECTORY3 = Directory.builder()
                    .title("trash_directory3")
                    .directoryColor("#FF00FF")
                    .depth(1L)
                    .parentDirectory(null)
                    .user(guest)
                    .build();

            Directory DUMMY_PARENT_DIRECTORY1 = Directory.builder()
                    .title("dummyDirectory1")
                    .directoryColor("#FF00FF")
                    .depth(1L)
                    .parentDirectory(null)
                    .user(user)
                    .build();
            Directory DUMMY_PARENT_DIRECTORY2 = Directory.builder()
                    .title("dummyDirectory2")
                    .directoryColor("#FF00FF")
                    .depth(1L)
                    .parentDirectory(null)
                    .user(user)
                    .build();
            Directory DUMMY_PARENT_DIRECTORY3 = Directory.builder()
                    .title("dummyDirectory3")
                    .directoryColor("#FF000F")
                    .depth(1L)
                    .parentDirectory(null)
                    .user(admin)
                    .build();

            Directory DUMMY_CHILD_DIRECTORY1 = Directory.builder()
                    .title("dummyDirectory4")
                    .directoryColor("#FF00FF")
                    .depth(2L)
                    .parentDirectory(DUMMY_PARENT_DIRECTORY1)
                    .user(user)
                    .build();
            Directory DUMMY_CHILD_DIRECTORY2 = Directory.builder()
                    .title("dummyDirectory5")
                    .directoryColor("#FF00FF")
                    .depth(2L)
                    .parentDirectory(DUMMY_PARENT_DIRECTORY1)
                    .user(user)
                    .build();
            Directory DUMMY_CHILD_DIRECTORY3 = Directory.builder()
                    .title("dummyDirectory6")
                    .directoryColor("#FF00FF")
                    .depth(2L)
                    .parentDirectory(DUMMY_CHILD_DIRECTORY2)
                    .user(user)
                    .build();

            directoryList.add(DUMMY_TRASH_DIRECTORY1);
            directoryList.add(DUMMY_TRASH_DIRECTORY2);
            directoryList.add(DUMMY_TRASH_DIRECTORY3);
            directoryList.add(DUMMY_PARENT_DIRECTORY1);
            directoryList.add(DUMMY_PARENT_DIRECTORY2);
            directoryList.add(DUMMY_PARENT_DIRECTORY3);
            directoryList.add(DUMMY_CHILD_DIRECTORY1);
            directoryList.add(DUMMY_CHILD_DIRECTORY2);
            directoryList.add(DUMMY_CHILD_DIRECTORY3);

            directoryRepository.saveAll(directoryList);
        }
    }
}

이때 그냥 각각의 User를 public static final로 만들어 놓은 다음에 다른 initializer에서 참조하게 만들면 되는 것이 아닌가 생각하실 수 있습니다.

저도 처음에 이와 같은 생각을 하고 코드를 만들었으나 계속 영속 관련 오류가 발생했습니다.

이유를 찾아보니 @Order annotation은 빈의 실행 순서에 영향을 주지 생성 순서에는 영향을 주지 않아서 User를 등록하기 전에 Directory에서 이 엔티티 객체를 참조하는 경우 User 객체의 PK가 존재하지 않아서 문제가 발생하였습니다.

따라서 public static final로 참조하게 하는 방식에서 repository에서 직접 찾아와서 참조하게 만들어주었습니다.

더미 데이터는 엄청난 양의 데이터가 아니기도 하고 DB가 초기화된 상황에서만 새로 데이터들을 등록하게 만들어서 큰 성능 부하는 없을 거라고 생각하여 위와 같이 코드를 만들었습니다.


테스트 환경에서 더미 데이터 사용하기

위에서 언급한 더미 데이터 다루는 방식은 실제로 DB에 데이터를 넣을 필요가 있을 때 사용하면 됩니다.

하지만 테스트 코드에서는 단위 테스트를 주로 사용하는데 이때 굳이 DB에 데이터를 넣을 필요는 없습니다. 또한 DB에 실제로 데이터를 넣게 되면 service나 controller에서 repository를 stub으로 사용해서 데이터를 가져와야 하는 등 불필요한 로직이 추가가 됩니다. 이와 같이 service의 메서드를 테스트하는데 repository에서의 작업도 무조건적으로 필요하면 이는 단위 테스트가 제대로 되어 있지 않다고 생각하여 DB에 데이터를 실제로 넣지 않고 테스트를 진행하는 방법을 찾아보았습니다.

방법을 찾아보던 중 fixture라는 개념을 사용하는 경우가 있었습니다. fixture는 고정물을 의미하는 단어로 필요한 엔티티 객체들을 미리 만들어놓은 후 public static final로 선언한 변수에 이를 할당하는 방식이었습니다.

public class UserFixture {

    public static final User USER_GUEST = User.builder()
            .email("guestImail")
            .role(Role.GUEST)
            .nickname("guest")
            .goal("guest go home")
            .provider(OauthProvider.KAKAO)
            .build();

    public static final User USER_USER = User.builder()
            .email("userImail")
            .role(Role.USER)
            .nickname("user")
            .goal("user go home")
            .provider(OauthProvider.KAKAO)
            .build();

    public static final User USER_ADMIN = User.builder()
            .email("adminImail")
            .role(Role.ADMIN)
            .nickname("admin")
            .goal("admin go home")
            .provider(OauthProvider.KAKAO)
            .build();
}

fixture를 만드는 방식은 위와 같습니다. 위와 같은 class를 test 폴더 밑에 만들어서 User 엔티티가 필요할 때는 이를 사용하면 됩니다. 하지만 위와 같은 방식은 문제가 있는데 엔티티에서 PK는 생성자에서 제거해 놓아서 이를 할당할 수 없다는 것입니다.(PK의 생성 전략이 IDENTITY인 경우만 이렇습니다)

PK를 직접 할당하기 위해서는 PK로 생성자에 포함시켜야 하는데 저는 IDENTITY를 PK의 생성 전략으로 사용하고 있기 때문에 이는 추후 테스트 코드가 아닌 실제 코드를 개발할 때 실수할 수 있는 가능성이 높아진다고 생각하였습니다.

따라서 다른 방식을 찾아보던 중 ReflectionTestUtils라는 reflection 기술을 사용하는 스프링 프레임워크에서 테스트를 위해서 지원하는 기술이 있다는 것을 알게 되어 private 접근 지정자를 가진 필드도 이를 이용해서 지정해 줄 수 있다는 것을 활용하여 PK를 지정해 주었습니다.

위와 같은 과정을 통해서 완성된 fixture 코드는 아래와 같습니다.

public class UserFixture {

    public static final User USER_GUEST = User.builder()
            .email("guestImail")
            .role(Role.GUEST)
            .nickname("guest")
            .goal("guest go home")
            .provider(OauthProvider.KAKAO)
            .build();

    public static final User USER_USER = User.builder()
            .email("userImail")
            .role(Role.USER)
            .nickname("user")
            .goal("user go home")
            .provider(OauthProvider.KAKAO)
            .build();

    public static final User USER_ADMIN = User.builder()
            .email("adminImail")
            .role(Role.ADMIN)
            .nickname("admin")
            .goal("admin go home")
            .provider(OauthProvider.KAKAO)
            .build();

    static {
        ReflectionTestUtils.setField(USER_GUEST, "userId", 1L);
        ReflectionTestUtils.setField(USER_USER, "userId", 2L);
        ReflectionTestUtils.setField(USER_ADMIN, "userId", 3L);
    }
}

위에서 만든 방식으로 Directory 엔티티도 똑같이 fixture를 만들어준 후 service에 대한 단위 테스트를 진행하였습니다.

테스트 코드 예시는 아래와 같습니다.

@ExtendWith(MockitoExtension.class)
class DirectoryServiceTest {

    @InjectMocks
    private DirectoryService directoryService;

    @Mock
    private DirectoryRepository directoryRepository;

    @Test
    @DisplayName("폴더 임시 삭제 테스트")
    void deleteDirectoryTemporalTest() {
        //given
        given(directoryRepository.findById(PARENT_DIRECTORY1.getId()))
                .willReturn(Optional.of(PARENT_DIRECTORY1));
        given(directoryRepository.findTrashDirectoryByUser(USER_USER.getUserId()))
                .willReturn(Optional.of(TRASH_DIRECTORY));

        //when
        directoryService.deleteDirectoryTemporal(USER_USER.getUserId(), PARENT_DIRECTORY1.getId());

        //then
        assertThat(PARENT_DIRECTORY1.getParentDirectory()).isEqualTo(TRASH_DIRECTORY);
    }
}

BDDMockito를 이용하여 stub과 mock 결과를 지정해 주었습니다.

Mockito보다는 BDDMockito를 사용하는 것이 //given 부분에 when이 들어가지 않고 given으로 mock 결과를 지정해 줄 수 있어서 이를 활용했습니다.

위와 같이 fixture를 사용해서 단위 테스트를 효율적으로 진행할 수 있었습니다.

반응형

댓글