[Spring Data JPA Tutorial] 9. LazyInitializationException 해결하기 1. @ManyToOne

2022. 4. 14. 11:16Spring/Spring Data JPA Tutorial

반응형
  1. 사전작업
    1. columnDefinition 제거
    2. Entity 정의
      1. Store
      2. User
    3. store 조회 api 생성
      1. Repository
      2. Service
      3. Controller
      4. 테스트 데이터
      5. api 테스트
  2. LazyInitializationException 해결
    1. @JsonIgnore
    2. Transactional 내부에서 연관관계 미리 조회
    3. Entity 설정에서 FetchType.EAGER로 설정
    4. EntityGraph
    5. fetch join
  3. N+1 문제 해결
    1. Transactional 내부에서 연관관계 미리 조회
    2. Entity 설정에서 FetchType.EAGER로 설정
    3. EntityGraph
    4. fetch join

이전시간에 만든 user 조회 api에서 response에 Entity 객체를 그대로 담았습니다.

기존에 만들었었던 User 엔티티는 연관관계 설정이 없는 매우 단순한 Entity 클래스 였기 때문에 response에 그대로 담아도 별 이슈가 없었습니다.
그러나 Entity에 여러 연관관계 설정을 하게 될 경우, LazyInitializationException이 발생되는 경우가 빈번합니다.

N:1, 1:1 연관관계에서 발생되는 Lazy fetch 문제 해결 방법과, 1:N 연관관계에서 발생되는 Lazy fetch 문제 해결방법이 약간 차이가 있는네,

이번 시간에는 N:1, 1:1 연관관계 필드값을 가지는 엔티티를 그대로 response에 설정할 경우 발생되는 LazyInitializationException을 해결하는 방법을 알아볼 것입니다.


1. 사전작업

1.1. columnDefinition 제거

이전 시간에 정의한 User 엔티티에서는 @Column에 columnDefinition 을 설정한 부분이 많았습니다.
그러나, columnDefinition은 지정한 필드값의 타입과 무관하게 직접적으로 컬럼 타입 및 설정을 정의하는 것이기 때문에 이후에 추가적으로 작업하는 연관테이블에서 FK로 이용하려고 할때에 에러가 발생되는 경우가 빈번합니다.

이번시간을 진행하기 앞서 기존에 이용하고 있던 users 테이블을 제거하고, 아래의 User 엔티티를 이용하여 새로 테이블을 생성한 후 과정을 진행합니다.

@NoArgsConstructor
@DynamicInsert @DynamicUpdate
@Getter
@Entity
@Table(name = "user", indexes = {@Index(name = "UK_USER_EMAIL", columnList = "email", unique = true)})
@Where(clause = "active = true")
public class User implements Serializable {

	private static final long serialVersionUID = -4253749884585192245L;

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(updatable = false, nullable = false)
	private Long id;

	@Column(nullable = false, length = 10)
	@ColumnDefault("'BASIC'")
	@Setter private String type;

	@Column(nullable = false, length = 100)
	@Setter private String email;

	@Column(nullable = false, length = 50)
	@Setter private String name;

	@Column(nullable = false, length = 1)
	@ColumnDefault("'M'")
	@Setter private String sex;

	@Column(length = 8)
	@Setter private String birthDate;

	@Column(nullable = false, length = 20)
	@Setter private String phoneNumber;

	@JsonIgnore
	@Column(nullable = false, length = 150)
	@Setter private String password;

	@Column(nullable = false)
	@ColumnDefault("true")
	@Setter private boolean active;

	@Temporal(TemporalType.TIMESTAMP)
	@Column(nullable = false, updatable = false)
	@ColumnDefault("CURRENT_TIMESTAMP()")
	private Date createdAt;

	@Temporal(TemporalType.TIMESTAMP)
	private Date updatedAt;

	@OneToMany
	@JoinColumn(name = "user_id")
	@Setter private List<Store> stores = new ArrayList<>();

	@Builder
	public User(String type, String email, String name, String sex, String birthDate, String phoneNumber,
			String password) {
		super();
		this.type = type;
		this.email = email;
		this.name = name;
		this.sex = sex;
		this.birthDate = birthDate;
		this.phoneNumber = phoneNumber;
		this.password = password;
	}


	@PrePersist
	protected void onCreate() {
		createdAt = Timestamp.valueOf(LocalDateTime.now());
		active = true;
	}

	@PreUpdate
	protected void onUpdate() {
		updatedAt = Timestamp.valueOf(LocalDateTime.now());
	}

}

※ 테이블 이름과, type과 sex의 기본값도 변경하였으니 참고 바랍니다.


1.2. Entity 정의

에러를 해결하기 앞서, LazyInitializationException 에러가 발생되는 경우를 알아보기 위해, User엔티티와 연관관계를 가질 엔티티를 추가해봅니다.

User 한명은 여러개의 Store를 가질 수 있고 → @OneToMany
Store 하나에는 User 하나에만 매핑됩니다. → @ManyToOne


1.2.1. Store

user 엔티티에 대하여 N:1 연관관계를 갖는 Store 엔티티를 아래와 같이 정의합니다.

@NoArgsConstructor
@DynamicInsert
@DynamicUpdate
@Getter
@Entity
public class Store {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(updatable = false, nullable = false)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_USER_STORE"))
    private User user;

    @Column(nullable=false, length = 100)
    private String name;

    @Column(length = 30)
    private String industry;

    @Builder
    public Store(User user, String name, String industry) {
        this.user = user;
        this.name = name;
        this.industry = industry;
    }
}

1.2.2. User

user 엔티티에도 stores 필드값을 추가하고, 연관관계 설정을 해줍니다.
서로 순환참조되는 에러를 방지하기 위해 stores 필드값에 @JsonIgnore 설정을 추가했습니다.

관련 에러: HttpMessageNotWritableException: Could not write JSON: Infinite recursion

@NoArgsConstructor
@DynamicInsert @DynamicUpdate
@Getter
@Entity
@Table(name = "user", indexes = {@Index(name = "UK_USER_EMAIL", columnList = "email", unique = true)})
@Where(clause = "active = true")
public class User implements Serializable {

	private static final long serialVersionUID = -4253749884585192245L;

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(updatable = false, nullable = false)
	private Long id;

	@Column(nullable = false, length = 10)
	@ColumnDefault("'BASIC'")
	@Setter private String type;

	@Column(nullable = false, length = 100)
	@Setter private String email;

	@Column(nullable = false, length = 50)
	@Setter private String name;

	@Column(nullable = false, length = 1)
	@ColumnDefault("'M'")
	@Setter private String sex;

	@Column(length = 8)
	@Setter private String birthDate;

	@Column(nullable = false, length = 20)
	@Setter private String phoneNumber;

	@JsonIgnore
	@Column(nullable = false, length = 150)
	@Setter private String password;

	@Column(nullable = false)
	@ColumnDefault("true")
	@Setter private boolean active;

	@Temporal(TemporalType.TIMESTAMP)
	@Column(nullable = false, updatable = false)
	@ColumnDefault("CURRENT_TIMESTAMP()")
	private Date createdAt;

	@Temporal(TemporalType.TIMESTAMP)
	private Date updatedAt;

	@JsonIgnore
	@OneToMany
	@JoinColumn(name = "user_id")
	@Setter private List<Store> stores = new ArrayList<>();

	@Builder
	public User(String type, String email, String name, String sex, String birthDate, String phoneNumber,
			String password) {
		super();
		this.type = type;
		this.email = email;
		this.name = name;
		this.sex = sex;
		this.birthDate = birthDate;
		this.phoneNumber = phoneNumber;
		this.password = password;
	}


	@PrePersist
	protected void onCreate() {
		createdAt = Timestamp.valueOf(LocalDateTime.now());
		active = true;
	}

	@PreUpdate
	protected void onUpdate() {
		updatedAt = Timestamp.valueOf(LocalDateTime.now());
	}

}

1.3. store 조회 api 생성

N:1 관계에서 발생되는 LazyInitializationException을 살펴보기 위해, store 조회 api를 생성해봅니다.

1.3. Repository

public interface StoreRepository extends JpaRepository<Store, Long> {

}

1.3.2. Service

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class StoreService {

	private final StoreRepository storeRepository;

	public Store select(Long id) {
		Store store = storeRepository.findById(id).orElse(null);
		return store;
	}

}

1.3.3. Controller

@RequiredArgsConstructor
@RequestMapping(path = "/api/stores")
@RestController
public class StoreController {

	private final StoreService storeService;

	@GetMapping("/{id}")
	public Map<String, Object> select(@PathVariable("id") long id) {
		Map<String, Object> response = new HashMap<>();

		Store store = storeService.select(id);
		if(store != null) {
			response.put("result", "SUCCESS");
			response.put("store", store);
		} else {
			response.put("result", "FAIL");
			response.put("reason", "일치하는 가게 정보가 없습니다. 사용자 id를 확인해주세요.");
		}

		return response;
	}

}

1.3.4. 테스트 데이터

API 테스트를 하기 전에, api 조회를 위해 데이터를 미리 넣어둡니다.

insert into `user` (`id`, `type`, `name`, `sex`, `phone_number`, `birth_date`, `email`, `password`, `active`, `created_at`, `updated_at`) values('1','BASIC','지니','F','01011112222','19920201','jini@jiniworld.me','1','','2022-04-11 16:59:07.148000',NULL);
insert into `user` (`id`, `type`, `name`, `sex`, `phone_number`, `birth_date`, `email`, `password`, `active`, `created_at`, `updated_at`) values('2','OWNER','이한','M','01088887777','19880702','2han@appleboxy.xyz','1','','2022-04-13 12:21:30.542000',NULL);
insert into `user` (`id`, `type`, `name`, `sex`, `phone_number`, `birth_date`, `email`, `password`, `active`, `created_at`, `updated_at`) values('3','OWNER','코코','F','01055557741','19980117','coco@jiniworld.me','1','','2022-04-13 16:14:01.098665',NULL);
insert into `store` (`id`, `user_id`, `name`, `industry`) values('1','1','우리의 시간','카페');
insert into `store` (`id`, `user_id`, `name`, `industry`) values('2','1','에머이','베트남음식');
insert into `store` (`id`, `user_id`, `name`, `industry`) values('3','2','한잔의 추억','술집');
insert into `store` (`id`, `user_id`, `name`, `industry`) values('4',NULL,'할리스','카페');

1.3.5. api 테스트

store를 조회하는 api를 실행해봅니다.
api를 조회한 결과 Proxy 초기화 관련 에러가 발생합니다.

02-10


이 에러가 바로 LazyInitializationException 입니다.

컨트롤러 쪽에서 디버깅을 해서 store 객체를 조회해보면 user 필드값이 조회되지 않고 아래와 같이 LazyInitializationException 이 발생된것을 확인할 수 있습니다.

02-9


LazyInitializationException 은 영속상태가 아닌 엔티티 객체 내에 DB로 부터 읽어들이지 않은 연관관계 정보를 읽으려고 시도할 때 발생되는 에러입니다.

Entity객체는 @Transactional 범위 내에서만 영속상태를 유지하며, @Transactional을 벗어나고나서는 비영속상태가 됩니다.

위의 코드상에서는 StoreService 클래스에 정의된 @Transactional(readOnly = true) 에 따라, Service 내의 메서드 내에서만 영속상태를 유지합니다.

StoreService에서 return된 값이 Controller에 도착하고 나서는 비영속상태가 되기 때문에, 그 전에 연관관계 설정된 필드값을 읽어(fetch)오지 않을 경우 LazyInitializationException 에러가 발생되는 것입니다.


그럼 이제부터 LazyInitializationException을 해결하는 방법을 알아보도록 합시다.


2. LazyInitializationException 해결

@ManyToOne 연관관계 설정된 필드값에서 발생되는 LazyInitializationException 에러를 해결하는 방법들과 각 방법들에 대한 차이점을 알아보도록 합시다.

  • @JsonIgnore
  • Transactional 내부에서 연관관계 미리 조회
  • Entity 설정에서 FetchType.EAGER로 설정
  • EntityGraph
  • fetch join

2.1. @JsonIgnore

@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_USER_STORE"))
private User user;
02-11

그러나, user 필드를 @JsonIgnore 설정할 경우, response에 포함되지 않기 때문에 우리가 원하는 결과가 아닙니다.


2.2. Transactional 내부에서 연관관계 미리 조회

@Transactional 설정된 메서드 내에서, 연관관계 필드값인 user를 미리 조회합니다.
아래와 같이 간단하게 name값을 한번 조회할 경우, user를 조회하는 쿼리가 실행됩니다.

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class StoreService {

	private final StoreRepository storeRepository;

	public Store select(Long id) {
		Store store = storeRepository.findById(id).orElse(null);
		Optional.ofNullable(store.getUser()).ifPresent(user -> user.getName());
		return store;
	}

}

LAZY fetch로 정의된 객체 내의 연관관계 필드값을 영속상태일 때에 미리 LOAD를 하더라도, 기본적으로 아래와 같은 에러가 발생됩니다.

nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)

이는, Response에 Entity 객체를 그대로 담을 때, LAZY fetch 설정된 user 객체 내에 hibernateLazyInitializer 가 함께 담겨서 발생되는 문제인데.

이 부분은 application.yml에 에러 방지를 위한 프로퍼티를 설정해주거나, User 엔티티 클래스에 @JsonIgnoreProperties({"hibernateLazyInitializer"}) 를 설정하여 response에 포함시켜서 문제를 해결할 수 있습니다.

그중 우리는 application.ymlspring.jackson.serialzation.fail-on-empty-beans 값을 false로 설정하여, hibernateLazyInitializer 설정 관련 에러를 방지했습니다.

spring:
  jackson:
    serialization:
      fail-on-empty-beans: false

다시 api를 실행해보면, 이번엔 api가 정상적으로 동작되는 것을 확인할 수 있습니다.

02-13


이번엔 console에 찍힌 sql을 확인해봅시다.
store와 user가 각각 따로 조회된것을 확인할 수 있습니다.

Hibernate:
	select
			store0_.id as id1_0_0_,
			store0_.industry as industry2_0_0_,
			store0_.name as name3_0_0_,
			store0_.user_id as user_id4_0_0_
	from
			store store0_
	where
			store0_.id=?
Hibernate:
	select
			user0_.id as id1_1_0_,
			user0_.active as active2_1_0_,
			user0_.birth_date as birth_da3_1_0_,
			user0_.created_at as created_4_1_0_,
			user0_.email as email5_1_0_,
			user0_.name as name6_1_0_,
			user0_.password as password7_1_0_,
			user0_.phone_number as phone_nu8_1_0_,
			user0_.sex as sex9_1_0_,
			user0_.type as type10_1_0_,
			user0_.updated_at as updated11_1_0_
	from
			user user0_
	where
			user0_.id=?
			and (
					user0_.active = 1
			)


2.3. Entity 설정에서 FetchType.EAGER로 설정

1:1, N:1 관계의 기본 fetchType은 EAGER입니다.
@ManyToOne 에 설정되어있던 LAZY 설정을 제거하면 EAGER 방식으로 user 정보를 fetch합니다.

@Entity
public class Store {

    ...

    @ManyToOne
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_USER_STORE"))
    private User user;
}

마찬가지로 api가 정상작동됩니다.

02-12

2.3 ~ 2.5 에서 id가 1인 Store를 조회한 Response 모두 위와 같습니다.


api를 실행할 때 이용되는 SQL은 아래와 같습니다.

Hibernate:
    select
        store0_.id as id1_0_0_,
        store0_.industry as industry2_0_0_,
        store0_.name as name3_0_0_,
        store0_.user_id as user_id4_0_0_,
        user1_.id as id1_1_1_,
        user1_.active as active2_1_1_,
        user1_.birth_date as birth_da3_1_1_,
        user1_.created_at as created_4_1_1_,
        user1_.email as email5_1_1_,
        user1_.name as name6_1_1_,
        user1_.password as password7_1_1_,
        user1_.phone_number as phone_nu8_1_1_,
        user1_.sex as sex9_1_1_,
        user1_.type as type10_1_1_,
        user1_.updated_at as updated11_1_1_
    from
        store store0_
    left outer join
        user user1_
            on store0_.user_id=user1_.id
    where
        store0_.id=?

EAGER방식으로 엔티티를 조회할 경우, LEFT OUTER JOIN 방식으로 엔티티를 join하여 한번에 읽어들입니다.

left outer join을 하기 때문에, 연관된 user가 없더라도 store 조회가 가능합니다.

02-17


2.4. EntityGraph

EntityGraph는 EAGER 방식으로 연관설정된 엔티티들을 읽어들입니다.

Entity의 연관관계 필드값에 직접 fetchType.EAGER 설정하는 것과 차이점은 기본적으로는 LAZY방식으로 조회하되, 필요한 경우에만 해단 메서드를 이용하여 EAGER방식으로 조회할 수 있다는 점입니다.
(기본적으로 findById 를 할때에는 LAZY로 조회됩니다.)

2.4.1. Entity

먼저 Store 엔티티에 fetch 타입을 LAZY로 다시 변경하고

@Entity
public class Store {

    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_USER_STORE"))
    private User user;
}

2.4.2. Repository

Repository에 @EntityGraph를 이용하여 user 필드값을 EAGER 방식으로 가져오는 메서드를 정의합니다.

public interface StoreRepository extends JpaRepository<Store, Long> {

    @EntityGraph(attributePaths = "user", type = EntityGraph.EntityGraphType.LOAD)
    Optional<Store> findDistinctWithUserById(Long id);

}
  • EntityGraphType.FETCH
    • 기본값
    • @EntityGraph의 attributePaths에 설정된 연관관계 외의 다른 연관관계 필드값을 Lazy 방식으로 로드합니다.
  • EntityGraphType.LOAD
    • @EntityGraph의 attributePaths에 설정된 연관관계 외의 다른 연관관계 필드값을 Entity에 정의된 fetchType으로 로드합니다.

2.4.3. Service

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class StoreService {

	private final StoreRepository storeRepository;

	public Store select(Long id) {
		Store store = storeRepository.findWithUserById(id).orElse(null);
		return store;
	}

}

2.4.4. query

작동된 쿼리는 2.4 과 동일합니다.

Hibernate:
    select
        distinct store0_.id as id1_0_0_,
        user1_.id as id1_1_1_,
        store0_.industry as industry2_0_0_,
        store0_.name as name3_0_0_,
        store0_.user_id as user_id4_0_0_,
        user1_.active as active2_1_1_,
        user1_.birth_date as birth_da3_1_1_,
        user1_.created_at as created_4_1_1_,
        user1_.email as email5_1_1_,
        user1_.name as name6_1_1_,
        user1_.password as password7_1_1_,
        user1_.phone_number as phone_nu8_1_1_,
        user1_.sex as sex9_1_1_,
        user1_.type as type10_1_1_,
        user1_.updated_at as updated11_1_1_
    from
        store store0_
    left outer join
        user user1_
            on store0_.user_id=user1_.id
    where
        store0_.id=?

EntityGraph는 left outer join으로 엔티티값을 읽기 때문에, user_id가 설정되지 않은 4번 가게도 조회가 됩니다.

02-17


2.5. fetch join

2.4 과 똑같이 Entity에서 user 필드값의 fetchType을 LAZY로 바꾸고, fetch join을 이용하여 user를 조회합니다.

public interface StoreRepository extends JpaRepository<Store, Long> {

    @Query("SELECT DISTINCT s FROM Store s join fetch s.user WHERE s.id = ?1")
    Optional<Store> findDistinctWithUserById(Long id);

}

Entity Graph는 left outer join 방식으로 user 정보를 가져왔다면, fetch join 은 inner join 방식으로 user 정보를 가져온다는 차이점이 있습니다.

fetch join 방식은 연관설정된

Hibernate:
    select
        distinct store0_.id as id1_0_0_,
        user1_.id as id1_1_1_,
        store0_.industry as industry2_0_0_,
        store0_.name as name3_0_0_,
        store0_.user_id as user_id4_0_0_,
        user1_.active as active2_1_1_,
        user1_.birth_date as birth_da3_1_1_,
        user1_.created_at as created_4_1_1_,
        user1_.email as email5_1_1_,
        user1_.name as name6_1_1_,
        user1_.password as password7_1_1_,
        user1_.phone_number as phone_nu8_1_1_,
        user1_.sex as sex9_1_1_,
        user1_.type as type10_1_1_,
        user1_.updated_at as updated11_1_1_
    from
        store store0_
    inner join
        user user1_
            on store0_.user_id=user1_.id
    where
        store0_.id=?

1번 가게의 경우, user가 설정되어있기때문에 2.3에서 조회된 결과와 같이 정상적으로 response가 내려오지만,

user_id가 설정되지 않은 4번 가게의 경우, inner join 조회시, 조회되는 것이 없기 때문에 아래와 같은 response를 내려줍니다.

02-16


3. N+1 문제 해결

stores 를 모두 조회하는 GET /api/stores 라는 api가 있다고 해볼때,
2.2 ~ 2.5 에서 살펴보았던 방식을 store 모두 조회에 적용해서, N+1 문제에 대해 알아보도록 합시다.

3.1. Transactional 내부에서 연관관계 미리 조회

3.1.1. Service

stream().forEach()를 이용하여 stores 내의 user값을 모두 읽습니다.

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class StoreService {

	private final StoreRepository storeRepository;

	public List<Store> select() {
		List<Store> stores = storeRepository.findAll();
		stores.stream().forEach(store -> Optional.ofNullable(store.getUser()).map(User::getName));
		return stores;
	}

}

3.1.2. query

Hibernate:
    select
        store0_.id as id1_0_,
        store0_.industry as industry2_0_,
        store0_.name as name3_0_,
        store0_.user_id as user_id4_0_
    from
        store store0_
Hibernate:
    select
        user0_.id as id1_1_0_,
        user0_.active as active2_1_0_,
        user0_.birth_date as birth_da3_1_0_,
        user0_.created_at as created_4_1_0_,
        user0_.email as email5_1_0_,
        user0_.name as name6_1_0_,
        user0_.password as password7_1_0_,
        user0_.phone_number as phone_nu8_1_0_,
        user0_.sex as sex9_1_0_,
        user0_.type as type10_1_0_,
        user0_.updated_at as updated11_1_0_
    from
        user user0_
    where
        user0_.id=?
        and (
            user0_.active = 1
        )
Hibernate:
    select
        user0_.id as id1_1_0_,
        user0_.active as active2_1_0_,
        user0_.birth_date as birth_da3_1_0_,
        user0_.created_at as created_4_1_0_,
        user0_.email as email5_1_0_,
        user0_.name as name6_1_0_,
        user0_.password as password7_1_0_,
        user0_.phone_number as phone_nu8_1_0_,
        user0_.sex as sex9_1_0_,
        user0_.type as type10_1_0_,
        user0_.updated_at as updated11_1_0_
    from
        user user0_
    where
        user0_.id=?
        and (
            user0_.active = 1
        )

별도의 설정을 하지 않았기 때문에, user 값을 join으로 가져오지 못합니다.
store를 먼저 조회한 후, store의 user_id에 설정된 값을 이용하여 user를 각각 조회합니다.
store에 정의된 user의 distinct 값이 1,2 이기 때문에 user 조회가 2번 이뤄집니다.

조회하고자하는 엔티티인 store를 조회하는 쿼리 1번 + 조회해야할 연관관계(user) 조회 쿼리 2번(N) → N+1 문제 발생!!!


3.2. Entity 설정에서 FetchType.EAGER로 설정

3.2.1. Entity

Entity의 user 필드값의 fetchType을 EAGER로 변경합니다.

@NoArgsConstructor
@DynamicInsert
@DynamicUpdate
@Getter
@Entity
public class Store {

    ...

    @JsonIgnore
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_USER_STORE"))
    private User user;

}

3.2.2. Service

EAGER를 이용하여 left outer join하여 user 정보를 조회할 것이기 때문에 user를 조회하는 코드는 필요하지 않습니다.

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class UserService {

	private final UserRepository userRepository;

	public List<User> selectAll() {
		List<User> users = userRepository.findAll();
		return users;
	}
}

3.2.3. query

Hibernate:
    select
        store0_.id as id1_0_,
        store0_.industry as industry2_0_,
        store0_.name as name3_0_,
        store0_.user_id as user_id4_0_
    from
        store store0_
Hibernate:
    select
        user0_.id as id1_1_0_,
        user0_.active as active2_1_0_,
        user0_.birth_date as birth_da3_1_0_,
        user0_.created_at as created_4_1_0_,
        user0_.email as email5_1_0_,
        user0_.name as name6_1_0_,
        user0_.password as password7_1_0_,
        user0_.phone_number as phone_nu8_1_0_,
        user0_.sex as sex9_1_0_,
        user0_.type as type10_1_0_,
        user0_.updated_at as updated11_1_0_
    from
        user user0_
    where
        user0_.id=?
        and (
            user0_.active = 1
        )
Hibernate:
    select
        user0_.id as id1_1_0_,
        user0_.active as active2_1_0_,
        user0_.birth_date as birth_da3_1_0_,
        user0_.created_at as created_4_1_0_,
        user0_.email as email5_1_0_,
        user0_.name as name6_1_0_,
        user0_.password as password7_1_0_,
        user0_.phone_number as phone_nu8_1_0_,
        user0_.sex as sex9_1_0_,
        user0_.type as type10_1_0_,
        user0_.updated_at as updated11_1_0_
    from
        user user0_
    where
        user0_.id=?
        and (
            user0_.active = 1
        )

그러나, 예상과 달리 user값을 left outer join으로 조회하지 않고 3.1. 과 동일하게 store조회와, id가 1, 2인 user를 각각 조회하는 쿼리가 총 3개 나갔습니다.

조회하고자하는 엔티티인 store를 조회하는 쿼리 1번 + 조회해야할 연관관계(user) 조회 쿼리 2번(N) → N+1 문제 발생!!!


3.3. EntityGraph

3.3.1. Repository

public interface StoreRepository extends JpaRepository<Store, Long> {

    @EntityGraph(attributePaths = "user")
    List<Store> findDistinctWithUserBy();

}

3.3.2. Service

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class UserService {

	private final UserRepository userRepository;

	public List<User> selectAll() {
		List<User> users = userRepository.findDistinctWithStoresBy();
		return users;
	}
}

3.3.3. query

EntityGraph를 이용하니, 우리가 원하던 대로 left outer join을 이용하여 1번의 쿼리만 이용되었습니다.

Hibernate:
    select
        distinct store0_.id as id1_0_0_,
        user1_.id as id1_1_1_,
        store0_.industry as industry2_0_0_,
        store0_.name as name3_0_0_,
        store0_.user_id as user_id4_0_0_,
        user1_.active as active2_1_1_,
        user1_.birth_date as birth_da3_1_1_,
        user1_.created_at as created_4_1_1_,
        user1_.email as email5_1_1_,
        user1_.name as name6_1_1_,
        user1_.password as password7_1_1_,
        user1_.phone_number as phone_nu8_1_1_,
        user1_.sex as sex9_1_1_,
        user1_.type as type10_1_1_,
        user1_.updated_at as updated11_1_1_
    from
        store store0_
    left outer join
        user user1_
            on store0_.user_id=user1_.id

3.3.4. response

response를 보면, user_id가 설정되지 않은 4번 가게까지 출력된것을 확인할 수 있습니다.

{
  "result": "SUCCESS",
  "stores": [
    {
      "id": 1,
      "user": {
        "id": 1,
        "type": "BASIC",
        "email": "jini@jiniworld.me",
        "name": "지니",
        "sex": "F",
        "birthDate": "19920201",
        "phoneNumber": "01011112222",
        "active": true,
        "createdAt": "2022-04-11T07:59:07.148+00:00",
        "updatedAt": null
      },
      "name": "우리의 시간",
      "industry": "카페"
    },
    {
      "id": 2,
      "user": {
        "id": 1,
        "type": "BASIC",
        "email": "jini@jiniworld.me",
        "name": "지니",
        "sex": "F",
        "birthDate": "19920201",
        "phoneNumber": "01011112222",
        "active": true,
        "createdAt": "2022-04-11T07:59:07.148+00:00",
        "updatedAt": null
      },
      "name": "에머이",
      "industry": "베트남음식"
    },
    {
      "id": 3,
      "user": {
        "id": 2,
        "type": "OWNER",
        "email": "2han@appleboxy.xyz",
        "name": "이한",
        "sex": "M",
        "birthDate": "19880702",
        "phoneNumber": "01088887777",
        "active": true,
        "createdAt": "2022-04-13T03:21:30.542+00:00",
        "updatedAt": null
      },
      "name": "한잔의 추억",
      "industry": "술집"
    },
    {
      "id": 4,
      "user": null,
      "name": "할리스",
      "industry": "카페"
    }
  ]
}

쿼리를 직접 db에서 조회해보면 아래와 같이 조회됩니다.

02-15


3.4. fetch join

3.4.1. Repository

public interface StoreRepository extends JpaRepository<Store, Long> {

    @Query("SELECT DISTINCT s FROM Store s join fetch s.user")
    List<Store> findDistinctWithStoresBy();

}

3.4.2. query

fetch join으로 조회했을 때에도 inner join을 이용하여 1번의 쿼리만 이용됩니다.

Hibernate:
    select
        distinct store0_.id as id1_0_0_,
        user1_.id as id1_1_1_,
        store0_.industry as industry2_0_0_,
        store0_.name as name3_0_0_,
        store0_.user_id as user_id4_0_0_,
        user1_.active as active2_1_1_,
        user1_.birth_date as birth_da3_1_1_,
        user1_.created_at as created_4_1_1_,
        user1_.email as email5_1_1_,
        user1_.name as name6_1_1_,
        user1_.password as password7_1_1_,
        user1_.phone_number as phone_nu8_1_1_,
        user1_.sex as sex9_1_1_,
        user1_.type as type10_1_1_,
        user1_.updated_at as updated11_1_1_
    from
        store store0_
    inner join
        user user1_
            on store0_.user_id=user1_.id

3.4.3. response

response를 보면, user_id가 설정되지 않은 4번 가게는 제외된 것을 확인할 수 있습니다.

{
  "result": "SUCCESS",
  "stores": [
    {
      "id": 1,
      "user": {
        "id": 1,
        "type": "BASIC",
        "email": "jini@jiniworld.me",
        "name": "지니",
        "sex": "F",
        "birthDate": "19920201",
        "phoneNumber": "01011112222",
        "active": true,
        "createdAt": "2022-04-11T07:59:07.148+00:00",
        "updatedAt": null
      },
      "name": "우리의 시간",
      "industry": "카페"
    },
    {
      "id": 2,
      "user": {
        "id": 1,
        "type": "BASIC",
        "email": "jini@jiniworld.me",
        "name": "지니",
        "sex": "F",
        "birthDate": "19920201",
        "phoneNumber": "01011112222",
        "active": true,
        "createdAt": "2022-04-11T07:59:07.148+00:00",
        "updatedAt": null
      },
      "name": "에머이",
      "industry": "베트남음식"
    },
    {
      "id": 3,
      "user": {
        "id": 2,
        "type": "OWNER",
        "email": "2han@appleboxy.xyz",
        "name": "이한",
        "sex": "M",
        "birthDate": "19880702",
        "phoneNumber": "01088887777",
        "active": true,
        "createdAt": "2022-04-13T03:21:30.542+00:00",
        "updatedAt": null
      },
      "name": "한잔의 추억",
      "industry": "술집"
    }
  ]
}

쿼리를 직접 db에서 조회해보면 아래와 같이 조회됩니다.

02-14


결론은 N+1 문제를 방지하기 위해 @EntityGraph나 fetch join을 이용하는 것을 권장하며, inner join을 하고 싶다면 fetch join을, left outer join을 하고 싶다면 @EntityGraph를 사용하는 것을 권장합니다.


GitHub에서 demo 프로젝트(v1.0.5)를 다운받아 볼 수 있습니다.

728x90
반응형