빌더 패턴

 

의도

복잡한 객체를 생성하는 방법과 표현하는 방법을 정의하는 클래스를 별도로 분리한다.

⇒ 객체 생성과 조립 알고리즘의 분리

 

활용성

  1. 복합 객체의 생성 알고리즘이 이를 합성하는 요소 개체들의 조립 방법에 독립적일 때
  2. 합성할 객체들의 표현이 서로 달라도 생성 절차에서 이를 지원해야 할 때

 

구조

* Builder : 객체의 일부 요소들을 생성하기 위한 추상 인터페이스를 정의

* ConcreteBuilder : 빌더 인터페이스를 구현하며 제품의 부품들을 모아서 요소들을 모아서 Product를 생성한다.

* Director : 빌더 인터페이스를 사용하는 객체

* Product : 생성할 객체

클라이언트는 Director 객체를 생성하고 생성된 Director는 원하는 빌더 객체를 통해서 Product에 필요한 요소를 추가한다. 최종적으로 클라이언트에서는 빌더 객체에서 Product를 가져온다.

 

장점과 단점

  1. (+) Product에 대한 내부 표현을 다양하게 변화할 수 있다. 빌더 인터페이스를 사용함으로써 실제 사용되는 요소가 무엇인지를 외부에는 숨길 수 있다. 또한 Product의 요소 복합 방법이 바뀐다면 해당 인터페이스를 구현하는 서브클래스를 새로 정의하면 된다
  2. (+) 생성과 표현에 필요한 코드를 분리해서 관리할 수 있다. 실제 객채를 생성하는 부분과 필요한 요소를 조립하는 부분을 분리하기 때문에 Product의 내부 요소를 정의한 클래스를 따로 알 필요 없이 빌더를 통해서 필요한Product를 가져올 수 있다
  3. (+) 불완전한 객체를 생성하지 못하게 끔 validate 할 수 있다
  4. (-) 원하는 객체 생성을 위해서 Director, ConcreteBuilder 객체를 미리 생성해야 한다

 

코드 예시

Director가 요청하는 각각의 요소를 생성하는 연산은 빌더 인터페이스에 구현한다. 그리고 이를 구현하는 서브클래스 concreteBuilder에서 실제로 필요한 요소의 생성 로직을 구현한다.

 

* 빌더 인터페이스

public interface TourPlanBuilder {

    // TourPlanBuilder를 리턴함으로써 메서드 체이닝이 가능하도록 함
  TourPlanBuilder title(String title);
  TourPlanBuilder nightsAndDays(int nights, int days);
    TourPlanBuilder whereToStay(String whereToStay);

    // 최종적으로 생성할 인스턴스의 상태 검증도 가능
  TourPlan getPlan();
}

 

* 빌더 구현체

public class DefaultTourPlanBuilder implements TourPlanBuilder{
  private String whereToStay;
  private String title;
  private Integer nights;
  private Integer days;

  @Override
  public TourPlanBuilder title(String title) {
      this.title = title;
      return this;
  }

  @Override
  public TourPlanBuilder nightsAndDays(int nights, int days) {
      this.nights = nights;
      this.days = days;
      return this;
  }

  @Override
  public TourPlanBuilder whereToStay(String whereToStay) {
      this.whereToStay = whereToStay;
      return this;
  }

  @Override
  public TourPlan getPlan() {
      // 필요하다면 여기서 validate
      return new TourPlan(whereToStay, title, nights, days);
  }
}

 

* Director

/* 자주 사용하는 빌더를 director클래스에 모아두고 사용할 수 있음 */
public class TourDirector {

  private TourPlanBuilder builder;

  public TourDirector(TourPlanBuilder builder){
      this.builder = builder;
  }

  public TourPlan defaultTrip(){
      return builder.title("title")
              .whereToStay("seoul")
              .nightsAndDays(2,3)
              .getPlan();
  }
}

 

* 실제 사용

public static void main(String[] args) {

    // director를 통해서 객체를 받아옴
  TourDirector director = new TourDirector(new DefaultTourPlanBuilder());

  System.out.println(director.defaultTrip());
}

 

실제 사용되는 빌더 패턴의 예시

GoF에 비해서 이펙티브 자바에서 말하는 빌더패턴은 좀 더 실제 사용성에 무게를 둔다. 코드의 가독성, 유지보수가 편리해지므로 빌더패턴 사용을 권한다.

아래는 자주 사용되는 빌더패턴 구현코드이다.

참고 : https://johngrib.github.io/wiki/builder-pattern/

// Effective Java의 Builder Pattern
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        // Required parameters(필수 인자)
        private final int servingSize;
        private final int servings;

        // Optional parameters - initialized to default values(선택적 인자는 기본값으로 초기화)
        private int calories      = 0;
        private int fat           = 0;
        private int carbohydrate  = 0;
        private int sodium        = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings    = servings;
        }

        public Builder calories(int val) {
            calories = val;
            return this;    // 이렇게 하면 . 으로 체인을 이어갈 수 있다.
        }
        public Builder fat(int val) {
            fat = val;
            return this;
        }
        public Builder carbohydrate(int val) {
            carbohydrate = val;
            return this;
        }
        public Builder sodium(int val) {
            sodium = val;
            return this;
        }
        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }
}

팩토리 메서드 패턴과의 차이점

  팩토리 메서드 패턴에서는 조건에 따른 적당한 객체의 생성을 팩토리 클래스로 위임하여 팩토리 메서드를 통해서 객체를 생성한다.

  추상 팩토리 패턴은 서로 관련이 있는 객체를 다 묶어서 팩토리 클래스로 만들고 이 팩토리 클래스를 조건에 따라서 생성하도록 다시 팩토리로 만들어서 객체를 생성한다.


즉, 추상 팩토리 패턴은 팩토리 메서드 패턴을 한 단계 더 캡슐화한 버전이라고도 볼 수 있겠다.


  ex) 컴퓨터 생성 시, 부속품(키보드, 마우스)의 생성을 S사, L사 중에서 어떤걸로 할지 정한다고 했을떄, 가장 상위 Creator인 컴퓨터 Product 생성 시점에 구체 클래스(팩토리) 분기처리를 하고, 각 팩토리에서는 동일한 성격을 가지는 부속품 Product를 생성하도록 한다.

  → 팩토리 클래스 내부에 각 부속품에 대한 팩토리 클래스가 있는 구조

팩토리 메서드 패턴은 팩토리(creator)를 구현하는 방법 (inheritance)에 초점을 두고 구체적인 객체 생성 과정을 하위(구체클래스) 클래스로 옮기는 것이 목적이고

추상 팩토리 패턴은 팩토리를 사용하는 방법 (composition)에 초점을 두고 관련있는 여러 객체를 구체 클래스에 의존하지 않고 만들 수 있게 해주는 것이 목적이다

- 인프런 강의 정리

 

의도

  상세화된 서브클래스를 정의하지 않고도 서로 관련성이 있거나 독립적인 여러 객체 군을 생성하기 위한 인터페이스를 제공한다.

 

활용성

추상 팩토리는 하기 경우에 사용한다.

  • 객체의 생성, 구성, 표현 방식과 무관하게 시스템을 독립적으로 만들고자 할 때
  • 여러 제품군 중 하나를 선택해서 시스템을 설정해야하고 이를 다른 것으로 대체할 수 있을 때
  • 관련된 제품이 함께 사용되도록 설계되었고, 이 부분에 대한 제약이 외부에도 지켜지도록 할 때
  • 제품에 대한 클래스 라이브러리를 제공하고 구현이 아닌 인터페이스를 외부에 노출하고자 할 때

 

구조

 

협력 방법

  일반적으로 하나의 ConcreteFactory 클래스의 인스턴스가 런타임 시 생성된다. 이 인스턴스는 특정 구현을 갖는 제품 객체를 생성한다. 다른 제품 객체를 생성하려면 다른 ConcreteFactory 의 인스턴스를 받아서 사용해야 하고

AbstractFactory는 필요한 Product 객체를 생성하는 책임을 ConcreteFactory 서브 클래스에 위임한다.

 

장단점

  1. (+) 구체적인 클래스를 분리
    Product 객체를 생성하는 과정, 책임을 캡슐화해서 구체적인 구현 클래스가 클라이언트에게서 분리된다.

  2. (+) 제품군을 쉽게 대체
    Creator의 구체 클래스의 인스턴스를 받아오면 전체 제품군에 대한 생성이 가능하기 때문에 일괄적으로 제품군을 받아올 수 있다.

  3. (+) Product 사이에 일관성을 증진
    하나의 군 안에 속한 제품 객체들이 함께 동작하도록 설계되어있을때, 이들 사이의 일관성이 중요하다. 추상 팩토리 패턴을 통해서 제품군에 대한 생성 등을 하나의 팩토리 클래스로 관리하기 때문에 이를 보장한다.

  4. (-) 새로운 종류의 Product를 추가하기 어려움
    생성되는 Product는 기존 추상 팩토리가 생성할 수 있는 제품 집합에만 고정되어있다. 새로운 Product가 추가된다면 해당 추상 팩토리의 모든 서브클래스를 이에 대응해서 변경해야 한다.

 

구현

  1. 팩토리를 단일체로 정의한다.
    하나의 제품군에 대해서 하나의 ConcreteFactory 클래스의 인스턴스만 있기 때문에 인스턴스를 중복해서 생성할 필요가 없다.

  2. 제품의 생성
    AbstractFactory는 제품을 생성하기 위한 인터페이스를 선언하고 실제 그것을 생성하는 책임은 Product의 서브클래스인 ConcreteProduct에 있다. AbstractFactory를 구현한 서브클래스에서는 필요한 제품군의 생성을 관리한다.

  3. 확장 가능한 팩토리들을 정의
    AbstractFactory에는 생성할 각 제품의 종류별로 서로 다른 생성 연산을 정의한다. (하나의 제품군에 포함되어 있는 여러 제품의 생성을 담당)
    이때 생성되는 제품의 종류는 메서드의 시그니처를 통해서 알 수 있다.

싱글톤

오직 한 개의 클래스 인스턴스만을 갖도록 보장하고, 이에 대한 전역적인 접근점을 제공한다. 어떤 클래스는 정확히 하나의 인스턴스만을 갖도록 하는 것이 좋다.

ex) 시스템에 많은 프린터가 있어도 프린터 스풀은 하나여야 한다.

ex) 파일 시스템도 하나로 운영되어야 한다.

해당 클래스에 대한 하나의 인스턴스만 만들고 전역변수를 통해서 이에 접근시킬 수 있지만 더 좋은 방법은 클래스 자신이 자기의 유일한 인스턴스로 접근하는 방법을 자체적으로 관리하는 것이다. 다른 인스턴스의 생성을 방지하고 클래스 자신이 단일 인스턴스에 접근 방법을 제공하는 패턴을 싱글톤 패턴이라고 한다.

 

활용성

다음과 같은 상황에서 사용할 수 있다.

  • 클래스의 인스턴스가 오직 하나임이 보장되어야 하고, 정의된 접근점을 통해서 모든 사용자가 접근해야 할 때
  • 유일한 인스턴스가 서브클래싱으로 확장되어야 하마, 사용자는 코드의 수정없이 확장된 서브클래스의 인스턴스를 사용할 수 있어야 할 때

 

사용 용례

  • 자바 Runtime 객체
  • Spring프레임워크 ApplicationContext에 등록된 bean을 getBean() 을 통해서 꺼내올때 항상 같은 인스턴스 반환
    → 싱글톤 scope이고 싱글톤패턴과는 좀 다름

 

구조

  • Singleton : Instance() 연산을 통해서 유일한 인스턴스에 접근할 수 있도록 한다.

 

결과

싱글톤 패턴을 통해서 얻는 장점은 다음과 같다.

  • 애플리케이션 시작 시, 최초 한번만 메모리를 할당하기 때문에 메모리 낭비를 줄인다.
  • 싱글톤 클래스 자체가 인스턴스를 캡슐화하기 때문에 인스턴스 접근에 대한 제어가 가능하다.
  • 싱글톤 클래스는 상속이 가능하기 때문에 상속된 서브클래스를 통해서 새로운 인스턴스를 생성할 수 있도록 한다. (연산이나 표현이 변경되는 것에 유연함)
  • 인스턴스의 개수를 변경하는 것에 자유롭다. (클래스 연산(static)을 사용하는 것보다 설계 변경이 용이)
  • 싱글쓰레드 환경에서는 별 문제가 되지 않을 수 있으나 멀티쓰레드 환경에서는 Instance() 메서드가 동시에 다른 쓰레드에서 호출됨으로써 싱글톤 인스턴스의 단일성이 보장되지 않을 수 있다.

 

구현

싱글톤 패턴을 구현할 때는 인스턴스가 유일해야 함을 보장해야한다.

 

 

소스예제

 

1. 간단한 버전

  • 싱글톤 패턴 구현을 위해서는 new 를 사용해서 인스턴스를 만들게 허용하지 않아야하므로 생성자의 접근제어자를 private으로 처리
  • static으로 public getter를 만들고 이를 전달해주는 로직을 해당 클래스에서 관리 (하나의 인스턴스로 관리)
private static Settings instance;

private Settings(){}

public static Settings getInstance(){
    if(instance == null){
        instance = new Settings();
    }

    return instance;
}

이 방법은 쓰레드 세이프 하지 않음

 

2. 멀티쓰레드 환경에서 안전하게 구현

  1. 위의 getInstance() 메서드에 synchronized 키워드 사용
    → 동기화처리 때문에 성능 이슈 발생 가능성 있음 (lock 발생)
    → 메서드 레벨에 synchronized를 붙이지 않고 double checked locking 사용함으로써 이미 instance가 null이 아닌 경우는 sync 처리를 하지 않아도 되도록 구현
private static **volatile** Setting instance;

if(instance == null){
    synchronized (Settings.class){
        if(instance == null){
            instance = new Settings();
        }
    }
}
  1. instance 객체를 선언하는 시점에 이른 초기화(eager initialization)를 통해서 미리 만들어 두기
    → 인스턴스 생성이 길고 메모리 사용을 많이 한다면 비효율적일 수 있음
private static final Settings INSTANCE = new Settings();
  1. static inner 클래스 사용
    → getInstance()가 호출되는 시점에 inner 클래스가 로딩되고 그때 인스턴스 생성을 하기 때문에 lazy loading이 가능
// 3. static inner 클래스 사용
private static class SettingsHolder{
    private static final Settings INSTANCE = new Settings();
}
public static Settings getInstanceStaticInner(){
    return SettingsHolder.INSTANCE;
}

 

3. 안전하고 단순하게 구현하는 방법

  • Enum 사용
    → 리플렉션, 직렬/역직렬화에도 안전
    → 단점은 lazy loading없이 미리 만들어져 있음
public enum Settings{
    INSTANCE;
}

Settings instance = Setting.INSTANCE;

 

싱글톤 패턴 구현을 깨뜨리는 사용 방법

  1. 리플렉션을 사용해서 싱글톤 인스턴스를 만드는 경우
  2. 직렬화 & 역직렬화 해서 인스턴스를 사용하는 경우
    → readResolve() 메서드 안에서 싱글톤 인스턴스를 가져오게끔 수정하면 의도대로 사용 가능

 

질문거리

  • 자바에서 enum을 사용하지 않고 싱글톤 패턴을 구현하는 방법은?
    • synchronized를 사용한 double checked locking 방식으로 구현
    • 객체 선언 시점에 미리 초기화해두는 eager init 방식 사용
    • static inner class(holder) 를 사용하는 방식
  • private 생성자와 static 메서드를 사용하는 방법의 단점은?
    • thread-safe 하지 않음
  • enum을 사용해 싱글톤 패턴을 구현하는 방법의 장점과 단점은?
    • 코드가 간결하고 리플렉션, 직렬화/역직렬화 등에도 대응이 되지만 lazy loading하는 방식이 아니라 미리 객체 사용유무에 상관없이 선언되어 있어야함
    • 상속을 사용하지 못한다

 

참고자료

  • GoF의 디자인 패턴 (개정판)
  • 인프런 강의 - 코딩으로 학습하는 GoF의 디자인 패턴

기존 코드를 클라이언트가 사용하는 인터페이스의 구현체로 바꿔주는 패턴

사용하는 이유

레거시 인터페이스를 신규 인터페이스로 교체하는 경우, 레거시에서 사용하고 있던 소스 변경 없이 그대로 사용하기 위해서는 두 가지 방법이 있다.

  • 신규 인터페이스에서 레거시 인터페이스의 구현을 모두 상속받는다. (클래스 버전)
  • 신규 인터페이스의 구현체에 레거시 구현체를 포함시키고 레거시 인터페이스를 사용해서 신규 인터페이스 구현체를 구현한다. (객체 버전)Adaptor에서는 기존 레거시에서 제공되지 못했던 추가적인 기능을 제공할 수 있다.
 레거시의 기능을 그대로 사용하는 더 확장된 신규 인터페이스 제공 가능
  • 활용성
    • 기존 클래스를 사용하고 싶은데 인터페이스가 맞지 않을 경우
    • 이미 만든 것을 재사용하고자 하나 이 재사용 가능한 라이브러리를 수정할 수 없는 경우
    • 이미 존재하는 여러 서브클래스를 사용할 때, 이 서브클래스의 상속을 통해서 이들의 인터페이스를 모두 수정하는 것이 어렵기 때문에 Adaptor를 사용해서 부모 클래스의 인터페이스를 수정하는 것이 더 효율적인 경우

 

구조

어댑터 패턴 구조

Adaptor(신규)는 Adaptee(레거시) 인스턴스를 포함함으로써 자신의 Request() 메서드 호출 시, Adaptee의 SpecificRequest() 를 호출할 수 있는 구조를 가진다.

장단점

  • 장점
    • 코드의 재사용성 증가
    • 서로 관계가 없는 여러 Adaptee 클래스를 하나의 Adaptor 클래스로 사용 가능
    • 기존의 코드 변경 없어도 됌 (객체지향원칙 OCP에 가까운 패턴)
  • 단점
    • 클래스 버전의 경우, Adaptee 클래스의 서브클래스가 있는 경우, Adaptor 에서 해당 서브클래스에 정의된 기능을 사용할 수 없음 (해당 케이스는 어댑터 패턴 적용 X)
    • 클래스 버전의 경우, 다중상속이 지원되지 않는 java 같은 언어에서는 adaptor당 하나의 adaptee 클래스만 상속받을 수 있음
    • 객체 버전의 경우, Adaptee 객체를 만들어야해서 추가적으로 관리해야하는 객체가 늘어난다

 

예제 코드

객체 버전의 어댑터 패턴 예제로 작성하였다.

class Adaptee{
    public void legacyMethod(){
        System.out.println("Hello World!");
    }
}

 

class Adaptor{
    Adaptee adaptee;

    Adaptor(Adaptee adaptee){
        this.adaptee = adaptee;
    }

    public void newMethod(){
                // 기존 레거시 메서드에 추가 기능 정의 
        System.out.println("new method");
        this.adaptee.legacyMethod();
    }

}

 

public class AdaptorMain {
    public static void main(String[] args){

        Adaptee adaptee = new Adaptee();
        Adaptor adaptor = new Adaptor(adaptee);

        adaptor.newMethod();
    }
}
/*
new method
Hellow World!
*/

 

참고 자료

전략 패턴

동일 계열의 알고리즘군을 정의, 캡슐화하고 이를 사용하는 클라이언트와 상관없이 독립적으로 알고리즘을 서로 교환해서 사용할 수 있다.

사용하는 이유

만약 텍스트 스트림을 라인으로 구분하는 기능(다수 알고리즘이 있음)이 필요하다고 했을 때, 이 알고리즘들을 클래스에 하드코딩하는 것은 하기 이유로 바람직하지 않다.

  • 기능에 대한 구현이 아닌 줄 분리 코드가 반복적으로 생김으로써 소스가 복잡해짐
  • 때에 따라서 필요한 알고리즘이 다르기 때문에 모든 알고리즘을 제공할 필요가 없음
  • 서비스로직과 줄 분리 알고리즘이 합쳐져 있으면 알고리즘 로직을 추상화시켜서 확장 시키기 어려움
  • 위 문제 해결을 위해서 서비스로직과 각각의 줄 분리 알고리즘을 하나씩 캡슐화 한 클래스를 따로 정의하고 이 캡슐화된 알고리즘을 전략이라고 지칭한다.

구조

전략패턴 구조

비슷한 계열의 알고리즘을 가지는 Strategy 인터페이스를 구현하는 N개의 전략 객체가 있고 Context(서비스로직)에서는 concrete한 전략 객체(알고리즘) 인스턴스를 주입받아서 직접 알고리즘 로직을 호출한다.

장단점

  • 장점
    • 코드 중복 방지
    • 런타임 시에 타겟 메서드 변경 가능
    • 동일 계열 알고리즘군을 정의했기 때문에 확장성, 재사용성이 높음
  • 단점
    • 여러 전략을 사용하는 케이스가 다 다르므로 이를 이해하고 있어야함
    • 전략의 복잡도와는 상관없이 Strategy객체는 Context객체에서 전달하는 미사용 매개변수를 받아야함

예제 코드

// Strategy 인터페이스
interface BillingStrategy{
    double GetActPrice(double rawPrice);
}

// 전략1 : Normal billing strategy (unchanged price)
class NormalStrategy implements BillingStrategy{
    public double GetActPrice(double rawPrice){
        return rawPrice;
    }
}

// 전략2 : Strategy for Happy hour (50% discount)
class HappyHourStrategy implements BillingStrategy{
    public double GetActPrice(double rawPrice){
        return rawPrice * 0.5;
    }
}

// Context
class Customer{
    private List<Double> drinks;
    public BillingStrategy strategy;

    public Customer(BillingStrategy strategy){
        this.drinks = new ArrayList<>();
        this.strategy = strategy;
    }

    // 전략 패턴 사용
    public void Add(double price, int quantity){
        drinks.add(strategy.GetActPrice(price * quantity));
    }

    // Payment of bill
    public void PrintBill(){
        double sum = 0;
        for(Double i : drinks){
            sum += i;
        }
        System.out.println(("Total due: " + sum));
        drinks.clear();
    }
}interface BillingStrategy{
    double GetActPrice(double rawPrice);
}

// 전략1 : Normal billing strategy (unchanged price)
class NormalStrategy implements BillingStrategy{
    public double GetActPrice(double rawPrice){
        return rawPrice;
    }
}

// 전략2 : Strategy for Happy hour (50% discount)
class HappyHourStrategy implements BillingStrategy{
    public double GetActPrice(double rawPrice){
        return rawPrice * 0.5;
    }
}

public class StrategyMain {

    public static void main(String[] args){
        Customer firstCustomer = new Customer(new NormalStrategy());

        // Normal billing
        firstCustomer.Add(1.0, 1);

        // Start Happy Hour
        firstCustomer.strategy = new HappyHourStrategy();
        firstCustomer.Add(1.0, 2);

        // New Customer
        Customer secondCustomer = new Customer(new HappyHourStrategy());
        secondCustomer.Add(0.8, 1);
        // The Customer pays
        firstCustomer.PrintBill();

        // End Happy Hour
        secondCustomer.strategy = new NormalStrategy();
        secondCustomer.Add(1.3, 2);
        secondCustomer.Add(2.5, 1);
        secondCustomer.PrintBill();
    }
}

/*
firstCustomer  -> Total due : 2.0
secondCustomer -> Total due : 5.5
*/

참고 자료

Chapter 11. null 대신 Optional 클래스

11.0.

기본적으로 null체크는 자바 프로그래밍을 하면서 꼭 필요한 체크이다. 테스트 시에 항상 있던 값이 실제 서비스 운영 중에는 의도치 않게 않았던 null로 들어오게 되는 경우가 있다. 이런 경우는 컴파일 에러로 잡지 못하기 때문에 이를 대비해서 해당 값에 default값을 설정하거나 null 체크는 필수적이다.

하지만 이러한 null 체크는 누락되기 쉽고 코드의 가독성을 해치는 요인이 된다. 이 장에서는 이를 대체할 수 있는 Optional 클래스에 대해서 이야기한다.


11.1. 값이 없는 상황 처리

public class Person{
    private Car car;
    public Car getCar() { return car; }
}

public class Car{
    private Insurance insurance;
    public Insurance getInsurance() { return insurance; }
}

public class Insurance{
    private String name;
  public String getName(){ return name; }
}

person.getCar().getInsurance().getName();

위와 같은 상황에서 NPE를 경험할 가능성이 높다.

Person객체 자체가 null일수도 있고 Person객체가 Car 객체를 가지고 있지 않을 수 있다. 최종적으로 getName()에서 참조하는 Insuranced의 name필드가 null 일수도 있다. 이를 보통 어떻게 처리하는가?


11.1.2 보수적인 자세로 NPE 줄이기

  • 보통은 null 체크 코드를 통해서 이를 확인한다. 하지만 다음과 같이 체크 했을 때 코드의 복잡도나 들여쓰기 수준이 증가한다.
if(person != null){
    Car car = person.getCar();
    if(car != null){
        .....중략.....
    }
}

다음과 같이 getter메서드에서 이를 분기처리하는 것도 결국 하나의 메서드에 네 개의 출구가 생겼기 때문에 이를 유지보수하기 어려워진다.

public String getCarInsuranceName(Person person){
    if(person == null){
      //처리
    }
    Car car = person.getCar();
    if(car == null){
        //처리
    }
    .....중략.....
}



11.1.2 - 3 null 때문에 발생하는 문제 및 다른 언어에서의 대처법

  1. 다수 에러의 근원
  2. 코드 가독성을 해친다.
  3. 아무 의미가 없는 값이다. 그래서 모든 참조 형식에 null을 할당할 수 있는데, 이때 이 null이 할당된 의미는 코드를 작성한 사람 이외에는 알 수 없다.
  4. 자바 철학에 위배된다. 자바는 개발자에 포인터를 숨겼지만 null 포인터는 예외이다.

그루비 언어 등에서는 safe navigation operator (?.) 를 도입해서 null문제를 해결했다.

def carInsuranceName = person?.car?.insurance?.name 



11.2. Optional 클래스

자바 8은 하스켈, 스칼라와 같은 함수형 프로그래밍 언어의 영향을 받아서 java.util.Optional라는 새로운 클래스를 제공한다. 이는 선택형 값을 캡슐화하는 클래스이다. 위의 Person, Car예제처럼 모든 Person의 인스턴스가 차를 가지고 있지 않을 수 있다. 그런 경우 Car 인스턴스에 null을 할당하는 것이 아니라, Optional클래스로 이 값을 감싸면 된다.

Optional<Car> car에서 car값이 있다면 이를 Optional클래스로 감싸고, 값이 없다면 Optional.empty 메서드를 통해서 Optional을 반환한다.

Optional을 사용함으로써 NPE를 줄일 수 있고 추가적으로 도메인 모델에 대한 의미를 좀 더 명확하게 만들 수 있다. (클래스 내의 변수가 null일 수도 있는 케이스, 절대 null이면 안되는 케이스를 Optional클래스 사용 여부에 따라서 구분할 수 있다.)

11.3. Optional 적용 패턴

11.3.1. Optional 객체 만들기

필요에 따라서 빈 Optional 객체, 혹은 값을 가지고 있는 Optional 객체를 만들 수 있다

Optional<Car> optCar = Optional.empty(); // 팩토리 메서드 
Optional<Car> optCar = Optional.of(car); // null이 아닌 값
Optional<Car> optCar = Optional.ofNullable(car); // null값을 저장할 수 있는 Optional 생성



11.3.2. Optional 값 가져오기

Optional의 get()메서드를 통해서 값을 가져올 때, Optional이 비어있으면 익셉션이 발생한다. 이는 결국 Optional을 잘못 사용한다면 결국 null 사용과 동일한 문제를 겪을 수 있다.

Optional 클래스는 위와 같은 문제를 겪지 않게 내부적으로 map 메서드를 사용하도록 한다. Optional이 값을 가지고 있으면 map의 인수로 제공되는 함수에 의해서 값을 반환하고 Optional이 비어있으면 아무 일도 일어나지 않는 형태가 된다. (Stream 연산과 비슷함)

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);

실제 위에서 봤었던 예제에서는 person.getCar().getInsurance().getName(); 이렇게 참조를 중첩적으로 하는 구조를 가진다. Map()의 경우 Optional(결과값) 타입을 반환하기 때문에 Optional 중첩을 없애기 위해서는 flatMap() 메서드를 이용해서 Optional의 중첩이 일어나지 않도록 한다.

결과적으로 Person클래스의 Car 멤버변수를 Optional로 감싸고 Car클래스 Insurance 멤버변수를 Optional 로 감싼 상태에서는 아래와 같이 보험명을 가져올 수 있다.

public String getCarInsuranceName(Optional<Person> person){
    return person.flatMap(Person::getCar)
            .flatMap(Car::getInsurance)
            .map(Insurance::getName)
            .orElse("Unknown"); // 결과 Optional 이 비었으면 기본값 사용
}



11.3.4. Optional 스트림 조작

자바9 에서는 Optional 을 포함하는 스트림 처리를 지원한다. 위의 하나 Person 객체의 보험사 이름조회를 Person객체 리스트에 대해 가입되어있는 보험사 Set으로 조회할 수 있다.

Optional 클래스의 stream() 메서드는 각 Optional의 값이 비어있는지 여부에 따라서 내부 값을 0~1개 포함하는 스트림을 반환하게 되고 이를 flatMap으로 하나의 스트림으로 합쳐서 Optional을 언랩 할 수 있다.

public Set<String> getCarInsuranceNameSet(List<Person> personList){
        return personList.stream()
            .map(Person::getCar)
            .map(optCar -> optCar.flatMap(Car::getInsurance))
            .map(optIns -> optIns.map(Insurance::getName))
            .flatMap(Optional::stream) // 이전단계까지의 결과물인 Stream<Optional<String>>에서 빈 Optional은 제외하고 실제 값이 있는 Optional만 가져오기 위해서 Optional::stream 메서드 활용
            .collect(toSet());
}

// 위에서 Optional의 stream() 메서드 사용부분은 아래와 같이 변환 가능
// Stream<Optional<String>> 
Set<String> result = stream.filter(Optional::isPresent)
                                                    .map(Optional::get)
                                                    .collect(toSet());



11.3.5 디폴트 액션과 Optional 언랩

이전에 orElse 를 통해서 Optioanl 에 대한 디폴트 값을 처리했었고 더 다양한 방식이 있다.

  • get()
    • Optional 값이 있으면 값을 반환하고 없으면 NoSuchElementException 발생. 내부적으로 unchecked exception이 발생
  • orElse()
    • 11.3.3 절 예제에 있듯이 값이 없으면 default 값으로 내려줄 수 있음
  • orElseGet()
    • orElse() 의 lazy 버전으로 Optional에 값이 없을 때만 default 값 생성
  • orElseThrow()
    • Optional이 비어있을때 예외를 발생시키는 부분은 get()과 동일하지만 원하는 예외를 던질 수 있음
  • ifPresentOrElse()
    • 자바9에서 추가되었으며 ifPresent와 동일하지만 Optional이 비었을때 실행 가능한 Runnable을 인수로 받음



11.3.6. 두 Optional 합치기

위의 예제의 연장선으로 Person과 Car의 정보를 기준으로 가장 싼값의 Insurance를 구한다고 했을 때 isPresent() 를 사용해서 일일히 Optional값의 존재여부를 체크하는건 기존의 null 체크와 다를바 없다. Optional의 map과 flatMap을 이용해서 다음과 같이 표현할 수 있다

public Optional<Insurance> nullSafeFindCheapestInsurance(
    Optional<Person>person, Optional<Car> car){
    return person.flatMap(p -> car.map(c -> findCheapestInsurance(p,c)));
}

위와 같이 표현함으로써 메서드 시그니처만 보고도 person과 car가 null일 수 있음을 인지할 수 있고 Optional 클래스의 flatMap, map 메서드 연산 시, Optional이 비여있는지 여부에 따라서 findCheapestInsurance() 메서드가 실행되므로 기존 null체크에 비해서 훨씬 코드가 간결하다.


11.4. Optional 실용 예제

11.4.1 잠재적 null 값을 Optional로 감싸기

기존 자바 API 자체에서 특정 액션에 대한 반환값으로 null을 반환하는 경우가 많은데 이런 케이스는 반환값을 Optional.ofNullable 로 감싸서 처리

Optional<Object> value = Optional.ofNullable(map.get("key"));



11.4.2. 예외와 Optional 클래스

자바 API 에서 값을 제공할 수 없을때 반환값으로 null이 아닌 익셉션을 내리는 경우가 있는데 이런 경우, 자바 API를 한 단계 감싸서 Optional로 반환하도록 유틸리트 클래스를 따로 만들어서 사용할 수 있음

public static Optional<Integer> stringToInt(String s){
    try{
        return Optional.of(Integer.parseInt(s));
    catch(NumberformatException e){
        return Optional.empty();
    }
}

투포인터

2020 카카오인턴십 코딩테스트 - 보석쇼핑 문제에서 사용

https://programmers.co.kr/learn/courses/30/lessons/67258

 

코딩테스트 연습 - 보석 쇼핑

["DIA", "RUBY", "RUBY", "DIA", "DIA", "EMERALD", "SAPPHIRE", "DIA"] [3, 7]

programmers.co.kr

 

기본적인 컨셉

  1차원 배열에서 start, end 두 인덱스 포인터를 두고 이동 시켜가면서 조건에 맞는 구간을 탐색

 

  1차원 배열의 모든 구간을 완전 탐색한다면 O(N^2)의 시간복잡도를 가진다. 위의 문제에서도 주어진 입력 배열 길이가 최대 100,000 건이므로 이를 완전탐색을 통해서 처리한다면 시간초과가 날 수 밖에 없다.

  투포인터 알고리즘을 통해서 두 개의 포인터를 통해서 처리한다면 각 인덱스가 0 → length(정방향) 혹은 length → 0(역방향)으로 움직이는 케이스를 확인하면 되므로 O(N)의 시간 복잡도로 문제를 해결할 수 있다.

 

  처음에는 이중 for문을 돌리되, 여러 break 조건을 두고 잘 핸들링하면 시간초과 없이 풀이가 가능할수도 있겠다는 생각을 했으나 선택한 알고리즘의 시간복잡도를 계산해보고 풀이 방법을 선택하는게 최선인 듯 하다

 

문제에서 적용

참고해야하는 조건

  • gems 배열의 크기는 1 이상 100,000 이하

    → (해당 조건을 보고 완전탐색으로 풀이할 생각은 버려야한다)

  • 전체 gems를 포함하는 가장 짧은 구간 도출

    → 포함여부를 체크하기 위해서 Set, Map 등의 자료구조가 필요하지 않을까?

  • 짧은 구간이 여러개인 경우, 시작 인덱스가 가장 작은 케이스를 답으로 출력

 

해당 문제에서 투포인터 알고리즘을 적용한 풀이 아이디어는 다음과 같다.

 

1) 일단 기본적으로 전체 gems를 포함하는 구간을 찾아야하므로 이를 만족할때까지 end 포인터를 증가

2) 현재의 start - end 포인터 사이 구간에서 전체 gems를 포함한다면 start 포인터 증가

→ 전체 gems 포함이라는 조건은 만족하고 있는 상태이기 때문에 가장 짧은 구간을 구하기 위해서 start포인터를 증가시킨다

3) start, end 포인터가 전체 인풋 length까지 도달할때까지 1, 2의 작업을 반복한다. 그리고 전체 gems를 만족하는 시점에 조건을 확인하고 정답을 갱신

 

코드

import java.util.*;

class Solution {
    public int[] solution(String[] gems) {
        int[] answer = new int[2];
        int answerLen = Integer.MAX_VALUE;
        answer[0] = Integer.MAX_VALUE;

        // 전체 gems 체크용
        HashSet<String> gemSet = new HashSet<>(Arrays.asList(gems));

        // 각 구간별 보석 수
        HashMap<String, Integer> gemMap = new HashMap<>();

        int strtIdx = 0;
        int endIdx = 0;

        while(endIdx <= gems.length && strtIdx < gems.length){

            if(gemMap.size() == gemSet.size()){
                // 모든 보석 선택완료 시, strt증가 (최소 보석 수를 구하기 위해)
                strtIdx++;

                // answer 구하기
                                // 동일한 길이의 구간의 경우, 시작 진열대 번호가 더 적은 것으로 출력
                if(endIdx - strtIdx + 1 < answerLen || (endIdx - strtIdx + 1 == answerLen && answer[0] > strtIdx)){
                    answerLen = endIdx - strtIdx + 1;
                    answer[0] = strtIdx;
                    answer[1] = endIdx;
                }

                int curVal = gemMap.get(gems[strtIdx-1]);
                if(curVal-1 > 0) {
                    gemMap.put(gems[strtIdx-1], curVal - 1);
                }else{
                    gemMap.remove(gems[strtIdx-1]);
                }
            }
            else{
                if( endIdx == gems.length){
                    strtIdx++;
                }else {
                    gemMap.put(gems[endIdx], gemMap.getOrDefault(gems[endIdx], 0) + 1);
                    endIdx++;
                }
            }
        }

        return answer;
    }
}

CI, CD 기초

Udemy 강의 DevOps, CI/CD for Beginners 참고

 

기본적으로 SDLC(Software/System Development Life Cycle) 에 대해서 이해하고 CI, CD를 통해서 IT시스템이 어떻게 발전했는지 확인

 

 

CI (Continuous Integration)

1. 이전 방식의 소프트웨어 개발 라이프사이클

CI/CD 적용 이전에 라이프사이클

  1. 개발자가 git 등 저장소 각자의 브랜치에 코드를 커밋

  2. Build, Integration을 담당하는 파트에서 각 브랜치를 merging하고 빌드 통해서 완성된 소프트웨어 패키지를 Operation 파트에 전달

  3. 테스트 환경에 배포된 버전으로 QA팀에서 QA진행

  4. 버그 발견 시, 다시 1번 스텝으로 돌아가고 QA 검증 완료 시, 운영환경에 배포

2. 이전 방식의 Integration의 단점

위의 라이프사이클에서 1 → 2로 넘어가는 스텝을 좀 더 자세히 보기 위해서 A회사에는 크게 개발파트가 회원, 상품, 주문 이렇게 세 파트로 나뉘어진다고 가정.

각 파트별로 개발 건은 각자의 브랜치에 커밋해두고 Integration 작업 시에 각 파트 리더가 Build&Integration 팀과 협업해서 각자의 개발 건이 문제 없이 빌드 될 수 있도록 협업한다. 하지만 이러한 과정에 비효율이 발생한다.

  1. 보통 이러한 Integration 작업이 수작업으로 이루어졌기 때문에 시간소요가 많았고 잘못된 머징으로 에러가 자주 발생한다.
  2. 각 파트별 작업분이 모두 합쳐진 완성본은 라이프사이클의 마지막에서 테스트 환경에 배포된 이후에야 확인할 수 있으므로 이슈를 확인, 수정하는 작업이 늦어진다.
  3. 기능 오류의 경우, QA 단계 이후에 이를 확인할 수 있으므로 다시 개발자에게 수정요청이 오는 피드백시간이 길다.

3. Continuous Integration

CI (지속적 통합) 은 개발자가 git과 같은 코드 저장소에 자신의 작업분을 짧은 텀으로 계속 업데이트해서 코드의 최신화를 최대한 보장한다.

 

보통 CI를 "빌드프로세스 자동화" 로만 생각하는 부분이 있는데 그것보다 전체적으로 개발자들이 일하는 방식의 변화라고 생각하면 좋을 듯 하다.

  1. CI를 위해서 각 개발파트는 동일한 소스컨트롤 리파지토리를 사용해야하고, 모든 작업이 완료된 이후에 한번에 브랜치의 내용을 master브랜치로 머지하는 것이 아니라 수시로 해당 머지 작업을 해야한다. 이렇게 함으로써 이전 라이프사이클에서 Build, Integration파트에만 있던 소스 머징에 대한 롤이 각 영역별 개발자들에게 분배된다. 개발자가 자신의 코드를 제일 잘 알기 때문에 Integration 작업을 수행하는데 있어서 가장 효율적으로 처리할 수 있다.
  2. 개발자가 자신의 코드를 커밋하고 이것이 기존 코드와 충돌없이 제대로 돌아가는지 알기 위해서 주기적으로 컴파일을 해야한다. 기존 라이프사이클에서 Integration, Build 파트에서 수기로 작업하던 부분을 자동화하기 위해서 따로 Build Server를 두고 이를 처리한다. 전체적인 빌드 프로세스는 커밋에 자동 트리거 되어서 돌아가도록 한다. 빌드 오류 발생 시, 이를 해당 개발자, 파트에 메일 발송해서 바로 확인할 수 있도록 한다.
  3. 빌드 시점에 중요한 중요한 유닛테스트 등을 자동 수행해서 코드의 퀄리티를 보장할 수 있도록 한다. 그리고 전체적인 빌드 파이프라인(컴파일, 테스트, 패키징)에 소요되는 시간이 최대한 줄임으로써 개발자가 자신이 개발한 코드에 대한 빠른 피드백을 받을 수 있도록 한다.
  4. 컴파일 에러 등이 발생 시, 다른 개발자들의 빌드프로세스에도 영향을 미칠 수 있으므로 오류수정이 가장 최우선의 태스크가 되어야 한다.

4. CI 도입을 통해서 이전 방식에서 어떤 개선이 있었는가

CI 이전에 Integration 단계에서 발생하던 문제가 CI 도입 이후에 어떻게 해결될 수 있는지 정리되어 있다.

옛날 방식의 Integration의 단점이 CI 도입 이후에 어떻게 해결될 수 있는지

결과적으로 시간, 개발리소스 등의 낭비를 줄이고 훨씬 안전한 방법으로 작업하는 코드의 최신화, 안정화를 보장할 수 있다.

 

CD (Continuous Delivery)

1. CD 이전 방식의 단점

이전 프로세스를 통해서 전체적인 빌드가 완료되면 개발팀에서는 구동 가능한 패키지, 배포 instruction을 Operations 팀에 전달하고 Operations 팀에서는 instruction을 참고해서 배포하고자 하는 테스트 서버 환경에 배포하고 이후에 QA를 통해서 운영에 나가도 되는 퀄리티라면 운영배포를 진행한다. 하지만 수기로 배포를 진행하면 발생할 수 있는 문제점이 몇가지 있다.

  1. 사람이 직접 배포하면 각 배포 환경 별 차이(ex. 환경변수 등) 가 혼동될 수 있다.
  2. 마이너한 수정본이 배포를 나간다고 해도 배포를 담당하는 파트에 요청하는 것부터 전체 스텝을 다 거쳐야 하므로 시간이 오래 걸린다. 그리고 배포를 위한 서버 downtime 또한 늘어나게 된다.

2. CD (Continuous Delivery)

CD는 소프트웨어가 언제든지 운영에 릴리즈 될 수 있음을 반영한다. CD가 가능하려면 기본적으로 CI가 보장되어 있어야 하므로 CI, CD는 하나의 큰 꼭지에서 이해하는 것이 맞다.

CI 를 통해서 나온 빌드 최종 산출물인 패키지는 운영이나 테스트환경에 바로 배포될 수 있고 이를 서버에 배포하기 위한 쉘스크립트를 작성해서 관리하면 사람이 수기로 배포에 관여하지 않고 자동화 할 수 있다.

 

운영배포까지 전체적인 프로세스가 모두 자동화되어서 처리되지는 않고 QA를 통해서 실제 운영배포 여부는 확인 후 처리가 필요하다.

3. CD 도입을 통해서 이전 방식에서 어떤 개선이 있었는가

결과적으로 자동화, 단축된 배포시간 등을 통해서 휴먼 에러를 줄이고 필요한 배포건이 있을때 신속하게 이를 운영환경에 배포할 수 있다.

+ Recent posts