서론
해당 글에서는 JPA 와 QueryDSL 에 대한 개념보단 프로젝트를 진행하면서
마주한 문제와 알게된 내용을 기록하기 위해 작성하였습니다.
JPA (Java Persistence API)
자바 객체(클래스) 와 데이터베이스 테이블을 자동으로 매핑해주는 ORM 표준 인터페이스이다.
인터페이스 이기 때문에 Hibernate, OpenJPA 등이 JPA를 구현한다.
ORM (Object-Relational Mapping)
애플리케이션의 Class 와 RDB (Relational Database) 의 테이블을 매핑(연결) 한다는 뜻이다.
기술적으로는 어플리케이션의 객체를 RDB 테이블에 자동으로 영속화 해주는 것이라고 보면 된다.

QueryDSL 을 적용하게 된 계기
JPA 를 통해서 쿼리를 작성하려다가, 디테일한 쿼리를 작성해야하는 것이 필요해서
문자열 기반으로 되어 가독성이 떨어지는 @Query 어노테이션의 JPQL 만 작성하는 것보다
QueryDSL 이라는 빌더를 사용하는 것이 복잡한 쿼리를 작성하는 데 가독성이 높을 것 같아 사용하게 되었다.
QueryDSL
Java에서 안전하고 가독성 있는 쿼리를 작성할 수 있게 해주는 프레임워크다.
QueryDSL 은 이러한 특징을 갖고 있다.
- 타입 안정성 : 컴파일 시점에 쿼리의 타입을 검증하여 런타임 에러 방지
- 가독성 : 코드 형태로 쿼리를 작성하므로 가독성이 높아지고 유지보수가 용이하다.
- 동적 쿼리 지원 : 조건에 따른 동적 쿼리를 유연하게 생성할 수 있다.
- 통합성 : JPA, Hibernate, SQL 과 쉽게 통합 가능하다.
아래 직접 QueryDSL 을 사용하면서 이해해보도록 하자!
팔로우 리스트 조회하는 쿼리
select
f.id
, f.from_user_id as user_id
, u.email
, u.nickname
, IF(targetF.id is not null
and targetF.deleted_at is null, TRUE, FALSE) as isFollowingBack
-- , avatar_file_path
from follows f join user u
on f.from_user_id = u.id
left join follows targetF
on f.from_user_id = targetF.to_user_id
and f.to_user_id = targetF.from_user_id
where f.to_user_id = 'userId' -- 로그인한 사용자의 id(pk) : 임시 데이터
and f.deleted_at is null
# and u.nickname like '%유저닉네임%' -- 임시 데이터
order by f.created_at desc
위 쿼리를 QueryDSL 로 표현했을 때
전체코드로는 아래와 같다. 위의 실제 쿼리와 QueryDSL 의 구문은 대체로 유사하여 이해하는데 어렵지 않았다.
하지만 처음보는 구문도 있기에 한번 하나씩 뜯어보도록 하자.
public List<FollowSearchListResponseDTO> findFollowerList(String userId, @Nullable String keyword, @Nullable Long lastId, int size) {
// follows 와 user 조인
// keyword 가 존재시 nickname Like 검색 조건 추가
return jpaQueryFactory
.select(Projections.constructor(FollowSearchListResponseDTO.class,
qFollows.id,
qFollows.fromUserId.as("userId"),
qUser.email,
qUser.nickname,
Expressions.booleanTemplate(
"CASE WHEN {0} IS NOT NULL AND {1} IS NULL " +
"THEN TRUE ELSE FALSE END",
targetF.id,
targetF.deletedAt
).as("isFollowingBack")
))
.from(qFollows)
.join(qUser).on(qFollows.fromUserId.eq(qUser.id))
.leftJoin(targetF)
.on(qFollows.fromUserId.eq(targetF.toUserId)
.and(qFollows.toUserId.eq(targetF.fromUserId)))
.where(qFollows.toUserId.eq(userId)
.and(qFollows.deletedAt.isNull())
.and(keywordCondition(keyword))
.and(cursorCondition(lastId))
)
.orderBy(qFollows.createdAt.desc())
.limit(size)
.fetch();
- QFollows 라는 Q클래스가 이미 생성되어 있고 주 테이블이기 때문에 객체를 생성한 상태에서
같은 테이블끼리 Join 이 필요할 때 또 다른 Q클래스를 생성해야 할 경우
- 전역 변수로 만들었기 때문에 위 코드에는 생성과정이 보이지 않아, 따로 넣어주었다.
// 기본 인스턴스를 사용하여 Q클래스 사용
private final QFollows qFollows = QFollows.follows;
// 원하는 별칭("targetF") 으로 Q클래스 사용
private final QFollows targetF = new QFollows("targetF");
- CASE WHEN THEN 을 표현할 경우
- Expressions.booleanTemplate("SQL 표현식", 파라미터들...)
- 복잡하거나 QueryDSL에서 직접 지원하지 않는 SQL 구문을 그대로 표현할 때 사용하는
SQL 템플릿 기반 Boolean 표현식 생성 메서드이다. - 아래 코드에서 {0} 과 {1} 은 placeholder로,
뒤에 오는 파라미터를 SQL로 치환해주는데 순서대로 0에는 targetF.id 가 들어가고
1 에는 targetF.deletedAt 이 들어가게 된다.
- 복잡하거나 QueryDSL에서 직접 지원하지 않는 SQL 구문을 그대로 표현할 때 사용하는
- Expressions.booleanTemplate("SQL 표현식", 파라미터들...)
Expressions.booleanTemplate(
"CASE WHEN {0} IS NOT NULL AND {1} IS NULL THEN TRUE ELSE FALSE END",
targetF.id,
targetF.deletedAt
)
- QueryDSL 을 DTO 로 결과를 매핑하는 방식
- Projections.constructor() : 생성자를 통해 매핑 (생성자 기반이라 불변객체가 가능하여 Builder 패턴에 적합함)
- Projections. fields() : 필드 이름으로 매핑
- Projections.bean() : Setter 로 매핑
JPA 의 더티체킹 (Dirty Checking)
JPA 는 DB 에서 엔티티를 조회하거나 저장 하는 시점에
그 엔티티의 @Id 의 값을 알게 된다.
- 엔티티를 생성 할 때 @Id 값을 DB 에서 받아오고
- 조회 시 쿼리 결과에서 id 컬럼을 함께 읽어 오기 때문이다.
이 시점에서 JPA 는 영속성 컨텍스트(1차 캐시) 에 등록을 한다.
내가 이 부분을 깨닫게 된 시점은 아래 QueryDSL 의 수정 부분의 동작을 이해하게 되었을 때이다.
@Override
public void addFollow(String fromUserId, String toUserId) {
Follows existing = jpaQueryFactory
.selectFrom(qFollows)
.where(qFollows.fromUserId.eq(fromUserId)
.and(qFollows.toUserId.eq(toUserId))
.and(qFollows.deletedAt.isNotNull()))
.fetchOne();
// 객체가 비어있다면 첫 팔로우 추가
if(ObjectUtils.isEmpty(existing)) {
jpaQueryFactory
.insert(qFollows)
.columns(qFollows.fromUserId, qFollows.toUserId)
.execute();
} else if(existing.getDeletedAt() != null) {
existing.restore();
}
}
여기서 중요하게 봐야할 부분은 existing.restore() 메서드를 호출하는 구간이다.
이 구간은 deletedAt 컬럼이 null 이냐 아니냐 를 통해서
- 기존에 팔로우 했다가 언팔한 사람을 다시 팔로우 하는 시점인지
- 새로운 사람을 팔로우하는 시점인지
를 파악하는 곳이었고, deletedAt 이 null 이 아닐 경우 전자 시점에 해당하여
이미 생성된 row 에서 deletedAt 의 컬럼 데이터만 null 로 바꾸어주면 다시 팔로우 하게 되게 하는 쿼리문이었다.
본래라면 아래코드와 같이 다시 수정 쿼리를 날려야 할 것 같지만, JPA 의 더티 체킹 이라는 기능으로 인해
달라진 필드만 update 가 된다는 것을 알게 되었다.
jpaQueryFactory
.update(qFollows)
.setNull(qFollows.deletedAt)
.where(qFollows.fromUserId.eq(fromUserId)
.and(qFollows.toUserId.eq(toUserId))
.and(qFollows.deletedAt.isNull()))
.execute();
저 restore 메서드는 Follows 엔티티에 작성된 메서드인 아래와 같다.
public void restore() {
this.deletedAt = null;
this.createdAt = LocalDateTime.now();
}
이렇게 deletedAt 컬럼과 createdAt 컬럼이 변경됨으로써 JPA 의 더티체킹에 걸리게 되면서
따로 update 쿼리를 생성할 필요 없이 조회 시 저장하고 있던 id 값을 통해 update 를 자동으로 요청하게 되는 것이다.
참고
[Spring JPA] JPA 란?
이번 글에서는 JPA(Java Persistence API)가 무엇인지 알아보려고한다. JPA는 자바 진영에서 ORM(Object-Relational Mapping) 기술 표준으로 사용되는 인터페이스의 모음이다. 그 말은 즉, 실제적으로 구현된것이
dbjh.tistory.com
[Java Spring Boot] QueryDSL 사용하기
[Java Spring Boot] QueryDSL 사용하기.
QueryDSL의 특징QueryDSL은 Java에서 안전하고 가독성 있는 쿼리를 작성할 수 있게 해주는 프레임워크이다.타입 안전성: 컴파일 시점에 쿼리의 타입을 검증하여 런타임 에러 방지가독성: 코드 형태로
kangth97.tistory.com
'Project > Team' 카테고리의 다른 글
| [Spring Security] CurrentUser 구현체 + ArgumentResolver (0) | 2025.10.29 |
|---|---|
| [Git] Branch 와 checkout 의 헤프닝 (2) | 2025.10.22 |