Spring Data JPA를 활용한 DAO를 바꿔보자.

부트 이전에 스프링에서 데이터베이스를 그래도 다른 사람이 쓰는 만큼 쓸려면 MyBatis를 써줘야했다.

MyBatis를 한번이라도 써본 사람이라면 알겠지만 복잡하다. 스프링 XML 설정의 복잡도에 MyBatis의 복잡도를 더하면 상당히 헬 수준으로 올라간다. 단순 목록 하나만 가져오는데 MyBatis를 쓰는건 형식주의에 빠진 불합리의 최상급이었다. 되려 JDBC를 가져다가 prepareStatement에 Bind 변수만 사용하는 것이 오히려 손쉽게 직관적일 수 있다. 글을 읽는 분들중에 Bind 변수를 쓴다는 말을 이해하지 못하시는 분들은 이해의 수고를 덜기 위해 그냥 MyBatis를 쓰는게 정신 건강에 좋다. 하지만 적극 추천한다.

부트(Springboot)를 공부하면서 대부분 Annotation을 가지고 처리하는데 데이터베이스에 대한 접근도 비슷한 방법이 없을까 싶어서 잠깐 찾아봤었지만 역시… JPA 라는 걸 이미 많이 사용하고 있었다.  그리고 MyBatis처럼 설정 그까이게 거의 없다.  테이블 혹은 쿼리와 맵핑되는 설정 몇 가지만으로도 바로 이 기능으로 데이터베이스에서 자료를 읽어내거나 저장할 수 있다.

개인적으로 데이터베이스를 엑세스하는 쿼리를 복잡하게 가져가는건 별로 좋은 방법이 아니라고 굳게 믿는다. 폄하하자면 프로그래밍을 못짜는 개발자들이 논리의 부재를 쿼리로 입막음하려는 경향이 있다.  정말 잘못된 자세다. 정신 머리를 고쳐먹고 쿼리는 최대한 심플하게 작성하고 로직은 프로그램으로 대응하는 버릇을 들이도록 정신을 개조해라.

시작하기

개발을 할려면 먼저 이걸 사용하기 위한 준비부터 해야한다. 메이븐을 개발 환경으로 사용하기 때문에 아래 설정을 반영하면 된다. 데이터베이스 종류에 따라 해당 데이터베이스에 대한 추가적인 의존성을 가져가야한다는건 이미 알고 있으리라 생각한다. 그냥 귀찮으니까 MySQL에 대한 설정 부분만 Copy & Paste하기 좋게 추가해둔다.  그리고 앞으로 설명은 전부 MySQL을 기반으로 진행한다.

<dependency>
  <groupId>org.springframework.data</groupId>
  <artifactId>spring-data-jpa</artifactId>
  <version>1.10.1.RELEASE</version>
</dependency>
<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>5.1.6</version>
  <scrope>runtime</scope>
</dependency>

버전에 관련된 사항은 해당 프로젝트 페이지를 통해 확인해보는게 좋다.

라이브러리는 준비됐으니까 이제 데이터베이스를 셋업해봐야겠다. 데이터베이스 셋업은 여기에서 설명하는 개발 주제랑은 무관하니까 일단 스킵. 하지만 개발자라면 꼭 자신의 로컬에 데이터베이스 하나쯤은 실행시켜서 쿼리 도구로 실행해봐야겠다.  그럼 이게 준비됐다라는 전제면 접속을 위한 설정 정보를 코드에 반영해둬야 한다. 이건 보통 application.properties 파일에 다음과 같이 잡아둔다.

spring.datasource.url=jdbc:mysql://localhost:3307/db_name
spring.datasource.username=admin
spring.datasource.password=********
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

응, 근데 JPA가 뭐지?

시작을 하긴 했지만 JPA가 뭐의 약자인지부터 알고 가자. JPA는 Java Persistence API의 약자. 2000년대 초반에 나왔다가 별로 빛을 보지 못했던 것 같다. 스프링 진영에서 아마 iBatis or MyBatis 류의 약진으로 뜰 기회가 아예 없었을 것 같다. 더구나 하나만 고집하는 한국 환경에서 이미 대세가 된 iBatis를 넘어서는 것이 불가능하다.

내가 이해하는 JPA의 컨셉은 데이터베이스에 존재하는 한 모델을 자바의 한 객체로 mapping하는데 목적이 있다.  이 방향성은 단방향성이 아니라 양방향성이다. 즉 코드상에 존재하는 객체는 마찬가지로 데이터베이스에서도 존재해야 한다.  이를 매개하는 존재가 ID 필드이다.  ID 필드는 특정 클래스/테이블의 Uniqueness를 보장하기 위해 사용된다.

jpa-concept

이 ID 필드는 클래스(객체)에 정의되지만 반대 급부로 테이블에도 마찬가지로 해당 필드가 정의되어야 한다. 다만 테이블과 다른 점은 객체를 통해 관리할 정보는 필요한 정보들에 국한될 수 있다.  즉 불필요한 정보는 굳이 객체로 관리할 필요가 없고, 따라서 클래스의 필드로 정의할 필요가 없다. 하지만 테이블 구조에서 Not null 필드 혹은 Constraint에 보호되는 필드라면 적절한 장치가 필요하긴 할 것이다.

단일 테이블부터

테이블 하나에 대한 처리는 정말 쉽다.  하지만 몇가지 지켜야 할 규칙이 있다.  우리가 다뤄야 할 테이블의 이름을 SAMPLE_TABLE이라고 가정하자.

create table SAMPLE_TABLE (
  id int not null autoincrement,
  sample_name varchar(100) not null,
  code varchar(20) not null,
  create_datetime datetime not null,
  primary key(id)
);
  • 테이블의 이름과 동일한 모델 클래스를 만든다. 따라서 클래스는 헝가리안 표기법(Hungarian Notation)을 따라 SampleTable 이라는 이름이어야 한다. 테이블 혹은 필드의 이름이 Underbar(‘_’)로 구분되면 각 단어의 처음이 대문자로 표현되어야 한다. 클래스의 이름을 통해 실제 테이블을 mapping하게 된다.
  • 테이블의 모든 컬럼에 대응하는 모든 필드를 만들 필요는 없으며 ID 필드를 포함한 다뤄야 할 필드들을 정의하면 된다. 쓸데없는 것까지 다룰 필요는 없다.
  • 테이블의 컬럼에 대응하는 필드의 이름은 카멜 표기법(Carmel Notation)을 따라 표기한다.
  • Serialize/Deserialize할 수 있어야 하기 때문에 테이블의 각 필드들에 대해 Getter/Setter를 만들어둬야 한다.  일일히 하기에 귀찮다. lombok 라이브러리를 사용하면 각 필드들에 @Getter @Setter Annotation을 사용하거나 전체 클래스에 대해 적용할려면 @Data Annotation을 적용하면 된다.
  • 스프링에서 해당 클래스를 참조할 수 있도록 @Entity라는 Annotation을 적용한다.
@Entity
@Data
public class SampleTable {
    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private long id;

    private String sampleName;
    private String code;
}

한 두개 팁을 확인해보자.

  • Entity 클래스와 테이블의 이름이 다른 경우 – Entity 클래스와 테이블의 이름이 틀려지면 @Table(name=”SAMPLE_TABLE”)을 지정한다.
  • 컬럼의 이름과 다른 필드의 이름을 부여하고 싶은 경우 – @Column(name = “COLUMN_NAME”) 어노테이션을 통해 해결한다.
  • 크기가 큰 컬럼들 – 테이블의 컬럼 타입이 CLOB 혹은 BLOB 같은 타입의 경우 자동으로 큰 크기를 처리할 수 없다.  이 경우에 mapping되는 컬럼이  해당 타입인지 여부를 @Clob 혹은 @Blob 같은 어노테이션을 통해 따로 지정해줘야 한다. (생각해보면 당연하다. CLOB/BLOB을 JDBC 혹은 Pro*C로 처리할 때 단순 Variable Binding을 가지고 처리할 수 없다는걸 아는 사람이라면. 그리고 왜 그래야만 하는지도.)

 

CRUD

데이터베이스에 대한 @Entity와 @Data 어노테이션을 활용해 데이터베이스 테이블과 클래스의 맵핑이 완료됐다. 그럼 실제로 동작이 되도록 이 두개를 Repository를 통해 이어주면 된다. 간단하다.

public interface YourSampleTableRepository extends CrudRepository<SampleTable, Long> {
    Layout findOne(Long id);
}

JPA에서 기본 제공해주는 CrudRepository 인터페이스를 상속해서 새로운 당신의 Repository를 만들면 된다. 그럼 기본은 전부할 수 있다. CrudRepository가 뭐하는건지 궁금하지 않은가? 이 인터페이스는 아래와 같이 생겨먹었다.

@NoRepositoryBean
public interface CrudRepository<T, ID extends Serializable> extends Repository<T, ID> {
    <S extends T> S save(S var1);
    <S extends T> Iterable<S> save(Iterable<S> var1);

    T findOne(ID var1);
    Iterable<T> findAll();
    Iterable<T> findAll(Iterable<ID> var1);

    boolean exists(ID var1);
    long count();

    void delete(ID var1);
    void delete(T var1);
    void delete(Iterable<? extends T> var1);
    void deleteAll();
}

인터페이스의 생겨먹은 모습에서 알 수 있듯이 대부분의 동작들을 지원한다. (보기 편이를 위해서 약간 순서를 조정하긴 했다.) 그런데 흔하게 보다보면 CrudRepository라는 것 이외에 JpaRepository도 흔하게 사용한다. 아마도 처음 접하는 예제의 종류가 틀려서 그런가 싶기도 하지만… CrudRepository와 JpaRepository의 차이점은 여기 링크에서 잘 설명하고 있다. 간단히 요약하자면

  • JpaRepository는 CrudRepository의 손자뻘 인터페이스이다.
  • JpaRepository는 Crud에 비해 게시판 만들기에 용이한 Paging개념과 배치 작업 모드를 지원한다.
  • 하지만 다 할 수 있다고 다 이걸로 쓰는건 아니다. 언제나 강조하지만 닭잡는데 소잡는 칼을 쓸 필요는 없지않은가?

다른 길로 빠지긴 했지만 일단 기본으로 쓸려면 그냥 CrudRepository를 기본 칼로 쓰는게 좋다. 상황에 따라 적절한 도구를 사용하고 있는가 혹은 사용할 수 있는 도구를 고를 수 있는 자질이 되는가를 스스로 질문해보자. 답변을 할 수 있는 역량이 본인에게 있다면 다행이다.

Queries

만들고, 변경하고, 지우고 있는지를 확인하고 등등 기본적인 동작이 되는건 대강 확인했다. 그럼 특정 상황에 맞는 조회 문장을 만드는 건 Repository 인터페이스에 조회용 메소드를 정의함으로써 이뤄진다. JPA 환경에서 특정 조건을 만족하는 쿼리를 수행하는 방법은 크게 아래와 같은 가지수로 나뉜다. (상세한 설정에 대한 도움말은 역시 공식 홈페이지에 상세하게 적혀있다.)

  • Query creation from method – 쿼리를 유추할 수 있는 메소드의 이름을 통해 쿼리를 정의한다.  일반적인 규칙은 findBy뒤에 조건이 되는 필드의 이름을 And 혹은 Or 조건을 섞어 작성한다. 실행에 필요한 값들은 언급된 매개 변수의 순서에 맞춰서 작성한다.
public interface UserRepository extends Repository<User, Long> {

  List<User> findByEmailAddressAndLastname(String emailAddress, String lastname);
}

이 쿼리가 대강 실행되면 대강 아래와 같은 쿼리의 형식으로 실행된다.

select u from User u where u.emailAddress = ?1 and u.lastname = ?2
  • NamedQuery annotation – 설정을 xml 파일에 두는 경우, 해당 파일의 이름은 orm.xml이어야 한다. 그게 아니면 테이블에 맵핑된 클래스(이걸 Domain Class라고 부르는군.)에 @NamedQuery라는 annotation을 사용해서 실행될 쿼리를 아래 예제처럼 적어주면 된다.
<named-query name="User.findByLastname">
  <query>select u from User u where u.lastname = ?1</query>
</named-query>
@Entity
@NamedQuery(name = "User.findByEmailAddress", query = "select u from User u where u.emailAddress = ?1")
public class User {
    ...
}

public interface UserRepository extends Repository<User, Long> {
  List<User> findByLastname(String lastname);

  User findByEmailAddress(String emailAddress);
}
  • Query annotation – 가장 흔하게 사용하는 방법이다. Repository의 조회 메소드에 직접 실행될 쿼리를 적어준다.
public interface UserRepository extends JpaRepository<User, Long> {
  @Query("select u from User u where u.emailAddress = ?1")
  User findByEmailAddress(String emailAddress);
}

JPA는 조건문에 들어가는 파라미터의 위치를 메소드의 파라미터 위치로부터 유추한다. 즉 ?1 이라는 표시된 곳에 첫번째 파라미터의 값이 반영된다. 쿼리가 복잡하다면 이름을 참조하는 좀 더 알아보기 쉬운 방식으로 사용하는게 좋다. 이름을 참조할려면 인터페이스 메소드의 각 파라미터 앞에 @Param 이라는 추가 annotation을 사용해서 참조 가능한 이름을 주고 쿼리에서 이 이름을 사용하면 된다.

public interface UserRepository extends JpaRepository<User, Long> {
  @Query("select u from User u where u.firstname = :firstname or u.lastname = :lastname")
  User findByLastnameOrFirstname(@Param("lastname") String lastname, @Param("firstname") String firstname);
}

예제에서 보면 쿼리에 firstname, lastname이라는 변수를 참조했고, 참조된 변수는 @Param annotation으로 메소드에 함께 선언된 것을 확인할 수 있다.  복잡하다면 이렇게 해주는게 읽는 사람을 위한 배려다.

그래도 테이블 두개는 조인할 수 있어야지

테이블 한개에 대한 처리는 지금까지의 설명으로 충분하다. 하지만 2개 이상의 테이블을 조인해서 뭔가를 추출하는 경우라면 어떻게 해야할까?  쿼리상으로는 간단히 조인을 걸어서 결과를 확인하는 아주 간단한 거다. JPA 방식에서 사용할려면 좀 더 터치가 필요하다.  간단히 다음과 같은 테이블 구조가 있다고 하자.

joined

여기에서 사용자쪽의 정보를 바탕으로 그룹에 대한 정보를 가져올려고 한다면


select user.userid, password, group.groupid, ... from user join group on user.group_id = group.group_id

와 같이 하면 된다.  이걸 JPA 기반에서 처리한다면


@Entity
@Data
public class User {
    ...
    @ManyToOne
    @JoinTable(name="USER_GROUP")
    Group group;
};

@Entity
@Data
public class Group {
};

흠.. 이렇게 하면 될까? 실제로 해보질 않아서 모르겠다.

JPQL이라는 걸 JPA 내부적으로 사용한다고 한다. 하지만 파보질 않아서 잘 모르겠네.

상세한 내용은 다음 링크에서 도움을 나중에 받을 수 있을 것 같다.

  • https://en.wikibooks.org/wiki/Java_Persistence/Querying
  • http://www.objectdb.com/java/jpa/query/jpql/from

일단 미완.

Tips

MySQL의 경우에 테이블 이름을 Underscore(_)로 구분하는게 아니라 대문자 형식(ex: MyTable)으로 작성하는 경우가 있다. 이 경우에는 Entity의 테이블 이름을 MyTable로 주면 Spring JPA에서 자동으로 my_table로 변경해버려서 테이블을 찾지 못한다고 이야기하는 경우가 있다.  이때는 Naming Strategy를 아래와 같이 변경해주면 된다.

spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

한참을 헤메긴 했지만 알고 있다면 좋을 것 같다.