정말 많이 접하는 문제입니다. 왜 발생하고 처리하는 방법은 뭐가 있는지 정리하려 합니다.

우선 왜 이런 문제가 생기는지 정리합니다. 언제나 문제는 원인부터 알아야 올바르게 대처하니까요.

 

포스트(Post)와 댓글(Comment)와의 관계를 예로 작성해 보았습니다.

@Getter@Setter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@Entity
@ToString
public class Post {

    @Id
    @GeneratedValue
    private Long id;

    private String title;

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

}

@Setter@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
@Entity
@ToString(exclude = "post")
public class Comment {

    @Id
    @GeneratedValue
    private Long id;

    private String comment;

    @ManyToOne
    private Post post;
}
public interface PostRepository extends JpaRepository<Post, Long> {
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
}
@Slf4j
@Service
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

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

    /**
     * Lazy Load를 실행하기 위해 모든 comment 순회
     */
    private List<String> getAllComments(List<Post> posts) {
        return posts.stream()
                .map(a -> a.getComments().toString())
                .collect(Collectors.toList());
    }
}

 

test code는 junit5을 사용하였습니다.

@SpringBootTest
class PostRepositoryTest {

    @Autowired
    private PostService postService;
    @Autowired
    private PostRepository postRepository;

    @BeforeEach
    public void setup() {
        Post post = new Post();
        post.setTitle("첫 포스트");

        Comment comment1 = new Comment();
        comment1.setComment("첫 댓글~! ");
        comment1.setPost(post);
        post.getComments().add(comment1);

        Comment comment2 = new Comment();
        comment2.setComment("두번째야~");
        post.getComments().add(comment2);
        comment2.setPost(post);

        Post post2 = new Post();
        post2.setTitle("내가 2등~");

        Comment comment3 = new Comment();
        comment3.setComment("좋아요~");
        comment3.setPost(post2);
        post2.getComments().add(comment3);

        Comment comment4 = new Comment();
        comment4.setComment("감사합니다.");
        comment4.setPost(post2);
        post2.getComments().add(comment4);

        Comment comment5 = new Comment();
        comment5.setComment("다음글 기대할께요.");
        comment5.setPost(post2);
        post2.getComments().add(comment5);

        postRepository.save(post);
        postRepository.save(post2);
    }

    @Test
    void testNPlusOne() {
        System.out.println(postService.findAllComments());
    }
Hibernate: 
    select
        post0_.id as id1_3_,
        post0_.title as title2_3_ 
    from
        post post0_
2020-06-05 11:50:15.206 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_3_] : [BIGINT]) - [1]
2020-06-05 11:50:15.210 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([title2_3_] : [VARCHAR]) - [첫 포스트]
2020-06-05 11:50:15.211 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_3_] : [BIGINT]) - [4]
2020-06-05 11:50:15.211 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([title2_3_] : [VARCHAR]) - [내가 2등~]
2020-06-05 11:50:15.218 TRACE 4256 --- [    Test worker] org.hibernate.type.CollectionType        : Created collection wrapper: [com.icatapark.jpa.post.entity.Post.comments#1]
2020-06-05 11:50:15.219 TRACE 4256 --- [    Test worker] org.hibernate.type.CollectionType        : Created collection wrapper: [com.icatapark.jpa.post.entity.Post.comments#4]
Hibernate: 
    select
        comments0_.post_id as post_id3_2_0_,
        comments0_.id as id1_2_0_,
        comments0_.id as id1_2_1_,
        comments0_.comment as comment2_2_1_,
        comments0_.post_id as post_id3_2_1_ 
    from
        comment comments0_ 
    where
        comments0_.post_id=?
2020-06-05 11:50:15.228 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2020-06-05 11:50:15.239 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_1_] : [BIGINT]) - [2]
2020-06-05 11:50:15.240 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([comment2_2_1_] : [VARCHAR]) - [첫 댓글~! ]
2020-06-05 11:50:15.240 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_1_] : [BIGINT]) - [1]
2020-06-05 11:50:15.242 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_0_] : [BIGINT]) - [1]
2020-06-05 11:50:15.242 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_0_] : [BIGINT]) - [2]
2020-06-05 11:50:15.250 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_1_] : [BIGINT]) - [3]
2020-06-05 11:50:15.250 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([comment2_2_1_] : [VARCHAR]) - [두번째야~]
2020-06-05 11:50:15.250 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_1_] : [BIGINT]) - [1]
2020-06-05 11:50:15.251 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_0_] : [BIGINT]) - [1]
2020-06-05 11:50:15.251 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_0_] : [BIGINT]) - [3]
Hibernate: 
    select
        comments0_.post_id as post_id3_2_0_,
        comments0_.id as id1_2_0_,
        comments0_.id as id1_2_1_,
        comments0_.comment as comment2_2_1_,
        comments0_.post_id as post_id3_2_1_ 
    from
        comment comments0_ 
    where
        comments0_.post_id=?
2020-06-05 11:50:15.264 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [4]
2020-06-05 11:50:15.266 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_1_] : [BIGINT]) - [5]
2020-06-05 11:50:15.267 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([comment2_2_1_] : [VARCHAR]) - [다음글 기대할께요.]
2020-06-05 11:50:15.267 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_1_] : [BIGINT]) - [4]
2020-06-05 11:50:15.268 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_0_] : [BIGINT]) - [4]
2020-06-05 11:50:15.268 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_0_] : [BIGINT]) - [5]
2020-06-05 11:50:15.269 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_1_] : [BIGINT]) - [6]
2020-06-05 11:50:15.270 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([comment2_2_1_] : [VARCHAR]) - [좋아요~]
2020-06-05 11:50:15.270 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_1_] : [BIGINT]) - [4]
2020-06-05 11:50:15.271 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_0_] : [BIGINT]) - [4]
2020-06-05 11:50:15.271 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_0_] : [BIGINT]) - [6]
2020-06-05 11:50:15.271 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_1_] : [BIGINT]) - [7]
2020-06-05 11:50:15.271 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([comment2_2_1_] : [VARCHAR]) - [감사합니다.]
2020-06-05 11:50:15.272 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_1_] : [BIGINT]) - [4]
2020-06-05 11:50:15.272 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([post_id3_2_0_] : [BIGINT]) - [4]
2020-06-05 11:50:15.272 TRACE 4256 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_2_0_] : [BIGINT]) - [7]
[[Comment(id=2, comment=첫 댓글~! ), Comment(id=3, comment=두번째야~)], [Comment(id=7, comment=감사합니다.), Comment(id=6, comment=좋아요~), Comment(id=5, comment=다음글 기대할께요.)]]

결과를 보면 처음 select 하나만 원했을텐데 이 후로 post의 수 많큼 select * from comment where post_id=1, select * from comment where post_id=4 이렇게 2번더 실행 되었습니다. 그래서 원하는 1번의 query가 아니라 n+1번 실행되어 N+1문제라고 합니다.

 

문제는 하위 entity가 처음 select에서 가져오지 않고 실제 사용될 때  하나씩 하나씩 따로 가져오게되어 N+1 문제가 발생합니다. 실제 변수를 사용하는 시점에 JPA가 해당 변수 하나만을 가져오는 쿼리가 실행이 되는 것이죠.

JPA는 fetchType과 무관하게 그때 그때 JPQL에 맞춰 쿼리를 실행합니다. 그래서 eager든 lazy든 n+1 문제는 동일하게 일어납니다.

 

 

 

 


나는 항상 오늘 하루만 생각을 해... 가 아니라 나는 변수 하나만을 생각해... 지금 그 변수를 당장 가져와야지라고 동작한다고 보시면 됩니다.

다음 포스트는 어떻게 처리할지 알아보겠습니다.

'Programming > JPA' 카테고리의 다른 글

N + 1 문제 해결 2  (0) 2020.06.11
N + 1 문제 해결 1  (0) 2020.06.11
왜 JPA를 써야할까?  (0) 2020.06.05
JPA 기본 Annotation 정리  (7) 2019.07.04
jpa 복합키에서 auto increment  (0) 2019.06.27

JPA를 왜 써야 할까에 앞서 이전에 진행했던 프로젝트들의 형태를 먼저 짚고 넘어가면,

 

기본적으로 JPA를 사용하고 있지만 더불어 JOOQ도 같이 사용하고 있습니다. 

 

public interface AccountInteHisRepositoryCustom {
    List<ActivityMonthlyReportDto> findAccountIntegrationHistoryMonthlyActiveStateByCreateDate(LocalDateTime date);
}

 

custom repository inteface를 정의하고 

 

@Repository
public class AccountInteHisRepositoryImpl implements AccountInteHisRepositoryCustom {

    ....

}

 

위와 같은 구현체인 XXXImpl에서 DSLContext를 주입받아 쿼리를 작성합니다.

이렇게 작성한 custom repository는 아래와 같이 JpaRepository와 같이 interface로 상속받아 적용합니다.

 

public interface AccountInteHisRepository extends JpaRepository<AccountInteHis, Long>, AccountInteHisRepositoryCustom {
....
}

 

그런데 이렇게 만들어진 custom repository는 변경에 대해 JPA보다 취약합니다.

native 쿼리보다 jooq dsl이 type safe하고 DB엔진에 영향을 받지 않습니다. 하지만 결국 쿼리가 바뀌면 custom repository도 바뀌어야 합니다.

예를 들어 필요에 의해 join해야하는 table이 하나 더 추가되면 JPA는 reference를 추가하는 것만으로 해결되지만 Jooq는 구현된 DSL을 다 바꿔야 하는 것입니다.

this.dslContext.select()
      .from(acctInteHis)
      .innerJoin(T_DEVICE)
      .on(acctInteHis.IMEI.eq(T_DEVICE.DEVICE_UID))
      .leftJoin(T_DEVICE_EXT)
      .on(device.DEVICE_UID.eq(T_DEVICE_EXT.DEVICE_UID))
      .where(acctInteHis.SEQ.in(selectSubQuery))
      .fetch()
      .stream()
      .map(m -> {
        return ActivityMonthlyReportDto.builder()
          .imei(m.getValue(acctInteHis.IMEI))
          .userId(m.getValue(acctInteHis.USER_ID))
          .state(AccountStateType.valueOf(m.getValue(acctInteHis.STATE)))
          .reqDate(m.getValue(acctInteHis.REQ_DATE).toInstant())
          .opCode(m.getValue(deviceExtension.OPCO))
          .productCode(m.getValue(deviceExtension.PRODUCE_CD))
          .build();
      })
      .collect(Collectors.toList());

jooq DSL의 예시입니다. 분명 내가 원하는 복잡한 쿼리를 구현할 수 있죠. 하지만 (비즈니스 로직 추가로 인해) query가 변경되면 DSL도 격하게 바뀔게 눈에 보이지 않나요? 실제 프로젝트는 영향받는 쿼리가 한 두 군데가 아닐 거라 생각합니다.

그리고 ActivityMonthlyReportDto라는 return 값도 바뀌지 말라는 보장도 없고 로직에 따라 Dto가 엄청 많아질 수 있습니다. 옛날에는 이게 귀찮아서 superset Dto를 만들어 사용하기도 했는데 이게 상황에 따라 어떤 member변수는 null이 된다는 것이 문제입니다.

하지만 JPA는 entity의 모든 reference는 (DB가 비어있지 않는 한) 채워져 있다는 것은 보장합니다. (이건 JPA가 application이 구동될 때 entity와 DB 스키마를 체크하기 때문에 믿을 수 있죠.)

더보기

이외에 JPA의 persistenceContext에 대한 장점등이 있는데요... dirty check, write behind, lazy loading등의 이점들이죠. 또한 domain 중심으로 데이터를 객체로 다룰 수 있는 기반을 JPA가 제공합니다.

 

결론은 DSL이 복잡하고 어려운 쿼리를 명확하게 구현한다는 것에는 의심의 여지가 없어 보입니다. 위의 dsl 예시처럼 subquery(selectSubQuery)도 마음먹은 대로 구현이 가능하니까요.

하지만 JPA를 잘 알고(!) 적용할 수 있다면 상당한 생산력 향상과 확장성을 확보 한다는 것은 매우 큰 매리트입니다.

JPA를 제대로 공부해서 마음껏 개발해야 겠습니다. (무슨 캠페인 같네요.ㅋㅋ)

'Programming > JPA' 카테고리의 다른 글

N + 1 문제 해결 1  (0) 2020.06.11
N + 1 문제 원인  (0) 2020.06.05
JPA 기본 Annotation 정리  (7) 2019.07.04
jpa 복합키에서 auto increment  (0) 2019.06.27
JPA 기본 키 전략  (0) 2019.06.27

먼저 이 패턴을 사용하게 된 계기는 api response에서 http status code처럼 result를 return하려고 하려다 보니 이 result라는 놈이 특정 에러에 따라 정해지는 것이라 메소드 이름에 내용을 정해서 파라미터를 정하면 어떨까 하고 사용하게 되었습니다.

 

Result model입니다. builder 패턴에 Success, failWithNotFoundPath같은 이름에 따라 내용이 정해지게 됩니다.

@Getter
@ToString
@Builder(builderClassName = "Builder")
public class MyResult {

    private boolean succeed;

    private String majorCode;

    private String minorCode;

    public static class Builder {

        public Builder success() {
            this.succeed = true;
            this.majorCode = "200";
            this.minorCode = "0000";
            return this;
        }

        public Builder failWithNotFoundPath() {
            this.succeed = false;
            this.majorCode = "404";
            this.minorCode = "4001";
            return this;
        }
    }
}

builder 패턴은 객체 생성에 관련된 파라미터가 많을 때 깔끔하게 생성을 해주는 장점이 있습니다. 또한 builder에 변경된 파라미터들이 세팅된 후 build()를 하는 시점에 불변하게 만들 수 있는 장점도 있습니다. 하지만 결국 이 패턴도 변경될 파라미터들이 많아지면 복잡해 지는건 마찬가지 입니다.

위의 예제는 3개밖에 안되는 파라미터지만 더 많아도 메소드 이름을 잘 지으면 숫자로 된 result code들이 사람이 읽을 수 있는 string으로 대체되는 효과가 있습니다.

 

아래는 해당 result를 test하는 코드입니다.

@Component
public class BuilderRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        MyResult myResult = MyResult.builder().failWithNotFoundPath().build();
        System.out.println(myResult.toString());
        System.out.println("Value of majorCode on failure : " + myResult.getMajorCode());

        myResult = MyResult.builder().success().build();
        System.out.println(myResult.toString());
        System.out.println("Before changed value of majorCode : " + myResult.getMajorCode());

        myResult = MyResult.builder().success().majorCode("201").build();
        System.out.println(myResult.toString());
        System.out.println("After changed value of majorCode : " + myResult.getMajorCode());
    }
}

결과는 예상하신 것과 같으실 거예요.

MyResult(succeed=false, majorCode=404, minorCode=4001)
Value of majorCode on failure : 404
MyResult(succeed=true, majorCode=200, minorCode=0000)
Before changed value of majorCode : 200
MyResult(succeed=true, majorCode=201, minorCode=0000)
After changed value of majorCode : 201

 

'Programming > Java' 카테고리의 다른 글

java print api 주의 사항  (0) 2021.01.15
왜 돈 연산에는 Floating-point type을 쓰면 안되나?  (0) 2020.06.30
JVM Garbage Collection Basic  (0) 2019.05.10
Non-blocking, Blocking  (0) 2019.03.18
Asynchronous (VS Synchronous)  (0) 2019.03.18

+ Recent posts