Spring

LazyInitializationException 해결기

윤밥밥 2025. 2. 22. 18:39

문제

@Slf4j
@Service
@RequiredArgsConstructor
public class DataInitService {

	private final PasswordEncoder passwordEncoder;
	private final JDBCRepository JDBCRepository;
	private final MemberRepository memberRepository;
	private final RoomRepository roomRepository;

	public void initializeData() {
		// Rooms 생성
		List<Room> rooms = new ArrayList<>();
		for (int i = 1; i <= 1; i++) {
			Room room = Room.builder()
				.id(String.valueOf(i))
				.name("room" + i)
				.memo("memo" + i)
				.build();
			rooms.add(room);
		}
		JDBCRepository.saveAllRooms(rooms);

		rooms = roomRepository.findAll(); //엔티티 조회

		// Places 생성
		List<Place> places = new ArrayList<>();
		for (int i = 1; i <= 1; i++) {
			Place place = Place.builder()
				.siDo("siDo" + i)
				.siGunGu("siGunGu" + i)
				.roadNameAddress("roadNameAddress" + i)
				.addressLatitude(37.0 + i * 0.0001)
				.addressLongitude(127.0 + i * 0.0001)
				.room(rooms.get(i - 1))
				.member(members.get(i - 1))
				.googlePlaceId("googlePlaceId" + i)
				.build();
			places.add(place);
		}
		// entityManager.clear(); // room들이 변경감지되어 place를 저장하기 전에 clear
		JDBCRepository.saveAllPlaces(places);
	}
}

위 로직에서 Place를 저장할 때 LazyInitializationException이 발생했다.

분석해보니 Place의 연관관계 편의 메서드에서 아래와 같이 Room의 place들을 조회해오면서 발생하였다.

Room의 places들을 조회하면서 LazyLoading을 하려했으나 @Transactional이 없어 실패했다는 것이다.

 

해결 과정

1. Room들을 영속성 컨텍스트에서 detach하면 LazyInitializationException이 안발생하지 않을까?

@Slf4j
@Service
@RequiredArgsConstructor
public class DataInitService {

	private final PasswordEncoder passwordEncoder;
	private final JDBCRepository JDBCRepository;
	private final MemberRepository memberRepository;
	private final RoomRepository roomRepository;

	@PersistenceContext
	private final EntityManager entityManager;

	@Transactional
	public void initializeData() {
		// Rooms 생성
		List<Room> rooms = new ArrayList<>();
		for (int i = 1; i <= 1; i++) {
			Room room = Room.builder()
				.id(String.valueOf(i))
				.name("room" + i)
				.memo("memo" + i)
				.build();
			rooms.add(room);
		}
		JDBCRepository.saveAllRooms(rooms);

		rooms = roomRepository.findAll();
		for (Room room : rooms) { // 조회해온 Room들을 detach
			emtityManager.detach(room);
		}

		// Places 생성
		List<Place> places = new ArrayList<>();
		for (int i = 1; i <= 1; i++) {
			Place place = Place.builder()
				.siDo("siDo" + i)
				.siGunGu("siGunGu" + i)
				.roadNameAddress("roadNameAddress" + i)
				.addressLatitude(37.0 + i * 0.0001)
				.addressLongitude(127.0 + i * 0.0001)
				.room(rooms.get(i - 1))
				.member(members.get(i - 1))
				.googlePlaceId("googlePlaceId" + i)
				.build();
			places.add(place);
		}

		JDBCRepository.saveAllPlaces(places);
	}
}

내 개인적인 착각으로 LazyLoading은 영속성 컨텍스트에 있을 때만 작동하고, 아니면 동작하지 않는다고 생각했다.

하지만 LazyLoading은 엔티티를 생성할 때 프록시를 사용해 프록시 객체를 필드에 주입해주고,

실제 필드를 조회하게 될 때 프록시 객체의 코드가 실행되면서 조회해오는 방식이었다.

따라서 영속성 컨텍스트와 무관하게 LazyLoading은 항상 동작하게 된다.

LazyLoading과 LazyInitializationException에 대해서는 아래에 상세히 적어뒀습니다.

https://yooooonshine.tistory.com/50

 

LazyInitializationException과 LazyLoading의 동작원리

@Transactional이 붙지 않은 메서드 내에서 LazyLoading된 필드를 접근 시 하이버네이트가 LazyInitializationException을 발생시킨다.왜냐하면 Lazy Loading이 동작하려면 영속성 컨텍스트가 필요하지만, 이게 닫

yooooonshine.tistory.com

 

 

참고 -  Bean으로 가져온 EntityManager와 트랜잭션의 EntityManager는 동일하다.

더보기

참고로 위 과정에서 Bean으로 가져온 EntityManager와 트랜잭션의 EntityManager는 동일합니다.

이에 대해서는 아래 글을 먼저 읽으시면 이해할 수 있습니다.

https://yooooonshine.tistory.com/57

즉 빈으로 등록된 EntityManager는 실제 EntityManager가 아닌 프록시 객체이며 이 프록시 객체는 TransactionSynchronizationManager를 통해 ThreadLocal에서 EntityManager가 있으면 찾아오며

트랜잭션 또한 TransactionSynchronizationManager를 통해 ThreadLocal에서 EntityManager가 있으면 찾아오기 때문에 동일합니다.

2. @Transactional을 붙여준다.

단순하게 LazyLoading에서 에러가 발생한 이유는 Room들의 연관관계를 가져오려는 데 영속성 컨텍스트가 닫혀있어 발생했기에 그냥 @Transactional을 붙여주면 됐다.

하지만 나는 Room들이 영속성 컨텍스트에서 관리되기를 원치 않았다.

 

첫 번째로는 데이터가 50만개나 되기에 영속성 컨텍스트에서 관리되면 메모리에 부담이 될 거라 생각했기 때문이었다.

두 번째로는 Room이 영속성 컨텍스트에서 관리되면, 연관관계 편의 메서드로 인해 Room은 Place에 대한 변경감지가 이뤄지는데, Place들을 저장할 때에는 JDBC를 사용해 저장하므로 영속성 컨텍스트에 저장되지 않는다.

이로 인해 트랜잭션이 끝날 때 Place의 변경감지를 처리해주는 과정에서 Place가 중복저장되어버린다.

 

따라서 다른 방법이 필요했다.

2. 저장할 때 아예 LazyLoading이 발생하지 않도록 하자.

영속성 컨텍스트와 무관하게 LazyLoading이 발생하므로 아예 연관관계를 접근하지 않도록 하면 됐다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Place {

	@Id
	@Column(name = "place_id")
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	...

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "room_id")
	private Room room;

	...

	pubilc Place(String siDo, String siGunGu, String roadNameAddress, Double addressLatitude,
		Double addressLongitude, Room room, Member member, String googlePlaceId) {
		this.siDo = siDo;
		this.siGunGu = siGunGu;
		this.roadNameAddress = roadNameAddress;
		this.addressLatitude = addressLatitude;
		this.addressLongitude = addressLongitude;
		addRoom(room);
		this.member = member;
		this.googlePlaceId = googlePlaceId;
	}

	private void addRoom(Room room) {
		this.room = room;
		room.getPlaces().add(this);
	}
}

그럼 위에서 생성자에 addRoom(room)을 빼버리면 됐지만,

이러면 모든 서비스 로직을 고쳐야할 게 분명해졌다…

단순 더미 데이터 로직 때문에 서비스 코드를 변경하는 것은 옳지 않다고 생각이 들었다.

그래서 다른 방법을 생각했다.

		// Places 생성
		List<Place> places = new ArrayList<>();
		for (int i = 1; i <= 1; i++) {
			Place place = Place.builder()
				.siDo("siDo" + i)
				.siGunGu("siGunGu" + i)
				.roadNameAddress("roadNameAddress" + i)
				.addressLatitude(37.0 + i * 0.0001)
				.addressLongitude(127.0 + i * 0.0001)
				.room(rooms.get(i - 1))
				.member(members.get(i - 1))
				.googlePlaceId("googlePlaceId" + i)
				.build();
			places.add(place);
		}

		JDBCRepository.saveAllPlaces(places);

위에서 JDBCRepository의 saveAllPlaces 메서드는 사실 필드 값들만 필요하지 엔티티를 저장하는 JPA와는 달랐다.

따라서 Place들의 값들을 갖는 PlaceDTO를 넘겨줘도 무관했다.

		List<PlaceDummyDto> places = new ArrayList<>();
		for (int i = 1; i <= DummyDataConstant.PLACE_COUNT.count; i++) {
			PlaceDummyDto place = new PlaceDummyDto(
				siDo,
				siGunGu,
				roadNameAddress,
				latitude,
				longitude,
				String.valueOf(i), // roomId
				(long)i, // memberId
				googlePlaceId
			);
			places.add(place);
		}
		jdbcRepository.saveAllPlaces(places);

따라서 위와 같이 아예 PlaceDTO로 넘겨주는 방식으로 변경했다.

느낀 점

  • 추후에 Entity를 개발할 때 연관관계 편의 메서드는 생성자 안에 넣지 말아야겠다. 생성자는 순수 생성자가 되어야 예상치 못한 에러를 줄일 수 있을 것 같다.