회사에서 스프링 레거시 + myBatis로 이루어진 프로젝트를 부트 환경의 JPA로 마이그레이션 하면서, 내가 나름 고민한 일종의 Tip들을 적겠다.
도메인 오브젝트는, 기존 테이블과 같이 만들었지만, 쓸데없이 의존관계를 갖고 있다면 과감하게 끊어서, 설계도를 최대한 단순하게 만들었다. 단방향 연관관계로만, 양방향 연관관계는 나중에 필요하다 싶을 시 고려를 하는 식으로 진행했다. 대부분의 경우에는 사실 양방향 연관관계까지 필요하다 싶은 경우는 없더라. 사실 아예 연관관계를 맺지 않아도, 세타 조인을 활용하거나, 정 안 되면 그냥 네이티브 쿼리를 사용할 수 도 있으니, 양방향 연관관계에 집착을 해서 엔티티 의존성을 복잡화 시킬 필요는 없어보인다. 마찬가지로 DB 제약조건들 (유니크 조건이나, 칼럼 길이 등.. )도 필수적인 걸 제외하면, 배제하고 작업을 했다.
JPQL 로 쿼리를 짜다가, 직면한 문제인데, 분명 fetch join으로 최적화를 시켜줬것만. 쿼리를 실행할 때 자꾸 N+1 문제가 발생하는 게 아닌가.
@OneToOne(fetch = FetchType.LAZY) //하나의 주문에 하나의 리뷰만 허용, 양방향, Review 엔티티
private Order order;
============================================
queryFactory
.selectFrom(review)
.join(review.order, order)
.fetchJoin()
.join(review.customer, customer)
.fetchJoin()
.where(review.order.restaurant.id.eq(restuarntId))
.limit(pageable.getPageSize())
.offset(pageable.getOffset())
.orderBy(review.id.desc())
.fetch();
쿼리는 위와 같다, 자꾸 Delivery 쪽에서 N번만큼 쿼리가 나오길래, Order 엔티티를 살펴보니,
=========================================
@OneToOne(fetch = FetchType.LAZY, mappedBy = "order")
private Delivery delivery;
delivery 엔티티와 양방향 연관관계를 맺고 있었다. 따라서 Lazy loading이 먹히지 않은 거.. Order 쪽 Delivery 를 주석처리해서 단방향으로 바꿔서 해결해줬다.
https://ocblog.tistory.com/70 // OneToMany 단방향의 단점
엔티티를 저장할 떄, 연관관계에 있는 엔티티가 영속 상태가 아니었을 시, 가장 편리한 수단 중 하나는 CASCADE 옵션을 사용하는 것이다. 하지만 이거는 많은 사람들이 경고하다시피, 하나의 엔티티가 다른 하나에 전적으로 종속적인 관계가 아닌 이상, 매우 주의해야 되는 옵션이다. 나는 그냥 배제하고 작업했다. orphanremoval 같은 것도 배제하고 진행.
JPA를 공부하다보면, 누구나 상속을 활용한 다형성 쿼리를 짜고 싶은 욕심이 든다. 하지만 실제 사용하기는 까다로운데, 만약 여러테이블 전략을 사용한다면 다형성 쿼리는 사실상 상속구조에 속해있지만 필요없는 테이블들도 다 join 하게 될 것이고, 단일 테이블 전략을 사용했을 시, 칼럼구조가 지저분해지기 쉽상이다. 차라리 공통된 필드들은 @Embedded 같은 걸로 포함시키는 게 나을 듯 싶다.
많은 JPA 책? 또는 블로그에서 ToOne 관계의 엔티티들을 조회할 떄는, 굳이 LAZY LOADING을 걸지 말고, 편하게 EAGER로 변경하라는 조언을 많이 한다. 하지만 그건 근본적인 해결책은 안 된다는 걸 알고, 언제든 서버에 불필요한 부하를 많이 줄 수 있다는 걸 알고 있다. 물론 지금 당장의 프로젝트에는 큰 차이가 없다는 걸 알고 있어도, 언제나 설계는 올바른 방향으로 진행하는 것이 나중의 스케일 확장에 도움이 된다는 걸 유념하며 LAZY LOADIN으로 고정했다.
트랜잭션 커넥션을 너무 오랫동안 물고 있다는 김영한님의 조언 외에도(사실 지금 프로젝트는 Admin 사이트의 느낌이 강하다보니, 굳이 false로 줄 필요가 없음에도 불구하고) 코딩을 하는데에 있어서 일관성을 지키기 위해 False로 고정했다. 대부분의 필요한 오브젝트의 정보는 트랜잭션 내에서 구현할려고 노력했다. 이럴 경우 가장 골치아팠던 점이 어떤 하나의 엔티티를 조회해 그 오브젝트가 의존한 모든 엔티티 오브젝트에 대한 통계나 합을 구할려고 했을 때, toMany 관계의 의존 엔티티들은 fetch join으로 최적화가 안 된다는 점이었다. 이 부분은 toMany 관계의 연관관계 엔티티를 같이 조회할때는 lazy loading으로 구현하지만, 배치 사이즈 를 정해서, 최대한 n+1 문제를 없애도록 노력했다.
jpa:
hibernate:
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
open-in-view: false
properties:
hibernate.default_batch_fetch_size: 1000
hibernate:
globally_quoted_identifiers: true #DB 예약어도 가능
스프링 데이터 JPA가 제공해주는 crud repository 인터페이스는 평소 사이드 프로젝트를 할 때는 유용하게 썼으나, 실제 프로젝트에서는 마이그레이션 하는 쪽 쿼리가 수백줄이 넘어가는 쿼리가 즐비하고 여러 테이블을 동시에 쓰는 경우가 많아서 한계가 있다고 판단했다. 네이밍 쿼리로는 그 모든 복잡한 쿼리를 표현하기는 힘들었고, 네이티브 쿼리를 쓰기에는 JPA의 DB 독립성 장점을 포기하는 셈이라 쓰기가 싫었다. 그래서 querydsl을 사용, 쿼리는 줄일 수 있으면 최대한 줄이도록 하고, 그럼에도 불구하고 네이티브 쿼리가 필요한 경우는, JDBCTemplate를 사용하는 쪽으로 가닥을 잡았다.
인터페이스를 사용하면 여러가지 장점이 따른다. 객체지향의 SOLID한 원칙들도 인터페이스를 이용함으로써 많이 지킬 수 있게 된다. 하지만 늘 느껴왔던 거슬리는 점들은 직접적인 비즈니스 로직들이 추상화를 통해서 지나치게 숨겨지는 걸 목격하게 된다. 대부분의 레거시 코드에서 굳이 인터페이스를 만들 의미가 느껴지지 않음에도 관습적으로 인터페이스를 만들고 의존하는 걸 많이 보게 된다. 인터페이스를 구현한 빈들이 여러개가 있는 게 아님에도 불구하고 굳이 인터페이스를 만들어야 하나? 나는 확장성에 너무 신경을 써서 코드의 직관성이 더 떨어지는 것이 더 문제라고 생각한다. 인터페이스는 필요할 때 쉽게 생성할 수 있도록 유의를 줄 정도만 코드를 설계하면 될 터이다. 나의 개인적인 생각이다..
기존 레거시 코드는 컨트롤러간의 인자 전달을 모조리 HashMap으로 구현한 코드였다. 무슨 인자를 받는지 주석이야 적혀있긴 했지만, 모든 개발자가 공감하다시피, 주석은 믿을만한 놈이 못된다. 주석에 뒷통수 크게 데여본 경험이 한둘이어야 하지.. 따로 전달받은 인터페이스 명세서도 없는 셈이라. 결국 코드 한 줄 한 줄 뜯어보면서 예측을 해 볼 수 밖에 없었다. 이 밖에도 DTO를 만들지 않았을 때의 단점은 여러가지가 있다. 미리 Validation을 걸어볼 수 도 없고.. 그래서 request 객체 같은 경우는 모두 DTO로 변환을 하였다. 하지만 response 시 굳이 모든 응답 객체를 DTO로 정의하지는 않았다. 어차피 open session in view가 false라 view단에서 lazy loading 이슈도 안 생기고 해서, 가급적이면 DTO 로 반환 하지만, 엔티티 객체 그대로 반환하는 경우로도 많이 구현하였다. (사실 귀찮은 게 좀 컸다.) dto 변환 시 modelmapper를 썼는데, 성능상 이슈가 신경쓰이다면, mapstruct로 교체 고려 중.. 아직은 그렇게 신경 쓸 필요성을 못 느껴서 교체 안함.
https://mangkyu.tistory.com/164
레거시 코드를 보면 항상 느끼는 생각이, 왜 DB에 이렇게나 많은 비즈니스 로직이 관여할까? 한국 웹 생태계는 데이터베이스 중심으로 잡혀있다. 하나의 페이지에 나타내야 할 복잡한 데이터들이 있을 경우, 어떻게든 한방 쿼리로 해결해서 뿌려주는 것이 지금까지 봐왔던 코드들의 공통된 특징이다. 조인에 조인, 서브쿼리에 서브쿼리.. 프로시저..어쩔 때는 SQL 쿼리 하나 이해하는 데 하루를 소모하기도 한다. 스프링 같은 미들웨어의 역할은 단순히 DB에 파라미터를 넘겨주고 응답값을 받아서 뷰단에 뿌려주는 역할로 그치는 경우로 제한된다.
내가 나름 생각한 이유는 초기 한국에는 어플리케이션 아키텍처에 대한 전문가 포지션 공급이 적었고, 대신 DB쪽 전문가는 많았다. 그래서 서버를 개발하는 측면에서 DB 쪽 중심으로 굴러갈 수 밖에 없었단 게 첫번째 이유.
두번째는 SI 업계 특성상, 갑이 요구하는 설계가 시도때도 없이 변경이 되는 경우가 잦다. 나 같은 경우도 소프트웨어 테스트 하루 전날, 요구사항이 바뀌는 것을 경험하기도 했다.. 그럴 경우 어플리케이션 자체가 비즈니스 로직에 밀접하게 연결이 되어있을 경우, 수정사항이 쉽게 반영이 되기도 힘들기도 하고, 대신 미들웨어가 단순히 통로 역할만 하고, 모든 연산을 DB쪽에 처리한다면, 변경사항이 있을 시, 쿼리 하나만 바꿔도 되니까 뭐 이런 식으로 관습이 고착화되지 않았나 생각한다..
그러면 이런 DB쪽 연산을 최소화해야 되는 이유는 뭘까? 일단 가독성이 떨어진다. 나는 자바 개발자이다. 물론 그 외에 이것저것 많이 하고, 데이터베이스에 대한 지식이 없는 것도 아니지만, 전문 DBA 수준에는 한참 못 미친다. 나에게 익숙한 환경이 내가 개발하기 편한 환경인 셈이다. 쿼리 하나에 1000줄이 넘어가는 경우, 보기만 해도 속이 울렁거리는 감각을 느낀다.
이어서 연결하여 유지보수성이 떨어진다. 어플리케이션 내에서 작성한 코드는 아무리 코드가 개판이어도, 브레이킹 포인트 잡고 디버깅 하면서 어느정도 감이라도 찾을 수 있다. 하지만 대부분의 쿼리는 디버깅하기도 용의하지 않고(운영계에서 잘못 건드렸다가 대형참사가 난다..), 주석도 거의 적혀있지 않거나 쓸모없는 내용만 적혀있다. 클라우드 환경에서도 불리하다. 아시다시피 DB에 대한 자원은 비싸다. 반면 WAS의 자원은 비교적 저렴하다. DB쪽에서 주요 비즈니스 로직이 몰려있는 환경은 나중에 스케일 아웃하기 불리하다. 뭐 이것은 나도 직접 경험한 건 아니고, 주위에서 이런 단점들이 있다쿠나 하고 들은 셈이지만.. 아무튼 DB에 로직이 몰린 것 자체가 나에겐 극혐이다 ㅠㅠ
이거는 성능에 대한 잘못된 접근이라고 생각한다.. 성능을 무조건 I/O 횟수를 줄이는 수단으로, 한방쿼리로 해결하려는 잘못된 생각. 나도 솔직히 한방쿼리에 대한 집착이 있는 놈이라, 이 집착을 못 놓긴 하지만 그럴 때마다 김영한님의 안티 SQL? 그 얘기를 유념하고 접근할려고 노력했다. 쿼리 하나에 수백줄이 넘어가는 쿼리 같은 경우, 일단 생각해봐서, 줄일 수 있으면 줄이고, 그래도 안 돼면 쿼리를 나눠서, 어플리케이션 단에서 조립하는 쪽으로 방향성을 정함
https://scidb.tistory.com/entry/한방-Query를-사용하지-말아야-할-때
https://okky.kr/article/734539?note=2015422
마이그레이션을 하면서, 아래 영상의 내용을 많이 곱씹었다. 꼭 필요한 의존관계가 아니면, 의존관계를 최대한 줄이도록 설계하라고 나름 노력했다.. 맞게 설계를 한 건지는 의문점이 남지만..
https://www.youtube.com/watch?v=dJ5C4qRqAgA&t=1174s&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9CTech
https://www.youtube.com/watch?v=00qwDr_3MC4&t=7286s&ab_channel=TobyLee
지금까지 내가 나름 노력하면서 지키려고 생각했던 포인트들이었다. 이 노력이 헛짓거리가 안 되도록..
잘봤습니다 ㅎㅎ