이번에 모 기업에 지원해서 과제 테스트를 수행했다. 기능은 완성했지만 과제의 품질을 위해 테스트 코드를 작성했는데 이상하게 애플리케이션 실행 중에는 정상적으로 동작하던 기능이 테스트 코드에서는 제대로 동작하지 않았다.
해당 기능은 일대다 연관관계의 엔티티를 저장하는 기능을 갖고 있는데 1 쪽의 엔티티를 저장할 때 N 쪽의 엔티티도 PERSIST 후 flush 하고 생성된 식별자를 받는 방식으로 동작한다.
1 쪽의 엔티티는 OEmbedResource
클래스, N 쪽의 엔티티는 ResourcePattern
클래스다. OEmbedResource
엔티티 생성 시 ResourcePattern
엔티티도 생성해서 같이 데이터베이스에 저장 후 그 식별자를 받아오기 위해 다음과 같은 서비스 로직을 구현했었다.
public OEmbedResourceDto createOEmbedResource(
String siteName,
String oEmbedUrl,
Set<String> patterns
) {
checkArgument(isNotBlank(siteName), INVALID_SITE_NAME);
checkArgument(isNotBlank(oEmbedUrl), INVALID_OEMBED_URL);
checkArgument(
ObjectUtils.isNotEmpty(patterns),
"at least 1 pattern required to create oembed resource."
);
if (resourceRepository.existsById(siteName)) {
throw new DataIntegrityViolationException("duplicated site name");
}
OEmbedResource oEmbedResource = OEmbedResource
.builder()
.siteName(siteName)
.oEmbedUrl(oEmbedUrl)
.build();
resourceRepository.saveAndFlush(oEmbedResource);
patterns.stream()
.map(pattern -> ResourcePattern.patternOf(pattern, oEmbedResource))
.map(resourcePatternRepository::save)
.forEach(oEmbedResource::addResourcePattern);
return OEmbedResourceDto.of(oEmbedResource);
}
각 ResourcePattern
클래스의 식별자도 필요하기 때문에 직접 repository 에 저장 후 flush 하여 식별자를 받아올 수 있었다. 그런데 이 과정이 테스트 코드에서는 제대로 동작하지 않았던 것이다.
실제로 그냥 애플리케이션을 실행시켜서 동일한 작업을 수행했을 때는 다음처럼 등록한 ResourcePattern
객체들이 잘 저장되서 반환된 것을 볼 수 있었다.
C:\Users\park2>curl http://localhost:8080/api/sites -H "Content-Type: application/json" -X POST -d "{\"siteName\":\"youtube\",\"oEmbedUrl\":\"https://www.youtube.com/oembed?url={oEmbedUrl}\",\"patterns\":[\"www.youtube.com\\\\/watch\\\\?.*v=.*\"]}"
{"siteName":"youtube","patterns":[{"id":5,"pattern":"www.youtube.com\\\\/watch\\\\?.*v=.*"}],"oEmbedUrl":"https://www.youtube.com/oembed?url={oEmbedUrl}"}
C:\Users\park2>
그러나 테스트 코드에서는 다음처럼 patterns
응답 배열이 아예 비어있었다. 즉 패턴이 제대로 저장되지 않았다는 것이다.
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"patterns":[]}
Forwarded URL = null
Redirected URL = null
Cookies = []
분명히 아래 @BeforeEach
코드에서 동일한 서비스 로직을 이용하여 엔티티를 저장했지만 어째서 비어있는 것일까?
@BeforeEach
void init() {
oEmbedResource1 = oEmbedResourceService.createOEmbedResource(
"SITE_NAME1", "OEMBED_URL1", Set.of(oEmbedResource1Pattern1)
);
}
알고보니 전혀 예상하지 못했지만 예상하지 못하면 안됐던 부분이 문제였다. 바로 스프링의 @Transactional
어노테이션이었는데 스프링 테스트 환경에서는 테스트가 끝난 후 트랜잭션을 롤백하는 기능이 있다.
Transaction Rollback and Commit Behavior
By default, test transactions will be automatically rolled back after completion of the test; however, transactional commit and rollback behavior can be...
그래서 나는 테스트 코드 클래스에 항상 @Transactional
어노테이션을 붙여서 아래처럼 테스트가 끝난 후 관련 데이터들을 롤백하는 습관이 있었다.
@SpringBootTest
@Transactional
@AutoConfigureMockMvc
class OEmbedResourcePatternControllerTest {
...
그런데 간과했던 점이 @Transactional
어노테이션은 단순히 테스트 환경을 롤백하기 위해 사용하는 것이 아니라는 점이다. 이 어노테이션은 실제로 스프링이 관리하는 트랜잭션을 시작하는 어노테이션이며 소스 코드든 테스트 코드든 이 어노테이션을 붙이면 REQUIRED 전파의 트랜잭션이 시작된다. 서비스 클래스에서 항상 붙이던 어노테이션이면서도 트랜잭션이 시작된다는 핵심 자체를 잊고 사용하던 것이다.
서비스 클래스에는 @Transactional
어노테이션이 붙어있기 때문에 일반적으로는 서비스 메서드 호출 후 트랜잭션이 커밋, 데이터베이스에 반영된다. 하지만 테스트 코드에서 @Transactional
을 붙인다면 서비스 클래스는 @Transactional
어노테이션의 기본 전파 레벨인 REQUIRED 에 의해 테스트 인스턴스에서 생성한 트랜잭션을 기반으로 동작하게 된다.
그렇기 때문에 테스트 코드에서 엔티티를 생성해도 트랜잭션이 커밋되지 않아 생성한 ResourcePattern
목록이 조회되지 않은 것이다.
그래서 테스트 코드에서 @Transactional
을 없애고 대신 @BeforeEach
와 동일한 역할인 @AfterEach
를 사용해서 직접 테스트 데이터를 롤백하는 방식으로 구현해서 해결할 수 있었다.
@BeforeEach
void init() {
oEmbedResource1 = oEmbedResourceService.createOEmbedResource(
"SITE_NAME1", "OEMBED_URL1", Set.of(oEmbedResource1Pattern1)
);
}
@AfterEach
void clean() {
oEmbedResourceRepository.deleteAll();
resourcePatternRepository.deleteAll();
}
그리고 다시 테스트를 돌려본 결과 전부 통과하는 것을 확인할 수 있었다.
구현한 기능 중에는 OEmbedResource
의 식별자인 siteName
필드를 변경하는 기능이 있다. 프론트엔드 애플리케이션에서는 시간 관계상 해당 부분까지 구현하진 못했지만 테스트 코드로 테스트해보았는데 다음과 같은 예외가 발생했다.
2022-01-11 17:18:12.747 ERROR 27860 --- [ Test worker] i.p.o.c.e.GlobalExceptionHandler : unhandled exception org.springframework.orm.jpa.JpaSystemException: identifier of an instance of io.purple.oembed.domain.OEmbedResource was altered from SITE_NAME1 to NEW_SITE_NAME;
처음 보는 예외(JpaSystemException
)라 당황했지만 에러 메시지를 잘 읽어보니
즉 실시간으로 식별자를 변경할 수 없는 문제였다. 어떻게보면 당연한 것이 하이버네이트의 엔티티 매니저에서는 식별자를 기반으로 영속성 컨텍스트의 엔티티를 관리한다고 알고 있다. 이걸 실시간으로 바꾸면 예측할 수 없는 문제가 일어날 수 있기 때문에 변경하지 못하도록 하는 것일 것이다.
스택오버플로우에서는 엔티티 매니저에서 해당 엔티티를 준영속(detach)시킨 후에 식별자를 변경하라고 하고 있지만 연관관계의 다른 테이블에서 참조하는 경우 문제가 발생할 수 있다고 한다. 처음 구상할 때는 식별자를 문자열로 하고 변경할 수도 있으면 좋지 않을까? 라고 생각했지만 변경될 수 있는 필드라면 그냥 안전하게 별도의 필드로 두고 식별자는 고정시키는 방향이 맞는 것 같다.
그러면 식별자를 변경할 수 있는 기능은 포기해야 할까? 한 가지 대안은 변경된 식별자의 엔티티를 하나 더 만들고 연관관계 객체들이 해당 엔티티를 새로 연관하도록 구성하는 것이다.
public OEmbedResourceDto updateSiteName(
String previousSiteName,
String newSiteName
) {
checkArgument(
isNotBlank(previousSiteName),
"previous site name cannot be null or blank."
);
checkArgument(
isNotBlank(newSiteName),
"new site name cannot be null or blank."
);
checkArgument(
!resourceRepository.existsById(newSiteName),
"already existing site name"
);
OEmbedResource resource = resourceRepository.findByIdOrElseException(previousSiteName);
OEmbedResource newResource = resourceRepository.saveAndFlush(
OEmbedResource.builder()
.siteName(newSiteName)
.oEmbedUrl(
resource.getOEmbedUrl())
.build());
Set<ResourcePattern> resourcePatterns = new HashSet<>(resource.getResourcePatterns());
for (ResourcePattern rp : resourcePatterns) {
rp.changeOEmbedResource(newResource);
}
resourceRepository.delete(resource);
return OEmbedResourceDto.of(newResource);
}
동일한 값에 새로운 식별자를 가진 엔티티 객체를 만들어서 기존 엔티티 객체를 참조하던 연관관계 객체들(Set<ResourcePattern>
)이 새로운 엔티티 객체를 연관하도록 변경한 후 기존 엔티티를 삭제하는 방식으로 구현할 수 있다.
백엔드의 핵심 중 하나인 데이터베이스를 다루는 트랜잭션에 대해서 정확히 알고있지 못했기 때문에, 그리고 의미를 까먹은 채 @Transactional
어노테이션을 남발했기 때문에 이런 삽질을 하게 된 것 같다.
처음에는 Cascade 에 문제가 있는건가 생각했지만 일반 실행에는 잘 되다가 테스트 코드에서는 안 되는 이유가 뭘까 생각해보니 전자의 경우 컨트롤러에는 @Transactional
이 없지만 후자의 경우 MockMvc
로 HTTP 요청을 전송하는 시점부터 @Transactional
이 적용되어 있었던 부분에서 해결책을 찾을 수 있었던 것 같다.
스프링의 트랜잭션 관리에 대해 좀 더 생각해 볼 수 있는 계기가 된 좋은 경험이었다.