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
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<>();
}
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이기 때문에 조회하여도 무방하다.
이번 포스팅은 프로젝트에서 QueryDSL을 사용하면서 가끔 잊어버리는 것이 있어서 전체적으로 정리해보려 한다.
➡️QueryDSL
원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 된다. 만약 이런과정에서 로직이 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 떨어지고 가독성이 떨어진다. JPQL 문자열에 오류가 있을 경우 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 런타임 시점에 에러가 발생한다. 이러한 문제를 QueryDSL이 해결을 해준다.
QueryDSL은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 프레임워크이다.
➡️QueryDSL의 장점
문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문제 오류를 빠르게 찾을 수 있다.
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절을 사용할 수 있다.
프로젝트를 진행하던 중 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로부터 받아온 DTO를 Controller에서 도메인(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)에 의존하게 된다.
블로그와 깃 헙을 보면서 정답을 찾으려 했지만 대부분의 사람들이 명확한 답이 없고 상황에 따라 맞게 써야 한다고 한다.
여기서 많은 개발자들이 서비스가 무조건 DTO를 반환해야 하는지 아니면 도메인 모델을 반환해도 되는지에 대한 질문이다.
여기서 나온 답을 정리해보자면
서비스 계층은 애플리케이션의 경계를 정의하고 도메인을 캡슐화한다.
DTO는 말 그대로 데이터 전달용 객체이고, communication에 사용된다면 의미가 있다. 대신 Presentation 레이어에서 도메인 모델을 사용하게 되면, 결합도가 증가해 코드 변경이 불가피할 수 있다
도메인 모델과 똑같은 DTO를 만드는 것 같고 의미 없게 느껴진다 : DTO의 단점 중 하나이다. 대신 지금은 코드 중복으로만 생각되지만, 나중에 프로젝트가 확장되면 더 큰 의미를 발휘할 수 있다.
대규모 프로젝트에는 DTO를 사용하면 더 큰 의미를 발휘할 수 있다고 한다.
어느 블로그에서 본 걸로 인용하지만 DTO사용범위라는 고민보다는 도메인 모델 보호라는 관점에서 생각해보자고 한다. 나도 이 의견에 동의한다.그리고 Repository단위는 다들 공통적으로 지양한다.
📁정리
DTO를 공부하면서 많은 고민을 해봤다. 내가 아직 많이 부족해서 답을 결정짓기 어려운 내용인 거 같다. DTO는 상황에 맞게 사용하고 무한한 피드백으로 최적의 케이스를 찾아야 한다. 정리하자면 DTO를 남발하는 것은 좋지 않다. 상황에 따라 DTO가 필요 없을 수도 있다.
이렇게 의견이 다 다르고 정답이 없는 건 처음 느껴본다. 공부를 무한히 하고 많은 사람들과 토론을 해보는 것도 성장에 많은 도움이 될 것 같다.
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)
해결 방법이 명확하게 존재하는 것은 아니다.그래서 상황에 맞게 적용해서 사용하는것이 가장 좋다고 생각한다.