여전히 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
도 수정해줘야 하니, 테스트를 돌릴 때 휴먼 에러가 발생할 가능성이 존재했다.
위와 같은 리뷰를 받고, 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
을 활용할 수 있겠다!
DatabaseCleaner
가BeforeEachCallback
에서 불려서 매 테스트케이스 전에 초기화가 이루어져야 한다.- JUnit에서는 Spring TestContext Framework 를 자신의 입맛(?)에 맞게 통합해 두었다.
- TestContext를 활용한다면, Spring Context를 활용할 수 있다.
- Spring Context에는 내가 등록한 Component 등이 들어있다: 따라서
@Autowired
가 올바르게 작동한다. 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도 마지막 미션을 앞두고 있는데, 수료할 때쯤에는 내가 진짜 내던져질 정도가 될 지가… 의심스럽다 참 ㅎㅎㅎ🥴
잠실역을 매번 지나쳐서 느끼지 못했는데, 출구 바로 앞에 교보문고가 있더라. 한 번 들러서 돌아다녀 봤는데, 간만에 책 냄새를 맡아서 마음이 안정됐다. 시간 남으면(???) 한 번 와서 책이나 읽고 가야지. 문학 서적을 읽은 지 꽤 됐다. 글을 잘 쓰려면 우선 많은 사람들의 글을 읽는 연습부터 해야지!