본문 바로가기
프로그래밍/클린 아키텍처

[클린아키텍처] 2부. 프로그래밍 패러다임

by 2KB 2021. 2. 24.

프로그래밍에는 구조적 프로그래밍(Structured programming), 객체지향 프로그래밍(Object-oriented programming), 함수형 프로그래밍(Functional programming) 등 3가지의 패러다임이 존재한다.

구조적 프로그래밍

구조적 프로그래밍은 제어의 흐름의 직접적인 전환에 대해 규칙을 부여한다.

구조적 프로그래밍은 "모듈"이라는 구조로 작은 단위의 코드 조각들이 모여 하나의 프로그램을 이루게 하는 프로그래밍 기법을 의미한다.

여기서 모듈은 하나의 기능을 위해, 시작점과 반환점을 가진 코드 단위인데, 클래스, 함수, 프로시저, 메서드, 블록 등으로 생각하면 쉽다.

"모듈"들이 많아지고, 이러한 "모듈"들이 모여 하나의 프로그램을 구성하게 되는것이 구조적 프로그래밍이다.

이러한 구조적 프로그래밍은 이러한 "모듈"을 만들기 위해 아래의 3가지의 제어 구조를 띄고 있다.

  • 순차(concatenation) : 구문 순서에 따라서 순서대로 수행

  • 분기(selection) :if, then, else, endif, switch, case와 같은 키워드로 여러 구문들 중 하나를 수행

  • 반복(repetition) : while, repeat, for, do..until 같은 키워드를 통해 프로그램이 특정 상태에 도달할때까지 구분을 반복하여 수행.

이러한 "순차, 분기, 반복"등의 제어구조는 결국 코드의 제어 흐름에 대한 직접적 전환에 대한 규칙을 부과하는 것이다.

 

객체지향 프로그래밍

객체 지향 프로그래밍은 제어흐름의 간접적인 전환에 대해 규칙을 부과한다.

객체 지향 프로그래밍은 캡슐화, 상속, 다형성 3가지 개념을 적절하게 조합한 것이거나, 또는 최소한 세가지 요소를 반드시 지원하는 프로그래밍 기법을 의미한다.

캡슐화

 객체 지향 프로그래밍에서 객체(Class)는 속성(data field)과 행위(method)를 가지게 되는데, 이때 속성(data)는 외부에 감추고, 일부 함수만이 외부에 노출되도록 하는것을 캡슐화라고 한다. 이러한 개념들은 실제 객체지향 언어들에서 각각 클래스의 private, public 과 같은 접근지정자로 관리가 된다.

상속

 객체 지향 프로그래밍에서 객체(Class)는 속성(data field)과 행위(method)를 가지게 되는데, 새로운 객체가 기존 객체의 속성과 행위를 그대로 사용하거나 재정의 해서 사용할 수 있도록 하는 것을 상속이라고 한다.

다형성

 객체 지향 프로그래밍에서 객체(Class)가 문맥에 따라 동일한 요청이나 이벤트에 대해 다른 결과를 반환하는 것을 말한다.

흔히 다형성을 이야기할때 "오버라이딩" 또는 "오버로딩"을 이야기 하게 되는데, "오버라이딩"과 "오버로딩"은 동일한 함수에 대한 변형을 제공한다는 점에서 공통점이 있다고 할 수 있다. 객체 지향 언어에서는 어떠한 요청이나 이벤트에 대해 어떤 함수를 사용할 것인지 가리키는 포인터(메모리 주소)를  안전하고 편하게 사용할 수 있도록 제공하는데 그게 다형성이다.

 다형성이 가진 힘

 플러그인 아키텍처(Plugin Architecture)는 기능의 독립성을 지원하기 위해 만들어졌다.

 예를 들면, 우리는 "프린터"라는 장치가 있고, 이를 위한 각 회사별 "프린터 드라이버"들이 존재한다. 이러한 프린터 드라이버를 우리는 플러그인이라고 할 수 있다.

 이러한 "프린터 드라이버"는 OS의 프린터 입출력 표준(인터페이스)에 따라 "구현"되고, OS에서는 해당 프린터에 "출력"을 요구하기만 하면 된다. 따라서, 어떤 프린트냐에 상관없이 프린터 드라이버라는 플러그인만 설치하면 동일하게 "출력"이 가능하게 된다.

 이러한 플러그인을 지원하는 것이 곧 "다형성"을 지원한다고 말할 수 있다.

 의존성 역전

 다형성을 적용하기 전의 소프트웨어는 다음과 같이 소스코드의 의존성이 곧 제어의 흐름을 따라가게 되었다.

소스 코드 의존성과 제어 흐름

   HL1 이라는 모듈에는 ML1, ML2 라는 사용되는 하위 모듈을 "직접 선언"하여 사용하게 되고 의존성이 생기게 된다.  제어의 흐름 또한 HL1 또는 ML2를 호출하게 되므로 둘은 동일한 흐름을 가지게 되었다.

하지만 "의존성 역전"이 끼어들면 무언가 특별한 일이 일어난다.

의존성 역전

 위의 HL1 모듈은 ML1 모듈의 F() 함수를 호출한다. 소스코드에서 HL1 은 인터페이스 I를 통해 F() 함수를 호출하게 된다.

자 그러면, 이제 이 시점에서 제어의 흐름과 의존성의 관계를 살펴보자.

제어의 흐름은 HL1 에서 ML1을 가르키나(간접 호출), 의존성은 인터페이스를 향하게 된다.

제어의 흐름은 인터페이스를 통해 ML1을 호출하였으므로 I -> ML1 이지만, I 인터페이스를 상속받은 ML1 모듈은 I 인터페이스에 의존하고 있다.

이를 우리는 "의존성 역전"이라고 부른다.

이러한 의존성 역전을 사용하게 되면, 객체 지향 언어로 개발된 시스템의 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖게 된다. 즉, 소스 코드의 의존성을 원하는 방향으로 설정할 수 있다.

그러면 이러한 의존성 역전을 어떻게 활용할 수 있을까?

데이터베이스와 사용자 인터페이스 업무 규칙에 의존

예를 들어 기존에 Business Rules 라는 상위 모듈이 UI 와 Database 라는 하위 모듈에 대한 의존성이 있었다고 생각해보자.

이때, 우리는 "의존성 역전"을 통해 반대로 데이터베이스와 UI가 업무 규칙에 의존하게 만들 수 있다.

즉, UI와 Database 가 "Business Rules"를 위한 플러그인이 된다는 뜻이고, Business Rules 에서는 UI 및 Database와 관련된 코드가 존재하지 않고 호출하지 않도록 하는 것이다.

이를 통해 "Business Rules"는 독립적으로 변경 및 배포가 가능하게 되므로, "배포 독립성"을 얻을 수 있다. 배포 독립성이라는 것은 개발팀이 독립적으로 개발이 가능하다는 이야기이므로, "개발 독립성" 또한 얻을 수 있다.

따라서,  이러한 객체지향 프로그래밍을 통해 우리는 "플러그인 아키텍처"를 구성할 수 있고, 이를 통해 상위 모듈은 하위 모듈에 대해 "독립성"을 보장 받을 수 있다.

 * 하위 모듈은 "플러그인 모듈"로 만들 수 있고, 상위 모듈은 "독립적으로 개발하고 배포"가 가능하다.

위에서 언급한 3가지의 요소를 통해 간접적인 참조, 간접적인 전환에 대한 규칙을 부과하는 것이 바로 객체지향 프로그래밍이다.

자바 예시


의존성 역전이 이루어지지 않은 아래의 예시 코드는 PaymentService 가 KookMinCard 클래스와 WooriCard 클래스를 직접 선언 및 사용하고 있다.

PaymentService의 하위 모듈 의존성

public class PaymentService {

    private final KookMinCard kookMinCard = new KookMinCard();
    private final WooriCard wooriCard = new WooriCard();

    void setPayment(final String cardType, final String cardNumber, final long amount) {
        if ("KookMin".equals(cardType)) {
            kookMinCard.setPayment(cardNumber, amount);
        } else if ("Woori".equals(cardType)) {
            wooriCard.setPayment(cardNumber, amount);
        } else throw new RuntimeException("해당 카드사는 결제가 불가능합니다.");
    }

    private static class KookMinCard {
        void setPayment(final String cardNumber, final long amount) {
            /* Payment Logic */
        }
    }

    private static class WooriCard {
        void setPayment(final String cardNumber, final long amount) {
            /* Payment Logic */
        }
    }
}

 

PaymentService 는 앞으로 새로운 결제수단이 생길때마다 의존성이 생기게 되고, 해당 Service의 의존성은 겉잡을 수 없게 된다.

이러한 의존성을 위해 PaymentService 클래스의 코드 크기 증가는 필연적일 수 밖에 없다.

 

하지만 아래 의존성 역전이 이루어진 코드를 살펴보자.

PaymentService의 하위 모듈 의존성 역전

public class PaymentService {

    private final PaymentCard paymentCard;

    PaymentService(final PaymentCard paymentCard) {
        this.paymentCard = paymentCard;
    }

    void payByCard(final String cardNumber, final long amount) {
        paymentCard.pay(cardNumber, amount);
    }


    public interface PaymentCard {
        void pay(final String cardNumber, final long amount);
    }

    public class KookMinCard implements PaymentCard {
        @Override
        public void pay(final String cardNumber, final long amount) {
            /* Pay Logic */
        }
    }

    public class WooriCard implements PaymentCard {
        @Override
        public void pay(final String cardNumber, final long amount) {
            /* Pay Logic */
        }
    }
}

PaymentService 는 플러그인과 같이, PaymentCard 인터페이스의 구현체인 KookMinCard, WooriCard 클래스를 선택하여 사용할 수 있다. 그리고 이러한 인터페이스를 통해 해당 구현체의 함수를 호출할 수 있게 된다.

PaymentService는 카드 결제에 있어서 더 이상 코드의 크기를 늘릴 필요도 없을 뿐더러, 추가적인 카드사에 대한 결제 시스템이 필요하다면, PaymentCard 인터페이스를 구현하는 구현체를 생성하고 PaymentService에 적용하기만 하면 된다.

 

함수형 프로그래밍

함수형 프로그래밍은 할당문에 대한 규칙을 부과한다

함수형 프로그래밍은 가변 변수를 멀리하는 프로그래밍 기법을 의미한다.

불변성과 아키텍처

 경합 조건(Race Condition), 교착상태 조건(DeadLock), 동시성 문제(Concurrency) 문제등은 모두 가변 변수로 인해 발생한다.

 애플리케이션에서 다수의 스레드와 프로세스를 사용할 때 발생되는 문제들은 가변 변수가 없다면 생기지 않는다.

 아키텍처에서 불변성을 실현하기 위해서는 일종의 타협이 필요하다.

 

가변성의 분리

 가변성의 분리를 위해서는 애플리케이션 또는 애플리케이션의 내부 서비스에서 가변 컴포넌트와 불변 컴포넌트를 분리해야 한다.

  불변 컴포넌트에서는 어떤 가변 변수도사용되지 않으며, 불변 컴포넌트는 변수의 상태를 변경할 수 있는 하나 이상의 가변 컴포넌트와 서로 통신한다.

가변성의 분리

또한 가변 컴포넌트는 트랜잭션 메모리 등과 같은 방법을 통해 동시성 문제 및 경합 조건(Race condition)으로부터 가변 변수를 보호해야 한다.

따라서 애플리케이션을 제대로 구조화 하려면, 변수를 변경하는 컴포넌트와 변경하지 않는 컴포넌트를 분리해야 하고,

가능한 많은 처리를 불변 컴포넌트에서 처리할 수 있도록 하고, 가변 컴포넌트의 역할은 최소한으로 줄여야 한다.

이벤트 소싱

 이벤트 소싱(event sourcing)은 상태(가변변수)가 아닌 트랜잭션을 저장하자는 전략이다.

상태가 필요해지면 단순히 상태의 시작점부터 모든 트랙잭션을 처리하면 된다는 발상이다.

소스코드 버전 관리 시스템인 Git 을 생각해보자. 우리는 각각의 상태로 Branch를 가지고 있다.

Commit을 통해서 불변의 트랜잭션을 저장할 수 있으며 개발이 완료된 내용은 Master(Main)에 적용할 시점에 Pull하여 새로운 상태 시작점을 가지게 된다.