2 추상화

source: categories/study/oop_programming/opp_programming_2.md

2. 추상화

2.1 다형성과 추상화

  • 다형성과 추상화
    • 다형성 보다는 추상화에 대해 많이 이야기해볼 생각

  • 캡슐화와 더불어서 객체 지향을 할 때 필요한 것이 추상화이다.
  • 매우 중요한 내용이다.

다형성(Polymorphism)

  • 여러(poly) 모습(morph)을 갖는 것
  • 객체 지향에서는 한 객체가 여러 타입을 갖는 것
    • 즉 한 객체가 여러 타입의 기능을 제공
    • 타입 상속으로 다형성 구현
      • 하위 타입은 상위 타입도 됨

다형성 예



public class Timer {
  public void start() {
    // ...
  }
  public void stop() {
    // ...
  }
}

public interface Rechargeable {
  void charge();
}




public class IotTimer extends Timer implements Rechargeable {
  public void charge() {
    // ...
  }
}


  • IotTimerTimer 타입도 돼고, Rechargeable 타입도 됩니다.


IotTimer it = new IotTimer();
it.start();
it.stop();

Timer t = it;
t.start();
t.stop();

Rechargeable r = it;
r.charge();


  • 그래서 위와 같이 IotTimerTimer에도 할당할 수 있고, Rechargeable에도 할당할 수 있다.
  • IotTimerTimer 타입에 정의되어있는 기능과 Rechargeable에 정의된 기능을 모두 사용할 수 있다.

추상화(Abstraction)

  • 그럼 다형성 이야기를 왜 꺼냈냐. 추상화 때문이다.
  • 우선 추상화가 뭔지 얘기를 해보자.

  • 데이터나 프로세스 등을 의미가 비슷한 개념이나 의미 있는 표현으로 정의하는 과정
    • 무언가 개념화 시키거나 표현을 도출하는 것이 추상화이다.
  • 두 가지 방식의 추상화
    • 특정한 성질, 공통 성질(일반화)
    • 첫번째, 특정한 성질을 뽑아내는 방식, 예를 들어서 사용자에서 아이디, 이름, 이메일을 뽑아내서 USER 테이블로 추상화할 수 있다.
    • 돈과 관련된 여러 특징에서 통화와 금액을 갖고서 Money 클래스로 추상화할 수 있다.
    • 두번째, 공통 성질을 뽑아내는 방식
    • HP MXXX, 삼성 SL-M2XXX 이 두 모델의 공통점은 ‘프린트'이다.
    • 지포스, 라데온의 공통점은 ‘GPU'이다.
    • 이렇게 추상화는 무언가 특정한 성질이나 공통점을 뽑아내는 과정이다.
    • 그럼 다형성은 무엇과 관련된 것일까?
    • 바로 공통 성질을 뽑아내는 추상화 방식과 다형성이 관련되어 있습니다.
  • 간단한 예
    • DB의 USER 테이블: 아이디, 이름, 이메일
    • Money 클래스: 통화, 금액
    • 프린터: HP MXXX, 삼성 SL-M2XXX
    • GPU: 지포스, 라데온

서로 다른 구현 추상화

  • 서로 다른 구현의 공통된 특징이 있으면 이를 추상화 해볼 수 있다.
  • 위에서 살펴본 두번째 방식으로 추상화를 해봤다.
SCP로 파일 업로드
HTTP로 데이터 전송 -- 추상화 --> 푸시 발송 요청
DB 테이블에 삽입
  • SCP로 파일을 업로드하고, HTTP로 데이터를 보내고, 경우에 따라 DB 테이블에 데이터를 삽입했는데,
  • 이 3가지가 알고 봤더니, 푸시를 보내기 위한 구현이었던 것.
  • 그럼 위 3가지를 추상화 해보면 푸시 발송 요청이란 것으로 표현할 수 있는 것이다.

타입 추상화

  • 그럼 타입 추상화는 위와 같은 구현을 추상화 할 때 사용을 합니다.
  • 여러가지 구현 클래스가 공통점이 있을 때, 이를 추상화한 상위 타입을 도출해낼 수 있다.

  • 여러 구현 클래스를 대표하는 상위 타입 도출
    • 흔히 인터페이스 타입으로 추상화
    • 추상화 타입과 구현은 타입 상속으로 연결
               <<Interface>>
                  Notifier                  <- 기능에 대한 의미 제공
          +notify(noti: Notification)           구현은 제공하지 않음
                    ^                           어떻게 구현할지 알 수 없음
                    |
      -------------------------------
      |             |               |
      |             |               |
EmailNotifier    SMSNotifier    KakaoNotifier <- 콘크리트(concrete) 클래스
  • 위와 같이 Notifier란 타입으로 추상화할 수 있다.
  • 이때 추상화한 타입은 interface로 많이 표현을 한다.
  • 그리고 추상화한 타입과 구현 클래스를 타입 상속으로 연결을 하게됩니다.
  • 추상화한 타입은 공통된 특징을 표현하는데, 이 표현은 곧 추상화한 타입이 어떤 기능을 제공하는지 의미하게 된다.
  • 추상화한 타입 자체는 구현이 어떻게될지 모르니까 구현은 제공하지 않게 된다.

  • 실제 구현은 추상 타입을 상속받는 클래스, 위 예시에서 보면 Notifier 추상 타입을 구현하고 있는 EmailNotifier 혹은 KakaoNotifier
  • 이런 추상 타입을 상속받고 있는 클래스에서 실제 구현을 제공을 하고 있는 것입니다.
  • 그리고 구현을 제공하는 클래스, 이런 클래스를 흔히 콘크리트 클래스라고 많이 표현을 합니다.

추상 타입 사용

  • 이렇게 콘크리트 클래스에서 추상 타입을 도출하면 추상 타입을 이용해서 프로그래밍을 할 수 있게된다.

  • 추상 타입을 이용한 프로그래밍


Notifier notifier = getNotifier(...);
notifier.notify(someNoti);


  • getNotifier 메소드가 어떤 타입의 객체를 반환하는지 상관 없이
  • 이 코드는 notifier라는 추상화 타입을 이용해서 통지 기능을 실행할 수 있다.
  • 실제 통지가 어떻게 이뤄지는지는 모른다. 모르지만 위 코드를 보면 무언가 통지한다는 의도는 잘 드러난다는 걸 우리가 알 수 있다.

  • 추상 타입은 구현을 감춤
    • 기능의 구현이 아닌 의도를 더 잘 드러냄

추상 타입 사용에 따른 이점: 유연함

  • 그러면 이런 추상 타입을 왜 사용하느냐,
  • 그 이유는 바로 변경이 유연해지기 때문

콘크리트 클래스를 직접 사용하면

  • 최초에는 주문 처리를 하면 SMS만 보내달라는 요청이 있었다고 하자
  • 그래서 SMS를 보내주는 SmsSender 클래스를 만들고
  • 주문 취소 기능을 가지고있는 cancel 메소드에서는 smsSender를 이용해서 문자를 보내도록 구현함
  • 그런데 요구 사항이 추가가 되는 것


private SmsSender smsSender;

public void cancel(String ono) {
  // ... 주문 취소 처리

  smsSender.sendSms(...);
}


  • 카카오 푸시가 가능하면 카카오로 푸시를 보내 라는 요구가 추가됨
  • 그래서 아래와 같은 코드들이 추가되기 시작하는 것
  • 카카오 푸시를 보내기 위한 KakaoPush 클래스를 만들고 이 클래스를 사용해서 푸시를 보내는 코드를 추가함
  • 하다보니까 email로도 취소 사실을 보낼 필요성이 생김


private SmsSender smsSender;
private KakaoPush kakaoPush;

public void cancel(String ono) {
  // ... 주문 취소 처리

  if (pushEnabled) {
    kakaoPush.push(...);
  } else {
    smsSender.sendSms(...);
  }
}


  • 그래서 MailService라는 클래스를 만들고 그 클래스를 이용해서 mail을 보내는 코드를 추가함


private SmsSender smsSender;
private KakaoPush kakaoPush;
private MailService mailSvc;

public void cancel(String ono) {
  // ... 주문 취소 처리

  if (pushEnabled) {
    kakaoPush.push(...);
  } else {
    smsSender.sendSms(...);
  }
  mailSvc.sendMail(...)
}


  • 그럼 위 과정에서 무슨 일이 벌어졌는지를 한번 보자.
  • 사실 주문 취소라는 본질과 크게 상관없는 sms 전송이나, 카카오 푸시나 메일 전송 같은 것들 때문에
  • 주문 취소를 위해 구현한 cancel 메소드의 코드가 함께 변하고 있다.
  • 요구 사항 변경에 따라 주문 취소 코드도 함께 변경

  • 위에 보시면 주문 취소 처리라는 코드는 하나도 변한게 없습니다. 주문 취소 처리 로직 자체는 변한게 없는 것이다.
  • 그런데 통지 방식이 바뀌면서 위와 같이 코드가 계속해서 바뀌고 있다.
  • 즉, 주문 취소 코드라는 본질과는 상관 없는 다른 요구 사항 때문에 그 본질을 구현하고 있는 기능에 변화가 생기는 것이다.

추상 타입 사용 이점: 유연함

  • 그럼 같은걸 추상 타입을 활용해 구현해보자.

  • 공통점을 도출하면
SMS 전송
카카오톡 보냄 -- 추상화 --> 통지
이메일 발송
  • 통지라는 interface를 사용

  • 도출한 추상 타입 사용
  • 도출한 통지라는 interface를 사용해서 코드를 작성하도록 cancel을 변경함


public void cancel(String ono) {
  // ... 주문 취소 처리

  Notifier notifier = getNotifier(...); // 추상화한 notifier 타입을 사용
  notifier.notify(...); // 추상화한 타입을 이용해서 통지 기능을 실행하도록 코드를 수정
}

private Notifier getNotifier(...) { // getNotifier 메소드는 상황에 따라서 알맞은 notifier 객체를 생성하도록 일단 구현함
  if (pushEnabled)
    return new KakaoNotifier();
  else
    return new SmsNotifier();
}


추상 타입 사용 이점: 유연함

  • 그리고 추상화를 한번 더 해봤다.

  • 사용할 대상 접근도 추상화
  • 아래 getNotifier 코드를 보면 notifier의 실제 구현을 생성하는 그런 코드이다.
  • 그래서 객체를 생성하는 기능 자체도 NotifierFactory로 추상화를 해본 것이다.


public void cancel(String ono) {
  // ... 주문 취소 처리

  Notifier notifier = getNotifier(...); // 추상화한 notifier 타입을 사용
  notifier.notify(...); // 추상화한 타입을 이용해서 통지 기능을 실행하도록 코드를 수정
}

private Notifier getNotifier(...) { // getNotifier 메소드는 상황에 따라서 알맞은 notifier 객체를 생성하도록 일단 구현함
  if (pushEnabled)
    return new KakaoNotifier();
  else
    return new SmsNotifier();
}


  • 위 코드를 아래와 같이 수정
  • cancel 메소드는 NotifierFactory를 이용해서 notifier를 구하도록 수정함


public void cancel(String ono) {
  // ... 주문 취소 처리

  Notifier notifier = NotifierFactory.instance().getNotifier(...);
  notifier.notify(...);
}

private interface NotifierFactory {
  Notifier getNotifier(...);

  static NotifierFactory instance() {
    return new DefaultNotifierFactory();
  }
}

public class DefaultNotifierFactory implements NotifierFactory {
  public Notifier getNotifier(...) {
    if (pushEnabled) return new KakaoNotifier();
    else return new SmsNotifier();
  }
}


  • 위와 같이 바꾼 뒤에 통지방식을 바꿔야된다는 수정사항이 들어옴
  • 그럴 땐 DefaultNotifierFactory 여기만 수정해주면 된다.
  • DefaultNotifierFactorygetNotifier 여기 어딘가만 바꿔주면 된다.
  • 또는 새로운 notifierFactory 구현 코드를 만들고 그 객체를 리턴하도록 중간에 있는 instance 메소드를 바꿔줘도 된다.

  • 여기서 중요한 점은 통지 방식이 바뀌더라도 cancel 메소드는 바뀌지 않는다는 것이다.
  • 즉, 주문 취소 처리 로직은 바뀌지 않는다는 것이다.
  • 이것이 바로 추상 타입을 사용하는 이유이다.
  • 즉, 주문 취소 처리 로직은 그대로 이용하면서 통지하는 방식을 바꿀 수 있게 해주는 것, 이런 유연함을 제공하는 것이 바로 추상화를 하는 이유이다.

추상화 결과: 사용 대상 변경 유연함

  • 추상화 결과를 한번 보자.

  • 우리가 무언가를 추상화하게 되면, 내가 사용하는 대상을 쉽게 변경할 수 있는 유연함을 얻을 수 있다.
  • 위 화면에서 설명해드리도록 하겠다.

추상화는 의존 대상이 변경하는 시점에

  • 그러면 이런 추상화는 언제 해야될까?
  • 사실 무턱대고 하면 안됩니다.
  • 추상화를 한다는 것은 새로운 추상 타입이 생긴다는 것을 의미합니다.
  • 그리고 타입이 늘어나게되면 그만큼 프로그램의 복잡도는 증가하게 됩니다.
  • 그래서 아직 존재하지 않는 기능에 대해서 너무 빨리 추상화를 하면 안된다.
  • 그렇게되면 추상화를 잘못할 가능성이 높아지고 괜히 복잡도만 증가하게 되는 것이다.

  • 추상화 -> 추상 타입 증가 -> 복잡도 증가
    • 아직 존재하지 않는 기능에 대한 이른 추상화는 주의: 잘못된 추상화 가능성, 복잡도만 증가
    • 실제 변경, 확장이 발생할 때 추상화 시도

  • 그럼 추상화는 언제해야될까?
  • 실제 변경, 확장이 발생할 때 추상화 시도

  • 최초에 주문 취소 서비스를 아래와 같이 구현을 했다.
  • 그런데 요구사항이 바뀌어서 SMS를 보내달라는 요구사항이 추가가됨


public class OrderService {
  private MailSender sender;

  public void order(...) {
    // 주문 취소 로직 수행
    sender.send(message); // 주문 취소 로직 수행 후 메일로 이 사실을 알려주는 식으로 구현함
  }
}


  • 그러면 일단 아래처럼 구현해볼 수 있는데,
  • 이렇게 변경이나 확장이 발생하는 시점에 추상화를 시도해보는 것이다.


public class OrderService {
  private MailSender sender;
  private SmsService smsService;

  public void order(...) {
    // 주문 취소 로직 수행
    sender.send(message);
    // ...
    smsService.send(smsMsg);
  }
}


  • 메일 알림도 보내고, SMS도 보내는데 이를 추상화 시도를 해보면,
  • 사용자에게 무언가를 통지하는 거구나!
  • 무언가를 통지한다는 공통점을 알 수 있겠죠?
  • 그럼 그때 notifier라는 타입으로 추상화를 해보는 것입니다.
  • 이렇게 추상화는 필요한 시점에, 변경이나 확장이 일어나는 시점에서 추상화를 시도하면
  • 프로그램의 복잡도를 증가시키지 않으면서
  • 추상화를 통해서 얻을 수 있는 이점인 유연함을 얻을 수 있다.


public class OrderService {
  private Notifier notifier;

  public void order(...) {
    // 주문 취소 로직 수행
    notifier.notify(noti);
  }
}


추상화를 잘 하려면

  • 구현을 한 이유가 무엇 때문인지 생각해야 함

  • 앞에서 봤던 SMSSender, KakaoPush, MailService이런 것들을 우리가 구현을 했다.
  • 그럼 이 세가지가 어떤 공통점이 있는지 생각을 해볼 수 있는 거죠?
  • 누군가는 "이거는 고객에게 통지하는 거구나."라는 방식으로 추상화를 해볼 수 있는 것이고,
  • 또 누군가는 "이거는 메신저 역할이구나."라고 생각해서 메신저로 추상화를 할 수도 있을 것이다.

  • 어떤식으로 추상화를 하던지간에 실제로 그 구현을 한 이유가 무엇 때문인지를 생각해야 추상화를 잘 할 수 있다.

2.2 추상화 예제

  • 추상화가 실제로 어떤 식으로 도움을 주는지 조금 더 구체적인 예를 이용해서 살펴보겠습니다.

예시

  • 기능 예시
    • 클라우드 파일 통합 관리 기능을 개발하는 가상의 프로젝트가 있다고 가정을 해보자
    • 이 프로젝트의 초기 버전의 기능은 다음과 같다.
      • 대상 클라우드
        • 드롭박스, 박스
      • 주요 기능
        • 각 클라우드의 파일 목록 조회, 다운로드, 업로드, 삭제, 검색
  • 추상화 하지 않은 코드 VS 추상화 한 코드

추상화하지 않은 구현: 파일 목록 조회

  • 먼저 추상화를 하지 않았을 때의 구현입니다.
  • 우선 클라우드 아이디를 구분하기 위한 CloudId라는 열거(enum) 타입을 만들었고, 여기에 DROPBOX와 BOX를 지원하기로 했으니까, 그 두가지를 값으로 가지고 있습니다.


public enum CloudId {
  DROPBOX,
  BOX,
}


  • 그리고 FileInfo라는 클래스를 만들었다.
  • 이거는 특정 파일 정보를 표현하기 위한..
    • 이 파일이 어느 클라우드의 파일인지 (CloudId)
    • 그거를 식별하기 위한 아이디가 뭐고 (fileId)
    • 이름은 뭐고 (name)
    • 파일 길이가 어떻게 되는지 (length)
    • 이런 정보를 담고있는 FileInfo라는 클래스를 만들었다.


public class FileInfo {
  private CloudId cloudId;
  private String fileId;
  private String name;
  private long length;

  // ...
  // get 메서드
}


  • CloudFileManager 라는 클래스도 만들었다.
  • 이 클래스가 클라우드 파일을 관리하는 기능을 제공하는 클래스이다.
  • 이 클래스에서 getFileInfos라는 메소드를 보시면 파일 목록을 조회하는 그런 기능들을 제공을 하는데,
  • 어떤 클라우드의 파일 목록을 조회할지의 여부를 CloudId 타입의 파라미터로 받고 있다.
    • 그래서 CloudIdDROPBOX이면, CloudId.DROPBOX..
    • DropboxClient <- 이런게 있다고 칩시다.
    • DropboxClient를 이용해서 db.getFiles() 파일 목록을 가져오고
    • 그 파일 목록을 FileInfo로 변환하는 코드이다.
    • FileInfo로 변환한 다음에 return하는식으로 DROPBOX를 구현했다.

    • 만약에 cloudIdBOX라면, 이 경우도 비슷하게 BOX와 연동하기 위한 BoxService 클래스가 있다고 쳤을 때,
    • BoxService를 이용해서 알맞은 구현을 한다.


public class CloudFileManager {
  public List<FileInfo> getFileInfos(CloudId cloudId) {
    if (cloudId == CloudId.DROPBOX) {
      DropboxClient dc = ...;
      List<DbFile> dbFiles = db.getFiles();
      List<FileInfo> result = new ArrayList<>();
      for (DbFile dbFile : dbFiles) {
        FileInfo fi = new FileInfo();
        fi.setCloudId(CloudId.DROPBOX);
        fi.setFileId(fi.getFileId());
        ...
        result.add(fi);
      }
      return result;
    } else if (cloudId == Cloud.BOX) {
      BoxService boxSvc = ...;
      ... //
    }
  }
}


추상화하지 않은 구현: 파일 다운로드

  • 추상화하지 않은 파일 다운로드 코드는 앞에서 봤던 목록 조회와 비슷하다.
  • FileInfo file 먼저 파일 정보를 준다. 이 파일을 다운받고 싶다고. 첫번째 파라미터.
  • localTarget 로컬에 어디에다 파일을 다운로드해라. 두번째 파라미터.
  • file.getCloudId() == CloudId.DROPBOX 파일의 클라우드 아이디가 드롭박스이면
  • DropboxClient를 이용해서 파일 정보를 읽어온다. 그리고 로컬로 타겟을 복사하는..
  • 이런식으로 드롭박스는 작동한다.

  • file.getCloudId() == CloudId.BOX 클라우드 아이디가 박스이면
  • 역시 또 박스에서 BoxService 데이터를 읽어와서 로컬 파일로 복사하는 코드이다.


public void download(FileInfo file, File localTarget) {
  if (file.getCloudId() == CloudId.DROPBOX) {
    DropboxClient dc = ...;
    FileOutputStream out = new FileOutputStream(localTarget);
    dc.copy(file.getFileId(), out);
    out.close();
  } else if (file.getCloudId() == CloudId.BOX) {
    BoxService boxSvc = ...;
    InputStream is = boxSvc.getInputStream(file.getId());
    FileOutputStream out = new FileOutputStream(localTarget);
    CopyUtil.copy(is, out);
  }
}


  • 그런데 드롭박스 연동 모듈과 박스 연동 모듈이, 파일을 읽어와서 저장하는 방식이 같으면 좋은데, 아마 다를거라서 위와 같이 드롭박스인 경우와 박스인 경우의 코드가 미세하게 달라지게 될겁니다.

추상화하지 않은 구현: 기타 기능 추가

  • 이와 같이 앞에서 봤던거와 비슷하게 업로드인 경우의 처리
  • 모두 비슷한 if, else 구문이 각 기능에 들어가게 됩니다.


public FileInfo upload(File file, CloudId cid) {
  if (cid == CloudId.DROPBOX) {
    // ...
  } else if (cid == CloudId.BOX) {
    // ...
  }
}




public void delete(String fileId, CloudId cid) {
  if (cid == CloudId.DROPBOX) {
    // ...
  } else if (cid == CloudId.BOX) {
    // ...
  }
}




public List<FileInfo> search(String query, CloudId cid) {
  if (cid == CloudId.DROPBOX) {
    // ...
  } else if (cid == CloudId.BOX) {
    // ...
  }
}


추상화하지 않은 구현: 이어지는 추가

  • 무사히 초기 버전을 출시했다.
  • 그리고 마침 반응이 좋아서 사용자가 모이기 시작했다. 투자도 받았다.
  • 그래서 지원하던 클라우드를 3개 더 늘리기로 결정했습니다.

  • 클라우드 추가
    • S클라우드
    • N클라우드
    • D클라우드
  • 기능 추가
    • 클라우드 간 파일 복사

  • 그러면 어떻게 될까?

  • 아래 코드처럼 download(), upload(), delete(), search()도 유사한 else-if 블록 추가


public List<FileInfo> getFileInfos(CloudId cloudId) {
  if (cloudId == CloudId.DROPBOX) {
    // ...
  } else if (cloudId == CloudId.BOX) {
    // ...
  } else if (cloudId == CloudId.SCLOUD) {
    // ...
  } else if (cloudId == CloudId.NCLOUD) {
    // ...
  } else if (cloudId == CloudId.DCLOUD) {
    // ...
  }
}


추상화하지 않은 구현: 클라우드 간 복사

  • 클라우드간 복사 기능을 추가하기 위해 copy라는 메소드를 추가함
  • FileInfo fileInfo, CloudId to 특정 클라우드에 있는 파일을 to 클라우드로 복사해라
  • CloudId from = fileInfo.getCloudId(); 어떤 클라우드에서 복사해올지를 결정
  • to == CloudId.DROPBOX to가 어디냐에 따라 방식이 약간식 바뀌는 것이다.
  • toDROPBOX라면 드롭박스 클라우드로 복사하는 것
  • from은 어디 클라우드로부터인지 확인하는 것
  • 각각의 from인 경우마다 작성되어있는 코드가 약간씩 다르다.
    • 우연히 같을 수도 있는데, 다를 가능성이 더 높을 것이다.


public FileInfo copy(FileInfo fileInfo, CloudId to) {
  CloudId from = fileInfo.getCloudId();
  if (to == CloudId.DROPBOX) {
    DropBoxClient dbClient = ...;
    if (from == CloudId.BOX) {
      cbClient = copyFromUrl('http://www.box.com/files/' + fileInfo.getFileId());
    } else if (from == CloudId.SCLOUD) {
      ScloudClient sClient = ...;
      InputStream is = sClient.getInputStream(fileInfo.getFileId());
      dbClient.copyFromInputStream(is, fileInfo.getName());
    } else if (from == CloudId.DCLOUD) {
      dbClient.copyFromUrl('http://www.dcloud.com/getfile?fileId='+fileInfo.getFileId());
    } else if (from == CloudId.NCLOUD) {
      NCloudClient nClient = ...;
      File temp = File.createTemp();
      nClient.save(fileInfo.getFileId(), temp);
      InputStream is = new FileInputStream(temp);
      dbClient.copyFromInputStream(is, fileInfo.getName());
    }
  }
}


  • 위의 코드는 toDROPBOX인 경우이다.
    • 전체 클라우드는 5개이죠?
    • 그렇다면 위와 유사한 코드가 4개 더 있어야 된다는 이야기이다.

추상화하지 않은 구현: 클라우드 간 복사 (if-else 구조만 표시)

  • if-else만 표시를 한건데도, 코드가 위와 같이 복잡해집니다.
    • 복사 대상이 드롭박스인 경우
    • 복사 대상이 박스인 경우
    • 복사 대상이 SCLOUD인 경우
    • 복사 대상이 DCLoud인 경우
    • 복사 대상이 NCLoud인 경우
  • 이렇게 각 복사 대상이 다른 경우의 코드 블록이 존재한다.
    • 위의 코드는 중간에 ...으로 생략했는데, 만약에 이 위치에 실제 코드가 있다고 생각해 보십시오.
    • 위 코드는 진짜 복잡할 것이다.
    • 그리고 만약 위 상태에서 새로운 클라우드를 2개를 더 추가해야 된다고 한다면, 어떻게될까?
    • 전체 블록에 else-if가 2개가 더 추가될 것이고
    • 각 블록에 else-if가 추가될 것이다.

  • 이런식의 코드를 작성 한다는게, 아무래도 어디를 수정해야되는지 좀 찾아야돼고 실제로 단번에 수정 위치를 찾아내기 힘들기 때문에 열심히 찾아야돼고, 코드도 굉장히 길어진다.
  • 그래서 실제 수정하는 그 시간 자체도.. 코드가 기니까 뭔가 코드를 분석하는 시간도 길어지고
  • 그래서 수정하는 위치를 찾는데도 시간이 오래걸리게 된다.
  • 결과적으로 코드를 수정하는 비용이 증가하는.. 그런 전형적인 코드 구조이다.

  • 위와 같은 코드는 시간이 흘러갈수록 기능이 추가될 수록 코드 한줄 수정하는 비용이 점점 증가하는 증상이 나타나게 된다.

개발 시간 증가 이유

  • 코드 구조가 길어지고 복잡해짐
    • 새로운 클라우드 추가시 모든 메서드에 새로운 if 블록 추가
      • 중첩 if-else는 복잡도 배로 증가
      • if-else가 많을수록 진척 더딤 (신중 모드)
        • if-else가 많아질 수록 언제 어디서 어떻게 실행될지를 모르기에 신중해져야된다.
  • 관련 코드가 여러 곳에 분산됨
    • 한 클라우드 처리와 관련된 코드가 여러 메서드에 흩어짐
  • 결과적으로, 코드 가독성과 분석 속도 저하
    • 코드 추가에 따른 노동 시간 증가
    • 실수하기 쉽고 이로 인한 불필요한 디버깅 시간 증가

추상화해보면

DROPBOX
BOX
SCLOUD  ------> 클라우드 파일시스템
DCLOUD
LCLOUD

추상화한 타입(클라우드 파일시스템)을 통해 시스템 설계

  • 위와 같이 추상화 타입의 기능을 정의했다.
  • 그런데 CloudFileSystem을 구해주는 CloudFileSystemFactory라는 클래스도 만들었다.
  • 그럼 이렇게 설계한 결과물을 통해 다시한번 구현을 해보겠습니다.

DROPBOX용 클라우드 파일시스템 구현



public class DropBoxFileSystem implements CloudFileSystem {
  private DropBoxClient dbClient = new DropBoxClient(...);

  @Override
  public List<CloudFile> getFiles() {
    List<DbFile> dbFiles = dbClient.getFiles();
    List<CloudFile> results = new ArrayList<>(dbFiles.size());
    for (DbFile file: dbFiles) {
      DropBoxCloudFile cf = new DropBoxCloudFile(file, dbClient);
      result.add(cf);
    }
    return results;
  }
}


  • DropBoxFileSystem은 추상화한 CloudFileSystem을 상속(implements)하고 있다.
  • getFiles 메소드에서는 내부적으로는 DropBoxClient를 이용해서 코드를 구현하는데, 실제 결과물은 CloudFile을 리턴하는 식으로 구현을 한겁니다.
  • CloudFile를 상속한 DropBoxCloudFile을 만들어서 그 목록을 리턴하도록 만들었다.

  • 그러면 DropBoxCloudFile은 어떻게 구현했을까?
  • 추상화한 CloudFile 타입을 implements 상속해서 구현을 했다.
  • 그리고 각각의 기능을 구현했다.


public class DropBoxCloudFile implements CloudFile {
  private DropBoxClient dbClient;
  private DbFile dbFile;

  public DropBoxCloudFile(DbFile dbFile, dbClient) {
    this.dbFile = dbFile;
    this.dbClient = dbClient;
  }

  public String getId() {
    return dbFile.getId();
  }
  public boolean hasUrl() {
    return true;
  }
  public String getUrl() {
    return dbFile.getFileUrl();
  }
  public String getName() {
    return dbFile.getFileName();
  }
  public InputStream getInputStream() {
    return dbClient.createStreamOfFile(dbFile);
  }
  public void write(OutputStream out) {
    // ...
  }
  public void delete() {
    dbCount.deleteFile(dbFile.getId());
  }
  // ...
}


파일 목록, 다운로드 기능 구현

  • CloudFileManager에서 다운로드 기능을 어떻게 구현해볼 수 있냐면,
  • 앞에서 구현한 CloudFileSystemFactory.getFileSystem(cloudId) 기능을 이용해서 CloudFileSystem을 구하고,
  • CloudFileSystem 추상화한 타입으로 코드를 작성하고 있죠?
  • fileSystem을 구하고 이 fileSystemgetFiles를 이용해서 파일 목록을 구하도록 했다.


public List<CloudFile> getFileInfos(CloudId cloudId) {
  CloudFileSystem fileSystem = CloudFileSystemFactory.getFileSystem(cloudId);
  return fileSystem.getFiles(); // 이 getFiles도 추상화한 타입인 CloudFile 목록을 리턴하고 있다.
}

// download도 마찬가지로 추상화한 타입을 사용함 CloudFile
public void download(CloudFile file, File localTarget) {
  // 추상화한 CloudFile이 제공하는 write 기능을 이용해서 특정 파일을 로컬에 복사하도록 코드를 구현함
  file.write(new FileOutputStream(localTarget));
}


BOX 클라우드 지원을 추가하려면

BOX 클라우드 지원 추가

  • 위와 같이 추상화한 CloudFileSystemCloudFile 이 두 타입을 이용해 콘크리트 클래스를 구현하기만 하면 된다.
  • 그러면 BOX 클라우드 지원을 추가할 수 있다.
  • 그리고 CloudFileSystemFactofy에서 CloudFileId에 따라서 알맞게 BoxFileSystem을 리턴하도록 하면 된다.

파일 목록, 다운로드 기능 구현

  • 이렇게 BOX 클라우드 지원을 추가했다.
  • 그리고 다시 파일 목록과 다운로드 기능을 봤더니 바뀔게 없다.

  • BOX 지원 추가 이후
  • BOX 클라우드를 지원하는 기능을 새로 추가했는데도, getFileInfos, download 이 두 기능의 코드가 바뀐데가 없다.


public List<CloudFile> getFileInfos(CloudId cloudId) {
  CloudFileSystem fileSystem = CloudFileSystemFactory.getFileSystem(cloudId);
  return fileSystem.getFiles(); // 이 getFiles도 추상화한 타입인 CloudFile 목록을 리턴하고 있다.
}

// download도 마찬가지로 추상화한 타입을 사용함 CloudFile
public void download(CloudFile file, File localTarget) {
  // 추상화한 CloudFile이 제공하는 write 기능을 이용해서 특정 파일을 로컬에 복사하도록 코드를 구현함
  file.write(new FileOutputStream(localTarget));
}


  • 이렇게 바뀌지 않는 이유는 뭐 때문이었죠?
  • 바로 추상화 타입을 사용했기 때문이다.

파일 복사 기능

  • 파일 복사 기능도 단순하다.


// CloudFile을 받아서 그것을 특정한 target 클라우드로 복사해주는데,
public void copy(CloudFile file, CloudId target) {
  // 우선 target에 해당하는 CloudFileSystem을 구하고
  CloudFileSystem fileSystem = CloudFileSystemFactory.getFileSystem(target);
  // 그 fileSystem의 copyFrom에다가 복사할 file을 인자로 전달했다.
  fileSystem.copyFrom(file);
}


  • 각 클라우드의 copyFrom 메소드에 대해 보자.
  • 각각 자신의 기능을 사용해 복사하도록, 알맞게 기능을 구현하고 있다.


-- DropBoxFileSystem
private DropBoxClient dbClient = new DropBoxClient(...);
public void copyFrom(CloudFile file) {
  if (file.hasUrl())
    dbClient.copyFromUrl(file.getUrl());
  else
    dbClient.copyFromInputStream(file.getInputStream(), file.getName());
}




-- NcloudFileSystem
private NcloudClient nClient = new NCloudClient(...);
public void copyFrom(CloudFile file) {
  File tempFile = File.createTemp();
  file.write(new FileOutputStream(tempFile));
  nClient.upload(tempFile, file.getName());
}


추상화 결과

  • 그럼 이렇게 추상화를 했더니 우리가 어떤 결과를 얻을 수 있었냐면,
  • 추상화한 타입으로만 핵심 기능을 구현을 했다.


public class CloudFileManager {
  public List<CloudFile> getFileInfos(CloudId cloudId) {
    CloudFileSystem fileSystem = CloudFileSystemFactory.getFileSystem(cloudId);
    return fileSystem.getFiles();
  }

  public void download(CloudFile file, File localTarget) {
    file.write(new FileOutputStream(localTarget));
  }

  public void copy(CloudFile file, CloudId target) {
    CloudFileSystem fileSystem = CloudFileSystemFactory.getFileSystem(target);
    fileSystem.copyFrom(file);
  }
  
  // ...
}


  • 위에 보시면 CloudFile, CloudFileSystem 이 타입들은 모두 추상화한 타입이죠?
  • 그런 추상화한 타입으로만 핵심 기능을 구현을 했구요.
  • 이런 구현의 결과로 무얼 얻을 수 있냐면,

추상화 결과

  • 추상화 결과로 CloudFileManager를 수정하지 않고 새로운 클라우드 지원을 추가할 수 있게 되었습니다.

  • 즉, 나를 바꾸지 않고.. 여기서의 ‘나'는 CloudFileManager이다.
  • 나를 바꾸지 않고 내가 사용하는 대상, 여기서는 CloudFileSystem이다.
  • 내가 사용하는 대상을 바꿀 수 있는 유연함을 얻게 된 것이다.
  • 게다가 이 코드는 특정 클라우드와 관련된 코드가 한 곳에 몰리는 이점도 있다.
  • 예를 들어, 추상화 하기 전에는 dropbox와 관련된 목록 조회, 업로드, 다운로드, 검색과 같은 코드가 여러 메소드에, 여러 if-else 블록으로 흩어져 있었다.
  • 그런데 추상화한 결과 구조에서는 dropbox 모듈에 다 모여있다.
  • 그래서 dropbox 모듈만 수정해야 할 때, 복잡한 if-else 블록을 뒤질 필요가 없이 빠르게 변경할 수 있게 됩니다.

이것이 바로 OCP (Open-Closed Principle)

  • 이것이 바로 OCP이다.
  • OCP는 개방 폐쇄 원칙을 말한다.
  • 이 원칙은 확장에선 열려 있어야하고, 변경에는 닫혀 있어야한다는 원칙이다.
  • 이 말을 조금 더 구체적으로 풀어보면 기능을 변경하거나 확장할 수 있으면서(즉, 기능 변경 확장에는 열려있으면서)
  • 그 기능을 사용하는 코드는 수정하지 않아야 한다. (즉, 수정엔 닫혀있어야 한다는 원칙이다.)

  • 앞에서 추상화를 사용해서 설계한 클라우드 파일 관리 기능이 바로 이 원칙을 잘 지키고 있다.
  • CloudFileManager를 수정하지 않으면서, 즉, 수정에는 닫혀 있으면서,
  • 새로운 CloudFileSystem을 추가할 수 있는, 즉, 확장에는 열려있는 구조를 갖고 있다.
  • 결국 추상화를 잘 하면 이렇게 OCP를 따르는 구조를 가질 확률이 높아진다.
  • 그리고 이는 변경이나 확장하는 비용을 낮춰주게 됩니다.
  • 이런 이유로 추상화를 하는 것이다.