일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- Database
- redis
- dto projection
- 눈송이
- 테오의 스프린트
- 보따리
- bean
- jooq
- 후기
- 체험
- 트러블슈팅
- 오블완
- 글또
- 글또 #다짐
- jscode
- 티스토리챌린지
- Spring
- 구슬
- SQL
- 코드트리
- 모의면접
- 동적 SQL
- 사이드 프로젝트
- open contribution jam
- 부꾸
- spring context
- 북극곰
- 프로그래머스
- 부꾸러미
- DI
- Today
- Total
벤티의 개발 로그
[Database] jOOQ: Java로 작성하는 Query 본문
DTO Projection
예전에 한 프로젝트에서 다른 팀원이 작성한 코드를 전체적으로 리팩토링 할 일이 생겼다. 코드를 읽다가 다소 낯선 형태의 코드가 있었다. 아래와 같았다.
@Query("SELECT " + "new com.spring.familymoments.domain.post.model.MultiPostRes" + "(p.postId, u.nickname, u.profileImg, p.content, CONCAT(COALESCE(p.img1, ''), ',', COALESCE(p.img2, ''), ',', COALESCE(p.img3, ''), ',', COALESCE(p.img4, '')), p.createdAt, pl.status) " + "FROM Post p JOIN p.writer u " + "LEFT JOIN PostLove pl On p = pl.postId AND pl.userId.userId = :userId " + "WHERE p.familyId.familyId = :familyId " + "AND p.status = 'ACTIVE' " + "ORDER BY p.createdAt DESC") List<MultiPostRes> findByFamilyId(@Param("familyId") long familyId, @Param("userId") long userId, Pageable pageable);
우리 프로젝트에서는 JPA를 사용했는데, 그동안 JPA를 쓸 때는 Post, User 같은 단일 엔티티(도메인 객체)를 기준으로 Query를 작성했기 때문에(이 클래스의 이름은 PostRepository였다.) 이 코드를 한 번에 이해하기 어려웠다. 구글링 끝에 이 코드는 DTO Projection를 이용해서 작성된 것이라는 것을 깨달았다.
DTO Projection은 JPA에서 Query 결과를 특정 DTO 형태로 매핑하여 반환하는 방식이다. 위 코드에서는 MultiPostRes라는 DTO 객체로 매핑하고 있다. 우선, SELECT 문(2번째 라인에서) MultiPostRes 객체를 생성하고, 이 객체의 생성자를 통해 필요한 필드를 3번째 라인을 통해 하나하나 설정하고 있다.
결론부터 말하면 이 코드는 userId가 가입한 Family에 대해 familyId를 반환해서, Post(게시물) 정보와 함께 PostLove(게시물 좋아요) 정보까지 반환하는 코드였다. 이 코드를 리팩토링 하면서 팀원들과 img1~img4 필드로 관리되던 이미지를 imgs라는 하나의 List 필드로 고치기로 했고, MongoDB를 도입한 후 스키마도 수정했기 때문에 위 코드는 정말 간단하게 수정되었으나, 이 코드를 통해 JPA를 다른 방법으로도 쓸 수 있다는 것을 공부할 수 있었다.
동적 SQL?
얼마 전에 배포를 완료한 프로젝트에서 개발 전 사용할 기술 스택을 정할 때 다른 팀원이 'jOOQ'라는 다소 낯선 기술을 추천해주셨다. 당시 Spring Data JPA를 통한 SQL Query 작성에만 익숙했던 나는 동적 SQL에 대해서도 공부해야겠다는 마음이 있었고, 덕분에 기쁘게 jOOQ를 쓰고 싶다고 했다.
동아리의 면접을 준비하면서 JPA도 N+1문제, Fetch Join 같이 고려할 것이 많다는 것을 알게 되었고, 위 경험을 통해 알게 된 DTO Projection도 써보고 싶었다. 다만, 위처럼 코드를 작성하는 것은 여러 가지 문제점이 있었다.
우선 불편했다. Intellij의 자동 완성 기능도 저기까지는 지원해주지 않았다. 때문에 JPQL로 Query를 작성해야 했고, DTO의 필드와 대조해가며 코드를 수정해야 했다. 또 Query를 작성할 때도 한 줄 한 줄 직접 '" "'와 '+'를 통해 조합했고, 그러다 큰따옴표를 닫기 전에 띄어쓰기를 포함하지 않으면 콘솔에서는 에러를 뱉어냈고, 콘솔에서는 단순히 어느 부분 근처에 SQL Syntax 에러가 있다는 것만 보여줬기 때문에 어떤 부분이 에러인지 하나하나 찾아야 했다. 따라서 에러를 고치려면 시간이 좀 오래 걸렸었다.
jOOQ

jOOQ는 Java Object Oriented Querying의 약자로, Java 객체와 SQL 간의 매핑을 도와준다. 가장 강력한 장점은 복잡한 SQL Query는 JPQL로 작성할 필요 없고, SQL문의 형태를 가진 Java 코드로 작성할 수 있다는 것이다. 또 다른 장점은, DB 테이블들을 바탕으로 Java 클래스를 만들기 때문에 type-safe한 SQL Query를 만들 수 있다는 것이다. 또, rand() 같은 SQL 내장 함수도 지원한다. 다만, ORM은 아니다.
jOOQ가 생성한 코드 분석
아래는 jOOQ를 사용한 프로젝트의 폴더 구조이다.

우선 pojos 폴더는, Java에서 제공하는 POJO처럼 DB 테이블을 바탕으로 Record 클래스를 만드는 것 뿐만 아니라, Query도 Java 코드처럼 작성할 수 있게끔 jOOQ에서 POJO를 생성해준다. 우리 팀은 equals(), hashCode(), toString() 만을 오버라이딩해서 사용했다. (Getter와 Setter는 records 폴더 아래에 있는 '~~~Record' 클래스 안에 별도로 구현했다.)

또, records 폴더 안에 있는 AnswerRecord 클래스 안에 생성자와 각 필드의 getter와 setter가 만들어진다. 그리고, 이 클래스를 구현한 형태로 아래처럼 Answers라는 클래스가 만들어진다.
/** * This class is generated by jOOQ. */ @SuppressWarnings({ "all", "unchecked", "rawtypes" }) public class Answers extends TableImpl<AnswersRecord> { private static final long serialVersionUID = 1L; /** * The reference instance of <code>buggu.answers</code> */ public static final Answers ANSWERS = new Answers(); /** * The class holding records for this type */ @Override public Class<AnswersRecord> getRecordType() { return AnswersRecord.class; } /** * The column <code>buggu.answers.id</code>. 고유번호 */ public final TableField<AnswersRecord, Integer> ID = createField(DSL.name("id"), SQLDataType.INTEGER.nullable(false).identity(true), this, "고유번호"); /** * The column <code>buggu.answers.member_id</code>. 답변을 작성한 회원 고유번호 */ public final TableField<AnswersRecord, Integer> MEMBER_ID = createField(DSL.name("member_id"), SQLDataType.INTEGER, this, "답변을 작성한 회원 고유번호"); //...(중략)... /** * Create an inline derived table from this table */ @Override public Answers where(Collection<? extends Condition> conditions) { return where(DSL.and(conditions)); } //...(중략)... }
이 클래스는 Answers 테이블에 있는 각 속성과 정보, 특성을 createField()를 이용해 주입한다. 또, where 메소드를 오버라이딩해서, 주어진 여러 조건들을 SQL의 AND 문을 이용한 것처럼 하나로 결합해서 where 절을 구성하고, 이를 바탕으로 Query를 생성하는데 도움이 되게 할 수 있다.
사용법
@Repository public class AnswerRepository extends AnswersDao { private final DSLContext dslContext; private static final Answers ANSWER = Answers.ANSWERS; private static final Questions QUESTION = Questions.QUESTIONS; public AnswerRepository(DSLContext dslContext) { super(dslContext.configuration()); this.dslContext = dslContext; } /** * 답변 저장 * @param dto */ public void createAnswer(AnswerDto dto) { // 서울 시간대(UTC +9)로 지정 ZonedDateTime currentSeoulTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); dslContext.insertInto(ANSWERS) .set(ANSWERS.MEMBER_ID, dto.memberId()) .set(ANSWERS.QUESTIONS_ID, dto.questionId()) .set(ANSWERS.SENDER, dto.sender()) .set(ANSWERS.CONTENT, dto.content()) .set(ANSWERS.COLOR_CODE, dto.colorCode()) .set(ANSWERS.REG_DATE, currentSeoulTime.toLocalDateTime()) .execute(); } }
위 코드는 jOOQ를 이용해 내가 작성한 createAnswer()라는 메소드인데, AnswerDto라는 답변 DTO 클래스를 주입 받아서 DB에 저장한다. 우선 @Repository를 통해 의존성을 주입한다는 점이나, final로 선언된 ANSWER와 QUESTION이 table 클래스, 즉 Answers 클래스와 Questions 클래스라는 것을 알면 쉽게 코드를 작성할 수 있다. 또, DSLContext를 선언해서 insertInto와 execute()을 이용해 DB에 데이터를 저장하는 부분은 JDBC와도 비슷하다.
장점
이렇게 보면 jOOQ를 이용하는 것의 장점이 없을 수 있다. 하지만 jOOQ는 JOIN이 복잡할수록 유리하다고 생각한다. 일단, 제일 위에 작성한 DTO Projection 코드를 jOOQ를 이용하면 아래처럼 바꿀 수 있기 때문이다.
public List<MultiPostRes> findByFamilyId(long familyId, long userId, Pageable pageable) { return dslContext.select( POST.POST_ID, USER.NICKNAME, USER.PROFILE_IMG, POST.CONTENT, DSL.concat( DSL.coalesce(POST.IMG1, ""), ",", DSL.coalesce(POST.IMG2, ""), ",", DSL.coalesce(POST.IMG3, ""), ",", DSL.coalesce(POST.IMG4, "") ).as("images"), POST.CREATED_AT, POST_LOVE.STATUS ) .from(POST) .join(USER).on(POST.WRITER_ID.eq(USER.USER_ID)) .leftJoin(POST_LOVE).on(POST.POST_ID.eq(POST_LOVE.POST_ID).and(POST_LOVE.USER_ID.eq(userId))) .where(POST.FAMILY_ID.eq(familyId) .and(POST.STATUS.eq("ACTIVE")) ) .orderBy(POST.CREATED_AT.desc()) .limit(pageable.getPageSize()) .offset(pageable.getOffset()) .fetchInto(MultiPostRes.class); }
이 코드는 무려 3개의 테이블을 JOIN한다. 그리고 Paging까지 지원한다. 따라서 JPQL의 형태로 쓰면 등호와 콜론, 따옴표와 띄어쓰기, 공백에도 신경을 기울여야했지만, jOOQ를 사용해서 코드를 작성하면 SQL 명령어들을 레고처럼 조합하듯이 코드를 작성할 수 있어 생각하기도, 작성하기도 편리했다. 예를 들어, 등호는 위처럼 '.eq()'의 형태로 쓸 수 있다.
하지만 가장 마음에 들었던 것은, 마지막 줄처럼 Query 실행 결과를 DTO 클래스로 매핑할 수 있어 Java 객체로 변환이 가능하다는 것이다. 바이트코드를 포함하며 Java 소스 코드 파일(.java)을 컴파일한 후 생성되는 .class 파일의 형태로 fetch 할 수 있다.
아쉬웠던 점
아래 코드는 이 프로젝트에서 작성한 또 다른 메소드로, 무한 스크롤을 구현하기 위해 Paging 기능을 포함해서 답변 리스트를 반환하는 메소드이다.
/** * 내가 보따리에 받은 전체 답변 수 * @param memberId * @return */ public int countByCondition(int memberId) { Optional<Integer> totalCount = Optional.ofNullable( dslContext .selectCount() .from(ANSWER) .join(QUESTION).on(QUESTION.ID.eq(ANSWER.QUESTIONS_ID)) .where(QUESTION.MEMBER_ID.eq(memberId)) .fetchOne(0, Integer.class) ); return totalCount.orElse(0); } /** * 내가 보따리에 받은 전체 답변 리스트 * @param memberId * @param reqDto * @return */ public Page<AnswerInfoResDto> findAnswerList(int memberId, AnswerListReqDto reqDto) { Pageable pageable = PageRequest.of(reqDto.start() - 1, reqDto.limit()); List<AnswerInfoResDto> list = dslContext .select( ANSWER.ID, ANSWER.SENDER, ANSWER.CONTENT, ANSWER.COLOR_CODE, ANSWER.REG_DATE ) .from(ANSWER) .join(QUESTION).on(QUESTION.ID.eq(ANSWER.QUESTIONS_ID)) .where(QUESTION.MEMBER_ID.eq(memberId)) .orderBy(ANSWER.ID.desc()) .offset(pageable.getOffset()) .limit(reqDto.limit()) .fetchInto(AnswerInfoResDto.class); int totalCount = countByCondition(memberId); return new PageImpl<>(list, pageable, totalCount); }
하지만 결국 여기서도 Spring Data JPA를 활용했는데, Paging 기능 때문이었다. 따라서, 'Spring Data JPA 라이브러리를 import 하지 않고 Paging을 구현할 수는 없을까?'라는 의문점이 생겼다. 다음에 리팩토링 할 때 이 점에 대해서 공부해봐야겠다! 😄