주요글: 도커 시작하기

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으로 하는 것만으로 충분할 것 같다.


+ Recent posts