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

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의
TDD 발담그기 @ 공감세미나 from beom kyun choi
Posted by 최범균 madvirus

댓글을 달아 주세요

신림프로그래머 모임에서 발표할 'JI 개발 이야기 리뷰' 자료입니다.



Posted by 최범균 madvirus

댓글을 달아 주세요

스프링캠프 2013에서 발표했던 'TDD 라이브'의 영상입니다.



(발표자료는 http://javacan.tistory.com/entry/TDD-Live-in-SpringCamp-2013 참고)

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 꿈꾸는 개발자 2014.02.03 23:53 신고  댓글주소  수정/삭제  댓글쓰기

    정말 좋은 자료 감사합니다.
    범균님 혹시 소스 파일 받아 볼수 있나요???

  2. 꿈꾸는 개발자 2014.02.04 08:46 신고  댓글주소  수정/삭제  댓글쓰기

    범균님 감사감사~~~~~^^
    복 많이 받으실꺼에요

  3. 김상균 2014.08.25 11:55 신고  댓글주소  수정/삭제  댓글쓰기

    궁금 한게 있습니다
    @autowired는 by type으로 되어있다고 알고 있습니다
    근데 지금 제가 전에 만들었던 소스를 보고 있는데 이런식으로 되어있어요
    근데 문제 없이 잘 작동 합니다.
    책에 의하면 이렇게 될 경우 스프링은 어떤 객체를 매칭시켜줄지 몰라서 익셉션을
    발생한다고 알고 있는데 이게 어떡해 잘 되는건가요 이럴경우 @Qualifier 사용 해서 하거나 그냥 @Resource를 사용하는걸로 알고 있는데요...
    @Autowired
    A a;
    @Autowired
    A b;

    <bean id="a" class="A" />
    <bean id="b" class="A" />

    • 최범균 madvirus 2014.08.25 23:52 신고  댓글주소  수정/삭제

      책에서 설명하진 않았지만 @Autowired 애노테이션이 적용된 필드 이름과 같은 식별자를 갖는 (할당 가능한 타입의) 빈이 있으면 그 빈을 자동 설정 대상으로 사용하게 됩니다.

      저 같은 경우는 빈의 이름을 이용한 매칭 방법을 방법을 선호하지 않는데, 이런 선호 때문에, 저도 모르게 이에 대한 내용을 넣지 않은 것도 같네요. 나중에 개정판을 낼 때 넣어야 할 필요성을 느끼게 해 주셔서 감사합니다. ^^

      그리고, 선호하지 않는 이유는.... 음 여기에 다 적으려고 보니 뭔가 주저리 주저리 적을 말이 많네요. 대단한 이유는 아닌데 주저리 주저리 적어야 해서, 그냥 제가 왜 그런 선호를 갖는지에 대한 부분이 궁금하시면 이메일로 다소 길게 정리해서 보내드릴께요. 제 이메일 주소는 madvirus@madvirus.net 인데, 이곳으로 메일 주소 알려주시면, 답변 드리도록 하겠습니다.

기존에 Mockito와 JUnit Eclosed를 이용해서 작성한 테스트 코드를 Spock을 이용해서 재작성해봤다. 뭐랄까, 속이 다 후련하다. Spock이 좀 더 익숙해지면 여러 상황을 위한 테스트 코드 만드는데 도움이 될 것 같다.


class PaymentErpSyncSpockSpecification extends Specification {

    def PaymentErpSync sync = new PaymentErpSync()

    def PaymentSyncSourceDao mockSyncSourceDao = Mock()

    def PaymentInfoConverter mockPaymentInfoConverter = Mock();

    def ExternalErpClient mockExternalErpClient = Mock();

    def OrderSyncResultDao mockOrderSyncResultDao = Mock();


    def setup() {

        sync.setPaymentSyncSourceDao(mockSyncSourceDao)

        sync.setExternalErpClient(mockExternalErpClient)

        sync.setPaymentInfoConverter(mockPaymentInfoConverter)

        sync.setOrderSyncResultDao(mockOrderSyncResultDao)

    }

    

    def "PaymentSyncSourceDao 읽기 실패시"() {

        when: "동기화 실행"

        sync.syncPaymentInfo()

        

        then: "SyncSource 읽기 실패하고, 실패 결과 기록해야 함"

        mockSyncSourceDao.findAllByBeforeSync() >> { throw new RuntimeException() };

        mockOrderSyncResultDao.insert(_) >> { OrderSyncResult result ->

            assert result.result == false

            assert result.syncType == null

            assert result.failedSource == "PaymentSyncSourceDao"

        }

    }

    

    def "PaymentSyncSourceDao 읽기 성공시"() {

        setup: "PaymentSyncSourceDao 데이터 제공 설정"

        def paymentSyncSources =[

            PaymentSyncSource.builder().id(1L).saleDate(new Date()).type("p").build(),

            PaymentSyncSource.builder().id(2L).saleDate(new Date()).type("p").build()

        ]

        mockSyncSourceDao.findAllByBeforeSync() >> paymentSyncSources

        

        def paymentInfo = []

        paymentSyncSources.each { source ->

            mockPaymentInfoConverter.convert(source) >> paymentInfo

        }

        

        when: "동기화 실행하면,"

        sync.syncPaymentInfo()

        

        then: "ERP 전송 실패하고, 실패 결과 기록해야 함"

        2 * mockExternalErpClient.send(paymentInfo) >> { throw new SendFailureException() }

        2 * mockOrderSyncResultDao.insert({ OrderSyncResult result ->

            result.result == false &&

            result.syncType == SyncType.payment &&

            result.failedSource == "ExternalErpClient"

        })

        

        when: "동기화 실행하면,"

        sync.syncPaymentInfo()

        

        then: "모두 성공하고, 성공 결과 기록해야 함"

        2 * mockExternalErpClient.send(paymentInfo)

        2 * mockOrderSyncResultDao.insert({OrderSyncResult result ->

            result.result == true &&

            result.syncType == SyncType.payment

        })

    }

}



Posted by 최범균 madvirus

댓글을 달아 주세요

발표 자료:


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 이호영 2013.10.15 13:28 신고  댓글주소  수정/삭제  댓글쓰기

    수고하셨습니다.
    좋은 내용 감사합니다 ^^

숫자 야구 게임을 이용해서 TDD를 연습해 봤습니다.




Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 개발자 2014.01.19 12:28 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요 동영상 강의 잘봤습니다.
    마지막에 guessNumber random은 피곤하셔서 구현을 못하셨는데 어떤식으로 하면 될까요?
    궁금해서 못참겠어요..ㅠ.ㅠ

    • 최범균 madvirus 2014.01.20 09:47 신고  댓글주소  수정/삭제

      0부터 9까지의 수를 섞은 다음에... (랜덤하게 인덱스를 추출해서 맨 앞으로 보낸다는가 하는 식으로 15~20회 수행 등), 1/3/5 번째를 뽑는다든가 하는 식으로 숫자 3개를 뽑아낼 수 있을 것 같아요.

  2. 개발자 2014.01.20 13:24 신고  댓글주소  수정/삭제  댓글쓰기

    넵 감사합니다. 참고하고 해보겠습니다.
    아참 그리고 범균님 책 잘읽었습니다.
    '개발자가 반드시 정복해야할 객체 지향과 디자인패턴' 끝까지 읽어보고 리뷰 달아볼께요.

  3. 개발자 2014.01.20 13:29 신고  댓글주소  수정/삭제  댓글쓰기

    랜덤하게 뽑아내는건 문제가 아닌데...동영상강의 마지막에 interface로 GameNumberGenerator를 만들었는데 의존관계를 갖지 않고 어떻게 GameNumberGenerator를 구현해야 되는지 그게 좀 어려워서요...
    만약 개발자가 반드시 정복해야할 객체 지향과 디자인패턴 이책을 안읽고 그냥 했다면 구현은 가능하겠으나...책도 거의 다 읽어가는 마당에 그렇게 코딩하면 안될거 같아서요..^^;;

  4. 나그네 2014.02.26 10:12 신고  댓글주소  수정/삭제  댓글쓰기

    혹시 소스 파일있나요???

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

    네엡 감사합니다~~~~~^^

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

지금까지 한 번에 한 개의 변환 결과만 생성하는 기능을 구현했다. 이번엔 한 번에 여러 형식의 변환 결과를 생성해주는 기능을 구현해보도록 하자.


두 개 이상의 OutputFormat을 입력으로 받는 테스트 추가하기


TDD니까, 테스트를 먼저 작성해보도록 하자.


public class FfmpegTranscoderTest {

    ...

    private OutputFormat mp4Format;

    private OutputFormat mp4Format2;

    private OutputFormat aviFormat;


    @Before

    public void setup() {

        outputFormats = new ArrayList<OutputFormat>();

        mp4Format = new OutputFormat(160, 120, 150, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        mp4Format2 = new OutputFormat(80, 60, 80, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        aviFormat = new OutputFormat(160, 120, 150, Container.AVI,

                VideoCodec.MPEG4, AudioCodec.MP3);

        multimediaFile = new File("src/test/resources/sample.avi");


        transcoder = new FfmpegTranscoder();

    }

    ...

    private void executeTranscoderAndAssert() {

        List<File> transcodedFiles = transcoder.transcode(multimediaFile,

                outputFormats);

        assertEquals(outputFormats.size(), transcodedFiles.size());

        for (int i = 0; i < outputFormats.size(); i++) {

            assertTrue(transcodedFiles.get(i).exists());

            VideoFormatVerifier.verifyVideoFormat(outputFormats.get(i),

                    transcodedFiles.get(i));

        }

    }


    @Test

    public void transcodeWithTwoMp4OutputFormats() {

        outputFormats.add(mp4Format);

        outputFormats.add(mp4Format2);

        executeTranscoderAndAssert();

    }

}


새로운 크기를 갖는 OutputFormat 객체인 mp4Format2를 필드로 추가하였고, transcodeWithTwoMp4OutputFormats() 메서드를 이용해서 두 개의 변환 결과에 대한 테스트를 수행하도록 했다. executeTrancoderAndAssert() 메서드는 두 개 이상의 변환 요청을 검증할 수 있도록 수정하였다.


테스트를 실행해보자. 아래와 같은 메시지와 함께 테스트에 실패한다.


java.lang.AssertionError: expected:<160> but was:<80>

    at org.junit.Assert.fail(Assert.java:91)

    at org.junit.Assert.failNotEquals(Assert.java:645)

    at org.junit.Assert.assertEquals(Assert.java:126)

    at org.junit.Assert.assertEquals(Assert.java:470)

    at org.junit.Assert.assertEquals(Assert.java:454)

    at org.chimi....VideoFormatVerifier.assertVideoFile(VideoFormatVerifier.java:88)

    at org.chimi....VideoFormatVerifier.verify(VideoFormatVerifier.java:40)

    at org.chimi....VideoFormatVerifier.verifyVideoFormat(VideoFormatVerifier.java:18)

    at org.chimi.....executeTranscoderAndAssert(FfmpegTranscoderTest.java:64)

    at org.chimi....transcodeWithTwoMp4OutputFormats(FfmpegTranscoderTest.java:79)

    ... 


이유는 뭘까? FfmpegTranscoder 클래스는 다음과 같이 동일한 비디오 컨테이너에 대해 항상 동일한 파일을 생성한다. 따라서, 가장 마지막으로 요청한 OutputFormat의 결과만 최종적으로 남아있게 된다.


public class FfmpegTranscoder implements Transcoder {

    ...

    private String getFileName(OutputFormat format) {

        return "outputFile." + format.getFileExtension();

    }


}


예를 들어, 위 테스트의 경우 크기가 80*60인 비디오 파일이 최종적으로 남게 되고, 따라서 생성된 비디오를 검증하는 과정에서 첫 번째 변환 요청인 160*120 크기를 검사하는 과정에서 테스트를 통과하지 못하게 되었다.


이 테스트를 통과시키려면 FfmpegTranscoder의 getFileName() 메서드가 매번 서로 다른 파일 이름을 리턴해 주어야 한다. 변환된 결과 파일 이름의 경우 변환 시스템을 사용하는 곳에 따라 작명 규칙이 달라질 수 있을 것 같다. 그래서, 파일 이름을 생성하는 규칙을 NamingRule이라는 별도 인터페이스로 분리하고 FfmpegTranscoder는 NamingRule에 위임하도록 코드를 구성하는 게 좋을 것 같다. 아래는 그렇게 코딩한 결과이다.


public class FfmpegTranscoder implements Transcoder {


    private NamingRule namingRule;


    public FfmpegTranscoder(NamingRule namingRule) {

        this.namingRule = namingRule;

    }

    ...

    private String getFileName(OutputFormat format) {

        return namingRule.createName(format);

    }


}


NamingRule 인터페이스를 다음과 같이 만들었다.


public interface NamingRule {


    String createName(OutputFormat format);


}


FfmpegTranscoder의 생성자가 변경되었으므로, FfmpegTranscoderTest 클래스가 컴파일 에러가 난다. 컴파일 에러가 나지 않도록 NamingRule을 전달해주자.


public class FfmpegTranscoderTest {


    private Transcoder transcoder;

    ...

    private NamingRule namingRule;

    ...

    @Before

    public void setup() {

        outputFormats = new ArrayList<OutputFormat>();

        mp4Format = new OutputFormat(160, 120, 150, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        ...

        transcoder = new FfmpegTranscoder(namingRule); // 아직 namingRule은 null

    }


NamingRule의 Mock 객체를 만들어서 테스트를 통과시킬까 고민하다가, DefaultNamingRule이라는 걸 만드는 게 좋을 것 같다라는 결론에 도달했다. 다음과 같이 NamingRule 인터페이스에 이너 클래스로 DefaultNamingRule 클래스를 정의했고, DEFAULT 상수가 값으로 DefaultNameRule 객체를 갖도록 했다.


public interface NamingRule {


    String createName(OutputFormat format);


    public static final NamingRule DEFAULT = new DefaultNamingRule();


    public static class DefaultNamingRule implements NamingRule {


        private Random random = new Random();

        private String baseDir;


        public DefaultNamingRule() {

            baseDir = System.getProperty("java.io.tmpdir");

        }


        public void setBaseDir(String baseDir) {

            this.baseDir = baseDir;

        }


        @Override

        public String createName(OutputFormat format) {

            String fileName = getFileNameFromTime();

            File file = createFileFromFileNameAndFormat(format, baseDir,

                    fileName);

            return file.getPath();

        }


        private String getFileNameFromTime() {

            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss");

            String time = dateFormat.format(new Date());

            int num = random.nextInt(1000);

            String fileName = time + "_" + num;

            return fileName;

        }


        private File createFileFromFileNameAndFormat(OutputFormat format,

                String tempDir, String fileName) {

            File file = new File(tempDir, fileName + "."

                    + format.getFileExtension());

            return file;

        }

    }

}


이제 FfmpegTranscoderTest가 NamingRule로 NamingRule.DEFAULT를 사용하도록 수정할 차례이다.


public class FfmpegTranscoderTest {


    private Transcoder transcoder;

    private File multimediaFile;

    private List<OutputFormat> outputFormats;


    private NamingRule namingRule; // 필요 없으므로 삭제


    private OutputFormat mp4Format;

    private OutputFormat mp4Format2;

    private OutputFormat aviFormat;


    @Before

    public void setup() {

        outputFormats = new ArrayList<OutputFormat>();

        mp4Format = new OutputFormat(160, 120, 150, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        mp4Format2 = new OutputFormat(80, 60, 80, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        aviFormat = new OutputFormat(160, 120, 150, Container.AVI,

                VideoCodec.MPEG4, AudioCodec.MP3);

        multimediaFile = new File("src/test/resources/sample.avi");


        transcoder = new FfmpegTranscoder(NamingRule.DEFAULT);

    }


테스트를 실행한다. 녹색! 통과다. 서로 다른 포맷을 갖는 OutputFormat을 생성하는 기능 구현이 완료되었다.


OutputFormat: 컨테이너와 코덱 옵션


OutputFormat의 생성자는 컨테이너 정보와 코덱 정보를 함께 받고 있다. 하지만, 컨테이너의 기본 코덱을 사용할 수 있기 때문에 코덱 정보를 전달하지 않을 경우 컨테이너의 기본 코덱을 사용하도록 하는 것이 편리할 것 같다. 이를 위해 OutputFormat 생성자에 코덱 정보를 전달하지 않는 테스트 코드부터 작성해보자. OutputFormat를 생성할 때 컨테이너 정보만 전달해도 정상적으로 실행되는 지 확인하면 되므로  VideoConverterTest를 이용해보도록 하겠다.


public class VideoConverterTest {

    ...

    @Test

    public void transcodeWithOnlyContainer() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI); // 일치하는 생성자 없으므로 컴파일 에러

        VideoConverter writer = new VideoConverter("target/sample.avi", reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);

        

        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File(

                "target/sample.avi"));

    }

}


컴파일 에러를 없애야 하므로 OutputFormat에 생성자를 추가해주고, 다음과 같이 구현해 주었다.


public class OutputFormat {


    private int width;

    private int height;

    private int bitrate;

    private Container container;

    private VideoCodec videoCodec;

    private AudioCodec audioCodec;


    public OutputFormat(int width, int height, int bitrate,

            Container container, VideoCodec videoCodec, AudioCodec audioCodec) {

        this.width = width;

        this.height = height;

        this.bitrate = bitrate;

        this.container = container;

        this.videoCodec = videoCodec;

        this.audioCodec = audioCodec;

    }


    public OutputFormat(int width, int height, int bitrate, Container container) {

        this(width, height, bitrate, container, null, null);

    }


컴파일 에러를 없앴으므로, VideoConverterTest를 실행해보자. transcodeWithOnlyContainer() 테스트 메서드는 통과하지 못했다. 실패 메시지 및 관련 위치는 다음과 같다.


-- 실패 메시지

java.lang.AssertionError: expected:<null> but was:<MPEG4>

    at org.junit.Assert.fail(Assert.java:91)

    at org.junit.Assert.failNotEquals(Assert.java:645)

    at org.junit.Assert.assertEquals(Assert.java:126)

    at org.junit.Assert.assertEquals(Assert.java:145)

    at org....VideoFormatVerifier.assertVideoFile(VideoFormatVerifier.java:90)

    at org....VideoFormatVerifier.verify(VideoFormatVerifier.java:40)

    at org....VideoFormatVerifier.verifyVideoFormat(VideoFormatVerifier.java:18)

    at org....VideoConverterTest.transcodeWithOnlyContainer(VideoConverterTest.java:52)


-- 실패 위치

    private void assertVideoFile() {

        assertEquals(expectedFormat.getWidth(), width);

        assertEquals(expectedFormat.getHeight(), height);

        assertEquals(expectedFormat.getVideoCodec(),

                CodecValueConverter.toDomainVideoCodec(videoCodec)); // 테스트 실패

        assertEquals(expectedFormat.getAudioCodec(),

                CodecValueConverter.toDomainAudioCodec(audioCodec));

    }


OutputFormat을 생성하는 과정에서 videoCodec의 값으로 null을 전달했기 때문에, expectedFormat.getVideoCodec()가 null을 리턴했다. 동일하게 audioCodec도 null 값을 갖게 된다. 이 문제는 간단하게 처리할 수 있다. 다음과 같이 코덱을 전달받지 않는 OutputFormat 생성자에서 null 대신 Container의 기본 코덱을 전달하도록 코드를 수정해 주면 될 것 같다.


public class OutputFormat {

    ...

    public OutputFormat(int width, int height, int bitrate,

            Container container, VideoCodec videoCodec, AudioCodec audioCodec) {

        ...

        this.videoCodec = videoCodec;

        this.audioCodec = audioCodec;

    }


    public OutputFormat(int width, int height, int bitrate, Container container) {

        this(width, height, bitrate, container, 

                container.getDefaultVideoCodec(), container.getDefaultAudioCodec());

    }


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


VideoConverterTest 작은 손질


VideoConverterTest를 정리할 타이밍이 왔다. 현재까지 만들어진 코드는 다음과 같다.


public class VideoConverterTest {


    private static final int WIDTH = 160;

    private static final int HEIGHT = 120;

    private static final int BITRATE = 150;

    private static final String SOURCE_FILE = "src/test/resources/sample.avi";

    private static final String TRANSCODED_FILE = "target/sample.mp4";


    @Test

    public void transcode() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);


        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.MP4, VideoCodec.H264, AudioCodec.AAC);

        VideoConverter writer = new VideoConverter(TRANSCODED_FILE, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File(

                TRANSCODED_FILE));

    }

    

    @Test

    public void transcodeWithOnlyContainer() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI);

        VideoConverter writer = new VideoConverter("target/sample.avi", reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);

        

        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File(

                "target/sample.avi"));

    }

}


두 테스트 메서드가 상당히 비슷하다. 우선, 두 메서드에서 사용되는 값들을 메서드의 앞 쪽에 변수로 선언해서 모으자.


public class VideoConverterTest {

    ...

    @Test

    public void transcode() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.MP4, VideoCodec.H264, AudioCodec.AAC);

        String outputFile = TRANSCODED_FILE;

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }


    @Test

    public void transcodeWithOnlyContainer() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI);

        String outputFile = "target/sample.avi";

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }

}


두 메서드에서 reader, outputFormat, outputFile을 필드로 정의한다. 이클립스와 같은 IDE는 메서드 로컬 변수를 필드로 빼주는 기능을 제공하고 있으니 그 기능을 사용하면 편리하다. 아 그 전에 테스트 하는 것 잊지 말기 바란다.


public class VideoConverterTest {

    ...

    private IMediaReader reader;

    private OutputFormat outputFormat;

    private String outputFile;


    @Test

    public void transcode() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.MP4, VideoCodec.H264, AudioCodec.AAC);

        outputFile = TRANSCODED_FILE;

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }


    @Test

    public void transcodeWithOnlyContainer() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.AVI);

        outputFile = "target/sample.avi";

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }

}


이제 두 메서드에서 코드가 완전히 동일한 부분을 별도의 메서드로 분리해내자. 다음의 두 부분이 완전히 동일하다.

  • reader를 생성하는 부분 -> @Before를 붙인 setup 메서드로 이동
  • VideoConverter를 생성하고 변환하는 부분 -> testVideoConverter 메서드로 이동

public class VideoConverterTest {


    private static final int WIDTH = 160;

    private static final int HEIGHT = 120;

    private static final int BITRATE = 150;

    private static final String SOURCE_FILE = "src/test/resources/sample.avi";

    private static final String TRANSCODED_FILE = "target/sample.mp4";

    private IMediaReader reader;

    private OutputFormat outputFormat;

    private String outputFile;


    @Before

    public void setup() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

    }


    @Test

    public void transcode() {

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        outputFile = TRANSCODED_FILE;

        testVideoConverter();

    }


    private void testVideoConverter() {

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }


    @Test

    public void transcodeWithOnlyContainer() {

        outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE, Container.AVI);

        outputFile = "target/sample.avi";

        testVideoConverter();

    }

}


outputFormat과 outputFile을 초기화하는 부분도 비슷하다. 이 부분도 한 번 별도 메서드로 분리해보자.


public class VideoConverterTest {

    ...

    @Test

    public void transcode() {

        initOutput(Container.MP4, VideoCodec.H264, AudioCodec.AAC,

                TRANSCODED_FILE);

        testVideoConverter();

    }


    private void initOutput(Container outputContainer, VideoCodec videoCodec,

            AudioCodec audioCodec, String outputFileName) {

        if (videoCodec == null && audioCodec == null) {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer);

        } else {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer, videoCodec, audioCodec);

        }

        outputFile = outputFileName;

    }


    private void initOutput(Container outputContainer, String outputFileName) {

        initOutput(outputContainer, null, null, outputFileName);

        outputFile = outputFileName;

    }


    private void testVideoConverter() {

        ...

    }


    @Test

    public void transcodeWithOnlyContainer() {

        initOutput(Container.AVI, "target/sample.avi");

        testVideoConverter();

    }

}


이제 TRANSCODED_FILE 상수는 더 이상 의미가 없다. 이 상수를 사용하지 않도록 돌려 놓자.


지금까지의 작업으로 바뀐 VideoConverterTest는 다음과 같다.


public class VideoConverterTest {


    private static final int WIDTH = 160;

    private static final int HEIGHT = 120;

    private static final int BITRATE = 150;

    private static final String SOURCE_FILE = "src/test/resources/sample.avi";

    private IMediaReader reader;

    private OutputFormat outputFormat;

    private String outputFile;


    @Before

    public void setup() {

        reader = ToolFactory.makeReader(SOURCE_FILE);

    }


    @Test

    public void transcode() {

        initOutput(Container.MP4, VideoCodec.H264, AudioCodec.AAC,

                "target/sample.mp4");

        testVideoConverter();

    }


    @Test

    public void transcodeWithOnlyContainer() {

        initOutput(Container.AVI, "target/sample.avi");

        testVideoConverter();

    }


    private void initOutput(Container outputContainer, String outputFileName) {

        initOutput(outputContainer, null, null, outputFileName);

        outputFile = outputFileName;

    }


    private void initOutput(Container outputContainer, VideoCodec videoCodec,

            AudioCodec audioCodec, String outputFileName) {

        if (videoCodec == null && audioCodec == null) {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer);

        } else {

            outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                    outputContainer, videoCodec, audioCodec);

        }

        outputFile = outputFileName;

    }


    private void testVideoConverter() {

        VideoConverter writer = new VideoConverter(outputFile, reader,

                outputFormat);

        reader.addListener(writer);

        while (reader.readPacket() == null)

            do {

            } while (false);


        VideoFormatVerifier.verifyVideoFormat(outputFormat,

                new File(outputFile));

    }

}


@Test 메서드가 단순해졌다. 새로운 컨테이너의 추가라든가, 코덱 변환 등을 테스트하고 싶을 때에 간단하게 테스트를 추가할 수 있게 되었다. testVideoConverter() 메서드와 initOutput() 메서드를 조금 더 정리하고 싶은 마음은 있지만, 일단 이 정도까지 코드를 정리하자.




Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 전홍석 2015.12.04 11:08 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요
    스프링 4 프로그래밍 입문을 구입하여 열공중인 중년 초보개발자 입니다.
    대용량의 동영상 converter작업할일이 있어 찾아보니 저자님 글이 있어
    학습하여 익히려 합니다.
    염치 불구하고 위 전체 소스를 받아서 도움받을수 있도록 부탁드립니다.


    • 최범균 madvirus 2015.12.04 14:31 신고  댓글주소  수정/삭제

      아,,, 이게 그냥 막 연습삼아서 하던거라서,,,, 코드가 있는지 모르겠네요. 업무용 장비에는 코드가 없네요. 아무래도 내용 보시면 관련 코드가 나오니까, 그 코드를 참조하셔야 할 것 같아요.

앞서 작업까지의 결과물인 FfmpegTranscoder 클래스의 코드를 보자.


public class FfmpegTranscoder implements Transcoder {


    @Override

    public List<File> transcode(File multimediaFile,

            List<OutputFormat> outputFormats) {

        List<File> results = new ArrayList<File>();

        for (OutputFormat format : outputFormats) {

            results.add(transcode(multimediaFile, format));

        }

        return results;

    }


    private File transcode(File sourceFile, OutputFormat format) {

        IMediaReader reader = ToolFactory.makeReader(sourceFile

                .getAbsolutePath());


        String outputFile = "outputFile.mp4"; // 항상 확장자, 파일 명이 같음

        VideoConverter converter = new VideoConverter(outputFile, reader,

                format);

        ...

    }


}


원하는 파일이 MP4인지 AVI인지에 상관없이 항상 파일명은 outputFile.mp4 이다. 이제 이 부분을 건드려볼 차례이다.


우선, 확장자는 멀티미디어 컨테이너에 따라 다르다. 예를 들어, AVI, MP4, MOV, FLV 등이 컨테이너이며, 보통 이 이름을 파일의 확장자로 사용한다. 또한, 각 컨테이너의 특징에 따라 사용가능한 코덱에 제한이 있다고도 한다. 하튼, 필자는 이 부분의 전문가는 아니기 때문에 컨테이너 포맷이나 코덱 등의 정보가 궁금한 사람은 따로 정보를 검색해보시라.


컨테이너 모델 추가


확장자를 검사하도록 테스트를 먼저 수정하자. 확장자를 검사하기 적당한 위치는 VideoFormatVerifier 클래스이다. 이 클래스에 확장자를 확인하는 기능을 추가해 넣도록 하자.


public class VideoFormatVerifier {

    ...

    public VideoFormatVerifier(OutputFormat expectedFormat, File videoFile) {

        this.expectedFormat = expectedFormat;

        this.videoFile = videoFile;

    }


    public void verify() {

        try {

            assertExtension();

            makeContainer();

            extractMetaInfoOfVideo();

            assertVideoFile();

        } finally {

            closeContainer();

        }

    }


    private void assertExtension() {

        assertEquals(expectedFormat.getFileExtenstion()fileExtenstion());

    }


    private String fileExtenstion() {

        String filePath = videoFile.getAbsolutePath();

        int lastDotIdx = filePath.lastIndexOf(".");

        String extension = filePath.substring(lastDotIdx + 1);

        return extension;

    }


OutputFormat의 getFileExtension() 메서드가 파일 확장자를 제공한다고 했다. 이 메서드가 없으니 아래와 같이 추가해서 기존의 테스트가 통과되도록 하자.


public class OutputFormat {


    private int width;

    private int height;

    private int bitrate;

    ...

    public String getFileExtension() {

        return "mp4"; //일단 테스트가 통과하도록

    }


    public VideoCodec getVideoCodec() {

        return videoCodec;

    }


    public AudioCodec getAudioCodec() {

        return audioCodec;

    }


}


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


이제 본격적으로 컨테이너 정보를 추가해 보자. 파일의 확장자를 결졍짓는 것은 컨테이너이므로, 코덱과 별개로 컨테이너 정보를 추가하자.


public enum Container {


    MP4(VideoCodec.H264, AudioCodec.AAC, "mp4"),

    AVI(VideoCodec.MPEG4,AudioCodec.MP3, "avi");


    private VideoCodec defaultVideoCodec;

    private AudioCodec defaultAudioCodec;

    private String fileExtension;


    private Container(VideoCodec defaultVideoCodec,

            AudioCodec defaultAudioCodec, String fileExtenstion) {

        this.defaultVideoCodec = defaultVideoCodec;

        this.defaultAudioCodec = defaultAudioCodec;

        this.fileExtension = fileExtenstion;

    }


    public VideoCodec getDefaultVideoCodec() {

        return defaultVideoCodec;

    }


    public AudioCodec getDefaultAudioCodec() {

        return defaultAudioCodec;

    }


    public String getFileExtension() {

        return fileExtension;

    }


}


Container는 enum 타입으로 각 값은 해당 컨테이너의 기본 코덱 정보와 확장자 정보를 갖도록 했다.


Container를 추가했으므로, 변환 결과물 정보를 담는 OutputFormat에 추가할 차례이다. OutputFormat이 Container를 갖도록 필드를 추가혹, 생성자를 통해서 Container를 전달받도록 수정하자.


public class OutputFormat {


    private int width;

    private int height;

    private int bitrate;

    private Container container;

    private VideoCodec videoCodec;

    private AudioCodec audioCodec;


    public OutputFormat(int width, int height, int bitrate,

            Container container, VideoCodec videoCodec, AudioCodec audioCodec) {

        this.width = width;

        this.height = height;

        this.bitrate = bitrate;

        this.container = container;

        this.videoCodec = videoCodec;

        this.audioCodec = audioCodec;

    }

    ...

    public String getFileExtension() {

        return container.getFileExtension();

    }

    ...

}


OutputFormat에 getFileExtension() 메서드가 추가되었다. 이제 VideoFormatVerifier 테스트에 추가했던 확장자 확인 코드가 정상적으로 컴파일된다.


public class VideoFormatVerifier {

    ...

    ...

    private void assertExtension() {

        assertEquals(expectedFormat.getFileExtenstion(), fileExtenstion()); // 컴파일 됨

    }



위 코드는 정상적으로 컴파일되지만, OutputFormat 생성자에 새로운 파라미터가 추가되었기 때문에, OutputFormat 객체를 생성하는 테스트 코드에서는 컴파일 에러가 발생한다. 


public class VideoConverterTest {

    ...

    @Test

    public void transcode() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);


        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                VideoCodec.H264, AudioCodec.AAC);

        VideoConverter writer = new VideoConverter(TRANSCODED_FILE, reader,

                outputFormat);

        ...

    }

}



public class FfmpegTranscoderTest {

    ...

    @Test

    public void transcodeWithOneOutputFormat() {

        File multimediaFile = new File("src/test/resources/sample.avi");

        List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();

        outputFormats.add(new OutputFormat(160, 120, 150, VideoCodec.H264,

                AudioCodec.AAC));

        List<File> transcodedFiles = transcoder.transcode(multimediaFile,

                outputFormats);

        ...

    }

}


컴파일 에러가 나지 않도록 OutputFormat 생성자에 Container를 값으로 전달해 준다.


public class VideoConverterTest {


    @Test

    public void transcode() {

        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);


        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,

                Container.MP4, VideoCodec.H264, AudioCodec.AAC);

        VideoConverter writer = new VideoConverter(TRANSCODED_FILE, reader,

                outputFormat);

        ...

    }

}



public class FfmpegTranscoderTest {

    ...

    @Test

    public void transcodeWithOneOutputFormat() {

        File multimediaFile = new File("src/test/resources/sample.avi");

        List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();

        outputFormats.add(new OutputFormat(160, 120, 150, Container.MP4

       VideoCodec.H264, AudioCodec.AAC));

        ...

    }

}


위와 같이 변경했으면, 두 테스트가 정상적으로 동작하는지 다시 테스트 해 본다. 녹색! 통과다.


AVI 변환 추가


이제 AVI 파일로 변환해주는 기능을 추가해 넣고 테스트를 해 보자. 이를 위해 FfmpegTranscoderTest를 다음과 같이 작성하였다. (음,, 바로 이전 코드하고 좀 많이 변경되었는데, 하나 하나 설명하지 않고 한 번에 넘어간 점 양해 바란다. 중복된 부분을 별도 메서드로 분리하고 setup에서 초기화를 진행하도록 수정했다.)


public class FfmpegTranscoderTest {


    private Transcoder transcoder;

    private File multimediaFile;

    private List<OutputFormat> outputFormats;


    private OutputFormat mp4Format;

    private OutputFormat aviFormat;


    @Before

    public void setup() {

        outputFormats = new ArrayList<OutputFormat>();

        mp4Format = new OutputFormat(160, 120, 150, Container.MP4,

                VideoCodec.H264, AudioCodec.AAC);

        aviFormat = new OutputFormat(160, 120, 150, Container.AVI,

                VideoCodec.MPEG4, AudioCodec.MP3);

        multimediaFile = new File("src/test/resources/sample.avi");


        transcoder = new FfmpegTranscoder(namingRule);

    }


    @Test

    public void transcodeWithOneMp4OutputFormat() {

        outputFormats.add(mp4Format);

        executeTranscoderAndAssert();

    }


    private void executeTranscoderAndAssert() {

        List<File> transcodedFiles = transcoder.transcode(multimediaFile,

                outputFormats);

        assertEquals(1, transcodedFiles.size());

        assertTrue(transcodedFiles.get(0).exists());

        VideoFormatVerifier.verifyVideoFormat(outputFormats.get(0),

                transcodedFiles.get(0));

    }


    @Test

    public void transcodeWithOneAviOutputFormat() {

        outputFormats.add(aviFormat);

        executeTranscoderAndAssert();

    }

}


AVI 변환에 대한 테스트 코드를 넣었다. 테스트 실행! 실패다. 실패 이유는 다음과 같다.


실패 메시지:

org.junit.ComparisonFailure: expected:<[avi]> but was:<[mp4]>

    at org.junit.Assert.assertEquals(Assert.java:123)

    at org.junit.Assert.assertEquals(Assert.java:145)

    at org.chimi.s4t.infra.ffmpeg.VideoFormatVerifier.assertExtension(VideoFormatVerifier.java:47)

    at org.chimi.s4t.infra.ffmpeg.VideoFormatVerifier.verify(VideoFormatVerifier.java:37)

    ...


실재 난 테스트 메서드:

    @Test

    public void transcodeWithOneAviOutputFormat() {

        outputFormats.add(aviFormat);

        executeTranscoderAndAssert();

    }

}


파일명이 AVI이길 기대했는데 실제 파일 이름은 MP4 여서 테스트를 통과하지 못했다. 이제 테스트를 통과시켜 보자. FfmpegTranscoder가 OutputFormat을 이용해서 파일의 확장자를 결정하도록 수정했다.


public class FfmpegTranscoder implements Transcoder {


    @Override

    public List<File> transcode(File multimediaFile,

            List<OutputFormat> outputFormats) {

        List<File> results = new ArrayList<File>();

        for (OutputFormat format : outputFormats) {

            results.add(transcode(multimediaFile, format));

        }

        return results;

    }


    private File transcode(File sourceFile, OutputFormat format) {

        IMediaReader reader = ToolFactory.makeReader(sourceFile

                .getAbsolutePath());


        String outputFile = getFileName(format);

        VideoConverter converter = new VideoConverter(outputFile, reader,

                format);

        reader.addListener(converter);

        while (reader.readPacket() == null)

            do {

            } while (false);

        return new File(outputFile);

    }


    private String getFileName(OutputFormat format) {

        return "outputFile." + format.getFileExtension(); // 기존 "outputFile.mp4"

    }


}


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


지금까지 한 번에 1개의 변환을 처리하는 부분에 집중했는데, 다음 연습 8에서는 여러 형식으로 변환하는 기능을 추가해 보도록 하자.








Posted by 최범균 madvirus

댓글을 달아 주세요

구현 진도를 확 빼고 싶지만, 글을 남기면서 진행하다보면 코드가 더디게 나간다. 답답함이 좀 있지만 몇 가지 영역에 대해서는 글로 남기는 게 좋을 것 같다.


Ffmpeg을 이용한 Transcoder 구현체 만들기! 테스트부터 작성


지금까지 TranscodingService 부터 시작해서 Job까지 점진적으로 구현을 만들어왔다. 다음 작업 대상은 Job 객체에서 호출할 Transcoder의 구현체이다. ffmpeg를 사용할 것이므로 구현체의 이름을 FfmpegTranscoder로 정리해 봤다. 구현의 시작은 테스트부터이다. 테스트부터 만들어보자.


public class FfmpegTranscoderTest {


    private Transcoder transcoder;


    @Before

    public void setup() {

        transcoder = new FfmpegTranscoder();

    }


    @Test

    public void transcodeWithOnfOutputFormat() {

        File multimediaFile = new File(".");

        List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();

        outputFormats.add(new OutputFormat(640, 480, 300, "h264", "aac"));

        List<File> transcodedFiles = transcoder.transcode(multimediaFile,

                outputFormats);

        assertEquals(1, transcodedFiles.size());

        assertTrue(transcodedFiles.get(0).exists());

    }

}


위 테스트는 한 개의 비디오 파일에 대해 한 개의 변환 포맷을 입력으로 지정하고, 변환 결과로 생성된 파일이 존재하는 지 확인한다. 마음 같아서 생성된 파일을 분석해서 그 파일이 지정한 형식으로 올바르게 변환되었는지 확인하고 싶지만, 지금은 일단 위 수준에서 넘어가보자. (이 부분은 나중에 한 번 시도해 볼 것이다.)


FFmpegTranscoder 클래스가 없으니 컴파일 에러가 발생한다. 일단 이 클래스를 만들어서 컴파일 에러부터 없애자. 


다음으로 할 작업은 테스트를 통과시키는 것이다. 테스트 통과는 어렵지 않다. 빠르게 테스트를 통과시키기 위해 아래와 같이 코드를 작성한다.


public class FfmpegTranscoder implements Transcoder {


    @Override

    public List<File> transcode(File multimediaFile,

            List<OutputFormat> outputFormats) {

        List<File> results = new ArrayList<File>();

        for (OutputFormat format : outputFormats) {

            results.add(new File("."));

        }

        return results;

    }


}


이제 FfmpegTranscoderTest를 실행하자. 녹색, 통과다.


다음으로 할 작업은 테스트 코드를 좀 더 의미있게 변경하는 것이다. 지금은 단순히 파일이 존재하는지 여부로 검증하고 있는데, 더 정확하게 검증하려면 생성된 파일이 지정한 비디오 포맷과 크기를 갖는지의 여부로 검증해야 한다.


사용법을 익히기 위한 테스트 코드


생성된 비디오 결과물을 확인하기 위한 용도로 xuggler를 사용해 보기로 했다. xuggler는 ffmpeg을 자바에서 사용할 수 있도록 래핑해 놓은 라이브러리이다. 관련 네이티브 모듈을 모두 포함하고 있어서 jar 파일이 다소 크지만, ffmpeg를 로컬에 설치하고 네이티브를 만들거나 별도 프로세스로 호출해서는 사용하는 것 보다 편리하다가 판단되어 xuggler를 사용하기로 결정했다.


이 프로젝트는 Maven을 사용하고 있는데, 다음과 같은 설정으로 xuggler를 사용할 수 있다.


    <repositories>

        <repository>

            <id>xuggle repo</id>

            <url>http://xuggle.googlecode.com/svn/trunk/repo/share/java/</url>

        </repository>

    </repositories>


    <dependencies>

        <!-- ffmpeg -->

        <dependency>

            <groupId>xuggle</groupId>

            <artifactId>xuggle-xuggler</artifactId>

            <version>5.3</version>

        </dependency>

        ...


Xuggler를 클래스패스에 추가했으므로, xuggler의 이용방법을 익힐 차례이다. 이를 위해 Xuggler 관련 샘플 코드를 좀 검색한 뒤에 그 코드를 복사해서 다음과 같은 테스트를 만들었다.


public class XugglerTest {


    @Test

    public void getMetadataOfExistingAVIFile() {

        IContainer container = IContainer.make();

        int openResult = container.open("src/test/resources/sample.avi",

                IContainer.Type.READ, null);

        if (openResult < 0) {

            throw new RuntimeException("Xuggler file open failed: "

                    + openResult);

        }

        int numStreams = container.getNumStreams();

        System.out.printf("file \"%s\": %d stream%s; ",

                "src/test/resources/sample.avi", numStreams,

                numStreams == 1 ? "" : "s");

        System.out.printf("bit rate: %d; ", container.getBitRate());

        System.out.printf("\n");


        for (int i = 0; i < numStreams; i++) {

            IStream stream = container.getStream(i);

            IStreamCoder coder = stream.getStreamCoder();


            System.out.printf("stream %d: ", i);

            System.out.printf("type: %s; ", coder.getCodecType());

            System.out.printf("codec: %s; ", coder.getCodecID());

            System.out.printf("duration: %s; ",

                    stream.getDuration() == Global.NO_PTS ? "unknown" : ""

                            + stream.getDuration());

            System.out.printf("start time: %s; ",

                    container.getStartTime() == Global.NO_PTS ? "unknown" : ""

                            + stream.getStartTime());

            System.out.printf("timebase: %d/%d; ", stream.getTimeBase()

                    .getNumerator(), stream.getTimeBase().getDenominator());

            System.out.printf("coder tb: %d/%d; ", coder.getTimeBase()

                    .getNumerator(), coder.getTimeBase().getDenominator());


            if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_AUDIO) {

                System.out.printf("sample rate: %d; ", coder.getSampleRate());

                System.out.printf("channels: %d; ", coder.getChannels());

                System.out.printf("format: %s", coder.getSampleFormat());

            } else if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_VIDEO) {

                System.out.printf("width: %d; ", coder.getWidth());

                System.out.printf("height: %d; ", coder.getHeight());

                System.out.printf("format: %s; ", coder.getPixelType());

                System.out.printf("frame-rate: %5.2f; ", coder.getFrameRate()

                        .getDouble());

            }

            System.out.printf("\n");

        }

        container.close();

    }

}


위 코드를 이용해서 테스트를 돌려보고 어떤 결과값들이 출력되는지 확인한다. 몇 차례 코드를 수정하면서 테스트를 더 해 보면서 Xuggler를 이용해서 정보를 읽어오는 방법을 숙지해 나간다.


다시 FfmpegTranscoderTest로, 결과값 검증하는 코드 만들기


앞의 FfmpegTranscoderTest 코드에 변환 결과로 생성된 비디오 파일을 검증하는 코드를 추가해 보자.


public class FfmpegTranscoderTest {

    ...

    @Test

    public void transcodeWithOnfOutputFormat() {

        File multimediaFile = new File("src/test/resources/sample.avi");

        List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();

        outputFormats.add(new OutputFormat(640, 480, 300, "h264", "aac"));

        List<File> transcodedFiles = transcoder.transcode(multimediaFile,

                outputFormats);

        assertEquals(1, transcodedFiles.size());

        assertTrue(transcodedFiles.get(0).exists());

        verifyTranscodedFile(outputFormats.get(0), transcodedFiles.get(0));

    }


    private void verifyTranscodedFile(OutputFormat outputFormat, File file) {

        IContainer container = IContainer.make();

        int openResult = container.open(file.getAbsolutePath(),

                IContainer.Type.READ, null);

        if (openResult < 0) {

            throw new RuntimeException("Xuggler file open failed: "

                    + openResult);

        }

        int numStreams = container.getNumStreams();


        int width = 0;

        int height = 0;

        ICodec.ID videoCodec = null;

        ICodec.ID audioCodec = null;


        for (int i = 0; i < numStreams; i++) {

            IStream stream = container.getStream(i);

            IStreamCoder coder = stream.getStreamCoder();


            if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_AUDIO) {

                audioCodec = coder.getCodecID();

            } else if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_VIDEO) {

                videoCodec = coder.getCodecID();

                width = coder.getWidth();

                height = coder.getHeight();

            }

        }

        container.close();


        assertEquals(outputFormat.getWidth(), width);

        assertEquals(outputFormat.getHeight(), height);

        assertEquals(outputFormat.getVideoFormat(), videoCodec.toString()); // ???

        assertEquals(outputFormat.getAudioFormat(), audioCodec.toString()); // ???

    }

}


verifyTranscodedFile() 메서드는 Xuggler를 이용해서 지정한 비디오 파일의 메타 정보가 OutputFormat에 지정된 값과 일치하는 지 여부를 검증한다. 테스트 코드의 마지막에서는 코덱을 비교하는 부분이 있는데, 다음의 두 가지가 걸린다.
  • OutputFormat의 메서드 이름이 getVideoFormat()/getAudioFormat()이다. 이건 getVideoCodec()과 같이 코덱의 의미로 바꿔줘야 할 것 같다.
  • OutputFormat은 String으로 값을 갖고 있는 반면에 비교하려는 값은 ICodec.ID 열거 타입이다. 이 둘 간에 변환 방법이 필요할 것 같다.
위 두 가지 중에서 첫 번째 것은 비교적 쉽다. 일단 먼저 처리하자. getVideoFormat과 getAudioFormat 이름을  getVideoCodec, getAutidoCodec 으로 변경한다.

두 번째는 Xuggler의 코덱 정보 표현 방법과 OutputFormat의 코덱 정보 표현 방법의 불일치로부터 발생하는 것이다. 이 부분은 뒤에서 다시 논의해 보자.

테스트 코드를 만들었으니 테스트를 실행한다. 빨간색. 통과에 실패했다. 실패를 발생시킨 코드는 아래와 같다.

// FfmpegTranscoderTest.java
    private void verifyTranscodedFile(OutputFormat outputFormat, File file) {
        IContainer container = IContainer.make();
        int openResult = container.open(file.getAbsolutePath(),
                IContainer.Type.READ, null);
        if (openResult < 0) { // 파일이 없으므로, 익셉션 발생됨
            throw new RuntimeException("Xuggler file open failed: "
                    + openResult);
        }


// FfmpegTranscoder.java
public class FfmpegTranscoder implements Transcoder {

    @Override
    public List<File> transcode(File multimediaFile,
            List<OutputFormat> outputFormats) {
        List<File> results = new ArrayList<File>();
        for (OutputFormat format : outputFormats) {
            results.add(new File(".")); // 디렉터리 정보임
        }
        return results;
    }

}

실패가 발생된 이유는 검증을 하기 위해 생성된 파일을 여는 과정에서 파일이 없어 발생한 것이다. 그 에러를 통과하려면 FfmpegTranscoder#transcode() 메서드에서 실제 파일을 리턴해 주어야 한다. 뿐만 아니라 폭, 높이 등에 대한 검증을 통과하려면 실제 비디오 파일을 생성해서 리턴해 주어야 한다.

아,, 비디오 변환 기능이 필요한 시점이 왔다. 음,,, 다시 Xuggler 테스트로 돌아가서 비디오 파일 변환 기능을 테스트 해 보자.

테스트 코드에서 Xuggler로 비디오 포맷 변환 기능 실험하기

Xuggler로 비디오 포맷을 변환하는 작업은 간단하다. 아래는 최초 테스트 코드이다.

public class XugglerTest {

    @Test
    public void transcode() {
        IMediaReader reader = ToolFactory
                .makeReader("src/test/resources/sample.avi");

        IMediaWriter writer = ToolFactory.makeWriter("target/sample.mp4",
                reader);
        reader.addListener(writer);
        while (reader.readPacket() == null)
            do {
            } while (false);
    }

아주 간단하게 비디오 변환을 할 수 있기에, 처음에는 '우~와' 했다. 그런데, 우리는 동영상의 크기도 변경시켜야 하고 비트레이트도 변경시킬 수 있어야 하고, MP4 컨테이너 정보 외에 인코딩할 때 사용할 비디오와 오디오의 코덱을 지정할 수 있어야 한다. 앗, 잠깐 컨테이너? 이런, 컨테이너를 설정하는 정보가 없다. 음, 일단 컨테이너에 대한 정보는 추후에 추가하자.

Xuggler가 다행히 오픈소스다. Xuggler의 소스 코드들을 탐색하고 테스트를 진행하면서, Xuggler를 이용한 VideoConverter를 완성했다. 더불어 VideoConverter를 만들어나가는 과정에서 다음을 함께 진행했다.

  • 테스트를 위한 VideoConverterTest 작성
  • OutputFormat에서 코덱 정보를 표현하기 위해 VideoCodec과 AudioCodec 타입이 도메인 모델에 추가
VideoConverterTest를 작성하면서 생성된 비디오의 내용물이 올바른지 확인하는 기능이 필요하다. 앞서 살펴봤던 FfmpegTranscoderTest의 verifyTranscodedFile() 메서드와 동일하다. 그러니, 이 메서드를 별도 헬퍼 클래스로 빼서 두 개의 테스트 코드에서 함께 사용할 수 있도록 하자.

헬러 클래스를 다음과 같이 작성해 보았다. 

public class VideoFormatVerifier {

    public static void verifyVideoFormat(OutputFormat expectedFormat,
            File videoFile) {
        new VideoFormatVerifier(expectedFormat, videoFile).verify();
    }

    private IContainer container;
    private int width;
    private int height;
    private ICodec.ID videoCodec;
    private ICodec.ID audioCodec;

    private OutputFormat expectedFormat;
    private File videoFile;

    public VideoFormatVerifier(OutputFormat expectedFormat, File videoFile) {
        this.expectedFormat = expectedFormat;
        this.videoFile = videoFile;
    }

    public void verify() {
        try {
            makeContainer();
            extractMetaInfoOfVideo();
            assertVideoFile();
        } finally {
            closeContainer();
        }
    }

    private void makeContainer() {
        container = IContainer.make();
        int openResult = container.open(videoFile.getAbsolutePath(),
                IContainer.Type.READ, null);
        if (openResult < 0) {
            throw new RuntimeException("Xuggler file open failed: "
                    + openResult);
        }
    }

    private void extractMetaInfoOfVideo() {
        int numStreams = container.getNumStreams();
        for (int i = 0; i < numStreams; i++) {
            IStream stream = container.getStream(i);
            IStreamCoder coder = stream.getStreamCoder();
            if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_AUDIO) {
                audioCodec = coder.getCodecID();
            } else if (coder.getCodecType() == ICodec.Type.CODEC_TYPE_VIDEO) {
                videoCodec = coder.getCodecID();
                width = coder.getWidth();
                height = coder.getHeight();
            }
        }
    }

    private void closeContainer() {
        if (container != null)
            container.close();
    }

    private void assertVideoFile() {
        assertEquals(expectedFormat.getWidth(), width);
        assertEquals(expectedFormat.getHeight(), height);
        assertEquals(expectedFormat.getVideoCodec(),
                CodecValueConverter.toDomainVideoCodec(videoCodec));
        assertEquals(expectedFormat.getAudioCodec(),
                CodecValueConverter.toDomainAudioCodec(audioCodec));
    }

}

VideoConverterTest 클래스는 다음과 같이 VideoFormatVerifier를 이용해서 변환 결과를 검증하도록 했다.

public class VideoConverterTest {

    @Test
    public void transcode() {
        IMediaReader reader = ToolFactory
                .makeReader("src/test/resources/sample.avi");

        OutputFormat outputFormat = new OutputFormat(160, 120, 150,
                VideoCodec.H264, AudioCodec.AAC);
        VideoConverter writer = new VideoConverter("target/sample.mp4", reader,
                outputFormat);
        reader.addListener(writer);
        while (reader.readPacket() == null)
            do {
            } while (false);

        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File("target/sample.mp4"));
    }
}

위 테스트가 통과할 때 까지 VideoConverter를 지속적으로 수정해 나갔고, 최종적으로 녹색이 들어왔다.

테스트 코드의 몇 가지 값들을 상수로 바꾸면서 의미를 명확하게 바꾸자.

public class VideoConverterTest {

    private static final int WIDTH = 160;
    private static final int HEIGHT = 120;
    private static final int BITRATE = 150;
    private static final String SOURCE_FILE = "src/test/resources/sample.avi";
    private static final String TRANSCODED_FILE = "target/sample.mp4";

    @Test
    public void transcode() {
        IMediaReader reader = ToolFactory.makeReader(SOURCE_FILE);

        OutputFormat outputFormat = new OutputFormat(WIDTH, HEIGHT, BITRATE,
                VideoCodec.H264, AudioCodec.AAC);
        VideoConverter writer = new VideoConverter(TRANSCODED_FILE, reader,
                outputFormat);
        reader.addListener(writer);
        while (reader.readPacket() == null)
            do {
            } while (false);

        VideoFormatVerifier.verifyVideoFormat(outputFormat, new File(TRANSCODED_FILE));
    }
}

다시 테스트. 녹색! 통과다.

FfmpegTranscoderTest로 돌아와 FfmpegTranscoder 구현하기

앞에서 작성한 VideoConverterTest 클래스의 transcode() 메서드는 FfmpegTranscoder#transcode() 메서드에서 비디오를 변환하기 위한 코드로 그대로 사용된다. 알맞게 FfmpegTranscoder#transcode()에 넣어보자.

public class FfmpegTranscoder implements Transcoder {

    @Override
    public List<File> transcode(File multimediaFile,
            List<OutputFormat> outputFormats) {
        List<File> results = new ArrayList<File>();
        for (OutputFormat format : outputFormats) {
            results.add(transcode(multimediaFile, format)); // 기존 new File(".")로 처리
        }
        return results;
    }

    private File transcode(File sourceFile, OutputFormat format) {
        IMediaReader reader = ToolFactory.makeReader(sourceFile
                .getAbsolutePath());

        String outputFile = "outputFile.mp4"; // 음, 일단 테스트 통과 위한 코딩
        VideoConverter converter = new VideoConverter(outputFile, reader,
                format);
        reader.addListener(converter);
        while (reader.readPacket() == null)
            do {
            } while (false);
        return new File(outputFile);
    }

}

FfmpegTranscoder 클래스를 작성했다. 음, 일단 테스트를 통과시키기 위해 결과 파일 이름을 지정했다. 마음에 안 드는 부분이다.

FfmpegTranscoder 클래스가 실제로 비디오 파일 변환을 처리하도록 구현했으니, 테스트를 실행해 보자. FfmpegTranscoderTest 클래스는 앞서 작성했던 VideoFormatVerifier를 이용해서 변환된 결과를 확인하도록 했다.

public class FfmpegTranscoderTest {

    private Transcoder transcoder;

    @Before
    public void setup() {
        transcoder = new FfmpegTranscoder();
    }

    @Test
    public void transcodeWithOnfOutputFormat() {
        File multimediaFile = new File("src/test/resources/sample.avi");
        List<OutputFormat> outputFormats = new ArrayList<OutputFormat>();
        outputFormats.add(new OutputFormat(160, 120, 150, VideoCodec.H264,
                AudioCodec.AAC));
        List<File> transcodedFiles = transcoder.transcode(multimediaFile,
                outputFormats);
        assertEquals(1, transcodedFiles.size());
        assertTrue(transcodedFiles.get(0).exists());
        VideoFormatVerifier.verifyVideoFormat(outputFormats.get(0),
                transcodedFiles.get(0));
    }
}

테스트 실행... 녹색이다. 야호!

다음에 할 것
  • 변환 결과로 생성되는 파일의 이름을 만들 정책이 필요하다.
  • MP4, AVI 등 컨테이너에 대한 정보 추가가 필요하다.
FfmpegTranscoder 클래스는 변환 내용에 상관없이 항상 outputFile.mp4라는 파일을 생성한다. AVI나 MP4, WMV 등 컨테이너에 종류를 지정하는 방법이 필요할 것 같고, 컨테이너 종류에 따라 생성되는 파일의 확장자가 변경되도록 하는 기능도 필요할 것 같다.

다음 TDD 연습 7에서는 이 두가지를 진행해보자.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2012.11.05 14:33 신고  댓글주소  수정/삭제  댓글쓰기

    "사용법을 익히기 위한 테스트 코드"
    이런 걸 study test라고 한다고 함. - 토비의 스프링3에서.
    사용법을 익히는 데는 실제로 실행해 보는 것이 가장 좋은 방법.
    자세한 사용법은 필요한 경우 찾아서 보게됨. 처음부터 한글자도 안 빼고 읽어보는 개발자는 없을 듯.
    이런 테스트도 사용법을 익혔고 별로 의미 없을 것 같다고 지운면 안됨.
    후에 다른 개발자가 동일한 라이브러리 사용법을 배우거나 업그레이드로 인한 문제점 파악에 도움이 됨.

TDD 연습은 오늘도 계속된다. 지난 금요일 점심 때 살짝 작업해 본 코드를 GitHub에 커밋하는 걸 깜빡해서 주말에 진행하지 못했다. 음, 그덕에 오늘 밤에 진행한다.


Job에 새로운 모델 추가


오늘은 Job에 모델을 추가해볼 거다. 비디오 변환을 처리하면 변환된 결과 비디오 파일과 썸네일 파일이 생성되는데, 그 파일을 작업 요청자가 지정한 위치에 저장해야 한다. 이 위치는 FTP가 될 수도 있고, HTTP 파일 업로드가 될 수도 있고, 단순히 로컬 파일 시스템이 될 수도 있다. 자, 일단, 구현의 상세함은 뒤로 미루고 다음과 같이 결과 파일이 보관될 위치를 추상화해서 표현할 수 있을 것이다. 


public class Job {


    private Long id;

    private MediaSourceFile mediaSourceFile;

    private DestinationStorage destinationStorage;

    private State state;

    private Exception occurredException;


    public Job(Long id, MediaSourceFile mediaSourceFile,

            DestinationStorage destinationStorage) {

        this.id = id;

        this.mediaSourceFile = mediaSourceFile;

        this.destinationStorage = destinationStorage;

    }


뭔가 이름이 적당한 게 떠오르지 않는다. (이럴 땐 영어공부를 게을리한 필자가 안타깝다.) 하튼, 이름은 DestinationStorage로 가기로 했다. 나중에 더 좋은 이름이 떠오르면 이클립스의 힘을 빌려 쉽게 바꿀 수 있다.


새로운 타입이 출현했다. 아무것도 없으므로 컴파일 에러가 난다. DestinationStorage 타입을 생성해주자. 그리고, Job 클래스의 생성자가 변경되었으므로 테스트 코드에서 Mock을 이용해서 DestinationStorage를 전달해준다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {


    private Long jobId = new Long(1);

    @Mock

    private MediaSourceFile mediaSourceFile;

    @Mock

    private DestinationStorage destinationStorage;


    private Job mockJob;

    ...

    

    @Before

    public void setup() {

        mockJob = new Job(jobId, mediaSourceFile, destinationStorage);

        when(mediaSourceFile.getSourceFile()).thenReturn(mockMultimediaFile);


        transcodingService = new TranscodingServiceImpl(transcoder,

                thumbnailExtractor, createdFileSender, jobResultNotifier,

                jobRepository);


바꿨으니 당연히 뭘 해야 하는지 알 거다. 테스트를 실행해보자. 기존 코드에 문제가 없음을 확인한다.


앞서 Job에 MediaSourceFile을 추가했던 과정을 생각해보자. (기억이 안 나면 http://javacan.tistory.com/221 글을 다시 보자.) 그 과정과 마찬가지로 다음과 같은 작업을 진행할 것이다.

  1. 목적지를 알고 있는 것은 DestinationStorage이므로, Job이 DestinationStorage에 파일을 저장해 달라고 요청하도록 코드를 수정한다.
  2. 이렇게 되면 CreatedFileSaver가 필요없지므로 삭제한다.
자 일단 Job이 DestinationStorage에게 파일을 저장해달라고 요청하도록 코드를 수정한다.

public class Job {

    private Long id;
    private MediaSourceFile mediaSourceFile;
    private DestinationStorage destinationStorage;
    ...
    public void transcode(Transcoder transcoder,
            ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier) {
        try {
            ...
            changeState(Job.State.STORING);
            storeCreatedFilesToStorage(multimediaFiles, thumbnails,
                    createdFileSaver);
            changeState(Job.State.NOTIFYING);
            notifyJobResultToRequester(jobResultNotifier);
            changeState(Job.State.COMPLETED);
        } catch (RuntimeException ex) {
            exceptionOccurred(ex);
            throw ex;
        }
    }

    private void storeCreatedFilesToStorage(List<File> multimediaFiles,
            List<File> thumbnails, CreatedFileSaver createdFileSaver) {
        destinationStorage.save(multimediaFiles, thumbnails);
        createdFileSaver.store(multimediaFiles, thumbnails, id);
    }

createdFileSaver를 호출하는 코드는 필요없으므로 삭제한다. destinationStorage는 save 메서드가 없어서 컴파일 에러가 난다. 컴파일 에러가 나지 않도록 save() 메서드를 추가해 준다. 이클립스의 만능키 Ctrl+1을 누루면 바로 추가되므로 쉽게 컴파일 에러를 제거할 수 있다.


public interface DestinationStorage {


    void save(List<File> multimediaFiles, List<File> thumbnails);


}


오케이 일단 뭔가 바꿨다. 테스트 코드를 돌려보자. .... 빨간불. 통과에 실패했다. 원인은 아래와 같다.


Wanted but not invoked:

createdFileSender.store([], [], 1);

-> at org...TranscodingServiceImplTest.verifyCollaboration(TranscodingServiceImplTest.java:117)

at org...TranscodingServiceImplTest.verifyCollaboration(TranscodingServiceImplTest.java:117)

...


실패 원인은 createdFileSender.store()가 호출되지 않은 것 때문이다. 앞서 Job 클래스의 storeCreatedFilesToStorage() 메서드에서 createFileSaver 객체의 store() 메서드를 호출하는 부분을 삭제했기 때문에 발생한 것이다. 이제 이 부분은 createdFileSender가 아니라 destinationStorage.save() 메서드가 호출되었는지 검증하는 코드로 바꿔야 한다. 그래서, 아래와 같이 테스트 코드에서 협업 객체들의 메서드 호출 여부를 검증하는 부분을 변경하였다.


    private void verifyCollaboration(VerifyOption verifyOption) {

        if (verifyOption.transcoderNever)

            verify(transcoder, never()).transcode(any(File.class), anyLong());

        else

            verify(transcoder, only()).transcode(mockMultimediaFile, jobId);


        if (verifyOption.thumbnailExtractorNever)

            verify(thumbnailExtractor, never()).extract(any(File.class),

                    anyLong());

        else

            verify(thumbnailExtractor, only()).extract(mockMultimediaFile,

                    jobId);


        if (verifyOption.destinationStorageNever) // createdFileSaverNever를 이름 변경

            verify(destinationStorage, never()).save(anyListOf(File.class),

                    anyListOf(File.class));

        else

            verify(destinationStorage, only()).save(mockMultimediaFiles,

                    mockThumbnails);


        if (verifyOption.createdFileSenderNever)

            verify(createdFileSender, never()).store(anyListOf(File.class),

                    anyListOf(File.class), anyLong());

        else

            verify(createdFileSender, only()).store(mockMultimediaFiles,

                    mockThumbnails, jobId);


        if (verifyOption.jobResultNotifierNever)

            verify(jobResultNotifier, never()).notifyToRequester(jobId);

        else

            verify(jobResultNotifier, only()).notifyToRequester(jobId);

    }


destinationStorage Mock 객체의 save() 메서드가 호출되는 지의 여부로 변경했으므로,  다시 테스트를 실행해 보자. 6개의 테스트 메서드 중에서 1개가 실패가 난다. 실패한 테스트는 아래 메서드이다.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {

        doThrow(mockException).when(createdFileSender).store(

                mockMultimediaFiles, mockThumbnails, jobId);


        executeFailingTranscodeAndAssertFail(Job.State.STORING);


        VerifyOption verifyOption = new VerifyOption();

        verifyOption.jobResultNotifierNever = true;


        verifyCollaboration(verifyOption);

    }


    private void executeFailingTranscodeAndAssertFail(State expectedLastState) {

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함"); // 익셉션이 발생하지 않아서 실패

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }

        ...

    }



검증하는 코드에서는 익셉션이 발생해야 통과되는데, Job 객체를 수정하는 바람에 더 이상 createdFileSaver 객체가 호출되지 않게 되었다. 위 코드는 createdFileSaver 대신 새롭게 추가된 모델인 destinationStorage의 save() 메서드에서 익셉션을 발생하도록 수정해주면 통과 시킬 수 있을 것 같다. 메서드 이름도 함께 변경해 주자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {

        doThrow(mockException).when(destinationStorage).save(

                mockMultimediaFiles, mockThumbnails);


        executeFailingTranscodeAndAssertFail(Job.State.STORING);


        VerifyOption verifyOption = new VerifyOption();

        verifyOption.jobResultNotifierNever = true;


        verifyCollaboration(verifyOption);

    }


다시 테스트 코드 실행. 모두 녹색. 통과다.


이제 필요 없어진 코드가 있다. 바로 CreatedFileSaver 이다. 이제 이 타입을 사용하는 코드를 모두 과감하게 삭제한다. (주석으로 막아서 테스트를 실행하고 통과되면 지우는 방법을 점진적으로 삭제한다.)


Job 클래스의 작은 리팩토링


필자와 개인적으로 친분이 있는 분께서 http://javacan.tistory.com/221#comment9371876와 같은 의견을 주셨다. 그 중 첫 번째 Job 클래스 부분에 대한 피드백은 아래 코드에서 구조적인 반복이 나타난다는 점이었다. 상태를 변경하고 작업 실행하고, 또 상태를 변경하고 작업을 실행하는 구조를 갖고 있다.


    public void transcode(Transcoder transcoder,

            ThumbnailExtractor thumbnailExtractor,

            JobResultNotifier jobResultNotifier) {

        try {

            changeState(Job.State.MEDIASOURCECOPYING);

            File multimediaFile = copyMultimediaSourceToLocal();

            changeState(Job.State.TRANSCODING);

            List<File> multimediaFiles = transcode(multimediaFile, transcoder);

            changeState(Job.State.EXTRACTINGTHUMBNAIL);

            List<File> thumbnails = extractThumbnail(multimediaFile,

                    thumbnailExtractor);

            changeState(Job.State.STORING);

            storeCreatedFilesToStorage(multimediaFiles, thumbnails);

            changeState(Job.State.NOTIFYING);

            notifyJobResultToRequester(jobResultNotifier);

            changeState(Job.State.COMPLETED);

        } catch (RuntimeException ex) {

            exceptionOccurred(ex);

            throw ex;

        }

    }


우선, 상태 변경과 작업 실행 순서 관리를 transcode()가 처리하고 있는데, 이는 transcode()의 역할을 잘못 잡은 것 같다. 실제 상태의 변화는 각각의 메서드에서 처리하고 transcode() 메서드는 순서대로 작업이 실행되도록 하는 것이 적당한 역할을 수행하는 것으로 생각된다. 그래서, 상태 변경은 각 메서드에서 처리하도록 수정했다. 그리고 완료 상태로 변경하는 부분은 completed()라는 메서드를 만들어서 그 메서드에서 처리하도록 했다.


public class Job {

    ...

    public void transcode(Transcoder transcoder,

            ThumbnailExtractor thumbnailExtractor,

            JobResultNotifier jobResultNotifier) {

        try {

            File multimediaFile = copyMultimediaSourceToLocal();

            List<File> multimediaFiles = transcode(multimediaFile, transcoder);

            List<File> thumbnails = extractThumbnail(multimediaFile,

                    thumbnailExtractor);

            storeCreatedFilesToStorage(multimediaFiles, thumbnails);

            notifyJobResultToRequester(jobResultNotifier);

            completed();

        } catch (RuntimeException ex) {

            exceptionOccurred(ex);

            throw ex;

        }

    }


    private File copyMultimediaSourceToLocal() {

        changeState(Job.State.MEDIASOURCECOPYING);

        return mediaSourceFile.getSourceFile();

    }


    private List<File> transcode(File multimediaFile, Transcoder transcoder) {

        changeState(Job.State.TRANSCODING);

        return transcoder.transcode(multimediaFile, id);

    }


    private List<File> extractThumbnail(File multimediaFile,

            ThumbnailExtractor thumbnailExtractor) {

        changeState(Job.State.EXTRACTINGTHUMBNAIL);

        return thumbnailExtractor.extract(multimediaFile, id);

    }


    private void storeCreatedFilesToStorage(List<File> multimediaFiles,

            List<File> thumbnails) {

        changeState(Job.State.STORING);

        destinationStorage.save(multimediaFiles, thumbnails);

    }


    private void notifyJobResultToRequester(JobResultNotifier jobResultNotifier) {

        changeState(Job.State.NOTIFYING);

        jobResultNotifier.notifyToRequester(id);

    }


    private void completed() {

        changeState(Job.State.COMPLETED);

    }


}


이제 Job은 작업을 순서대로 실행하는 한 가지 일만을 책임지게 되었으므로 정리가 된 느낌이다. 하지만, 여전히 각각의 메서드들은 상태변경-작업실행 요청의 반복적 구조를 갖고 있다. 이런 구조를 제거할 수 있는 다른 방법이 있을 것도 같다. 예를 들어, 각 단계를 State 같은 걸로 추상화하고 State가 지정된 순서대로 전이되도록 StateChain 같은 걸 만들어서 지정한 순서대로 각 단계의 작업이 실행되도록 할 수 있을 것 같다. 하지만, 이런 구조를 갖는 것은 현재로서는 유연함에서 오는 이점보다 복잡함에서 오는 단점이 더 많은 것 같다.


그래서, 위의 구조적 반복이 여전히 남아 있긴 하지만, 일단 이쯤에서 정리한다. 혹시 Job의 하위 타입이 생긴다거나 새로운 상태가 생긴다거나 실행 순서의 변경이 발생하게 된다면, 그 때 가서 다시 리팩토링을 해 보도록 하자. (이 정도로 지인분의 요구가 충족될지는........ 이 정도로 만족하시지요!)


테스트의 VerifyOption에 기능 주기


지인분께서 http://javacan.tistory.com/221#comment9371876에 남기신 두 번째 피드백은 verifyOption이 검증과 관련된 정보를 갖고 있으니, VerifyOption을 단순 데이터 구조가 아닌 기능을 제공하는 객체로 만들어보자는 내용이다. 음, 이 부분은 고민이 좀 된다. 테스트 코드에서 사용되는 데이터인데 그것까지 객체로 만들 필요가 있을까 하는 고민이었다. 그래도 변경하는게 어렵지 않으니 바꿔보자.


뭔가 협업 객체가 올바르게 호출되는지 여부를 확인하는 코드는 아래와 같은 방식으로 사용된다.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(

                mockException);


        executeFailingTranscodeAndAssertFail(Job.State.TRANSCODING);


        VerifyOption verifyOption = new VerifyOption();

        verifyOption.thumbnailExtractorNever = true;

        verifyOption.destinationStorageNever = true;

        verifyOption.jobResultNotifierNever = true;


        verifyCollaboration(verifyOption);

    }


    private void verifyCollaboration(VerifyOption verifyOption) {

        if (verifyOption.transcoderNever)

            verify(transcoder, never()).transcode(any(File.class), anyLong());

        else

            verify(transcoder, only()).transcode(mockMultimediaFile, jobId);


        if (verifyOption.thumbnailExtractorNever)

            verify(thumbnailExtractor, never()).extract(any(File.class),

                    anyLong());

        else

            verify(thumbnailExtractor, only()).extract(mockMultimediaFile,

                    jobId);


        if (verifyOption.destinationStorageNever)

            verify(destinationStorage, never()).save(anyListOf(File.class),

                    anyListOf(File.class));

        else

            verify(destinationStorage, only()).save(mockMultimediaFiles,

                    mockThumbnails);


        if (verifyOption.jobResultNotifierNever)

            verify(jobResultNotifier, never()).notifyToRequester(jobId);

        else

            verify(jobResultNotifier, only()).notifyToRequester(jobId);

    }


    public class VerifyOption {

        public boolean transcoderNever;

        public boolean thumbnailExtractorNever;

        public boolean destinationStorageNever;

        public boolean jobResultNotifierNever;

    }

}


우선, verifyCollaboration 메서드를 VerifyOption의 public 메서드로 이동시킨다. 물론, 옮기면서 this를 사용하도록 변경한다. 그리고, 각 테스트 메서드에서는 VerifyOption의 verifyCollaboration() 메서드를 호출하도록 변경한다. 그럼 코드는 아래와 같은 바뀐다.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {

        doThrow(mockException).when(destinationStorage).save(

                mockMultimediaFiles, mockThumbnails);


        executeFailingTranscodeAndAssertFail(Job.State.STORING);


        VerifyOption verifyOption = new VerifyOption();

        verifyOption.jobResultNotifierNever = true;


        verifyOption.verifyCollaboration();

    }


    public class VerifyOption {

        public boolean transcoderNever;

        public boolean thumbnailExtractorNever;

        public boolean destinationStorageNever;

        public boolean jobResultNotifierNever;


        public void verifyCollaboration() {

            if (this.transcoderNever)

                verify(transcoder, never()).transcode(any(File.class),

                        anyLong());

            else

                verify(transcoder, only()).transcode(mockMultimediaFile, jobId);


            if (this.thumbnailExtractorNever)

                verify(thumbnailExtractor, never()).extract(any(File.class),

                        anyLong());

            else

                verify(thumbnailExtractor, only()).extract(mockMultimediaFile,

                        jobId);


            if (this.destinationStorageNever)

                verify(destinationStorage, never()).save(anyListOf(File.class),

                        anyListOf(File.class));

            else

                verify(destinationStorage, only()).save(mockMultimediaFiles,

                        mockThumbnails);


            if (this.jobResultNotifierNever)

                verify(jobResultNotifier, never()).notifyToRequester(jobId);

            else

                verify(jobResultNotifier, only()).notifyToRequester(jobId);

        }

    }


바꿨으니 테스트 실행이다. 녹색! 통과되었다.


이름이 마음에 안 든다. 이름을 바꾸자. VerifyOption은 더 이상 단순 옵션이 아니다. 실제 검증을 수행하는 역할을 갖게 되었으므로, CollaborationVerifier와 같이 역할에 맞는 이름을 부여한다. verifyCollaboration() 메서드는 순히 verify()로 바꿔도 될 것 같다. 웃, verify()로 이름을 변경하려고 했더니 메서드 내부에서 사용한 Mockito의 verify()가 컴파일 에러가 난다. 음, 다른 이름을 찾아보자. 음.. 딱히 이름이 안 떠오른다. 일단 verifyCollaboration()으로 놔두자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtDestinationStorage() {

        doThrow(mockException).when(destinationStorage).save(

                mockMultimediaFiles, mockThumbnails);


        executeFailingTranscodeAndAssertFail(Job.State.STORING);


        CollaborationVerifier colVerifier = new CollaborationVerifier();

        colVerifier.jobResultNotifierNever = true;


        colVerifier.verifyCollaboration();

     }


    public class CollaborationVerifier {

        public boolean transcoderNever;

        public boolean thumbnailExtractorNever;

        public boolean destinationStorageNever;

        public boolean jobResultNotifierNever;


        public void verifyCollaboration() {

            if (this.transcoderNever)

                verify(transcoder, never()).transcode(any(File.class),

                        anyLong());

            else

                verify(transcoder, only()).transcode(mockMultimediaFile, jobId);


            ...

        }


    }

}


이름을 바꿨으니 다시 테스트! 녹색! 통과되었다.


CollaborationVerifier와 관련된 다음 고민은 아래와 같다.

  • transcoderNever, thumbnailExtractorNever 등의 필드를 private으로 바꿔야 하나?
  • verifyCollaboration() 메서드에서 접근하는 transcoder 등의 객체를 필드로 정의해야 하나?
  • CollaborationVerifier 클래스를 private으로 바꿔야 하나?
음, 아주 짧게만 고민해 봤는데 (슬슬 졸리기 시작한다), 현재로서 CollaborationVerifier 클래스는 TranscodingServiceImplTest 내부에서만 사용되므로, 필드가 public이어도 상관없을 것 같고 TranscodingServiceImplTest 클래스의 필드에 직접 접근해도 문제가 없다. 일단 지금은 CollaborationVerifier를 private으로 하는 것만으로 충분할 것 같다.


Posted by 최범균 madvirus

댓글을 달아 주세요

연습 4-1(http://javacan.tistory.com/220)에 이어 계속해서 Job 부분을 만들어 나가자. 


새로운 모델의 출현: MediaSourceFile


Job은 원본 미디어 파일 정보를 갖고 있어야 한다. 미디어 원본 파일을 MediaSourceFile로 추상화하고, 아래와 같이 이 타입을 필드로 갖도록 코드를 만들었다.


public class Job {

    ...

   private MediaSourceFile mediaSourceFile;

   ...

   public Job(Long id, MediaSourceFile mediaSourceFile) {

       this.id = id;

       this.mediaSourceFile = mediaSourceFile;

   }

   ...

}


빨간 줄이 나오니까 컴파일 에러가 나지 않도록 변경해 주자. 먼저 MediaSourceFile을 생성할 것이다. 아직 MediaSourceFile의 정확한 구현을 알 수 없으므로 일단 인터페이스로 만든다. 인터페이스만 있고 메서드는 없다.


// 아직 기능 없음

public interface MediaSourceFile {


}


Job 클래스의 컴파일 에러는 사라졌는데, 대신 TranscodingServiceImplTest 코드에서 컴파일 에러가 발생한다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {


    private Long jobId = new Long(1);

    private Job mockJob = new Job(jobId);


Job의 생성자가 변경되어서 컴파일 에러가 발생하고 있다. Job 객체를 생성하려면 MediaSourceFile 구현체가 필요하다. 일단, MediaSourceFile에 대한 Mock 객체를 만들고, 그 Mock 객체를 받도록 변경한다. Job 객체를 생성하는 위치는 setup() 메서드로 이동시킨다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {


    private Long jobId = new Long(1);

    @Mock

    private MediaSourceFile mediaSourceFile;

    

    private Job mockJob;

    ...


    @Before

    public void setup() {

        mockJob = new Job(jobId, mediaSourceFile);

        ...

뭔가 바꾸면 해야 할 게 있다. 그렇다, 바로 테스트 실행이다. 테스트 실행 ... 녹색바! 통과다.


MediaSourceFile은 미디어 파일을 복사하는 것과 관련되었으므로, Job 클래스의 copyMultimediaSourceToLocal() 메서드를 보자.


public class Job {


    private Long id;

    private MediaSourceFile mediaSourceFile;

    ...


    public Job(Long id, MediaSourceFile mediaSourceFile) {

        this.id = id;

        this.mediaSourceFile = mediaSourceFile;

    }


    public void transcode(MediaSourceCopier mediaSourceCopier,

            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,

            CreatedFileSaver createdFileSaver,

            JobResultNotifier jobResultNotifier) {

        try {

            changeState(Job.State.MEDIASOURCECOPYING);

            File multimediaFile = copyMultimediaSourceToLocal(mediaSourceCopier);

            ...

        } catch (RuntimeException ex) {

            exceptionOccurred(ex);

            throw ex;

        }

    }


    private File copyMultimediaSourceToLocal(MediaSourceCopier mediaSourceCopier) {

        return mediaSourceCopier.copy(id); // mediaSourceFile을 줘야 하나?

    }



copyMultimediaSourceToLocal() 메서드는 mediaSourceCopier 객체의 copy() 메서드를 호출할 때 id 필드를 값으로 주고 있다. 읽어올 미디어 원본 파일 정보가 mediaSourceFile 필드에 있으므로, id 대신 mediaSourceFile을 건내주면 될 것 같다.


음... 잠깐! MediaSourceFile에 다음과 같은 메서드를 정의하면 어떨까?


public interface MediaSourceFile {

    public File getSourceFile();

}


또는


public interface MediaSourceFile {

    public void writeTo(File file);

}


두 가지 중 어떤 것을 선택해도 mediaSourceCopier의 필요성이 사라진다. 오호~ 그렇군. 조금 고민하다가 파일이 어디에 보관될지 여부는 Job이 스스로 결정하는 게 좋을 듯 싶어, getSourceFile() 메서드를 사용하기로  결정했다. 결정했으니 바꿔보자.

  • MediaSourceFile 인터페이스에 getSourceFile() 메서드를 추가한다.
  • copyMultimediaSourceToLocal() 메서드가 mediaSourceFile.getSourceFile()를 사용하도록 변경한다.
  • 이렇게 바꾸면, copyMultimediaSourceToLocal() 메서드가 MediaSourceCopier를 필요로 하지 않는다. 그러니 파라미터에서 mediaSourceCopier를 제거한다.
Job에 적용한 결과는 아래와 같다.

public class Job {

    public void transcode(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier) {
        try {
            changeState(Job.State.MEDIASOURCECOPYING);
            File multimediaFile = copyMultimediaSourceToLocal(); // mediaSourceCopier 빠짐
            ...
        } catch (RuntimeException ex) {
            exceptionOccurred(ex);
            throw ex;
        }
    }

    private File copyMultimediaSourceToLocal() { // mediaSourceCopier 파라미터 제거
        return mediaSourceFile.getSourceFile();
    }

테스트를 실행해 보자. 앗! 모두 실패다. 실패나는 이유를 봤더니 mediaSourceCopier Mock 객체가 더 이상 호출되지 않아서 발생하는 문제였다. 단위 테스트 코드에서 mediaSourceCopier를 verify 하는 부분을 삭제한다.

    private void verifyCollaboration(VerifyOption verifyOption) {
        verify(mediaSourceCopier, only()).copy(jobId);

        if (verifyOption.transcoderNever)
            verify(transcoder, never()).transcode(any(File.class), anyLong());
        else
            verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        ...
    }

다시 실행해 보자. 앗! 그래도 모두 실패다. 위 코드에서 trancoder.trancode() 메서드가 호출되었는지 여부를 확인하는 부분에서 실패가 난다. trancoder.trancode()의 호출 여부를 검증할 때 사용한 파라미터는 mockMultimediaFile인데 실제로 Job 객체에서 mediaSourceFile.getSourceFile()은 null을 리턴한다. 그래서 검증에 실패하였다.

통과하게 하는 방법은 간단하다. 테스트 코드의 setup() 메서드에서 mediaSourceFile을 위해 생성한 Mock 객체의 getSourceFile() 메서드가 mockMultimediaFile을 리턴하도록 설정해주면 된다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
@Before
public void setup() {
mockJob = new Job(jobId, mediaSourceFile);
when(mediaSourceFile.getSourceFile()).thenReturn(mockMultimediaFile);
transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
transcoder, thumbnailExtractor, createdFileSender,
jobResultNotifier, jobRepository);

...

다시 테스트. 6개의 테스트 메서드 중에 한 개가 실패했다. 그 메서드는 다음과 같다.

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        executeFailingTranscodeAndAssertFail(Job.State.MEDIASOURCECOPYING);

        VerifyOption verifyOption = new VerifyOption();
        verifyOption.transcoderNever = true;
        verifyOption.thumbnailExtractorNever = true;
        verifyOption.createdFileSenderNever = true;
        verifyOption.jobResultNotifierNever = true;

        verifyCollaboration(verifyOption);
    }

MediaSourceCopier는 더 이상 호출되지 않기 때문에 mediaSourceCopier가 익셉션을 발생시키도록 만들고, 실제로 그렇게 되는지 확인하는 건 의미가 없다. mediaSourceCopier 대신 mediaSourceFile가 익셉션을 발생시키는 경우를 테스트 하도록 수정하는 것이 맞다. 그래서 아래와 같이 테스트를 바꾼다.

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceFile() {
        when(mediaSourceFile.getSourceFile()).thenThrow(mockException);

        executeFailingTranscodeAndAssertFail(Job.State.MEDIASOURCECOPYING);

        VerifyOption verifyOption = new VerifyOption();
        verifyOption.transcoderNever = true;
        verifyOption.thumbnailExtractorNever = true;
        verifyOption.createdFileSenderNever = true;
        verifyOption.jobResultNotifierNever = true;

        verifyCollaboration(verifyOption);
    }

테스트 실행,,, 녹색! 모두 통과다. 이로서 Job과 관련된 새로운 모델인 MediaSourceFile을 추가하고 MediaSourceFile이 원본으로부터 파일을 가져오도록 설계를 변경하는데 성공했다.


MediaSourceCopier는 이제 필요 없어요!


MediaSourceFile의 등장으로 MediaSourceCopier는 필요 없어졌다. 이제 과감하게 MediaSourceCopier를 소스 코드에서 제거할 차례이다. MediaSourceCopier 타입을 없애고, MediaSourceCopier 타입의 필드, 초기화 코드, 파라미터 등을 모두 제거한다.


// Job 클래스에서 제거

public class Job {

    ....

    public void transcode(MediaSourceCopier mediaSourceCopier,

            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,

            CreatedFileSaver createdFileSaver,

            JobResultNotifier jobResultNotifier) {

        try {



// TranscodingServceImpl 클래스에서 제거

public class TranscodingServiceImpl implements TranscodingService {

    private MediaSourceCopier mediaSourceCopier;

    private Transcoder transcoder;

    ...


    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,

            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,

            CreatedFileSaver createdFileSaver,

            JobResultNotifier jobResultNotifier,

            JobRepository jobRepository) {

        this.mediaSourceCopier = mediaSourceCopier;

        this.transcoder = transcoder;

        ...

    }


    @Override

    public void transcode(Long jobId) {

        Job job = jobRepository.findById(jobId);

        job.transcode(mediaSourceCopier, transcoder, thumbnailExtractor,

                createdFileSaver, jobResultNotifier);

    }



// 테스트 코드에서 제거

@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {

    ...

    @Mock

    private MediaSourceCopier mediaSourceCopier;

    @Mock

    private Transcoder transcoder;

    ...


    @Before

    public void setup() {

        mockJob = new Job(jobId, mediaSourceFile);

        when(mediaSourceFile.getSourceFile()).thenReturn(mockMultimediaFile);

        

        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,

                transcoder, thumbnailExtractor, createdFileSender,

                jobResultNotifier, jobRepository);


        when(jobRepository.findById(jobId)).thenReturn(mockJob);

        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);

        ...

    }


코드를 수정했으니까, 다음으로 할 작업은 테스트 실행이다. 테스트 실행~.. 녹색! 모두 통과다.


테스트 코드를 이용한 안정적인 리팩토링


TDD를 이용해서 TranscodingServce의 구현부터 Job의 일부 구현까지 진행되었다. 한 번 정리해 보자.


최초의 TDD 결과물은 아래와 같았다.



하지만, 결과물이 마음에 안 들었고 그래서 Job으로 기능을 옮겼다. 물론, 테스트는 그대로 유지하면서. 그래서 다음과 같이 Job이 출현하고 불필요해진 두 개의 타입이 사라졌다.



그리고, 새로운 도메인 모델인 MediaSourceFile을 추가하는 과정에서 일부 로직이 MediaSourceFile로 이동했고, 이 과정에서 MediaSourceCopier 타입이 또 사라졌다.



그리고, 무엇보다도 중요한 건, 테스트 코드를 통해서 이러한 변화 과정을 안정적으로 진행했다는 점이다.



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2012.10.26 20:11 신고  댓글주소  수정/삭제  댓글쓰기

    verifyCollaboration(verifyOption); -> verifyOption.verify();가 어떨까 ?
    글고
    dosomething(); changeState(); 의 구조적 반복 제거...
    ㅎㅎ
    재밌는데. 근데 이런걸 github에서 했으면 정말 재밌었을 듯.
    블로그에 댓글 놀이로는...

http://javacan.tistory.com/218#comment9368196 댓글을 보자. 사실 필자가 하고 만들고 싶었던 코드는 지금까지 작성했던 그런 코드가 아니었다. 그래 마음에 안 든다. 마음에 안 드는 것들은 다음과 같다.

  • TranscodingServiceImp이 Job의 상태 변경을 관리한다.
    • Job이 자기의 상태 변화를 직접 관리하는 것이 아니라 남이 변경을 한다.
    • Job이 작업 순서를 관리하는 게 아니라 남이 관리해 준다.
      • 향후 작업 순서가 바뀌거나 Job의 종류에 따라 작업 순서가 달려져야 할 경우, TranscodingServiceImpl에 변화가 생긴다. 뭔가 Job의 다형성이 안 될 것 같은 느낌이 든다.
이런 불만때문에, 리팩토링을 하기로 했다. 오, 리팩토링! 한 번 바꿔보자. 두려워할 필요는 없다. 우리에겐 리팩토링을 진행할 수 있도록 안전판 역할을 하는 테스트 코드가 있지 않은가!

TranscodingServiceImpl의 기능을 Job으로 이동시키기

우선 TranscodingServiceImpl의 transcode() 메서드를 Job에 복사한다. 물론, 관련 private 메서드도 복사한다.

public class Job {
    ...
    // TranscodingServiceImpl 클래스의 transcode 메서드를 복사
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        changeJobState(jobId, Job.State.TRANSCODING);
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        changeJobState(jobId, Job.State.EXTRACTINGTHUMBNAIL);
        List<File> thumbnails = extractThumbnail(multimediaFile, jobId);
        changeJobState(jobId, Job.State.STORING);
        storeCreatedFilesToStorage(multimediaFiles, thumbnails, jobId);
        changeJobState(jobId, Job.State.NOTIFYING);
        notifyJobResultToRequester(jobId);
        changeJobState(jobId, Job.State.COMPLETED);
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        try {
            return mediaSourceCopier.copy(jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }

    private List<File> transcode(File multimediaFile, Long jobId) {
        try {
            return transcoder.transcode(multimediaFile, jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }

    private List<File> extractThumbnail(File multimediaFile, Long jobId) {
        try {
            return thumbnailExtractor.extract(multimediaFile, jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }

    private void storeCreatedFilesToStorage(List<File> multimediaFiles,
            List<File> thumbnails, Long jobId) {
        try {
            createdFileSaver.store(multimediaFiles, thumbnails, jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }

    private void notifyJobResultToRequester(Long jobId) {
        try {
            jobResultNotifier.notifyToRequester(jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }

}

컴파일 에러가 난다. 일단 컴파일 에러부터 없애자. Job의 transcode() 메서드가 필요한 협업 객체를 전달받도록 하고, 전달받은 협업 객체를 다시 각각의 private 메서드 호출시 전달하는 방법으로 컴파일 에러를 없앨 것이다. 음.... 아니다. 필자가 생각한 결과가 있는데 컴파일 에러부터 먼저 없애고 단계적으로 수행하면 뭔가 많이 돌아간다. 그래서 몇 가지를 한 번에 처리할 것이다. 그 내용은 아래와 같다.
  • transcode() 메서드가 jobId를 전달받는데, 그럴 필요가 없다. 왜냐면, jobId를 알고 있는 건 Job이기 때문이다. Job에 이를 위해 id 필드를 추가한다. 그리고, 각 메서드는 jobId 파라미터 대신 id 필드를 사용하도록 바꾼다.
  • transcodingExceptionHandler가 필요했던 이유는 TranscodingServiceImpl이 에러 사실을 Job에 알리기 위함이었다. 위 코드가 Job 내부에서 실행되므로 이제 에러가 발생하면 Job이 알게 된다. 따라서, try-catch 블록은 transcode() 메서드에만 위치하면 되며, 나머지 다른 메서드의 try-catch는 필요 없다. 또한, transcodingExceptionHandler도 필요없다.
  • jobStateChanger가 필요했던 이유는 TranscodingServceImpl에서 job의 상태를 변경하기 위함이었다. 이제 Job 객체가 실행 흐름을 제어하므로 스스로 상태를 변경할 수 있게 되었다. 따라서, jobStateChanger가 필요 없다.
  • 또한, changeState() 메서드가 있으므로 changeJobState() 메서드는 더 이상 필요 없다. trancode() 메서드 내부에서 changeJobState() 메서드 대신에 changeState() 메서드를 호출하도록 변경하자.
  • mediaSourceCopier, jobResultNotifier 등의 협업 객체는 transcode() 메서드를 통해서 전달받도록 하고, transcode() 메서드가 다시 알맞은 협업 객체를 전달해주는 방식으로 바꾼다.
다음은 위 내용을 적용한 Job 클래스 코드이다.

public class Job {

    public static enum State {
        MEDIASOURCECOPYING, TRANSCODING, EXTRACTINGTHUMBNAIL, STORING, NOTIFYING, COMPLETED
    }

    private Long id;
    private State state;
    ...
    public void transcode(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier) {
        try {
            changeState(Job.State.MEDIASOURCECOPYING);
            File multimediaFile = copyMultimediaSourceToLocal(mediaSourceCopier);
            changeState(Job.State.TRANSCODING);
            List<File> multimediaFiles = transcode(multimediaFile, transcoder);
            changeState(Job.State.EXTRACTINGTHUMBNAIL);
            List<File> thumbnails = extractThumbnail(multimediaFile,
                    thumbnailExtractor);
            changeState(Job.State.STORING);
            storeCreatedFilesToStorage(multimediaFiles, thumbnails,
                    createdFileSaver);
            changeState(Job.State.NOTIFYING);
            notifyJobResultToRequester(jobResultNotifier);
            changeState(Job.State.COMPLETED);
        } catch (RuntimeException ex) { // 익셉션 처리를 각 메서드가 아닌 한 곳에서 수행
            exceptionOccurred(ex);
        }
    }

    private File copyMultimediaSourceToLocal(MediaSourceCopier mediaSourceCopier) {
        return mediaSourceCopier.copy(id);
    }

    private List<File> transcode(File multimediaFile, Transcoder transcoder) {
        return transcoder.transcode(multimediaFile, id);
    }

    private List<File> extractThumbnail(File multimediaFile,
            ThumbnailExtractor thumbnailExtractor) {
        return thumbnailExtractor.extract(multimediaFile, id);
    }

    private void storeCreatedFilesToStorage(List<File> multimediaFiles,
            List<File> thumbnails, CreatedFileSaver createdFileSaver) {
        createdFileSaver.store(multimediaFiles, thumbnails, id);
    }

    private void notifyJobResultToRequester(JobResultNotifier jobResultNotifier) {
        jobResultNotifier.notifyToRequester(id);
    }

}

Job 클래스에 처리 기능이 만들어졌으므로, 다음으로 할 작업은 TranscodingServceImpl 클래스의 transcode() 메서드가 Job 객체의 transcode() 메서드를 호출하도록 변경하는 것이다. 자 과감함이 필요할 때다. TranscodingServiceImpl 클래스에 있던 copyMultimediaSourceToLocal() 메서드 등은 필요 없다. 다 지워버리자. 바뀐 코드는 다음과 같다.

public class TranscodingServiceImpl implements TranscodingService {
    private MediaSourceCopier mediaSourceCopier;
    private Transcoder transcoder;
    private ThumbnailExtractor thumbnailExtractor;
    private CreatedFileSaver createdFileSaver;
    private JobResultNotifier jobResultNotifier;
    private JobStateChanger jobStateChanger;
    private TranscodingExceptionHandler transcodingExceptionHandler;
    private JobRepository jobRepository;

    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier,
            JobStateChanger jobStateChanger,
            TranscodingExceptionHandler transcodingExceptionHandler,
            JobRepository jobRepository) {
        this.mediaSourceCopier = mediaSourceCopier;
        this.transcoder = transcoder;
        this.thumbnailExtractor = thumbnailExtractor;
        this.createdFileSaver = createdFileSaver;
        this.jobResultNotifier = jobResultNotifier;
        this.jobStateChanger = jobStateChanger;
        this.transcodingExceptionHandler = transcodingExceptionHandler;
        this.jobRepository = jobRepository;
    }

    @Override
    public void transcode(Long jobId) {
        Job job = jobRepository.findById(jobId);
        job.transcode(mediaSourceCopier, transcoder, thumbnailExtractor, 
                createdFileSaver, jobResultNotifier);
    }

//    private void changeJobState(Long jobId, State newJobState) {
//        jobStateChanger.chageJobState(jobId, newJobState);
//    }
//
//    private File copyMultimediaSourceToLocal(Long jobId) {
//        try {
//            return mediaSourceCopier.copy(jobId);
//        } catch (RuntimeException ex) {
//            transcodingExceptionHandler.notifyToJob(jobId, ex);
//            throw ex;
//        }
//    }
... // 다른 메서드도 주석으로 처리해버림 (이따 지울 것임)

transcode() 메서드는 JobRepository에서 jobId에 해당하는 Job을 구한 뒤에 해당 Job 객체의 transcode() 메서드를 호출하는 것으로 바뀌었다. jobRepository 필드가 없어서 컴파일 에러가 나므로, jobRepository 필드를 추가해 주고, 생성자를 통해서 전달받도록 추가 구현했다.

TranscodingServiceImpl 클래스의 생성자에 변화가 생겼으므로, 테스트 코드에서 컴파일 에러가 난다. 에러가 나지 않도록 아래와 같이 setup() 메서드를 수정하자.

public class TranscodingServiceImplTest {
    .,.
    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger,
                transcodingExceptionHandler, jobRepository);

        when(jobRepository.findById(jobId)).thenReturn(mockJob);
        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
                mockMultimediaFiles);
        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
                mockThumbnails);
        ...

TranscodingServceImpl의 주요 기능을 Job으로 옮기는 작업이 끝났다. 이제 테스트 코드를 실행할 차례이다. 테스트 코드 실행 .... 두근 두근 두근,,, 켁,, 빨간색이다. 전체 테스트 메서드에서 다 에러가 났다. transcodeFailBecauseExceptionOccuredAtTranscoder() 메서드에의 실패 이유를 살펴보니 아래와 같다.

java.lang.AssertionError: 발생해야 함
    at org.junit.Assert.fail(Assert.java:91)
    at org.....executeFailingTranscodeAndAssertFail(TranscodingServiceImplTest.java:168)
    at org.....transcodeFailBecauseExceptionOccuredAtTranscoder(TranscodingServiceImplTest.java:186)
    ...

관련 테스트 코드 부분은 아래와 같다.

    private void executeFailingTranscodeAndAssertFail(State expectedLastState) {
        try {
            transcodingService.transcode(jobId);
            fail("발생해야 함"); // 테스트 실패 위치
        } catch (Exception ex) {
            assertSame(mockException, ex);
        }

아! 조금 코드를 파 보니, 처리 과정에서 익셉션이 발생하면 Job의 transcode() 메서드가 내부적으로 익셉션을 catch 한 뒤에 재전파하지 않는다. 일단, 통과되도록 아래와 같이 수정하자.

public class Job {
    ...
    public void transcode(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier) {
        try {
            changeState(Job.State.MEDIASOURCECOPYING);
            File multimediaFile = copyMultimediaSourceToLocal(mediaSourceCopier);
            ...
            changeState(Job.State.COMPLETED);
        } catch (RuntimeException ex) {
            exceptionOccurred(ex);
            throw ex;
        }
    }

다시 테스트를 실행하자. 아 그래도 빨간색이다. 좀 더 파보자. 오케이,, 원인을 찾았다. Job 객체의 id 필드는 항상 null 이다. 그래서, 테스트 코드의 Mock 객체들이 동작을 안 한 것이다. 이는 Job 객체의 id 필드를 1로 할당해주는 것으로 해결할 수 있다. 일단 테스트 통과를 위해 Job 클래스의 id 필드를 초기화할 수 있도록 생성자를 만들고, 테스트 클래스에서 1을 전달해주자.

// Job 클래스에 생성자 추가
public class Job {

    private Long id;
    ...    
    public Job(Long id) {
        this.id = id;
    }



// 테스트 코드의 Job 객체 생성 시 ID 값 전달
@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {

    private Long jobId = new Long(1);
    private Job mockJob = new Job(jobId);


음, 다시 테스트를 실행해 보자. 두그 두그 두그 두그.... 녹색! 야호!

테스트를 통과했으니, 앞에 TranscodingServiceImpl에서 주석으로 처리했던 부분을 시원하게 지워버리자.

필요 없는 인터페이스 삭제하기

이제 다음으로 할 작업은 필요없어진 타입을 삭제할 차례이다. 그 두 타입은 다음의 두 개이다.
  • JobStateChanger
  • TranscodingExceptionHandler
이 두 타입은 TranscodingServceImpl이 진행 상태 및 익셉션 상황을 Job에게 알리기 위해 출현했었다. 이 두 타입이 더 이상 필요 없어 졌으니 시원하게 지워버리자. 지우면 TranscodingServiceImpl 클래스가 이 두 타입이 없다고 컴파일 에러가 날 것이다. 컴파일 에러를 처리하자. 물론, 테스트 코드에도 컴파일 에러가 생길테니 그것도 처리하자.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {

    @Mock
    private MediaSourceCopier mediaSourceCopier;
    @Mock
    private Transcoder transcoder;
    @Mock
    private ThumbnailExtractor thumbnailExtractor;
    @Mock
    private CreatedFileSaver createdFileSender;
    @Mock
    private JobResultNotifier jobResultNotifier;
    @Mock
    private JobRepository jobRepository;
    @Mock
    private JobStateChanger jobStateChanger;
    @Mock
    private TranscodingExceptionHandler transcodingExceptionHandler;

    private TranscodingService transcodingService;

    private File mockMultimediaFile = mock(File.class);
    private List<File> mockMultimediaFiles = new ArrayList<File>();
    private List<File> mockThumbnails = new ArrayList<File>();
    private RuntimeException mockException = new RuntimeException();

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, /* jobStateChanger,
                transcodingExceptionHandler, */ jobRepository);

        when(jobRepository.findById(jobId)).thenReturn(mockJob);
        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(
                mockMultimediaFiles);
        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(
                mockThumbnails);

        /* 코드 삭제
        doAnswer(new Answer<Object>() {
            @Override
            public Object answer(InvocationOnMock invocation) throws Throwable {
                Job.State newState = (State) invocation.getArguments()[1];
                mockJob.changeState(newState);
                return null;
            }
        }).when(jobStateChanger).chageJobState(anyLong(), any(Job.State.class));

        doAnswer(new Answer<Object>() {
            @Override
            public Object answer(InvocationOnMock invocation) throws Throwable {
                RuntimeException ex = (RuntimeException) invocation
                        .getArguments()[1];
                mockJob.exceptionOccurred(ex);
                return null;
            }
        }).when(transcodingExceptionHandler).notifyToJob(anyLong(),
                any(RuntimeException.class));
        */
    }

이번엔 파일도 삭제했다. 하지만 불안할 필요는 없다. 테스트가 있잖은가! 테스트를 돌려보자. 녹색! 통과다.

Job의 public 메서드 중 private인 것 처리

Job 클래스에서 changeState() 메서드와 exceptionOccurred() 메서드는 더 이상 외부에서 호출되지 않는다. 이 메서드를 호출하기 위해서 사용됐던 JobStateChanger와 TranscodingExceptionHandler가 저 멀리 사라지지 않았는가! 이 두 메서드를 private으로 바꾸자.

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

    private void exceptionOccurred(RuntimeException ex) {
        occurredException = ex;
    }

바꿨다. 테스트를 돌리자. 녹색! 통과다.

한 발 나아갔으나, 아직도 부족한 것...

TranscodingServceImpl에서 Job으로 작업 처리를 이동시킴으로써 협업해야 했던 두 개의 타입이 사라졌다. Job의 일부 메서드도 private으로 바꿨다. 그래도 뭔가 아직 부족하다. 그 부분은 아래와 같다.
  • Job이 기능을 수행하기 위해 필요한 협업 객체를 Job에게 전달하기 위해 TranscodingServceImpl 객체가 mediaSourceCopier, transcoder 등의 필드를 갖고 있다.
TranscodingServceImpl 객체는 내부적으로 mediaSourceCopier 객체의 메서드를 호출하지 않는다. 그럼에도 불구하고 mediaSourceCopier를 필드로 갖고 있다. jobRepository를 제외한 나머지 필드는 사실 TranscodingServceImpl은 필요로 하지 않는 것들이다. 그럼에도 불구하고 필드로 갖고 있는 것이다.

public class TranscodingServiceImpl implements TranscodingService {
    private MediaSourceCopier mediaSourceCopier;
    private Transcoder transcoder;
    private ThumbnailExtractor thumbnailExtractor;
    private CreatedFileSaver createdFileSaver;
    private JobResultNotifier jobResultNotifier;
    private JobRepository jobRepository;

    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier,
            JobRepository jobRepository) {
        this.mediaSourceCopier = mediaSourceCopier;
        this.transcoder = transcoder;
        this.thumbnailExtractor = thumbnailExtractor;
        this.createdFileSaver = createdFileSaver;
        this.jobResultNotifier = jobResultNotifier;
        this.jobRepository = jobRepository;
    }

    @Override
    public void transcode(Long jobId) {
        Job job = jobRepository.findById(jobId);
        job.transcode(mediaSourceCopier, transcoder, thumbnailExtractor,
                createdFileSaver, jobResultNotifier);
    }

}

Job 객체가 필요로 하는 협업 객체가 바뀌면 TranscodingServceImpl도 바꿔야 한다. 이런 문제를 없애려면 빨간색으로 표시한 부분을 모두 삭제하거나 또는 최소화해야 할 것 같다.

어떻게 하면 이를 줄일 수 있을까? 조금 더 고민을 해 보고 싶지만,, 지금 시간이 23시 25분이다. 졸리다. 자야겠다. 나머지 리팩토링은 내일 저녁에 마저 진행해 보자.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2012.10.26 13:01 신고  댓글주소  수정/삭제  댓글쓰기

    알고 있겠지만. 보는 이들을 위해... job#transcode가 너무 크다.
    "function should do onething !!"
    "Extract till you drop !!"
    그리고 의존성 문제는 spring의 경우 @Configurable이나 aspectJ로 해소하는 방법밖에 없지 않을까나 ?

    • 최범균 madvirus 2012.10.26 15:39 신고  댓글주소  수정/삭제

      AspectJ를 사용하지 않게 되면,,,,, 아무래도 Locator 같은 것이 출현하겠죠? 푸,, AspectJ 사용하면 단위 테스트 목적으로 별도 메서드나 기능을 만들어야 해서,, 그게 좀 짱나요. ^^;

  2. 백명석 2012.10.26 18:52 신고  댓글주소  수정/삭제  댓글쓰기

    File multimediaFile = copyMultimediaSourceToLocal(mediaSourceCopier);
    changeState(Job.State.TRANSCODING);
    처럼. doSomething then changeState의 구조적 반복도 제거해야...

세 번째 TDD 연습에서는 테스트 코드 자체를 좀 정리할 것이다. 연습1과 2를 통해서 TranscodingServiceImpl 클래스의 정상 동작인 경우와 비정상 동작인 경우에 대해서 테스트를 진행하였다. 그런데 테스트 코드를 만들면서 중복된 코드들이 많이 출현하였다.


먼저 다음의 두 메서드를 비교해 보자.


    @Test

    public void transcodeSuccessfully() {

        when(jobRepository.findById(jobId)).thenReturn(mockJob);


        File mockMultimediaFile = mock(File.class);

        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);


        List<File> mockMultimediaFiles = new ArrayList<File>();

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(

                mockMultimediaFiles);


        List<File> mockThumbnails = new ArrayList<File>();

        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(

                mockThumbnails);


        Job job = jobRepository.findById(jobId);

        assertTrue(job.isWaiting());


        transcodingService.transcode(jobId);


        job = jobRepository.findById(jobId);

        assertTrue(job.isFinished());

        assertTrue(job.isSuccess());

        assertEquals(Job.State.COMPLETED, job.getLastState());

        assertNull(job.getOccurredException());


        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);

        verify(createdFileSender, only()).store(mockMultimediaFiles,

                mockThumbnails, jobId);

        verify(jobResultNotifier, only()).notifyToRequester(jobId);

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        when(jobRepository.findById(jobId)).thenReturn(mockJob);


        File mockMultimediaFile = mock(File.class);

        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);


        List<File> mockMultimediaFiles = new ArrayList<File>();

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(

                mockMultimediaFiles);


        List<File> mockThumbnails = new ArrayList<File>();

        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(

                mockThumbnails);


        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);


        Job job = jobRepository.findById(jobId);

        assertTrue(job.isWaiting());


        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        job = jobRepository.findById(jobId);

        assertTrue(job.isFinished());

        assertFalse(job.isSuccess());

        assertEquals(Job.State.NOTIFYING, job.getLastState());

        assertNotNull(job.getOccurredException());


        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);

        verify(createdFileSender, only()).store(mockMultimediaFiles,

                mockThumbnails, jobId);

        verify(jobResultNotifier, only()).notifyToRequester(jobId);

    }

}


굵게 표시한 부분은 테스트를 위한 Mock 객체를 초기화하는 부분으로 다른 테스트 메서드도 일부 다른 부분이 있지만, 거의 유사하다. 먼저 다음과 같이 코드를 수정한다.

  • mockMultimediaFile 변수, mockMultimediaFiles 변수, mockThumbnails 변수를 필드로 변경한다.
    • 각 테스트 메서드에서 세 변수 선언 부분을 삭제한다.

변경된 코드는 다음과 같다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {

    ...

    // 각 테스트 메서드에서 공통으로 사용되는 로컬 변수를 필드로 전환

    private File mockMultimediaFile = mock(File.class);

    private List<File> mockMultimediaFiles = new ArrayList<File>();

    private List<File> mockThumbnails = new ArrayList<File>();

    ...

    @Test

    public void transcodeSuccessfully() {

        // 필드로 바뀐 로컬 변수 선언 삭제됨

        when(jobRepository.findById(jobId)).thenReturn(mockJob);

        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(

                mockMultimediaFiles);

        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(

                mockThumbnails);

        ...

        // verify에서 필드 사용

        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        ...

    }


    // 나머지 테스트 코드도 동일하게 변경

    ...

    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        when(jobRepository.findById(jobId)).thenReturn(mockJob);

        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(

                mockMultimediaFiles);

        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(

                mockThumbnails);

        ...

        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        ...

    }

}


뭔가 바꿨으니 테스트를 실행해 본다. 녹색! 문제가 발생하지 않았다. 이제 다음 중복으로 넘어가자. 또 다른 중복되는 부분은 Mock 객체의 행위를 지정하는 부분이다. (when().thenReturn() 류의 코드.) 이런 코드들 역시 각 테스트 메서드에서 중복해서 출현하므로 setUp() 메서드로 이동시키자.



    @Before

    public void setup() {

        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,

                transcoder, thumbnailExtractor, createdFileSender,

                jobResultNotifier, jobStateChanger, transcodingExceptionHandler);


        // 각 테스트 메서드의 중복되는 초기화 코드를 setup으로 이동

        when(jobRepository.findById(jobId)).thenReturn(mockJob);

        when(mediaSourceCopier.copy(jobId)).thenReturn(mockMultimediaFile);

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenReturn(

                mockMultimediaFiles);

        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenReturn(

                mockThumbnails);


        doAnswer(new Answer<Object>() {

            @Override

            public Object answer(InvocationOnMock invocation) throws Throwable {

                Job.State newState = (State) invocation.getArguments()[1];

                mockJob.changeState(newState);

                return null;

            }

        }).when(jobStateChanger).chageJobState(anyLong(), any(Job.State.class));

        ...

    }


    @Test

    public void transcodeSuccessfully() {

        // 초기화 코드 제거됨

        Job job = jobRepository.findById(jobId);

        assertTrue(job.isWaiting());


        transcodingService.transcode(jobId);

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        // 초기화 코드 제거됨

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

        when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(

                mockException);

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtThumbnailExtractor() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenThrow(

                mockException);

        ...

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(createdFileSender).store(

                mockMultimediaFiles, mockThumbnails, jobId);

        ...

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        // 초기화 코드 제거됨

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);

        ...

    }


뭔가를 바꿨으니 또 테스트다. 녹색! 통과다.


이제 실패의 경우를 테스트하는 5개의 테스트 메서드에서 중복을 제거하자. 실패 시나리오를 테스트하는 두 메서드를 보자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(createdFileSender).store(

                mockMultimediaFiles, mockThumbnails, jobId);


        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        Job job = jobRepository.findById(jobId);


        assertTrue(job.isFinished());

        assertFalse(job.isSuccess());

        assertEquals(Job.State.STORING, job.getLastState());

        assertNotNull(job.getOccurredException());


        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);

        verify(createdFileSender, only()).store(mockMultimediaFiles,

                mockThumbnails, jobId);

        verify(jobResultNotifier, never()).notifyToRequester(jobId);

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {

        RuntimeException mockException = new RuntimeException();

        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);


        Job job = jobRepository.findById(jobId);

        assertTrue(job.isWaiting());


        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        job = jobRepository.findById(jobId);

        assertTrue(job.isFinished());

        assertFalse(job.isSuccess());

        assertEquals(Job.State.NOTIFYING, job.getLastState());

        assertNotNull(job.getOccurredException());


        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);

        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);

        verify(createdFileSender, only()).store(mockMultimediaFiles,

                mockThumbnails, jobId);

        verify(jobResultNotifier, only()).notifyToRequester(jobId);

    }


굵게 표시한 부분이 중복된 코드이다. 이 중복을 다음과 같이 제거해 보자.

  • mockException 로컬 변수를 필드로 전환
  • try - catch 블록과 job의 상태를 검사하는 코드를 메서드로 분리
이번엔 두 가지를 한 번에 적용한 결과를 보자.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    private List<File> mockThumbnails = new ArrayList<File>();
    private RuntimeException mockException = new RuntimeException();
    ...
    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        executeFailingTranscodeAndAssertFail(Job.State.MEDIASOURCECOPYING);

        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, never()).transcode(any(File.class), anyLong());
        verify(thumbnailExtractor, never()).extract(any(File.class), anyLong());
        verify(createdFileSender, never()).store(anyListOf(File.class),
                anyListOf(File.class), anyLong());
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }

    private void executeFailingTranscodeAndAssertFail(State expectedLastState) {
        try {
            transcodingService.transcode(jobId);
            fail("발생해야 함");
        } catch (Exception ex) {
            assertSame(mockException, ex);
        }

        Job job = jobRepository.findById(jobId);

        assertTrue(job.isFinished());
        assertFalse(job.isSuccess());
        assertEquals(expectedLastState, job.getLastState());
        assertNotNull(job.getOccurredException());
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(
                mockException);

        executeFailingTranscodeAndAssertFail(Job.State.TRANSCODING);

        verify(mediaSourceCopier, only()).copy(jobId);
        ...
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtThumbnailExtractor() {
        when(thumbnailExtractor.extract(mockMultimediaFile, jobId)).thenThrow(
                mockException);

        executeFailingTranscodeAndAssertFail(Job.State.EXTRACTINGTHUMBNAIL);

        verify(mediaSourceCopier, only()).copy(jobId);
        ...
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
        doThrow(mockException).when(createdFileSender).store(
                mockMultimediaFiles, mockThumbnails, jobId);

        executeFailingTranscodeAndAssertFail(Job.State.STORING);

        verify(mediaSourceCopier, only()).copy(jobId);
        ...
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);

        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());

        executeFailingTranscodeAndAssertFail(Job.State.NOTIFYING);

        verify(mediaSourceCopier, only()).copy(jobId);
        ...
    }
}

테스트를 실행해서 정상적으로 동작하는 지 확인한다. 실패 시나리오 테스트 메서드가 보다 간결해졌다.

또 다른 중복은 TranscodingService의 transcode() 메서드를 호출하기 전에 Job 객체가 대기 상태인지 확인하는 코드이다.

        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());

이 코드가 모든 테스트 코드에 포함되어 있진 않지만 중복해서 들어가 있다. 따라서, 다음과 같이 중복된 부분을 별도의 메서드로 분리해 내자.

    @Test
    public void transcodeSuccessfully() {
        assertJobIsWaitingState();
        transcodingService.transcode(jobId);
        ...
    }

    private void assertJobIsWaitingState() {
        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
        doThrow(mockException).when(jobResultNotifier).notifyToRequester(jobId);
        assertJobIsWaitingState();
        executeFailingTranscodeAndAssertFail(Job.State.NOTIFYING);
        ...
    }

거의 정리가 되어 가는 느낌이다. Robert C. Martin의 표현을 빌자면, Lovely해 지고 있다.

하지만, 여전히 불만족스러운 부분이 있는데, 그 부분은 바로 협업 객체가 호출되는지의 여부를 확인하는 verify 부분이다. 각 메서드의 verify 부분만 모아보면 아래와 같다.

    @Test
    public void transcodeSuccessfully() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).store(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, only()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, never()).transcode(any(File.class), anyLong());
        verify(thumbnailExtractor, never()).extract(any(File.class), anyLong());
        verify(createdFileSender, never()).store(anyListOf(File.class),
                anyListOf(File.class), anyLong());
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }


    @Test
    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, never()).extract(any(File.class), anyLong());
        verify(createdFileSender, never()).store(anyListOf(File.class),
                anyListOf(File.class), anyLong());
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtThumbnailExtractor() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, never()).store(anyListOf(File.class),
                anyListOf(File.class), anyLong());
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtCreatedFileSender() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).store(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, never()).notifyToRequester(jobId);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtJobResultNotifier() {
        ...
        verify(mediaSourceCopier, only()).copy(jobId);
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        verify(thumbnailExtractor, only()).extract(mockMultimediaFile, jobId);
        verify(createdFileSender, only()).store(mockMultimediaFiles,
                mockThumbnails, jobId);
        verify(jobResultNotifier, only()).notifyToRequester(jobId);
    }

뭔가 굉장히 비슷하다. 이 놈들을 어찌해야 하나 고민을 좀 했다. 뭔가 verify를 해 주는 메서드를 하나 만들고, 그 메서드에서 파라미터로 각 협업 객체의 verify 방식을 지정해 줘야 할까도 생각해 봤다. 대충 다음과 같은 모양의 메서드를 상상해 봤다.

private void verifyCollaboration(boolean transcoderNever, boolean thumbnailExtractorNever, 
        boolean createdFileSenderNever, boolean jobResultNotifierNever) {
    if (transcoderNever)
        verify(transcoder, never()).transcode(any(File.class), anyLong());
    else
        verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
    ... // 비슷한 코드
}

파라미터가 네 개이기 때문에 순서를 잘못 적는 실수를 범할 수 있을 것도 같다. 음,,,,, 네 개의 파라미터 대신에 이 파라미터들을 필드로 갖는 데이터 구조를 만들어서 전달하면 어떨까? 오케이 그게 좋을 것 같다. 그럼, 중복도 없애고 파라미터 순서에 따른 실수도 덜하면서 의미도 잃지 않을 수 있을 것 같다. 이를 반영한 코드는 아래와 같다.

    @Test
    public void transcodeSuccessfully() {
        assertJobIsWaitingState();

        transcodingService.transcode(jobId);

        Job job = jobRepository.findById(jobId);
        assertTrue(job.isFinished());
        assertTrue(job.isSuccess());
        assertEquals(Job.State.COMPLETED, job.getLastState());
        assertNull(job.getOccurredException());

        VerifyOption verifyOption = new VerifyOption();
        verifyCollaboration(verifyOption);
    }

    private void verifyCollaboration(VerifyOption verifyOption) {
        verify(mediaSourceCopier, only()).copy(jobId);

        if (verifyOption.transcoderNever)
            verify(transcoder, never()).transcode(any(File.class), anyLong());
        else
            verify(transcoder, only()).transcode(mockMultimediaFile, jobId);
        ... // 비슷한 코드로 각 협업 객체 verify
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        executeFailingTranscodeAndAssertFail(Job.State.MEDIASOURCECOPYING);

        VerifyOption verifyOption = new VerifyOption();
        verifyOption.transcoderNever = true;
        verifyOption.thumbnailExtractorNever = true;
        verifyOption.createdFileSenderNever = true;
        verifyOption.jobResultNotifierNever = true;

        verifyCollaboration(verifyOption);
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtTranscoder() {
        when(transcoder.transcode(mockMultimediaFile, jobId)).thenThrow(
                mockException);

        executeFailingTranscodeAndAssertFail(Job.State.TRANSCODING);

        VerifyOption verifyOption = new VerifyOption();
        verifyOption.thumbnailExtractorNever = true;
        verifyOption.createdFileSenderNever = true;
        verifyOption.jobResultNotifierNever = true;

        verifyCollaboration(verifyOption);
    }
    ...
    public class VerifyOption {
        public boolean transcoderNever;
        public boolean thumbnailExtractorNever;
        public boolean createdFileSenderNever;
        public boolean jobResultNotifierNever;
    }
}

메서드를 사용해서 코드를 하나씩 바꿀 때 마다 테스트를 실행해서 안전하게 동작하는지 확인한다. 모든 테스트가 정상적으로 통과된다. 야호!

테스트 코드 정리가 끝났으니, 다음번엔 각 협업 객체들을 점진적으로 구현해 나갈 차례이다.



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2012.10.25 07:22 신고  댓글주소  수정/삭제  댓글쓰기

    good job !!
    running test가 있으니 test에 대한 refactoring을 공격적으로 할 수 있다는 것이 검증되는군.
    근데 verifyCollaboration을 좀 더 이쁘게 할 수 없을까나 ???

* TDD 과정을 공유하기 위해 연습하는 과정을 글로 남긴다.


실패 상황 시나리오 구현하기


앞서 http://javacan.tistory.com/entry/TDD-exercise-1-Design-communication-using-tdd 글에 이어 실패 상황을 구현해 보자. 우선 원본 소스로부터 파일을 복사해 오는 과정이 실패났을 경우를 가정해서 테스트 코드를 만든다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {

    private Long jobId = new Long(1);


    @Mock

    private MediaSourceCopier mediaSourceCopier;

    ....

    private TranscodingService transcodingService;


    @Test

    public void transcodeSuccessfully() {

        ....

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.isLastState());


        verify(mediaSourceCopier, only()).copy(jobId);

        verify(transcoder, never()).transcode(any(File.class), anyLong());

        verify(thumbnailExtractor, never()).extract(any(File.class), anyLong());

        verify(createdFileSender, never()).store(anyListOf(File.class),

                anyListOf(File.class), anyLong());

        verify(jobResultNotifier, never()).notifyToRequester(jobId);

    }

}


위 테스트 메서드는 MediaSourceCopier가 copy 과정에서 문제가 발생한 경우 그걸 검증하는 코드로 작성되었다. 먼저 transcodingService.transcode() 메서드는 익셉션을 발생시켜야 한다. 그렇지 않을 경우 테스트가 실패하도록 했다. 그리고, mediaSourceCopier를 제외한 나머지 협업 객체는 호출되어서는 안 되므로, 호출 검증 값을 never()로 지정하였다.


드디어 Job과 jobRepository가 출현했다. 먼저 연습 1에서 했던 것 처럼, jobRepository를 필드로 만들고 JobRepository 인터페이스를 생성한다. 그리고, Job 클래스도 함께 만들어준다.


@RunWith(MockitoJUnitRunner.class)

public class TranscodingServiceImplTest {

    ...

    @Mock

    private JobRepository jobRepository;

    

    private TranscodingService transcodingService;

    ...

    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

        ...

    }

}


컴파일 에러다. 컴파일 에러가 나지 않도록 다음과 같이 메서드/enum 타입 등을 추가해 주자.


public interface JobRepository {

    Job findById(Long jobId);

}



public class Job {


    public static enum State {

        MEDIASOURCECOPYING

    }


    public boolean isSuccess() {

        return false;

    }


    public State getLastState() {

        return null;

    }


}


컴파일 에러를 제거했으므로 테스트를 실행한다. 테스트가 실패한다. 원인은 다음과 같다.


java.lang.AssertionError: 발생해야 함

at org.junit.Assert.fail(Assert.java:91)

at org.chimi.s4t.domain.transcode.TranscodingServiceImplTest.transcodeFailBecauseExceptionOccuredAtMediaSourceCopier(TranscodingServiceImplTest.java:74)

at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

        ...


아! transcodingService.transcode() 메서드가 익셉션을 발생시켜야 하는데, 무사히 실행되어서 fail()에 걸렸다. 이 테스트는 원본 미디어 파일을 복사해 오는 도중에 에러가 발생한 경우를 가정한 것이므로 mediaSourceCopier Mock 객체가 익셉션을 발생시키도록 하자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        RuntimeException mockException = new RuntimeException();

        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isDone());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

        ...

    }

    



다시 테스트 실행.... 빨간색이다. NullPointerException이다. 스택트레이스를 보니 아래 줄에서 job이 null이어서 발생한 것이다.


assertFalse(job.isSuccess());


jobRepository가 Job 객체를 리턴하도록 Mock 객체를 설정하자.


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        when(jobRepository.findById(jobId)).thenReturn(new Job());

        

        RuntimeException mockException = new RuntimeException();

        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());



다시 테스트 실행. 에러가 났다. job.getLastState()가 MEDIASOURCECOPYING이 아니라는 에러다. 그런데, job.isSuccess()가 false인지 확인하는 코드는 통과되었다. 이런! 잠시 고민하다가, 앞에서 작성했었던 transcodeSuccessfully() 테스트에 다음과 같이 Job에 대한 검증 코드를 추가하였다.



    @Test

    public void transcodeSuccessfully() {

        when(jobRepository.findById(jobId)).thenReturn(new Job());

        ...

        transcodingService.transcode(jobId);


        Job job = jobRepository.findById(jobId);

        assertTrue(job.isSuccess());

        assertEquals(Job.State.COMPLETED, job.getLastState());

        ... // Mock 객체들의 상호작용 확인

    }


    @Test

    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {

        when(jobRepository.findById(jobId)).thenReturn(new Job());

        

        RuntimeException mockException = new RuntimeException();

        when(mediaSourceCopier.copy(jobId)).thenThrow(mockException);

        

        try {

            transcodingService.transcode(jobId);

            fail("발생해야 함");

        } catch (Exception ex) {

            assertSame(mockException, ex);

        }


        Job job = jobRepository.findById(jobId);

        assertFalse(job.isSuccess());

        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

        ... // Mock 객체들의 상호작용 확인

    }


Job.State enum 타입에 COMPLETED를 추가한 뒤에 다시 테스트를 실행해 보자. 에러다! 두 테스트 메서드에서 모두 실패가 발생했다.

  • transcodeSuccessfully() 메서드의 실패 지점
    • assertTrue(job.isSuccess()) 
    • 아직 assertEquals(Job.State.COMPLETED, job.getLastState()) 는 확인도 못한다.
  • transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() 메서드의 실패 지점
    • assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());

이제 이 두 테스트 메서드가 모두 통과되도록 TranscodingServiceImpl을 수정해 보자.


TranscodingServiceImpl 구현 고민 그리고 새로운 협업 객체 등장


테스트를 통과시키기 위해서 TranscodingServiceImpl 클래스에 다음의 처리를 해야 한다.

  • 성공적으로 실행되면 Job의 상태를 COMPLETED로 바꾸고 성공 여부를 true로 지정한다.
  • 원본 파일 복사 전에 Job의 상태를 MEDIASOURCECOPYING로 바꾸고, 복사 과정 중 익셉션이 발생하면 Job의 성공 여부를 false로 지정한다.
위의 내용을 보면 뭔가 Job의 상태를 변경해주는 기능이 필요해 보인다. 또한, 익셉션이 발생하면 에러 상태로 변경해 주어야 할 것 같기도 하다.

우선, TranscodingServceImpl 클래스에 상태를 변경해주는 기능을 아래와 같이 별도의 메서드인 changeJobState()로 추상화하고, transcode()는 그 메서드를 호출하도록 했다.

public class TranscodingServiceImpl implements TranscodingService {
    ...
    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        List<File> thumbnails = extractThumbnail(multimediaFile, jobId);
        storeCreatedFilesToStorage(multimediaFiles, thumbnails, jobId);
        notifyJobResultToRequester(jobId);
        changeJobState(jobId, Job.State.COMPLETED);
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState); // 컴파일 에러
    }


위 코드에서 changeJobState() 메서드는 Job의 상태 변경 처리를 jobStateChanger 객체에 위임하고 있다. (이런 위임은 이미 copyMultimediaSourceToLocal() 메서드나 extractThumbnail() 메서드 등에서 동일하게 사용하고 있으며, 위임을 하면 Mock 객체를 사용해서 빠르게 테스트를 통과시킬 수 있게 된다.) jobStateChange 필드가 없어서 컴파일 에러가 나므로, jobStateChanger 필드를 추가하고 JobStateChanger 인터페이스를 만들고, 그 인터페이스를 changeJobState() 메서드를 정의해서 컴파일 에러를 제거한다. 또한, 생성자를 통해서 JobStateChanger 객체를 전달받도록 수정하자.

public class TranscodingServiceImpl implements TranscodingService {
    private MediaSourceCopier mediaSourceCopier;
    private Transcoder transcoder;
    private ThumbnailExtractor thumbnailExtractor;
    private CreatedFileSaver createdFileSaver;
    private JobResultNotifier jobResultNotifier;
    private JobStateChanger jobStateChanger;

    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier, JobStateChanger jobStateChanger) {
        ...
        this.jobStateChanger = jobStateChanger;
    }

    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        List<File> multimediaFiles = transcode(multimediaFile, jobId);
        List<File> thumbnails = extractThumbnail(multimediaFile, jobId);
        storeCreatedFilesToStorage(multimediaFiles, thumbnails, jobId);
        notifyJobResultToRequester(jobId);
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState);
    }

생성자가 수정되었으므로, 기존에 생성자를 사용한 테스트 코드에서 컴파일 에러가 발생한다. 컴파일 에러가 발생하지 않도록 JobStateChanger에 대한 Mock 객체를 만들고, 생성자에 전달해주도록 하자.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    @Mock
    private JobRepository jobRepository;
    @Mock
    private JobStateChanger jobStateChanger;

    private TranscodingService transcodingService;

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger);
    }

새로운 협업 객체가 생겼다. 이제 첫 번째로 할 작업은 정상적으로 실행된 경우의 테스트를 통과시키는 것이다. 이를 위해 해야 할 작업은 JobStateChanger의 Mock 객체가 알맞게 Job의 상태를 변경시키도록 하는 것이다. 아래 코드는 테스트를 통과시키기 위해 Mock 객체에 기능을 부여한 코드이다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    @Mock
    private JobStateChanger jobStateChanger;

    private Job mockJob = new Job();
    
    private TranscodingService transcodingService;

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger);

        doAnswer(new Answer<Object>() {
            @Override
            public Object answer(InvocationOnMock invocation) throws Throwable {
                Job.State newState = (State) invocation.getArguments()[1];
                mockJob.changeState(newState);
                return null;
            }
        }).when(jobStateChanger).chageJobState(anyLong(), any(Job.State.class));
    }

    @Test
    public void transcodeSuccessfully() {
        when(jobRepository.findById(jobId)).thenReturn(mockJob);
        ...
        Job job = jobRepository.findById(jobId);
        assertTrue(job.isSuccess());
        assertEquals(Job.State.COMPLETED, job.getLastState());
        ...
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        when(jobRepository.findById(jobId)).thenReturn(mockJob);
        ...
        Job job = jobRepository.findById(jobId);
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        ...
    }
}

위 코드에서 주의할 점은 Job 객체를 mockJob으로 필드로 빼고, 각 테스트 메서드에서 jobRepository Mock 객체의 findById(jobId) 메서드가 호출되면 mockJob을 리턴하도록 구현했다는 것이다.

Job 클래스에 changeState() 메서드가 없어서 컴파일 에러가 발생하고 있으므로, changeState() 메서드를 구현할 차례이다. 이 메서드를 알맞게 구현해서 위 테스트가 통과되도록 만들면 된다. 통과시키려면 다음과 같이 changeState() 메서드를 구현해 주면 될 것 같다.

public class Job {

    public static enum State {
        MEDIASOURCECOPYING, COMPLETED
    }

    private State state;

    public boolean isSuccess() {
        return state == State.COMPLETED;
    }

    public State getLastState() {
        return state;
    }

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

}

이제 테스트를 실행해 보자. 오, 예! 녹색바다.

실패의 의미가 없다.

미디어 원본 파일을 복사하는 것 뿐만 아니라 변환 처리, 썸네일 추출, 결과 전송, 통지에 대해서도 동일하게 실패 과정에 대한 테스트를 넣으려고 했으나, 마음에 안 드는 부분을 발견했다. transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() 메서드의 Job에 대한 검증 부분을 다시 살펴보자.

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        Job job = jobRepository.findById(jobId);
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        ...
    }

job.isSuccess()는 작업의 성공 여부는 알려주는데, 작업이 중간에 실패했는지 아니면 작업이 진행중인지의 여부는 알려주지 못한다. 그렇다, 우리에겐 다음이 필요하다.
  • 작업이 시작 전 / 진행 중 / 끝남 여부
  • 작업의 성공/실패 여부
    • 실패했다면, 어느 단계에서 실패했는 지 그리고 실패 원인
몇 가지 assert를 떠올려 봤는데, 아래와 같이 하면 좋을 것 같다.

    @Test
    public void transcodeSuccessfully() {
        ...
        Job job = jobRepository.findById(jobId);
        assertTrue(job.isWaiting());
        transcodingService.transcode(jobId);

        job = jobRepository.findById(jobId);
        assertTrue(job.isFinished());
        assertTrue(job.isSuccess());
        assertEquals(Job.State.COMPLETED, job.getLastState());
        assertNull(job.getOccurredException());
        ... // verify 들
    }

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        try {
            transcodingService.transcode(jobId);
            fail("발생해야 함");
        } catch (Exception ex) {
            assertSame(mockException, ex);
        }

        Job job = jobRepository.findById(jobId);
        assertTrue(job.isFinished());
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        assertNotNull(job.getOccurredException());
        ... // verify 들
    }

추가된 메서드는 다음과 같다.
  • Job.isWaiting(): 작업이 대기 중인지의 여부
  • Job.isFinished(): 작업이 완료되었는지의 여부
  • Job.getOccuredException(): 진행 과정 중 발생한 익셉션을 구한다.
Job.isSuccess()는 작업이 성공/실패 여부를 의미하도록 변경했다. 컴파일 에러가 발생하므로, 각각의 메서드를 추가해 주고 점진적으로 테스트를 통과시켜 나갈 차례이다. transcodeSuccessfully() 테스트가 통과될 때 까지 테스트 실행/Job 클래스 수정을 반복해서 Job 클래스를 다음과 같이 수정하였다.

public class Job {

    public static enum State {
        MEDIASOURCECOPYING, COMPLETED
    }

    private State state;
    private Exception occurredException;

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

    public boolean isWaiting() {
        return state == null;
    }

    public boolean isFinished() {
        return isSuccess() || isExceptionOccurred();
    }

    public boolean isSuccess() {
        return state == State.COMPLETED;
    }

    private boolean isExceptionOccurred() {
        return occurredException != null;
    }

    public State getLastState() {
        return state;
    }

    public Exception getOccurredException() {
        return occurredException;
    }

}

이제 남은 작업은 transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() 테스트를 통과시킬 차례이다. 실패 케이스의 테스트를 실행해보면 아래 코드를 통과하지 못한다.

assertTrue(job.isFinished()); // 통과 실패
assertFalse(job.isSuccess());
assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
assertNotNull(job.getOccurredException());

job.isFinished() 메서드는 작업이 성공했거나 익셉션이 발생한 경우 true를 리턴하도록 구현하였다. 따라서, 필요한 것은 TranscodingServiceImpl이 처리 도중 익셉션이 발생하면 그 사실을 Job 객체에 알려주는 것이다. 뭔가 Job 객체에게 알려주는 기능이 필요해졌다. 음,, 지금까지 글을 따라 읽어왔다면 이 쯤에서 새로운 협업 객체가 등장할 것 같은 느낌을 받을 것이다.

익셉션 발생을 Job에 알리기 위한 새로운 협업 객체의 등장

각 처리 과정에서 익셉션이 발생하면 그 사실을 Job에게 알려주는 기능을 추가하자. 약간의 고민을 한 끝에 아래와 같이 해 보기로 결정했다.

public class TranscodingServiceImpl implements TranscodingService {
    ...
    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        ...
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        try {
            return mediaSourceCopier.copy(jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }
    ... // 나머지 메서드도 동일하게 transcodingExceptionHandler 사용 코드 추가

transcodingExceptionHandler라는 새로운 협업 객체가 등장했다. 이 객체의 notifyToJob() 메서드는 Job 객체에게 익셉션이 발생했다는 사실을 알린다. 그리고 익셉션이 사라지지 않도록 재전파한다.

transcodingExceptionHandler 협업 객체가 없으므로, 필드로 정의하고 transcodingExceptionHandler 객체를 위한 인터페이스를 생성하고, 그 인터페이스에 notifyToJob() 메서드를 정의하자. 물론, 생성자를 통해서 추가된 협업 객체를 전달받도록 수정하는 것도 잊지 말자.

public class TranscodingServiceImpl implements TranscodingService {
    ...
    private JobStateChanger jobStateChanger;
    private TranscodingExceptionHandler transcodingExceptionHandler;

    public TranscodingServiceImpl(MediaSourceCopier mediaSourceCopier,
            Transcoder transcoder, ThumbnailExtractor thumbnailExtractor,
            CreatedFileSaver createdFileSaver,
            JobResultNotifier jobResultNotifier,
            JobStateChanger jobStateChanger,
            TranscodingExceptionHandler transcodingExceptionHandler) {
        ...
        this.transcodingExceptionHandler = transcodingExceptionHandler;
    }

    @Override
    public void transcode(Long jobId) {
        changeJobState(jobId, Job.State.MEDIASOURCECOPYING);
        File multimediaFile = copyMultimediaSourceToLocal(jobId);
        ...
        changeJobState(jobId, Job.State.COMPLETED);
    }

    private void changeJobState(Long jobId, State newJobState) {
        jobStateChanger.chageJobState(jobId, newJobState);
    }

    private File copyMultimediaSourceToLocal(Long jobId) {
        try {
            return mediaSourceCopier.copy(jobId);
        } catch (RuntimeException ex) {
            transcodingExceptionHandler.notifyToJob(jobId, ex);
            throw ex;
        }
    }

TranscodingServiceImpl의 생성자를 변경했으므로, 테스트 코드도 함께 수정해 준다.

@RunWith(MockitoJUnitRunner.class)
public class TranscodingServiceImplTest {
    ...
    @Mock
    private TranscodingExceptionHandler transcodingExceptionHandler;

    private Job mockJob = new Job();

    private TranscodingService transcodingService;

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger, transcodingExceptionHandler);
        ...
    }


앞서 실패했던 부분을 다시 보자.

    @Test
    public void transcodeFailBecauseExceptionOccuredAtMediaSourceCopier() {
        ...
        try {
            transcodingService.transcode(jobId);
            fail("발생해야 함");
        } catch (Exception ex) {
            assertSame(mockException, ex);
        }

        Job job = jobRepository.findById(jobId);

        assertTrue(job.isFinished()); // 통과 실패
        assertFalse(job.isSuccess());
        assertEquals(Job.State.MEDIASOURCECOPYING, job.getLastState());
        assertNotNull(job.getOccurredException());
...

    }

이제 남은 작업은 새로 추가된 협업 객체를 이용해서 위 테스트과 통과되도록 만드는 것이다. 우선, 테스트 클래스에 새롭게 추가된 Mock 객체인 transcodingExceptionHandler의 notifyToJob() 메서드가 호출되면 mockJob객체에 발생한 익셉션을 전달할 수 있도록 exceptionOccured() 메서드를 호출한다.

    @Before
    public void setup() {
        transcodingService = new TranscodingServiceImpl(mediaSourceCopier,
                transcoder, thumbnailExtractor, createdFileSender,
                jobResultNotifier, jobStateChanger, transcodingExceptionHandler);

        ...

        doAnswer(new Answer<Object>() {
            @Override
            public Object answer(InvocationOnMock invocation) throws Throwable {
                RuntimeException ex = (RuntimeException) invocation.getArguments()[1];
                mockJob.exceptionOccurred(ex);
                return null;
            }
        }).when(transcodingExceptionHandler).notifyToJob(anyLong(),
                any(RuntimeException.class));
    }

물론, 아직 Job 클래스에 exceptionOccurred() 메서드는 없다. 이제부터 만들어야 한다. 이 메서드를 만드는 건 어렵지 않을 것 같다. 다음과 같이 이 메서드를 통해서 전달받은 익셉션 객체를 필드에 보관하기만 하면 된다.

package org.chimi.s4t.domain.job;

public class Job {
    ...
    public boolean isFinished() {
        return isSuccess() || isExceptionOccurred();
    }

    public boolean isSuccess() {
        return state == State.COMPLETED;
    }

    private boolean isExceptionOccurred() {
        return occurredException != null;
    }

    public Exception getOccurredException() {
        return occurredException;
    }

    public void exceptionOccurred(RuntimeException ex) {
        occurredException = ex;
    }

}

필요한 구현을 한 것 같으므로 테스트를 실행해 보자. 야호! 녹색바다!

이제 나머지 실패 시나리오에 대해서 추가적으로 테스트 클래스를 만들고 비슷한 과정을 반복하면 된다. 아, 잠깐! 비슷한 과정을 반복하다보면 각각의 테스트 메서드에 중복된 코드가 출현하게 된다. 중복은 악의 축이므로, 이를 없애야 한다. 테스트 코드도 유지보수가 가능하도록 리팩토링을 해 주어야 하는데, 이 과정은 연습 3에서 진행해 본다.


결과는?

  • 드디어 Job과 JobRepository가 출현했다.
  • 두 개의 협업 객체가 새롭게 추가되었다. (최초에 필자가 머리속으로 상상했던 것 보다 더 많은 협업 객체가 출현했다.)
  • 성공 시나리오와 실패 시나리오에 대한 테스트도 만들어졌다.

연습3에서는 앞서 말한데로 테스트 코드의 중복을 제거해 보도록 하자.

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 백명석 2012.10.25 07:16 신고  댓글주소  수정/삭제  댓글쓰기

    . service, repository, controller 등에는 위임(상호작용)을 제외하고는 테스트가 필요할 수준의 코드가 없는 것이 좋다고 생각함. 그에 비해 위 테스트는 너무 자세함. exception이 발생해야 할 경우 발생했나만 확인하면 되지 굳이 catch로 막고, assert/verify 등을 할 필요가 있을까 ?
    . TranscodingService의 생성자에 인자의 수가 너무 많은 듯(6개). 이럴 경우 setter가 더 보기 좋을 수도. 그리고 이렇게 많아야만 하는지가 가장 의문시 됨.
    . Job#changeState에서 state를 바로 변경하기 보다 start, end 등의 trigger 메소드를 추가하고 해당 메소드에서 state 객체에게 위임(this를 넘겨서 state가 자신을 변경하고 job의 상태도 변경하도록)하는 것이 좋을 듯.

    • 최범균 madvirus 2012.10.25 09:24 신고  댓글주소  수정/삭제

      TranscodingService와 Job의 State는 다음과 같은 고민의 결과입니다. 아직까지 완전한 답을 찾지 못했고, 그래서 일단 제가 하기 쉬운 방법으로 구현을 진행하게 되었어요.

      첫 번째 고민거리는 Job에 execute() 메서드를 넣고, 그 execute() 메서드에서 변환 처리를 수행하는 것에 대한 부분이였어요. Job이 작업을 처리하면, TranscodingService는 리포지토리에서 Job을 구하고, 그 Job의 execute() 메서드를 호출하는 걸로 끝나게 되죠.

      처음에는 이 방법으로 약간의 구현을 생각해봤는데, 다음과 같은 것들이 걸렸어요.
      - 각 단계가 끝나면 Job의 상태가 변경되어서 작업 요청자가 확인할 수 있어야 함
      - State 패턴을 적용해서 State별로 자기 작업을 수행하고, 실패를 처리하는 등의 것을 생각해 봄
      - 그런데, 향후에 뭐가 되었든 Job이 Persistence 영역과 엮이게 되면, 각 State 객체의 메서드가 내부적으로 트랜잭션을 처리해 주어야 하고, State의 전이가 발생하면 Job의 물리적 데이터가 해당 State로 변경되어야 함

      그런데, 제가 좋아하는 ORM과 스프링을 사용할 경우, (사실, 이게 문제죠! 제가 구현을 너무 빨리 고려했다는 것),
      - Job은 DB 세션과 연결되어 있어서 State의 전이가 일어날 때 그걸 바로 반영하려면 뭔가 Persistece 관련 기능에 의존할 것 같고
      - State의 메서드마다 별도로 트랜잭션을 처리하려면 뭔가 Transaction을 추상화해서 Inject 해 줘야 할 것 같고,
      - State의 전이는 새로운 State 객체의 생성과도 연결되니 객체의 생성 시점에 Inject 하는 방법이 필요할 것 같고

      음,, 이렇게 생각이 뻗어나가면서 해답을 찾지 못했어요. 이건 좋은 아이디어 있으면 좀 주셔요.

      하튼, 그래서 트랜잭션을 쉽게 분리할 수 있는 방법을 찾았고, 그 결과로 각 단계마다 별도 협업 객체를 출현시키는 방법이 출현하게 되었어요.

      이렇게 되다보니 Job의 상태가 단순 값으로 바뀌었고, 상태 전이 부분은 향후 리팩을 기약하며(^^;) 먼저 동작하는 코드를 만들었어요.

      시간이 많으면 더 고민하겠는데, 저녁에 짬짬히 하다보니 (드라마를 병행하면서....) 생각이 잘 안 떠오르네요.