04. JPA를 이용한 DAO 개발

1. DAO 와 JPA

Data Access Object를 줄여서 Dao라고 부른다. Dao는 말 그대로 데이터베이스에 접근해서 필요한 작업을 하는 Object를 뜻하며 데이터베이스를 사용하는 프로젝트의 많은 비중이 Dao를 만드는 것에 치중되어 있다. 


Spring Data JPA는 매우 적은 코드로 Dao를 구현할 수 있도록 해준다. 인터페이스를 만드는 것 만으로 Entity 클래스에 대한 Insert,Update, Delete, Select 를 실행할 수 있게 해준다. 뿐만 아니라 인터페이스에 메소드를 선언하는 것 만으로 라이트한 쿼리를 수행하는 코드를 만드는 것과 동등한 작업를 수행한다.


Spring Data JPA가 만들 수 있는 코드는 매우 가볍고 쉬운 쿼리를 대체하는 것이라 무시하는 개발자가 더러 있지만, CRUD를 포함한 단순 쿼리가 차지하는 비율은 대부분의 프로젝트에서 40~50%를 차지한다. 더구나 이러한 단순 쿼리는 필수적인 작업이며, 모아 놓고 보면 그 양이 어마어마 하다. 


Spring Data JPA는 데이터베이스 작업에 큰 도움이 되지만 아쉬운 부분있다. 앞에서도 언급했듯이 비교적 간단한 쿼리작업을 대체할 수 있을 뿐이다. 복잡도가 조금이라도 높아지면 사용하기가 매우 어려워 진다. 이럴 때는 QueryDSL을 사용해서 해결해야 한다. QueryDSL은 Jpa Entity를 바탕으로 작업을 진행하며, JPQL이나 Criteria 같은 것을 사용하지 않고 다이나믹 쿼리를 수행할 수 있도록 지원한다. 보통의 프로젝트에서 QueryDSL을 이용하여 작업하는 쿼리는 전체 프로젝트에 20~30%정도를 차지한다.


QueryDSL도 한계가 있기는 마찬가지다. Entity를 Object로 취급한다는 근본적인 문제가 있어 FROM절의 서브쿼리나 Union 계열의 쿼리를 수행할 수 없는 한계가 있다. 또한 통계성 쿼리가 수행하는 작업은 포기하는 것이 답일 수도 있다. 그럴 경우는 Native 쿼리를 사용하는 것이 좋다.


전체적으로 보면 하나의 기술로 완벽한 작업을 수행할 수 없는 것이 JPA의 한계이다. JPA와 QueryDSL를 사용해서 구현 할 수 있는 Dao의 비율은 70~80% 정도로 보는 것이 적당하다. 그러나 이정도 만으로도 Dao 작업에 소요되는 작업의 양은 절반 이하로 줄어든다. 이유는 코딩의 절대적인 양이 절반이하고 줄어 들고, 모델의 변화에 민감하게 작용하기 때문에 프로그램 수정과 디버깅에 소요되는 작업 시간이 급격히 줄어들기 때문이다. 여기서 예를 든 것은 일반적인 OLTP환경에 대한 이야기다. 프로젝트의 성격이 OLTP 성능에 치중되지 않은 배치성 프로젝트라면 JPA는 지양해야할 기술일 수도 있다.



2. JpaRepository

JPA Repository 인터페이스를 만드는 것은 매우 간단하다. 본인은 Entity 이름 뒤에 Repository라는 이름의 인터페이스를 만든다. 그리고 extends JpaRepository를 붙이는 것을 끝낸다.


아래는 ComUserMngt에 대한 Repository이다. 인터페이스로 선언 했으며, 7번 라인에 extends로 JpaRepository를 추가 했다. 그리고 여기서 사용할 Entity가 ComUserMngt 클래스임을 선언하고, PK 타입이 Long으로 선언되어 있다. 즉, ComUserMngtRepository 인터페이스는 어떤 테이블을 관리할 수 있고, Primary Key는 어떤 유형인지를 알려주는 것이다.


9번 라인에는 findByLoginId라는 메소드가 선언되어 있다. ComUserMngt는 공통 사용자 관리 테이블로 LoginId라는 컬럼으로 사용자의 로그인 정보를 확인하기 때문에 사용자가 로그인 되었을 경우 사용자 정보는 찾게 하기 위해 만드는 메소드이다. 그리고, 이것으로 데이터 베이스에 접근하여 사용자 정보는 찾는 작업은 끝났다. 

뭔가 아쉬울 수도 있지만, 쿼리라던가 구현체를 만들거나 하는 일은 없다.



또 다른 ComAuthGrpMenuMapRepository를 보자. ComAuthGrpMenuMap은 복합키로 이루어져 있다. 그래서 9번 라인의 뒷부분이 ComUserMngtRepository와는 다르다. 즉, 여기서 사용할 Entity가 ComAuthGrpMenuMap 클래스임을 알리지만,  Primary Key 유형에는 IdClass가 들어간다.




아래는 ComUserMngtRepository의 메소드 목록이다. 우리가가 작성한 findByLoginId() 이외에도 많은 메소드들이 기본으로 제공되고 있다. 물론 아래의 메소드 목록은 기본적인 CRUD에 대한 것이다. 좀더 복잡한 작업을 하는 메소드를 원한다면 아래의 Supported keywords inside method names 표를 참고하면 될 것이다.


Supported keywords inside method names

 Keyword

 Sample

 JPQL snippet

 And

 findByLastnameAndFirstname

 … where x.lastname = ?1 and x.firstname = ?2

 Or

 findByLastnameOrFirstname

 … where x.lastname = ?1 or x.firstname = ?2

 Is,Equals

 findByFirstname,

 findByFirstnameIs,

 findByFirstnameEquals

 … where x.firstname = 1?

 Between

 findByStartDateBetween

 … where x.startDate between 1? and ?2

 LessThan

 findByAgeLessThan

 … where x.age < ?1

 LessThanEqual

 findByAgeLessThanEqual

 … where x.age <= ?1

 GreaterThan

 findByAgeGreaterThan

 … where x.age > ?1

 GreaterThanEqual

 findByAgeGreaterThanEqual

 … where x.age >= ?1

 After

 findByStartDateAfter

 … where x.startDate > ?1

 Before

 findByStartDateBefore

 … where x.startDate < ?1

 IsNull

 findByAgeIsNull

 … where x.age is null

 IsNotNull,NotNull

 findByAge(Is)NotNull

 … where x.age not null

 Like

 findByFirstnameLike

 … where x.firstname like ?1

 NotLike

 findByFirstnameNotLike

 … where x.firstname not like ?1

 StartingWith

 findByFirstnameStartingWith

 … where x.firstname like ?1 

      (parameter bound with appended %)

 EndingWith

 findByFirstnameEndingWith

 … where x.firstname like ?1 

      (parameter bound with prepended %)

 Containing

 findByFirstnameContaining

 … where x.firstname like ?1 

      (parameter bound wrapped in %)

 OrderBy

 findByAgeOrderByLastnameDesc

 … where x.age = ?1 order by x.lastname desc

 Not

 findByLastnameNot

 … where x.lastname <> ?1

 In

 findByAgeIn(Collection<Age> ages)

 … where x.age in ?1

 NotIn

 findByAgeNotIn(Collection<Age> age)

 … where x.age not in ?1

 True

 findByActiveTrue()

 … where x.active = true

 False

 findByActiveFalse()

 … where x.active = false

 IgnoreCase

 findByFirstnameIgnoreCase

 … where UPPER(x.firstame) = UPPER(?1)


3. QueryDSL

앞에서도 이야기 했듯이 Spring Data JPA는 한계가 있다. 복잡한 쿼리를 요구하는 작업이 필요할 경우 JPQL이나 Criteria를 사용해야 하지만 만만한 작업이 아니다. 일반 쿼리툴 처림 눈으로 보고 결과를 확인할 수 있는 툴도 없고, SQL과 비슷하지만 뭔가 다른 것이 내 마음대로 되지 않기 때문이다. 이럴 때 QueryDSL은 JPA 본연의 특성을 잃지 않고도 SQL을 사용하는 것과 같은 편안함을 준다.


QueryDSL을 사용하기 위해서는 우선 pom.xml에 추가할 항목이 있다. 아래의 32라인에서 44라인 처럼 QueryDSL 라이브러리를 추가한다. 53라인에서 68라인에 추가한 플러그인은 Q-Object를 생성하기 위한 것으로 QueryDSL은 Jpa Entity 를 보고 Q-Object라는 새로운 코드를 자동으로 생성하기 위해서 추가하는 플러그인이다.


이렇게 해서 끝나면 좋겠지만 [You need to run build with JDK or have tools.jar on the classpath.... ]라는 메시지를 보게 될 수도 있다. 이럴 경우eclipse.ini (Spring Tool Suite의 경우 sts.ini)에 -vm옵션을 추가 하면 된다. 본인은 맨 마지막 줄에 -vm 옵션을 설정했다. JDK 위치는 본인의 환경에 맞게 설정하면 된다. 



이제 QueryDSL을 작성하기 위한 준비가 끝났다. 우선 ComAuthGrpMngtRepositoryQuerydsl을 작성해 보자. 이름을 이렇게 만든 이유는 ComAuthGrpMngtRepository에 덧붙이기 위해서이다. 즉 ComAuthGrpMngtRepository에 사용할 Querydsl이란 뜻이다. 10번 라인은 사용자 관리 오브젝트로 사용자에게 할당된 권한 목록을 조회하는 메소드의 명세이다.


ComAuthGrpMngtRepositoryQuerydsl 작성이 되었으면 ComAuthGrpMngtRepositoryImpl을 작성한다. 15번 라인과 같이 @Repository 어노테이션을 붙여 퍼시스턴트에서 동작하는 클래스임을 알린다. 16번 라인의 클래스 선언부엔 extends를 추가하고 QueryDslRepositorySupport를 상속받아 QueryDSL을 처리할 수 있도록 하며, implements에는 방금 작성한 ComAuthGrpMngtRepositoryQuerydsl을 넣는다, 

findByComUserMngt()메소드 내에  24번라인25번라인이 Q-Object이다. QueryDSL은 이것으로 Object 쿼리를 작성할 수 있도록 한다. 이것은 우리가 작성한 적이 없지만, QueryDsl이 Entity 클래스를 보고 자동으로 만들어 주는 클래스이다. 이것의 존재를 확인하고자 한다면 소스폴더 중 target/generated-sources/java 에서 확인할 수 있다.

27번 라인28번 라인이 쿼리 문장을 만드는 부분이다. 마치 select절이 없는 쿼리처럼 보인다. List<ComAuthGrpMngt>를 반환할 것이기 때문에 굳이 select절을 만들지 않았다. 28번 라인의 where절에는 조건을 입력하여 쿼리를 완성했다. 이것이 QueryDSL로 만드는 가장 간단한 소스 중 하나라고 보면 된다. 


다음은 좀더 복잡한 ComMenuMngtRepositoryQuerydsl과 ComMenuMngtRepositoryImpl을 보자. 11번 라인에 사용자 정보와 그룹 정보를 받아 메뉴 목록을 조회하는 메소드를 선언했다. 13번 라인은 모든 메뉴를 조회하는 메소드를 선언했다. 이 메소드들은 ComAuthGrpMngtRepositoryImpl에 구현된 소스보다 조금 더 복잡할 것이다.


ComAuthGrpMngtRepositoryImpl의 소스의 36번 라인을 보면 BooleanBuilder가 선언되어 있는 것을 볼 수 있다. ComAuthGrpMngtRepositoryImpl에서는 이 부분을 where() 메소드로 처리했는데 이렇게 하면 상황에 따른 조건절 처리가 불가능하다. 이것을 BooleanBuilder로 하면 if-else처리가 가능해 진다. 42/43라인은 if문을 사용하진 않았지만 변수를 받는 부분이다. return 문이 있는 45번 라인에서는 BooleanBuilder로 모은 조건을 where절에 넣어 쿼리를 완성해 준다.

findAllComMenuMngtAndAuthGrpMngt() 메소드에서 볼 부분은 55/56번 라인이다. 이부분은 innerJoin을 하는 부분인데 맨 마지막에 .fetchJoin()이 추가된 것을 볼 수 있다. .fetchJoin()이 하는 역할은 ComMenuMngt목록을 반환할 때 ComAuthGrpMngt와 QComAuthGrpMenuMap를 포함시켜 가져오도록 지시하는 부분이다.

이 부분이 없다면 ComMenuMngt에 선언되어 있는 QComAuthGrpMenuMap를 가져오는 부분이 FetchType.LAZY로 되어 있기 때문에 쿼리를 여러번 수행하게 된다. 즉, 한번의 쿼리로 가져올 수 있는 부분을 나눠서 가져오는 결과가 발생하기 때문에 .fetchJoin()를 추가하므로써 원래 의도대로 한번에 가져오도록 하는 것이다.


이렇게 두개의 QueryDsl소스가 만들어졌다. 코딩이 완료 되었으면 이것을 Repository에 붙여 사용한다. 물론 QueryDsl만 단독으로 사용할 수도 있지만 기존에 만들어진 Repository에 붙여 사용하면 호출 클래스를 하나로 할 수 있기 때문에 약간의 간편함이 있다. 기존 Repository와 합치기 위해서는 아래의 소스 8번 라인처럼 extends에 QueryDsl 인터페이스를 넣으면 된다. QueryDsl의 구현체의 이름이 기존 Repository에 impl을 붙인 형태인 이유는 이렇게 통합하기 위해서 이다.


다음은 위의 소스들을 테스트 하기 위해 flyway를 설정하여 기본 데이터를 넣고, Junit으로 테스트 할 것이다.