240606 Spring 심화 - QueryDSL 공부하기(1)

노재원·2024년 6월 6일
0

내일배움캠프

목록 보기
55/90

강의만 다 보고 정리하려고 해도 어찌나 시간이 오래 걸리는지 공휴일에도 기어이 강의를 챙겨보게 됐다. 과제까지 생각하면 주말도 써야할 것 같은데 범위는 진짜 큰 것 같다. 우선 어제 시간 부족해서 넘긴 파트 먼저 봤다.

Spring security 인가 예외처리 핸들링

어제 인증되지 않은 유저에 대한 예외처리 핸들링을 위해 CustomAuthenticationEntrypoint을 사용해서 예외처리를 진행했는데 이번엔 인가되지 않은 유저에 대한 예외처리도 403 Forbidden이 아닌 401 UnAuthorized로 처리되는 일이 발생했다. 따라서 인가도 별도로 예외처리를 해줘야 한다.

인가에 대한 예외는 AccessDeniedException이 발생하는데 이건 AccessDeniedHandler가 담당해서 CustomAccessDeniedHandler를 생성해서 상속받고 handle 메소드를 구현하면 된다.

@Component
class CustomAccessDeniedHandler: AccessDeniedHandler {

    override fun handle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        accessDeniedException: AccessDeniedException
    ) {
        response.status = HttpServletResponse.SC_FORBIDDEN
        response.contentType = MediaType.APPLICATION_JSON_VALUE
        response.characterEncoding = "UTF-8"

        val objectMapper = ObjectMapper()
        val jsonString = objectMapper.writeValueAsString(ErrorResponse("No permission to run API"))
        response.writer.write(jsonString)
    }
}


/// config에 등록
.exceptionHandling{
    it.authenticationEntryPoint(authenticationEntrypoint)
    it.accessDeniedHandler(accessDeniedHandler)
}

그런데 구현 내용을 보면 CustomAuthenticationEntrypoint, CustomAccessDeniedHandler는 너무 유사하다. 상속받는 객체와 구현 함수 이름만 달라지고 내용은 사실상 똑같으니 왜 EntryPoint, Handler로 나뉘는지도 조금 더 조사를 해봤는데 우선 인증/인가에 대해 예외처리를 위해 사용하는 비슷한 목적임은 맞다.

이름이 다른 이유는 당연히 인증/인가를 구분하기 위해서인데 EntryPoint는 클라이언트의 요청이 들어오는 진입점 이라는 의미에서 사용한 것이고 Handler는 그냥 권한 없음을 핸들링하기 위해서만 처리하는 용도다.

강의에서는 CustomAuthenticationEntrypoint를 response에 예외 메시지 발생으로만 썼지만 인증이 안됐고 아직 인증을 처리하는 진입점이니 response.sendRedirect("/login") 같은 식으로 처리하는 역할도 가능하다고 한다.

인가도 비슷하게 생각하려면 할 수야 있겠지만 뉘앙스가 약간 다르다고 해석하고 넘어가기로 했다.

QueryDSL

QueryDSL을 알아보게 됐는데 지난 번에 JPA 강의 들을 때 메소드 이름을 규칙적으로 짓다가 복잡해지거나 명시적인 Join이 필요하면 JPQL을 사용하면 된다는 얘기는 이미 공부했었다.

하지만 JPQL도 단점이 있는데 Spring AOP에서 고민한 것처럼 컴파일 타임에 쿼리에 에러가 있는지 확인할 수가 없고 문자열이라 동적인 쿼리 작성도 불편하다는 단점이 있었다.

동적인 쿼리는 필터를 걸어야할 때 필터를 껐다켰다 할 수 있다면 Where 뒤에 필터의 조건이 달라질 수 있을 것이다. 그런데 이걸 JPQL로 작성한다면 모든 쿼리를 생성해야 할 것이고 서비스에서도 적절한 쿼리를 호출해야한다.

JPA에서도 이런 쿼리를 처리하기 위해 CriteriaQuery를 제공한다는데 강의에선 문법이 너무 직관적이지 않고 복잡해서 쓰기 어렵다고 하셨다.

때문에 위 단점을 해결한 QueryDSL이 등장했고 동적인 쿼리를 직관적인 코드로 작성할 수 있어서 컴파일 타임에 에러를 체크할 수도 있다.

의외의 사실은 JPA가 공식 지원하는 프레임워크가 아니고 오픈소스 프레임워크라고 한다.

기본 문법

QueryDSL은 JPA가 관리하는 Entity를 쓰는게 아니라 static한 QClass를 사용한다고 한다. QClass는 Entity를 기반으로 자동 생성되는 클래스라고 한다. 자동으로 만들어지는 거라 뭔가 GraphQL 느낌이 있다.

조회

val post = QPost.post // 기본 instance
val post = QPost("p") // sql처럼 별칭 지정

queryFactory.select(post).from(post).fetch()

queryFactory.selectFrom(post).fetch()

// Title 만 조회 - List<String> 형태로 조회됨
queryFactory.select(post.title).from(post).fetch()

// Title, numLike 만 조회 - List<Tuple> 형태로 조회됨
queryFactory.select(post.title, post.numLike).from(post).fetch()

// `fetch()` : 리스트 조회
// `fetchFirst()` : 단건 조회, 반환 타입이 `T!` 이기 때문에 없다면 NPE(Null Pointer Exception) 이 발생함 (코틀린 한정)
// `fetchOne()` : 단건 조회 반환 타입은 `T?`

이처럼 다양한 문법을 제공해주는데 JPA의 메소드쿼리도 신기하긴 하지만 이쪽이 좀더 명확하고 쿼리가 어떻게 작동할지 예측하기 쉬울 것 같은 느낌이다. 그리고 Repository pattern을 고려하기도 조금 더 쉬울 것 같다.

추가로 갯수 집계와 결과와 함께 얻어내는 fetchCount(), fetchResults() 가 있었는데 버그가 있어서 Deprecated 됐다고 한다.

Where 조건 처리

post.title.eq("title1") // title = 'title1'
post.title.ne("title1") // title != 'title1'

post.title.isNotNull() // title is not null
post.title.isNull() // title is null

post.numLike.gt(100) // numLike > 100
post.numLike.goe(100) // numLike >= 100
post.numLike.lt(10) // numLike < 10
post.numLike.loe(10) // numLike <= 10
post.numLike.between(10, 100) // numLike between 10 and 100

post.title.like("%hello%") // title like '%hello%'
post.title.contains("hello") // title like '%hello%'
post.title.startsWith("hello") // title like 'hello%'
post.title.endsWith("hello") // title like '%hello'

post.id.`in`(1, 2) // id in (1, 2)
post.id.notIn(1, 2) // id not in (1, 2)

이쪽은 좀 문법이 직관적이라기보단 외워야 할 필요성이 있어보인다. IDE가 자동 완성을 지원해주긴 하겠지만 필요할 때 정보를 좀 찾아야 할 것 같다.

Where 조건 연결 처리

queryFactory.select(post)
		.from(post)
		.where(post.title.eq("title")
        .and(post.numLike.gt(100))
        .or(post.numDislike.lt(10))
    )
.fetch()

And, Or를 쓰면 메소드 체인으로 SQL 작성하듯이 이어나갈 수 있다. eq는 이퀄이다.

정렬 처리

// 좋아요 수 기준 내림 차순 정렬
queryFactory.select(post)
		.from(post)
		.where(post.title.contains("title"))
		.orderBy(post.numLike.desc())
		.fetch()

// 생성 날짜 기준 오름 차순 정렬
queryFactory.select(post)
		.from(post)
		.where(post.title.contains("title"))
		.orderBy(post.createdAt.asc())
		.fetch()

// 좋아요 수 기준 내림 차순, 좋아요 수 동일할시 생성 날짜 기준 오름 차순 정렬, 생성일 없을때는 마지막에 출력
queryFactory.select(post)
		.from(post)
		.where(post.title.contains("title"))
		.orderBy(post.numLike.desc(), post.createdAt.asc().nullsLast())
		.fetch()

페이징

queryFactory.select(post)
		.from(post)
		.where(post.title.contains("title"))
    .offset(40) // 40번째 부터 조회
    .limit(20) // 20개씩 조회

정렬과 페이징은 Pageable 인터페이스로 즐겨 처리했는데 Repository에서 메소드쿼리를 쓸지 QueryDSL을 써서 Pageable 정보로 쿼리를 생성할지같은 내용은 알아서 정하면 될 것 같다.

카운팅

// SELECT COUNT(post.id) FROM post
val count1: Long? = queryFactory
   .select(post.count())
   .from(post)
   .fetchOne()

// SELECT COUNT(*) FROM post
val count2: Long? = queryFactory
   .select(Wildcard.count)
   .from(post)
   .fetchOne()

Wildcard로 표기가 가능한 건 좀 신기하다.

연관 관계 조인

val comment = QComment.comment

// 기본 조인 (innerJoin과 동일)
queryFactory.select(post)
		.from(post)
		.join(post.commet, comment)
    .where(comment.content.contains("아니"))
    .fetch()

// LEFT JOIN
queryFactory.select(post)
		.from(post)
		.leftJoin(post.commet, comment)
    .where(comment.content.contains("아니"))
    .fetch()

// RIGHT JOIN
queryFactory.select(post)
		.from(post)
		.rightJoin(post.commet, comment)
    .where(comment.content.contains("아니"))
    .fetch()

// FETCH JOIN
queryFactory.select(post)
		.from(post)
		.leftJoin(post.commet, comment)
    .fetchJoin()
    .where(comment.content.contains("아니"))
    .fetch()

이 쪽은 Entity가 연관관계가 되어있을 때 써먹는 용도라고 생각하면 된다.

아직 Join query를 많이 작성해보지 못해서 익숙하진 않겠지만 ChatGPT의 도움도 받아가면서 도움을 받으면 좋을 것 같다.

연관 관계와 무관하게 조인

val user = QUser.user

queryFactory.select(post)
		.from(post)
		.join(user).on(post.authorName.eq(user.name))
    .fetch()

join 구문엔 한 개의 Entity만 들어갈 수 있다고 한다. 그리고 .on으로 명시적으로 사용해야 비교가 가능하다.

이걸 쓰려면 이전 프로젝트에서 게시글 / 댓글의 신고를 구분하기 위해 entity_id, entity_type으로 처리했을 때처럼 찾아낼 때 이 쿼리가 필요하지 않을까 싶다.

서브 쿼리

val p = QPost("postSub")

// JPAExpressions 사용하여 서브쿼리 작성
queryFactory.select(post)
		.from(post)
		.where(post.numLike.eq(
				JPAExpressions.select(p.numLike.max()).from(m))
		)
    .fetch()

서브쿼리는 다른 테이블의 값을 기준으로 한 테이블에서 데이터를 검색할 수 있도록 다른 쿼리 내부에 중첩시킨 쿼리다. SQL로 보면 아래와 같은 느낌이다.

// select
select
	username, (select top 1 username from testTable)
from
	testTable
    
// from
select
	a.*
from
	(
		select username from testTable where username like '%e%'
	) as a

// where
select
	username
from
	testTable
where
	username =
    	(
            select top 1 username from testTable where username like '%e%'
        )

그 외에 SELECT CASE, GROUP BY 같은 문법은 강의에 나오진 않았고 QueryDSL 찾아보면 나오니 필요에 따라 찾아보면 될 것 같다. 생각보다 QueryDSL이 꽤 직관적인 문법이라 문법만 익숙해지면 좋을 것 같다.

QueryDSL 활용

처음 얘기 나온것 처럼 필터링을 처리하기 위해 정적으로 메소드쿼리를 여러개 쓰거나 JPQL을 쓰는 건 복잡하기 때문에 QueryDSL은 이를 동적으로 설정할 수 있게끔 지원한다.

Boolean Builder

fun serarchUser(email: String?, nickname: String?): List<User> {
		val builder = BooleanBuilder()
		
		email?.let { builder.and(user.email.contains(it)) }
		nickname?.let { builder.and(user.nickname.contains(it) }

		return queryFactory
						.selectFrom(user)
						.where(builder)
						.fetch()
}

이처럼 Builder 패턴을 지원해줘서 queryFactory가 자동으로 지원을 해주기도 하고

Where에 가변 인자 넣기

fun serarchUser(email: String?, nickname: String?): List<User> {
		return queryFactory
						.selectFrom(user)
						.where(
								userEmailContains(email), 
								userNicknameContains(nickname)
							)
						.fetch()
}

private fun userEmailContains(email: String?) {
		return email?.let { user.email.contains(it) }
}

private fun userNicknameContains(nickname: String?) {
		return nickname?.let { user.nickname.contains(it) }
}

Builder를 쓰지 않고 where에 그냥 추가하면 null은 무시하고 나머지는 연결해주게 알아서 처리가 되어있다.

or 조건에는 먹히지 않는다고 하니 or를 써야하거나 명시적으로 쓰고싶다면 Builder 패턴을 써도 될 것 같다.

QueryProjection

쿼리의 결과는 기본적으로 JPA처럼 Entity로 나오게 되는데 이걸 다른 타입으로 변환하는 방법도 제공해준다.

Projections.bean 활용

class UserDto(
    var id: Long? = null,
    var email: String? = null
)

val userDtos = queryFactory
			.select(Projections.bean(
					UsertDto::class.java,
					user.id,
					user.email
			))
			.from(member)
			.fetch()

내가 쓰던 Rest template의 exchange처럼 변환해주는 느낌인데 setter를 기반으로 작동해서 불변인 data class 사용은 안된다고 한다.

Projections.constructor

data class UserDto(
    var id: Long? = null,
    var email: String? = null
)

val userDtos = queryFactory
			.select(Projections.constructor(
					UsertDto::class.java,
					user.id, // 순서 중요!
					user.email // 순서 중요!
			))
			.from(user)
			.fetch()

bean과 달리 생성자를 써서 반환해주는 방식이라 data class를 써도 상관 없지만 객체 자체의 생성자를 쓰는게 아니라 Projections.constructor가 처리하는 방식이라 해당 객체의 생성자 순서를 준수해야만 한다.

@QueryProjection 활용

data class UserDto @QueryProjection constructor(
		val id: Long,
		val email: String,
)

val userDtos = queryFactory
			.select(QUserDto(
					id = user.id, 
					email = user.email
			))
			.from(user)
			.fetch()

@QueryProjection을 사용하면 Dto도 QClass를 만들어줘서 평범하게 생성자를 써서 반환할 수 있게 된다. 가장 안전하긴 하지만 DTO도 QueryDSL을 의존하게 된다는 단점이 존재한다.


Repository가 Entity를 반환하지 않고 복잡한 경우에 바로 Dto를 반환해야 하는 경우에 사용하는 것이니 기본적으로 자주 사용하게 될 것 같진 않다.

벌크 수정 / 삭제

벌크 연산은 여러 레코드에 대해 한번의 SQL 명령어로 대량 연산하는 걸 의미한다고 한다.

JPA가 지원하는 Dirty checking은 알아서 Entity의 변경을 감지해서 쿼리를 작성해주니 편하긴 한데 대량이 되면 모든 Entity를 체크하느라 시간이 꽤 걸린다고 한다.

여기서 QueryDSL을 사용하면 성능 개선이 가능하다고 한다.

주의사항

  • 꼭 @Transactional 내부에서 실행되어야 함
  • 영속성 컨텍스트의 Entity와 관련 없기 때문에 QueryDSL을 사용하면 DB와 영속성 컨텍스트의 상태가 달라짐. 쿼리 실행 후 em.clear()로 영속성 컨텍스트를 초기화 해주는 것이 좋다
// 벌크 수정
val updatedCount = queryFactory
			.update(user)
			.set(user.nickname, "TEST_NICKNAME")
			.where(user.id.lt(10L))
			.execute()

// 벌크 삭제
val updatedCount = queryFactory
			.delete(user)
			.where(user.id.lt(10L))
			.execute()

추가로 JPA를 우회하기 때문에 SQLDelete로 설정한 Soft delete도 안돼고 Lifecycle callback( @PrePersist, @PreRemove, @PostUpdate 등) 어노테이션들도 싸그리 무시한다고 한다.

0개의 댓글