주요글: 도커 시작하기

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분이다. 졸리다. 자야겠다. 나머지 리팩토링은 내일 저녁에 마저 진행해 보자.


  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의 구조적 반복도 제거해야...

+ Recent posts