JPA
- 레거시 DB+JPA 적용기(신림프로그래머 2017) 2017.12.17
- 레거시DB JPA 예: 복합키를 사용하는 테이블의 밸류 콜렉션 2017.10.13
- JPA EntityListener를 이용한 char 타입 값 공백 제거 2017.07.21
- 모델링 연습 리뷰 자료 2014.11.21
- JCO 컨퍼런스 DDD 발표 자료 2013.02.19 (8)
- TDD 연습 9, DB 연동 리포지토리 테스트, 빌더 패턴, SRP, Spring Data, 다형 2012.11.15 (6)
- Hibernate 3.6에서 JPA의 @EmbeddedId 사용시 오라클 count 관련 오류 2012.07.12 (3)
- ORM 상속으로의 모델 변경에 따른 수정 경험담 2012.06.25
- Spring Data JPA의 Specifcation을 이용한 검색 조건 조합의 편리함 2012.03.16 (8)
- DDD 쌩기초 세미나 발표 자료 2012.01.18
레거시 DB+JPA 적용기(신림프로그래머 2017)
레거시DB JPA 예: 복합키를 사용하는 테이블의 밸류 콜렉션
최근 진행하는 프로젝트는 DB 관련 기술로 JPA를 사용하고 있는데, 연동 대상은 레거시 DB이다. 레기서 DB답게 복합키를 갖는 테이블이 다수 존재한다. 아래는 한 예이다.
이 테이블은 특정 업무의 점검 결과를 저장할 때 사용하는데(간결함을 위해 이름을 변경하고 칼럼수도 줄였다), CHECK_H는 점검 결과에 대한 마스터 테이블이고, CHECK_D는 각 세부 점검 항목의 결과를 저장하는 테이블이다. CHECK_H와 CHECK_D는 1:N의 관계를 갖는다.
그림에서 보는 것처럼 CHECK_H의 PK는 네 개의 칼럼을 구성되어 있으며, CHECK_D는 이 네 칼럼을 참조한다.
검사 항목은 20여개 정도 되는데, 각 항목을 그룹으로 나눠서 표현한다. 각 항목이 속한 그룹을 저장하는 칼럼이 GROUP이고 검사 항목을 저장하는 칼럼이 ITEM이며, 그 결과를 저장한 칼럼이 RSLT이다. 화면에 결과를 표시할 때에는 GROUP과 ITEM을 오름차순으로 정렬해서 출력한다.
CHECK_H와 CHECK_D는 개념적으로 하나의 검사 결과를 의미하므로, 이 둘과 매핑되는 모델은 하나의 애그리거트에 포함된다. 매핑할 애그리거트를 다음과 같이 만들었다.
CHECK_D는 별도 라이프사이클이 없고 CHECK_H에 종속되어 있으므로 CHECK_D에 매핑되는 Detail을 밸류로 표현했고, Check를 애그리거트의 루트로 했다. 복합키를 사용하므로 복합키에 해당하는 식별자 클래스인 CheckId도 따로 만들었다.
Detail 클래스와 CHECK_D 테이블의 매핑 설정
Detail은 밸류이므로 CHECK_D의 주요키와 매핑할 필요가 없으므로 밸류가 가져야 할 속성만 정의했다.
@Embeddable
public class Detail {
private String group;
private String item;
private String rslt;
...
}
Check 클래스와 CHECK_H 테이블의 매핑 설정
복합키를 위한 CheckId는 다음과 같다.
@Embeddable
public class CheckId implements Serializable {
@Column(name = "JOIN_NUM")
private String joinNum;
@Column(name = "PATH_FLAG")
private String pathFlag;
@Column(name = "JOIN_YMD")
private String joinYmd;
@Column(name = "RSLT_FLAG")
private String rsltFlag;
}
다음은 Check 클래스 설정이다.
@Entity
@Table(name = "CHECK_H")
public class Check {
@EmbeddedId
private CheckId id;
@Column(name = "FROM_TIME")
private String fromTime;
@Column(name = "TO_TIME")
private String toTime;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "CHECK_D", joinColumns = {
@JoinColumn(name = "JOIN_NUM", referencedColumnName = "JOIN_NUM"),
@JoinColumn(name = "PATH_FLAG", referencedColumnName = "PATH_FLAG"),
@JoinColumn(name = "JOIN_YMD", referencedColumnName = "JOIN_YMD"),
@JoinColumn(name = "RSLT_FLAG", referencedColumnName = "RSLT_FLAG") })
@org.hibernate.annotations.OrderBy(clause = "GROUP asc, ITEM asc")
private Set<CheckDetail> details = new LinkedHashSet<>();
@CollectionTable의 joinColumns 속성을 사용해서 CHECK_D에서 CHECK_H를 참조할 때 사용하는 조인 칼럼을 지정했다. Check가 필요한 기능에서 CheckDetail도 함께 사용하기에 @ElementCollection의 fetch 속성을 EAGER로 설정했다. 화면에서 GROUP과 ITEM을 오름차순 기준으로 정렬해서 보여주기 때문에, 하이버네이트의 @OrderBy 애노테이션을 사용해서 값을 정렬했다.
JPA EntityListener를 이용한 char 타입 값 공백 제거
현재 참여하고 있는 프로젝트는 레거시 시스템과 관련이 있다. CHAR 타입 칼럼의 기본값으로 공백문자(' ')를 지정한 테이블이 많아서 기존 코드를 보면 TRIM 처리를 하는 쿼리가 많이 있다. 현재 프로젝트는 JPA를 사용하고 있는데, 조회 결과에서 불필요한 공백을 제거할 필요가 생겼다. 이를 위해 EntityListener와 커스텀 애노테이션을 사용했다.
@Trim 커스텀 애노테이션
DB에서 읽어온 값을 trim해야 하는 대상을 설정하기 위해 @Trim 애노테이션을 추가했다.
@Target({ ElementType.FIELD })
@Retention(RUNTIME)
public @interface Trim {
}
JPA 엔티티 리스너(entity listener) 메서드 구현
엔티티 단위로 @Trim 애노테이션이 붙은 필드에 대해 trim 처리를 수행하기 위해 JPA 엔티티 리스너 메서드를 구현했다. 엔티티 리스너 메서드는 크게 별도 클래스로 구현하거나 엔티티 메서드로 구현하는 두 가지 방법이 존재하는데 범용적으로 적용하기 위해 별도 클래스로 구현했다. 구현 클래스는 아래와 같다.
import javax.persistence.PostLoad;
public class TrimEntityListener {
@PostLoad
public void postLoad(Object entity) {
Field[] fields = entity.getClass().getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(Trim.class)) {
field.setAccessible(true);
try {
Object value = field.get(entity);
if (value != null && value instanceof String) {
String s = (String) value;
field.set(entity, s.trim());
}
} catch (Exception ex) {
}
}
}
}
}
@PostLoad 애노테이션은 엔티티를 로딩한 후에 호출할 메서드를 설정한다. @PostLoad 외에도 엔티티 라이프사이클에 맞춘 @PrePersist, @PostPersist, @PreUpdate, @PostUpdate 등의 애노테이션이 존재한다.
postLoad() 메서드의 entity 파라미터를 로딩한 엔티티 객체이다. postLoad() 메서드는 엔티티 객체의 필드 중에서 @Trim 애노테이션이 존재하는 필드를 찾아서 값에 대해 trim 처리를 한다.
엔티티 리스너 클래스 적용
이제 엔티티 리스너 클래스를 사용하도록 설정만 하면 된다. 전체 엔티티를 대상으로 엔티티 리스너 클래스를 적용하고 싶다면 JPA 설정 파일(스프링 부트를 사용한다면 META-INF/orm.xml 파일)의 <entity-listener> 태그에 엔티티 리스너 클래스를 설정하면 된다.
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
http://xmlns.jcp.org/xml/ns/persistence/orm_2_1.xsd"
version="2.1">
<persistence-unit-metadata>
<persistence-unit-defaults>
<entity-listeners>
<entity-listener class="jpa.TrimEntityListener" />
</entity-listeners>
</persistence-unit-defaults>
</persistence-unit-metadata>
</entity-mappings>
엔티티별로 엔티티 리스너 클래스를 적용할 수도 있다. @EntityListener 애노테이션을 사용해서 각 엔티티 클래스에 사용할 엔티티 리스너를 지정하면 된다.
import javax.persistence.EntityListeners;
@Entity
@EntityListeners({TrimEntityListener.class})
public class Division {
@Trim
private String location;
...
}
TrimEntityListener는 @PostLoad 메서드를 정의하고 있으므로, 엔티티를 로딩할 때마다 @PostLoad 메서드를 사용해서 @Trim이 붙은 필드 값을 trim 처리할 수 있다.
모델링 연습 리뷰 자료
신림프로그래머 모임에 발표할 모델링 연습 리뷰 자료입니다.
발표 자료에 나오는 JPA의 AttributeConverter에 대한 내용은 아래 링크에 정리했습니다.
- http://javacan.tistory.com/entry/How-to-use-JPA-21-by-AttributeConverter-for-custom-value-type
JCO 컨퍼런스 DDD 발표 자료
-
오늘 세션 너무 잘 들었습니다~!
예전에 뭣 모르고 JPA 쓸 때 당최 개념이 그려지지가 않았는데,
오늘 큰 배움을 얻고 갑니다~ :D
-
알려주세요 2013.02.24 07:02
DDD(Domain Driven Development)관련 자료를 찾고있었는데 자료 감사합니다. Domain Driven Design로 개발하는걸 Domain Driven Development라고 하는건가요? 제가 잘몰라서 ㅜㅜ 알려주세요
-
최범균 madvirus 2013.02.25 09:29 신고
도메인을 중심으로 설계하는 것을 Domain Driven Design 이라고 합니다. DDD는 domain driven design의 약자입니다.
-
-
-
TDD 연습 9, DB 연동 리포지토리 테스트, 빌더 패턴, SRP, Spring Data, 다형
지금까지 각각의 구현들을 만들어나갔다. 그러면서 채워진 도메인 영영역은 아래와 같다. 아래 그림은 DestinationStorage과 DestinationStorageFactory에 대한 구현 클래스를 포함하고 있는데, MediaSourceFile과 ResultCallback도 동일하게 구현체를 일부 구현하였다. 아래 그림에서는 공간 제약 때문에 표시하지 않았다.
현재까지 영속성에 대한 것 없이 위 내용을 구현했다. Job의 transcode() 기능을 구현했고, JobRepository와 Job을 이용해서 AddJobService, TranscodingService 등을 구현했다.
안정적으로 Job의 상태를 보관하기 위해서 JobRepository DB 구현체를 만들어보자. DB 구현체를 테스트 하려면 DB 연동이 필요하다. DB를 따로 설치하고 준비하면 최초 개발에 시간이 걸리니 일단 메모리 DB인 HSQL을 사용해서 테스트를 진행해보기로 하자.
JpaJobRepository 구현 테스트 추가
JPA를 이용한 JobRepository를 구현할 것이다. JpaJobRepository 클래스를 테스트 하려면 사실상 DB 연동 등 많은 부분이 필요하기 때문에 스프링이 제공하는 테스트 지원 기능을 사용할 것이다. 데이터를 조회하는 기능으로부터 시작할 것이다.
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ApplicationContextConfig.class })
public class JpaJobRepositoryIntTest {
@Autowired
private JobRepository jobRepository; // 스프링 설정에 JpaJobRepository로 등록
@Test
public void findById() {
Job job = jobRepository.findById(1L);
assertNotNull(job);
assertTrue(job.isWaiting());
assertEquals(2, job.getOutputFormats().size());
}
}
도메인의 데이터 구조 표현을 위한 JobData 출현
DB와의 연동 부분은 JPA를 이용해서 처리할 것이다. 그런데, JPA를 Job 도메인 모델에 그대로 적용하기에는 한 가지 제약이 있다. 그것은 바로 JPA가 @Embeddable에 대한 상속을 지원하지 않는다는 점이다. 예를 들어, DestinationStorage는 Job에 포함되는 @Embeddable 객체이며 별도 @Entity 객체는 아니다. 그리고, DestinationStorage는 여러 하위 타입을 갖는다. 따라서 Job과 함게 DestinationStorage를 JPA를 이용해서 처리하려면 @Embeddable 객체인 DestinationStorage의 상속 관계를 설정할 수 있어야 하는데, 현재 JPA API는 이를 지원하지 않는 걸로 알고 있다. (JPA 구현체 중 TopLink와 같은 건 지원하는 걸로 알고 있지만 이는 벤더에 특화된 기능이고 표준은 아닌 듯 하다.)
특정 JPA 구현체에 의존한 코드를 만들 수도 있지만, 특정 구현체에 의존하기는 싫다. 그러면서도 Job 및 (추상화 된) 관련 객체들을 데이터 구조(테이블)에 저장할 수 있어야 한다. 그래서 선택한 방법은 다소 수고스럽더라도 도메인 객체와 DB 사이에 징검다리 역할을 해 줄 데이터 모델을 만드는 것이다.
데이터 모델은 Job 객체를 다시 복원할 수 있을 만큼의 정보를 가져야 하기에, 위 그림 상에 출현한 모든 데이터를 갖도록 구현했다.
@Entity
@Table(name = "JOB")
public class JobData {
@Id
@Column(name = "JOB_ID")
@TableGenerator(name = "JOB_ID_GEN", table = "ID_GENERATOR",
pkColumnName = "ENTITY_NAME", pkColumnValue = "JOB", valueColumnName = "ID_VALUE")
@GeneratedValue(strategy = GenerationType.TABLE, generator = "JOB_ID_GEN")
private Long id;
@Column(name = "STATE")
@Enumerated(EnumType.STRING)
private Job.State state;
@Column(name = "SOURCE_URL")
private String sourceUrl;
@Column(name = "DESTINATION_URL")
private String destinationUrl;
@Column(name = "CALLBACK_URL")
private String callbackUrl;
@Column(name = "EXCEPTION_MESSAGE")
private String exceptionMessage;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "JOB_OUTPUTFORMAT",
joinColumns = { @JoinColumn(name = "JOB_ID") })
@OrderColumn(name = "LIST_IDX")
private List<OutputFormat> outputFormats;
... // getter
OutputFormat은 그 자체가 데이터이므로 OutputFormat에도 JPA 연동 정보를 추가하였다. (음, OutputFormat은 도메인 소속인데 JPA 정보가 스며들어갔다. 일단, 지금은 뭔가 동작하게 만들고 그 다음에 정리해보자.)
@Embeddable
public class OutputFormat {
@Column(name = "WIDTH")
private int width;
@Column(name = "HEIGHT")
private int height;
@Column(name = "BITRATE")
private int bitrate;
@Column(name = "CONTAINER")
@Enumerated(EnumType.STRING)
private Container container;
@Column(name = "VIDEO_CODEC")
@Enumerated(EnumType.STRING)
private VideoCodec videoCodec;
@Column(name = "AUDIO_CODEC")
@Enumerated(EnumType.STRING)
private AudioCodec audioCodec;
...
HSQL DB 사용
Job을 저장하기 위해 사용되는 데이터 모델인 JobData 및 OutputFormat에 대한 JPA 설정을 완료했다. 이제 테스트를 위한 DB를 준비할 차례이다. 일단 지금은 메모리 DB인 HSQL DB를 사용해서 테스트 주기를 빠르게 유지하는 게 중요해 보인다. HSQL DB에 맞는 테이블 생성 쿼리는 아래와 같다.
create table ID_GENERATOR (
ENTITY_NAME varchar(50),
ID_VALUE int,
primary key (ENTITY_NAME)
);
create table JOB (
JOB_ID INT IDENTITY,
STATE varchar(20),
SOURCE_URL varchar(100),
DESTINATION_URL varchar(100),
CALLBACK_URL varchar(100),
EXCEPTION_MESSAGE varchar(255),
primary key (JOB_ID)
);
create table JOB_OUTPUTFORMAT (
JOB_ID INT,
LIST_IDX INT,
WIDTH INT,
HEIGHT INT,
BITRATE INT,
CONTAINER varchar(20),
VIDEO_CODEC varchar(20),
AUDIO_CODEC varchar(20)
);
create INDEX JOB_OUTPUTFORMAT_IDX ON JOB_OUTPUTFORMAT (JOB_ID, LIST_IDX);
또한, 테스트를 진행하려면 테이블에 데이터가 포함되어 있어야 한다. 테스트에 사용할 데이터를 추가해주는 쿼리는 다음과 같다.
insert into JOB values (1, 'WAITING', 'file://source.avi', 'file://dest', 'http://calback', null);
insert into JOB_OUTPUTFORMAT values (1, 0, 10, 20, 30, 'MP4', 'H264', 'AAC');
insert into JOB_OUTPUTFORMAT values (1, 1, 100, 200, 300, 'AVI', 'MPEG4', 'MP3');
insert into ID_GENERATOR values ('JOB', 10);
테스트를 실행하기 위한 스프링 설정
- DataSource 설정
- JPA 관련 설정
- 리포지토리 설정
- 리포지토리가 의존하는 다른 빈에 대한 설정
- JobData#sourceUrl 로부터 MediaSourceFile 객체 생성
- JobData#destinationUrl 로부터 DestinationStorage 객체 생성
- JobData#callbackUrl 로부터 ResultCallback 객체 생성
- JobData를 생성하는데 필요한 모든 정보를 제공해주기 위해 Job에 get 메서드 추가. 즉 getMediaSourceUrl(), getResultCallbackUrl() 등의 메서드를 Job 에 추가.
- Job의 데이터를 익스포트 해주는 빌더 사용
- Job이 데이터 추출 과정을 제어한다.
- Job이 데이터를 제공하므로 get 메서드를 최소화할 수 있다.
JpaJobRepository의 책임 분리: SRP
JpaJobRepository는 다음의 두 가지 책임을 갖고 있다.
- Job과 JobData 사이의 변환 실행
- JobData와 DB 사이의 매핑 처리
- DbJobRepository: DB를 이용한 JobRepository 구현
- JobDataDao: JobData에 대한 DAO. 구현은 JPA를 이용해서 구현
위 그림에서 job 도메인의 어떤 타입도 persistence 영역에 대한 의존을 갖지 않는다. (아니다, 정확하게는 OutputFormat이 JPA 애노테이션을 사용하니까 의존이 있긴 하지만, 설정 파일을 사용하면 제거 가능하므로 의존을 갖지 않는다고 표현해도 될 것 같다.) 따라서, persistence의 새로운 구현이 필요하더라도 job 도메인은 영향을 받지 않는다.
-
나그네 2014.03.04 10:53
범균님 덕분에 TDD9까지 집에서 테스트 해봤습니다.
너무 좋은 자료 감사합니다. 한 10일은 걸린거 같아요 뛰엄뛰엄 하느냐구요
첫술에 배부를수는 없지만 반복해서 다시 해보려구요 생각보다 쉽지 않아서요^^
여러번 해보는 방법밖에는 없겠죠?? -
Hibernate 3.6에서 JPA의 @EmbeddedId 사용시 오라클 count 관련 오류
JPA를 이용해서 구현하는 프로젝트에서 두 개의 프로퍼티를 묶어서 식별자로 사용해야 하는 경우가 생겼다. 그래서, @EmbeddedId를 이용해서 ID 필드를 지정하였다. 이 프로젝트는 Spring Data JPA를 이용해서 Repository를 자동생성하고 있는데, @EmbeddedId 부분에서 문제가 발생했다. 아래 코드는 문제를 발생시킨 리포지토리의 인터페이스이다.
public interface FollowRepository extends Repository<Follow, FollowId> {
Page<Follow> findAll(Specification<Follow> spec, Pageable pageable);
...
}
Follow의 식별자의 타입은 FollowId 클래스이고, FollowId 클래스는 두 개의 프로퍼티를 갖고 있다. 그런데, 위 코드에서 findAll()을 실행하는 순간에 다음과 같은 쿼리가 실행되면서 문제가 발생했다.
select count((follow0_.FOLLOWING_ID, follow0_.USER_ID)) as col_0_0_
from FOLLOW follow0_
where ....
DBMS로 오라클을 사용하고 있는데, 위 count() 부분에서 쿼리 오류가 발생한 것이다. 처음엔 Spring Data JPA 문제일까 해서 커스텀 구현을 넣어 보았다.
public class FollowRepositoryImpl implements FollowRepositoryCustom {
@PersistenceContext
private EntityManager entityManager;
@Override
public Page<Follow> findAll(Specification<Follow> spec, Pageable pageable) {
List<Follow> result = getResultList(spec, pageable);
long total = count(spec);
return new PageImpl<Follow>(result, pageable, total);
}
private List<Follow> getResultList(Specification<Follow> spec,
Pageable pageable) {
...
return query.getResultList();
}
private long count(Specification<Follow> spec) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Long> c = cb.createQuery(Long.class);
Root<Follow> root = c.from(Follow.class);
c.select(cb.count(root));
Predicate predicate = spec.toPredicate(root, c, cb);
c.where(predicate);
TypedQuery<Long> query = entityManager.createQuery(c);
return query.getSingleResult();
}
}
하지만, 결과는 동일했다. JPA API를 직접 사용해도 변화가 없는 걸 봐서는 Spring Data JPA의 문제는 아니였다. Spring Data JPA 1.1 버전을 사용하고 있었는데, 이 버전은 하이버네이트 3.6.9를 기준으로 하고 있어서 해당 버전의 하이버네이트를 사용했었다.
하이버네이트의 버전 문제가 아닐까해서 범위를 좀 더 좁혀서 구글링을 해 봤더니 하이버네이트에서 이런 문제가 발생하고 있는 듯 했다. (관련 문의가 포럼에 있었다.) 좀 더 파 볼까 하다가, 하이버네이트 버전을 올려보기로 결심했다. 하이버네이트 버전을 3.6.9에서 4.1.4로 올렸다.
결과는? 야호! 테스트케이스의 녹색바! 에러가 없어졌다. 처음부터 하이버네이트 버전 업부터 할 걸, 괜히 코드 작성했다. 오픈소스에서 뭔가 이상한 문제가 있으면 먼저 버전을 올려보라는 걸 다시 한 번 확인했다.
ORM 상속으로의 모델 변경에 따른 수정 경험담
ORM!! 오늘 한번 더 ORM을 추종할 수 밖에 없는 일이 벌어졌다. 경험한 내용이 흥분도 되고 ORM의 좋음을 공유하고자 이렇게 글을 남긴다.
최초의 설계: 별도 클래스, 별도 테이블
현재 진행하는 프로젝트에서 컨텐츠에 대한 모델을 설계할 때 컨텐츠 종류마다 별도 테이블과 별도 클래스로 구성하도록 설계를 진행했었다. 각 컨텐츠들이 비슷한 데이터를 갖고 있었지만, 절반은 다른 데이터를 갖고 있었다. 또한, 컨텐츠 타입은 두 개였고 컨텐츠마다 사용되는 영역이 달랐기 때문에, 상위 클래스에 공통 정보를 두지 않고 서로 계층 관계에 묶이지 않는 별도 클래스로 구성하였다. 물론, 테이블도 별도로 구성하였다.
변화의 압박: 서로 다른 컨텐츠 종류에 공통으로 적용되는 기능들
최초 개발하는 동안에는 크게 문제될 것이 없었다. 그런데, PoC 프로젝트를 거의 마무리하는 과정에서 1.0 버전의 기획으로 다음의 기능들이 추가되었다.
- 모든 컨텐츠에 대해 댓글 달기
- 모든 컨텐츠에 대해 좋아요 하기
- 모든 컨텐츠에 대해 즐겨찾기 하기
- 모든 컨텐츠에 연관 정보 넣기
- 새로운 종류의 컨텐츠 타입 추가 및 새로운 타입 컨텐츠에도 댓글/좋아요/연관 정보 넣기
- 각 컨텐츠 타입마다 댓글을 위한 테이블을 구분해서 만들고, 각 컨텐츠 타입별로 댓글 관련 인터페이스 묶음 구현하기.
- 댓글 테이블에 컨텐츠 타입 보관 위한 컬럼을 추가하고, 한 묶음의 인터페이스로 각 컨텐츠 타입을 위한 댓글 기능 구현하기.
구현이야 할 수 있겠지만, 둘 다 딱히 마음에 안 들었는다. 첫 번째 방법은 완전히 동일한 데이터 구조, 구현 코드, 테이블이 중복된다는 점이 불만이었다.
두 번째 방법은 하나의 신규 테이블에서 서로 다른 테이블에 대한 참조를 가져야 할 수도 있다는 것이 거슬렸다. 예를 들어, 두 컨텐츠에 동일하게 추가되는 정보인 경우, 두 컨텐츠에 대한 외부키를 각각 갖고 있어야 했다.
또한, 두 방법 모두 2.0 버전에 새로운 컨텐츠 타입이 추가되거나 각 컨텐츠 타입에 공통으로 적용되는 기능이 추가되면 같은 짓을 반복해야 하는 동일한 문제점을 갖고 있었다.
그래서, 뭐가 문제일까 하고 잠시 고민해 본 결과, 모델링을 잘못 했다는 결론에 다다랐다. PoC 시점에서는 그리 잘못 되지 않았지만, 몇 달 후에 신규 기능들이 추가되면서 최초에 만들었던 모델로는 깔끔하게 처리할 수 없게 된 것이다. 이 모든 문제를 깔끔하게 해결하는 방법은 모델을 다시 정리하는 것 뿐이었다.
상속으로 풀기로 결심!
이 모든 문제의 근원은 추상화의 변경에서 비롯되었다. 최초에는 두 개의 컨텐츠를 논리적인 하나의 컨텐츠로 바라보지 않았다. 그런데, 댓글, 연관 정보, 좋아요 기능이 생기면서 개별 컨텐츠 타입이 아닌 '컨텐츠'라는 개념이 도출된 것이다. 따라서, 컨텐츠라는 새로운 상위 개념을 도출했고, 이 상위 개념에 대해서 '좋아요', '댓글' 등의 기능을 적용하기로 하였다. 새로운 모델은 다음과 같이 변경되었다.
컨텐츠라는 단일 개념을 표현하기 위해 Content를 출현시켰고, 댓글과 좋아요 등의 기능은 새롭게 추가한 Content에 대고 구현을 하도록 했다. 각 개별 타입들의 변이는 Content의 하위 타입으로 처리하였다. 그리고, 각 컨텐츠 타입마다 별도의 테이블을 가졌던 것에서 모든 컨텐츠 타입을 한 개의 테이블에 저장하기로 결정했다.
변경의 여파는?
모델을 새롭게 정의했으니, 이제 기존 코드에 변경할 차례이다. 시간은 대략 얼마나 걸렸을까? 주요 기능의 정상적인 동작을 확인하는 데 까지 40분 정도 (밖에 안) 걸렸다. 물론, 개발 과정이기 때문에 기존 데이터의 마이그레이션이나 변경 등의 이슈는 없었고, 단지, 테스트를 위해 마련해 둔 데이터를 변경하는 정도의 데이터 수정 작업이 있었다. 하지만, 그렇다고 하더라도 모델을 변경하고 테이블을 합친 것에 비하면 이는 정말 짧은 시간이다.
그렇다면 어떻게 이것이 가능했을까? 이는 ORM을 사용했기 때문이다. 실제로 모델을 변경하기 위해 한 작업은 다음과 같다.
- 상위 Content 클래스를 추가하고 각 하위 타입에 대해 공통인 필드와 관련 메서드를 추가
- 각 하위 Content 타입 클래스에서 상위 클래스로 옮겨간 필드 및 관련 메서드 제거
- Content 및 하위 클래스에 대한 ORM 설정
- 테스트 데이터 구성
- 기능 테스트 (약 40% 진행)
Spring Data JPA의 Specifcation을 이용한 검색 조건 조합의 편리함
Specification을 사용하는 방법은 아주 간단한데, 그 방법은 다음과 같다.
- Specification을 입력 받도록 Repository 인터페이스를 정의하기
- 검색 조건을 모아 놓은 클래스 만들기
- 검색 조건을 조합한 Specification 인스턴스를 이용해서 검색하기
1. Specification을 입력 받는 Repository 메서드 정의
Spring Data에서 Specification은 검색 조건을 추상화하기 위해 사용되는데, 이 추상화된 검색 조건을 이용해서 데이터를 검색하도록 구현하려면 다음과 같이 Specification을 검색 메서드의 파라미터로 추가해주기만 하면 된다.
public interface ContentRepository extends Repository<Content, Long> {
Page<Content> findAll(Specification<Content> spec, Pageable pageable);
}
2. 검색 조건 모아 놓은 클래스 만들기
다음에 할 일은 검색 조건을 모아 놓은 클래스를 하나 만드는 것이다. 이 클래스를 구현할 때 JPA의 Criteria API를 사용하게 된다. 아래는 구현 예이다.
public class ContentSpecs {
public static Specification<Content> titleLike(final String keyword) {
return new Specification<Content>() {
@Override
public Predicate toPredicate(Root<Content> root,
CriteriaQuery<?> query, CriteriaBuilder cb) {
return cb.like(root.get(Content_.title), "%" + keyword + "%");
}
};
}
public static Specification<Content> category(final Category category) {
return new Specification<Content>() {
@Override
public Predicate toPredicate(Root<Content> root,
CriteriaQuery<?> query, CriteriaBuilder cb) {
return cb.equal(root.get(Content_.category), category);
}
};
}
... // 기타 검색 조건 생성 클래스
}
위에서 눈여겨 볼 점은 Criteria를 이용해서 검색 조건을 지정하는 코드가 category(), titleLike()와 같은 메서드로 추상화된다는 점이다.
3. 검색 조건 조합해서 검색하기
이제 할 일은 앞서 만든 검색 조건을 모아 놓은 클래스를 이용해서 검색 조건을 조합하고 Repository를 이용해서 결과를 가져오는 것이다. 아래는 예시 코드이다.
Specifications<Content> spec = Specifications.where(ContentSpecs.titleLike(query));
if (category != null) {
spec = spec.and(ContentSpecs.category(category));
}
Page<Content> p = contentRepository.findAll(spec); // david hong 님의 지적으로 수정합니다.
Spring Data JPA가 제공하는 Specifications를 사용하면 두 개 이상의 Specification을 AND나 OR 등으로 조합할 수 있다.
왜 좋지?
이 방식이 마음에 드는 이유는 검색 조건을 생성할 때 도메인의 용어를 사용할 수 있게 된다는 점이다. 앞서 만들었던 ContentSpecs.titleLike()만 보더라도 이 검색 조건이 제목을 LIKE 검색하기 위한 조건이라는 것을 알 수 있다.
마음에 드는 또 다른 이유는 검색 조건을 조합하는 코드에서 Criteria와 같은 실제 구현 기술의 타입을 사용하지 않는다는 점이다. 사용자는 ContentSpecs.category() 메서드를 사용해서 검색 조건을 추상화한 Specification 객체를 구하기만 하면 된다. 내부적으로 JPA의 Criteria를 사용하는지의 여부는 전혀 알 필요가 없다.
마지막으로 이 방식은 누구나 쉽게 만들 수 있을 만큼 쉽다. 처음 이 방식을 접하는 경력자 뿐만 아니라 이제 3개월 된 신입들도 별다른 어려움 없이 위 방식에 적응했다.
불필요한 코드 작성을 줄이고 그러면서도 이전보다 쉽게 DB 연동 부분을 처리하고 싶은 개발자분들에게 JPA + Spring Data JPA/Specification의 조합을 아주 강력하게 권장한다.
-
david hong 2013.03.15 16:53
spec 설정과 repository 의 findAll 호출간에 relation 이 없는데 findAll(spec,pageRequest) 와 같은 형태로 전달해야 하지 않나요?
-
최범균 madvirus 2013.03.17 20:38 신고
글에 오류가 있었네요. findAll(pageRequest)가 아니라 findAll(spec) 이었습니다. 지적하신 부분 수정해 놓았습니다. 잘못된 부분 알려주셔거 고맙습니다.
-
-
ncrash 2013.12.06 17:10
좋은 잘 보고 있습니다 ^^
저의 개발 환경에서는 Hibernate Metamodel generator를 사용하지 않고 개발했던 터라
Content_ 처럼 자동생성 되는 클래스 없어서 해매다 답은 찾아서 적용하고 있습니다
http://stackoverflow.com/questions/8634241/how-to-make-a-custom-search-by-spring-data-jpa-framework/8634316#8634316
헌데 Specifications를 통해 적용하는데 한가지 문제가 있어 질문 드립니다.
where에 대한 조건은 적용이 잘 됩니다.
헌데 and조건 부터는 먹지를 않네요.. 하이버네이트 버전 문제인가 싶어 버전을 바꿔봐도 동일하구요.
Specifications<TransactionLog> spec = Specifications.where(TransactionLogSpecs.startedDateBetween(dt.minusDays(1).toDate(), dt.toDate()));
spec.and(TransactionLogSpecs.AppIdLike("asdf"));-
최범균 madvirus 2013.12.06 18:16 신고
and()를 호출해줄 때 마다 새로운 객체가 생성됩니다. (기존 정보가 변경되는 게 아니죠.) 그래서 아래와 같이 and() 호출해 줄 때 마다 할당을 새로 해 주어야 합니다.
spec = spec.and(....);
spec = spec.and(....);
-
-
ncrash 2013.12.08 11:15
답변을 엄청 빨리 달아주셨는데 이제서야 확인했네요
답변 감사 드립니다 ^^
전 StringBuilder.appned() 메소드와 동일한 형태로 처리되는 줄 알았는데 헛다리를 ㅠ.ㅠ
그렇다면 위 포스팅 내용도 아래와 같이 변경되야 하는거 아닌지 궁금하네요
if (category != null) {
spec = spec.and(ContentSpecs.category(category));
}
DDD 쌩기초 세미나 발표 자료
세미나 관련 정보는 http://cafe.daum.net/javacan/9Voo/88 에서 확인할 수 있습니다.