테스트를 작성하다가 Lazy Loading 관련 에러를 만나게 되었다.
이런 오류가 왜 발생하는지, 어떻게 해결할 수 있을지 정리했다.
## 어디서 발생했지? 오류는 뭐지?
병을 생성하는 api를 호출한 뒤, 실제로 데이터베이스에서 병 레코드가 추가되었는지 확인해야 했다.
응답받은 병 id를 이용해 데이터베이스에서 조회했고, 병이 있는지 확인했다.
그후 연관객체인 음악 정보를 마저 확인하는데.. 이곳에서 예외가 터졌다.
Bottle saved = bottleRepository.findById(bottleResponse.id()).orElseThrow();
assertEquals(saved.getSpotifyMusic().getTitle(), bottleResponse.title()); // 이 부분!
//org.hibernate.LazyInitializationException: could not initialize proxy [~~~~] - no Session
assertEquals(saved.getUploadedStreamingMusicType().name(), bottleResponse.uploadedStreamingMusicType());
Bottle 객체인 saved에서 조회한 연관객체 SpotifyMusic을 사용한 것이 문제였다.
## LazyInitializationException?
이 예외는 연관객체를 데이터베이스에서 불러올 수 없을때 발생한다고 한다.
연관객체를 불러올 수 없는데 왜 null오류가 아닌거냐! 라는 의문이 들 수 있는데,
hibernate는 나중에 로드할 엔티티를 프록시 객체를 통해 처리하기 때문이다.
## no Session?
하이버네이트의 세션을 말하는 것인데, 이 세션객체는 sqlalchemy의 세션과 유사한 기능을 하는 것 같다.
공식 문서를 보면, 꽤 간단히 설명하고 있는데 주요 글만 가져와봤다.
The main function of the Session is to offer create, read and delete operations for instances of mapped entity classes. Instances may exist in one of three states:
-> 세션의 주요 함수는 매핑된 엔티티 클래스의 인스턴스에 대해 생성, 읽기, 삭제 명령을 하는 것이다. 인스턴스는 3가지 상태로 존재한다.
transient: never persistent, not associated with any Session
-> transient: 영속화되지 않으며, 세션에 연결되지 않음. (그냥 엔티티 객체를 생성한 경우)
persistent: associated with a unique Session
-> persistent: 하나의 세션에 연결됨
detached: previously persistent, not associated with any Session
-> detached: 이전에 영속화 되었었고, 현재는 세션에 연결되지 않음. (세션이 닫힌 경우)
Transient instances may be made persistent by calling save(), persist() or saveOrUpdate(). Persistent instances may be made transient by calling delete(). Any instance returned by a get() or load() method is persistent. Detached instances may be made persistent by calling update(), saveOrUpdate(), lock() or replicate(). The state of a transient or detached instance may also be made persistent as a new persistent instance by calling merge().
-> Transient 인스턴스는 save(), persist(), saveOfUpdate()를 호출해서 영속화할 수 있고, 영속화된 인스턴스는 delete()를 통해 transient로 바꿀 수 있다. get(), load()를 통해 얻은 모든 인스턴스는 영속화되어있다. Detached 인스턴스는 update(), saveOfUpdate(), lock(), replicate()를 통해 영속화된다. merge()에 transient와 detached 인스턴스를 넣고 호출해서 새로운 영속화 인스턴스를 만들 수 있다.
요 세션은 실질적으로 엔티티를 관리하는 친구가 되시겠다!
그럼 EntityManager나 Repository는 이 세션 객체를 다루게 되겠지? 싶다.
다시 no Session 으로 돌아가보자면, 이것은 세션이 만료되어서 그런것이다.
그러니까 트랜잭션이 시작되지 않았거나 종료되었다! 아무튼 없다는 것이다.
에러가 난 부분을 다시 보자.
Bottle saved = bottleRepository.findById(bottleResponse.id()).orElseThrow();
assertEquals(saved.getSpotifyMusic().getTitle(), bottleResponse.title()); // 이 부분!
위를 보면 repository를 통해 간접적으로 session객체를 컨트롤 했다는 것을 유추할 수 있다.
위 줄에서 트랜잭션이 닫힌 것이다.
사실 repository의 함수는 기본적으로 트랜잭션을 시작하게 되어있다.
함수가 끝나면 트랜잭션은 자동으로 종료되는데, 아래 라인에는 트랜잭션에 대한 선언이 없어서 그런 것이다.
해결책으로는 @Transactional을 테스트 메서드에 붙일 수 있겠다. 붙이게 되면 메서드가 시작할때 트랜잭션이 시작되고 끝날때 롤백된다. (테스트 코드에서는)
하지만, 그것은 좀 문제가 된다. (아래봐라)
## DatabaseCleaner 구현
테스트 루틴을 하나의 트랜잭션으로 묶는다면 세션도 살아있고~ 롤백시 데이터가 날라가고~ 좋을 것 같지만, 그러기 어려울 때가 있다.
아래와 같이 api호출을 하는 상황일때 그렇다.
1.테스트 트랜잭션 시작
2.테스트 유저생성
3.API 호출 (에러!, 없는 유저)
4.응답검증
5.데이터베이스 검증
6.테스트 트랜잭션 롤백
때문에 테스트 루틴이 시작할때 데이터베이스를 초기화 하는게 낫다고 판단했다.
그래서 여러 코드를 염탐했는데, DatabaseCleaner라고 헬퍼 객체를 만들어서 사용하는 예제를 보았다.
아래와 같다.
@Component
public class DatabaseCleaner {
private final List<String> tables = new ArrayList<>();
@PersistenceContext
private EntityManager entityManager;
@PostConstruct
public void findDatabaseTables() {
List<Object[]> tableInfos = entityManager.createNativeQuery("SHOW TABLES").getResultList();
for (Object[] tableInfo : tableInfos) {
String tableName = (String) tableInfo[0];
tables.add(tableName);
}
}
public void clear() {
Session session = entityManager.unwrap(Session.class);
session.doWork(this::truncate);
}
private void truncate(Connection conn) throws SQLException {
Statement statement = conn.createStatement();
statement.execute("SET REFERENTIAL_INTEGRITY FALSE");
for (String table : tables) {
statement.execute(String.format("TRUNCATE TABLE %s", table));
// auto increment가 포함된 id같은 경우, 카운터를 초기화해줌
statement.execute(String.format("ALTER TABLE %s ALTER COLUMN id RESTART WITH 1", table));
}
statement.execute("SET REFERENTIAL_INTEGRITY TRUE");
}
}
## 결론..
트랜잭션을 관리하기가 까다로워서, 통합 테스트에서는 요청과 상황에 대한 응답만 검증하기로 했다.
아래 글을 보고나서 든 생각인데, 차라리 ServiceTest를 하나 더 만들어서 여기서 데이터베이스에 대한 검증을 하면 좋을 것 같다. (같은 트랜잭션으로 묶을 수 있으니까!)
'프로그래밍 > A music in a balloon' 카테고리의 다른 글
# 10 스프링 예외처리 (1) | 2024.06.06 |
---|---|
# 9 프로젝트 수정 (0) | 2024.06.06 |
# 8 깃허브 엑션 테스트 적용기 (0) | 2024.06.05 |
# 5 게스트 구현 (0) | 2024.05.31 |
# 2 스프링과 재회 (1) | 2024.05.30 |