[우아한테크코스 6기] 레벨 2: 6주 차 회고 (Extension으로 테스트 격리하기)

,

여전히 JPA의 늪에서 빠져나오지 못한 채… 미션이 끝나가고 있다. 이것저것 시도해보고는 있지만 이게 레벨 1에서의 습관인지, 레벨 2에서 배우는 방식이 잘못된 것인지 혼란이 왔다. 특히나 ‘JPA는 객체 관점에서 테이블을 설계하도록 해 주니, 테이블 신경쓰지 않고 객체의 연관관계를 중심으로 설계해나가는 것이배우는 것’이라고 오해하기도 했다. 코치, 리뷰어와 많은 이야기를 나누면서 어느 정도 자리를 잡아가는 것 같기도 하지만…

이번 주는 칼퇴를 두 번이나(!) 했다. 대학교 동기들과의 밥 (+ 학교 축제), 솔라조 회식까지 수/금에 일찍 가버리는 바람에 생각보다 오래 키보드를 안 잡은 듯하지만… 아니었다. 그만큼 일과 시간에 내내 붙잡고 있어서 총량은 비슷한 게 참 무섭다 😇 이번 주는 배워나간 것들을 위주로 작성하려고 한다!

🤔 JPA, 객체만 보면 안 돼요?

JPA는 객체/데이터베이스에서 주요하게 생각하는 것이 서로 다른 것을 메꿔주기 위해서 등장했다. 데이터베이스의 외래키 중심 데이터 설계, 객체지향에서의 객체 중심 연관관계 설계가 서로 다르니, 이를 객체지향 언어에서 사용할 수 있도록, 특히나 Java 진영에서 사용할 수 있도록 등장했다.

이런 배경을 알고 나니, ‘그럼 JPA를 사용하면 테이블을 신경쓰지 않고, 정말 객체의 기준에서 설계를 해도 되는 것 아닌가?’ 라는 생각이 들었다. 그래서 객체 중심으로, 테이블을 신경쓰지 않고 온전히 JPA / Hibernate / Spring Data Jpa에서 지원해주는 여러가지 기능을 사용해 서비스를 설계하고 구현했다. 그러다보니 아래와 같은 질문이 자연스럽게 따라왔다.

리뷰어에게 건넨 질문 중 일부

리뷰어의 답변은 내가 예상한 대로, “ORM 을 사용하더라도, 데이터베이스 주도 개발을 지양해야한다는 말을 하더라도, 실제로는 DB를 많이 신경쓰며 개발을 할 수 밖에 없다“고 하셨다. 😅 크루들과 이야기할 때에도 ‘테이블 설계’라는 이야기보다는 ‘객체 설계’라는 말을 더 많이 썼고, 레벨 1에서의 객체지향을 어떻게 하면 잘 지켜나가면서 JPA를 학습할 수 있을까에 주안점을 두었던 나는 말 그대로 세상이 무너진 것과도 같았다 (ㅋㅋㅋ)

애플리케이션 구조만큼 DB 설계도 중요하다고 하시면서, 프로젝트에 들어가기 전에 미리 ERD/UML을 짜고 들어가신다고 했다. 이 정도면 객체 주도가 아닌 데이터 주도가 다시 우세한 듯하고, 그럼 JPA는 왜..? 라는 생각이 다시 들게 되었다. 우선 JPA를 학습하고 있으니, 이런저런 생각을 많이 부딪혀보면서 철학을 다져야겠다는 생각뿐이다 🔥

🥳 JUnit에서 제공하는 Extension을 활용한 테스트 격리

지금까지 나는 테스트 격리를 위해 다음과 같은 코드를 작성해서 사용하고 있었다. 어노테이션을 새로 만들어, 모든 서비스 테스트에 붙여 두었다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(
        classes = TestConfig.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@Sql("/truncate.sql")
public @interface ServiceTest {
}

물론 truncate.sql에서도 모든 테이블을 하나하나 떨궈주는 일을 하고 있었다. 이게 문제가 뭐였냐면, 테이블 객체가 추가될 때마다 sql도 수정해줘야 하니, 테스트를 돌릴 때 휴먼 에러가 발생할 가능성이 존재했다.

전체 테스트에서는 3, 개별 테스트에서는 1인 id

위와 같은 리뷰를 받고, truncate.sql을 다시 손보려는 순간 이전에 해커톤에서 이걸 해결한 적 있었는데..? 라는 생각이 스쳤다. 예전에 블로그에서 받아쳤던 부분들을 다시 학습해나갔고, 이번에는 내 나름대로의 철학을 담아 매 테스트케이스마다 DB 격리를 위한 코드를 작성했다. JUnit5에서 제공하는 @Extension을 활용했다!

스프링에서 제공하는 것도 아니고, JUnit5에서 제공하다보니 나름(?) 라이브러리 종속적이다. 다만 JUnit은 프레임워크와 무관하게 테스트를 진행할 때 꼭 사용하는 라이브러리다보니, 스프링보다는 기술에 덜 종속적이라고 생각했다. 기존 나의 코드는 InitializingBean을 활용해 스프링에 의존적이었다. 스프링 위에서 코드를 짜는데 스프링에 의존적인 것이 뭐가 문제냐고 이야기할 수도 있겠지만, 제거해도 무방한 의존성을 굳이 가져갈 필요는 없다.

InitializingBean에서는 afterPropertiesSet 메서드를 통해, BeanFactory에서 만들어진 뒤 단 한 번 실행되는 것이 특징이다. 초기화를 내 입맛대로 커스텀하거나, 몇 가지 필드가 정확하게 설정되었는지를 확인할 때 사용한다고 공식 문서에 적혀있다. 테이블명을 가져오는 것은 객체가 만들어진 뒤에 진행돼야 했으므로, 이때 당시에는 그렇게 이해만 하고 넘어갔었다. 다만 이런 의존성도 조금 내려두고 싶어 여러 방향을 찾다가, PostConstruct를 활용해 의존성 없는 코드를 만들어낼 수 있었다.

몇 가지 지식을 가지고 가 보자! 스프링 공식문서에서 소개하듯, Jakarta EE와 Spring이 서로 상호 보완적이라는 이야기를 담아두었다. Jakarta EE는 Enterprise Edition으로, 웹 프로그램을 만드는 데에 필요한 각종 명세가 포함돼 있다. 이 중에서도 우리와 친근한 JPA(JSR-338), Servlet(JSR-340) 등이 통합되었다고 이야기한다.

이번 단락에서 중요하게 봐야할 것은 JSR-250이다. Common annotation이라고도 불리우는데, 아래와 같은 어노테이션을 포함해 다양한 역할을 하도록 도와주는 여러 어노테이션을 가진다.

스프링에서는 이러한 어노테이션을 지원하므로, 어노테이션을 달아주는 것만으로 명세에 적혀있는 다양한 기능을 제공한다. PostConstruct는 위에서 설명한 InitializingBean을 대체해 스프링에 종속되는 코드를 해방(?)시켜줄 수 있게 된다.

public class DatabaseCleaner {
    private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s";
    private static final String ALTER_FORMAT = "ALTER TABLE %s ALTER COLUMN ID RESTART WITH 1";

    @PersistenceContext
    private EntityManager entityManager;

    private List<String> tableNames;

    @PostConstruct
    public void afterPropertiesSet() {
        tableNames = entityManager.getMetamodel().getEntities().stream()
                .filter(entity -> entity.getJavaType().isAnnotationPresent(Table.class))
                .map(entity -> entity.getJavaType().getAnnotation(Table.class).name())
                .toList();
    }

    @Transactional
    public void execute() {
        entityManager.flush();
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
        for (String tableName : tableNames) {
            entityManager.createNativeQuery(
                TRUNCATE_FORMAT.formatted(tableName)
            ).executeUpdate();
            entityManager.createNativeQuery(
                ALTER_FORMAT.formatted(tableName)
            ).executeUpdate();
        }
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
    }
}

@Table 어노테이션이 붙은 엔티티를 찾아 해당 엔티티의 테이블명을 동적으로 가져오고, 이를 truncate해주는 과정을 반복한다. 이제 준비는 끝났으니, 본격적으로 JUnit5를 활용해 테스트 격리를 활용할 수 있겠다!

JUnit5에서는 Extension을 활용해 테스트에 부가적인 조치를 취할 수 있다. @ExtendWith가 그 예시인데, 내부에 들어갈 수 있는 클래스는 Extension 인터페이스를 상속받는 클래스여야 한다. @BeforeEach와 같은 것도 JUnit에서 제공하는 어노테이션인데, 이를 좀 더 공통적으로 활용할 수 있도록 만들어진 인터페이스다.

우리는 테스트가 실행되기 전, 해당 execute 메서드가 실행되기를 원한다. 따라서 BeforeEachCallback을 활용할 수 있겠다!

BeforeEachCallback Javadoc
  1. DatabaseCleanerBeforeEachCallback에서 불려서 매 테스트케이스 전에 초기화가 이루어져야 한다.
  2. JUnit에서는 Spring TestContext Framework 를 자신의 입맛(?)에 맞게 통합해 두었다.
  3. TestContext를 활용한다면, Spring Context를 활용할 수 있다.
  4. Spring Context에는 내가 등록한 Component 등이 들어있다: 따라서 @Autowired가 올바르게 작동한다.
  5. DatabaseCleaner를 테스트할 때 등록해두면 좋지 않을까?

위와 같은 사고 흐름으로, 테스트 전용 환경에서만 빈으로 등록한 뒤, 추가로 Extension을 활용해 코드를 추가하니 모든 테스트가 올바르게 격리되었다 🤩

@TestConfiguration
public class TestConfig {
    // ...
    @Bean
    public DatabaseCleaner databaseCleaner() {
        return new DatabaseCleaner();
    }
}

public class DatabaseCleanerExtension implements BeforeEachCallback {

    @Override
    public void beforeEach(ExtensionContext context) {
        SpringExtension.getApplicationContext(context)
                .getBean(DatabaseCleaner.class) // <- 테스트 환경에서는 빈으로 등록돼 있다!
                .execute();
    }
}

처음 사용할 때에는 JUnit에서 나온 것인줄도 모르고 있었는데, Extension을 활용한 테스트 커스텀이 꽤나 매력적으로 다가와서, 나중에 여러 Extension을 활용하면 좋겠다고도 생각했다. SQL 파일을 통한 테스트 격리 안녕~

🍻 솔라조 두 번째 회식

아쉽게 리건이 불참하고 아홉 명이서 즐거운 밥 + 술 시간을 가졌다! 잠실이라 일찍 헤어지는 건 아쉬웠지만, 매번 개발 얘기가 아닌 사람 이야기를 나눌 시간이 있다는 게 참 좋다. 여기저기 2차를 찾아볼 때에도 금요일이라 그런지 다들 만석이라.. 돌고돌아 전에 회식을 처음 가졌던 집에 가서 2차까지 잘 마무리. 가위바위보 아이스크림까지 잘 먹었습니다 😋

이번에는 조금 잘 찍었을지도

🔥 이번 주는요

테코톡도 매주 잘 진행되고 있고, 날이 갈수록 크루들의 성장세가 가파라지는 것이 보여 즐겁다. 이런저런 이야기를 나누면서 서로 다른 코드 관점을 보는 것도 즐겁고, ‘당연하다고 생각한 것’들에 반박당하면서 단단해지는 철학을 다시 느낀다. 5월이 다 지나가고 이제 레벨 2도 마지막 미션을 앞두고 있는데, 수료할 때쯤에는 내가 진짜 내던져질 정도가 될 지가… 의심스럽다 참 ㅎㅎㅎ🥴

잠실역을 매번 지나쳐서 느끼지 못했는데, 출구 바로 앞에 교보문고가 있더라. 한 번 들러서 돌아다녀 봤는데, 간만에 책 냄새를 맡아서 마음이 안정됐다. 시간 남으면(???) 한 번 와서 책이나 읽고 가야지. 문학 서적을 읽은 지 꽤 됐다. 글을 잘 쓰려면 우선 많은 사람들의 글을 읽는 연습부터 해야지!

Categories