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

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

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

OOP 소개 자료 입니다.


2012년 11월 10일 자료: 객체, 캡슐화, 추상화

OOP 소개 20121110_2page.pdf



2012년 10월 13일 자료: 객체, 캡슐화, 추상화

OOP 소개 20121013_2page.pdf



2012년 9월 22일 자료: 객체, 캡슐화, 추상화

OOP 소개 20120922.pdf


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 홍구 2012.10.04 21:28 신고  댓글주소  수정/삭제  댓글쓰기

    감사히 봅늬다

두 번째로 살펴볼 객체 지향 기초 이야기는 추상화와 다형성에 대한 것이다. 이는 모든 패턴의 기본이 되는 내용이므로 이에 대해 이해하는 것은 무엇보다 중요하다. (아래 내용이 완벽하진 않겠지만 기초가 필요한 분들이 입문하는데에 도움이 되었으면 한다.)

추상화(Abstraction)


추상화Abstraction란? 추상화의 사전적 의미는 특정한 개별 사물과 관련되지 않은 공통된 속성이나 관계 등을 뽑아내는 것이다. 이를 컴퓨터 관점에서 생각해보면, 추상화란 데이터나 프로세스 등을 의미가 비슷한 개념이나 표현으로 정의해 나가는 과정이면서 동시에 각 개별 개체의 구현에 대한 상세함은 감추는 것, 이것이 추상화라고 할 수 있다.

예를 들어, 프로그래밍 언어에서 for, while, foreach 등은 반복을 추상화한 것들이다. 실제로 이것들은 CPU의 이동 명령을 통해서 구현되겠지만, 이 구현으로부터 '반복'이라는 개념을 뽑아내서 for, while 등으로 추상화한 것이다. 비슷하게 OS는 그래픽 기능을 위한 API를 제공하는데, 이 API는 서로 다른 그래픽 카드들의 공통된 기능을 추상화한 결과물이다.

추상화를 하게 되면 상세한 구현이 아닌 공통된 개념과 관계에 집중할 수 있게 되는데, 이는 큰 수준에서 시스템을 이해할 수 있도록 도와준다. 예를 들어, 로그 분석 시스템에서 로그를 다음과 같은 세 가지 방법으로 읽어올 수 있다고 해 보자.
  • 원격 서버의 로그 파일을 FTP로 다운로드한 뒤 로그 조회
  • 원격 서버의 로그 파일을 SCP로 복사한 뒤 로그 조회
  • DB 서버의 로그 테이블로부터 로그 조회
이 세 가지는 개별으로 구현은 다르지만 개념적으로는 '로그 수집'을 처리하는 과정이다. 따라서, 위 세 가지를 '로그 수집'이라는 개념으로 추상화할 수 있으며, 세 개의 상세한 구현이 아닌 한 개의 개념으로 생각할 수 있도록 해 준다.

타입 추상화와 다형성(Polymorphism)

자바에서는 클래스를 이용해서 객체를 표현한다. 예를 들어, FTP 서버로부터 로그 파일을 다운로드해서 로그를 조회하는 객체는 다음과 같은 클래스로 표현할 수 있을 것이다.

public class FtpLogCollector {
    private String ftpServer;
    ... // 기타 다른 필드

    public FtpLogCollector(String ftpServer, String user, String password) {
        this.ftpServer = ftpServer;
        ...
    }

    public FileLogSet collect() {
        ... // FTP에서 파일 다운로드
        ... // 해당 파일의 로그 정보를 담고 있는 FileLogSet 생성 및 리턴
    }
}

FtpLogCollector를 이용해서 로그 정보를 조회하는 코드는 다음과 유사한 형태를 띌 것이다.

FtpLogCollector collector = new FtpLogCollector();
FileLogSet logSet = collector.collect();
FileLogIterator logIter = logSet.iterator();
while (logIter.hasNext()) {
    Log log = logIter.next();
    ...
}
logIter.close();

비슷하게 DB 서버로부터 로그 데이터를 조회하는 객체와 그 기능을 사용하는 객체는 다음과 같은 클래스로 표현될 수 있을 것이다.

public class DBLogCollector {
    private String jdbcUrl;
    ... // 기타 다른 필드

    public DbLogCollector(String jdbcUrl, String user, String password) {
        this.jdbcUrl = jdbcUrl;
        ...
    }

    public JdbcLogSet collect() {
        ... // DB에서 로그 읽어오는 JdbcLogSet 생성 및 리턴
    }
}

DBLogCollector collector = new DBLogCollector();
JdbcLogSet logSet = collector.collect();
JdbcLogIterator logIter = logSet.iterator();
while (logIter.hasNext()) {
    Log log = logIter.next();
    ...
}
logIter.close();

두 코드를 보고나면 너무나도 똑같다는 것을 알 수 있다. 차이점은 FtpLogCollector와 DBLogCollector, FileLogSet과 JdbcLogSet 그리고 FileLogIterator와 JdbcLogIterator의 구현이 다르다는 것일 뿐, 각 클래스가 제공하는 기능은 개념적으로 완전히 동일하다.
  • FtpLogCollector, DBLogCollector : 로그 집합을 특정 자원으로부터 수집하는 기능
  • FileLogSet, JdbcLogSet: 로그에 접근할 수 있는 Iterator를 제공하는 기능
  • FileLogIterator, JdbcLogIterator: 로그를 순차적으로 접근할 수 있는 기능
여기서 추상화의 정의를 다시 살펴보자. 추상화란 데이터나 프로세스 등을 의미가 비슷한 개념이나 표현으로 정의해 나가는 과정이면서 동시에 각 개별 개체의 구현에 대한 상세함은 감추는 것이라고 했다. 이를 위에 맞춰서 설명하면 FTP나 DB에서 로그를 수집하고 파일이나 테이블에 있는 로그에 접근하는 상세한 구현은 감추고 비슷한 개념인 로그 수집과 로그 접근을 도출해내는 것이 추상화인 것이다.

객체 지향 언어에서는 이렇게 추상화된 개념을 인터페이스로 표현한다. 언어마다 인터페이스를 제공하는 방식이 다른데, 자바의 경우 인터페이스를 interface라는 별도 타입으로 제공하고 있으며 또한 추상 클래스(abstract class)를 이용해서 객체의 인터페이스를 정의할 수도 있다. 앞서 로그 수집과 접근 기능을 추상화한 인터페이스는 다음과 같이 정의할 수 있을 것이다.

public interface LogCollector {
    LogSet collect();
}

public interface LogSet {
    LogIterator iterator();
}

public interface LogIterator {
    boolean hasNext();
    Log next();
    void close();
}

객체를 추상화했으면, 그 다음으로 할 작업은 실제 기능을 제공하도록 추상화된 타입을 구현하는 것이다. 자바 언어에서는 상속을 통해서 추상화된 타입을 구현할 수 있도록 하고 있다. interface를 정의한 경우에는 인터페이스 상속인 implements를 이용해서 추상 타입을 구현하게 되고, 추상 클래스를 정의한 경우에는 클래스 상속인 extends를 통해서 추상 타입을 구현하게 된다. 다음은 FTP로부터 로그를 수집해서 로컬 파일에 저장하고 로컬 파일로부터 로그 데이터를 읽어오는 구현을 제공하는 구현의 모습을 보여주고 있다.
 
public class FtpLogCollector implements LogCollector {
    private String ftpServer;
    ...
    public FtpLogCollector(...) { ... }
    public LogSet collect() { ... // FileLogSet을 생성해서 리턴 }
}

public class FileLogSet implements LogSet {
    private List<File> files;

    public LogIterator iterator() { ... FileLogIterator를 생성해서 리턴 }
}

public class FileLogIterator implements LogIterator {
    public boolean hasNext() { ... }
    public Log next() { ... }
    public void close() { ... }
}

위 코드에서 FtpLogCollector, FileLogSet, FileLogIterator는 각각 LogCollector, LogSet, LogIterator가 정의한 기능(메서드)의 실제 구현을 제공하고 있다. JDBC를 사용하는 경우에도 비슷한 구성을 갖게 된다. 즉, FTP로 읽어오기 위해 필요한 구현과 DB에서 읽어오기 위해 필요한 구현이 모두 동일한 인터페이스에 맞춰서 개발되는 것이다.

인터페이스 상속이나 클래스 상속을 통해서 추상화 타입을 구현한다고 해서 모든 게 끝나는 것은 아니다. 이런 추상화가 힘을 발휘할 수 있는 것은 다형성 때문이다. 다형성Polymorphism은 이름 그래도 한 객체가 여러 형태를 갖는다는 의미로 다형성으로 인해 추상화가 빛을 발한다.

다형성을 자바 언어와 연결해 설명해 보자면, 한 클래스의 인스턴스(객체)는 그 클래스가 상속 받은 모든 상위 타입도 될 수 있다는 것이다. 앞의 예제로 다시 한 번 설명해 보자. FtpLogCollector는 LogCollector를 상속받고 있으므로, FtpLogCollector 타입의 객체는 LogCollector로서도 동작할 수 있다. 따라서, 다음과 같은 코드 작성이 가능하다.

LogCollector collector = new FtpLogCollector(....);
LogSet logSet = collector.collect();
LogIterator logIter = logSet.iterator();

FtpLogCollector 객체는 LogCollector 타입도 되기 때문에, 위 코드에서와 같이 FtpLogCollector의 객체를 LogCollector 타입 변수에 할당할 수 있다. 또한, collector.collect()는 실제 객체의 타입인 FtpLogCollector 객체의 collect() 메서드를 실행하게 된다.

위 코드에서 FtpLogCollector를 생성하는 코드를 한 번 더 추상화하면 어떻게 될까?

LogCollector collector = LogCollectorFactory.create();
....

LogCollectorFactory 클래스는 시스템 설정에 따라서 FtpLogCollector나 JdbcLogCollector를 생성해주는 기능을 제공하는 팩토리라고 해 보자. 즉, LogCollectorFactory 클래스는 LogCollector 타입의 객체 생성을 추상화한 클래스이다. 이제 위 코드는 더 이상 특정 LogCollector의 구현체(FtpLogCollector와 같은)에 의존하지 않으며, 오직 추상 타입인 LogCollector만 사용하고 있다. 따라서, FtpLogCollector를 사용하다가 DBLogCollector를 사용하더라도 위 코드는 전혀 영향을 받지 않는다. 오직 LogCollectorFactory만 영향을 받을 뿐이다. 이렇게 실제 사용되는 구현 클래스가 변경되었음에도 불구하고 그 타입을 사용하는 코드가 영향을 받지 않는 것이 바로 다형성으로 인해 가능하다.

추상화란 결국 개발자의 뇌를 위한 것

로그 분석 기능을 생각해보면 큰 덩어리로 '로그 파일을 읽어와 파일을 한줄 한줄 파싱한 뒤 계산해서 내용을 DB에 저장한다'일 것이다. 이런 상세함을 한 번에 다 머리속으로 생각할 수 있는 사람은 많지 않다. (물론, 정말 똑똑한 사람은 한 번에 다 생각할 수 있겠지만..) 그래서 필요한 게 생각이 가능한 수준의 덩어리로 나눠서 생각하는 것이다. 상세한 구현을 생각이 가능한 수준의 덩어리로  만들어나가는 과정이 추상화 과정이며, 이를 통해 개발자는 구현의 상세함 속에서 허우적거리지 않고 (머리속에서) 관리 가능한 수준으로 프로그램과 구현을 다룰 수 있게 된다.

관련글

참고자료


제가 쓴 객체 지향 입문서입니다.


http://www.aladin.co.kr/shop/wproduct.aspx?ISBN=8969090010  에서 확인하실 수 있습니다.



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 지노 2012.10.23 08:41 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 글 잘 읽었습니다. ^^