JPA – Dreaming for the Future 영원한 개발자를 향해서. 월, 13 1월 2025 13:44:09 +0000 ko-KR hourly 1 https://wordpress.org/?v=4.7 108384747 Spring Data JPA와 AspectJ가 함께 친 사고 /index.php/2018/04/22/conflict-of-spring-data-jpa-and-aspectj/ Sun, 22 Apr 2018 05:11:50 +0000 /?p=539

Continue reading ‘Spring Data JPA와 AspectJ가 함께 친 사고’ »]]> Spring JPA는 데이터베이스를 사용하는데 있어서 새로운 장을 열었다. 쿼리를 직접 사용해서 데이터베이스를 엑세스하는 MyBatis의 찌질한 XML 덩어리를 코드에서 걷어냄으로써 코드 자체도 간결해지고 직관적으로 특정 Repository 및 DAO가 어떤 테이블과의 매핑 관계가 있는지를 명확하게 파악할 수 있도록 해준다. 단점으로 생각되는 부분이 여러 테이블들을 복잡한 조인 관계를 설정하는게 상당히 난감하다. 하지만 역설적으로 이런 조인 관계를 왜 설정해야하는지를 역으로 질문해볼 필요가 있다. 우리가 복잡한 코드를 최대한 단순화시키려고 노력할 때, 그 코드가 달성할려고 하는 목적과 의미를 가장 먼저 생각하는 것처럼. 더구나 마이크로서비스 아키텍처 개념에서는 한 서비스가 자신의 독립적인 Repository를 가지는 것이 원칙이라고 할 때 해당 Repository의 구조는 단순해야한다. 그렇기 때문에 Spring JPA가 더욱 더 각광을 받는 것이 아닐까 싶다.

AspectJ 역시 특정 POJO 객체의 값을 핸들링하거나 특정 동작을 대행하는데 있어서 아주 좋은 도구이다. 주로 특정 Signature 혹은 Annotation을 갖는 객체 혹은 메소드가 호출되는 경우에 일반적으로 처리해야할 작업들을 매번 명시적으로 처리하지 않아도 AspectJ를 통해 이를 공통적으로 실행시켜줄 수 있기 때문이다.

작업하는 코드에 이를 적용한 부분은 특정 DB 필드에 대한 자동 암호화이다.  값을 암호화해서 저장하고, 저장된 값을 읽어들여 평문으로 활용하는 경우가 있다. 이 경우에는 이만한 도구가 없다.

이런 이해를 바탕으로 지금까지 서비스들을 잘 만들어서 사용하고 있었다. 그런데 새로운 기능 하나를 추가하면서 이상한 문제점이 발생하기 시작했다.  암호화된 테이블에 대한 저장은 분명 한번 일어났는데, 엉뚱하게 다른 처리를 거치고 나면 암호화 필드가 저장되어야 할 필드에 엉뚱하게 평문이 저장되었다. 그전까지 암호화된 값이 잘 저장되었는데… 더 황당한 문제는 내가 명시적으로 저장하지 않은 데이터까지 덤탱이로 변경이 되버린다!!!  개별 기능을 테스트 코드를 가지고 확인했을 때는 이상이 없었는데, 개발 서버에 올리고 테스트할려니 이런 현상이 발생한다.

 

새로운 기능이 동작하는 방식을 간단히 정리하면 아래와 같다.

  1. 기존 테이블에서 2라는 키값이 존재하는 데이터를 쿼리한다. 원래 시나리오상으로는 이런 데이터가 존재하지 않아야 한다.
  2. 2라는 PK값을 가지는 데이터를 저장한다.
  3. 저장 후 fk.for.grouping 이라는 그룹 키를 가진 항목들을 로딩해서 특정 값을 계산한다. 읽어들인 걸 저장하지는 않고, 읽어들여서 계산만 한다.
  4. 계산된 결과를 신규 테이블(New table)에 저장한다.

디버깅을 통해 확인해보니 2번 단계에서는 정상적으로 AspectJ를 통해 암호화 필드가 정상적으로 저장된다. 그런데 4번 과정을 거치고나면 정말 웃기게도 평문값으로 업데이트가 이뤄진다.  그림에서 따로 적지는 않았지만, 멀쩡하게 암호화되어 있던 1번 데이터도 평문화되는게 아닌가??? 몇 번을 되짚어봐도 기존 테이블에 저장하는 로직은 없다. 뭐하는 시츄에이션이지??

JPA와 AspectJ를 사용하는데, 우리가 놓치고 있었던 점은 없었는지 곰곰히 생각해봤다. 생각해보니 JPA를 사용자 관점에서만 이해를 했지, 그 내부에서 어떻게 동작이 이뤄지는지를 잘 따져보지는 않았던 것 같다. 글 몇 개를 읽어보니 Spring의 Data JPA는 이런 방식으로 동작하는 것으로 정리된다.

재미있는 몇가지 사실들을 정리하면 아래와 같다.

  • RDBMS를 위한 Spring Data는 JPA를 Wrapping해서 그저 쓰기 좋은 형태로 Wrapping한 것이고, 실제 내부적인 동작은 JPA 자체로 동작한다.
  • JPA는 내부적으로 EntityManager를 통해 RDBMS의 데이터를 어플리케이션에서 사용할 수 있도록 관리하는 역할을 한다.
  • EntityManager는 어플리케이션의 메모리에 적제된 JPA Object를 버리지 않고, Cache의 형태로 관리한다!!!

EntityManager가 관리를 한다고?? 그럼 그 안에 있는 EntityManager는 어떤 방식으로 데이터를 관리하지?

 

JPA Entity Lifecycle

 

오호.. 문제의 원인을 이해할 수 있을 것 같다.

  • Springboot application이 위의 처리 과정 3번에서 “fk.for.grouping 이라는 그룹 키를 가진 항목들을 로딩” 과정을 수행한다.
  • 정말 읽어들이기만 했다면 문제가 없었겠지만 AspectJ를 통해서 읽어들인 객체의 암호화 필드를 Decryption했다.
  • Entity Manager에서 관리하는 객체를 변경해버렸다! 객체의 상태가 Dirty 상태가 되버렸다.
  • 4번 과정에서 신규 테이블에 데이터를 저장할 때, Entity Manager는 관리하는 데이터 객체 가운데 Dirty 객체들을 테이블과 동기화시키기 위해 저장해버렸다.
  • 하지만 이 과정은 Entity Manager 내부 과정이기 때문에 따로 AspectJ가 실행해야할 scope를 당연히 따르지 않는다.
  • 결과적으로 평문화된 값이 걍 데이터베이스에 저장되어버렸다.

따로 저장하라고 하지 않았음에도 불구하고 엉뚱하게 평문화된 값이 저장되는 원인을 찾았다.

문제를 해결하는 방법은 여러가지가 있을 수 있겠지만, 가장 간단한 방법으로 취한 건 이미 메모리상에 로드된 객체들을 깔끔하게 날려버리는 방법이다.

public interface DBRepositoryCustom {
    void clearCache();
}

public class DBRepositoryImpl implements DBRepositoryCustom {
    @PersistenceContext
    private EntityManager em;

    @Override
    @Transactional
    public void clearCache() {
        em.clear();
    }
}

위와 같이 하면 Spring Data JPA 기반으로 EntityManager를 Access할 수 있게 된다. 이걸 기존 JPA Interface와 연동하기 위해, Custom interface를 상속하도록 코드를 변경해주면 된다.

@Repository
public interface DBRepository extends CrudRepository<MemberInfo, Long>, DBRepositoryCustom {
    ...
}

여기에서 주의할 점은 DBRepository라는 Repository internface의 이름과 Custom, Impl이라는 Suffix rule을 반드시 지켜야 한다. 해당 규칙을 따르지 않으면 Spring에서 구현 클래스를 정상적으로 인식히지 못한다.  따라서 반드시 해당 이름 규칙을 지켜야 한다.

  • DBInterface가 JPA Repository의 이름이라면
  • DBInterfaceCustom 이라는 EntityManager 조작할 Interface의 이름을 주어야 하며
  • DBInterfaceImpl 이라는 구현 클래스를 제공해야 한다.

이렇게 해서 위의 처리 단계 3번에서 객체를 로딩한 이후에 clearCache() 라는 메소드를 호출함으로써, EntityManager 내부에서 관리하는 객체를 Clear 시키고, 우리가 원하는 동작대로 움직이게 만들었다.

하지만 이건 정답은 아닌 것 같다. 제대로 할려면 AspectJ를 좀 더 정교하게 만드는 방법일 것 같다. 그러나 결론적으로 시간이 부족하다는 핑계로 기술 부채를 하나 더 만들었다.

 

참고자료

  • https://www.javabullets.com/access-entitymanager-spring-data-jpa/
  • https://www.javabullets.com/jpa-entity-lifecycle/

 

– 끝 –

]]> 539
Spring Data JPA를 활용한 DAO를 바꿔보자. /index.php/2016/05/08/spring-data-jpa-for-short-memories/ Sat, 07 May 2016 16:21:37 +0000 /?p=130

Continue reading ‘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를 만들어둬야 한다.  일일히 하기에 귀찮다. 를 사용하면 각 필드들에 @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도 흔하게 사용한다. 아마도 처음 접하는 예제의 종류가 틀려서 그런가 싶기도 하지만… . 간단히 요약하자면

  • 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로 변경해버려서 테이블을 찾지 못한다고 이야기하는 경우가 있다.  이때는 해주면 된다.

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

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

]]> 130