no image
JPA - Enum 타입 엔티티에서 커스텀 코드 DB에 넣기
JPA entity에서 Enum 값을 사용하는 경우 흔히 아는 건 ORDINAL, STRING 옵션이다.  하지만 서비스를 구성하다 보면 DB에 특정 코드가 들어가야 하는 경우도 분명 있을 거다. 예시를 한번 보자 이런식으로 ENUM이 구성되어 있을 때 별도의 설정 없이 사용하면 ORDINAL 값인 인덱스 값이 들어가고 옵션을 STRING으로 설정하면 ENUM의 이름 자체가 DB에 인서트 될 것이다. 하지만 만약 저 code 값을 DB에 넣어주고 싶다면 어떻게 해야 할까위 이미지는 Hibernate 공식 페이지의 설명이다. 열거형 옵션에는  ORDINAL, STRING만 지원을 하고 다른 옵션은 제공하지 않는다. 하지만 바로 아래로 내려가면  Converters라는 내용이 있다. 대충 설명하자면 주어진 ..
2024.10.27
no image
[JPA] 더티 체킹(Dirty Checking)
더티 체킹(Dirty Checking)은 트랜잭션 안에서 엔티티의 변경이 일어나면, 변경 내용을 자동으로 데이터 베이스에 반영하는 JPA의 특징 중 하나이다. 우선 데이터베이스에 변경 데이터를 저장하는 시점은 Transaction Commit, EntityManager flush, JPQL 사용 이렇게 3가지가 있다. JPA에서는 트랜잭션이 끝나는 시점에 변화가 있는 모든 엔티티 객체를 데이터 베이스에 자동으로 반영해준다.(변화의 기준은 최초 조회 상태) JPA에서는 엔티티를 조회하면 해당 엔티티의 조회 상태 그대로 스냅숏을 만든다. 트랜잭션이 끝나는 시점에 스냅숏과 현재의 상태를 비교해 다른 점이 있다면 Update 쿼리를 데이터 베이스로 날린다. 이제 예시를 보며 더 자세하게 보자 public voi..
2021.12.22
no image
[JPA] N + 1 이슈
JPA를 사용하다 보면 가장 자주 마주하게 되는 이슈가 N+1이다. N+1이 발생할 때 데이터의 양이 아주 적으면 그냥 무심코 넘길 수 있지만 데이터가 많아질수록 성능에서 엄청난 차이를 가져온다. 이번 포스팅에서는 문제가 발생하는 예시와 해결 방안에 대해서 정리하려 한다. Post를 호출하여 Comment를 사용할 것이다라고 가정하자. ➡️엔티티 클래스 post @Entity @Getter @NoArgsConstructor public class Post { @Id@GeneratedValue @Column(name = "post_id") private Long id; private String name; @OneToMany(mappedBy = "post",cascade = CascadeType.ALL,f..
2021.12.17
no image
[JPA] 양방향 연관관계(무한 루프)
🧑🏻‍💻양방향 연관관계(무한 루프) JPA로 개발을 하다 보면 양방향으로 매핑하여 참조가 필요한 모델들이 종종 있다. 이러한 과정에서 양방향 관계를 가진 객체를 직렬화 하려고 할 때 무한으로 참조가 되어 StackOverFlow를 발생시킨다. 그러나 이러한 문제가 있다고 JPA가 가진 장점을 포기하고 단방향으로 구성하기엔 너무 비효율적이다. 이를 해결하려면 양방향 연관관계에서 연관관계의 주인을 설정해주어야 한다. 예시 엔티티 @Entity public class User { @Id @GeneratedValue private Long id; private String name; private int age; @ManyToOne private Team team; } @Entity public class Te..
2021.12.14
no image
[Querydsl] Projection 정리
🧭Querydsl Projection Projection은 테이블의 특정한 칼럼들만 따로 조회한다는것을 의미한다. 결과값을 반환할 때, DTO로 반환이 가능하다. 이때 DTO클래스로 반환라기 위해 Projection 하는 방법이 여러가지 있다. Tuple Projection.bean Projection.constructor Projection.field @QueryProjection 📡Tuple을 이용한 Projection void ProjectionByTuple()throws Exception{ Listtuples = jpaQueryFactory .select(member.name, member.age) .from(member) .fetch(); } Tuple을 통해서 반환은 하나의 데이터로 반환한다...
2021.12.12
no image
[Querydsl] QueryDSL 사용법 정리
이번 포스팅은 프로젝트에서 QueryDSL을 사용하면서 가끔 잊어버리는 것이 있어서 전체적으로 정리해보려 한다. ➡️QueryDSL 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 된다. 만약 이런과정에서 로직이 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 떨어지고 가독성이 떨어진다. JPQL 문자열에 오류가 있을 경우 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 런타임 시점에 에러가 발생한다. 이러한 문제를 QueryDSL이 해결을 해준다. QueryDSL은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 프레임워크이다. ➡️QueryDSL의 장점 문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문제 오류를 빠르게 찾을 수 있다. 동적인 쿼리 작성..
2021.12.03
no image
[Spring] DTO 사용범위에 대하여
프로젝트를 진행하던 중 DTO를 개념만 알고 어디서 사용해야 적절한지에 대해서 고민해보지 않고 사용해서 정리를 해보려고 한다. 그래서 어디서 사용해야 적절한 것인지 알아보려 한다. ➡️DTO DTO(Data Transfer Object)란 계층 간 데이터 교환을 위해 사용하는 객체이다. MVC패턴으로 예를 들어보자 MVC 패턴은 애플리케이션을 개발할 때 그 구성요소를 Model과 View 및 Controller 등 세 가지 역할로 구분하는 디자인 패턴이다. 비즈니스 처리 로직(Model)과 UI영역(View)은 서로의 존재를 모르고 Controller가 중간에서 Model과 View를 연결하는 역할을 한다. Controller는 View로부터 들어온 사용자 요청을 해석하여 Model을 업데이트하거나 Mo..
2021.11.12
no image
[JPA]Fetch.Lazy를 설정했을때 @OneToOne에서 발생하는 이슈
JPA는 개발하는 입장에서 매우 편리하고 설정만 잘하면 편할거라고 생각하는데 사용하기 편하고 쉬운만큼 성능적인 측면에서 생각하지 못한 이슈가 발생할 수 있다.이번에 정리할 이슈는 즉시로딩(EAGER Loading)과 지연로딩(LAZY Loading)이다. @OneToOne 매핑시에 지연로딩(LAZY Loading)로 설정해도 즉시로딩(EAGER Loading)로 작동하는 경우가 있다.이번 포스팅에서는 발생하는 이슈에 대해서 해결책을 적어보려한다. 일단 정상적으로 작동하는 예제이다. 일대일 단방향 매핑으로 외래키를 가지고 있는 USER가 연관관계 주인이다. 여기서 유저를 조회하면 어떻게 쿼리가 작동하는지 보자 이처럼 이름으로 조회하면 select user0_.user_id as user_id1_1_, us..
2021.10.13
728x90

JPA entity에서 Enum 값을 사용하는 경우 흔히 아는 건 ORDINAL, STRING 옵션이다.  하지만 서비스를 구성하다 보면 DB에 특정 코드가 들어가야 하는 경우도 분명 있을 거다. 

예시를 한번 보자

 

이런식으로 ENUM이 구성되어 있을 때 별도의 설정 없이 사용하면 ORDINAL 값인 인덱스 값이 들어가고 옵션을 STRING으로 설정하면 ENUM의 이름 자체가 DB에 인서트 될 것이다. 하지만 만약 저 code 값을 DB에 넣어주고 싶다면 어떻게 해야 할까

위 이미지는 Hibernate 공식 페이지의 설명이다. 열거형 옵션에는  ORDINAL, STRING만 지원을 하고 다른 옵션은 제공하지 않는다.

 

하지만 바로 아래로 내려가면  Converters라는 내용이 있다.

 

대충 설명하자면 주어진 자바 유형을 ORDINAL, STRING으로 변환하거나 데이터베이스에서 작업이 일어날 때 사전사후 처리를 해준다고 한다.

이 기능을 사용해서 ENUM이 인서트 되기 전 ENUM 내부 특정값으로 바꿔주고 가지고 와서는 ENUM 값으로 보이도록 구성이 가능해진다.

 

AttributeConverter 인터페이스를 통해 컨버터 구현체를 만들어 줄 거다. 우선 내부가 어떤 식으로 구성이 되어있는지 보자 


AttributeConverter 내부를 보면 따로 구현할 구현체 외에도 Hibernate내부 동작에서도 사용을 하고 있다.

 

이제 우리가 ENUM에서 사용할 컨버터를 구성해 보자

 

이런 식으로 우선 간단하게 컨버터를 작성해줬다. convertToDatabaseColumn은 DB에 입력될 때 전처리 해주는 것이고 convertToEntityAttribute는 DB에서 값을 가지고 왔을때 ENUM값으로 표시해주는 컨버터이다.

DB 에서 가져온 값을 ENUM으로 표시하기 위해 ENUM 클래스에 변환로직을 작성해 준다.

 

그리고 @Converter(autoApply = true)를 컨버터 클래스 위에 명시해 주면  아래처럼 특별한 설정 없이 해당 ENUM에 자동으로 적용된다. 하지만 지금 필요한 것은 아니라 코드에선 빼둔 상태이다.

 

그다음 엔티티에 설정을 해주자

 

이런 식으로 Converter 클래스를 입력해 주면 

 

 

DB에 코드 값으로 정상 입력 된다. 

 

하지만 fromCode에서 for문을 보고 좀 찜찜함을 느끼는 사람도 있었겠지만 저게 지금 당장은 문제가 안되지만 나중에 DB 요청 자체가 많아지게 되면 저거 자체가 매우 불필요한 낭비다. 이걸 사용하려면 잘 고민을 해보고 사용하면 좋을 것 같다.

 


REFERENCE

https://docs.jboss.org/hibernate/orm/6.3/introduction/html_single/Hibernate_Introduction.html#enums

728x90

'Dev > SpringData' 카테고리의 다른 글

[JPA] 더티 체킹(Dirty Checking)  (0) 2021.12.22
[JPA] N + 1 이슈  (0) 2021.12.17
[JPA] 양방향 연관관계(무한 루프)  (0) 2021.12.14
[Querydsl] Projection 정리  (0) 2021.12.12
[Querydsl] QueryDSL 사용법 정리  (0) 2021.12.03
728x90

더티 체킹(Dirty Checking)은 트랜잭션 안에서 엔티티의 변경이 일어나면, 변경 내용을 자동으로 데이터 베이스에 반영하는 JPA의 특징 중 하나이다.

  • 우선 데이터베이스에 변경 데이터를 저장하는 시점은 Transaction Commit, EntityManager flush, JPQL 사용 이렇게 3가지가 있다.
  • JPA에서는 트랜잭션이 끝나는 시점에 변화가 있는 모든 엔티티 객체를 데이터 베이스에 자동으로 반영해준다.(변화의 기준은 최초 조회 상태)
  • JPA에서는 엔티티를 조회하면 해당 엔티티의 조회 상태 그대로 스냅숏을 만든다. 트랜잭션이 끝나는 시점에 스냅숏과 현재의 상태를 비교해 다른 점이 있다면 Update 쿼리를 데이터 베이스로 날린다.

이제 예시를 보며 더 자세하게 보자

 public void updateNative(Long id, String trade){
        EntityManager em = entityManagerFactory.createEntityManager();
        EntityTransaction ef = em.getTransaction();
        ef.begin();
        Payment payment = em.find(Payment.class, id);
        payment.changeTradeNumber(trade);
        ef.commit();
    }

코드를 보면 별도로 데이터베이스에 저장하지 않는다.

트랜잭션을 시작되고 엔티티 조회 후 엔티티값을 변경하고 트랜잭션을 커밋한다.

@Test
@DisplayName("엔티티 메니저로 확인")
void find_entityManager()throws Exception{
    //given
    Payment pay = paymentRepository.save(new Payment("test1",  100L));

    //when
    String updateTradeNo = "test2";
    paymentService.updateNative(pay.getId(), updateTradeNo);

    //then
    Payment saved = paymentRepository.findAll().get(0);
    Assertions.assertThat(saved.getTradeNumber()).isEqualTo(updateTradeNo);

}

위 테스트를 수행하면, 아래와 같은 로그를 확인할 수 있다.

Hibernate: 
    update 
    	payment 
    set 
        amount=?, 
        trade_number=? 
    where 
        id=?

save 메서드로 변경 사항을 저장하지 않았지만 update 쿼리가 실행된다.

이것이 더티체킹의 역할이다.

위에 소개에서 말한 것처럼 JPA에서는 트랜잭션이 끝나는 시점에 변화가 있는 모든 엔티티 객체를 데이터베이스에 자동으로 반영해준다.

여기서 JPA는 엔티티 조회를 하면 엔티티의 조회 상태를 스냅숏으로 만들어 놓고 트랜잭션이 끝나는 시점에는 조회 상태를 저장한 스냅숏과 비교해서 다른 점이 있으면 Update 쿼리를 데이터베이스로 전달한다.

여기서 더티 체킹(상태 변경 검사)의 대상은 영속성 콘텍스트가 관리하는 엔티티에만 적용된다.

데이터베이스에 반영되기 전 처음으로 생성된 엔티티나 detach 된 엔티티는 더티 체킹 대상에 들어가지 않는다.(값을 변경해도 데이터 베이스에 반영되지 않는다.)

 

 

이제 Spring Data Jpa와 @Transactional을 같이 사용하는 경우를 보자

@Transactional
public void update(Long id,String tradeNumber){
    Payment payment = paymentRepository.getOne(id);
    payment.changeTradeNumber(tradeNumber);
}
@Test
@DisplayName("SpringDataJpa로_확인")
void find_jpa() {
    //given
    Payment pay = paymentRepository.save(new Payment("test1",  100L));
    //when
    String updateTradeNo = "test2";
    paymentService.update(pay.getId(), updateTradeNo);
    //then
    Payment saved = paymentRepository.findAll().get(0);
    Assertions.assertThat(saved.getTradeNumber()).isEqualTo(updateTradeNo);
}
Hibernate: 
    update 
    	payment 
    set 
        amount=?, 
        trade_number=? 
    where 
        id=?

정상적으로 쿼리가 실행된다.

 

현재는 JPA에서 전체 필드를 업데이트하는 방식을 기본값으로 사용한다.

전체 필드를 업데이트를 하는 방식은 생성되는 쿼리가 같아 부트 실행 시점에 미리 만들어서 재사용이 가능하고, 데이터베이스 입장에서도 재사용이 가능하다.

다만, 필드가 많아질 경우 전체 필드에 대한 Update 쿼리는 부담스러울 수 있다.

이럴 경우 @DynamicUpdate로 변경 필드만 반영되도록 할 수 있다.

Hibernate:
        update 
             payment 
        set 
             trade_number=? 
        where 
              id=?

테스트 코드를 다시 실행해보면 변경한 trade_number만 update 쿼리에 반영된 것을 확인할 수 있다.

 

 

 

🧑🏻‍💻예제 코드: https://github.com/ryudongjae/blog-ex

 

GitHub - ryudongjae/blog-ex: 📁블로그 예제 코드

📁블로그 예제 코드 . Contribute to ryudongjae/blog-ex development by creating an account on GitHub.

github.com


REFERENCE

https://jojoldu.tistory.com/415

728x90

[JPA] N + 1 이슈

ryudjae
|2021. 12. 17. 17:04
728x90

JPA를 사용하다 보면 가장 자주 마주하게 되는 이슈가 N+1이다. N+1이 발생할 때 데이터의 양이 아주 적으면 그냥 무심코 넘길 수 있지만 데이터가 많아질수록 성능에서 엄청난 차이를 가져온다.

이번 포스팅에서는 문제가 발생하는 예시와 해결 방안에 대해서 정리하려 한다.

Post를 호출하여 Comment를 사용할 것이다라고 가정하자.


➡️엔티티 클래스

post

@Entity
@Getter
@NoArgsConstructor
public class Post {

    @Id@GeneratedValue
    @Column(name = "post_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "post",cascade = CascadeType.ALL,fetch = FetchType.LAZY)
    private List<Comment> comments = new ArrayList<>();

    @Builder
    public Post(String name, List<Comment> comments) {
        this.name = name;
        if(comments != null){
            this.comments = comments;
        }
    }

    public void addComment(Comment comment){
        this.comments.add(comment);
        comment.updatePost(this);
    }
}

comment

@Entity
@Getter
@NoArgsConstructor
public class Comment {

    @Id@GeneratedValue
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @Builder
    public Comment(String content, Post post) {
        this.content = content;
        this.post = post;
    }

    public void updatePost(Post post){
        this.post = post;
    }
}

Repository

@Repository
public interface PostRepository extends JpaRepository<Post,Long> {
}

Service

@RequiredArgsConstructor
@Slf4j
@Service
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public List<String> findAllComments() {
        return extractSubjectNames(postRepository.findAll());
    }

    private List<String> extractSubjectNames(List<Post> posts) {
        log.info("Comments Size : {}", posts.size());

        return posts.stream()
                .map(a -> a.getComments().get(0).getContent())
                .collect(Collectors.toList());
    }
}

ServiceTest

 

@SpringBootTest
class PostServiceTest {

    @Autowired
    private PostRepository postRepository;

    @Autowired
    private PostService postService;


    @BeforeEach
    public void setUp(){
        List<Post>posts = new ArrayList<>();

        for(int i = 0;  i< 10; i++){
            Post post = Post.builder()
                    .name("게시글"+i)
                    .build();
            post.addComment(Comment.builder()
                            .content("댓글"+i)
                            .build());
            posts.add(post);
        }
        postRepository.saveAll(posts);
    }


    @Test
    @DisplayName("게시글 조회시 댓글 조회 N+1 발생")
    void N_plus_1_O()throws Exception{
        List<String> comments = postService.findAllComments();
        assertThat(comments.size()).isEqualTo(10);

    }
}

 

위 테스트 코드를 실행하면 아래처럼 Post를 조회하는 쿼리와 Post의 자신의 Comment를 조회하는 쿼리가 10번이 나간다.

 

이게 오늘 정리하는 N+1 쿼리이다.

위처럼 하위 엔티티를 한 번에 가져오지 않고 지연 로딩으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제이다.

지금은 Post가 10개이니 처음 조회되는 쿼리 한 번과 10번의 Comment조회가 발생하여 총 11번의 쿼리가 발생한다.

지금은 11번만 나가지만 조회 결과가 1만 개면 DB 조회가 최소 1만 번은 일어나서 성능이 급격하게 저하된다.

이제 이러한 문제에 대한 해결책을 보자.

 

1.fetch join

@Query("select a from Post a join fetch a.comments")
List<Post>findAllFetchJoin();

fetch join은 바로 가져오고 싶은 Entity 필드를 지정하는 것이다.

@Test
@DisplayName("fetchjoin으로 게시글 가져옴")
void fetchJoin_getPost()throws Exception{
    List<Post> post = postRepository.findAllFetchJoin();
    assertThat(post.size()).isEqualTo(10);
      
}

테스트 코드를 실행하면 쿼리가 한 줄만 실행된다.

이로써 문제는 해결되지만 불필요한 쿼리문이 추가된다는 단점이 존재한다.

필드마다 Eager.Lazy 방식을 다르게 해서 쿼리가 불필요하면 @EntityGraph를 사용하면 된다.

 

2.EntityGraph

@EntityGraph의 attributePaths에 쿼리 수행 시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager로 조회가 된다.

@EntityGraph(attributePaths = "comments")
@Query("select a from Post a")
List<Post> findAllEntityGraph();

위처럼 원본 쿼리의 손상 없이 (Eager/Lazy) 필드를 정의하고 사용할 수 있게 된다.

테스트 코드를 작성해보자 

@Test
@DisplayName("게시글 여러개를 EntityGraph 로 가져온다.")
void entityGraph()throws Exception{

    List<Post> post = postRepository.findAllEntityGraph();
    assertThat(post.size()).isEqualTo(10);

}

 

테스트 코드 결과를 보면 정상적으로 조회 쿼리가 작동한다.

 

 

두 방식 모두 카테시안 곱이 발생하여 comment의 수만큼 Post가 중복 발생하게 된다.

FetchJoin은 InnerJoin이고, @EntityGraph는 OuterJoin이다. 

➡️fetchJoin

select 
      post0_.post_id as post_id1_3_0_, 
      comments1_.id as id1_1_1_, 
      post0_.name as name2_3_0_, 
      comments1_.content as content2_1_1_, 
      comments1_.post_id as post_id3_1_1_, 
      comments1_.post_id as post_id3_1_0__, 
      comments1_.id as id1_1_0__ 
from post post0_ 
     inner join comment comments1_ 
             on post0_.post_id=comments1_.post_id

➡️@EntityGraph

select post0_.post_id as post_id1_3_0_, 
       comments1_.id as id1_1_1_, 
       post0_.name as name2_3_0_, 
       comments1_.content as content2_1_1_, 
       comments1_.post_id as post_id3_1_1_, 
       comments1_.post_id as post_id3_1_0__, 
       comments1_.id as id1_1_0__ 
from post post0_ left 
     outer join comment comments1_ 
             on post0_.post_id=comments1_.post_id

위 쿼리문은  위 두 방식의 조회 쿼리이다. 이제 중복을 확인해보자

@BeforeEach
public void setUp(){
   List<Post>posts = new ArrayList<>();

   for(int i = 0;  i< 10; i++){
         Post post = Post.builder()
               .name("게시글"+i)
                .build();
          post.addComment(Comment.builder(
                 .content("댓글"+i)
                 .build());
          post.addComment(Comment.builder()
                .content("댓글1"+i)
                .build());
            posts.add(post);
    }
    postRepository.saveAll(posts);
}

@Test
@DisplayName("Posts여러개를_joinFetch로_가져온다")
void postFetchJoin() throws Exception {
    //given
    List<Post> posts = postRepository.findAllFetchJoin();
    List<String> comments = postService.findAllSubjectNamesByJoinFetch();
    
    //then
    assertThat(posts.size()).isEqualTo(20);
    assertThat(comments.size()).isEqualTo(20);
    System.out.println("post size : " + posts.size());
    System.out.println("comments size : " + comments.size());
    }

테스트 코드를 실행해보면 Post의 개수가 10개가 아닌 20개로 생성된다.

 

이 문제의 해결법을 해결하는 데는 2가지의 방법이 있다.

첫 번째는 @OneToMany 필드를 Set 선언해주면 된다. Set은 중복을 허용하지 않아서 하나의 해결 방법이 된다.

@OneToMany(mappedBy = "post",cascade = CascadeType.ALL,fetch = FetchType.LAZY)
private Set<Comment> comments = new LinkedHashSet<>();

Set은 순서도 보장하지 않기 때문에 LinkedHashSet을 사용하면 순서도 보장이 된다.

 

그러나 List를 써야 하는 상황이라면 distinct을 사용하여 중복을 제거해주는 방식이다.

@Query("select DISTINCT a from Post a join fetch a.comments")
List<Post> findAllJoinFetchDistinct();
@Test
@DisplayName("distinct_FetchJoin")
void distinct()throws Exception{
    //given
    List<Post> posts = postRepository.findAllJoinFetchDistinct();
    //then
    assertThat(posts.size()).isEqualTo(10);
    System.out.println("post size : " + posts.size());
  }

중복되지 않고 정상적으로 조회가 된다.

@EntityGraph(attributePaths = "comments")
@Query("select DISTINCT a from Post a")
List<Post> findAllEntityGraphDistinct();
@Test
@DisplayName("distinct_EntityGraph")
void distinct_EntityGraph()throws Exception{
    //given
    List<Post> posts = postRepository.findAllEntityGraphDistinct();
    //then
    assertThat(posts.size()).isEqualTo(10);
    System.out.println("post size : " + posts.size());
}

또한 정상적으로 작동한다.

 

두 방식 중 자신의 프로젝트 상황에 맞게 사용하면 된다.

 

 

🧑🏻‍💻예제 코드

https://github.com/ryudongjae/blog-ex

 

GitHub - ryudongjae/blog-ex: 📁블로그 예제 코드

📁블로그 예제 코드 . Contribute to ryudongjae/blog-ex development by creating an account on GitHub.

github.com


Reference

https://jojoldu.tistory.com/165?category=637935

728x90
728x90

🧑🏻‍💻양방향 연관관계(무한 루프)

JPA로 개발을 하다 보면 양방향으로 매핑하여 참조가 필요한 모델들이 종종 있다. 이러한 과정에서 양방향 관계를 가진 객체를 직렬화 하려고 할 때 무한으로 참조가 되어 StackOverFlow를 발생시킨다. 그러나 이러한 문제가 있다고 JPA가 가진 장점을 포기하고 단방향으로 구성하기엔 너무 비효율적이다.

이를 해결하려면  양방향 연관관계에서 연관관계의 주인을 설정해주어야 한다.


예시 엔티티

@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private int age;

    @ManyToOne
    private Team team;
}
@Entity
public class Team {

    @Id@GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;

    @OneToMany
    private List<User> members = new ArrayList<>();
}

예시로 위 두개의 엔티티가 있다.

User와 Team은 ManyToOne의 관계이고 Team과 User는 OneToMany이다.

OneToMany의 관계의 경우 여러 건의 연관관계를 맺기 위해 Collection을 사용하여야 한다.

OneToMany@OneToMany 어노테이션을 붙여주면 되고 ManyToOne@ManyToOne 붙여주면된다.

이제 양방향으로 되었다면 연관관계 주인을 정해줘야 한다.

만약 연관관계 주인을 설정을 안 해준다면 위 예시로 들면 User를 조회하면 Team이 조회가 된다. 그리고 Team에서 User를 조회한다.

이런 식으로 계속 서로를 참조하여 무한루프에 갇힌다.

무한루프에 갇히면서 제일 처음 말했던 StackOverFlow가 발생한다.

이러한 문제를 연관관계 주인을 정해줌으로써 해결할 수 있다.

 

🧑🏻‍💻연관관계 주인 설정

일반적으로 양방향 관계를 맺으면 연관관계 주인을 주로 외래 키가 저장되는 측을 연관관계의 주인으로 지정한다.

외래키가 저장되는 엔티티에서는 @JoinColumn으로 저장할 외래키가 있는 엔티티의 키를 name속성으로 외래 키를 지정해주면 된다

@Entity
public class User {
	.
	.
    .
    
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

이런 식으로 외래키 속성을 지정해주고 주인이 아닌 엔티티에는 mappedBy를 통해 주인이 설정한 필드명을 통해 연관관계 주인을 설정한다.

@Entity
public class Team {
	.
	.
    .
    @OneToMany(mappedBy = "team")
    private List<User> Users = new ArrayList<>();
}

주인을 설정하기 위해 User 엔티티에 외래키로 설정된 필드명을  가져와서 주인을 지정해준다. 여기선 User 엔티티의 team이 외래키로 설정된 필드이기 때문에 mappedBy = "team"으로 설정을 해준다.

@ManyToOne은 항상 외래키가 저장되어 연관관계의 주인이 되기 때문에 mappedBy 속성을 설정할 수도 없다. 

 

🧑🏻‍💻양방향 매핑 정리

  • 테이블 연관관계의 주인은 두 테이블 중 외래키를 관리하는 쪽을 주인이라 한다.(보통 외래 키가 저장되는 테이블)
  • 연관관계 주인은 mappedBy속성을 사용하지 않는다. 주인이 아닐 경우에만 mappedBy로 연관관계 주인을 지정해주어야 한다.
  • 연관관계 주인만이 DB 연관관계와 매핑되고, 외래키를 관리할 수 있다.
  • @ManyToOne은 항상 연관관계 주인이다.

🧑🏻‍💻예제 코드 : https://github.com/ryudongjae/blog-ex

 

GitHub - ryudongjae/blog-ex: 📁블로그 예제 코드

📁블로그 예제 코드 . Contribute to ryudongjae/blog-ex development by creating an account on GitHub.

github.com

 

728x90

'Dev > SpringData' 카테고리의 다른 글

[JPA] 더티 체킹(Dirty Checking)  (0) 2021.12.22
[JPA] N + 1 이슈  (0) 2021.12.17
[Querydsl] Projection 정리  (0) 2021.12.12
[Querydsl] QueryDSL 사용법 정리  (0) 2021.12.03
[Spring] DTO 사용범위에 대하여  (0) 2021.11.12
728x90

🧭Querydsl Projection

Projection은 테이블의 특정한 칼럼들만 따로 조회한다는것을 의미한다. 결과값을 반환할 때, DTO로 반환이 가능하다.

이때 DTO클래스로 반환라기 위해 Projection 하는 방법이 여러가지 있다. 

  • Tuple
  • Projection.bean
  • Projection.constructor
  • Projection.field
  • @QueryProjection

📡Tuple을 이용한 Projection

void ProjectionByTuple()throws Exception{
    List<Tuple>tuples = jpaQueryFactory
    		.select(member.name, member.age)
            .from(member)
            .fetch();

        
}

 

  • Tuple을 통해서 반환은 하나의 데이터로 반환한다.
  • Tuple을 사용하여 조회하는 방법은 권장하지 않는다.Tuple 은 com.querydsl.core import 를 물고 있기 때문에 이후에 querydsl 을 쓰지 않도록 서비스가 바뀌게되면 유연한 대처를 하지 못하기 때문이다.

📡Projection.Bean

//DTO
@Setter
@NoArgsConstructor
public static class MemberDtoByBean{
   private String name;
   private int age;
}

void ProjectionByBean()throws Exception{
    List<MemberDto.MemberDtoByBean> memberDto = jpaQueryFactory
            .select(Projections.bean(MemberDto.MemberDtoByBean.class,member.name,member.age))
            .from(member)
            .fetch();

   memberDto.forEach(System.out::println);
}
  • Projection.Bean()이용하여 Setter를 통해서 조회한다.

 

📡Projection.constructor

//DTO
public static class MemberDtoByConstructor{
    private String name;
    private int age;

    public MemberDtoByConstructor(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

void ProjectionByConstructor()throws Exception{
    List<MemberDto.MemberDtoByConstructor> memberDto = jpaQueryFactory
          .select(Projections.constructor(MemberDto.MemberDtoByConstructor.class, member.name, member.age))
          .from(member)
          .fetch();
}
  • DTO에 생성자를 미리 만들고 Projection.constructor를 이용하여 조회할 수 있다.
  • 여기서 조회하는값은 DTO 생성자에 무조건 들어있어야 한다.

 

📡Projection.field

//DTO
@NoArgsConstructor
public static class MemberDtoByField{
     private String name;
     private int age;
}

void ProjectionByField()throws Exception{
      List<MemberDto.MemberDtoByField> memberDto = jpaQueryFactory
          .select(Projections.fields(MemberDto.MemberDtoByField.class, member.name, member.age))
          .from(member)
          .fetch();
}
  • Projection.field를 이용할 때는 DTO를 만들고 필드값을 삽입한다.
  • 접근 지정자가 private라고 하더라도 값의 삽입이 가능하다.(projection 내부에 리플렉션 API를 사용하고 있기 때문에 접근이 가능하다.)

 

📡@QueryProjection

//DTO
@Getter
public static class MemberDtoByQueryProjection{
    private String name;
    private int age;

    @QueryProjection
    public MemberDtoByQueryProjection(String name, int age) {
         this.name = name;
         this.age = age;
    }

}

void ProjectionByQueryProjection()throws Exception{
   List<MemberDto.MemberDtoByQueryProjection> memberDto = jpaQueryFactory
          .select(new QMemberDto_MemberDtoByQueryProjection( member.name, member.age))
          .from(member)
          .fetch();
}
  • DTO클래스 생성자에 @QueryProjection을 어노테이션을 달아 놓고 조회하는 방법이 있다.
  • compileQuerydsl을 수행시켜주면 Q-Class에 Dto클래스가 생성된다.

무엇을 쓰던지 상관없다.

Constructor을 통한 Projection조회 시에는 .select()절에 새로운 컬럼을 추가해도 컴파일 에러가 발생하기 때문에 런타임과정에서 발생하기 때문에 개발자가 사전에 인지하지 못할 수도 있다.그러나 @QueryProjection은 새로운 칼럼 추가시, 컴파일에러가 발생해서 사전에 알아차리기 쉽다.

setter나 field는 새로운 칼럼 추가하더라도 Projection 유형이 setter,field이기 때문에 조회하여도 무방하다.

 

🧑🏻‍💻예제 코드 : https://github.com/ryudongjae/blog-ex

 

GitHub - ryudongjae/blog-ex: 📁블로그 예제 코드

📁블로그 예제 코드 . Contribute to ryudongjae/blog-ex development by creating an account on GitHub.

github.com


Reference

 

728x90
728x90

이번 포스팅은 프로젝트에서 QueryDSL을 사용하면서 가끔 잊어버리는 것이 있어서 전체적으로 정리해보려 한다.

➡️QueryDSL

원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 된다. 만약 이런과정에서 로직이 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 떨어지고 가독성이 떨어진다. JPQL 문자열에 오류가 있을 경우 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 런타임 시점에 에러가 발생한다. 이러한 문제를 QueryDSL이 해결을 해준다.

QueryDSL은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 프레임워크이다.

 

➡️QueryDSL의 장점

문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문제 오류를 빠르게 찾을 수 있다.

동적인 쿼리 작성이 편리하다.

쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.

 


⚙️설정

build.gradle

plugins {
	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'com'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}
ext {
	set('snippetsDir', file("build/generated-snippets"))
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'com.querydsl:querydsl-jpa'
	runtimeOnly 'com.h2database:h2'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
	outputs.dir snippetsDir
	useJUnitPlatform()
}

def querydslDir = "$buildDir/generated/querydsl"
querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}
sourceSets {
	main.java.srcDir querydslDir
}
configurations {
	querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

def querydslDir은 Q-class가 생성되는 경로이다.

 

Q-type class는 QueryDSL 설정을 성공적으로 마치면 오른쪽 상단 Gradle에 들어가 Task를 선택 후 other에 들어가 complieQueryDSL를 선택하면 @Entity가 붙은 클래스에 자동으로 생성된다.

이런식으로 생성된다.

 

Q-type의 클래스는 QueryDSL을 사용하여 메소드 기반으로 쿼리를 작성할 때 우리가 만든 도메인 클래스의 구조를 설명해주는 메타데이터 역할을 하며 쿼리의 조건을 설정할 때 사용된다.

 


이제 QueryDSL의 기본적인 문법을 보자.

 

1.sort

  • 내림차순 정렬(desc)
List<Post> descPostId = jpaQueryFactory
        .selectFrom(post)
        .orderBy(post.id.desc())
        .fetch();
  • 오름차순 정렬(asc)
List<Post> ascPostId = jpaQueryFactory
        .selectFrom(post)
        .orderBy(post.id.asc())
        .fetch();
  • 여러 조건으로 정렬 
List<Post> resultPost = jpaQueryFactory
        .selectFrom(post)
        .orderBy(post.id.desc(), post.title.asc())
        .fetch();
  • null 처리
List<Post> result= jpaQueryFactory
        .selectFrom(post)
        .orderBy(post.id.desc(), post.title.asc().nullsLast)
        .fetch();

 

 

2.fetch

  • fetch()
    • 리스트로 반환하는 방법이고 만약 리스트가 비어있으면 빈 리스트를 반환한다.
List<Post> fetchList = jpaQueryFactory
        .selectFrom(post)
        .fetch();
  • fetchOne()
    •  하나를 조회할 때 사용한다. 결과가 없을 경우 null을 반환하고 둘 이상일 경우에는 NonUniqueReultException을 반환한다.
Post fetchOne = jpaQueryFactory
        .selectFrom(post)
        .fetchOne();
  • fetchFirst()
    • 가장 처음의 한건을 가져오고 싶을때 사용한다.
Post fetchFirst = jpaQueryFactory
        .selectFrom(post)
        .fetchFirst();
  • fetchResults()
    •  페이징을 위해 사용되고 total content를 가져온다.
QueryResults<Post> fetchResult = jpaQueryFactory
        .selectFrom(post)
        .fetchResult();
  • fetchCount()
    •  개수조회에 사용된다.
long fetchCount = jpaQueryFactory
        .selectFrom(post)
        .fetchCount();

 

3.paging

querydsl에서는 페이징에 필요한 정보를 가져올 수 있는 메서드가 존재한다.

QueryResults<Post> result = jpaQueryFactory
        .selectFrom(post)
        .orderBy(post.id.desc())
        .offset(0)
        .limit(3)
        .fetchResult();
        
long total =  result.getTotal();
long limit = result.getLimit();
long offset = result.getOffset();
List<Post> results = result.getResults();

간단하게 메서드를 설명하자면  

total은 전체 컨텐츠 갯수이고,

offset은 조회를 시작할 위치이고,

limit은 조회 개수를 제한할 위치이다.

.getResults() 메서드는 조건에 맞게 조회된 컨텐츠 리스트이다.

4. aggregation

querydsl에서 편하게 sql 집계를 낼수 있는 기능이 있다. 실제 sql을 사용하는 것처럼 sql의 groupBy.having절을 사용할 수 있다.

List<Tuple> result = jpaQueryFactory
		.select(
        		team.name,
        		user.count,
                user.age.avg(),
                user.age.min(),
                user.age.max()
         )
        .from(user)
        .join(user.team, team)
        .groupBy(team.name)
        .having(team.name.eq("A")
        .fetch();
        
Tuple team1 = result.get(0);

String teamName = team1.get(team.name);
Long team1Cnt = team1.get(user.count());
Double team1AgeAvg = team1.get(user.age.avg());
Integer team1MinAge = team1.get(user.age.min());
Integer team1MaxAge = team1.get(user.age.max());

 

😸GITHUB

https://github.com/ryudongjae/Querydsl

 

GitHub - ryudongjae/Querydsl

Contribute to ryudongjae/Querydsl development by creating an account on GitHub.

github.com


REFERENCE

https://devkingdom.tistory.com/243

https://joont92.github.io/jpa/QueryDSL/

728x90
728x90

프로젝트를 진행하던 중 DTO를 개념만 알고 어디서 사용해야 적절한지에 대해서 고민해보지 않고 사용해서 정리를 해보려고 한다. 그래서 어디서 사용해야 적절한 것인지 알아보려 한다.


➡️DTO

DTO(Data Transfer Object)란 계층 간 데이터 교환을 위해 사용하는 객체이다.

 

MVC패턴으로 예를 들어보자

 

MVC 패턴은 애플리케이션을 개발할 때 그 구성요소를 Model과 View 및 Controller 등 세 가지 역할로 구분하는 디자인 패턴이다. 비즈니스 처리 로직(Model)과 UI영역(View)은 서로의 존재를 모르고 Controller가 중간에서 Model과 View를 연결하는 역할을 한다.

Controller는 View로부터 들어온 사용자 요청을 해석하여 Model을 업데이트하거나  Model로부터 데이터를 받아 View로 전달하는 작업을 수행한다.

MVC 패턴의 장점은 Model과 View를 분리함으로써 서로의 의존성을 낮추고 독립적인 개발을 가능하게 한다.

Controller는 View와 도메인 Model의 데이터를 주고받을 때 별도의 DTO 를 주로 사용한다.

왜냐하면 도메인 객체를 View에 직접 전달할 수 있지만, 민감한 도메인 비즈니스 기능이 노출될 수 있으며 Model과 View 사이에 의존성이 생기고 비즈니스 로직 등 민감한 정보가 외부에 노출되기 때문에 보안상의 문제가 생길 수 있기 때문이다. 

 

 

문제가 생길수 있는 예제를 한번 보자

public class User{

    public Long id;
    public String name;
    public String email;
    public String password; 

}
@GetMapping
public ResponseEntity<User> findeUser(@PathVariable long id) {
    User user = userService.findById(id);
    return ResponseEntity.ok(user);
   
}
  • 이런 식으로 컨트롤러가 클라이언트의 요청에 대한 응답으로 도메인 모델인 User를 넘겨주면 다음과 같은 문제점이 발생한다.
  • 도메인 모델의 모든 속성이 외부에 노출된다. 저 요청에서 사용하지 않는 데이터를 불필요하게 가지고 있는 것이다.
  • 모든 속성이 외부에 노출되기 때문에 보안상 좋지 않다.
  • View에서  Model의 메서드를 호출하거나 상태를 변경시킬 위험이 존재한다.
  • Model과 View가 상호 의존성이 높아져서 View에 요구사항이 있을 경우 Model에 영향을 끼칠 수 있다.

 

 

이제 DTO를 사용해보자 

public class UserDto {

    public long id;
    public String name;
    public String email;
	
    @Builder    
    public UserDto(long id,String name,String email){
         this.id = id;
         this.name = name;
         this.email = email;
  	}
}
@GetMapping
public ResponseEntity<UserDto> findUser(@PathVariable long id) {
    UserDto userDto = userService.findById(id);
    return ResponseEntity.ok(userDto);
}

이런 식으로 DTO를 사용하면 도메인 모델을 캡슐화하고, View에서 사용하는 데이터만 선택적으로 응답할 수 있다. DTO는 클라이언트 요청에 포함된 데이터를 담아 서버 측에 전달하고, 서버 측의 응답 데이터를 담아 클라이언트에 전달하는 계층 간 전달자 역할을 한다.

 

이제 DTO의 사용범위에 대해서 알아보자 

 

위 그림은 Layered Architecture이다. 유사한 관심사를 레이어로 나눠서 추상화하여 수직적으로 배열하는 아키택처이다. 하나의 계층은 주어진 역할을 수행하고, 인접한 다른 계층과 상호작용한다. 이런 식으로 시스템을 계층으로 나누면 시스템 전체를 수정하지 않고도 특정 계층을 수정 및 개선할 수 있어 재사용성과 유지보수에 유리하다.

 

한 로직을 예로 들어보자

View로부터 받아온 DTOController에서 도메인(Entity)으로 변환하고 서비스 계층에게 이를 전달하여 작업을 수행하고 서비스 계층은 Controller에게 도메인을 반환하고, Controller는 도메인을 DTO로 변환해 View로 응답을 보낸다.

여기서 보면 Controller가 도메인을 DTO로 변환을 한다. 그러나 잘 생각해보면 굳이 Controller에서 변환을 해야 하나 라는 생각이 든다.


서비스 계층이 요청으로 DTO를 받고 응답으로 DTO를 보내줘도 아무 문제가 없다. 그러면 어느 계층에서 사용하는 것이 자연스러운지 알아보자 

 

 

🪐Repository

Repository 계층은 Entity의 영속성을 관장하는 역할이라고 명시되어있다. 이로 인해, 표현 계층에서 사용할 도메인 계층을  DTO로 변환하는 작업을 책임지게 하는 것을 지양하자는 다수의 의견이 존재한다.

대부분은 Controller와 Service 계층에 위치시켰다.

 

🪐Service 

마틴 파울러의 말을 인용해보자면 Service계층이란 애플리케이션의 경계를 정의하고 비즈니스 로직 등 도메인을 캡슐화하는 역할이라고 정의한다. 즉, 도메인 모델을 표현 계층에서 사용하는 경우 결합도가 증가하여 도메인의 변경이 Controller의 변경을  촉발하는 유지보수의 문제로 이어질 수 있다.

이러한 관점에서 바라볼 때, 레이어 간 데이터 전달 목적으로 DTO를 엄격하게 고수한다면 변환 로직이 Service 레이어에서 정의되어야 한다는 의견이 존재했습니다. 요청에 대한 응답 역시 Service 레이어의 일부분이기 때문이다.

 

 

Service가 DTO를 사용하는 경우 Controller가 View로부터 받은 DTO를 Entity로 변환한 뒤, Service 레이어가 Entity를 전달받아 일련의 비즈니스 로직을 수행한다고 가정해보자.

  • 복잡한 애플리케이션의 경우 Controller가 View에서 전달받은 DTO만으로 Entity를 구성하기란 어렵다. Repository를 통해 여러 부수적인 정보들을 조회하여 Domain 객체를 구성할 수 있는 경우도 존재하기 때문이다.
  • Controller에서 DTO를 완벽하게 Domain 객체로 구성한 뒤 Service에 넘겨주려면, 복잡한 경우 Controller가 여러 Service(혹은 Repository)에 의존하게 된다. 

 

 

 

블로그와 깃 헙을 보면서 정답을 찾으려 했지만 대부분의 사람들이 명확한 답이 없고 상황에 따라 맞게 써야 한다고 한다. 

 

https://stackoverflow.com/questions/21554977/should-services-always-return-dtos-or-can-they-also-return-doma in-models

 

Should services always return DTOs, or can they also return domain models?

I'm (re)designing large-scale application, we use multi-layer architecture based on DDD. We have MVC with data layer (implementation of repositories), domain layer (definition of domain model and

stackoverflow.com

여기서 많은 개발자들이 서비스가 무조건 DTO를 반환해야 하는지 아니면 도메인 모델을 반환해도 되는지에 대한 질문이다.

 

여기서 나온 답을 정리해보자면 

  • 서비스 계층은 애플리케이션의 경계를 정의하고 도메인을 캡슐화한다.
  • DTO는 말 그대로 데이터 전달용 객체이고, communication에 사용된다면 의미가 있다. 대신 Presentation 레이어에서 도메인 모델을 사용하게 되면, 결합도가 증가해 코드 변경이 불가피할 수 있다
  • 도메인 모델과 똑같은 DTO를 만드는 것 같고 의미 없게 느껴진다 : DTO의 단점 중 하나이다. 대신 지금은 코드 중복으로만 생각되지만, 나중에 프로젝트가 확장되면 더 큰 의미를 발휘할 수 있다.

대규모 프로젝트에는 DTO를 사용하면 더 큰 의미를 발휘할 수 있다고 한다.

 

어느 블로그에서 본 걸로 인용하지만 DTO사용범위라는 고민보다는 도메인 모델 보호라는 관점에서 생각해보자고 한다. 나도 이 의견에 동의한다.그리고 Repository단위는 다들 공통적으로 지양한다.


📁정리

DTO를 공부하면서 많은 고민을 해봤다. 내가 아직 많이 부족해서 답을 결정짓기 어려운 내용인 거 같다. DTO는 상황에 맞게 사용하고 무한한 피드백으로 최적의 케이스를 찾아야 한다. 정리하자면 DTO를 남발하는 것은 좋지 않다. 상황에 따라 DTO가 필요 없을 수도 있다.

 

이렇게 의견이 다 다르고 정답이 없는 건 처음 느껴본다. 공부를 무한히 하고 많은 사람들과 토론을 해보는 것도 성장에 많은 도움이 될 것 같다.

728x90
728x90

JPA는 개발하는 입장에서 매우 편리하고 설정만 잘하면 편할거라고 생각하는데  사용하기 편하고 쉬운만큼 성능적인 측면에서 생각하지 못한 이슈가 발생할 수 있다.이번에 정리할 이슈는 즉시로딩(EAGER Loading)과 지연로딩(LAZY Loading)이다.

@OneToOne 매핑시에 지연로딩(LAZY Loading)로 설정해도 즉시로딩(EAGER Loading)로 작동하는 경우가 있다.이번 포스팅에서는 발생하는 이슈에 대해서 해결책을 적어보려한다.


일단 정상적으로 작동하는 예제이다. 일대일 단방향 매핑으로 외래키를 가지고 있는 USER가 연관관계 주인이다. 여기서 유저를 조회하면 어떻게 쿼리가 작동하는지 보자 

 

이처럼 이름으로 조회하면 

 

 

  select
        user0_.user_id as user_id1_1_,
        user0_.name as name2_1_,
        user0_.studygroup_id as studygro3_1_ 
    from
        user user0_ 
    where
        user0_.name=?
 
 

지연 로딩이 정상적으로 작동한다. 그러면 도대체 어디에서 이슈가 발생할까?

이제 이슈에 대해서 다뤄보겠다.

 

위 예시는 USER와 STUDYGROUP이 일대일 양방향 관계를 가지고 있다.여기에서 또한 연관관계의 주인은 외래키를 가지고있는 USER이다.

여기서 USER를 조회하면 정상적으로 지연로딩이 작동한다.

그러나 STUDYGROUP을 조회하면 아래처럼 지연로딩이 작동하지 않고 즉시로딩이 작동한다.

 

 

select
        studygroup0_.id as id1_4_0_ 
    from
        studygroup studygroup0_ 
    where
        studygroup0_.id=?

  select
        user0_.user_id as user_id1_1_,
        user0_.name as name2_1_,
        user0_.studygroup_id as studygro3_1_ 
    from
        user user0_ 
    where
        user0_.studygroup_id=?

 

 

fetch전략을 지연로딩으로 설정했음에도 불구하고 즉시로딩으로 유저를 조회하는 비효율적이고 불필요한 쿼리가 발생한다.

 

이러한 이슈의 원인은 지연로딩은 로딩되는 시점에 엔티티를 프록시객체로 가져온다.여기서는 문제가 발생하지 않지만 이후에는 StudyGroup 객체를 가져오는 시점에는 실제 객체가 사용된다.

지연로딩으로 설정이 되어있는 엔티티를 조회할 때에는 프록시로 감싸서 동작한다.그러나 프록시의 한계로 null을 감싸지못한다. 그래서 이러한 문제가 발생한다.

 

StudyGroup라는 테이블에는 USER를 참조할 수 있는 컬럼이 존재하지 않는다. 따라서 StudyGroup는 어떤 USER에 의해 참조되고 있는지 알 수 없다.

StudyGroup가 어떤 USER에 의해 참조되고 있는지 알 수 없다는 뜻은 만약 USER가 null이더라도 StudyGroup는 이 사실을 알지 못한다는 것이다.

만약 USER가 null이 아니라고 해도, StudyGroup의 입장에서는 USER가null인지 null이 아닌지 확인할 방법이 없다.

따라서 USER의 존재 여부를 확인하는 쿼리를 실행하기 때문에 지연 로딩으로 동작하지 않는 것이다.

 


해결책 

  • 양방향 매핑이 반드시 필요한지 검토해본다.
  • 다대일이나 일대다로 관계를 변경할 수 있는지 고민해본다.
  • StudyGroup을 조회할 때 User도 같이 조회한다.(fetch join)

해결 방법이 명확하게 존재하는 것은 아니다.그래서 상황에 맞게 적용해서 사용하는것이 가장 좋다고 생각한다.

 

728x90