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

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

스프링 3.0 버전까지만 해도 스프링 자바 설정이 상대적으로 인기가 없었는데, 주된 이유는 XML의 네임스페이스 기반 설정 편의 기능을 대체하지 못했기 때문이었다. 스프링 3.1 버전부터 자바 설정도 편의 기능을 제공하기 시작했는데 그것이 바로 @Enable로 시작하는 애노테이션 설정이다. @Enable 설정 편의 애노테이션은 사용자를 대신해서 많은 설정을 대신 해준다. 예를 들어, @EnableWebMvc의 경우 100여 줄에 가까운 설정 코드륻 대신 한다. 


스프링이 제공하는 @Enable 설정 편의 기능뿐만 아니라 직접 @Enable 설정 편의 애노테이션을 만들 수도 있는데, 크게 세 가지 방법이 존재한다.


1. 설정을 임포트하는 @Enable 애노테이션


첫 번째 방법은 @Configuration 설정을 임포트하는 @Enable 애노테이션을 작성하는 것이다. 예를 들어, 다음 @Configuration 설정을 보자.


@Configuration

public class MyCompanyConfig {

    

    @Bean

    public Authenticator authenticator() {

        Authenticator authenticator = new Authenticator();

        ...

        return authenticator;

    }


}


이 설정을 자동으로 처리하는 @Enable 애노테이션은 다음과 같이 구현할 수 있다.


@Retention(RetentionPolicy.RUNTIME)

@Target(ElementType.TYPE)

@Import(MyCompanyConfig.class)

@interface EnableMyCompany {}


@Import 속성은 설정으로 사용할 클래스를 지정한다.


이제 @EnableMyCompany 애노테이션을 설정에 추가하면 MyCompanyConfig에 포함된 설정을 자동으로 포함시킨다.


@Configuration

@EnableMyCompany

public class AppConfig {


    @Autowired

    private Authenticator authenticator;


    ...

}



2. ImportSelector 사용하기


두 번째 방법은 ImportSelector를 사용하는 것이다. 애노테이션 애트리뷰트 값에 따라 다른 설정 클래스를 사용하고 싶을 때 이 방식을 사용한다. 예를 들어, 다음 @Enable 애노테이션을 보자.


@Retention(RetentionPolicy.RUNTIME)

@Target(ElementType.TYPE)

@Import(CompanyConfigSelector.class)

public @interface EnableAuthConfig {

    ClientType type() default ClientType.HTTP;

}


이 애노테이션은 type 애트리뷰트를 갖고 있다.


@Import에 지정한 CompanyConfigSelector는 ImportSelector 인터페이스를 구현한 클래스로서 다음 코드처럼 @EnableAuthConfig 애노테이션의 type 애트리뷰트 값에 따라 사용할 설정 클래스 이름을 리턴하도록 구현한다. ImportSelector 자체는 설정 클래스가 아니므로 @Configuration이 붙지 않는다.


public class CompanyConfigSelector implements ImportSelector {

    @Override

    public String[] selectImports(AnnotationMetadata importingClassMetadata) {

        Map<String, Object> attributesMap = 

                 importingClassMetadata.getAnnotationAttributes(

                         EnableAuthConfig.class.getName(), false);

        AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributesMap);

        ClientType typeValue = attributes.<ClientType>getEnum("type");

        if (typeValue == ClientType.HTTP) {

            return new String[]{HttpAuthClientConfiig.class.getName()};

        } else if (typeValue == ClientType.PROTOBUFF) {

            return new String[]{ProtobuffAuthClientConfig.class.getName()};

        }

        return new String[0];

    }

}



ImportSelector의 selectImport() 메서드는 String 배열을 리턴하는데, 이 배열은 설정으로 사용할 클래스 이름을 값으로 갖는다. 위 코드의 경우 @EnableAuthConfig 애노테이션의 type 애트리뷰트 값에 따라 HttpAuthClientConfig나 ProtobuffAuthClient를 설정으로 사용하도록 구현하고 있다. 이 두 클래스는 다음과 같이 @Configuration을 이용한 설정 클래스이다.


@Configuration

public class HttpAuthClientConfiig {

    @Bean

    public HttpAuthClient authClient() {

        return new HttpAuthClient();

    }

}


이제 @EnableAuthConfig 애노테이션을 사용하면 type 애트리뷰트 값에 따라 알맞은 설정을 사용하게 된다. 예를 들어, 아래 코드는 ProtobuffAuthClientConfig를 설정으로 사용하게 된다.


@Configuration

@EnableAuthConfig(type = ClientType.PROTOBUFF)

public class AppConfig {}



3. ImportBeanDefinitionRegistrar 사용하기


세 번째 방법은 ImportBeanDefinitionRegistrar을 사용하는 것이다. ImportSelector가 설정으로 사용할 클래스 이름을 리턴하는 방식이라면 ImportBeanDefinitionRegistrar는 빈 설정을 직접 등록하는 방식이다. 이 방식은 기존에 XML 스키마 확장 방식을 자바 설정으로 마이그레이션하고자 할 때 사용할 때 좋다.


Enable 애노테이션은 동일하다. 다음과 같이 @Import로 ImportBeanDefinitionRegistrar 인터페이스를 구현한 클래스를 지정하면 된다.


@Retention(RetentionPolicy.RUNTIME)

@Target(ElementType.TYPE)

@Import(CompanyConfigRegistrar.class)

public @interface EnableCompSecConfig {

    ClientType type() default ClientType.HTTP;

}


ImportBeanDefinitionRegistrar 인터페이스를 구현한 클래스는 애노테이션 애트리뷰트 정보를 이용해서 알맞은 빈 설정을 등록하면 된다. 다음은 간단한 구현 예이다.


public class CompanyConfigRegistrar implements ImportBeanDefinitionRegistrar {

    @Override

    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,

            BeanDefinitionRegistry registry) {

        Map<String, Object> attributesMap = 

                importingClassMetadata.getAnnotationAttributes(

                        EnableCompSecConfig.class.getName(), false);

        AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributesMap);

        ClientType typeValue = attributes.<ClientType>getEnum("type");

        if (typeValue == ClientType.HTTP) {

            BeanDefinition beanDef = new RootBeanDefinition("comp.config.HttpAuthClient");

            registry.registerBeanDefinition("authClient", beanDef);

        } else if (typeValue == ClientType.PROTOBUFF) {

            BeanDefinition beanDef = 

                    new RootBeanDefinition("comp.config.ProtobuffAuthClientConfiig");

            registry.registerBeanDefinition("authClient", beanDef);

        }

    }

}



이제 @EnableCompSecConfig를 사용하면 CompanyConfigRegistrar에서 등록한 빈 설정이 자등으로 추가된다.


@Configuration

@EnableCompSecConfig(type = ClientType.HTTP)

public class AppConfig {



Posted by 최범균 madvirus

댓글을 달아 주세요

스프링 4.1 버전에 새롭게 추가된 테스트 기능 중에서 @Sql 애노테이션이 있는데, @Sql 애노테이션은 지정한 스크립트를 실행해주는 애노테이션이다.  스프링 3 버전에 추가된 ResourceDatabasePopulator를 사용해도 되지만, @Sql 애노테이션을 이용하면 매우 편리하게 테스트에서 데이터를 초기화할 수 있다.


사용방법은 매우 간단한다.

  1. 테스트를 실행하기 전에 사용할 쿼리 목록을 담은 파일을 작성한다. (각 쿼리는 ';'로 구분한다.)
  2. @Sql 애노테이션을 테스트 클래스나 테스트 메서드에 적용한다.

예를 들어, 아래 코드는 각 테스트 메서드를 실행하기 전에 member.init1.sql 파일과 memberinit2.sql 파일에 입력한 쿼리 목록을 실행한다.


@ContextConfiguration("classpath:spring-*.xml")

@RunWith(SpringJUnit4ClassRunner.class)

@Sql({"classpath:member.init1.sql", "classpath:member.init2.sql"})

public class MemberIntTest {

    @Autowired private MemberService memberService;


    @Test

    public void list() {

        …

    }


    @Test

    public void regist() {

        …

    }

}


@Sql에 명시한 파일은 테스트를 실행하는데 적합한 상태로 DB를 초기화하기 위해 DELETE, TRUNCATE, INSERT, CREATE와 같은 쿼리를 포함하게 된다.


클래스에 @Sql 애노테이션을 적용하면 각 테스트 메서드마다 적용되며, 테스트 메서드에 적용하면 해당 테스트를 실행할 때에만 사용된다. 예를 들어, 아래 코드와 같이 테스트 클래스와 메서드에 각각 @Sql 애노테이션을 적용하면, regist() 테스트 메서드를 실행할 때에는 "member.init3.sql" 만을 사용해서 쿼리를 실행하고, list() 테스트 메서드를 실행할 때에는 "member.init1.sql"과 "member.init2.sql"을 사용해서 쿼리를 실행한다.


@ContextConfiguration("classpath:spring-*.xml")

@RunWith(SpringJUnit4ClassRunner.class)

@Sql({"classpath:member.init1.sql", "classpath:member.init2.sql"})

public class ShopIntTest {

    @Autowired private MemberService memberService;


    @Test

    public void list() {

        …

    }


    @Sql("classpath:member.init3.sql")

    @Test

    public void regist() {

        …

    }

}


@Sql은 테스트 메서드 실행 전과 실행 후 중에서 언제 쿼리를 실행할지 여부를 지정할 수 있다. @Sql의 executionPhase 속성의 값으로 ExecutionPhase 열거 타입에 정의된 BEFORE_TEST_METHOD나 AFTER_TEST_METHOD를 설정하면 된다. 기본 값은 BEFORE_TEST_METHOD 이다. 다음 코드는 executionPhase 속성의 설정 예를 보여주고 있다.


@Sql("init.sql")

@Sql(scripts="remove.sql", executionPhase=ExecutionPhase.AFTER_TEST_METHOD)

@Test public void someTest() { … }


위 코드에서 보듯이 자바8을 사용하면 @Sql 애노테이션을 여러 개 사용해서 실행할 쿼리를 지정할 수 있다. 자바 7 이하 버전을 사용한다면, 아래 코드처럼 @SqlGroup 애노테이션을 이용하면 여러 개의 @Sql을 한 테스트 클래스나 메서드에 적용할 수 있다.


@SqlGroup( {

    @Sql("init.sql"), @Sql(scripts="clear.sql", executionPhase=ExecutionPhase.AFTER_TEST_METHOD)} )

@Test public void someTest() { … }


@Sql 애노테이션은 별도 설정을 하지 않으면 @ContextConfiguration에 지정한 설정 정보에 있는 DataSource 빈을 사용해서 스크립트를 실행하고, 트랜잭션 관리자가 존재할 경우 해당 트랜잭션 관리자를 이용해서 트랜잭션 범위 내에서 스크립트를 실행한다.

Posted by 최범균 madvirus

댓글을 달아 주세요

Ant 경로 패턴은 경로를 지정할 때 유용하게 사용할 수 있는 경로 표현 패턴인데, 스프링은 특정 경로가 Ant 경로 패턴 경로와 일치하는지 여부를 확인할 때 사용할 수 있는 org.springframework.util.AntPathMatcher 클래스를 제공하고 있다. 


AntPathMatcher 클래스의 사용방법은 간단하다. boolean match(String pattern, String path) 메서드를 이용해서 path가 Ant 패턴인 pattern에 매칭되는지 확인하면 된다. 매칭될 경우 match() 메서드는 true를 리턴한다.


AntPathMatcher pathMatcher = new AntPathMatcher();

assertThat(pathMatcher.match("/a/**/b", "/a/1/2/3/b"), equalTo(true));

assertThat(pathMatcher.match("/a/**/b", "/a/1/2/3/b/c"), equalTo(false));


AntPathMatcher의 또 다른 기능은 패턴을 기준으로 매칭되는 부분을 추출하는 것이다. 이 기능을 사용하려면 다음과 같이 extractPathWithinPattern(String pattern, String path) 메서드를 사용하면 된다. 이 메서드는 pattern의 '*', '?' 등을 기준으로 경로에서 패턴에 매칭되는 부분을 추출해준다. 다음은 이 메서드의 실행 결과를 보여주고 있다.


assertThat(pathMatcher.extractPathWithinPattern("/a/*", "/a/1"), equalTo("1"));

assertThat(pathMatcher.extractPathWithinPattern("/a/*", "/a/1/b"), equalTo("1/b"));

assertThat(pathMatcher.extractPathWithinPattern("/a/b/*", "/a/b/c"), equalTo("c"));

assertThat(pathMatcher.extractPathWithinPattern("/a/b/*.xml", "/a/b/c.xml"), equalTo("c.xml"));


assertThat(pathMatcher.extractPathWithinPattern("/*", "/a/b/c.xml"), equalTo("a/b/c.xml"));

assertThat(pathMatcher.extractPathWithinPattern("/*.xml", "/a/b/c.xml"), equalTo("a/b/c.xml"));


assertThat(pathMatcher.extractPathWithinPattern("/a/*", "/a/1/b"), equalTo("1/b"));

assertThat(pathMatcher.extractPathWithinPattern("/a/**", "/a/1/b"), equalTo("1/b"));


assertThat(pathMatcher.extractPathWithinPattern("/a/*", "/a/1/b.xml"), equalTo("1/b.xml"));

assertThat(pathMatcher.extractPathWithinPattern("/a/**", "/a/1/b.xml"), equalTo("1/b.xml"));


assertThat(pathMatcher.extractPathWithinPattern("/a/*/b", "/a/1/b"), equalTo("1"));

assertThat(pathMatcher.extractPathWithinPattern("/a/**/b", "/a/1/b"), equalTo("1"));


assertThat(pathMatcher.extractPathWithinPattern("/a/**/b.xml", "/a/1/b.xml"), equalTo("1"));

assertThat(pathMatcher.extractPathWithinPattern("/a/**/*.xml", "/a/1/b.xml"), equalTo("1/b.xml"));


위 코드에서 equalTo() 부분의 결과가 extractPathWithinPattern() 메서드의 실행 결과인데, 결과를 보면 '*'와 '**'가 차이가 없다는 것을 알 수 있다.


다음 extractPathWithinPattern() 실행 결과는 어떻게 될까?


pathMatcher.extractPathWithinPattern("/a/**/b", "/a/1/2/3/b") ????


실행 결과로 "1/2/3" 이라고 결과를 예상했겠지만, 실제 결과는 "1/3/b" 다. 상상에서 완전히 벗어난다. 다음은 몇 가지 상상을 벗어나느 결과를 만들어내는 몇 개 예제를 만들어 본 것이다.


assertThat(pathMatcher.extractPathWithinPattern("/a/**/b", "/a/1/2/3/b"), equalTo("1/3/b"));

assertThat(pathMatcher.extractPathWithinPattern("/a/**/b", "/a/1/2/3/b"), equalTo("1/3/b"));

assertThat(pathMatcher.extractPathWithinPattern("/a/**/b", "/a/1/2/3/4/b"), equalTo("1/3/4/b"));

assertThat(pathMatcher.extractPathWithinPattern("/a/a1/**/b", "/a/1/2/3/4/b"), equalTo("2/4/b"));


위 결과를 보면 알 수 있겠지만, match() 메서드를 기준으로 '*'나 '**'에 매칭되는 경로가 두 개 이상이 되면 extractPathWithinPattern() 메서드는 상상한 것과 완전히 다른 결과를 돌려준다. AntPathMatcher를 사용하려면 이 점에 주의하기 바란다.


Posted by 최범균 madvirus

댓글을 달아 주세요

지금까지 각각의 구현들을 만들어나갔다. 그러면서 채워진 도메인 영영역은 아래와 같다. 아래 그림은 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번...!!!

요즘 저녁에 집에서 짬이 생길 때마다 공부겸 취미겸 간단한 웹 기반 어플리케이션을 만들고 있는데, 만들던 중 아래와 같은 기능이 필요하게 되었다.

  • WAR로 배포하고, 데이터 디렉토리를 외부에서 변경할 수 있어야 함
  • JNDI나 시스템 프로퍼티 값을 이용해서 디렉토리 경로를 지정할 수 있어야 함
위 기능을 직접 구현할까 하다가 누군가도 위와 같은 기능을 필요로 할 것 같아서 검색을 해 보았다. 아니나 다를까, 딱 들어맞는 기능을 제공하는 모듈이 있어 간단하게 기능을 정리해보았다. 이 모듈의 이름은 Data directory locator tool, 줄여서 datadirlocator (http://simplericity.org/datadirlocator)로서 사용법도 매우 간단하다.

모듈 다운로드

홈페이지에서 다운로드 받거나 Maven을 사용하는 경우 다음과 같이 의존을 추가해주면 된다.

<dependency>
    <groupId>org.simplericity.datadirlocator</groupId>
    <artifactId>datadirlocator</artifactId>
    <version>1.10</version>
</dependency>

지원하는 설정 방식

datadirlocator는 설정 파일이 위치하는 디렉토리나 어플리케이션의 홈 디렉토리와 같이 디렉토리 경로를 구하는 기능을 제공하며, 다음과 같이 4가지 방식으로 설정 경로를 구할 수 있도록 지원하고 있다.
  • JNDI 설정 이용 (기본 JNDI 명: java:com/env/dataDirectory)
  • 서블릿 컨텍스트 파라미터 이용 (기본 컨텍스트 파라미터 명: dataDirectory)
  • 시스템 프로퍼티 이용 (기본 시스템 프로퍼티  명: dataDirectory)
  • 환경 변수 이용 (기본 환경 변수 명: DATADIRECTORY)
JNDI부터 순서대로 값이 존재하는지 검색하고 값이 존재하면 그 값을 사용하고 존재하지 않으면 그 다음 방식의 값이 존재하는 검사한다. 위의 네 가지 경우에 대해 모두 값이 존재하지 않으면 기본 디렉토리로 $HOME/datadirectory를 사용한다.

사용법1, 직접 모듈 사용하기

가장 간단한 사용방법은 다음과 같다.
  • ServletContextListener를 추가한다.
  • ServletContextListener에서 DefaultDataDirectoryLocator를 사용해서 경로 값을 구한다.
예를 들어, 아래와 같은 코드를 구현해서 JNDI나 시스템 프로퍼티에 지정된 경로값을 구해서 시스템을 초기화하는데 사용할 수 있다.

public class ConfigInitializerServletContextListener implements ServletContextListener {

@Override
public void contextInitialized(ServletContextEvent sce) {
DefaultDataDirectoryLocator locator = new DefaultDataDirectoryLocator();
locator.setServletContext(sce.getServletContext());
locator.setJndiName("java:comp/env/rr4s/home");
locator.setSystemProperty("rr4s.home");
locator.setContextParamName("rr4shome");
locator.setEnvVarName("RR4SHOME");
locator.setDefaultDir("$HOME/rr4s.home");
File homeDirectory = locator.locateDataDirectory();
// homeDirectory를 이용한 설정 초기화
}
....
}

사용법2, 스프링 빈으로 사용하기

또 다른 방법은 스프링 빈으로 사용하는 것이다. DefaultDataDirectoryLocator를 스프링 빈 객체로 설정해서 사용할 수 있고, 만약 서블릿 컨텍스트 파라미터에 접근해야 한다면, ServletContextAware 인터페이스를 구현한 ServletContextAwareDataDirectoryLocator를 사용하면 된다. 다음은 설정 예이다.

<bean id="dataDirectoryLocator"
class="org.simplericity.datadirlocator.spring.ServletContextAwareDataDirectoryLocator">
<property name="jndiName" value="java:comp/env/rr4s/home" />
<property name="systemProperty" value="rr4s.home" />
</bean>

<bean id="contextReloader" class="org.chimi.rr4s.setup.ContextReloader">
<property name="dataDirectoryLocator" ref="dataDirectoryLocator" />
</bean>

위 코드에서 ContextReloader 클래스는 인젝션을 통해서 전달받은 dataDirectoryLocator를 이용해서 설정에 필요한 디렉토리 경로를 받아올 것이다. 

public class ContextReloader implements ApplicationContextAware,
ApplicationListener<ContextRefreshedEvent> {

private DataDirectoryLocator dataDirectoryLocator;
...
private File locateHomeDirectory() {
return dataDirectoryLocator.locateDataDirectory();
}
...
}

 




Posted by 최범균 madvirus

댓글을 달아 주세요