객체지향!

상속(inheritance)

흔히 상속을 is-a 관계라 표현하는데 좀더 명확한 표현은 is a kind of관계라 할 수 있다.

객체지향에서 상속은 매우 자주 나오는 용어로 코드 재사용을 위해 흔히 사용되는 개념이다.
상속이란 언어 때문에 아래처럼 상속관계를 오해하기 쉽다.

image02

위 사진은 has-a 상속관계로 java의 클래스 상속과는 잘 어울리지 않는 모델이다.

우리가 java에서 상속이라 표현하는 모델은 아래 사진과 더 유사하다.

image02

즉 상속은 확장, 포함, 분류의 개념이라 할 수 있다.

java에서 inheritance키워드 대신 extends키워드를 사용하는 것이 이때문이다.

인터페이스

다중상속을 통해 좀더 효율적으로 코드 재사용을 하기 위해 나온것이 인터페이스

인터페이스와 이를 구현하는 클래스와의 관계는 is a kind of아닌 is able to관계이다.

즉 무엇을 할 수 있는 지에 대한 명세가 인터페이스라 할 수 있다.

Serializable, Cloneable, Comparable, Runnable 등 java 기존의 여러 인터페이스 뒤에 able이란 스펠이 붙는 이유도 위와 같다.
캡슐화, 추상화, 다형성에 대한 내용은 생략

상속(Inheritance) vs 조합(Composition)

  • 상속을 선택해야 하는 경우:
    • 클래스 간에 명확한 is-a 관계가 있을 때.
    • 다형성을 사용하여 코드의 유연성을 극대화하고 싶을 때.
  • 조합을 선택해야 하는 경우:
    • 클래스 간에 has-a 관계가 있을 때.
    • 코드의 모듈성을 높이고, 객체 간의 결합도를 낮추고 싶을 때.
    • 유지보수성과 확장성을 고려할 때.

SOLID - 객체지향 설계 5원칙

좋은 소프트웨어는 낮은 결합도, 높은 응집도를 요구한다.

결합도는 객체간 의존정도를 나타내고 결합도를 낮추면 의존성도 줄어들고 객체의 재사용, 수정, 유지보수가 용이해진다.

SOLID를 통해 어떻게 낮을 결합도, 높은 응집도를 구현하는지 알아보자.

위키: 컴퓨터 프로그래밍에서 SOLID란 로버트 마틴이 명명한 객체지향의 다섯 가지 기본 원칙을 마이클 페더스가 두문자어로 소개한 것이다.

  • SRP
  • OCP
  • LSP
  • ISP
  • DIP

SRP - (단일책임 원칙: Single Responsibility Principle)

어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다 - 로버트 마틴

모든 객체는 본인만의 책임이 있다.
개발자를 예로들면 아래 종류 개발자들이 있고 각자 본인만의 책임이 있을것이다.
웹개발, 시스템개발, 모바일개발, 임베디드개발 등등…

개발자 란 때 클래스 설계를 할때 위의 모든 개발을 할수 있도록 설계할 것인가?
아니면 각종 종류별 개발자 클래스를 각각 설계할 것인가?

당연히 후자가 좋다.

, 시스템, 모바일 개발 함수를 모두 하나의 통합 개발자 클래스안에 때려넣으면 각 함수에 문제가 생길때 마다 매번 통합 개발자 클래스에 접근해야 한다.

클래스 변경 이유가 여러개가 되는것이다!

역할과 책임에 따라 개발자 클래스를 웹개발자, 시스템개발자, 모바일개발자, 임베디드개발자 등으로 나누었다.
SRP원칙은 지킬수 있겠지만 클래스가 4개로 쪼개지는 바람에 중복코드도 많이 발생하게 된다.

개발자란 직업이 공통으로 가지는 기능, 예를 들어 출근하기(), 잠자기() 같은 기본적인 기능들,
클래스마다 별도로 선언해야 하는가?

만약 시스템 개발자 중에서도 리눅스 시스템 개발자, 윈도우 시스템 개발자 같은 또 다른 별도의 클래스를 설계해야 한다면?

시스템 개발자는 대부분 c/c++을 사용할 것이고 공통적으로 가지는 기능들이 매우 많을텐데 다시 그대로 중복되는 코드들을 정의해야 하는가?

만약 개발을 오래한 사람이라면 윈도우 시스템 개발도 하면서 안드로이드 모바일 개발도 할 수 있는 개발자가 있을 것이다.
그렇다면 윈도우 안드로이드 개발자 클래스도 새로 정의해야 하는가?

객체지향의 어려운점이 이것이다.
어디까지 추상화하고 어디서부터 구현해야 하는가이다.

위의 각종 개발자 클래스는 클래스로 설계하기 보단 인터페이스로 설계하는 것이 옳다.

앞으로 interface, abstract class, class 상관 없이 최소한의 책임을 가지는 엔티티로 설계하자.

즉 객체지향 개발을 하려면 어느정도까지 추상화를 통해 인터페이스로 구현할 것인지,
어디서 부턴 인터페이스들을 구현한 클래스를 설계할 것인지 효율적인 결정이 필요하다.

인터페이스로 구현하게된다면 메서드 삭제/추가 등의 변화가 일어나게 되면 하위클래스를 모두 변경해야 하기에 구현클래스들 개발자, 웹개발자, 시스템개발자 등은 변경이 힘들어진다.

따라서 기초가 거의 변하지 않는 개념은 인터페이스
그외는 의존(상속)객체 를 통해 중복코드를 피하는 것을 추천한다.

OCP - (개방폐쇄 원칙: Open Close Principle)

소프트웨어 엔티티(클래스, 모듈, 함수)는 확장에 대해 열려있어야 하지만 변경에 대해서는 닫혀있어야 한다 - 로버트 마틴

OCP를 잘 구현하려면 모든 엔티티가 기본적으로 가지고 있는 행위를 default method로, 인터페이스로 구현 사용하는 것, 그리고 확장할 때에는 기존 코드는 건드리지 않고 확장 해야한다.

확장에 대해서는 앞으로 소개할 추상화 패턴 옵저버, 데코레이트, 어뎁터 등을 사용해 구현할 수 있다.
대원칙은 앞으로 호출될 하위객체의 메서드에 추가구현(확장) 을 해놓고 해당 메서드를 상위객체에서 코드변경없이 호출할 수 있도록 하는것, 이걸 위한 디자인패턴을 무엇을 사용하던 상관없다.

OCP를 잘 구현한 예는 JDBC이다, 상위 객체의 Connection 부분만 잘 설정하고 연결하면
그 뒤의 하위객체(Mysql, Oracle 등)의 sql문을 실행하고 결과값을 가져오는 과정은 모두 구현(확장)되어 있다.

JDBC 를 사용할 때 폐쇄적으로 구성된 인터페이스의 메서드 사용법만 알면되고 확장부분의 코드에 대해서는 전혀 신경 쓸 필요가 없다.
이것 또한 OCP 의 장점이라 할 수 있다.

LSP - (리스코브 치환의 원칙: The Liskov Substitution Principle)

서브타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다 - 로버트 마틴

리스코브는 바바라 리스코프란 사람이 만들었기 때문에 붙은 스펠이다.

위에서 상속은 is a kind of, 인터페이스는 is able to 관계를 가져야 한다 말했는데
위의 두 관계대로 프로그램 개발을 했다면 이미 LSP를 잘 구현한 것이다.

자바에서의 다운 캐스팅, 업 캐스팅은 LSP를 기반으로 이루어진다.

상위(부모) 클래스는 하위(자식)클래스인척 할 수 있다.
하위(자식) 클래스는 상위(부모)클래스의 메서드를 호출할 수 있고 논리적으로 이상하지 않고 하위클래스에서 기능이 추가되지 삭제되진 않는다.

is a 관계로 구성된 클래스 구조를 LSP 위반 사례로 예를들 수 있다.

직사각형은 정사각형 처럼 동작할 수 없기 때문에 is able to 관계가 아니라 할 수 있다.
하지만 단순히 정사각형이 직사각형에 포함되는 is a 관계라서 아래와 같이 상속구조를 구성했다면 아래와 같은 상황에서 LSP 를 위반하게 된다.

calcArea 메서드를 보면 당연히 20 이 반환될거라 예상하지만 실상은 16이 반환된다.

@Getter
@Setter
public abstract class Rectangle { // 직사각형
    private int width;
    private int height;

    public int getArea() {
        return width * height;
    }
}

public class Square extends Rectangle { // 정사각형

    public Square(int width) {
        super(width, width);
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

@Test
void getArea() {
    Square square = new Square(5);
    int area = calcArea(square);
    Assertions.assertEquals(20, area); // error!
}

private int calcArea(Rectangle rectangle) {
    rectangle.setWidth(5);
    rectangle.setHeight(4);
    return rectangle.getArea();
}

ISP - (인터페이스 분리 원칙: Interface Segregation Principle)

클라이언트 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다 - 로버트 마틴

ISP는 인터페이스를 최소한의 기능을 가지도록 설계하는 것을 뜻한다.
인터페이스에 정의된 메서드 때문에 사용하지 않을 메서드를 구현해야 하는 상황을 최대한 피한다.

객체지향에선 하위(자식) 클래스는 기능이 풍성할 수록 좋고, 인터페이스는 작을 수록 좋다.
먼저 클래스의 기능이 풍성해야 하는 이유를 알아보자.

class 훈련병 {
  ...
  잠자기() {...}
  말하기() {...}
}
class 병사 extends 훈련병 {
  ...
  먹기() {...} //신규
  훈련하기() {...} //신규
}

위와 같은 클래스가 정의되어 있을때 훈련병 홍길동 = new 병사(); 으로 훈련병 인스턴스를 생성하자.

홍길동 객체를 통해서 호출할 수 있는건 훈련병 인터페이스에 정의된 추상메서드 잠자기() 뿐이다.
만약 먹기()를 억지로 사용하려 한다면 억지로 형변환 과정이 필요하다. ((병사)홍길동).먹기();

사실 먹기()훈련병 클래스 정의되는게 맞다. 즉 클래스 정의할땐 의 책임안에서 최대한(풍성한)의 기능을 제공해야한다.

이번엔 인터페이스의 기능을 최소화 해야 하는 이유를 알아보자.

interface 훈련병 {
  ...
  잠자기();
  먹기();
  말하기();
}
class 병사 implements 훈련병 {
  ...
  잠자기() {...} //구현
  먹기() {...} //구현
  말하기() {...} //구현
  훈련하기() {...} //신규
}

조금 억지이지만 훈련병은 꼭 사람만 될 수 있는가? 개도 될 수 있다 생각해보자.
개는 말할 수 있는가? 항상 is able to 기준으로 인터페이스를 생성하고 역할에 충실한 최소한의 기능만 구현하자.

interface 훈련병 {
  ...
  잠자기();
  먹기();
}
class 군견 implements 훈련병 {
  ...
  잠자기() {...} //구현
  먹기() {...} //구현
  짖기() {...} //신규
  훈련하기() {...} //신규
}

SRP에선 클래스의 단일 책임을,
ISP에선 인터페이스의 단일 책임을 요구한다.

만약 최소한의 기능이지만 많은 기능이 들어갈 수 밖에 없다면 인터페이스 보단 SRP를 따라 클래스로 만드는 것이 좋다.

즉 하나의 엔티티를 여러개의 엔티티로 쪼갤 수 있다면 ISP를, 그리고 다중상속을 진행한다.
하나의 엔티티를 쪼갤 수 없고, 쪼갠다 하더라도 많은 기능이 포함된다면 SRP이다.

어디까지는 인터페이스로 설계하고 어디까지는 클래스로 설계하는 지에 대한 경계를 정할 수 있다! (대부분이 SRP 로 진행된다.)

DIP - (의존역전 원칙: Dependency Inversion Principle)

자신보다 변하기 쉬운것에 의존하지 마라 - 로버트 마틴

객체가 의존관계를 가질때 최대한 상위클래스, 추상클래스, 인터페이스와 같은 불변할 가능성이 높은 객체를 의존하도록 하는 법칙.
하위클래스, 하위모듈을 직접적으로 의존할 경우 하위클래스, 하위 모듈이 변함에 따라 서로 의존하는 관계가 될 수 있다

어렵게 설명하지만 사실 상위클래스와 하위클래스 사이에 완화 역할을 해줄 인터페이스를 추가해주는 것이다.
즉 하위 모듈에 의존하더라도 추상화 클래스에 의존하라는 뜻이다.

DIP 를 잘 구현한 대표적인 예가 JDBC 라이브러리이다.

image02

프로그램 → DriverManager → JDBC인터페이스 → JDBC드라이버 → DB

바로 하위 묘듈에 의존하는 프로그램 -> JDBC드라이버 의존구조를 가질경우
DB가 업데이트 되면서 기존의 JDBC드라이버의 메서드들 또한 업데이트 되면 기존 JDBC드라이버 접근 코드를 모두 수정해야 한다.
나의 프로그램이 JDBC드라이버 를 의존하면서 발생한 불상사이다.

하지만 JDBC인터페이스를 통해 JDBC드라이버를 사용중이라면 [Oracle, MySQL] 등에서 개발한 JDBC드라이버만 새로운 버전으로 업데이트 하면 된다.

각 DB 벤더사가 JDBC인터페이스를 기반으로 개발하는 가정하에
JDBC인터페이스 는 모든 DB 밴더사가 불변한다는 가정하에 개발해주기 때문에 신뢰하며 사용할 수 있는 객체이다.

즉 의존관계가 필요할 경우 최대한 불변할 것 같은 객체를 의존관계로 두고 자주 업데이트 되는 하위객체를 해당 인터페이스에 의존토록 하는것이 DIP.

인식을 아래 첫번째 그림에서 두번째 그림으로 변경해야 한다.

image02

기타 설계 원칙

최소 지식 원칙(Principle of Least Knowledge)
객체 사이의 상호작용은 될 수 있으면 아주 가까운 친구 사이에서만 허용하는 편이 좋음

할리우드 원칙(Hollywood Principle) 의존성이 폭포수처럼 호출되도록, 저수준 구성요소는 고수준 구성요소 함수를 절대 호출할 수 없도록 구성한다.

DDD(Domain Driven Design: 도메인 주도 설계)

단순 CRUD 방식의 코드스타일을 사용하면 복잡한 비지니스 로직 구성시 여러 오류와 즉면할 수 있음으로 DDD 패턴을 사용하자

DDD 대원칙은 아래와 같다.

  • 도메인 클레스는 엔티티와 벨류타입으로 구성
  • 도메인 클레스에 기본생성자 protected 로 정의
  • 도메인 클레스에 setter 사용 X, 도메인 관점으로 필드의 변경 구현
  • 도메인 클레스에 비지니스 함수 구성

도메인 아키텍처는 아래 4가지 계층 구분

  1. 표현: DTO 기반 HTTP 연결점
  2. 응용: 비지니스 로직
  3. 도메인: 도메인 관리를 위한 각종 함수구현부
  4. 인프라스트럭처: 외부서비스(DB, Broker, SMTP 등) 구현부

image02

순차적으로 의존하는 구조가 정석, 하위계층이 상위계층을 의존하는 방식을 절대 피해야함
단 편리성을 우해 상위계층은 두단계 아래의 하위계층을 의존하기도함

패키지 구성은 아래와 같이 진행

  • 도메인 구성요소별로 패키지 모듈을 구성
  • 도메인 아키텍처별로 패키지 모듈을 구성

정답은 없으며 (취향+편리성)을 토대로 구성하면 된다.

개인적으로는 도메인 아키텍처별로 구성을 자주함

도메인 구성요소

도메인의 구성요소는 아래와 같다.

  1. 엔티티
  2. 밸류
  3. 애그리거트
  4. 리포지토리
  5. 도메인서비스

엔티티

엔티티의 가장 큰 특징은 식별자, 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
식별자로는 주문번호, 운송장번호, 카드번호 같은 개념적ID 부터 UUID, 시퀀스ID 같은 일련번호값이 사용될 수 있다.

코드가 진행되면서 엔티티 내부값들은 변경될 수 있다.

밸류 타입

밸류 타입은 개념적으로 완전한 하나를 표현하는 클래스타입.
값자체를 표현하는 클래스이기에 코드가 진행되며 내부값이 변경될 일이 없음으로 불변으로 정의할 것을 권장.

아래와 같은 타입을 밸류타입이라 함.

public class Address {
  private final String address1;
  private final String address2;
  private final String zipcode;
}

public class Receiver {
  private final String name; 
  private final String phoneNumber;
}

public class Money {
  private final int value;
  public Money add(Money money) { return new Money(this.value + money.value); }
}

애그리거트

연관된 엔티티와 밸류타입를 개념적으로 하나로 묶은 것, 구현하고자 하는 도메인의 개념을 모델링한 단위를 애그리거트라 할 수 있다

애그리거트는 계층+군집화 구조를 통해 도메인 모델을 관리한다.

image02

규모가 커질수록 많은 엔티티와 벨류가 추가되면서 애그리거트는 복잡해진다.

그중 루트 엔티티를 애그리거트의 본체라 할 수 있으며 도메인 구현의 전체적인 그림을 가지고 있다.

애그리거트는 다른 애그리거트를 변경하지 않도록 구성해야 한다.
독자적인 라이프 사이클을 갖는다면 별도의 애그리거트일 가능성이 높다.

해당 규칙을 가지고 애그리거트(도메인)을 나누고 사이즈를 줄인다.

주문 애그리거트 와 관련있는 객체들을 애그리거트 단위로 묶어 표현하면 아래 그림과 같다.

image02

또한 에그리거트에 속하는 엔티티, 벨류 값을 외부에서 변경하면 안되고 루트에서만 수정할 수 있도록 구성해야 한다.

@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
    private OrderNo number;
    private List<OrderLine> orderLines;
    protected Order() {
    }

    public Order(OrderNo number, List<OrderLine> orderLines) {
        setNumber(number);
        setOrderLines(orderLines);
    }

    private void setNumber(OrderNo number) {
        if (number == null) throw new IllegalArgumentException("no number");
        this.number = number;
    }

    private void setOrderLines(List<OrderLine> orderLines) {
        verifyAtLeastOneOrMoreOrderLines(orderLines);
        this.orderLines = orderLines;
        calculateTotalAmounts();
    }

    private void verifyAtLeastOneOrMoreOrderLines(List<OrderLine> orderLines) {
        if (orderLines == null || orderLines.isEmpty()) {
            throw new IllegalArgumentException("no OrderLine");
        }
    }
}

setter 가 외부로 노출되는일이 없도록 private 으로 구성

위 그림처럼 애그리거트간 연관성이 있어 매핑을 해야 하는 경우
직접 참조 보다는 ID 필드를 삽입하여 사용하는 것을 권장한다.

N:M 관계에 대해서도 ID 만 가지고 별도의 연관 조인테이블 생성을 권장한다.

public class Product {

  @ElementCollection 
  @CollectionTable(name = "product_category", joinColumns = 
  @JoinColumn(name = "product_id"))
  private Set<CategoryId> categoryIds;
  ...
}

리포지토리

Spring Data JPA 를 기반으로 설명

한 트랜잭션에서는 한 개의 애그리거트만 수정해야한다.
트랜잭션 성능이슈 뿐만 아니라 두개 이상 애그리거트 수정이 필요하다는 것은 애그리거트 간 결합도가 높다는 것.

물론 한번의 사용자 요청으로 두개이상의 애그리거트 수정이 필요할 수 있는데
각 애그리거트 루트객체를 기반으로 수정하는것을 권장한다.

또한 JPA ORM 으로 도메인객체 정의시 아래 어노테이션 사용을 권장한다.

  • 루트엔티티는 @Entity
  • 벨류객체는 @Embeddable

구현상 밸류객체도 DB테이블로 저장해야할 상황일 경우 아래와 같이 하나의 트랜잭션에 모든 도메인 객체들이 같이 저장, 삭제될 수 있도록 구성해야 한다.

  • @ElementCollection
  • @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)

단 벨류객체를 위한 테이블을 생성할 경우 항상 N+1, Catasian Product 이슈를 조심해야함

도메인 서비스

애그리거트의 영역의 경계가 모호해지는 지점이 있다.

물건을 주문하고 결제까지 진행할 때 아래와 같은 도메인들이 필요하다.

  • 상품 에그리거트
  • 주문 에그리거트
  • 할인 에그리거트
  • 회원 에그리거트

비지니스 로직을 구성할 때 억지로 도메인 클래스안에 함수를 구현하는것 보다

여러 도메인 기능을 합진 별도의 도메인서비스 를 구현하는것을 권장한다.

public class DiscountCalculationService {
  public Money calculateDiscountAmounts(List<OrderLine> orderLines, List<Coupon> coupons, MemberGrade grade) {
    ...
  }
  private Money calculateDiscount(Coupon coupon) {
    ...
  }

  private Money calculateDiscount(MemberGrade grade) {
    ...
  }
}

트랜잭션 처리를 담당하는 응용서비스가 주로 도메인서비스를 호출한다.

같은 DB의 애그리거트가 아닌 외부시스템의 도메인과도 엮일 수 있다.
그래서 interface 로도 자주 구현함

바운디드 컨텍스트

Bounded Context: 경계를 갖는 컨텍스트

도메인은 상하관계를 가지며 모든 도메인들은 서로 연결되어 있다.

image02

회원도메인은 각 도메인 에서 여러명칭으로 불릴 수 있다.
그리고 도메인마다 하위 도메인을 부르는 명칭도 다르고 관계도 다르다(1:N or 1:1)

  • 주문도메인에선 주문자
  • 배송도메인에선 배송인

이렇게 한 도메인을 가지고 다른 명칭으로 구분짖는 경계가 있는데 이 경계를 바운디드 컨텍스트라 부른다.
이 경계를 잘 정의해야 복잡한 시스템에서 수월한 유지보수가 가능해진다.

도메인에서 중복적으로 사용하는 하위 모델을 모든 도메인에서 참조하는 것 보다 각자 별도로 구성해서 사용하는것을 권장한다.
하나의 모델을 서로 참조해서 엉키는 것을 방지하고 결합도는 낮추고 응집도를 높힐 수 있는 방법이다.

바운디드 컨텍스트는 단순 도메인 클래스만 포함하지 않고 표현, 응용, 인프라스트럭쳐도 같이 포함되며
따라서 각 바운디드 컨텍스트는 반드시 하나의 서버에 있을 필요 없고 각 다른 기술로 다른 서버에 구현하여도 상관없다.

만약 바운디드 컨텍스트에 직접 접근시에는 REST 방식, 간접 접근시에는 MQ 방식을 추천한다.

이런 특성때문에 바운디드 컨텍스트간 결합시에 중간에 interface 를 하나 껴서 DIP 형태로 구성하는 것을 추천한다. 바운디드 컨텍스트간 침범을 막아주는 안티코럽션 계층(Anticorruption Layer) 이라고도 한다.
안티코럽션 계층은 바운디드 컨텍스트간 공유될 수 있으며 공유커널(Shared Kernel) 이라 부를 수 있다.(공유시 발생하는 단점 장점이 있음으로 할지 말지는 개발자 자유)
통합하는 방식인 독립방식(Sperate Way) 도 존재한다. 수동 혹은 배치와 같은 방식으로 각바운디드 컨텍스트에서 성장한 모델을 통합(동기화) 시켜준다.

바운디드 컨텍스트는 상류, 하류 컴포넌트로 나눌 수 있다.

  • API 를 호출하는 쪽이 하류컴포넌트
  • API 에 반환하는 쪽이 상류컴포넌트

상류컴포넌트는 하류컴포넌트들이 사용할 수 있도록 API 를 정의하고 공개하는데
상류컴포넌트공개 호스트 서비스(OHS: Open Host Service) 라고도 한다.

지금까지 배운 개념을 가지고 전체적인 그림 컨텍스트 맵 을 그리는 것도
핵심 도메인 파악에 도움을 준다.

image02

그림과 같이 안티코럽션 계층과 공개 호스트 서비스를 가지고 바운디드 컨텍스트 맵을 표기

컨텍스트 이벤트

컨텍스트간 강결합 해소를 위해 이벤트 구조를 사용할 수 있다.

여러개의 바운디드 컨텍스트 가 굳이 하나의 트랜잭션 내에서 처리될 필요 없다면
트랜잭션 에러를 피하기 위해서라도 이벤트 방식 사용이 권장된다.

  • ApplicationEventPublisher
  • DB Event Store
  • MQ Broker

비동기 이벤트 방식은 항상 아래와 같은 상황에 대해 고려해야 한다.

  • 이벤트 전송실패
  • 이벤트 장애
  • 이벤트 중복처리
  • 이벤트 순서

위 장애들에 대해서 보상트랜잭션, 관리자 알림 등의 방법을 통해 해결할 수 있지만 완벽한 정답은 없다.

이벤트 스토밍

출처: https://www.youtube.com/watch?v=QUMERCN3rZs&t=3364s

DDD 에서 서비스간 비동기 메세지 전송, 이벤트 호출을 이벤트 스토밍을 통해 설계하면 효율적이다.

개발자, 관리자, 설계자 모두 모여 서로가 가지고 있는 관점을 논의한다.
퍼셀리레이터 - 이벤트 스토밍 진행자 또한 있으면 좋음
매일 혹은 격일로 3시간 미만의 워크숍을 가지고 진행하는 것을 권장

벽, A0 용지, 마커, 스티커, 포스트잇, 선을 그릴수 있는 라인 테이브, 스카치 테이프

ddd1

유형 색깔 설명
도메인 이벤트 주황 발생사건, 과거시제동사로 표현
커맨드 파랑 명령 (도메인 이벤트 트리거)
외부시스템 핑크 관계 있는레거시 또는 외부 시스템(도메인 이벤트가 호출)
애그리거트 노랑 도메인 이벤트와 커맨드가 처리하는 데이터
정책 라일락 이벤트 조건에 따라 진행되는 결정
읽기모델 초록 도메인 이벤트 액터에게 제공되는 데이터
핫스폿 자주 의문, 질문, 미결정
사용자 인터페이스 흰색 화면 레이아웃
액터 약노랑 커맨드를 실행하는 자

ddd1

이벤트(주황) - 외부시스템(핑크) - 파랑(커맨드) - 약노랑(액터) - 생노랑(애그리거트)

하나의 애그리거트 하나의 서비스라 생각하면 되고(가끔 2개이상일 수 있음)
애그리거트 단위로 스티커를 분류하고 줄을 긋는다(바운디드 컨텍스트)

ddd1

나누어진 서비스 단위로 동기요청, 비동기 요청 매핑한다.
어그리거트, 외부시스템 포스트잇만 따로 붙여 매핑 스티커를 통해 줄을 긋는다.

ddd1

빨강:비동기, 파랑:동기

보리스 다이어그램 ddd1

SNAP-E - 서비스별 상세 설명 ddd1

디자인 패턴

유명한 디자인패턴 몇가지를 간단히 설명

  • 상속 기반 패턴
    • 템플릿 메서드 패턴
    • 팩토리 메서드 패턴
  • 조합 기반 패턴
    • 전략 패턴
    • 데코레이트 패턴
    • 어댑터 패턴

템플릿 메서드 패턴

템플릿 메소드 패턴은 템플릿(골격, 틀)을 정의하는 패턴이다.
비슷한 클래스를 모아 추상화를 통해 상속과정을 거쳐 중복코드를 제거할 수 있는 템플릿을 제공한다.

아래처럼 몇가지 함수만 다른 비슷한 Tea, Coffee 클래스 가 있다면
추상화 객체의 상속을 통한 비슷한 클래스간 중복코드를 제거할 수 있다.

public class Tea {
    final void prepareRecipe() {
        boilWater(); steepTeaBag();
        pourInCup(); addLemon();
    }
    public void steepTeaBag() { System.out.println("Steeping the tea"); }
    public void addLemon() { System.out.println("Adding Lemon"); }
    public void boilWater() { System.out.println("Boiling water"); }
    public void pourInCup() { System.out.println("Pouring into cup"); }
}
public class Coffee {
    final void prepareRecipe() {
        boilWater(); brewCoffeeGrinds();
        pourInCup(); addSugarAndMilk();
    }
    public void brewCoffeeGrinds() { System.out.println("Dripping Coffee through filter"); }
    public void addSugarAndMilk() { System.out.println("Adding Sugar and Milk"); }
    public void boilWater() { System.out.println("Boiling water"); }
    public void pourInCup() { System.out.println("Pouring into cup"); }
}

중복되지 않은 부분만 아래와 같이 각 클래스에서 정의하고
중복되는 내용은 CaffeineBeverage 추상화 클래스를 통해 사전에 정의해둔다.

public class Tea extends CaffeineBeverage {
    public void brew() { System.out.println("Steeping the tea"); }
    public void addCondiments() { System.out.println("Adding Lemon"); }
}

public class Coffee extends CaffeineBeverage {
    public void brew() { System.out.println("Dripping Coffee through filter"); }
    public void addCondiments() { System.out.println("Adding Sugar and Milk"); }
}

중요한것은 추상화 개념.
steepTeaBag, brewCoffeeGrindsbrew 함수로 추상화하고
addLemon, addSugarAndMilkaddCondiments 함수로 추상화한다.

public abstract class CaffeineBeverage {
  
    final void prepareRecipe() {
        boilWater(); brew();
        pourInCup(); addCondiments();
    } 
    abstract void brew();
    abstract void addCondiments();
    void boilWater() { System.out.println("Boiling water"); }
    void pourInCup() { System.out.println("Pouring into cup"); }
}

템플렛 메서드 후크

public abstract class CaffeineBeverageWithHook {
    final void prepareRecipe() {
        boilWater(); brew(); pourInCup();
        if (customerWantsCondiments())
            addCondiments();
    }
    
    abstract void brew();
    void boilWater() { System.out.println("Boiling water"); }
    void pourInCup() { System.out.println("Pouring into cup"); }
    // hook method
    abstract void addCondiments();
    abstract boolean customerWantsCondiments();
}

추상클래스에서 customerWantsCondiments addCondiments 와 같이 후크(조건문) 을 설정해두면 구현클래스에서 후킹될 조건과 메서드를 정의할 수 있다.

후크를 사용하면 템플릿화 할 클래스들이 조금씩 다르더라도 융퉁성 있게 변경해가면서 템플릿 메서드 패턴을 적용시킬 수 있다.

템플릿 패턴 예로 Comparable 를 들 수 있다.
Arrays.sort 메서드를 사용하려면 Comparable 인터페이스의 compareTo 메서드를 구현하면 된다.

전략패턴이랑 비슷해 보이지만 템플릿 메서드 패턴은 완전한 구현을 필요로 한다.
compareTo 메서드의 완전한 구현 없이 Arrays 함수만으로는 구동하지 않음으로 전략보단 뼈대에 가깝다.

전략패턴

템플릿 패턴과 마찬가지로 골격을 구현하는 디자인 패턴이다.

작업을 다양한 방식으로 수행하는 클래스들을 그룹핑, 모든 작업을 전략(strategies)​이라는 별도의 추상 클래스로 추출.
해당 작업을 수행해야 하는 클래스는 전략을 상속하는 것만으로 구현이 완료되고 어떤 작업을 수행하는지 알 수 있게 된다.

// strategies 추상클래스
public interface SortHandle {
    void swap(int index);
    boolean outOfOrder(int index);
    int length();
    void setArray(Object array);
}

public class BubbleSorter {
    private int operations = 0;
    private int length = 0;
    // 전략으로 구성할 handle, DIP
    private final SortHandle itsSortHandle; 

    public BubbleSorter(SortHandle handle) {
        itsSortHandle = handle;
    }

    public int sort(Object array) {
        itsSortHandle.setArray(array);
        length = itsSortHandle.length();
        operations = 0;
        if (length <= 1)
            return operations;

        for (int nextToLast = length - 2; nextToLast >= 0; nextToLast--)
            for (int index = 0; index <= nextToLast; index++) {
                if (itsSortHandle.outOfOrder(index))
                    itsSortHandle.swap(index);
                operations++;
            }

        return operations;
    }
}
public class DoubleBubbleSorter implements SortHandle {
    private double[] array = null;

    @Override
    public void swap(int index) {
        double temp = array[index];
        array[index] = array[index + 1];
        array[index + 1] = temp;
    }

    @Override
    public int length() { 
        return array.length;
    }

    @Override
    public void setArray(Object array) {
        this.array = (double[]) array;
    }

    @Override
    public boolean outOfOrder(int index) {
        return (array[index] > array[index + 1]);
    }
}

public class IntSortHandle implements SortHandle {
    private int[] array = null;

    @Override
    public void swap(int index) {
        int temp = array[index];
        array[index] = array[index + 1];
        array[index + 1] = temp;
    }

    @Override
    public void setArray(Object array) {
        this.array = (int[]) array;
    }

    @Override
    public int length() {
        return array.length;
    }

    @Override
    public boolean outOfOrder(int index) {
        return (array[index] > array[index + 1]);
    }
}

템플릿 패턴은 핵심로직과 세부구현 모두 하나의 클래스로부터 내려오지만
전략 패턴은 핵심로직과 세부구현 클래스가 분리되어 있으며 템플릿 패턴과 다르게 DIP 를 사용한다는게 가장 큰 특징이다.

해당 전략을 사용하는 클래스라면 언제든지 다른 클래스에서도 사용될 수 있다.

옵저버 패턴

옵저버 패턴(Observer Pattern)에는 아래 2가지 종류의 객체가 있다.

  • Publisher: 발행자, Subject 객체라고도 함
  • Subscriber: 구독자, Observer 객체라고도 함

Publisher(Subject) 가 자신의 상태가 변경되거나 하면
Subscriber(Observer) 들에게 이를 전파하는 방식

PublisherSubscriber 는 1:N 관계를 가진다.

public interface Subject {
    public void registerObserver(Observer o); // Observer 등록
    public void removeObserver(Observer o); // Observer 삭제
    public void notifyObservers(); // Observer 에게 전파
}

public interface Observer {
    public void update(int value);
}

코드는 간단하다, 등록된 Observer 들에게 반복문을 돌며 데이터를 update 한다.

public void notifyObservers() {
    for (Observer observer : observers) {
        observer.update(value);
    }
}

pull 방식

현재 SubjectObserver 에 정의된 update 함수를 호출해서 데이터를 전달하는 push 방식을 사용중이다.
모든종류의 Observer 가 동일한 Subject 데이터를 사용하면 문제없지만 Observer 종류별로 다양한 포멧의 데이터를 요구할 경우 Subject 내부에 분기코드가 많아지고 update 메서드 종류도 많아진다.
그리고 데이터를 전달받고 싶지 않은 observer 도 해당 데이터를 온전히 전달받아야 한다.

이를 해결하기 위해 Observer 에서 Subject 에 접근해서 원하는 데이터만 pull 해오는 방식을 pull 방식이라 한다.

ObserverSubject 어느쪽에 결합을 느슨하게 설정할건지 차이이며
둘다 장단점이 있음으로 상황에 맞춰 사용하면 된다.

데코레이트 패턴

객체에 추가 요소를 동적으로 더해나가는 패턴

단순히 상속객체를 무한히 생성 해나가는 것 보다 훨씬 유연하게 만들 수 있다.

Beverage 는 음료수의 기본 클래스
Beverage 를 계속 꾸미면서 각종 여러가지 음료들을 만들어 낸다는 개념이다.

기본 음료는 Decaf, DarkRoast, Espress 3가지

public abstract class Beverage {
    String description = "Unknown Beverage";
    public String getDescription() { return description; }
    public abstract double cost();
}

public class Decaf extends Beverage {
    public Decaf() { description = "Decaf Coffee"; }
    public double cost() { return 1.05; }
}

public class DarkRoast extends Beverage {
    public DarkRoast() { description = "Dark Roast Coffee"; }
    public double cost() { return .99; }
}

public class Espresso extends Beverage {
    public Espresso() { description = "Espresso"; }
    public double cost() { return 1.99; }
}

CondimentDecoratorBeverage 를 데코레이트해줄 클래스

간단하게 Milk, Mocha, Soy, Whip 4종류 클래스만 정의했다.

public abstract class CondimentDecorator extends Beverage {
    Beverage beverage;
    public abstract String getDescription();
}

public class Milk extends CondimentDecorator {
    public Milk(Beverage beverage) { this.beverage = beverage; }
    public String getDescription() { return beverage.getDescription() + ", Milk"; }
    public double cost() { return .10 + beverage.cost(); }
}

public class Mocha extends CondimentDecorator {
    public Mocha(Beverage beverage) { this.beverage = beverage; }
    public String getDescription() { return beverage.getDescription() + ", Mocha"; }
    public double cost() { return .20 + beverage.cost(); }
}

public class Soy extends CondimentDecorator {
    public Soy(Beverage beverage) { this.beverage = beverage; }
    public String getDescription() { return beverage.getDescription() + ", Soy"; }
    public double cost() { return .15 + beverage.cost(); }
}
 
public class Whip extends CondimentDecorator {
    public Whip(Beverage beverage) { this.beverage = beverage; }
    public String getDescription() { return beverage.getDescription() + ", Whip"; }
    public double cost() { return .10 + beverage.cost(); }
}

고객이 Whip DoubleMocha DarkRoast 커피를 주문하면 아래와 같이 생성하면 된다.

Beverage beverage1 = new DarkRoast();
beverage1 = new Mocha(beverage1);
beverage1 = new Mocha(beverage1);
beverage1 = new Whip(beverage1);
System.out.println(beverage1.getDescription() + " $" + beverage1.cost());
// Dark Roast Coffee, Mocha, Mocha, Whip $1.49

image02

Java HTTP Servlet, Java I/O stream 객체도 데코레이트 패턴을 사용한 예이다.

내부구조가 계층형으로 구성되어있다 보니 얼마나 많은 데코레이터를 거쳐왔는지 파악이 힘들 수 있으며 각 클래스 내부의 데이터를 변경하는 것에 힘든점이 있다.

팩토리 패턴

new 연산자를 사용하는 것은 구상클래스(concrete class) 를 사용한다는 것

구상클래스: 더이상 구현이 필수가 아닌 클래스

구상클래스는 상속 끝단에 있는 클래스이다 보니 변화가 많은 클래스이다.
어떻게 보면 new 연산자 사용 자체가 DIP 위반이라 할 수 있다.

그래서 메인코드에 구상클래스를 생성하는 코드를 많이 사용하면 메인코드도 같이 많은 변화가 일어날 수 있다.
아래와 같은 구상클래스를 생성하는 코드가 곳곳에 깔려있다면 새로운 Pizza 클래스 추가 시 당혹스럽다.

String type = "cheese";
Pizza pizza;
if (type.equals("cheese")) {
    pizza = new CheesePizza();
} else if (type.equals("greek")) {
    pizza = new GreekPizza();
} else if (type.equals("pepperoni")) {
    pizza = new PepperoniPizza();
}

따라서 구상클래스를 생성할 메서드 또는 클래스를 별도로 생성하는 것이 팩토리 패턴이다.
보통 PizzaFactory 와 같은 명칭을 사용해 별도의 클래스로 분리하는 것이 일반적이다.

abstract public class Pizza {
    String name;
    String dough;
    String sauce;
    List<String> toppings = new ArrayList<String>();
}

public class ClamPizza extends Pizza {
    public ClamPizza() {
        name = "Clam Pizza";
        dough = "Thin crust";
        sauce = "White garlic sauce";
        toppings.add("Clams");
        toppings.add("Grated parmesan cheese");
    }
}

public interface PizzaFactory {
    Pizza createPizza(String type);
}

public class SimplePizzaFactory implements PizzaFactory {
    @Override
    public Pizza createPizza(String type) {
        Pizza pizza = null;
        if (type.equals("cheese")) {
            pizza = new CheesePizza();
        } else if (type.equals("pepperoni")) {
            pizza = new PepperoniPizza();
        } else if (type.equals("clam")) {
            pizza = new ClamPizza();
        } else if (type.equals("veggie")) {
            pizza = new VeggiePizza();
        }
        pizza.prepare();
        pizza.bake();
        pizza.cut();
        pizza.box();
        return pizza;
    }
}

Factory 는 관용구일 뿐이며 구상클래스 생성을 전문적으로 하는 클래스는 모두 펙토리 클래스라 할 수 있다.
아래처럼 적절한 상속구조를 통해 팩토리패턴을 완성시켜나가면 된다.

피자생성, 피자주문 두가지 역할을 분리하여 팩토리 역할도 수행하는 클래스를 정의했다.

public abstract class Pizza {
    String name;
    String dough;
    String sauce;
    ArrayList<String> toppings = new ArrayList<String>(); 

    void prepare() {
        System.out.println("Prepare " + name);
        System.out.println("Tossing dough...");
        System.out.println("Adding sauce...");
        System.out.println("Adding toppings: ");
        for (String topping : toppings) {
            System.out.println("   " + topping);
        }
    }
    void bake() { System.out.println("피자 굽기"); }
    void cut() { System.out.println("피자 자르기"); }
    void box() { System.out.println("피자 담기"); }
}

public class NYStyleClamPizza extends Pizza {

    public NYStyleClamPizza() {
        name = "NY Style Clam Pizza";
        dough = "Thin Crust Dough";
        sauce = "Marinara Sauce";
        toppings.add("Grated Reggiano Cheese");
        toppings.add("Fresh Clams from Long Island Sound");
    }
}
// 팩토리 역할을 하는 PizzaStore
public abstract class PizzaStore {
    // 피자 만들기
    abstract Pizza createPizza(String item);
    // 피자를 만들고 난 뒤 과정
    public Pizza orderPizza(String type) {
        Pizza pizza = createPizza(type);
        System.out.println("--- Making a " + pizza.getName() + " ---");
        pizza.prepare();
        pizza.bake();
        pizza.cut();
        pizza.box();
        return pizza;
    }
}

public class ChicagoPizzaStore extends PizzaStore {
    Pizza createPizza(String item) {
        if (item.equals("cheese")) { // chicago cheese pizza
            return new ChicagoStyleCheesePizza();
        } else if (item.equals("veggie")) { // veggie chicago pizza
            return new ChicagoStyleVeggiePizza();
        } else if (item.equals("clam")) { // clam chicago pizza
            return new ChicagoStyleClamPizza();
        } else if (item.equals("pepperoni")) { // pepperoni chicago pizza
            return new ChicagoStylePepperoniPizza();
        } else return null;
    }
}

public class NYPizzaStore extends PizzaStore {
    Pizza createPizza(String item) {
        if (item.equals("cheese")) { // newyork cheese pizza
            return new NYStyleCheesePizza();
        } else if (item.equals("veggie")) { // newyork veggoe pizza
            return new NYStyleVeggiePizza();
        } else if (item.equals("clam")) { // newyork clam pizza
            return new NYStyleClamPizza();
        } else if (item.equals("pepperoni")) { // newyork pepperoni pizza
            return new NYStylePepperoniPizza();
        } else return null;
    }
}

팩토리 패턴의 장점은 반환값이 추상클래스이기만 하면 되기때문에
해당 팩토리 클래스를 변경하면 언제든지 운영코드 변경 없디 생성되는 구상클래스 변경이 가능하단 점.

하지만 모든 구상클래스에 대해 팩토리 패턴을 사용할 순 없다.
팩토리의 필요성이 충분히 커지면 시스템에 팩토리를 도입하는 것을 권장한다.

추상 팩토리 패턴

지금까지는 구상클래스 내부에서 하드코딩된 방식으로 초기화를 진행했다.
구상클래스가 무한정 늘어나고, 이에 따른 팩토리 클래스 코드도 계속해서 변경이 필요하다.

추상 팩토리 패턴은 DIP 개념을 사용해 구상클래스가 추상팩토리 클래스를 이용하는 방법이다.
구상클래스를 그룹화 할 수 있다면 최대한 추상화하여 구상클래스의 개수를 줄이고, 구상클래스 내부 데이터의 초기화는 추상 팩토리에게 맡기는 방법이다.

아래 코드처럼 구상클래스를 생성하는 추상 형식을 제공할 뿐이다.

public abstract class Pizza {
    String name;
    Dough dough;
    Sauce sauce;
    Veggies veggies[];
    Cheese cheese;
    Pepperoni pepperoni;
    Clams clam;

    abstract void prepare();
    void bake() { System.out.println("피자 굽기"); }
    void cut() { System.out.println("피자 자르기"); }
    void box() { System.out.println("피자 담기"); }
}

// 구상 클래스
public class ClamPizza extends Pizza {
    // 추상팩토리
    PizzaIngredientFactory ingredientFactory;
 
    public ClamPizza(PizzaIngredientFactory ingredientFactory) {
        this.ingredientFactory = ingredientFactory;
    }
 
    void prepare() {
        System.out.println("Preparing " + name);
        dough = ingredientFactory.createDough();
        sauce = ingredientFactory.createSauce();
        cheese = ingredientFactory.createCheese();
        clam = ingredientFactory.createClam();
    }
}
// 추상팩토리 클래스
public interface PizzaIngredientFactory {
    public Dough createDough();
    public Sauce createSauce();
    public Cheese createCheese();
    public Veggies[] createVeggies();
    public Pepperoni createPepperoni();
    public Clams createClam();
}

// 팩토리 클래스, 
public class NYPizzaStore {
    protected Pizza createPizza(String item) {
        Pizza pizza = null;
        // 추상팩토리 클래스
        PizzaIngredientFactory ingredientFactory = new NYPizzaIngredientFactory();

        if (item.equals("cheese")) {
            pizza = new CheesePizza(ingredientFactory);
            pizza.setName("New York Style Cheese Pizza");
        } else if (item.equals("veggie")) {
            pizza = new VeggiePizza(ingredientFactory);
            pizza.setName("New York Style Veggie Pizza");
        } else if (item.equals("clam")) {
            pizza= new ClamPizza(ingredientFactory);
            pizza.setName("New York Style Clam Pizza");
        } else if (item.equals("pepperoni")) {
            pizza = new PepperoniPizza(ingredientFactory);
            pizza.setName("New York Style Pepperoni Pizza");
        } 
        return pizza;
    }
}

Pizza 구상클래스의 그룹화를 통해 최대한 구상클래스 개수를 줄였다.
시카고 지역이 추가된다 하더라도 위 구상클래스에서 추가 구상클래스를 생성할 필요가 없다.

추상 팩토리 패턴은 구상클래스 종류/그룹화/깊이 가 많을수록 사용하면 좋다.

싱글턴 패턴

인스턴스가 하나만 생성되도록 강제하는 패턴
한 클래스의 인스턴스를 2개 이상 만들지 않게 강제하는것이 관건

public class Singleton {
    private static Singleton uniqueInstance;
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }

    public String todo() {
        //TODO
        return "todo";
    }
}

멀티스레드 동기화 문제

멀티스레드가 동시에 Singleton.getInstance() 을 호출하면 인스턴스가 2개 생성될 수 있다.

null 체크 if문을 동시에 진행할 경우 두 스레드 모두 통과될 가능성이 있음

처음부터 인스턴스를 생성하고 시작하면 문제없다.

public class Singleton {
    private static Singleton uniqueInstance = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() { return uniqueInstance; }
}

synchronized 키워드 하나면 해결 가능하다.

public static synchronized Singleton getInstance() {
    if (uniqueInstance == null) {
        uniqueInstance = new Singleton();
    }
    return uniqueInstance;
}

getInstance 호출마다 성능이 떨어짐으로 DCL(Double-Checked Locking) 사용 권장
synchronized 구문을 만날 가능성을 조금이라도 줄인다.

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {}
 
    public static Singleton getInstance() {
        if (uniqueInstance == null) {
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

volatile 키워드는 Java 변수를 Main Memory 에 저장하는 것을 명시
캐시저장을 피해 멀티스레드 동기화에 안정성을 더한다.

enum 을 사용하는 것도 좋은 방법 중 하나.

public enum Singleton {
    UNIQUE_INSTANCE;
 
    public String todo() {
        //TODO
        return "todo";
    }
}

public class SingletonClient {
    public static void main(String[] args) {
        Singleton singleton = Singleton.UNIQUE_INSTANCE;
        System.out.println(singleton.getDescription());
    }
}

역직렬화, 리플렉션 문제

객체 데이터를 바이트 스트림 형태로 외부에 저장해 두었다가 다시 역직렬화 해서 내부로 가져올 때 싱글톤이 깨질 수 있다.

직렬화 관련 메서드인 readResolve() 를 재정의 해서 해결하면 된다.

class Singleton implements Serializable {
    private static final Singleton uniqueInstance = new Singleton();
    private Singleton() {}

    public static Singleton getInstance() { return uniqueInstance; }

    // 역직렬화한 객체는 무시하고 클래스 초기화 때 만들어진 인스턴스를 반환
    private Object readResolve() {
        return uniqueInstance;
    }
}

리플렉션의 경우 클래스 풀네임만 알고 있다면 접근제한자 상관없이 인스턴스 생성이 가능하다.

// Singleton의 Class에서 생성자 흭득
Constructor<Singleton> consructor = Singleton.class.getDeclaredConstructor();

// 접근제한자 무시
consructor.setAccessible(true);

// 가져온 생성자를 이용해 인스턴스화 한다
Singleton singleton1 = consructor.newInstance();
Singleton singleton2 = consructor.newInstance();

싱글톤에 리플렉션 접근시 주의하는 방법밖에 없음

Monostate 패턴

만약 클래스 내부 변수를 보두 static 으로 정의하면 해당 클래스는 하나만 생성되었다 할 수 있다.

public class Monostate {
    private static int itsX = 0;

    public Monostate() {
    }

    public void setX(int x) {
        itsX = x;
    }

    public int getX() {
        return itsX;
    }
}

싱글턴은 private 생성자로 인해 추가 상속이 불가능 하지만,
모노스테이트는 상속이 가능함으로 다형성을 제공한다.

커맨드 패턴

캡슐화된 메서드를 통해 만능메서드 생성이 가능하다.
내부 구현체 상관없이 excute 만 호출하면 하고자하는 비지니스로직이 수행된다.

public interface Command { 
    public void execute(); 
}

수행하고자 하는 커맨드 객체

public class LightOnCommand implements Command { 
    Light light;
    public LightOnCommand(Light light) { this.light = light; }
    public void execute() { light.on(); }
}

public class LightOffCommand implements Command {
    Light light; 
    public LightOffCommand(Light light) { this.light = light; }
    public void execute() { light.off(); }
}

커맨드를 통해 수행하고자 하는 비지니스로직 리시버 객체

public class Light {
    public Light() {}
    public void on() { System.out.println("Light is on"); }
    public void off() { System.out.println("Light is off"); }
}

커맨드를 통해 비지니스 로직을 실행시키는 인보커(Invoker) 객체

public class SimpleRemoteControl {
    Command slot;
    public SimpleRemoteControl() {}
    public void setCommand(Command command) { slot = command; }
    public void buttonWasPressed() { slot.execute(); }
}

리모컨은 buttonWasPressed 만 하면 된다, 해당 버튼(Command)이 Light 관련 커맨드인지 알 필요가 없다.
그냥 버튼을 누르면 등록된 비지니스 로직이 수행된다는 사실만 알면 된다.

public static void main(String[] args) {
    Light light = new Light();
    LightOnCommand lightOn = new LightOnCommand(light);
    SimpleRemoteControl remote = new SimpleRemoteControl();
    remote.setCommand(lightOn);
    remote.buttonWasPressed();
}

요약하면 커맨드 패턴의 객체 종류는 아래 3가지

  • 인보커 객체: 커맨드객체를 실행시키는 객체
  • 커맨드 객체: execute 메서드 구현 객체
  • 리시버 객체: 비지니스 로직을 가지고 있는 객체

인보커가 커맨드를 캡슐화 하고, 커맨드가 리시버를 캡슐화 한다.

클라이언트는 인보커 객체의 실행메서드만 호출시킬줄 알면 모든게 해결되고
커맨드 객체도 리시버를 통해 비지니스 로직을 분리함으로서 같은 커맨드 객체로 다양한 리시버(비지니스 로직)을 할당할 수 있는 유연성을 가진다.

작업 큐 와 같은 시스템에서 응용할 수 있고
작업에 대한 공통 기능, 작업과 관련된 행위들(작업 취소, 기록, 불러오기 등) 은 커맨드 객체 인터페이스를 사용함으로서 실수 없이 구현할 수 있다.

Active Object 패턴

커맨드 패턴중 매우 유명한 패턴중 하나,
스레드 하나를 여러개의 커맨드들이 멀티스레드처럼 나눠 사용할 수 있다

아래와 같이 List 에 커맨드를 담고 빌때까지 실행시킨다.
while 문이 무한루프 돌면서 CPU 부하가 걸리겠지만 효과적으로 싱글스레드에서 돌아가며 처리할 수 있다.

public class ActiveObjectEngine {
    LinkedList<Command> itsCommands = new LinkedList();

    public void addCommand(Command c) {
        itsCommands.add(c);
    }

    public void run() {
        while (!itsCommands.isEmpty()) {
            Command c = itsCommands.removeFirst();
            c.execute();
        }
    }
}

SleepCommand 하나를 추가해서 1초후애 정의한 wakeup 커맨드가 실행되는지를 검증한다.

@Test
void testSleep() throws Exception {
    Command wakeupCommand = new Command() {
        @Override
        public void execute() {
            System.out.println("wake up!");
        }
    };
    Command wakeupSpy = Mockito.spy(wakeupCommand);
    ActiveObjectEngine engine = new ActiveObjectEngine();
    engine.addCommand(new SleepCommand(1000, engine, wakeupSpy));
    long start = System.currentTimeMillis();
    engine.run();
    long stop = System.currentTimeMillis();
    long sleepTime = (stop - start);
    Assertions.assertTrue(sleepTime >= 1000, "SleepTime " + sleepTime + " expected >= 1000");
    Mockito.verify(wakeupSpy, Mockito.times(1)).execute();
}

SleepCommand 클래스는 아래와 같다.
execute 메서드가 실행되면 sleepTime 을 검증하고 시간이 초과되었을 때 wakeup 커맨드를 추가하고, 그외의 경우 모두 자기자신을 다시 넣는다.

public class SleepCommand implements Command {

    private final ActiveObjectEngine engine;
    private final Command wakeupCommand;
    private final long sleepTime;

    private long startTime = 0;
    private boolean started = false;

    public SleepCommand(long milliseconds, ActiveObjectEngine engine, Command wakeupCommand) {
        this.engine = engine;
        this.wakeupCommand = wakeupCommand;
        this.sleepTime = milliseconds;
    }

    @Override
    public void execute() {
        long currentTime = System.currentTimeMillis();
        if (!started) {
            started = true;
            startTime = currentTime;
            engine.addCommand(this);
        } else if ((currentTime - startTime) < sleepTime) {
            engine.addCommand(this);
        } else {
            engine.addCommand(wakeupCommand);
        }
    }
}

아래와 같이 SleepCommand 와 연계하여 n 밀리초 뒤에 char 가 입력되는 DelayedTyper 커맨드를 추가할 수 있다.

@Test
void delayTyperTest() throws Exception {
    ActiveObjectEngine engine = new ActiveObjectEngine();
    engine.addCommand(new DelayedTyper(100, '1', engine));
    engine.addCommand(new DelayedTyper(300, '3', engine));
    engine.addCommand(new DelayedTyper(500, '5', engine));
    engine.addCommand(new DelayedTyper(700, '7', engine));

    Command stopCommand = () -> DelayedTyper.stop = true; // 멈춤 조건
    engine.addCommand(new SleepCommand(20000, engine, stopCommand)); // 20초 뒤에 멈춤
    engine.run();
}
public class DelayedTyper implements Command {
    public static boolean stop = false;
    private final ActiveObjectEngine engine;

    private final long itsDelay;
    private final char itsChar;

    public DelayedTyper(long delay, char c, ActiveObjectEngine engine) {
        this.itsDelay = delay;
        this.itsChar = c;
        this.engine = engine;
    }

    @Override
    public void execute() {
        System.out.print(itsChar);
        if (!stop)
            delayAndRepeat();
    }

    private void delayAndRepeat() {
        engine.addCommand(new SleepCommand(itsDelay, engine, this));
    }
}

어댑터 패턴

한국 전자제품을 영국에서 연결하려면 220V-110V 변경 어뎁터가 필요하다.

소트웨어도 마찬가지

기존 시스템의 통신방식과 연동해야할 서버의 통신방식이 다르다면 그 사이에서 어뎁터 역할을 해줄 소프트웨어가 있어야 한다.

어댑터 패턴(Adapter Pattern) 인터페이스가 호환되지 않아 같이 쓸 수 없었던 클래스를 사용할 수 있게한다.

특정 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다.

image02

[OCP, LSP, DIP] 3가지 원칙을 잘 지키고 있는 패턴이다.

Client 는 여전히 Target 인터페이스로 통신하지만,
Target 을 구현한 Adaptor 가 새로운 통신방식인 Adaptee 로 변환한다.

파사드 패턴

퍼사드(facade): 겉모양이나 외관이라는 뜻.

퍼사드 패턴은 쓰기쉬운 인터페이스를 제공하는것
복잡하게 구현된 클래스 시스템을 훨씬 편리하게 사용할 수 있다.

복잡한 코드를 객체로 감쌓는게 전부이기 때문에 패턴이라고 해야하나 할 정도로 간단한 패턴이다.

사실상 캡슐화 원칙을 잘 지키는 코드.

반복자 패턴

반복자 패턴을 간단히 설명하면 반복해야할 객체가 있다면 Iterator 클래스를 사용하는 것이다.
이미 Iterator 클래스 내부에 반복자 패턴 구현을 위한 추상화 메서드들이 정의되어 있다.

  • hasNext()
  • next()
  • remove()

아래와 같이 배열, 리스트 형태의 회원 클래스가 있고 이를 출력해야 한다면

class MemberName {
    List<Member> mems = new ArrayList<>();
    {
        mems.add(new Member("Kian", 23));
        mems.add(new Member("William", 22));
        mems.add(new Member("Ashton", 24));
        mems.add(new Member("Walker", 21));
        mems.add(new Member("Quentin", 26));
    }
}

class UserName {
    Member[] mems = new Member[5];
    {
        mems[0] = new Member("Ryan", 24);
        mems[1] = new Member("Isaac", 21);
        mems[2] = new Member("Jackson", 22;
        mems[3] = new Member("Joseph", 20);
        mems[4] = new Member("Roderick", 24;
    }
}

다음처럼 Iterator 인터페이스를 사용하도록 제한시키면 된다.

class UserList {
    public Member[] mems = new Member[5];

    {
        mems[0] = new Member("Ryan", 24);
        mems[1] = new Member("Isaac", 21);
        mems[2] = new Member("Jackson", 22);
        mems[3] = new Member("Joseph", 20);
        mems[4] = new Member("Roderick", 24);
    }

    public Iterator<Member> createIterator() {
        // 배열길이가 엄청 길다면 성능이슈가 발생할 수 있음
        return Arrays.stream(mems).iterator();
    }
}

List 의 상위인 Collection 객체는 이미 반복자 패턴을 사용하기 위해 Iterable 을 구현하였기에 별도 작업을 할 필요가 없지만
배열은 반복자 패턴을 사용하지 않기 때문에 createIterator 라는 메서드를 추가정의해준다.

public static void main(String[] args) {
    MemberList members = new MemberList();
    UserList users = new UserList();
    printMember(members.mems.iterator());
    printMember(users.createIterator());
}

public static void printMember(Iterator<Member> memberIterator) {
    while (memberIterator.hasNext())
        System.out.println(memberIterator.next().toString());
}

상황에 맞춰 아래와 같이 별도의 Iterator 구현체를 만들어 사용해도 된다.

public class MemberIterator implements Iterator<Member> {
    ...
}

컴포지트 패턴

객체들의 관계를 복합구조(composite structure) 로 구성, 부분-전체 계층구조(part-whole hierarchy) 로 표현하기 위한 패턴
트리 형태의 구조이다.

image02

복합객체, 잎객체 모두 같은 방법으로 사용할 수 있는 패턴이다

Component: 복합객체, 잎객체를 위한 인터페이스
Composite: 자식이 있는 구성 요소의 행동을 정의하고 자식 구성 요소를 저장하는 복합객체 역할
Leaf: 더이상 자식생성이 불가능한 opertion() 만 할 수 있는 잎객체

위에서 배운 반복자 패턴의 Member 와 이를 포함하는 Group 을 사용해 컴포지트 패턴을 구현한다.


public abstract class MemberComponent {
    public void add(MemberComponent memberComponent) { throw new UnsupportedOperationException(); }
    public void remove(MemberComponent memberComponent) { throw new UnsupportedOperationException(); }
    public MemberComponent getChild(int i) { throw new UnsupportedOperationException(); }
    public String getName() { throw new UnsupportedOperationException(); }
    public Integer getAge() { throw new UnsupportedOperationException(); }
    public void print () { throw new UnsupportedOperationException(); }
}


public class Group extends MemberComponent {
    List<MemberComponent> memberComponent;
    public String name;

    public Group(String name) {
        this.memberComponent = new ArrayList<>();
        this.name = name;
    }

    @Override
    public void add(MemberComponent memberComponent) {
        this.memberComponent.add(memberComponent);
    }

    @Override
    public void remove(MemberComponent memberComponent) {
        this.memberComponent.remove(memberComponent);
    }

    @Override
    public MemberComponent getChild(int i) {
        return this.memberComponent.get(i);
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public void print() {
        System.out.println("-------group name:" + name + "-------");
        for (MemberComponent component : this.memberComponent) {
            component.print();
        }
    }
}

public class Member extends MemberComponent {

    public String name;
    public Integer age;

    public Member(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public void print() {
        System.out.println(name + ":" + age);
    }
}
  • MemberComponent: Component 역할
  • Group: Composite 역할
  • Member: Leaf 역할

메인코드는 아래와 같다.

public static void main(String[] args) {
    MemberComponent internetDept = new Group("Internet Dept");
    MemberComponent planDept = new Group("Plan Dept");
    MemberComponent financeDept = new Group("Finance Dept");
    MemberComponent accountingDept = new Group("Accounting Dept");
    MemberComponent divisionDept = new Group("Division Dept");
    MemberComponent developmentDept = new Group("Development Dept");

    internetDept.add(new Member("Alexiley", 20));
    internetDept.add(new Member("Apricus", 21));
    internetDept.add(planDept);
    internetDept.add(divisionDept);

    planDept.add(new Member("Britzel", 23));
    planDept.add(new Member("Edrose", 25));
    planDept.add(financeDept);
    planDept.add(accountingDept);

    financeDept.add(new Member("Negilla", 23));
    financeDept.add(new Member("Pirion", 27));

    accountingDept.add(new Member("Selmana", 22));

    divisionDept.add(new Member("Treigh", 24));
    divisionDept.add(new Member("Viccus", 28));
    divisionDept.add(developmentDept);

    developmentDept.add(new Member("Xydriel", 24));

    internetDept.print();
    /*
    -------group name:Internet Dept-------
    Alexiley:20
    Apricus:21
    -------group name:Plan Dept-------
    Britzel:23
    Edrose:25
    -------group name:Finance Dept-------
    Negilla:23
    Pirion:27
    -------group name:Accounting Dept-------
    Selmana:22
    -------group name:Division Dept-------
    Treigh:24
    Viccus:28
    -------group name:Development Dept-------
    Xydriel:24
    */
}

MemberComponentMemberGroup 두가지 역할을 하기 때문에 SRP 를 위반했다.
하지만 Composite 객체와 Leaf 객체 모두 투명하게 Component 객체를 의존하고 동일한 방식으로 컨트롤 되게 하는 패턴이다.

위처럼 SRP 를 조금이라도 적용할 수 있게 모든 추상메서드 에 예외를 발생시키는 것도 일종의 방법.

상태패턴

상태패턴은 상태머신에서 주로 사용하는 패턴이다.
같은 메서드를 호출해도 변경된 상태에 따라 다른 클래스처럼 실행된다.

고정된 상태와 고정된 행동이 사전정의되어 있다면
아래처럼 각 상태를 표현할 고정변수 정의로 구현해도 된다.

final static int SOLD_OUT = 0;
final static int NO_QUARTER = 1;
final static int HAS_QUARTER = 2;
final static int SOLD = 3;

그리고 행동을 통해 다른 상태로 변환된다.

insertQuarter(동전삽입) 행동은 NO_QUARTER 상태에서만 동작해야하며
insertQuarter 이후 HAS_QUARTER 상태로 변환된다.

public void insertQuarter() {
    if (state == HAS_QUARTER) {
        System.out.println("You can't insert another quarter");
    } else if (state == NO_QUARTER) {
        state = HAS_QUARTER;
        System.out.println("You inserted a quarter");
    } else if (state == SOLD_OUT) {
        System.out.println("You can't insert a quarter, the machine is sold out");
    } else if (state == SOLD) {
        System.out.println("Please wait, we're already giving you a gumball");
    }
}

이런코드는 상태와 행위가 더이상 추가되는 순산 많은 실수를 유발할 수 있다.

상태패턴은 상태와 행위가 추가되어도 개발자의 실수를 최소화 할 수 있게한다.
아래처럼 상태별 행위를 매칭시킬 인터페이스 정의

public interface State {
    public void insertQuarter();
    public void ejectQuarter();
    public void turnCrank();
    public void dispense();    
    public void refill();
}

위에서 insertQuarter(동전삽입) 은 NO_QUARTER 상태에서만 동작가능하다 했었는데
State 를 상속한 NoQuarterState 코드를 보면 아래와 같다.

public class NoQuarterState implements State {
    GumballMachine gumballMachine;
 
    public NoQuarterState(GumballMachine gumballMachine) { 
        this.gumballMachine = gumballMachine;
    }
 
    public void insertQuarter() {
        System.out.println("You inserted a quarter");
        gumballMachine.setState(gumballMachine.getHasQuarterState());
    }
 
    public void ejectQuarter() { System.out.println("You haven't inserted a quarter"); }
 
    public void turnCrank() { System.out.println("You turned, but there's no  quarter"); }
 
    public void dispense() { System.out.println("You need to pay first"); } 
    
    public void refill() { }
 
    public String toString() {
        return "waiting for quarter";
    }
}

나머지 HasQuarterState SoldOutState SoldState 상태도 NoQuarterState 와 동일하게 행동에 대한 메서드를 구현해주면 된다.

상태머신이 상태 객체들을 사용하는 방법은 아래와 같다.

public class GumballMachine {
    State soldOutState;
    State noQuarterState;
    State hasQuarterState;
    State soldState;
 
    State state; // current state refer
    int count = 0;
 
    public GumballMachine(int numberGumballs) {
        soldOutState = new SoldOutState(this);
        noQuarterState = new NoQuarterState(this);
        hasQuarterState = new HasQuarterState(this);
        soldState = new SoldState(this);

        this.count = numberGumballs;
        // initial state
        if (numberGumballs > 0) state = noQuarterState; 
        else state = soldOutState;
    }
    public void insertQuarter() { state.insertQuarter(); } 
    public void ejectQuarter() { state.ejectQuarter(); }
    ...
}

상태가 추가되면 State 인터페이스 구현클래스를 추가정의하면 되고
행위가 추가되면 State 인터페이스 추강메서드를 추가정의하면 된다.

추가되는 코드는 많을지언정 고정 상수를 사용하는 것 보다는 실수가 덜하다.

프록시 패턴

프록시는 다른 무언가와 이어지는 인터페이스의 역할을 하는 클래스,
중간자 역할을 하는 느낌이 강한 패턴이다.

실제 함수호출 전에 여러가지 부가작업(초기화, 로깅, 캐싱, 흐름제어 등) 을 해야한다면 호출자와 메서드 사이에서 중간자 역할을 해야하는데
프록시 패턴이 해당 역할을 수행할 수 있다.

image02

그림과 같이 목적을 수행하는 DoAction 추상화 메서드를 두고 프록시클래스와 실제 구현클래스를 생성,
Proxy 객체를 통해 RealObject 객체의 DoAction 이 호출되는 구조이다.

public interface Subject {
    void doAction();
}

public class RealSubject implements Subject {
    @Override
    public void doAction() { System.out.println("HelloCoCoWorld!"); }
}

public class Proxy implements Subject {
    private Subject subject;
    @Override
    public void doAction() {
        // 초기화
        if (this.subject == null) {
            this.subject = new RealSubject();
        }
        // TODO
        // 로깅, 캐싱
        subject.doAction();
    }
}

부가적인 기능은 기존코드 수정없이 프록시 객체 추가만으로 구현 가능하다 또한 메인코드에선 Subject 에서 사용하는 구현코드가 얼마나 복잡하던 상관없이 호출만 하면 되기 때문에 하위클래스와의 분리가 가능하다.

동적 프록시

위에선 프록시 클래스를 정의하고 직접 생성해서 RealSubject 로 호출이 이루어졌다.

동적 프록시는 java.lang.reflect.Proxyjava.lang.reflect.InvocationHandler 클래스를 사용하는 방법으로
별도의 프록시 클래스 정의가 필요없고 동적으로 프록시 객체를 생성해서 RealSubject 함수를 호출한다.

image02

Proxy 클래스 생성방식과 InvocationHandler 는 아래와 같다.

// interface 만 매개변수로 사용 가능
public static Object newProxyInstance(
    ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)

public interface InvocationHandler {
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}

클래스로더, 구현해야할 인터페이스, 호출핸들러를 요구한다.

아래처럼 프록시 패턴으로 사용할 Member, 그리고 RealProxyMemberImpl 클래스 정의

// Subject
public interface Member {
    String getName();
    int getAge();
    void setName(String name);
    void setAge(int age);
}
// Real Subject
public class MemberImpl implements Member {
    String name;
    int age;
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getAge() { return age; }
    public void setAge(int age) {  this.age = age; }
}

원래대로라면 프록시로 사용할 Member 인터페이스를 구현한 클래스를 추가정의 해야하지만
아래와 같이 newProxyInstance 함수를 사용해 동적으로 프록시로 사용할 객체를 생성한다.

Member getOwnerProxy(Member member) {
    return (Member) Proxy.newProxyInstance(
        member.getClass().getClassLoader(),
        member.getClass().getInterfaces(),
        new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                System.out.println("method name:" + method.getName());
                // method name:getName
                // method name:getAge
                return method.invoke(member, args);
            }
        });
}

Method reflection API 를 통해 사전에 참조시켜 두었던 RealSubjectMemberImpl 의 메서드에 접근시킨다.

여기선 익명클래스를 사용해 프록시 객체를 생성한다.

Member joe = new MemberImpl();
joe.setName("Joe Javabean");
joe.setAge(7);
Member ownerProxy = getOwnerProxy(joe);
System.out.println("Name is " + ownerProxy.getName()); // Name is Joe Javabean

동적 프록시의 장점은 코드 간소화에 있다.
상당히 많은 유형의 프록시 패턴 클래스를 정의해야할 때 일반 프록시 패턴보단 동적 프록시를 사용하는 것이 코드 중복, 코드 유연성에서 앞선다.

카테고리:

업데이트: