주요글: 도커 시작하기

GoF Decorator 패턴 요약



GoF Composite 패턴 요약



Adapter 패턴 요약한 것



GoF Builder 패턴 요약한 것



  1. 백명석 2013.04.10 09:14

    Exporter가 Fluent Interface를 제공하면 Employee에서

    exp.id(this.id)
    .name(this.name)
    .address(this.address)
    .build();

    이렇게 가독성이 좀 나아질 듯. 그리고 내게 builder는 한마디로 정의한다면 복잡한 설정이 필요한 객체 생성을 담당하는 놈. 즉 생성을 위한 정보 설정과 생성. 2가지 기능을 제공하는 놈. 만일 정보 설정 기능을 제공하지 않는다면 위 코드는.

    exp.build(this.id, this.name, this.address);

    가 될텐데... 3개의 파라미터가 다 String이라면 순서를 지키거나, 각 파라미터의 의미를 명확히하기 어려움. 하지만 처음의 코드는 각 this.id가 아니라 String s라고 되어 있어도 아 이게 id구나라고 알 수 있음. 그런 면에서 Builder가 보다 대중적으로 사용될 수 있다고 생각함. XML Node Composite 생성처럼 꼭 객체 생성 과정이 복잡하지 않더라도...

    그냥 그렇다고...

GoF 패턴 FactoryMethod 요약한 것.




  1. 백명석 2013.04.10 09:40

    범균님도 언근한 것 처럼 Factory Method는 Template Method의 일종이라고 생각됨.

    • 최범균 madvirus 2013.04.10 13:21 신고

      객체 생성이 템플릿 메서드에서 많이 호출되는 경우도 있어서, 템플릿메서드와 함께 자주 사용되는 듯 합니다.

GoF 패턴 AbstractFactory 요약한 것



  1. 백명석 2013.04.10 09:44

    나한테 참 어려운 패턴 중 하나였는데. 예제(JDBC)가 훌룡 ^^

    • 최범균 madvirus 2013.04.10 13:19 신고

      일반적인 웹 개발에서 접하는 것 중에 JDBC가 잘 들어맞는 것 같아요. 객체 군을 생성해주는 팩토리를 만들어본 기억이.. 음.... 없는 것 같기도 하고 말이죠...

GoF 패턴 1장 요약한 것

주용 내용:

  • 디자인 패턴의 가치 / 디자인 패턴이란?
  • 주요 개념: 메시지, 인터페이스, 타입, 클래스, 추상/콘크리트 클래스, 상속
  • 디자인 패턴이 풀어주는 것: 객체 역할, 크기, 인터페이스, 구현 등
  • 기본 규칙: 인터페이스 대고 프로그래밍하기, 상속보단 조립을 통한 재사용하기



도메인 모델의 데이터를 뷰에 노출하는 방법 때문에 늘상 고민을 하는데, 그런 고민을 하는 이유는 도메인 객체의 기능을 뷰에 노출하고 싶지 않기 때문이다. 도메인 객체가 단순 데이터 구조라면 도메인 객체를 그대로 (JSP나 Velocity와 같은) 뷰에 노출해도 setter 메서드만 조심해서 사용하면 크게 문제될 것이 없다. 하지만, 도메인 객체가 암호 변경, 만료일 연장과 같은 도메인 기능을 제공하고 있다면, 뷰 코드에서 이들 기능을 실행하지 않는 것을 선호하는데, 그 이유는 최대한 한 레이어에서 기능 실행을 제어하길 원하기 때문이다. (예를 들어, 도메인 기능은 바로 위 어플리케이션 레이어에서만 실행하도록 하고 컨트롤러나 뷰에서는 실행하지 않도록 함으로써, 도메인 기능을 한 곳에서만 실행하도록 코드를 제어하고 싶다.)


본 글에서는 도메인 객체를 뷰에 전달할 때 사용할 수 있는 세 가지 방법을 살펴볼 것이며, 이를 통해 여러분은 각 방법 중 자신에게 알맞은 방식을 선택할 수 있을 것이다.


방법1: 그냥 전달하기


이 방법은 가장 쉬운 방법이다. 그냥 도메인 객체를 그대로 뷰에 전달하는 방법이다. 예를 들어, 스프링 컨트롤러에서 다음과 같이 뷰에 도메인 객체를 그대로 전달한다.


public class UserInfoController {

    private UserRepository userRepository;


    @RequestMapping("/user/info")

    public String info(ModelMap modelMap) {

        User user = userRepository.findOne(getUserId());

        if (user == null) {

            return "user/userNotFound";

        }

        modelMap.addAttribute("user", user);

        return "user/userInfo";

    }

    ...

}


JSP와 같은 뷰 코드에서는 다음과 같이 user 객체를 사용한다.


${user.id} , ${user.name}


-- 연관된 객체에 접근

${user.team.name} 


-- 도메인 객체가 제공하는 기능 실행 가능

${user.changePassword('newpassword')}


위 방식은 다음의 특징이 있다.

  • 뷰 코드에서 연관된 객체에 접근할 수 있도록 해야 한다.
    • JSP와 같은 ORM 기술을 사용할 경우, 트랜잭션 범위를 뷰 영역까지 확장하거나 연관된 객체를 미리 읽어오는(eager loading) 등의 처리가 필요하다.
  • 도메인 객체가 제공하는 기능을 뷰에서 실행하는 것에 대한 엄격한 규칙이 필요하다.
    • 암호 변경과 같은 기능을 뷰에서 실행할 수 있다는 것은 도메인 기능 실행을 요청하는 곳이 여러 영역으로 분산된다는 의미인데, 이는 유지보수에 도움이 되지 않는다. 따라서, 뷰에서는 도메인 기능을 실행하지 않도록 제한하는 것이 좋다.


방법2: 읽기 전용 인터페이스


두 번째 방법은 읽기 전용 인터페이스를 만드는 것이다. 예를 들어, 다음과 같이 데이터 제공을 위한 전용 인터페이스를 작성한다. 예를 들어, 다음과 같이 정보 제공을 하는 인터페이스를 작성한다.


public interface ReadonlyUser {

    public Long getId();

    public String getName();

    public ReadonlyTeam getTeam();

}


public interface ReadonlyTeam {

    public Long getId();

    public String getName();

}


public class User implements ReadonlyUser {

    @Override

    public Long getId() { return id; }

    @Override

    public String getName() { return name; }

    @Override

    public Team getTeam() { return team; } // 자바는 리턴타입에 하위 타입 지정이 가능하다.


    public void changePassword(String newPassword) { ... }

}


public class Team implements ReadonlyTeam {

    ...

}


읽기 전용 인터페이스를 만들었다면, 읽기 전용 인터페이스를 이용해서 데이터를 읽어오는 객체를 추가한다.


public class UserDataLoaderImpl implements UserDataLoader {


    public ReadonlyUser getUser(Long id) {

        User user = userRepository.findOne(id);

        throwExceptionWhenUserIsNull(user);

        return user;

    }

}


이제 컨트롤러는 다음과 같이 UserDataLoader를 사용해서 읽기 전용 User 타입을 가져온다.


public class UserInfoController {

    private UserDataLoader userDataLoader;


    @RequestMapping("/user/info")

    public String info(ModelMap modelMap) {

        try {

            ReadonlyUser user = userDataLoader.getUser(getUserId());

            modelMap.addAttribute("user", user);

            return "user/userInfo";

        } catch(UserNotFoundException ex) {

            return "user/userNotFound";

        }

    }

    ...

}


그런데, 위와 같이 코드에서는 읽기전용 인터페이스를 사용했다고 하더라도 뷰 코드에서는 리플렉션을 이용하기 때문에 실제로는 다음과 같이 도메인 기능을 사용할 수 있다.


-- 리플렉션으로 실행 가능

${user.changePassword('newpassword')}


따라서, 완전한 읽기 전용 객체를 전달하고 싶다면, 다음과 같이 User 객체가 아닌 프록시 객체를 만들어서 전달하는 것이 좋다.


public class UserProxy implements ReadonlyUser {

    private User user;

    private ReadonlyTeam team;


    public UserProxy(User user) {

        this.user = user;

        this.team = new TeamProxy(user.getTeam());

    }


    @Override

    public Long getId() { return user.getId(); }

    ...

    @Override

    public ReadonlyTeam getTeam() { return team; }

}


DataLoader에서는 다음과 같이 User를 리턴하는 대신에 UseProxy를 생성해서 리턴한다.


public class UserDataLoaderImpl implements UserDataLoader {


    public ReadonlyUser getUser(Long id) {

        User user = userRepository.findOne(id);

        throwExceptionWhenUserIsNull(user);

        return new UserProxy(user);

    }

}


이제 뷰에는 UserProxy 객체가 전달되므로, 완전한 읽기 전용 객체를 뷰에서 사용하게 되며, 더불어 도메인 기능을 뷰에서 실행할 수 있는 가능성도 없어졌다.


방법3: Exporter 사용하기


세 번째 방법은 자신이 필요한 데이터만 가져가도록 도메인 객체에 Exporter 인터페이스를 추가하는 것이다. 이 방법을 사용하려면 다음과 같이 도메인 객체의 데이터를 받을 수 있는 인터페이스를 먼저 정의한다.


public interface UserExporter<T> {

    public void id(Long id);

    public void name(String name);

    public void team(Long teamId, String teamName);

    public T build();

}


도메인 객체는 UserExporter에게 데이터를 전달해주기 위한 메서드를 제공한다.


public class User {


    public <T> T export(userExporter<T> expoter) {

        exporter.id(this.id);

        exporter.name(this.name);

        exporter.team(this.team.getId(), this.geam.getName());

        return exporter.build();

    }

}


뷰 영역에 데이터를 전달해주어야 하는 객체는 다음과 같이 필요한 데이터를 받는 Exporter 구현체와 DTO를 만든다.


public class DataLoaderImpl .. {


    public UserDto getUser(Long id) {

        User user = userRepository.findOne(id);

        throwExceptionWhenUserIsNull(user);

        return user.export(new MyUserExporter());

    }


    private class MyUserExporter implements UserExporter<UserDto> {

        private UserDto userDto;

        public MyUserExporter() {

            userDto = new UserDto();

        }

        public void id(Long id) { userDto.setId(id); }

        public void name(String name) { userDto.setName(name); }

        ...

        public UserDto build() { return userDto; }

    }

}


DataLoader를 사용하는 코드는 UserDto를 사용하므로, 결과적으로 뷰는 데이터만 사용하게 된다.



발표 자료 첨부합니다.


DDD구현기초@JCOConf13_2p.pdf




  1. 마음이 뛰다 2013.02.23 21:20 신고

    오늘 세션 너무 잘 들었습니다~!
    예전에 뭣 모르고 JPA 쓸 때 당최 개념이 그려지지가 않았는데,
    오늘 큰 배움을 얻고 갑니다~ :D

  2. 알려주세요 2013.02.24 07:02

    DDD(Domain Driven Development)관련 자료를 찾고있었는데 자료 감사합니다. Domain Driven Design로 개발하는걸 Domain Driven Development라고 하는건가요? 제가 잘몰라서 ㅜㅜ 알려주세요

    • 최범균 madvirus 2013.02.25 09:29 신고

      도메인을 중심으로 설계하는 것을 Domain Driven Design 이라고 합니다. DDD는 domain driven design의 약자입니다.

  3. devSejong 2013.02.25 16:24

    JCO 재미있게 잘 들었습니다. 세미나 자주자주 해주세요.^^

  4. 일퍼센트 2013.03.07 16:05

    자료 잘 보고 갑니다.~

올 1월에 만들었던 DDD 자료(http://www.slideshare.net/madvirus/ddd-final)의 41 페이지에 대해 박재성 님과 박성철 님이 의견/문의를 주셨다.

필자가 왜 그렇게 구성했는지에 대해 설명하는 게 논의에 도움이 될 것 같아 블로그에 글을 남겨본다.


41 페이지에 있는 내용


예전에 필자가 한 모임에서 발표했었던 DDD 자료를 보면 DDD를 구현할 때 각 레이어의 요소를 아래와 같이 정리했었다. (아래 그림에서 DomainApplicationService는 클래스입니다. 인터페이스라고 잘못 표기되어 있습니다.)


DDD 레이어 구성


의견/취향


다음과 같은 의견/취향 때문에 DataLoadingService를 ApplicationService와 분리하고, 또 DataLoadingService를 ui 레이어에 위치시키게 되었다. (의견과 취향을 구분했다.)

  • (의견) UI에서 필요로 하는 데이터가 도메인 객체가 제공하는 데이터와 일치하지 않는다면, 도메인 객체가 아닌 UI에서 필요로 하는 데이터를 제공하는 것이 좋다고 판단하며, 이를 위해 도메인 객체를 UI 특화된 데이터로 변환하는 기능이 필요하다.
    • 이 변환 기능은 어플리케이션이나 도메인 레이어에 속하기보다는 ui에 특화된 기능이다.
    • 따라서, 도메인 객체를 UI 객체로 변환하는 역할은 ui 레이어에 위치해야 한다.
  • (의견) 도메인 엔티티가 데이터 구조가 아니라 기능 중심의 객체라면, 객체 자체를 ui 노출하기 보다는 ui에서 필요한 데이터만 추려서 제공하는 것이 좋다고 생각한다.
    • 이렇게 함으로써 UI 영역에서 객체를 실행하는 것을 막을 수 있다. 
    • (취향) 물론, 도메인 객체의 상위 인터페이스를 제공함으로써 UI 영역에서 객체 실행을 어느 정도 방지할 수 있지만, JSP/Velocity 등 리플렉션을 사용하는 곳에서 실행할 수 있기 때문에, 인터페이스 사용보단 별도 DTO를 사용하는 것을 선호한다.
  • (취향) OSIV(Open Session In View)를 좋아하지 않는다. 트랜잭션 범위에 렌더링(JSP 렌더링, XML 변환 등)이 포함되는 걸 선호하지 않는다. OSIV는 어떤 면에서 클라이언트에서 트랜잭션을 제어하는 것처럼 느껴지기도 한다.
    • 이런 이유로, ORM을 사용할 경우, 렌더링에 필요한  모든 데이터가 로딩된 상태로 렌더링 영역에 전달되는 것을 선호한다.
만약 아래와 같은 경우라면, DTO 변환 없이 ui 레이어에서 리포지토리에 접근해서 도메인 엔티티를 직접 사용해도 된다는 것이 개인 의견이다.
  • 도메인 엔티티가 기능이 거의 없는 단순 데이터 구조이다.
    • 즉, get/set 메서드 또는 public 필드를 가진 인스턴스이다.
    • 예를 들어, 게시글/첨부파일/방명록과 같이 특별한 기능을 갖지 않고 데이터 구조에 가까운 것들
  • 데이터 구조인 도메인 엔티티와 화면이 거의 1대 1이다.


------- 2011-11-20 내용 추가

이일민님의 의견 및 저의 고민에 대한 답글: https://plus.google.com/108466558467124953919/posts/UhYZxoZXMA4



  1. 자바랑 2012.11.21 16:11

    궁금한 점을 적습니다.
    글을 보다가 문득...
    모델링을 하신 툴은 무엇으로 그리셨는지 궁금합니다.

UI 관련된 코드를 살펴보다가 리팩토링이 필요한 부분이 있어 점진적으로 리팩을 하고 있는데, 최근에 State 패턴을 적용한 사례가 있어 내용을 공유해 본다.


구현된 결과물은 다음과 같은 UI를 갖고 있다.



검색 탭을 누르면 '전체' 또는 '비디오'와 '블로그'로 분류해서 검색할 수 있다. 검색 탭을 누르면 기본적으로 '전체'를 기준으로 검색을 하며, '비디오'와 '블로그'를 선택하면 각 분류에 해당하는 컨텐츠만 검색한다. 비슷하게 마이로그 탭을 누르면, 기본적으로 즐겨찾기와 전체가 선택된다. 내역을 누르면 동일하게 전체가 기본으로 선택된다. 즐겨찾기와 내역 둘 다 비디오와 블로그를 선택하면 각 분류에 속한 결과만 조회된다.


이러한 기능을 제공하는 UI를 구현하기 위한 코드는 다음과 같은 방식으로 구현되어 있었다. (아래 코드는 이해를 돕기 위해 간단하게 정리한 것이다. 실제로는 더 복잡하게 되어 있다.)


public class SearchUI extends .... {


    private int selectMode; // 0 검색, 1 즐겨찾기, 2 내역

    private int tabMode; // 0 전체, 1 비디오, 2 블로그


    public void onClick(View v) { // 탭이 눌릴 때 이벤트 처리

        switch(v.getId()) {

        case R.id.search:

            selectMode = 0;

            tabMode = 0;

            break;

        case R.id.mylog:

            selectMode = 1;

            tabMode = 0;

            break;

        case R.id.favorite:

            selectMode = 1;

            tabMode = 0;

            break;

        case R.id.history:

            selectMode = 2;

            tabMode = 0;

            break;

        case R.id.all:

            tabMode = 0;

            break;

        case R.id.video:

            tabMode = 0;

            break;

        case R.id.blog:

            tabMode = 0;

            break;

        }

    }


    // selectMode와 tabModel에 따라 알맞은 분기

    private void process() {

        if (selectMode == 0) {

            if (tabMode == 0) {

                processSearchAll();

            } else if (tabMode == 1) {

                processSearchVideo();

            } else if (tabMode == 2) {

                processSearchBlog();

            }

        } else if (selectMode == 1) {

            if (tabMode == 0) {

                processGetAllFavoriteContent();

            } else if (tabMode == 1) {

                processGetFavorite();

            } else if (tabMode == 2) {

                processGetFavoriteBlog();

            }

        } else if (selectMode == 2) {

            ... // 비슷한 코드

        }

    }

}


만약 이 상태에서 탭이라도 하나 덩 생긴다면,,,,,,  Oh NO!!! 생각도하기 싫어진다. 그래서 새로운 요구사항이 생기기 전에 위 코드의 설계를 변경하기로 했다. 설계를 변경하기에 앞서 코드의 의미를 먼저 파악해 보았다. 파악한 결과는 아래와 같다.


* 검색을 누르면

======> 검색 상태가 되고, 전체 검색 기능을 실행한다.

* 마이그로나 즐겨찾기를 누르면

======> 즐겨찾기 상태가 되고, 전체 즐겨찾기 조회 기능을 실행한다.

* 내역을 누르면

======> 내역 상태가 되고, 전체 내역 조회 기능을 실행한다.


* 검색 상태에서

======> 전체를 누르면, 전체 검색 기능을 실행한다.

======> 비디오를 누르면, 비디오 검색 기능을 실행한다.

======> 블로그를 누르면, 블로그 검색 기능을 실행한다.

* 즐겨찾기 상태에서

======> 전체를 누르면, 전체 즐겨찾기 조회 기능을 실행한다.

======> 비디오를 누르면, 비디오 즐겨찾기 조회 기능을 실행한다.

======> 블로그를 누르면, 블로그 즐겨찾기 조회 기능을 실행한다.

* 내역 상태에서

======> 전체를 누르면, 조회한 전체 내역 조회 기능을 실행한다.

======> 비디오를 누르면, 조회한 비디오 내역 조회 기능을 실행한다.

======> 블로그를 누르면, 조회한 블로그 내역 조회 기능을 실행한다.


위에서 공통된 부분을 추려보면 다음과 같은 형식을 따른다.


* 특정한 상태에서 

======> 전체를 누르면, 전체 관련 기능 실행한다.

======> 비디오를 누르면, 비디오 관련 기능 실행한다.

======> 블로그를 누르면, 블로그 관련 기능 실행한다.

* 특정한 상태가 되면 전체 관련 기능 실행한다.


뭔가 상태에 변화가 있고, 동일한 기능 요청에 대해서 상태에 따라 다르게 동작해야 한다. 빙고! 상태(State) 패턴이다. 이제 위 코드를 상태 패턴을 이용해서 변경해 보자. 상태 패턴에 대한 설명은 필자가 예전에 정리한 패턴 스터디 발표 자료를 참고하기 바란다.


상태의 종류


상태는 크게 다음의 세 종류가 있다.

  • 검색 상태
  • 즐겨찾기 상태
  • 내역 상태
따라서, 다음과 같이 타입을 구성할 수 있을 것이다.


상태의 기능 정의


상태에서 제공할 기능은 다음의 3개다.

  • 전체 관련 기능
  • 비디오 관련 기능
  • 블로그 관련 기능
따라서, State 인터페이스는 다음과 같의 3개의 기능을 제공하도록 정의할 수 있다.

public interface State {
    void all();
    void video();
    void blog();

    void enter();
}

State의 3개의 하위 타입들은 각각 위 메서드를 알맞게 오버라이딩 할 것이다. 잠깐, 위에서 enter() 메서드는 상태가 바뀔 때 해당 상태로의 진입 처리를 하기 위한 메서드이다. 예를 들어, SearchState의 enter() 메서드는 화면상에 검색어를 입력하는 화면을 보여줄 수 있을 것이다.

상태를 정리했으니 다음으로 할 작업은 상태를 사용하도록 코드를 수정하는 것이다. 바뀐 코드는 아래와 같다.

public class SearchUI extends .... {


    private State currentState;


    public void onClick(View v) { // 탭이 눌릴 때 이벤트 처리

        switch(v.getId()) {

        case R.id.search:

            state = new SearchState();

            state.enter();

            break;

        case R.id.mylog:

        case R.id.favorite:

            state = new FavoriteState();

            state.enter();

            break;

        case R.id.history:

            state = new HistoryState();

            state.enter();

            break;

        case R.id.all:

            state.all();

            break;

        case R.id.video:

            state.video();

            break;

        case R.id.blog:

            state.blog();

            break;

        }

    }

}


앞의 코드와 비교해보자. 훨씬 간단해진 것을 알 수 있다. 하지만, 위 코드는 상태 변이와 기능 요청코드-State 객체를 생성하는 코드와 State의 all() 등의 메서드를 실행하는 코드-가 섞여 있다. 이는 다음과 같이 두 개의 리스너로 구분하는 것으로 정리할 수 있다.


public class SearchUI extends .... {


    private State currentState;


    private OnClickListener changeStateLitener = new OnClickListener() {

        void onClick(View v) { // 탭이 눌릴 때 이벤트 처리

            switch(v.getId()) {

                case R.id.search:

                    state = new SearchState();

                    break;

                case R.id.mylog:

                case R.id.favorite:

                    state = new FavoriteState();

                    break;

                case R.id.history:

                    state = new HistoryState();

                    break;

                default:

                    throw new ..... // 익셉션

        }

        state.enter();

    }


    private OnClickListener requestFunctionLitener = new OnClickListener() {

        void onClick(View v) { // 탭이 눌릴 때 이벤트 처리

            switch(v.getId()) {

            case R.id.all:

                state.all();

                break;

            case R.id.video:

                state.video();

                break;

            case R.id.blog:

                state.blog();

                break;

        }

    }

}


State 패턴을 적용한 결과 다음과 같은 일을 덜 고생하면서 할 수 있게 되었다.

  • 새로운 상태 추가 : if-else 블록의 경우 새로운 상태 추가와 함께 비슷한 if-else 블록이 이곳 저곳에 추가되어 코드가 더 복잡해진다. 하지만, State 패턴을 적용하면, State 객체를 사용하는 코드 입장에서 즉, 위 코드에서는 SearchUI 입장에서 새로운 State 구현 클래스와 해당 State로 전이하는 코드만 추가해 주면 된다.

  • 새로운 기능 요청 : if-else 블록의 경우 새로운 기능 요청이 추가되면 비슷한 if-else 블록이 이곳 저곳에 추가되어 코드가 더 복잡해진다. 하지만, State 패턴을 적용하면 각각의 State 구현체들이 해당 기능을 구현하므로 코드의 복잡도가 전혀 증가되지 않는다. SearchUI 입장에서 새로운 기능을 실행하는 코드만 추가해 주면 된다.


  1. maruldy 2012.11.09 16:15

    안녕하세요.
    Android 어플 개발 도중에, State 패턴을 적용해 보려다 궁금한 점이 있어서 댓글을 남깁니다..
    평소 최범균님 블로그에서 공부를 하려다가 댓글 남기는건 처음인데요..
    바쁘지 않으신 시간에 한번만 봐주시면 감사하겠습니다.. ^^;

    현재 작성중인 MaruActivity 클래스를 살펴보니, 여러 상태를 비교적으로 명확하게 가지게 되어서,
    기존에 if 문 들로 구분하던 부분을 각각의 State 로 분리 했습니다.
    그 결과 MaruAcitvity 는 SubState 라는 interface 를 구현한 AState, BState, CState, DState 를 갖게 되었습니다.

    또한 MaruActivity 에서 if 문으로 비교된 코드들이 상당히 간결화 되고, 화면 객체를 컨트롤 하는 Method 들 위주로 남아있게 되었습니다.

    문제는, 각각의 SubState 들로 옮겨진 코드들의 결과값을 MaruActivity 의 화면에 표현해 주기 위해서 각각의 SubState 들이 MaruActivity 의 직접적인 Reference 를 가지게 되었습니다.

    MaruActivity 와 SubState 가 각각 상호간에 참조를 하게 된겁니다.

    지금 이에 대한 처방으로는 MaruActivity 에서는 객체가 없어질 때, 각 SubState 로 부터 MaruActivity 의 Reference 를 해제해주는 메소드를 호출해주고 있습니다.

    하지만, 그동안 책에서 봤었던 State 패턴에서는 중간에 다른 클래스를 두어서 MaruActivity 와 SubState 들 간의 coupling 을 낮추는 방식을 많이 추천하고 있는데요.. 지금 이대로 가는게 좋을지, 아니면 중간에 다른 클래스를 두어서 결합도를 더 낮추는게 맞을지를 어떤 기준으로 판단해야 할지 몰라서 여쭙니다..

    • 최범균 madvirus 2012.11.12 00:18 신고

      SubState가 MaruActivity에 요구하는 것이 무엇인지 확인하고, 그걸 추상화한 인터페이스로 분리하면 어떨까요? 대충 아래와 같이요.

      public class MaruActivity extends ... implements SomeIF {

      SubState ss = new ConcreteSubState(this);
      }

      public class ConcreteSubState implements SubState {
      private SomeIF someIF;

      public ConcreteSubState(SomeIF someIF) {
      this.someIF = someIF;
      }

      public void doAnything() {
      ...
      someIF.some();
      ...
      }
      }

    • maruldy 2012.11.12 10:22

      답변 감사합니다.

      답변해주신 부분 참고해 보겠습니다. ^^
      좋은 한 주 되세요~~ :)

OOP 소개 자료 입니다.


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

OOP 소개 20121110_2page.pdf



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

OOP 소개 20121013_2page.pdf



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

OOP 소개 20120922.pdf


  1. 홍구 2012.10.04 21:28

    감사히 봅늬다

거의 모든 객체 지향 관련 서적에서 설명하는 기본 객체 지향 설계 원칙이 있는데, 객체 지향 기초의 마지막 이야기로 이들 원칙들을 살펴보도록 하자.


이전글


단일 책임 원칙(SRP)


첫 번째 원칙은 '단일 책임 원칙(SRP; Single Responsibility Principle)'이다. 이는 객체는 단 한 개의 책임(역할)만을 가져야 한다는 내용으로, 객체를 변경해야 하는 이유는 단 하나여야 한다는 원칙이다. 예를 들어, Book 클래스가 JSON으로의 변환을 처리한다고 해 보자. 이 경우 클래스는 다음과 같이 정의될 것이다.


public class Book {

    public int calculatePrice() { ... }

    public String toJson() { ... }

}


만약 Book를 XML이나 HTML로도 표현해야 한다면? 이런 식으로 변환해야 할 대상이 늘어날수록 Book 클래스의 함께 변하게 된다. 이 외에도 JSON이나 XML의 구조가 변경되더라도 Book 클래스의 코드가 변경된다. Book 클래스의 코드가 표현 방식의 추가나 변경에 따라서 함께 바뀌는 증상이 발생하는데, 그 이유는 Book 클래스가 많은 책임을 지고 있기 때문이다.


책임을 많이 질수록 클래스 내부에서 서로 다른 역할을 위한 코드들 간에 강하게 결합될 가능성이 높아진다. 예를 들어, JSON으로 변환하는 코드와 HTML로 변환하는 코드와 계산을 위한 코드 중 어딘가가 연결되어 있을 수 있고, 이런 경우 계산 공식의 변화가 JSON 변환 코드에 변화를 불러 일으킬 수도 있다.


따라서, 한 객체는 하나의 책임만 갖도록 설계해야 한다. 예를 들어, 앞서 Book 클래스의 경우 책 자체의 정보를 처리하는 Book 클래스와 JSON이나 XML로 표현을 변경해주는 JsonConverter, XMLConverter 클래스로 분리해주어야 한다.


이렇게 한 객체가 하나의 책임만을 갖도록 함으로써 각 객체를 변경시키는 이유는 한 가지로 좁혀진다. 책과 관련된 계산 변경이 필요하면 Book만 변경되며, JSON이나 XML 형식이 변경되면 JsonConverter 클래스와 XMLConverter 클래스에만 변화가 생긴다. (물론, Book의 정보 자체에 변화가 생기면 그 변화는 두 Converter에 영향을 준다.)


의존 역전 원칙(DIP)


의존 역전 원칙(Dependency Inversion Principle)은 구현 클래스가 아닌 인터페이스 타입을 사용하라는 규칙이다. 이는 이미 앞서 '객체 지향 기초 3, 유연함 http://javacan.tistory.com/entry/OO-Basic-3-Flexibility'에서 말했던 내용과 동일하다. 구현 클래스는 자주 변경될 가능성이 높기 때문에, 변화 가능성이 상대적으로 적은 추상 타입이나 메서드에 의존하면 변화에 따른 영향을 최소화할 수 있다.



예를 들어, 위 그림에서 보듯이 SomeClient 클래스는 구현 클래스인 FtpLogCollector를 사용(의존)하는 것 보다, 추상 타입인 LogCollector를 사용하는 것이 변화에 영향을 덜 받게 된다. 이 경우 FtpLogCollector 자체의 구현이 변경되거나 심지어 DBLogCollector로 구현 클래스가 바뀌더라도 SomeClient는 거의 영향을 받지 않게 된다.


개방 폐쇄 원칙(OCP)


개방 폐쇄 원칙(Open-Closed Principle)은 특정 클래스(또는 모듈 등)는 그 클래스를 수정하지 않으면서 확장(extension) 가능해야 한다라는 원칙이다. 객체 지향에서 OCP는 크게 두 가지 방법으로 구현할 수 있다.

  • 상속을 통한 구현
  • 조립을 통한 구현

상속을 통한 구현은 스프링 2 버전의 MVC 컨트롤러 구성에서 확인할 수 있다. 아래 그림은 스프링 MVC의 Controller 타입의 계층 구조이다. 이 구조를 보면 AbstractController 클래스를 수정하지 않고, 상속을 통해 기능을 확장한 것을 알 수 있다. 예를 들어, 파라미터를 커맨드 객체로 받기 위해 AbstractController 클래스를 수정하지 않고 이 클래스를 상속받은 BaseCommandController 클래스를 만들었다. 즉, 변경을 하지 않으면서(Closed for modification) 확장에는 열려(open for extension)있는 것이다. 추가로 아래 클래스들은 각각 한 가지 역할만을 갖도록 설계되어 있다. 상속을 통해 기능을 확장하는 경우에도 SRP를 잘 지키고 있다.



OCP를 실현하는 두 번째 방법은 조립(composition)이다. 조립에 대한 내용은 '객체 지향 기초 4, 상속 보단 조립 http://javacan.tistory.com/entry/OO-Basic-4-Composition-Over-Inheritance)에서 살펴봤었는데, 조립과 DIP 그리고 상속이 함께 맞물리는 방법으로 OCP를 구현하게 된다. 아래 그림은 의존 역전 원칙을 설명할 때 사용한 클래스 다이어그램인데, 이 구조를 OCP에도 그대로 사용할 수 있다.



위 그림에서 SomeClient는 로그를 수집하는 기능을 조립하는 방식으로 구현하고 있다. SomeClient를 사용하는 또 다른 코드는 SomeClient가 로그 수집을 직접 수행하는지 조립을 통해 수행하는지는 모를 것이다. SomeClient는 조립을 통해 로그 수집 기능을 구현하고 있기 때문에, 로그 수집 방법을 개선할 필요가 있을 때 SomeClient는 수정하지 않으면 기능을 확장할 수 있다.


참고자료


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


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

기능의 재활용이나 기존 기능을 확장하기 위해서 사용하는 가장 손쉬운 방법은 구현 상속을 사용하는 것이다. 수화물 목록을 관리하기 위해 ArrayList를 상속받아서 구현했다고 하자.


public class LuggageCompartment extends ArrayList<Luggage> {

    private int restSpce;

    public void add(Luggage piece) {

        this.restSpace -= piece.getSize();

        super.add(piece);

    }


    public void canContain(Luggage piece) {

        return this.restSpace > piece.size();

    }


    public void extract(Luggage piece) {

        this.restSpace += piece.getSize();

        super.remove(piece);

    }


}


LuggageCompartment는 ArrayList가 제공하는 목록 관리 기능을 재사용하기 위해 ArrayList를 상속받았고, 내부적으로 짐을 넣을 수 있는 여분 공간을 관리하는 기능을 추가하였다. LuggageCompartment 클래스를 정해진 대로만 사용하면 문제가 발생하지 않는다. 하지만, LuggageCompartment는 상위 클래스인 ArrayList의 기능도 함께 제공하기 때문에 다음과 같은 코드를 작성하는 것이 가능하다.


LuggageCompoartment lc = new LuggageCompartment();

lc.add(new Luggage(10));

lc.remove(someLuggage); // 앗!! restSpace가 계산되지 않는다!

lc.extract(anyLuggage);

lc.canContain(aLuggage); // 잘못된 결과


위 코드에서 remove() 메서드는 ArrayList가 제공하는 메서드이므로 LuggageCompartment 객체에서도 호출할 수 있다. 문제는 remove() 메서드를 호출할 경우 LuggageCompartment가 지켜야 하는 규칙(여분 공백 계산)이 지켜지지 않는다는 점이다. 그렇다고 여분 공백 계산을 위해서 ArrayList의 거의 모든 메서드를 오버라이딩하는 것은 배보다 배꼽이 더 큰 상황을 만든다.


상속은 'IS A'에 대한 것


그럼, 왜 이런 문제가 발생하는 걸까? 그 이유는 역할이 같지 않은 클래스를 상속받아 재사용했기 때문이다. 상속은 'IS A' 관계일 때 의미를 갖는다. 예를 들어, ArrayList는 AbstractList이다. (ArrayList is a AbstractList). 따라서, ArrayList가 오버라이딩 하지 않은 AbstractList의 메서드를 호출하더라도 ArrayList 객체는 아무런 문제를 일으키지 않으며, ArrayList는 기대하는 바대로 동작한다.


반면 앞서 LuggageComponent는 ArrayList가 아니다. 즉, LuggageComponent is not a ArrayList 인 것이다. 이런 상황에서 ArrayList의 목록 관리 기능을 재사용하기 위해 ArrayList를 상속받아 구현하면 앞서 살펴본 것 처럼, 상위 클래스의 메서드를 호출할 때 하위 클래스의 기능이 올바르게 동작하지 않는 문제가 발생하게 된다. 즉, 'IS A' 관계가 아닌 두 클래스를 구현 상속으로 연결함으로써 원하지 않은 문제가 발생한 것이다.


따라서, 구현 상속을 이용해서 기능을 재사용하려면 반드시 두 클래스가 같은 역할을 수행하는지 확인해야 한다. 두 클래스가 생성하는 두 객체가 서로 다른 추상 타입을 위한 것이라면 기능 재사용의 방법으로 상속을 선택하면 안 된다.


조립을 통한 기능 재사용


두 클래스가 IS A 관계가 아니라면, 그 두 클래스는 서로 다른 역할을 수행한다는 의미를 갖는다. 앞서 LuggageCompart는 ArrayList처럼 범용적인 객체의 목록을 관리하는 역할을 수행하지 않는다. 단지, 짐의 목록을 관리하기 위한 목적으로 ArrayList의 기능이 필요했던 것이다. 이렇게 역할이 다른 객체의 기능을 재사용하고 싶다면, 조립(composition)을 통해서 재사용하는 것이 좋다.


조립을 통한 재사용은 다음과 같이 필드로 재사용할 객체를 정의하고 메서드 내부에서 해당 필드를 사용하는 식으로 구현된다.


public class LuggageCompartment {


    private List<Luggage> luggages = new ArrayList<Luggage>();

    private int restSpce;


    public void add(Luggage piece) {

        restSpace -= piece.getSize();

        luggages.add(piece);

    }


    public void canContain(Luggage piece) {

        return this.restSpace > piece.size();

    }


    public void extract(Luggage piece) {

        restSpace += piece.getSize();

        luggage.remove(piece);

    }


}


조립 방식으로 구현한 LuggageCompartment 클래스는 ArrayList를 상속받지 않기 때문에 LuggageCompoartment를 사용하는 코드는 remove()와 같은 메서드를 호출할 수 없다. 따라서 LuggageCompartment 클래스는 구현 상속의 경우처럼 원하지 않는 메서드가  호출되어 기능이 비정상적으로 동작하는 걱정을 하지 않아도 된다. 또한, LuggageCompartment가 목록을 관리하기 위해 ArrayList를 사용한다는 점을 클래스 외부에서 알 수 없기 때문에 캡슐화도 향상된다.


조립 방식의 또 다른 장점은 런타임에 재사용할 기능을 교체할 수 있다는 점이다. 상속은 컴파일타임에 정적으로 관계가 결정된다. 만약 ArrayList가 아닌 LinkedList를 상속받으려면 코드를 수정해서 재컴파일해야 한다. 반면 조립방식의 경우는 런타임에 재사용할 객체를 결정할 수 있다. 예를 들어, 앞서 LuggageCompartment가 생성자를 통해서 기능을 재사용할 List 구현 객체를 전달받는다고 해 보자. 이 방식은 런타임에 얼마든지 구현 객체를 변경할 수 있기 때문에, 소스 코드 수정이 필요한 정적 방식보다 더 유연하게 재사용할 객체를 변경할 수 있게 된다. ('객제 지향 기초 이야기 3, 유연함'http://javacan.tistory.com/entry/OO-Basic-3-Flexibility 을 참고하자.)


구현 상속은 정말 필요할 때 만 쓸 것!


구현 상속은 유지보수를 어렵게 만드는 요인이 된다. 예를 들어, 스프링의 AbstractController를 생각해보자. AbstractWizardFormController, SimpleFormController, MultiActionController 등이 이 클래스의 구현을 상속받고 있다. 이는 AbstractController의 기능을 수정하려면 그 하위 클래스들이 받을 영향에 대해서 고려해야 한다는 것을 뜻한다. 즉, 상속 받은 클래스가 많으면 많을수록 코드 변경시 고려해야 하는 클래스들의 숫자는 비례해서 증가하며, 이는 변경의 어려움이 증가한다는 것을 뜻한다. 따라서, 구현 상속을 통해 기능을 재사용하려거든 정말 필요한 경우에 한해서 적용해야 하며, 조립이나 메타 정보 사용 등의 방법으로 풀 수 있다면 다른 방법을 사용하는 것이 코드 유지보수에 있어 유리하다.


관련글

참고자료


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


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




객체 지향을 하는 이유는? 앞서 '객체 지향 기초 이야기 1, 객체(http://javacan.tistory.com/entry/OO-Basic-1-Object)' 글에서 유지보수 비용을 절감하기 위해 객체 지향적으로 개발한다고 했던 것을 기억할 것이다. 유지보수 비용을 줄일 수 있다는 것은 기능을 쉽게 추가하거나 변경할 수 있다는 것을 의미하는데, 이는 다음의 두 가지를 통해서 얻을 수 있다.
  • 캡슐화: 내부의 구현이 변경되더라도 외부에 영향을 최소화
  • 다형성: 구현체 변경의 유현함을 제공
이번에 살펴볼 내용은 위 두 가지 중 다형성이 주는 유연함에 대한 것이다.

유연하지 않은 코드


유연한 코드에 대해 살펴보기 전에 먼저 유연하지 않은 코드를 살펴보자. 앞서 예제들에서 사용했던 로그 수집을 생각해보자. 최초에 로그 처리는 FTP로부터 파일을 읽어오는 요구만 있었다. 그래서 로그 처리 과정을 제어하는 객체는 FTP로부터 로그를 다운받아오는 FtpLogCollector를 사용해서 구현하였다.


// 흐름 제어

FtpLogCollector logCollector = new FtpLogCollector(....);

FtpLogSet logSet = logCollector.collect();

....


그런데, 요구 사항이 갑자기 바뀌었다. FTP가 아닌 DB에 쌓여 있는 데이터를 로그로 사용해야 한다는 것이었다. 이제 해야 하는 일은? DB에서 로그를 읽어오는 객체를 구현하고 FtpLogCollector 클래스가 출현하는 부분을 모두 DbLogCollector로 바꿔주는 것이다.


DbLogCollector logCollector = new DbLogCollector(....);

DbLogSet logSet = logCollector.collect();

....


로그의 수집 방식이 바뀌면, 로구를 수집하는 구현 클래스가 바뀌는 것 뿐만 아니라 수집 기능을 사용하는 코드도 바뀌게 된다. 즉, 하나의 변화가 다른 코드의 변화를 유발시키는 것이다. 이런 걸 당해본 개발자들은 알겠지만, 이런 종류의 일은 어딜 변경해야 하는지 코드를 검색해야 하고, 노동 집약적인 방식으로 일을 하도록 만든다.


이런 일이 벌어지는 이유는 추상화된 타입이 아닌 실제 구현체를 이용해서 코드를 만들었기 때문이다.


구현체(implementation)가 아닌 인터페이스(interface)에 대고 프로그래밍하기


객체 지향의 기본 규칙 중에 '구현체가 아닌 인터페이스에 대고 프로그래밍'하라는 규칙이 있다. 이 규칙이 나온 이유는 바로 유연함 때문이다. 앞서 봤듯이 추상화된 인터페이스 타입이 아닌 실제 구현을 제공하는 타입을 이용해서 프로그래밍 할 경우, 구현에 변화가 생겼을 때 변화의 영향력이 이곳 저곳으로 퍼져 나가게 된다.


변화가 발생하는 부분이 있다면, 이 부분을 추상화해서 인터페이스 타입을 이용해서 프로그래밍 해 주어야 한다. 인터페이스 타입을 사용해서 구현하게 되면, 실제 구현 객체가 변경되더라도 인터페이스 타입을 이용해서 구현한 코드는 영향을 받지 않게 된다. 예를 들어, 아래는 FtpLogCollector 대신 로그 수집을 추상화한 LogCollector 인터페이스에 대고 프로그래밍을 한 코드이다.


LogCollector logCollector = ... // LogCollector 구현 객체를 어디선가 구함

LogSet logSet = logCollector.collect();

...


LogCollector의 실제 구현 객체를 구하는 부분은 일단 잊어두고 나머지 코드를 살펴보자. 로그 수집하는 방식이 FTP에서 DB로 바뀌더라도 위 코드는 바뀌지 않는다. DB 뿐만 아니라 어떤 방식으로 바뀌더라도, 심지어 FTP와 DB에서 모두 로그를 읽어오도록 구현하더라도 위 코드는 바뀌지 않는다. 즉, 인터페이스에 대고 프로그래밍함으로써 실제 사용되는 구현 객체를 쉽게 변경할 수 있는 유연함을 얻게 된 것이다.


위 코드에서 바뀌는 부분은 실제 LogCollector 구현 객체를 생성하는 부분이다. 실제 사용할 구현 객체를 구하는  부분은 Factory 패턴을 사용하거나 DI(Dependecy Injection)을 통해서 코드 변경을 최소화할 수 있는데, 이 두 가지에 대한 내용은 본 글의 범위를 벗어나므로 패턴 관련 서적 등을 참고하기 바란다.


하지만, 유연함을 얻는 과정은 간단하지 않다. 먼저 실제 구현 객체를 추상화한 인터페이스를 도출해내야 한다. 처음 객체 지향을 접하는 사람들이 어려워 하는 것 중의 하나가 객체를 알맞게 추상화한 인터페이스를 도출하는 것이기 때문에, 유연한 코드를 얻어 내려면 기능(객체)을 추상화하는 연습을 많이 해 주어야 한다.


인터페이스를 도출하기 위한 기본적인 방법은 구현 클래스의 public 메서드를 인터페이스로 변경해보는 것이다. public 메서드는 외부에 기능을 제공할 목적으로 만들어지는 경우가 많기 때문에 인터페이스 후보가 될 수 있다. 내부의 private 메서드 중에서도 인터페이스 후보가 나올 수 있다. private 메서드는 클래스 내부에서 사용되는 기능을 분리한 경우가 많은데, 이 경우 private 메서드를 별도의 인터페이스로 분리함으로써 해당 기능에 대한 유연함을 얻을 수 있게 된다.


인터페이스를 항상 만들어야 하나?


당장 발생하지도 않을 변화를 준비하느라 미리 앞서 인터페이스를 사용할 필요는 없다. 실제로 모든 구현 타입에 대해 인터페이스 타입을 만드는 것은 매우 성가시면서도 불필요한 일이 될 수 있다. 하지만, 그럼에도 불구하고 필자는 인터페이스를 최대한 도출해내려고 노력하는데, 그 이유 중 하나는 단위 테스트에 필요한 Mock 테스트를 할 수 있도록 하기 위함이다.


구현을 하다보면 다른 기능이 구현되지 않아서 현재 작업중인 코드를 테스트 할 수 없는 경우가 발생하는데, 이 경우 Mock 객체를 사용하면 다른 기능의 구현이 되어 있지 않더라도 작업중인 코드를 어느 정도 테스트 할 수 있게 된다. 다른 기능이 만약 인터페이스로 추상화되어 있다면 여러 Mock 라이브러리를 사용해서 다른 기능을 가짜로 제공할 수 있게 되며, 이는 다른 기능의 구현이 완성되어 있지 않더라도 현재 기능을 테스트 할 수 있다는 것을 의미한다.


관련글

참고자료


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


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


  1. 권남 2012.05.29 12:28

    잘 읽고 있습니다.
    참 쉽게 설명하시네요.

+ Recent posts