설계 원칙

좋은 SW 시스템은 깔끔한 코드로부터 시작한다.
좋은 벽돌을 사용하지 않으면 아키텍처가 좋고 나쁨은 중요치 않다.
반대로 좋은 벽돌을 사용하더라도 엉망으로 만들 수 있다.

-> 따라서 좋은 벽돌로 좋은 아키텍처를 정의하는 원칙이 필요한데...
그것이 SOLID이다.

SOLID 원칙은 함수와 데이터 구조를 클래스로 배치하는 방법 그리고 이들 클래스를 서로 결합하는 방법을 설명해준다.

SOLID의 목적

  • 변경에 유연하다
  • 이해하기 쉽다
  • 많은 SW 시스템에 사용할 수 있는 컴포넌트의 기반이 된다.

'중간 수준'이라 함은 프로그래머가 이들 원칙을 모듈 수준에서 작업할 때 적용할 수 있다는 뜻이다.

SRP

단일 책임 원칙
이름만 들으면 모든 모듈이 단 하나의 일만 해야 한다는 의미로 받아들이기 쉽다.
-> 함수는 하나의 일만 해야 한다 가 정확하다.

모듈 = "소스 파일"

원칙을 위반하는 징후

우발적 중복

급여 App의 Employee Class
해당 클래스는 calculatePay(), reportHours(), save() 메서드를 갖는다.

  • calculatePay() 는 회계팀에서 기능을 정의하며, DFO 보고를 위해 사용
  • reportHours() 는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용
  • save() 는 DBA가 기능을 정의하고, CTO 보고를 위해 사용

각각의 메서드를 갖고 있는 Employee라는 단일 클래스에 3종류의 actor가 결합되었다.

예를 들어, calculatePay()와 ㄱeportHours()가 초과근무를 제외한 업무 시간을 계산하는 알고리즘을 공유한다고 해보자
그리고 중복을 회피하기 위해 regularHours()라는 메서드를 넣었다고 가정하자

이제 CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식을 수정하기로 했다.
반면 인사를 담당하는 COO 팀에서는 초과 근무를 제외한 업무 시간을 CFO 팀과는 다른 목적으로 사용하기에, 변경을 원치 않는다.

  1. 해당 업무를 받은 개발자는 calculatePay() 메서드가 regularHours()를 호출한다는 것을 발견!
  2. 하지만 reportHours() 메서드에서도 호출된다는 것을 발견하지 못함..

개발자는 업무를 테스트하고 변경했다.
CFO 팀에서는 기능을 검증하고 시스템은 배포된다.
하지만 COO 팀에서는 이러한 사실을 알지 못했고, reportHours() 메서드를 사용하고 나서야 잘못된 수치들을 확인한다.

-> 누가, 어떤 목적으로 사용하는지에 대한 기능을 제공을 단일로 책임? , 즉 Class가 단일 actor에 대한 책임을 진다?

병합

소스 파일에 다양하고 많은 메서드를 포함하면 병합이 자주 발생할 것이다.
이들 메서드가 서로 다른 actor를 책임진다면 병합이 발생할 가능성은 더 높다

DBA가 속한 CTO팀에서 DB의 Employee Table Schema를 수정하기로 했다.
동시에 COO 팀에서는 reportHours() 메서드의 보고서 포맷을 변경하기로 했다.

서로 다른 개발자가 Employee Class를 체크아웃 받을 후 적용할 것이다.
이러한 변경사항들은 분명히 충돌한다.

-> 이 문제를 벗어나기 위해서는 서로 다른 actor를 책임지는 코드를 서로 분리하는 것이다.

해결책

가장 확실한 해결책은 데이터와 메서드를 분리하는 방식이다.
즉, 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData Class를 만들어 세개의 Class가 공유한다.
각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함한다.
각 클래스는 서로의 존재를 모른다.

하지만 개발자가 3가지 Class를 인스턴스화하고 추적해야 한다는 게 단점이다.
이러한 난관을 피하기 위해 퍼싸드 패턴을 사용한다.

EmployeeFacade에 코드는 거의 없다. 이 클래스는 3개의 클래스의 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다.

결론

SRP는 메서드와 클래스 수준의 원칙이다. 하지만 이보다 상위 두 수준에서도 다른 형태로 다시 등장한다.
컴포넌트 수준에서는 공통 폐쇄 원칙
아키텍처 수준에서는 아키텍처 경계의 생성을 책임지는 변경의 축이 된다.

좋은 아키텍처를 만드는 일은 OO 설계 원칙을 이해하고 응용하는 데서 출발한다.

그렇다면 OO란 무엇인가?
-> 데이터와 함수의 조합
-> 만족스럽진 않다. o.f()와 f(o)가 다르다는 의미를 내포하기 때문
-> 실제 세계를 모델링하는 새로운 방법
-> 얼버무리는 수준
-> 현실 세계와 의미적으로 가깝기 때문에, 사용하면 SW를 쉽게 이해할 수 있다고 한다.
-> 하지만 정의가 너무 모호하다

캡슐화

OO를 정의하는 요소 중 하나로 데이터와 함수를 쉽고 효과적으로 캡슐화하는 방법을 OO 언어가 제공하기 때문이다.
이를 통해 데이터와 함수가 응집력 있게 구성되고 서로 구분 지을 수 있다.
구분선 바깥(외부)에서 데이터는 은닉되고, 일부 함수만이 외부에 노출된다.
실제 OO 언어에서는 각 클래스의 private/public 으로 표현된다.

EX
//point.h
struct Point;
struct Point* makePoint(double x, double y);
double distance (struct Point *p1, struct Point *p2);

//point.c
#include "point.h"
...

struct Point{
double x,y;
};

struct Point* makepoint(...){...}
double distance(...){...}

point.h를 사용하는 측에서 struct Point의 멤버에 접근할 방법이 전혀 없다.
사용자는 makePoint() 와 distance()를 호출할 수 있지만, Point 구조체의 데이터 구조와 함수가 어떻게 구현되었는지는 모른다.
이것은 완벽한 캡슐화이며, 위와 같은 OO가 아닌 언어에서도 가능하다

C언어 개발자들은 이러한 방식을 활용했다.
데이터 구조와 함수를 헤더 파일에 선언하고, 구현 파일에서 구현한다.
그리고 사용자는 구현 파일에 작성된 항목에 대해서는 어떻게도 접근할 수 없었다.

이후에 C++이 등장했고, C언어가 제공하던 완전한 캡슐화가 깨졌다.
C++ 컴파일러는 기술적인 이유로 클래스의 멤버 변수를 해당 클래스의 헤더파일에 선언할 것을 요구했다.
-> 기술적인 이유 : 클래스의 인스턴스 크기를 알 수 있어야 한다.

EX
//point.h
class Point{
public:
Point(double x, double y);
double distance(const Point& p) const;
private:
double x;
double y;
}

//point.cc
...... 구현 .......

이제 point.h 헤더 파일을 사용하는 측에서는 멤버 변수인 x, y를 알 수 있다.
물론 접근하는 것은 private으로 막겠지만, 사용자는 멤버 변수가 존재한다는 사실을 알게 된다.
예를 들어, 멤버 변수의 이름이 바뀌면 point.cc 파일은 다시 컴파일 해야 한다 -> 캡슐화가 깨졌다.

언어에 public, private, protected 를 도입하여 불완전한 캡슐화를 보완하기는 했다.
하지만 이는 컴파일러가 헤더 파일에서 멤버 변수를 볼 수 있어야 했기에 임시 방편일 뿐...

자바와 C#은 헤더와 구현체를 분리하는 방식을 보렸다. 이로써 캡슐화는 더욱 더 훼손되었다.
클래스 선언과 정의를 구분하는게 불가능하다.

-> 이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 어렵다.
-> 실제로 많은 OO 언어가 캡슐화를 강제하지 않는다.

상속

OO 언어가 더 나은 캡슐화를 제공하지 못했지만, 상속만큼은 OO 언어가 제공했다.
상속이란?
-> 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 일에 불과하다
사실상 OO 언어가 있기 전에도 프로그래머는 직접 이러한 방식을 구현할 수 있었다.

다형성

OO 언어가 있기 전에 다형성을 표현할 수 있는 언어가 있었다.
하지만 함수 포인터를 사용하기 때문에, 위험하다.
OO는 다형성을 좀 더 안전하고 편리하게 사용할 수 있게 해준다.

다형성이 가진 힘

그렇다면 다형성은 뭐가 좋은 것일까?
새로운 입출력 장치가 생긴다면 프로그램에는 어떤 변화가 생기는가?
새로운 장비에서도 복사 프로그램이 동작하려면 수정해야 하는가? -> 변경이 필요치 않다.
왜냐하면 복사 프로그램의 소스 코드는 입출력 드라이버의 소스코드에 의존하지 않기 때문이다.
-> 장치에 의존적인 수많은 프로그램을 만들고 나서, 하나의 프로그램이 다른 장치에서도 같은 동작을 할 수 있도록 만드는 것을 바래왔다.

의존성 역전

다형성을 안전하고 편리하게 적용할 수 있는 메커니즘이 등장하기 전 SW는 어떤 모습이었을까?
전형적으로 main 함수가 고수준 함수를 호출하고
고수준 함수는 다시 중간 수준 함수
중간 수준 함수는 다시 저수준 함수를 호출한다.
이러한 호출 트리에서 소스코드 의존성 방향은 반드시 제어흐름을 따르게 된다

main 함수가 고수준 함수를 호출하기 위해서는 고수준 함수가 포함된 모듈의 이름을 지정해야만 한다.
C는 #include
Java는 import 이다.

실제로 모든 호출 함수는 피호출 함수가 포함된 모듈의 이름을 명시적으로 지정해야 한다.

하지만 다형성이 끼어들면 무언가 특별한 일이 일어난다.

HL1 모듈은 ML1 모듈의 F() 함수를 호출한다.
소스 코드에서는 HL1 모듈은 인터페이스를 통해 F() 함수를 호출한다.
이 인터페이스는 런타임에는 존재하지 않는다.
HL1은 단순히 ML1 모듈의 함수 F()를 호출할 뿐이다.

하지만 ML1과 I 인터페이스 사이의 소스 코드 의존성(상속 관계)이 제어흐름과는 반대인 점을 주목하자.
이를 의존성 역전(DI)라고 부른다.

OO 언어가 다형성을 안전하고 편리하게 제공한다는 사실은 소스 코드 의존성을 어디에서든 역전시킬 수 있다는 뜻이기도 하다.

소스 코드의 의존성은 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다.

이러한 접근법을 사용한다면, OO 언어로 개발된 시스템을 다루는 SW 아키텍트는 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 권한을 갖는다.
즉, 소스코드 의존성이 제어흐름의 방향과 일치하지 않아도 된다.
호출하는 모듈이든 호출되는 모듈이든 관계없이 소스 코드 의존성을 원하는 방향으로 설정할 수 있다.

그렇다면 이 힘으로 무엇을 할 수 있는가?
예를 들어) 업무 규칙이 DB와 UI에 의존하는 대신에, 소스코드 의존성을 반대로 배치하여 DB와 UI가 업무 규칙에 의존하게 만들 수 있다.

즉, UI와 DB가 업뮤 규칙의 플로그인이 된다는 뜻이다. 다시 말해 업무 규칙의 소스 코드에서는 UI나 DB를 호출하지 않는다.
따라서 업무 규칙을 UI와 DB와는 독립적으로 배포할 수 있다.
UI나 DB에서 발생한 변경사항은 업무 규칙에 일절 영향을 미치지 않는다.
이 컴포넌트들은 개별적이며 독립적으로 배포 가능하다.

결론

OO란 무엇인가?
아키텍처 관점에서는 OO란 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력이다
각각의 세부사항을 포함하는 모듈들을 독립적으로 개발하고 배포할 수 있다.

다익스트라가 초기에 익식한 문제

  1. 프로그래밍은 어렵다
  2. 프로그래머는 프로그래밍을 잘하지 못한다
    • 프로그램은 단순할지라도 너무 많은 세부사항을 담고 있다.

다익스트라는 증명이라는 수학적인 원리를 적용하여 이 문제를 해결하고자 했다.

연구를 진행하면서 goto 문장이 모듈을 더 작은 단위로 재귀적으로 분해하는 과정에 방해가 되는 경우가 있다는 사실을 발견했다.
만약 모듈을 분해할 수 없다면, 증명할 때 필요한 분할 정복 기법을 사용할 수 없다.

반면 goto 문을 사용하더라도 모듈을 분해할 때 문제가 되지 않는 경우도 있었다.
이런 goto문의 좋은 사용 방식은 if/then/else와 do/while과 같은 분기와 반복이라는 단순한 제어 구조에 해당한다는 것을 발견했다.
.....
결국 다익스트라가 10년의 전쟁에서 이겼고, goto문은 뒤로 밀려났다.
대다수의 현대 언어에서는 goto가 없다.

기능적 분해

구조적 프로그래밍을 통해 모듈을 증명 가능한 더 작은 단위로 재귀적으로 분해할 수 있게 되었고, 모듈을 기능적으로 분해할 수 있다.
거대한 문제 기술서는 고수준의 기능들로 분해
고수준의 기능은 또 여러개의 저수준의 함수로 분해
-> 대규모 시스템을 모듈과 컴포넌트로
-> 모듈과 컴포넌트를 아주 작은 기능으로

테스트

테스트는 버그가 있음을 보여줄 뿐, 버그가 없음을 보여줄 수 없다.
-> 프로그램이 잘못되었음을 보여줄 수 있지만, 맞다고는 증명할 수 없다.

결론

구조적 프로그래밍이 오늘날까지 가치 있는 이유는 기능 단위를 분해하는 능력 때문이다.
또한 요즘 goto문이 없는 이유기도 하다.

SW 아키텍처는 코드로부터 시작한다.

구조적 프로그래밍

  • 최초로 적용된 패러다임
  • 무분별한 goto문은 해롭다.
    • if/then/else , do/while/until 로 대체
  • 제어 흐름의 직접적인 전환에 대해 규칙을 부과한다*

객체 지향 프로그래밍

함수 호출 스택 프레임을 Heap으로 옮기면, 호출이 반환된 이후에도 함수에서 선언된 지역 변수가 오랫동안 유지됨을 발견
이러한 함수가 클래스의 생성자가 되었고, 지역변수는 인스턴스 변수, 중첩함수는 메서드가 되었다.
제어흐름의 간접적인 전환에 대해 규칙을 부과한다

함수형 프로그래밍

최근에 들어 인기있고 사용되는 패러다임
세 패러다임 중 가장 먼저 만들어졌다.
람다 계산법의 기초가 되는 개념 = 불변성 즉, 심볼의 값이 변경되지 않는다는 점이다.
할당문에 대해 규칙을 부과한다

각 패러다임은 프로그래머에게서 권한을 박탈한다.
어느 패러다임도 새로운 권한을 부여하지 않는다.
규칙을 부과한다. 즉, 무엇을 하면 안되는 지를 알려준다.

결론

패러다임의 역사로부터 얻을 수 있는 교훈은 아키텍처와 어떤 관계가 있는가?
-> 모두 관계있음.

아키텍처 경계를 넘나들기 위한 메커니즘으로 다형성을 이용한다.
우리는 FP를 이용하여 데이터의 위치와 접근 방법에 대해 규칙을 부과한다.
우리는 모듈의 기반 알고리즘으로 구조적 프로그래밍을 사용한다.

함수, 컴포넌트 분리, 데이터 관리가 어떻게 서로 연관되는 지 주목하자.

+ Recent posts