옆히

디자인 패턴의 아름다움 본문

개인공부용1/cs

디자인 패턴의 아름다움

옆집히드라 2024. 7. 14. 08:32

 

디자인 패턴의 아름다움 - 10점
왕정 지음, 김진호 옮김/제이펍

 

chapter 1 개요

#객체 지향

  • 캡슐화(encapsulation): 정보를 숨기고 데이터를 보호
  • 추상화(abstraction): 메서드의 내부 구현을 숨김
  • 상속(inheritance) -> 과도한 사용으로 계층 구조가 깊어지고 복잡해지면 코드의 가독성과 유지 관리성이 떨어짐
  • 다형성(polymorphism): 하위 클래스를 상위 클래스 대신 사용하고, 하위 클래스의 메서드를 호출할 수 있는 특성; 상속과 메서드 재정의를 하는 방식, 인터페이스 문법을 사용, duck-typing 문법(python, javascript 같은 일부 동적 언어)을 사용

#설계 원칙

 

#SOLID 원칙

  1. 단일 책임 원칙(single responsibility principle, SRP)
  2. 개방 폐쇄 원칙(open-closed principle, OCP)
  3. 리스코프 치환 원칙(Liskov substitution, LSP)
  4. 인터페이스 분리 원칙(interface segregation principle ISP)
  5. 의존 역전 원칙(dependency inversion principle, DIP)
  • KISS 원칙(keep it simple principle)
  • YANGNI 원칙(you aren't gonna need it principle)
  • DRY 원칙(don't repeat yourself principle)
  • LoD(law of Demeter)

#디자인 패턴

  • 생성 디자인 패턴: 싱글턴 패턴, 팩터리 패턴(단순 팩터리 패턴, 팩터리 메서드 패턴, 추상 팩터리 패턴 포함), 빌더 패턴, 프로토타입 패턴
  • 구조 디자인 패턴: 프록시 패턴, 데커레이터 패턴, 어댑터 패턴, 브리지 패턴, 퍼사드 패턴, 복합체 패턴, 플라이웨이트 패턴
  • 행동 디자인 패턴: 옵서버 패턴, 템플릿 메서드 패턴, 전략 패턴, 책임 연쇄 패턴, 상태 패턴, 반복자 패턴, 비지터 패턴, 메멘토 패턴, 커맨드 패턴, 인터프리터 패턴, 중재자 패턴

디자인 패턴을 적용하는 목적은 디커플링, 즉 더 나은 코드 구조를 사용하여 단일 책임을 위해 큰 코드 조각을 작은 클래스로 분할하여 코드가 높은 응집도와 낮은 결합도의 특성을 충족하도록 하는 것이다. 생성 디자인 패턴은 사용 코드에서 생성 코드를 분리하는 것이고, 구조 디자인 패턴은 다른 기능 코드를 분리하는 것이고, 행동 디자인 패턴은 다른 행동 코드를 분리하는 것이다. 디커플링의 주요 목적은 코드의 복잡성을 처리하는 것이다. 즉, 복잡한 코드 문제를 해결하기 위해 디자인 패턴이 만들어진다. 우리가 개발하는 코드가 복잡하지 않다면 복잡한 디자인 패턴을 도입할 필요가 없다.


chapter 2 객체지향 프로그래밍 패러다임

#객체지향 프로그래밍 언어란? (엄격한 정의 x)

  1. 객체지향 프로그래밍은 프로그래밍 패러다임 또는 프로그래밍 스타일을 의미한다. 코드를 구성하는 기본 단위로 클래스 또는 객체를 사용하고, 코드 설계와 구현의 초석으로 캡슐화, 추상화, 상속, 다형성의 4가지 특성을 사용한다.
  2. 객체지향 프로그래밍 언어는 클래스 또는 객체 문법을 지원하며, 이 문법은 객체지향 프로그램의 4가지 특성인 캡슐화, 추상화, 상속, 다형성을 쉽게 구현할 수 있다.

프로그래밍 언어의 지속적인 변화와 진화 과정에서 소프트웨어 엔지니어는 상속이 불명확한 수준과 혼란스러운 코드를 쉽게 야기할 수 있음 깨닫고 Go 언어와 같은 많은 프로그래밍 언어는 설계 과정에서 상속 기능을 포기하기도 함

 

#객체지향 설계 프로세스

  1. 책임과 기능을 나누고 어떤 클래스가 있는지 확인한다.
  2. 클래스를 정의하고, 클래스의 속성과 메서드를 정의한다.
  3. 클래스 간의 상호작용을 정의한다.
  4. 클래스를 연결하고 실행 엔트리 포인트를 제공한다.

요구사항 명세에서 동사, 명사 추출 vs 요구사항 명세에 따른 기능 나열 후 유사한 책임 지니거나 동일한 속성 사용하는지 여부에 따라 클래스를 분류

요구사항 분석 -> 요구사항 명세 -> 단일 책임 기능으로 분해 -> 클래스로 구현

 

객체지향 프로그래밍은 본질적으로 반복을 통한 지속적인 최적화 프로세스다. 요구 사항에 따라 먼저 대략적인 설계 계획을 제공한 다음, 이를 기반으로 반복적인 최적화를 수행하여 더 명확한 구현을 해나간다.(실제로 대규모 소프트웨어 개발에 직면하면 요구사항이 더 복잡해지고 관련 기능과 클래스가 기하급수적으로 늘어나므로 복잡한 요구 사항을 개발하기 위해 먼저 모듈을 나누고, 요구 사항을 여러 개의 작고 독립적인 기능 모듈로 나누는 작업이 선행되어야 한다 -> 모듈의 디커플링, 식별과 클래스의 디커플링)

 

#클래스 간의 상호 작용 in UML

  • 일반화(generalization): 단순한 상속 관계
  • 실체화(realization): 인터페이스와 구현 클래스 간의 관계
  • 집합(aggregation): 약한 소유 관계(생명 주기 - 독립적)
  • 합성(composition): 강한 소유 관계(생명 주기 = 종속적)
  • 연관(association): 집합과 합성의 두 가지 속성을 모두 가지는 매우 약한 관계
  • 의존(dependancy): 연관 관계를 포함하며, 연관 관계보다도 더 약한 관계(일시적인 사용 관계)

본 책에서 합성은 집합과 연관을 통합하는 관계로 정의됨

 

소프트웨어 개발은 본질적으로 지속적인 반복, 패치, 문제 발견, 문제 해결의 과정이며, 지속적인 리팩터링의 과정이다. 엄격하게 하나의 단계를 모두 마치고 다음 단계로 넘어가는 것이 불가능하다는 것을 알아야 한다.

 

요구사항이 간단하고 전체적인 작업 흐름이 가지를 뻗어나가지 않고 일직선 형태를 띄는 경우 절차적 프로그래밍 스타일이 유리하나 복잡한 대규모 프로그램을 개발하는 경우 전체 프로그램의 처리 흐름이 복잡하고 작업 흐름이 여러 개로 구성되기 때문에 객체지향 프로그래밍이 훨씬 유리하다 -> 절차적 프로그래밍 방식이 객체지향 프로그램과 완전 반대되는 것이 아니다. 절차지향 프로그래밍 언어를 사용하면서 객체지향 프로그래밍의 장점 중 일부를 채용하기도 함

 

함수형 프로그래밍을 독특하게 만드는 것은 프로그래밍 철학이다. 함수형 프로그래밍에서는 프로그램이 일련의 수학적 함수 또는 표현식의 조합으로 표현될 수 있다고 생각한다. -> 이론적으로 가능은 하지만 대규모 비즈니스 시스템 개발을 할 때 수학적 표현을 통한 추상화와 구현에 비용이 많이 들어가서 쉽지 않음

 

함수형 프로그래밍에서 함수는 stateless function 이라는 점에서 절차적 프로그래밍에서의 stateful function과 다르다. 함수에 포함된 변수는 클래스 멤버 변수를 공유하는 객체지향 프로그래밍과 전역 변수를 공유하는 절차 프로그래밍과 달리 지역 변수다. 함수의 실행 결과는 입력 매개변수에만 관련되며 다른 외부 변수와는 관련이 없다.

 

stream 클래스, 람다 표현식, 함수형 인터페이스

 

#객체지향 프로그래밍처럼 보이지만 실제로는 절차적 프로그래밍

  • getter, setter 메서드 남용: getter 메서드의 반환값이 컬렉션인 경우 내부의 데이터가 수정될 가능성이 있
  • 전역 변수와 전역 메서드 남용: 객체지향 프로그래밍에서 일반적인 전역 변수에는 싱글턴 클래스 객체, 정적 멤버 변수, 상수가 포함되며, 일반적인 전역 메서드에는 정적 메서드가 포함된다.
  • 데이터와 메서드 분리로 클래스 정의

Utils 클래스를 정의하기 전에 다음과 같은 질문에 대해 생각해볼 필요가 있다. Utils 클래스를 별도로 정의해야 하는가? Utils 클래스의 일부 메서드를 다른 클래스로 정의할 수 있는가? 이러한 질문에 답한 후에도 여전히 Utils 클래스를 정의할 필요가 있다고 생각하면 과감하게 정의하면 된다. 객체지향 프로그래밍에서도 절차적 프로그래밍 스타일 코드를 완전히 배제하지는 않는다.

 

#빈약한 도메인 모델(anemic domain model)에 기반한 전통적인 개발 방식은 OOP를 위반하는가?

MVC( model–view–controller, MVC ) 아키텍처 개발 방식은 객체지향 프로그래밍 스타일에 위배될 뿐만 아니라 철저하게 절차적 프로그래밍 스타일에 해당하기 때문에 일부에서는 안티 패턴(anti-pattern)이라고도 한다. 특히 도메인 주도 설계(domain driven design, DDD)의 인기 이후 빈약한 도메인 모델을 기반으로 한 이 전통적인 개발 방식이 비판받기 시작하고, 풍성한 도메인 모델에 기반한 DDD 개발 방식이 옹호되기 시작했다.

 

빈약한 도메인 모델에서 데이터와 비즈니스 논리는 별도의 클래스로 나뉘는 반면, 풍성한 도메인 모델(rich domain model)은 정반대로 데이터와 비즈니스 논리가 하나의 클래스에 포함된다. 따라서 풍성한 도메인 모델은 객체지향 프로그래밍의 캡슐화 특성을 만족하여 전형적인 객체지향 프로그래밍 스타일에 속한다.

 

#추상 클래스와 인터페이스

추상 클래스가 속성과 메서드 구현을 정의할 수 있는데 반해, 인터페이스는 속성을 정의하거나 메서드에 실제 코드 구현을 포함할 수 없다.

 

추상 클래스는 클래스이지만 객체로 인스턴스화할 수 없고, 하위 클래스에서만 상속할 수 있는 특수 클래스다. 상속 관계는  is-a 관계이므로, 클래스의 일종인 추상 클래스도 마찬가지로 is-a 관계다. 이에 반해 인터페이스는 특정 기능이 있음을 나타내는 has-a 관계다(또는 can-do 관계, behave like 관계). 따라서 인터페이스에는 계약(contract)이라는 외형적인 이름이 존재한다.

//일반클래스로 인터페이스를 모방
public class MockInterface {
  protected MockInterface() {} //인스턴스 생성 불가
  
  pulic void funA() {
    throw new MethodUnSupportedException(); //메서드 재정의 필수
  }
}

 

#인터페이스 기반 프로그래밍: 모든 클래스에 대해 인터페이스를 정의해야 할까?

인터페이스 설계 사상을 이해할 때 특정 프로그래밍 언어로 떠올리면 안됨. 이 설계 자체는 자바보다도 먼저 GoF의 디자인 패턴에서 처음 등장함.

 

본질적으로 인터페이스는 프로토콜 또는 규약의 집합으로, 사용자에게 제공되는 기능의 목록이다. 인터페이스는 구현이 아닌 인터페이스 기반이라는 설계 사상에서 프로그래밍 언어의 인터페이스 또는 추상 클래스로 이해될 수 있다.

 

이 설계 사상을 적용하면 코드 품질을 효과적으로 향상시킬 수 있는데, 그 이유는 구현이 아닌 인터페이스 기반의 프로그래밍을 통해 구현과 인터페이스를 분리하고, 불안정한 구현을 직접 노출하는 대신 캡슐화하여 감추고, 안정적인 인터페이스만 노출할 수 있기 때문이다.

 

구현이 아닌 인터페이스에 기반한 프로그래밍이라는 설계 사상을 표현하는 또 다른 방법은 구현이 아닌 추상화에 기반한 프로그래밍이다. 추상화, 탑 레벨 아키텍처, 구현에 영향받지 않는 설계는 코드 유연성을 높여주며, 이후 요구 사항이 변경되더라도 훨씬 더 잘 대응할 수 있게 된다.

좋은 코드 설계는 현재 요구 사항에 유연하게 대응할 수 있을 뿐만 아니라 이후 요구 사항이 변경될 때조차도 기존의 코드 설계를 훼손하지 않고 유연하게 대응하는 것이다. 추상화는 코드의 확장성, 유연성, 유지 보수성을 향상시키는 효과적인 수단이다.

 

실제로 인터페이스를 정의할 때 클래스를 먼저 구현하고 그에 맞추어 인터페이스를 정의하는 경우가 적지 않다. 하지만 이런 식으로 작업하게 되면 추상화가 충분히 이루어지지 않을뿐더러, 인터페이스 정의가 구체적인 구현에 의존하는 좋지 않은 형태로 고차고딜 가능성이 매우 높으며, 그러한 인터페이스 설계는 의미가 없다.

 

코드를 작성할 때 추상화, 캡슐화, 인터페이스에 대해 항상 정확히 인식해야 한다. 인터페이스 정의는 구현 세부 정보를 노출하지 않으며, 구체적인 수행 방법이 아닌, 어떤 작업을 수행하는지만 고려한다. 또한 인터페이스를 설계할 때 현재 인터페이스 설계가 보편적인지, 인터페이스 정의를 변경하지 않고도 다르게 구현할 수 있는지 신중하게 고려해야 한다.

 

#인터페이스의 남용을 방지하려면 어떻게 해야 할까?

구현이 아닌 인터페이스 기반 설계 사상의 원래 의도는 구현에서 인터페이스를 분리하고 불안정한 구현을 캡슐화하고 안정적인 인터페이스를 노출하는 것이다. 업스트림 시스템은 구현 프로그래밍이 아닌 인터페이스 지향적이며 불안정한 구현 세부 사항에 의존하지 않는다. 이렇게 하면 구현이 변경될 때 업스트림 시스템의 코드를 기본적으로 변경할 필요가 없으므로 코드의 확장성이 향상된다.

이 설계 사상의 원래 의도를 생각해보면, 비즈니스 시나리오에서 특정 기능에 대한 구현 방법이 하나뿐이고, 이후에도 다른 구현 방법으로 대체할 일이 없다면 인터페이스를 정의할 필요가 없다.

 

함수는 그 자체로 구현 세부 사항을 캡슐화한 추상화이기도 하다. 함수의 정의가 충분히 추상적이라면, 인터페이스가 없어도 구현이 아닌 추상화 사상을 만족할 수 있다.

#상속보다 합성

복잡한 상속 관계는 코드의 가독성을 극도로 떨어뜨리는데, 클래스에 포함된 메서드와 속성을 파악하려면 상위 클래스의 코드뿐만 아니라 상속을 거슬러 올라가 모든 상위 클래스를 파악해야 하기 때문이다. 또한 이는 상위 클래스의 구현 세부 정보를 하위 클래스에 노출하게 되므로 클래스의 캡슐화 특성을 깨뜨리는 문제도 발생한다. 이때 하위 클래스의 구현은 상위 클래스의 구현에 따라 달라진다. 다시 말해 상위 클래스와 하위 클래스는 밀접하게 결합되어 있기 때문에, 상위 클래스의 코드가 수정되면 모든 하위 클래스에 영향을 미치게 된다.

 

상속 -> 갈수록 계층이 깊고 관계가 복잡해져서 코드의 가독성과 유지 보수성에 영향을 미친다

 

상속 문제는 합성(composition), 인터페이스, 위임(delegation)이라는 세 가지 기술적 방법을 통해 해결 가능

public interface IFlyable
{
    void fly();
}

public class Flyability: IFlyable
{
    public void fly()
    {
        Console.WriteLine("I am flying");
    }
}

public interface ITweetable
{
    void tweet();
}

public class Tweetability : ITweetable
{
    public void tweet()
    {
        Console.WriteLine("I am tweeting");
    }
}

public class tarrot: IFlyable, ITweetable
{
    private Flyability flyability = new Flyability(); //합성
    private Tweetability tweetability = new Tweetability(); //합성

    public void fly()
    {
        flyability.fly(); //위임
    }

    public void tweet()
    {
        tweetability.tweet(); //위임
    }
}

 

is-a 관계는 합성과 인터페이스의 has-a 관계로 대체될 수 있고, 다형성은 인터페이스를 사용하여 달성될 수 있으며, 코드 재사용은 합성과 위임으로 목적을 달성 할 수 있다. 이론적으로 합성, 인터페이스, 위임의 세 가지 기술 수단은 상속을 완전히 대체할 수 있다.

 

#합성을 사용할지 상속을 사용할지 결정하기

클래스 간의 상속 구조가 안정적이어서 쉽게 변경되지 않고 상속 단계가 2단계 이하로 비교적 얕아 상속 관계가 복잡하지 않다면 과감하게 상속을 사용할 수 있다. 반대로 시스템이 불안정하고 상속 계층이 깊고 상속 관계가 복잡하면 상속 대신 합성을 사용해야 한다.

 

하지만 일부 특수한 시나리오에서는 상속을 사용해야 하는 경우가 있다. 함수의 입력 매개변수 유형을 변경할 수 없고 입력 매개변수가 인터페이스가 아닌 경우 상속을 사용해야만 다형성을 지원할 수 있다.

using System;

namespace InheritanceExample
{
    // 기본 클래스
    public class Animal
    {
        public virtual void Speak()
        {
            Console.WriteLine("Animal sound");
        }
    }

    // 기본 클래스를 상속한 서브 클래스
    public class Dog : Animal
    {
        public override void Speak()
        {
            Console.WriteLine("Woof");
        }
    }

    // 기본 클래스를 상속한 또 다른 서브 클래스
    public class Cat : Animal
    {
        public override void Speak()
        {
            Console.WriteLine("Meow");
        }
    }

    class Program
    {
        // Animal 타입을 매개변수로 받는 함수
        static void MakeAnimalSpeak(Animal animal)
        {// 함수의 매개변수 타입을 변경할 수 없는 상황에서 입력 매개변수가 
         // 인터페이스가 아닐 때 상속을 사용해 다형성을 구현한 예
            animal.Speak();
        }

        static void Main(string[] args)
        {
            // Animal 타입의 객체 생성
            Animal myDog = new Dog();
            Animal myCat = new Cat();

            // 상속을 통해 다형성을 지원하여 다른 Animal 객체들을 전달할 수 있음
            MakeAnimalSpeak(myDog); // 출력: Woof
            MakeAnimalSpeak(myCat); // 출력: Meow
        }
    }
}

 

합성으로 구현하면 더 많은 클래스와 인터페이스를 정의해야 해서 코드의 복잡성과 유지 관리 비용이 증가한다는 단점도 있음. 상속과 잘 선택해서 골라야 함


 

chapter 3 설계 원칙

SOLID, KISS, YANGNI, DRY, LoD

#SOLID

  • 단일 책임 원칙(SRP)
  • 개방 폐쇄 원칙(OCP)
  • 리스코프 치환 원칙
  • 인터페이스 분리 원칙
  • 의존 역전 원칙

#단일 책임 원칙(SOLID 원칙: single responsibility principle, SRP)

클래스와 모듈은 하나의 책임 또는 기능만을 가지고 있어야 한다.(A class or module should have a single responsibility)

 

#단일 책임 여부를 결정하기 위한 몇가지 원칙

  1. 클래스에 코드, 함수 또는 속성이 너무 많아 코드의 가독성과 유지 보수성에 영향을 미치는 경우 클래스 분할을 고려해야 한다.
  2. 클래스가 너무 과하게 다른 클래스에 의존한다면, 높은 응집도와 낮은 결합도의 코드 설계 사상에 부합하지 않으므로 클래스 분할을 고려해야 한다.
  3. 클래스에 private 메서드가 너무 많은 경우 이 private 메서드를 새로운 클래스로 분리하고 더 많은 클래에서 사용할 수 있도록 public 메서드로 설정하여 코드의 재사용성을 향상시켜야 한다.
  4. 클래스의  이름을 비즈니스적으로 정확하게 지정하기 어렵거나 Manager, Context처럼 일반적인 단어가 아니면 클래스의 이름을 정의하기 어려울 경우, 클래스 책임 정의가 충분히 명확하지 않음을 의미할 수 있다.
  5. UserInfo 클래스의 많은 메서드가 주소를 위해서만 구현된 앞의 예시처럼 클래스의 많은 메서드가 여러 속성 중 일부에서만 작동하는 경우 이러한 속성과 해당 메서드를 분할하는 것을 고려할 수 있다.

어떤 설계 원칙을 적용하든 디자인 패턴을 적용하든 그 목표는 코드의 가독성, 확장성, 재사용성, 유지 보수성을 향상시키는 것이다.

#오픈 개방 원칙(open-closed principle, OCP)

확장할 때는 개방, 수정할 때는 폐쇄 원칙으로도 불린다. 확장성이 코드 품질의 중요한 척도이기 때문에 가장 유용

 

개방 폐쇄 원칙을 이해하기 어려운 이유는 코드를 변경할 때 그 결과를 확장으로 보아야 하는지, 수정으로 보아야 하는지 명확하게 구분하기 어렵기 때문이다. ... UserInfo class 파서 변수 부분 캡슐화, 핸들러 클래스 시스템 도입

 

새로운 기능을 추가할 때 소프트웨어 단위에 해당하는 모듈, 클래스, 메서드의 코드를 전혀 수정하지 않는 것은 불가능하다는 것을 인지해야 한다. 실행 가능한 프로그램을 빌드하려면 클래스를 생성하고 조합해야 하며, 일부 초기화 작업을 수행해야 하기 때문에 이 부분에 해당하는 코드 수정은 불가피하다. 따라서 우리는 수정을 아예 안 하는 것이 아니라 수정을 가능한 한 상위 수준의 코드에서 진행하고, 코드의 핵심 부분이나 복잡한 부분, 공통 코드나 기반 코드가 개방 폐쇄 원칙을 충족하는 방향으로 노력해야 한다.

 

코드의 변경 가능한 부분과 변경할 수 없는 부분을 잘 식별해야 한다. 변경되는 사항을 기존 코드와 분리할 수 있도록 변수 부분을 캡슐화하고, 상위 시스템에서 사용되는 변경되지 않을 추상 인터페이스를 제공해야 한다. 이 구조에서는 특정 구현이 변경되어도 추상 인터페이스를 기반으로 새로운 구현을 확장하여 기존 구현을 대체할 수 있으며, 상위 시스템의 코드를 수정할 필요가 없다.

 

코드의 확장성을 개선하기 위해 설계 원칙과 디자인 패턴에서 많이 사용되고 있는 방법에는 다형성, 의존성 주입, 구현이 아닌 인터페이스 기반의 프로그래밍이 있으며, 이는 전략 패턴, 템플릿 메서드 패턴, 책임 연쇄 패턴과 같은 대부분의 디자인 패턴에서 볼 수 있다.

 

개방 폐쇄 원칙은 '공짜'가 아니다. 가독성을 희생함. 코드의 확장성이 더 중요한 일부 시나리오에는 코드의 가독성을 일부 희생할 필요가 있다. 

#리스코프 치환 원칙(Liskov substituion principle, LSP)

만약 S가 T의 하위 유형인 경우, T 유형의 객체는 프로그램을 중단하지 않고도 S 유형의 객체로 대체될 수 있다.

if S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program.

 

기본 클래스에서 참조 포인터를 사용하는 함수는 특별히 인지하지 않고도 파생 클래스의 객체를 사용할 수 있어야 한다.

Functions that use pointers pf references to base classes must be able to use objects of derived classes without knowing it.

 

-> 하위 유형 또는 파생 클래스의 객체는 프로그램 내에서 상위 클래스가 나타나는 모든 상황에서 대체 가능하며, 프로그램이 원래 가지는 논리적인 동작이 변경되지 않으며 정확성도 유지된다.

 

리스코프 원칙에는 좀 더 이해하기 쉬운 설명 방식이 있는데, 바로 계약에 따른 설계(design by contract)라는 표현이다. 하위 클래스를 설계할 때는 상위 클래스의 동작 규칙을 따라야 한다. 상위 클래스는 함수의 동작 규칙을 정의하고 하위 클래스는 함수의 내부 구현 논리를 변경할 수 있지만 함수의 원래 동작 규칙은 변경할 수 없다.

여기서 말하는 동작 규칙에는 함수가 구현하기 위해 선언한 것, 입력, 출력, 예외에 대한 규칙, 주석에 나열된 모든 특수 사례 설명이 포함된다. 이곳에서 상위 클래스-하위 클래스 관계는 인터페이스-구현 클래스 간의 관계로도 대체 가능하다.

 

#LSP에 위반되는 안티패턴

  • 하위 클래스가 구현하려는 상위 클래스에서 선언한 기능을 위반하는 경우
  • 하위 클래스가 입력, 출력 및 예외에 대한 상위 클래스의 계약을 위반하는 경우
  • 하위 클래스가 상위 클래스의 주석에 나열된 특별 지침을 위반하는 경우

#인터페이스 분리 원칙(interface segregation principle, ISP)

클라이언트는 필요하지 않은 인터페이스를 사용하도록 강요되어서는 안 된다.

Clients should not be forced to depend upon interfaces that they do not use.

 

여기서 클라이언트는 인터페이스 호출자나 사용자로 이해하면 된다.

 

#인터페이스의 의미

  1. API나 기능의 집합
  2. 단일 API 또는 기능
  3. 객체지향 프로그래밍의 인터페이스

마이크로 서비스의 인터페이스, 클래스 라이브러리 기능을 설계할 때, 인터페이스 또는 기능의 일부가 호출자 중 일부에만 사용되거나 전혀 사용되지 않는다면 불필요한 항목을 강요하는 대신, 인터페이스나 기능에서 해당 부분을 분리하여 해당 호출자에게 별도로 제공해야 하며, 사용하지 않는 인터페이스나 기능에는 접근하지 못하게 해야 한다.

 

호출자가 인터페이스의 일부 또는 그 기능의 일부만 사용하는 경우 해당 인터페이스 설계는 단일 책임 원칙을 충족하지 않는다고 말할 수 있다.

 

#의존 역전 원칙(dependency inversion principle, DIP)

ABOUT.Series (12) IoC (Inversion of Control; 제어의 역전) & DI (Dependency Injection; 의존성 주입) — THE DEVELOPER (tistory.com)

#제어 반전(inversion of control)

//테스트 프레임워크
public abstract class TestCase
{
    public void run()
    {
        if(doTest()){
            System.out.println("Test succeed.");
            }else{
                System.out.println("Test failed");
            }
        }
    //특정 클래스를 테스트하려면 TestCase 클래스의 실행 흐름을 담당하는 main() 함수를 직접 작성할 필요 없이
    //프레임워크에서 제공하는 확장 포인트인 doTest() 추상 메서드에 테스트 코드를 채우기만 하면 된다.
    public abstract boolean doTest();
}

public class JunitApplication
{
    private static final List<TestCase> testCases = new ArrayList();
    
    public static void register(TestCase testCase)
    {
        testCase.add(testCase)
    }
    
    public static final void main(String[] args)
    {
        for (TestCase testCase: testCases) {
            testCase.run();
        }
    }
}

 

제어는 프로그램의 실행 흐름을 제어하는 것을 의미하며, 역전이 되는 대상은 프레임워크를 사용하기 전에 직접 작성했던 전체 프로그램 흐름을 제어하는 코드다. 프레임워크를 사용한 후 전체 프로그램의 실행 흐름은 프레임워크에 의해 제어되고, 흐름의 제어는 프로그래머에서 프레임워크로 역전되는 것이다.

public class UserServiceTest extends TestCase
{
    @Override
    public boolean doTest() { ... }
}
//명시적으로 register()를 호출하여 등록하는 대신 설정을 통해 구현할 수도 있다.
JunitApplication.register(new UserServiceTest());

 

위의 예제 코드는 프레임워크를 통해 구현한 제어 반전의 일반적인 형태이다. 프레임워크는 객체를 조합하고 전체 실행 흐름을 관리하기 위한 확장 가능한 코드 골격을 제공한다. 프로그래머가 프레임워크를 사용할 때는 확장 포인트에 비즈니스 코드를 작성하는 것만으로 전체 프로그램이 실행된다.

 

#의존성 주입 프레임워크(dependency injection framework)

객체 생성과 의존성 주입은 비즈니스 논리에 속하지 않기 때문에 프레임워크에 의해 자동으로 완성되는 코드 형태로 완전히 추상화될 수 있다. 이러한 프레임워크를 의존성 주입 프레임워크라 한다.

의존성 주입 프레임워크를 사용하면 생성해야 하는 모든 클래스 객체와 클래스 간의 의존성을 간단히 구성할 수 있으며, 이를 통해 프레임워크가 자동으로 객체를 생성하고, 객체의 라이프 사이클을 관리하고, 의존성 주입을 할 수 있다.

 

#의존성 역전 원칙

상위 모듈은 하위 모듈에 의존하지 않아야 하며, 추상화에 의존해야만 한다. 또한 추상화가 세부 사항에 의존하는 것이 아니라, 세부 사항이 추상화에 의존해야 한다.

High-level modules shouldn't depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn't depend on details. Details depend on abstractions.

 

상위 모듈 = 호출자, 하위 모듈 - 수신

#KISS 원칙과 YANGNI 원칙

#KISS(Keep It Simple and Straight forward) 원칙

#KISS 원칙을 만족하는 코드 작성 방법

  • 복잡한 정규표현식, 프로그래밍 언에서 제공하는 지나치게 높은 레벨의 코드 등 지나치게 복잡한 기술을 사용하여 코드를 구현하지 않는다.
  • '바퀴를 다시 발명(reinvent the wheel)'하는 대신 기존 라이브러리를 사용하는 것을 고려한다. 라이브러리의 기능을 직접 구현하면 버그가 발생할 확률이 높아지고 유지 관리 비용도 덩달아 높아진다.
  • 과도하게 최적화하지 않는다. 코드를 최적화하기 위해 산술 연산 대신 비트 연산을 사용하거나 if-else 대신 복잡한 조건문을 사용하는 것을 최소화 한다.

#YANGNI(You Are Not Gonna Need It) 원칙

현재 사용되지 않는 기능을 설계하지 말고 현재 사용되지 않는 코드를 작성하지 않는다. 과도한 설계 X

 

#DRY 원칙

#DRY(Don't Repeat Yourself) 원칙

중복 코드를 작성하지 말라

 

코드 논리의 중복: 논리는 동일하지만 의미가 다르면 DRY 원칙에 위배되지 않음

기능적(의미론적) 중복: 코드 논리가 중복되지 않아도 의미적인 중복, 즉 기능이 중복되면 DRY 원칙에 위배됨

코드 실행의 중복

 

#코드 재사용성

  • 코드의 결합도를 줄인다: 결합도가 높은 코드는 코드의 재사용성에 영향을 미치며, 이를 피하기 위해 코드의 결합도를 최소화할 필요가 있다.
  • 단일 책임 원칙을 충족시켜야 한다. 모듈이나 클래스의 책임이 충분히 단일하지 않은데, 설계가 거대하다면 해당 모듈이나 클래스에 의존하는 더 많은 코드가 있을 것이며, 반대로 이 모듈이나 클래스도 다른 코드에 많이 의존하고 있을 것이다. 이는 코드의 결합도를 증가시키고 재사용성에 영향을 미친다. 또한 코드의 단위가 작을수록 범용성이 향상되고, 재사용성이 더 쉬워진다.
  • 코드의 모듈화는 필수다: 여기서의 모듈은 클래스의 모음뿐만 아니라 단일 클래스나 함수로도 이해 가능
  • 비즈니스 논리와 비즈니스 논리가 아닌 부분을 분리할 필요가 있다: 호출관계의 혼동을 피하기 위해 상위 계층의 코드가 하위 계층의 코드를 호출하는 작업과 동일 계층의 코드끼리 호출하는 작업만 허용하고, 하위 계층의 코드가 상위 계층의 코드를 호출하는 작업을 금지한다.
  • 일반적인 코드는 하위 계층으로 내려보낸다: 
  • 상속, 다형성, 추상화, 캡슐화를 활용한다: 
  • 애플리케이션 템플릿과 같은 디자인 패턴을 활용하면 코드 재사용성을 향상시킬 수 있다: 

명시적인 재사용 요구 사항이 없다면, 현재 불필요한 재사용 요구 사항에 대해 너무 많은 개발 리소스를 투자하는 것은 권장되지 않으며 이는 앞서 이야기한 YANGNI 원칙에도 위배된다.

 

3의 법칙(rule of three): 1, 2번째는 그냥 하고 3번 째부터 리팩토링

 

#데메테르의 법칙(Law of Demeter, LoD)

높은 응집도(high cohesion), 낮은 응집도(loose coupling) 달성에 도움이 되는 설계 원칙

 

높은 응집도는 클래스 자체의 설계에 사용된다. 즉, 유사한 기능은 동일한 클래스에 배치되어야 하고, 유사하지 않은 기능은 다른 클래스로 분리해야 함을 의미한다.

 

낮은 결합도는 클래스 간의 의존성 설계에 사용되는데, 코드에서 클래스 간의 의존성이 단순하고 명확해야 함을 의미한다.

 

응집도와 결합도는 완전히 독립적이지 않기 때문에, 높은 응집도는 낮은 결합도를 이끌어내며, 반면에 낮은 결합도는 높은 응집도로 이어진다.

 

#LoD

LoD는 최소 지식의 원칙(the least knowledge principle)이라고도 함

최소 지식의 원칙은 모든 유닛이 자신과 매우 밀접하게 관련된 유닛에 대해서 제한된 지식만 알아야 한다. 또는 모든 유닛은 자신의 친구들에게만 이야기해야 하며, 알지 못하는 유닛과는 이야기하면 안 된다.

Each unit should have only limited knowledge about other units: only  units "closely" related to the current unit. Or: Each unit should only talk to its friends; Don't talk to strangers.

 

직접 의존성이 없어야 하는 클래스 사이에는 반드시 의존성이 없어야 하며, 의존성이 있는 클래스는 필요한 인터페이스에만 의존해야 한다.

 

직렬화와 역직렬화 클래스를 설계하는 케이스에서 높은 응집도를 유지하면서 직렬화, 역직렬화를 별개로 적용해야 하는 케이스의 경우 사용가능한 방법 -> 두 개의 인터페이스를 도입

 

public interface Serializable {
    String serialize(Object object);
}

public interface Deserializable {
    Object deserializable(String text);
}

public class Serialization implements Serializable, Deserializable {
    @Override
    public String serialize(Object object) {
        String serializedResult = ...;
        ...
        return serializedResult;
    }
    
    @Override
    public Object deserialize(String str) {
        Object deserializedResult = ...;
        ...
        return deserializedResult;
    }
}

public class DemoClass_1 {
    //Serializable 인터페이스만 의존하므로 사용하지 않는 역직렬화 함수에 접근이 불가함(인지조차 불가)
    //인터페이스 분리 원칙이 지켜짐
    private Serializable serializer;
    
    public Demo(Serialzable serializer) {
        this.serializer = serializer;
    }
    ...
}

public class DemoClass_2 {
    private Deserializable deserializer;
    
    public Demo(Deserialzable deserializer) {
        this.deserializer = deserializer;
    }
    ...
}

 

Serialization 클래스 처럼 두 가지 기능만 가지고 있는 단순한 경우 굳이 두 개의 인터페이스로 나누지 않고도 구현이 가능하긴 함


chapter 4 코딩 규칙

명명(naming)과 주석(comment), 코드 스타일(code style), 코딩 팁(coding tip) 다룸

 

#문맥 정보를 사용한 명명 단순화 -> user 클래스의 string name field의 경우 userName이라 짓지 말고 name으로 축약 가능

 

#명명은 정확하지만 추상적이어야 한다.

 

#클래스의 멤버 순서: 멤버 -> 함수; public -> protected -> private

 

#일반적으로 함수 매개 변수는 5개 이상이면 너무 많다고 말할 수 있다 -> 기능 분리 or 매개 변수 캡슐화

 

#함수의 플래그 매개변수 제거: 일반적인 경우 true, false 일 때 실행하는 코드가 다르면 단일 책임 원칙과 인터페이스 분리 원칙에 위배됨 -> 플래그 매개변수를 제거하고 함수 분리 필요

 

#깊은 중첩 코드 제거: if-else, switch-case, for 반복문의 과도한 중첩으로 인해 깊은 중첩이 발생할 수 있다. 일반적으로 중첩은 2단계를 넘지 않는 것이 좋으며, 중첩이 2단계를 초과하는 경우 중첩 단계의 수를 줄이는 방법을 찾자

 

#설명 변수(explanatory variable) 사용 

  1. 매직 넘버 대신 상수를 사용
  2. 설명 변수를 사용하여 복잡한 표현을 설명한다
if(data.after(SUMMER_START) && date.before(SUMMER_END)) { ... } else { ... }

//설명 변수 도입
boolean isSummer = data.after(SUMMER_START) && date.before(SUMMER_END);
if(isSummer) { ... } else { ... }

 


chapter 5 리팩터링 기법

리팩터링은 코드에 대한 이해를 쉽게 하기 위해 소프트웨어의 내부 구조를 개선하는 것으로, 소프트웨어의 외부 동작을 변경하지 않고 수정 비용을 줄이는 것을 목적으로 한다.

 

이때 외부 동작은 상대적인 것으로, 함수를 리팩터링한다면 함수의 정의가 외부적인 동작에 해당하고, 클래스 라이브러리를 리팩터링한다면 클래스 라이버러리에 의해 노출된 API나 메서드가 외부적인 동작에 해당한다.

 

 지속적인 리팩터링에 대한 개념을 항상 머리 속에 넣고 있어야 한다. 단위 테스트와 코드 리뷰(code review)를 개발의 일부로 취급하는 것처럼 지속적인 리팩터링을 개발의 일부로 다루야 한다. 지속적인 리팩터링이 개발 습관이 되고 팀 내에서 합의가 이루어진다면 코드 품질은 보장된다.

 

통합 테스트는 요청을 시작하여 코드가 실행 결과를 반환하는 전체 경로(end of end)에 대한 테스트이며, 테스트 대상은 전체 시스템이나 사용자 등록, 로그인 기능과 같은 기능 단위의 모듈이다. 반면에 단위 테스트는 코드 수준의 테스트이며 테스트 대상은 클래스 또는 함수로 제한되어, 해당 대상이 예상대로 실행되는지를 테스트하는 방법이다.

 

#단위 테스트 코드를 작성하는 이유

  • 단위 테스트는 프로그래머가 코드에서 버그를 찾는 데 도움이 될 수 있다
  • 단위 테스트는 프로그래머가 코드 설계에서 문제를 찾는 데 도움이 될 수 있다
  • 단위 테스트는 통합 테스트를 보완하는 강력한 도구다
  • 단위 테스트 코드를 작성하는 과정은 코드 리팩터링 과정에 해당한다
  • 단위 테스트는 프로그래머가 코드에 빠르게 익숙해지도록 도와준다: 단위 테스트 사례는 코드가 수행하는 작업과 사용 방법을 반영하는 사용자 사례에 해당한다
  • 단위 테스트는 테스트 주도 개발을 개선하고 대체 할 수 있다: 테스트 주도 개발(test-driven development, TDD)은 자주 언급되지만 거의 구현되지 않는 개발 패턴으로, 이 패턴의 핵심 사상은 테스트 케이스가 코드보다 먼저 작성된다는 것이다.

단위 테스트는 코드에 대한 다양한 입력, 예외, 경계 조건을 다루는 테스트 케이스를 설계하고 테스트 케이스를 코드로 변환하는 프로세스

 

테스트가 불가능한 코드 -> 보류 중인 동작, 전역 변수, 정적 메서드, 복잡한 상속 관계

 

#디커플링

#코드 디커플링 방법

  • 캡슐화와 추상화로 디커플링하기
  • 중간 계층으로 디커플링하기
  • 모듈화와 계층화로 디커플링하기

#고전적인 코드 설계 원칙과 사상을 이용한 디커플링

  • 단일 책임 원칙
  • 구현이 아닌 인터페이스 기반의 프로그래밍
  • 의존성 주입
  • 상속보다는 합성을 더 많이 사용
  • LoD를 따르는 것

 

#예외 처리를 위한 리팩터링

  1. 오류 코드 반환
  2. null 반환
  3. 비어 있는 객체 반환(null 대신 빈 객체) -> null 판단을 피할 수 있다
  4. 예외 처리

#위 4번 예외처리

1) 직접 catch를 통해 처리하고 상위 코드에 전파하지 않음 

2) 상위 함수에 예외를 그대로 전달함(throw)

3) 발생한 예외를 다시 새로운 예외로 감싼 후 상위 함수에 전달함

 

현재 가장 많이 사용되는 함수의 오류 처리 방법은 예외를 발생시키는 것이다. 예외는 함수 호출 스택 정보와 같은 더 많은 오류 정보를 전달할 수 있고 일반 논리와 예외 논리의 처리를 분리할 수 있어 코드의 가독성 향상에 큰 도움이 된다.

 

compile exception(checked exception), runtime exception(unchecked exception)

참고로 C#에서는 모든 예외가 runtime exception로 처리되므로 컴파일 시점에서 예외 처리를 강제하지 않는다.

 


생성 디자인 패턴이 주로 객체의 생성에 관련된 문제를 해결하고, 구조 디자인 패턴은 주로 클래스나 객체의 결합 문제를 해결한다. 행동 디자인 패턴은 주로 클래스나 객체 간의 상호 작용 문제를 해결한다.(11개) 22개중 절반


생성 디자인 패턴 - 싱글턴 패턴(전역적으로 유일한 객체 생성), 팩토리 패턴(같은 상위 클래스나 인터페이스를 상속하는 하위 클래스와 같이 비록 유형은 다르지만 서로 관련되어 있는 객체를 주어진 객체 타입에 맞게 생성하는 데 사용), 빌더 패턴(복잡한 객체를 생성하는데 사용, 서로 다른 선택적 매개변수를 설정하여 다양한 객체를 사용자 정의 가능함)

 

구조 디자인 패턴 - 프록시 패턴(주로 원본 클래스에 연관 없는 기능을 추가할 때 사용), 데커레이터 패턴(주로 원본 클래스와 관련이 있거나 향상된 기능을 추가하는 데 사용), 어댑터 패턴(코드 호환성 문제를 해결하는 데 사용), 브리지 패턴(합성의 폭발 문제를 해결하는 데 사용), 퍼사드 패턴(인터페이스 설계에 사용), 복합체 패턴(주로 트리 구조로 나타낼 수 있는 데이터에 사용)

 

행동 디자인 패턴 - 옵서버 패턴, 템플릿 메서드 패턴, 전략 패턴, 책임 연쇄 패턴, 상태 패턴, 반복자 패턴, 비지터 패턴, 메멘토 패턴, 커맨드 패턴, 인터프리터 패턴, 중재자 패턴

chapter 6 생성 디자인 패턴

생성 디자인 패턴은 주로 객체 생성 문제를 해결하고 복잡한 생성 프로세스를 캡슐화하며 객체의 생성 코드와 사용 코드를 분리한다. 싱글턴 패턴은 전역적으로 유일한 객체를 생성하는데 사용되며, 팩터리 패턴은 같은 상위 클래스나 인터페이스를 상속하는 하위 클래스와 같이 비록 유형은 다르지만 서로 관련되어 있는 객체를 주어진 객체 타입에 맞게 생성하는 데 사용된다. 빌더 패턴은 복잡한 객체를 생성하는 데 사용되는데, 서로 다른 선택적 매개변수를 설정하여 다양한 객체를 사용자 정의할 수 있다. 프로토타입 패턴은 기존 객체를 복사하는 방법을 사용하여 생성 비용이 높은 객체를 생성하는 시간을 절약한다.

#싱글턴 패턴(singleton pattern)

#java에서 싱글턴 구현 방식

  1. 생성자는 new 예약어를 통한 인스턴스 생성을 피하기 위해 private 접근 권한을 가지고 있어야 한다.
  2. 객체가 생성될 때 스레드 안전성을 보장하는지 확인해야 한다.
  3. 지연 로딩을 지원하는지 여부를 확인해야 한다.
  4. getInstance() 함수의 성능이 충분해야 한다.

#즉시 초기화(eager initialization)

인스턴스는 클래스가 메모리에 적재될 때 이미 생성되어 초기화가 완료되기 때문에, 인스턴스 생성 프로세스는 스레드가 안전하다고 보장 가능함. 지연 적재(lazy loading) 지원 안함

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static final IdGenerator instance = new IdGenerator();
    
    private IdGenerator() {}
    public static IdGenerator getInstance(){
        return instance;
    }
    
    public long getId() {
        return id.incrementAndGet();
    }
}

 

#늦은 초기화(lazy initialization)

지연 적재를 지원하기 때문에, 인스턴스의 생성과 초기화가 실제로 사용되기 전까지 일어나지 않는다.

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static final IdGenerator instance;
    
    private IdGenerator() {}
    
    public static syncronized IdGenerator getInstance(){
        if(instance == null) {
            instance = new IdGenerator();
        }
        return instance;
    }
    
    public long getId() {
        return id.incrementAndGet();
    }
}

 

인스턴스가 많은 리소스를 차지한다면, 문제가 있다면 빨리 노출시키는 fail-fast 설계 원칙에 따라 프로그램이 시작될 때 인스턴스 초기화가 완료되는 것이 합리적

 

늦은 초기화 방식은 사용빈도가 높다면 잠금이 빈번하게 일어나서 낮은 동시성 문제로 인한 병목 현상이 발생할 수 있음

 

#이중 잠금(double-checked locking)

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static final IdGenerator instance;
    //private static volatile IdGenerator instance; 
    // volatile 추가 -> 변수의 값이 항상 메인 메모리와 동기화되도록 보장하여 명령어 재정렬 문제를 방지함
    
    private IdGenerator() {}
    
    public static IdGenerator getInstance(){
        if(instance == null) {
            syncronized(IdGenerator.class) { //클래스 레벨의 잠금 처리
                if(instance == null) {
                    instance = new IdGenerator();
                }
            }
        }
        return instance;
    }
    
    public long getId() {
        return id.incrementAndGet();
    }
}

 

늦은 초기화 방식의 낮은 동시성 해결 가능, CPU 명령이 재정렬되면 IdGenerator 클래스의 객체가 new 예약어를 통해 instance 멤버 변수가 지정된 후, 초기화가 이루어지기 전에 다른 스레드에서 이 객체를 사용하려고 할 수 있다. 이 문제를 해결하려면 volatile 키워드를 인스턴스 멤버 변수에 추가하여 명령어 재정렬을 방지하면 된다.

 

#홀더에 의한 초기화(initialization on demand holder idiom)

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    
    private IdGenerator() {}
    
    public static class SingletonHolder{
        private static final IdGenerator instance = new IdGenerator();
    }
    
    public static IdGenerator getInstance() {
        return SingletonHolder.instance;
    }
    
    public long getId() {
        return id.incrementAndGet();
    }
}

 

#열거(enumeration)

public enum IdGenerator{
    INSTANCE;
    private AtomicLong id = new AtomicLong(0);
    
    public long getId() {
        return id.incrementAndGet();
    }
}

 

 

 

#팩터리패턴

 

 

 

#빌더 패턴

 

 

 

#프로토타입 패턴

 

 

 

 

'개인공부용1 > cs' 카테고리의 다른 글

함수형 프로그래밍  (0) 2024.07.15
시작하세요! C# 12 프로그래밍 - 2부  (0) 2024.07.14
정규표현식  (0) 2024.07.11
혼자 공부하는 컴퓨터 구조 + 운영체제  (0) 2024.06.18
Bresenham's line algorithm  (0) 2024.02.07