저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의
Posted by 최범균 madvirus

댓글을 달아 주세요

최근 진행하는 프로젝트는 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 애노테이션을 사용해서 값을 정렬했다.



Posted by 최범균 madvirus

댓글을 달아 주세요

현재 참여하고 있는 프로젝트는 레거시 시스템과 관련이 있다. 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 처리할 수 있다.

Posted by 최범균 madvirus

댓글을 달아 주세요

신림프로그래머 모임에 발표할 모델링 연습 리뷰 자료입니다.



발표 자료에 나오는 JPA의 AttributeConverter에 대한 내용은 아래 링크에 정리했습니다.

  • http://javacan.tistory.com/entry/How-to-use-JPA-21-by-AttributeConverter-for-custom-value-type


Posted by 최범균 madvirus

댓글을 달아 주세요

발표 자료 첨부합니다.


DDD구현기초@JCOConf13_2p.pdf




Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 마음이 뛰다 2013.02.23 21:20 신고  댓글주소  수정/삭제  댓글쓰기

    오늘 세션 너무 잘 들었습니다~!
    예전에 뭣 모르고 JPA 쓸 때 당최 개념이 그려지지가 않았는데,
    오늘 큰 배움을 얻고 갑니다~ :D

  2. 알려주세요 2013.02.24 07:02 신고  댓글주소  수정/삭제  댓글쓰기

    DDD(Domain Driven Development)관련 자료를 찾고있었는데 자료 감사합니다. Domain Driven Design로 개발하는걸 Domain Driven Development라고 하는건가요? 제가 잘몰라서 ㅜㅜ 알려주세요

  3. devSejong 2013.02.25 16:24 신고  댓글주소  수정/삭제  댓글쓰기

    JCO 재미있게 잘 들었습니다. 세미나 자주자주 해주세요.^^

  4. 일퍼센트 2013.03.07 16:05 신고  댓글주소  수정/삭제  댓글쓰기

    자료 잘 보고 갑니다.~

지금까지 각각의 구현들을 만들어나갔다. 그러면서 채워진 도메인 영영역은 아래와 같다. 아래 그림은 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 관련 설정
  • 리포지토리 설정
  • 리포지토리가 의존하는 다른 빈에 대한 설정
HSQL DB 임베딩 설정
DataSource는 스프링이 제공하는 Embedded DB 지원 기능을 사용할 것이다. 설정은 아래와 같다.

<?xml version="1.0" encoding="UTF-8"?>

<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:jdbc="http://www.springframework.org/schema/jdbc"
    xsi:schemaLocation="http://www.springframework.org/schema/beans   
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/jdbc
       http://www.springframework.org/schema/jdbc/spring-jdbc.xsd">

    <jdbc:embedded-database id="dataSource" type="HSQL">
        <jdbc:script location="classpath:schema.sql" />
        <jdbc:script location="classpath:testdata.sql" />
    </jdbc:embedded-database>

</beans>

위 설정에서 schema.sql과 testdata.sql은 앞에서 살펴봤던 테이블 생성 쿼리와 데이터 추가 쿼리를 포함하고 있다.

JPA 관련 설정
JPA는 @Configuration을 이용해서 설정했다.

@Configuration
public class JpaConfig {

    @Autowired
    private DataSource dataSource;

    @Bean
    public PersistenceExceptionTranslationPostProcessor persistenceExceptionTranslationPostProcessor() {
        return new PersistenceExceptionTranslationPostProcessor();
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory()
            throws PropertyVetoException {
        LocalContainerEntityManagerFactoryBean factoryBean =
                new LocalContainerEntityManagerFactoryBean();
        factoryBean.setPersistenceUnitName("s4t");
        factoryBean.setDataSource(dataSource);
        factoryBean.setJpaVendorAdapter(jpaVendorAdapter());
        return factoryBean;
    }

    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setDatabase(Database.HSQL);
        return hibernateJpaVendorAdapter;
    }

    @Bean
    public PlatformTransactionManager transactionManager()
            throws PropertyVetoException {
        JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
        jpaTransactionManager.setEntityManagerFactory(entityManagerFactory()
                .getObject());
        return jpaTransactionManager;
    }
}

리포지토리 설정
리포지토리 설정은 아래와 같다.

@Configuration
public class RepositoryConfig {

    @Bean
    public JobRepository jobRepository() {
        return new JpaJobRepository();
    }
}

설정 모으기
위 설정들을 한 파일만 참조하면 사용할 수 있도록 하기 위해 아래와 같이 별도 설정 클래스를 만들었다. 또한, 이 설정 클래스는 @Transactional 지원을 위해 @EnableTransactionManagement을 추가하였다.

@Configuration
@Import({ RepositoryConfig.class, JpaConfig.class })
@ImportResource("classpath:spring/datasource.xml")
@EnableTransactionManagement
public class ApplicationContextConfig {

}

JpaJobRepository#findById 구현 시작

이제 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());
    }
}

아직 JpaJobRepository에 구현이 없으니 당연히 실패다. JpaJobRepository의 코드를 일부 구현했다.

@Repository
public class JpaJobRepository implements JobRepository {

    @PersistenceContext
    private EntityManager entityManager;
    
    @Transactional
    @Override
    public Job findById(Long jobId) {
        JobData jobData = entityManager.find(JobData.class, jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return null; // JobData -> Job
    }

위 코드에서 핵심은 JobData으로부터 Job 객체를 복원하는 것이다. 이를 위해서 우리는 다음의 작업을 해야 한다.
  • JobData#sourceUrl 로부터 MediaSourceFile 객체 생성
  • JobData#destinationUrl 로부터 DestinationStorage 객체 생성
  • JobData#callbackUrl 로부터 ResultCallback 객체 생성
위 작업을 하려면 각 객체 타입별 Factory가 필요하다. 이를 위해, JpaJobRepository에 각 종류별 Factory를 추가해주고 이를 사용해서 Job 객체를 생성할 수 있도록 했다.

@Repository
public class JpaJobRepository implements JobRepository {

    @PersistenceContext
    private EntityManager entityManager;

    private MediaSourceFileFactory mediaSourceFileFactory;
    private DestinationStorageFactory destinationStorageFactory;
    private ResultCallbackFactory resultCallbackFactory;

    public JpaJobRepository(MediaSourceFileFactory mediaSourceFileFactory,
            DestinationStorageFactory destinationStorageFactory,
            ResultCallbackFactory resultCallbackFactory) {
        this.mediaSourceFileFactory = mediaSourceFileFactory;
        this.destinationStorageFactory = destinationStorageFactory;
        this.resultCallbackFactory = resultCallbackFactory;
    }

    @Transactional
    @Override
    public Job findById(Long jobId) {
        JobData jobData = entityManager.find(JobData.class, jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new Job(jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage());
    }

JpaJobRepository의 생성자가 변경되었으니, 이와 관련된 스프링 설정인 RepositoryConfig도 변경해 주어야 한다.

@Configuration
public class RepositoryConfig {

    @Autowired
    private MediaSourceFileFactory mediaSourceFileFactory;
    @Autowired
    private DestinationStorageFactory destinationStorageFactory;
    @Autowired
    private ResultCallbackFactory resultCallbackFactory;

    @Bean
    public JobRepository jobRepository() {
        return new JpaJobRepository(mediaSourceFileFactory,
                destinationStorageFactory, resultCallbackFactory);
    }
}

RepositoryConfig에서는 도메인 영역의 팩토리 객체를 필요로 한다. 따라서, 도메인 영역의 팩토리 객체도 스프링 설정에 추가해 주어야 한다. DomainConfig에 이들 팩토리 객체의 설정을 추가하고, ApplicationContextConfig에 반영하자.

@Configuration
public class DomainConfig {

    @Bean
    public ResultCallbackFactory resultCallbackFactory() {
        return new DefaultResultCallbackFactory();
    }

    @Bean
    public DestinationStorageFactory destinationStorageFactory() {
        return new DefaultDestinationStorageFactory();
    }

    @Bean
    public MediaSourceFileFactory mediaSourceFileFactory() {
        return new DefaultMediaSourceFileFactory();
    }
}


@Configuration
@Import({ DomainConfig.class, RepositoryConfig.class, JpaConfig.class })
@ImportResource("classpath:spring/datasource.xml")
@EnableTransactionManagement
public class ApplicationContextConfig {

}


다시 테스트를 실행해보자. 녹색! 통과다.

JpaJobRepository#save 기능 구현

다음으로 구현할 기능은 save() 기능이다. 이 기능을 위해 테스트를 작성하였다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ApplicationContextConfig.class })
public class JpaJobRepositoryIntTest {

    @Autowired
    private JobRepository jobRepository;

    @Test
    public void findById() {
        ...
    }

    @Test
    public void save() {
        List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();
        outputFormats.add(new OutputFormat(60, 40, 150, Container.MP4));

        Job job = new Job(
                new LocalStorageMediaSourceFile("file://./video.avi"),
                new FileDestinationStorage("file://./target"), outputFormats,
                new HttpResultCallback("http://"));
        Job savedJob = jobRepository.save(job);
        assertNotNull(savedJob); // 아직 savedJob은 null
        assertNotNull(savedJob.getId());
        assertJobsEquals(job, savedJob);
    }

    private void assertJobsEquals(Job job, Job savedJob) {
        assertEquals(job.getOutputFormats().size(), savedJob.getOutputFormats()
                .size());
    }
}

아직 save()는 null을 리턴하므로, 위 테스트는 통과하지 못한다. 간단하게 필요한 구현을 넣어봤다.

@Repository
public class JpaJobRepository implements JobRepository {
    ...
    @Transactional
    @Override
    public Job save(Job job) {
        JobData jobData = null; // Job을 JobData로 변환 필요!
        entityManager.persist(jobData);
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new Job(jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage());
    }

}

위에서 해결해야 할 부분은 Job 객체로부터 JobData를 만들어내는 것이다. 구현 방법으로는 다음과 같은 것들이 떠오른다.
  • JobData를 생성하는데 필요한 모든 정보를 제공해주기 위해 Job에 get 메서드 추가. 즉 getMediaSourceUrl(), getResultCallbackUrl() 등의 메서드를 Job 에 추가.
  • Job의 데이터를 익스포트 해주는 빌더 사용
위 방법 중 첫 번째 방법이 쉽지만, 도메인 객체에 get을 최소화하기 위해 두 번째 방법인 빌더 패턴을 사용해보기로 하자.

Job에서 JobData를 생성하기 위해 빌더 패턴 사용하기

현재까지는 Job 객체가 변환되는 타입은 JobData 뿐이지만, 뷰 영역을 구현하게 되면 Job 객체를 뷰에 알맞게 변환해서 제공해 주어야 한다. 물론, 그 변환 타입이 JobData와 동일한 구조를 가질 수도 있지만 뷰에서 JobData를 바로 사용하면 안 된다. 지금의 JobData는 어디까지나 DB 연동을 위해서 필요했던 것이기 때문이다. 영속성 메커니즘이 DB가 아닌 파일이나 단순히 메모리로 바뀐다면 JobData는 더 이상 존재하지 않게 되므로, 뷰는 이 클래스를 사용하면 안 된다.

Job 객체로부터 JobData 객체 또는 뷰를 위한 (아직 미정인) JobView 객체를 생성하는 과정은 매우 유사하다. Job 객체로부터 JobData/JobView 객체를 생성할 때 필요한 일련의 데이터를 차례대로 받고, 그 데이터를 이용해서 각각의 객체를 생성하는 것이다.

이건 딱 빌더 패턴에 들어맞는다. 빌더 패턴으로 한 번 풀어보자.

우선, Job 으로부터 순차적으로 데이터를 받을 수 있는 빌더를 정의하자. 여기서는 Job의 데이터를 어딘가로 내보낸다는 의미에서 Exporter라는 이름을 부여하였다.

    public static interface Exporter<T> { // Job 내부에 정의함
        public void addId(Long id);

        public void addState(Job.State state);

        public void addMediaSource(String url);

        public void addDestinationStorage(String url);

        public void addResultCallback(String url);

        public void addExceptionMessage(String exceptionMessage);

        public void addOutputFormat(List<OutputFormat> outputFormat);
        
        public T build();
    }

Exporter는 Job으로부터 주요 데이터를 받을 수 있는 메서드를 정의하고 있으며, 받은 데이터로부터 새로운 데이터를 만들 수 있는 build() 메서드를 정의하고 있다.

Job은 이제 Exporter를 이용해서 익스포트 과정을 처리할 수 있다.

public class Job {
    ...
    public <T> T export(Exporter<T> exporter) {
        exporter.addId(id);
        exporter.addState(state);
        exporter.addMediaSource(mediaSourceFile.getUrl());
        exporter.addDestinationStorage(destinationStorage.getUrl());
        exporter.addResultCallback(callback.getUrl());
        exporter.addOutputFormat(getOutputFormats());
        exporter.addExceptionMessage(exceptionMessage);
        return exporter.build();
    }
    
    public static interface Exporter<T> {
        public void addId(Long id);
        ...
        public T build();
    }
}

Exporter를 사용함으로써 생기는 이점은 다음과 같다.
  • Job이 데이터 추출 과정을 제어한다.
  • Job이 데이터를 제공하므로 get 메서드를 최소화할 수 있다.
이제 Job의 데이터를 필요로 하는 곳에서는 Exporter를 구현해서 Job 객체에 전달해주기만 하면 된다. 그럼, Job으로부터 필요한 데이터를 받아와 알맞은 객체를 생성할 수 있다.


예를 들어, JobData를 생성해주는 Exporter는 다음과 같이 구현할 수 있다.

@Entity
@Table(name = "JOB")
public class JobData {
    ...
    @Id
    private Long id;
    ...
    public static class ExporterToJobData implements Job.Exporter<JobData> {

        private JobData jobData = new JobData();

        @Override
        public void addId(Long id) {
            jobData.id = id;
        }

        @Override
        public void addState(State state) {
            jobData.state = state;
        }

        @Override
        public void addMediaSource(String url) {
            jobData.sourceUrl = url;
        }

        @Override
        public void addDestinationStorage(String url) {
            jobData.destinationUrl = url;
        }

        @Override
        public void addResultCallback(String url) {
            jobData.callbackUrl = url;
        }

        @Override
        public void addExceptionMessage(String exceptionMessage) {
            jobData.exceptionMessage = exceptionMessage;
        }

        @Override
        public void addOutputFormat(List<OutputFormat> outputFormat) {
            jobData.outputFormats = outputFormat;
        }

        @Override
        public JobData build() {
            return jobData;
        }
    }
}

위 코드에서 ExporterToJobData는 JobData 클래스의 내부 클래스이다. 따라서, ExporterToJobData에서 Job의 필드에 직접 접근해서 데이터를 초기화하고 있다. 이렇게 함으로써 JobData 클래스는 불필요한 set 메서드를 제공하지 않아도 된다.

이제 Job 객체로부터 JobData를 생성하는 부분을 처리했으니, JpaJobRepository의 save() 메서드를 완성해보자.

@Repository
public class JpaJobRepository implements JobRepository {
    ...
    private Job createJobFromJobData(JobData jobData) {
        return new Job(jobData.getId(), ...);
    }

    @Transactional
    @Override
    public Job save(Job job) {
        JobData.ExporterToJobData exporter = new JobData.ExporterToJobData();
        JobData jobData = job.export(exporter);
        entityManager.persist(jobData);
        return createJobFromJobData(jobData);
    }

}

테스트 실행.... 녹색! 통과다.

현재까지 만들어진 결과물의 정적 구조는 다음과 같다.



JpaJobRepository에서 생성한 Job의 기능 확인

JpaJobRepository을 구현했으니 이제 JpaJobRepository로부터 읽어온 Job이 제대로 동작하는지 확인해보자. 이를 위해 다음과 같은 테스트를 작성했다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ApplicationContextConfig.class })
public class JobIntTest {

    @Autowired
    private JobRepository jobRepository;

    private Transcoder transcoder;
    private ThumbnailExtractor thumbnailExtractor;

    @Before
    public void setup() {
        transcoder = mock(Transcoder.class);
        thumbnailExtractor = mock(ThumbnailExtractor.class);
    }

    @Test
    public void jobShouldChangeStateInDB() {
        RuntimeException trancoderException = new RuntimeException("강제발생");
        when(
                transcoder.transcode(any(File.class),
                        anyListOf(OutputFormat.class))).thenThrow(
                trancoderException);

        Long jobId = new Long(1);
        Job job = jobRepository.findById(jobId); // DB로부터 Job 로딩
        try {
            job.transcode(transcoder, thumbnailExtractor); // job 기능 실행
        } catch (RuntimeException ex) {
        }

        Job updatedJob = jobRepository.findById(jobId); // DB에서 동일 Job 로딩

        assertEquals(Job.State.TRANSCODING, job.getLastState());
        assertEquals(Job.State.TRANSCODING, updatedJob.getLastState()); // 반영 확인
    }
}

위 테스트는 JpaJobRepository를 이용해서 DB로부터 Job을 읽어온다. Job의 trancode()를 실행해서 트랜스코딩 기능을 실행하는데, 이 과정에서 익셉션을 발생하도록 transcoder Mock 객체를 설정했다. 따라서, Job의 최종 상태는 TRANSCODING 이여야 한다.

메모리에서만 반영되고 DB에는 반영되지 않으면 상태를 조회하는 어플리케이션이 잘못된 상태 값을 가져가게 되므로, 같은 ID를 갖는 Job 객체를 구해서 그 객체의 상태도 TRANSCODING인지 확인한다.

위 테스트를 실행해보자. 위 코드에서 빨간색으로 표시한 부분에서 테스트가 실패한다.

Job의 changeState() 메서드 살펴보기

Job은 changeState() 메서드를 이용해서 상태를 변경한다.

public class Job {
    ...
    public void transcode(Transcoder transcoder,
            ThumbnailExtractor thumbnailExtractor) {
        try {
            File multimediaFile = copyMultimediaSourceToLocal();
            List<File> multimediaFiles = transcode(multimediaFile, transcoder);
            List<File> thumbnails = extractThumbnail(multimediaFile,
                    thumbnailExtractor);
            storeCreatedFilesToStorage(multimediaFiles, thumbnails);
            notifyJobResultToRequester();
            completed();
        } catch (RuntimeException ex) {
            exceptionOccurred(ex);
            throw ex;
        }
    }

    private void changeState(State newState) {
        this.state = newState;
    }

    private File copyMultimediaSourceToLocal() {
        changeState(Job.State.MEDIASOURCECOPYING);
        return mediaSourceFile.getSourceFile();
    }
    ...
}

changeState() 메서드는 state 필드만 변경하기 때문에, 위 메서드가 호출된다고 하더라도 DB에 결과가 반영되지 않는다. changeState() 메서드에 DB 연동 기능을 넣으면, 지금까지 도메인과 영속성 처리 코드를 애써 분리해온 게 무용지물이 된다.

Job 클래스는 그대로 유지하면서 Job 클래스의 상태를 유지하는 방법은 뭐가 있을까? 답은 다형성에 있다. JpaJobRepository가 Job 객체 대신 Job을 상속받은 JobImpl 객체를 생성하고, changeState() 메서드를 오버라이딩해서 DB 처리를 수행하도록 만들면 될 것 같다.

오버라이딩 JobImpl은 아마 이런 식일 것 같다.

public class JobImpl extends Job {

    public JobImpl(Long id, State state, MediaSourceFile mediaSourceFile,
            DestinationStorage destinationStorage,
            List<OutputFormat> outputFormats, ResultCallback callback,
            String errorMessage) {
        super(id, state, mediaSourceFile, destinationStorage, outputFormats,
                callback, errorMessage);
    }

    @Override
    protected void changeState(State newState) {
        super.changeState(newState);
        jobDataDao.updateState(getId(), newState); // JobDataDao?????
    }

}

음..... jobDataDao라는 게 출현했다. 이 jobDataDao는 JobImpl의 생성자로부터 받아야 하는데, 그렇다면 JpaJobRepository가 jobDataDao 역할을 해야 하나?

@Repository
public class JpaJobRepository implements JobRepository {
    ...
    @Transactional
    @Override
    public Job findById(Long jobId) {
        JobData jobData = entityManager.find(JobData.class, jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new JobImpl(jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage(),
                this); // JpaJobRepository가 JobDataDao인가???
    }
    
    public void updateState(Long id, Job.State newState) {
        ...
    }
}

뭔가 기분이 안 좋다. 음,,, 이유를 알았다. JobImpl이 JobDataDao를 필요로 하는 순간 알게 된 것이 있다. 그것은 바로 JpaJobRepository가 두 개의 책임을 지고 있다는 것이다.

JpaJobRepository의 책임 분리: SRP


JpaJobRepository는 다음의 두 가지 책임을 갖고 있다.

  • Job과 JobData 사이의 변환 실행
  • JobData와 DB 사이의 매핑 처리
단일 책임 원칙(SRP)를 위반하고 있다. SRP를 적용하면 JpaJobRepository로부터 DB 연동 부분이 분리된다.
  • DbJobRepository: DB를 이용한 JobRepository 구현
  • JobDataDao: JobData에 대한 DAO. 구현은 JPA를 이용해서 구현
JobDataDao는 Spring Data를 사용하면 최소한의 코딩으로 구현할 수 있다. Spring Data를 이용해서 JobDataDao 인터페이스를 다음과 같이 정의하였다.

import org.springframework.data.repository.Repository;

public interface JobDataDao extends Repository<JobData, Long> {

    public JobData save(JobData jobData);

    public JobData findById(Long id);

}

DB 연동 부분이 생겼으니, JpaJobRepository는 다음과 같이 JobDataDao를 사용하도록 변경된다.

@Repository // 이 애노테이션은 필요 없으니 삭제
public class JpaJobRepository implements JobRepository {

    private JobDataDao jobDataDao;
    ...

    public JpaJobRepository(JobDataDao jobDataDao,
            MediaSourceFileFactory mediaSourceFileFactory,
            DestinationStorageFactory destinationStorageFactory,
            ResultCallbackFactory resultCallbackFactory) {
        this.jobDataDao = jobDataDao;
        this.mediaSourceFileFactory = mediaSourceFileFactory;
        this.destinationStorageFactory = destinationStorageFactory;
        this.resultCallbackFactory = resultCallbackFactory;
    }

    @Transactional // 트랜잭션 처리는 JobDataDao로 이동
    @Override
    public Job findById(Long jobId) {
        JobData jobData = jobDataDao.findById(jobId);
        if (jobData == null) {
            return null;
        }
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        ...
    }

    @Transactional
    @Override
    public Job save(Job job) {
        JobData.ExporterToJobData exporter = new JobData.ExporterToJobData();
        JobData jobData = job.export(exporter);
        JobData savedJobData = jobDataDao.save(jobData);
        return createJobFromJobData(savedJobData);
    }

}

JpaJobRepository는 더 이상 JPA를 사용하고 있지 않으므로 이름을 DbJobRepository로 변경해 주자.

생성자를 변경했으므로 RepositoryConfig에서 컴파일 에러가 발생한다. 이제 RepositoryConfig에서 컴파일 에러를 없애주자.

@Configuration
@EnableJpaRepositories(basePackages = "org.chimi.s4t.infra.persistence")
public class RepositoryConfig {

    @Autowired
    private MediaSourceFileFactory mediaSourceFileFactory;
    @Autowired
    private DestinationStorageFactory destinationStorageFactory;
    @Autowired
    private ResultCallbackFactory resultCallbackFactory;
    @Autowired
    private JobDataDao jobDataDao;

    @Bean
    public JobRepository jobRepository() {
        return new DbJobRepository(jobDataDao, mediaSourceFileFactory,
                destinationStorageFactory, resultCallbackFactory);
    }
}

위 코드에서 @EnableJpaRepositories 애노테이션은 Spring Data가 제공하는 기능으로서, 이 애노테이션을 적용하면 Spring Data의 Repository 인터페이스를 상속받은 인터페이스로부터 구현 객체를 생성해준다. 이 예제의 경우 JobDataDao가 Repository 인터페이스를 상속받고 있으므로, JobDataDao에 대한 구현 객체를 생성해서 빈으로 등록해 준다. 따라서, 위 코드와 같이 @Autowired를 이용해서 생성된 JobDataDao 구현 객체를 참조할 수 있게 된다.

JpaJobRepository를 DbJobRepository로 변경하고 DB 연동 부분을 JobDataDao로 분리해냈다. 수정하는 작업을 했으니 테스트를 실행해서 정상적으로 동작하는 지 확인해 보자. 기존에 만들어둔 JpaJobRepositoryIntTest가 있으므로 이 테스트를 실행해보면 된다. 실행해보자. 녹색! 오~ 통과다. 테스트를 통과했으므로 이 테스트 클래스의 이름을 DbJobRepositoryIntTest로 변경하자.

다시 DbJobRepository에서 생성한 Job의 기능 확인

앞서, JpaJobRepository가 생성한 Job이 정상적으로 동작하는 지 확인해보는 과정에서 JpaJobRepository의 역할을 분리하게 되었다. 다시 돌아가도록 하자. 기억이 나지 않는다면, 앞 부분을 다시 읽어보고 여기로 오면 된다. 이제 JobImpl 클래스는 chageState() 메서드에서 JobDataDao를 이용해서 DB에 저장된 상태 값을 변경할 수 있다.

public class JobImpl extends Job {

    private JobDataDao jobDataDao;

    public JobImpl(JobDataDao jobDataDao, Long id, State state,
            MediaSourceFile mediaSourceFile,
            DestinationStorage destinationStorage,
            List<OutputFormat> outputFormats, ResultCallback callback,
            String errorMessage) {
        super(id, state, mediaSourceFile, destinationStorage, outputFormats,
                callback, errorMessage);
        this.jobDataDao = jobDataDao;
    }

    @Override
    protected void changeState(State newState) {
        super.changeState(newState);
        jobDataDao.updateState(getId(), newState); // 아직 updateState() 메서드 없음
    }

}

JobDataDao에 updateState() 메서드가 없으므로 위 코드에서 빨간색 부분이 컴파일 에러가 발생한다. JobDataDao에 updateState() 메서드를 추가하자.

public interface JobDataDao extends Repository<JobData, Long> {

    public JobData save(JobData jobData);

    public JobData findById(Long id);

    public int updateState(Long id, Job.State newState);
}

이제 JobImpl에서 컴파일 에러가 사라진다.

이제 DbJobRepository가 Job 대신 JobImpl 객체를 생성하도록 수정하자.

public class DbJobRepository implements JobRepository {
    ...
    @Override
    public Job findById(Long jobId) {
        ...
        return createJobFromJobData(jobData);
    }

    private Job createJobFromJobData(JobData jobData) {
        return new JobImpl(jobDataDao, jobData.getId(), jobData.getState(),
                mediaSourceFileFactory.create(jobData.getSourceUrl()),
                destinationStorageFactory.create(jobData.getDestinationUrl()),
                jobData.getOutputFormats(),
                resultCallbackFactory.create(jobData.getCallbackUrl()),
                jobData.getExceptionMessage());
    }

    @Transactional
    @Override
    public Job save(Job job) {
        ...
        return createJobFromJobData(savedJobData);
    }

}

JobImpl까지 만들었으니, 앞에서 작성했던 JobIntTest 클래스를 다시 실행해보자. 빨간색! 실패다.

실패가 발생한 이유는 Spring Data가 앞서 추가한 updateState() 메서드에 대한 알맞은 구현체를 만들지 못하기 때문이다. 필요한 JPA QL을 직접 지정해서 수정 기능을 완성짓도록 하면 될 것 같다.

public interface JobDataDao extends Repository<JobData, Long> {

    public JobData save(JobData jobData);

    public JobData findById(Long id);

    @Transactional
    @Modifying
    @Query("update JobData j set j.state = ?2 where j.id = ?1")
    public int updateState(Long id, Job.State newState);
}

다시 JobIntTest를 실행해보자. 녹색 통과다!

JobIntTest에 검증하는 기능을 추가해서 넣자. Job 객체는 변환 과정 중 에러가 발생하면 exceptionMessage에 에러 원인을 보관한다. JobIntTest는 중간 과정에서 오류가 발생한 경우에 상태 값이 올바른지 테스트 하고 있으므로, 다음과 같이 오류 메시지가 올바르게 저장되는 검증하는 코드를 추가해 보자.

public class JobIntTest {
    ...
    @Test
    public void jobShouldChangeStateInDB() {
        RuntimeException trancoderException = new RuntimeException("강제발생");
        when(
                transcoder.transcode(any(File.class),
                        anyListOf(OutputFormat.class))).thenThrow(
                trancoderException);

        Long jobId = new Long(1);
        Job job = jobRepository.findById(jobId);
        try {
            job.transcode(transcoder, thumbnailExtractor);
        } catch (RuntimeException ex) {
        }

        Job updatedJob = jobRepository.findById(jobId);

        assertEquals(Job.State.TRANSCODING, job.getLastState());
        assertEquals(Job.State.TRANSCODING, updatedJob.getLastState());
        assertEquals("강제발생", job.getExceptionMessage());
        assertEquals(job.getExceptionMessage(), updatedJob.getExceptionMessage());
    }
}

테스트를 실행해보자. 그럼, 위 코드에서 붉게 표시한 부분에서 통과하지 못한다. 앞서 상태 변경과 동일하게 메모리 상에 오류 메시지를 보관하고 있으나 DB에는 반영이 되지 않아 통과하지 못한 것이다. 이 부분은 상태를 변경하는 부분과 비슷하게 구현하면 될 것 같다.

눈치 챘는지 모르겠지만, JobIntTest를 통과시키는 과정에서 Job 클래스의 private 메서드 두 개를 protected로 변경했다.

public class Job {
    ...
    protected void changeState(State newState) {
        this.state = newState;
    }

    protected void exceptionOccurred(RuntimeException ex) {
        exceptionMessage = ExceptionMessageUtil.getMessage(ex);
        callback.nofiyFailedResult(id, state, exceptionMessage);
    }
    ...
}

위와 같이 변경한 이유는 JobImpl 클래스에서 위 두 기능을 오버라이딩해야 했기 때문이다. 기능을 구현하기 위해 하위 클래스에 이 정도 개방해주는 것은 허용해도 괜찮을 것 같다.

최종 모습

지금까지 JobRepository의 DB 구현을 만들었다. 그 결과로 아래와 같은 구조가 만들어졌다.


위 그림에서 job 도메인의 어떤 타입도 persistence 영역에 대한 의존을 갖지 않는다. (아니다, 정확하게는 OutputFormat이 JPA 애노테이션을 사용하니까 의존이 있긴 하지만, 설정 파일을 사용하면 제거 가능하므로 의존을 갖지 않는다고 표현해도 될 것 같다.) 따라서, persistence의 새로운 구현이 필요하더라도 job 도메인은 영향을 받지 않는다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 나그네 2014.03.04 10:53 신고  댓글주소  수정/삭제  댓글쓰기

    범균님 덕분에 TDD9까지 집에서 테스트 해봤습니다.
    너무 좋은 자료 감사합니다. 한 10일은 걸린거 같아요 뛰엄뛰엄 하느냐구요
    첫술에 배부를수는 없지만 반복해서 다시 해보려구요 생각보다 쉽지 않아서요^^
    여러번 해보는 방법밖에는 없겠죠??

  2. 나그네 2014.04.09 17:51 신고  댓글주소  수정/삭제  댓글쓰기

    드디어...2번 다했습니다. ^^
    이번엔 프로젝트와 맞물려 좀 소홀히? 한거 같아요~ 좀 디테일하게 소스코드를 봐서 그런지 얻어가는게 더 많아진 기분입니다.
    감사합니다. 해보면서 느낀건데 진짜 많은 노력과 정성이 들어간 내용들이었습니다.
    앞으로 8번 남았네요!!

  3. 나그네 2014.04.09 17:54 신고  댓글주소  수정/삭제  댓글쓰기

    답변 남겨주시면 정말 힘이 날거 같아요...으으읔

  4. 나그네 2014.04.10 09:53 신고  댓글주소  수정/삭제  댓글쓰기

    아니에요 너무너무 도움이 많이 되요~~ 앞으로 8번...!!!

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로 올렸다.


결과는? 야호! 테스트케이스의 녹색바! 에러가 없어졌다. 처음부터 하이버네이트 버전 업부터 할 걸, 괜히 코드 작성했다. 오픈소스에서 뭔가 이상한 문제가 있으면 먼저 버전을 올려보라는 걸 다시 한 번 확인했다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 권남 2012.07.12 17:37 신고  댓글주소  수정/삭제  댓글쓰기

    @IdClass를 사용할때도 마찬가지 현상이 발생합니다.
    @IdClass/MySQL

  2. PKH갈휘 2012.07.16 22:22 신고  댓글주소  수정/삭제  댓글쓰기

    버전업후에 말끔해졌네요, 감사합니다.

ORM!! 오늘 한번 더 ORM을 추종할 수 밖에 없는 일이 벌어졌다. 경험한 내용이 흥분도 되고 ORM의 좋음을 공유하고자 이렇게 글을 남긴다.


최초의 설계: 별도 클래스, 별도 테이블


현재 진행하는 프로젝트에서 컨텐츠에 대한 모델을 설계할 때 컨텐츠 종류마다 별도 테이블과 별도 클래스로 구성하도록 설계를 진행했었다. 각 컨텐츠들이 비슷한 데이터를 갖고 있었지만, 절반은 다른 데이터를 갖고 있었다. 또한, 컨텐츠 타입은 두 개였고 컨텐츠마다 사용되는 영역이 달랐기 때문에, 상위 클래스에 공통 정보를 두지 않고 서로 계층 관계에 묶이지 않는 별도 클래스로 구성하였다. 물론, 테이블도 별도로 구성하였다.




변화의 압박: 서로 다른 컨텐츠 종류에 공통으로 적용되는 기능들


최초 개발하는 동안에는 크게 문제될 것이 없었다. 그런데, PoC 프로젝트를 거의 마무리하는 과정에서 1.0 버전의 기획으로 다음의 기능들이 추가되었다.

  • 모든 컨텐츠에 대해 댓글 달기
  • 모든 컨텐츠에 대해 좋아요 하기
  • 모든 컨텐츠에 대해 즐겨찾기 하기
  • 모든 컨텐츠에 연관 정보 넣기
  • 새로운 종류의 컨텐츠 타입 추가 및 새로운 타입 컨텐츠에도 댓글/좋아요/연관 정보 넣기
고민에 휩싸이기 시작했다. 예를 들어, 댓글 구현은 다음과 같이 두 가지 중 하나로 할 수 있다.
  • 각 컨텐츠 타입마다 댓글을 위한 테이블을 구분해서 만들고, 각 컨텐츠 타입별로 댓글 관련 인터페이스 묶음 구현하기.
  • 댓글 테이블에 컨텐츠 타입 보관 위한 컬럼을 추가하고, 한 묶음의 인터페이스로 각 컨텐츠 타입을 위한 댓글 기능 구현하기.

구현이야 할 수 있겠지만, 둘 다 딱히 마음에 안 들었는다. 첫 번째 방법은 완전히 동일한 데이터 구조, 구현 코드, 테이블이 중복된다는 점이 불만이었다.



두 번째 방법은 하나의 신규 테이블에서 서로 다른 테이블에 대한 참조를 가져야 할 수도 있다는 것이 거슬렸다. 예를 들어, 두 컨텐츠에 동일하게 추가되는 정보인 경우, 두 컨텐츠에 대한 외부키를 각각 갖고 있어야 했다.



또한, 두 방법 모두 2.0 버전에 새로운 컨텐츠 타입이 추가되거나 각 컨텐츠 타입에 공통으로 적용되는 기능이 추가되면 같은 짓을 반복해야 하는 동일한 문제점을 갖고 있었다.


그래서, 뭐가 문제일까 하고 잠시 고민해 본 결과, 모델링을 잘못 했다는 결론에 다다랐다. PoC 시점에서는 그리 잘못 되지 않았지만, 몇 달 후에 신규 기능들이 추가되면서  최초에 만들었던 모델로는 깔끔하게 처리할 수 없게 된 것이다. 이 모든 문제를 깔끔하게 해결하는 방법은 모델을 다시 정리하는 것 뿐이었다.


상속으로 풀기로 결심!


이 모든 문제의 근원은 추상화의 변경에서 비롯되었다. 최초에는 두 개의 컨텐츠를 논리적인 하나의 컨텐츠로 바라보지 않았다. 그런데, 댓글, 연관 정보, 좋아요 기능이 생기면서 개별 컨텐츠 타입이 아닌 '컨텐츠'라는 개념이 도출된 것이다. 따라서, 컨텐츠라는 새로운 상위 개념을 도출했고, 이 상위 개념에 대해서 '좋아요', '댓글' 등의 기능을 적용하기로 하였다. 새로운 모델은 다음과 같이 변경되었다.



컨텐츠라는 단일 개념을 표현하기 위해 Content를 출현시켰고, 댓글과 좋아요 등의 기능은 새롭게 추가한 Content에 대고 구현을 하도록 했다. 각 개별 타입들의 변이는 Content의 하위 타입으로 처리하였다. 그리고, 각 컨텐츠 타입마다 별도의 테이블을 가졌던 것에서 모든 컨텐츠 타입을 한 개의 테이블에 저장하기로 결정했다.


변경의 여파는?


모델을 새롭게 정의했으니, 이제 기존 코드에 변경할 차례이다. 시간은 대략 얼마나 걸렸을까? 주요 기능의 정상적인 동작을 확인하는 데 까지 40분 정도 (밖에 안) 걸렸다. 물론, 개발 과정이기 때문에 기존 데이터의 마이그레이션이나 변경 등의 이슈는 없었고, 단지, 테스트를 위해 마련해 둔 데이터를 변경하는 정도의 데이터 수정 작업이 있었다. 하지만, 그렇다고 하더라도 모델을 변경하고 테이블을 합친 것에 비하면 이는 정말 짧은 시간이다.


그렇다면 어떻게 이것이 가능했을까? 이는 ORM을 사용했기 때문이다. 실제로 모델을 변경하기 위해 한 작업은 다음과 같다.

  • 상위 Content 클래스를 추가하고 각 하위 타입에 대해 공통인 필드와 관련 메서드를 추가
  • 각 하위 Content 타입 클래스에서 상위 클래스로 옮겨간 필드 및 관련 메서드 제거
  • Content 및 하위 클래스에 대한 ORM 설정
  • 테스트 데이터 구성
  • 기능 테스트 (약 40% 진행)
위에서 가장 시간이 오래 걸린 작업은 테스트 데이터 구성과 기능을 테스트 한 시간이다. Content 및 하위 클래스 구성, 그리고 ORM 관련 설정에는 불과 10분 정도 밖에 소요되지 않았다. 테스트 데이터를 구성하는데 10분 정도가 소요되었고, 기능 테스트에 20분 정도가 소요되었다. 물론, 전체 기능 중에서 조회 위주로만 기능을 테스트 했지만, 놀라운 점은 조회 기능 테스트하는 과정에서 오류가 발생하지 않았다는 점이다. 게다가 위의 작업 내역을 보면 DB 연동을 처리하는 리포지토리는 변경도 하지 않았다. 오!! 놀랍지 않은가?

ORM이 아니였다면?


어떻게 이런 과감한 변경을 하면서도 짧은 시간에 변경이 가능했을까? 정답은 바로 ORM 때문이다.

만약 ORM이 아니였다면 관련된 쿼리들을 쫓아다니면서 테이블을 변경해주고, 쿼리를 변경해주는 등의 쌩노가나를 해야만 했을 것이다. 물론, 이 과정에서 짜증 유발과 함께 오타도 발생했을 거고, 누가 오타를 덜 내면서 쿼리를 변경해 내느냐가 그 사람의 (노가다) 능력으로 인정받았을 것이다.

하지만, 이 프로젝트에서는 ORM을 사용했기에, 매우 적은 스트레스를 받으며, 약간의 노가다 만으로, 그야말로 스마트하게 변경 작업을 진행할 수 있었다. 프로젝트를 진행하다보면, SQL로 했으면 거부감이 확 들었을 이런 종류의 변경에서부터 작게는 사소한 컬럼 추가까지 다양한 변경이 발생하게 되는데, 이만하면 ORM을 도입하기 위한 약간의 학습 비용은 프로젝트 전체로 봤을 때 그리고 향후 유지보수를 생각해 봤을 때 절대로 큰 비용이 아닐 것이다. 아니 오히려 매우 작은 비용일 것이다.

Posted by 최범균 madvirus

댓글을 달아 주세요

JPA를 사용할 때 입력 값에 따라 동적으로 검색 조건을 변경하려면 Criteria API를 사용하면 되지만, 이게 참 이쁘지가 않고 코드를 만지다 보면 짜증이 나기도 한다. 최근에 하는 프로젝트에서 Spring Data JPA를 선택했는데, Spring Data JPA 덕분에 검색 조건을 조합할 때 발생하는 짜증을 많이 감소시킬 수 있게 되었다. Spring Data JPA가 Repository 또는 Dao의 인터페이스만 정의하면 알아서 구현체를 만들어준다는 장점이 정말 좋은데, 그에 못지 않게 검색 조건을 추상화한 Specification을 지원한다는 것 역시 아주 마음에 든다.

Specification을 사용하는 방법은 아주 간단한데, 그 방법은 다음과 같다.
  1. Specification을 입력 받도록 Repository 인터페이스를 정의하기
  2. 검색 조건을 모아 놓은 클래스 만들기
  3. 검색 조건을 조합한 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의 조합을 아주 강력하게 권장한다. 


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. david hong 2013.03.15 16:53 신고  댓글주소  수정/삭제  댓글쓰기

    spec 설정과 repository 의 findAll 호출간에 relation 이 없는데 findAll(spec,pageRequest) 와 같은 형태로 전달해야 하지 않나요?

    • 최범균 madvirus 2013.03.17 20:38 신고  댓글주소  수정/삭제

      글에 오류가 있었네요. findAll(pageRequest)가 아니라 findAll(spec) 이었습니다. 지적하신 부분 수정해 놓았습니다. 잘못된 부분 알려주셔거 고맙습니다.

  2. 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(....);

  3. ncrash 2013.12.08 11:15 신고  댓글주소  수정/삭제  댓글쓰기

    답변을 엄청 빨리 달아주셨는데 이제서야 확인했네요
    답변 감사 드립니다 ^^

    전 StringBuilder.appned() 메소드와 동일한 형태로 처리되는 줄 알았는데 헛다리를 ㅠ.ㅠ

    그렇다면 위 포스팅 내용도 아래와 같이 변경되야 하는거 아닌지 궁금하네요

    if (category != null) {
    spec = spec.and(ContentSpecs.category(category));
    }

1월 28일 예정되어 있는 DDD 쌩기초 세미나 발표 자료입니다.
세미나 관련 정보는 http://cafe.daum.net/javacan/9Voo/88 에서 확인할 수 있습니다.
 
Posted by 최범균 madvirus

댓글을 달아 주세요