본문 바로가기

프로그래밍 기술 노트/Functional Architecture

[함수형 아키텍쳐] Functional Design and Architecture 1장 소프트웨어 디자인은 무엇인가?

작성중

본 글은 https://github.com/graninas/Functional-Design-and-Architecture 을 학습하면서 정리한 글 입니다.

필자 함수형 언어와 패러다임을 나름 오랜시간 공부하고 있다 그러나 이를 실제로 활용하여 프로젝트를 진행한 경험은 없다. 람다, 고차함수 더 나아가 모나드 등을 공부하면서도 이를 실제 프로젝트에 어떻게 적용해야하는지 모르고있다.

C# 에서 For 문 대신 LINQ 를 더 사용하고 Stateful 한 상황을 제거하려고 노력하고있지만 구조와 사고를 함수적으로 하는것이 아닌, 아주 지역적인 부분에서만 적용하고있다고 느낀다.

이를 극복하고 보다 FP 다운 코드를 작성하고자 공부할 자료를 찾던중 [Functional Design and Architecture] 라는 책 (아직 완성되지는 않았지만) 이 존재하여 이를 공부해보고자 한다.

본 포스트는 그 책중 1장 "What is software design?" 에 관한 내용을 정리한것이다.

 

왜 함수형인가?

 함수형 패러다임은 우리의 코드를 보다 안전하고, 짧고 더 좋게 만들어 줄것입니다.
물론 병렬성 이나 동시성은 FP가 가지는 큰 장점중 하나입니다. 그러나 함수형 프로그램의 진정한 힘은 "정교한 수학적 특성" 입니다. 이로 인하여 복잡한것을 훨씬 간단하고 즐겁게 만들 수 있습니다.

소프트웨어 설계

소프트웨어를 개발하면서 다들 느껴보았을것입니다.
개발을 하다가 "아.. 이거좀 아닌거 같은데...." 라던가 "하.. 이렇게 짜도 될라나..." 같은것 말이죠.
대충 시작한 프로젝트에서 이러한 경향이 더 심해집니다. 우리는 그럼에도 불구하고 계속해서 개발을 하죠.
본 책에서는 이를 아래와 같이 표현했습니다.

 크고 복잡한 시스템은 더욱더 복잡해진다.

그런데 어찌됫건 개발을 해야하는데.. 이렇게 복잡한 프로그램을 수정하려 할 때 아래와 같은 문제가 발생합니다.

크고 복잡한 시스템은 더 크고 복잡하게 만들려는 시도에 저항합니다.

제가 바르게 이해한것인지는 모르겠으나 복잡하게 되어있는 시스템은 기능을 추가하기도 힘들다~ 뭐 이런 말인것 같습니다.

 이러한 소프트웨어의 복잡성은 우리 같은 개발자가 다루는 주요한 문제 입니다. 각종 패턴이나 OOP 같은 패러다임도 이러한 복잡성을 낮추고, 기능을 쉽게 추가 및 변경할수있도록 하는 기술입니다.
소프트웨어를 구조하는 방법을 정하고, 혼돈을 피하기 위하여 시스템의 행동을 결정적으로 만들고 코드를 가능한 심플하고 명확하게 작성하는 기술등 좋은 소프트웨어를 개발하기 위한 많은 기술등이 존재합니다.

 모든 프로그램이 이러한 규칙을 지켜가면서 구현된것은 아닙니다. 이런한 규칙을 지키지 않아도 충분히 소프트웨어를 개발 할 수 있습니다. 그러나 스파게티 범벅인 코드를 받아서 유지보수하는 일은 분명 재미없고 시시한 일 일것입니다.

 그럼 FP 를 사용한다면 간단하고 유지관리 가능한 코드가 되는것일까요?
많은 FP 둘들을 잘못사용한다면 소프트웨어를 더 위험하게 만들것 업니다. 따라서 FP 에서도 소프트웨어 설계가 중요합니다. 그리고 소프트웨어 설계를 위해서는 목표, 제한, 요구사항등을 이해할 필요가 있습니다.

요구사항, 목표, 단순성

스페이스 Z 코퍼레이션 (...) 이라는 회사의 이사분께서 아주 큰 우주 프로젝트를 기획하고있고, 우주선을 관리하는 소프트웨어가 필요하여 개발에 참여해달라고 부탁했다고 해봅시다.
언제까지 프로토타입, 언제까지 버전1 ..등의 스케줄과 기술문서 연락처 등을 주고 떠났습니다.

개발 프로세스 로드맵

여러 도큐먼트를 읽고 SW 정보를 수집할것입니다. 이 시점에서 소프트웨어 설계단계에 진입할수있습니다.

해당 우주 소프트웨어는 내결함성, 정확한 동작등을 보장해야하고 쉽게 실행할수있고, 보안이 중요하고.. 등 과 같은 요구 사항이 존재합니다. 이러한 요구사항을 "비 기능적 요구사항" (NFR) 이라고 합니다.
해당 소프트웨어는 우주 비행사가 수동으로 조작하는 모드가 존재하고 자동 조종 모드도 존재 해야합니다. 이러한 요구사항을 "기능적 요구사항" 이라고 합니다.

 이제 이러한 요구사항을 충족하면서 다시 작성할 필요가 없는 프로그램을 만들어야합니다.
단순하면서도 강력한 코드를 디자인하는데는 시간이 필요하며 종종 타협이 필요할것입니다.
고려 요소는 다음과 같은것이 있을수 있습니다.

  • 목표달성 : 필요할때 시스템을 제공해야합니다. 예산, 기한, 지원등을 만족해야합니다.
  • 요구사항 만족 : 합의도니 모든 함수와 프로퍼티가 있어야하며, 잘 동작해야합니다.
  • 단순화: 유지보수가 쉽고 간결한 코드를 사용하여 쉽게 수정할수있어야합니다. (후임이 와도 바로 시작할 수 있도록)

모든 요소를 완벽하게 만족하는것이 베스트지만.. 현실적으로 불가능 하다면 각 요소간 적당히 타협하면서 개발을 진행햐야 합니다.

이상적인 상황(외부) 와 현재 상황 (내부)

SW 설계라는 것은 위험(risk)과 비용(cost)을 관리하는 프로세스입니다. 위험관리는 설계 결정에 영향을 미치면서 우리가 싫어하는 도구나 관행을 사용하게 할수도 있습니다. (흔히 말하는.. 이거 했다가 안되면 책임질꺼야? 같은..)
일반적으로 다음과 같은 위험들이 존재합니다

  • 낮은 예산
  • 요구사항 변경
  • 명확하지 않은 요구사항
  • 새로운 요구사항
  • 시간 부족
  • 지나치게 복잡한 코드
  • 잘못된 도구 및 접근 방식

따라서 프로젝트를 시작할때 설계, 아키텍쳐에 적합한 도구와 접근 방식을 선택하는것이 중요합니다.
C++/Java 를 사용하지 않고 FP 를 사용하는 이유는 병렬처리, 정확성, 결정성, 단순성(Simplicity)* 등이 존재 하겠죠

*) 본 서적에도 나와있는 것인데 Simplicity Easiness 는 다른것입니다. http://ohyecloudy.com/pnotes/archives/1826/#more-1826 에서 한국어로 정리된 글이 있습니다.

소프트웨어 설계 정의

엔진 제어 서브 시스템의 Use Case 다이어 그램

유즈케이스를 위와같이 UML 로 표현했습니다.
위 그림만 보면 심플해보이는데... 아무튼 각 서브 시스템 내에 많은 하위 함목도 있을것이고 각 서브 시스템간에 통신 프로토콜도 내결함성을 가져아하며 모든 명령에는 복구 코드가 존재하는등 정교하고 많은 요소를 포함하고 있습니다. (복잡성)

또한 이러한 이슈들을 무시하거나 단순화 하는 방법은 없습니다. 위의 복잡성은 우주선 제어 소프트웨어의 고유한 속성이므로 모든 기능을 지원해야합니다. 이러한 피할수 없는 복잡성을 필수 복잡성 (essential complexity) 이라고 합니다.
대형 시스템의 일체형 특성으로 우리의 솔루션도 크고 무거워 지게 됩니다.

각 서브 시스템의 커멘드중 일부가 아래와 같다고 합시다.

Command

Native API function

Start boosters

int send(BOOSTERS, START, 0)

Stop boosters

int send(BOOSTERS, STOP, 0)

Start rotary engine

core::request::result request_start(core::RotaryEngine)

Stop rotary engine

core::request::result request_stop(core::RotaryEngine)

이곳 저곳 혼합되어 만들어진 API는 지저분하고 복잡하지만.. 어쩌겠습니다. 우리도 받은건데
우리의 임무는 이러한 필수 복잡성을 추상화를 통하여 숨기는것입니다.

  • 추상화 없이 네이티브 Call 사용
  • 네이티브  함수와 상위 수준의 함수간 런타임 매핑
  • 컴파일 타임 매핑
  • 다형성 오브젝트로 래핑 (커멘드 패턴)
  • 인터페이스와 구문이 통합된 상위 수준 API 를 생성하고 네이티브 API 를 래핑
  • 통합 내부 DSL 생성
  • 통합 외부 DSL 생성

위와 같이 다양한 방법이 존재합니다. 각 방법은 장단점 과 여러가지 요인에 따른 자체적인 복잡성을 가지고 있습니다. 우리는 다양한 방면에서 고려하여 이중 하나를 선택해야합니다.

우리가 하나의 방법을 선택할때 선택한 방법에 따라 "우발적인 복잡성"(Accidental complexity) 에 영향을 미칩니다. 만약 비합리적인 방법으로 tricky 한 코드를 작정하게 된다면 우발적 복잡성은 증가 할것입니다.

우발적인 복잡성을 최소화 하기 위하여 과도하게 상위 level을 만들지 말아야합니다.
어떻게 번역 해야할지 모르겠는데 아래 그림을 보면 이해가 쉬울것 같습니다.

솔루션에 따른 우발적 복잡성

 

소프트웨어 디자인에서 완벽한 정답이란 없습니다. 따라서 우리는 디자인하고, 여러 옵션들중에서 밸런스를 맞추어야 합니다. 이것이 우리가 패턴과 모범사례들을 원하고 학습하는 이유입니다. 우리보다 이런 문제를 먼저 접하신 분들이 우리가 사용할수있는 편리한 솔루션들을 발명했고 우리는 이것을 사용할수가 있습니다.

 이제 우리는 소프트웨어 디자인이 무엇인지와 주요 task들을 공식화 할 수 있습니다.

Software design is the process of implementing the domain model and requirements in high-level code composition. It's aimed at accomplishing goals. The result of software design can be represented as software design documents, high-level code structures, diagrams, or other software artifacts. The main task of software design is to keep the accidental complexity as low as possible, but not at the expense of other factors.

객체 지향 디자인에서의 예는 아래와 같습니다.

엔진 제어 서브 시스템의 클래스 다이어그램 의 일 예

클래스 다이어그램은 OOD에서 가장 널리 사용하는 UML 입니다. 클래스 다이어그램을 통하여 OO 개발자는 코딩하기 전에 서로 커뮤니케이션와 아이디어를 표현할수있습니다.

이러한 UML 을 함수형 프로그래밍에서는 어떻게 적용할수있을까요? 이는 다음장에서 설명하겠습니다.

낮은 커플링과 높은 응집도

온도계에서 데이터를 읽고 내부 표현으로 변환하여 원격 서버로 보내는 기능을 구현하는 간단한 작업이 있습니다.

아래와 같은 스칼라 코드가 있습니다.

object Observer {
   def readAndSendTemperature() {
      def toCelsius(data: native.core.Temperature) : Float =
        data match {
          case native.core.Kelvin(v) => 273.15f - v
          case native.core.Celsius(v) => v
        }

      val received = native.core.thermometer.getData()
      val inCelsius = toCelsius(received)
      val corrected = inCelsius - 12.5f    // defective device!
      server.connection.send("temperature", "T-201A", corrected)
   }
}

High coupled 객체지향 코드 (Scala)

위 스칼라 코드는 테스트가 불가능한 코드입니다. 모든 커멘드가 실제 온도계와 연결되어있어 테스트환경에서 작업이 불가능합니다. 따라서 테스트를 하지 못하여 v – 273.15f 가 올바른 계산방법이지만 273.15f - v 로 잘못 작성되어있습니다. 또한 매직넘버와 비밀지식 을 포함하고있습니다.

클래스는 외부 시스템과 매우 결합되어있기때문에 예측할수 없습니다. SRP 도 위반합니다. 하위 시스템에 엑세스하지 않고는 테스트도 불가능합니다.

이러한 문제를 해결하기 위하여 새로운 레벨의 추상화를 도입해야합니다. 인터페이스를 사용해봅시다.

trait ISensor {
    def getData() : Float
    def getName() : String
    def getDataType() : String
}
trait IConnection {
    def send(name: String, dataType: String, v: Float)
}
final class Observer (val sensor: ISensor, val connection: IConnection) {
   def readAndSendData() {
      val data = sensor.getData()
      val sensorName = sensor.getName()
      val dataType = sensor.getDataType()
      connection.send(sensorName, dataType, data)
   }
}

Low coupled 객체지향 코드 (Scala)

여기서 ISensor 인터페이스는 일반 센서 장치를 나타내며 장치에 대하여 너무 많이 알 필요가 없습니다. 결함이 존재할수있지만 코드는 결함을 수정할 책임이 없습니다. ISensor 의 구체적인 구현에서 수행해야합니다.

ISensor 와 IConnection 는 원격서버나 DB 또는 다른것이 구현될수있습니다. 하지만 인터페이스 뒤에 구현될 내용은 현 코드에서 중요한것이 아닙니다. 이 코드에 대한 클래스 다이어그램은 다음과 같습니다.

Low Coupled 객체지향 코드의 클래스 다이어그램

 

FP 프로그램에서도 위에서본 OOP 설계를 적용할수있을까요?

아래에 하스켈로 구현된 FP 코드를 보도록 합시다.

import qualified Native.Core.Thermometer as T
import qualified ServerContext.Connection as C

readThermometer :: String -> IO T.Temperature   #A
readThermometer name = T.read name              #A

sendTemperature :: String -> Float -> IO ()          #B
sendTemperature name t = C.send "temperature" name t #B

readTemperature :: IO Float            #C
readTemperature = do                   #C
    t1 <- readThermometer "T-201A"     #C
    return $ case t1 of                #C
        T.Kelvin  v -> 273.15 – v      #C
        T.Celsius v -> v               #C

readAndSend :: IO ()                       #D
readAndSend = do                           #D
    t1 <- readTemperature                  #D
    let t2 = t1 - 12.5 -- defect device!   #D
    sendTemperature "T-201A" t2            #D

#A Native impure call to thermometer
#B Server impure call
#C Impure function that depends on native call
#D Highly coupled impure function with a lot of dependencies

High coupled 함수형 코드 (Haskell)

위 코드는 FP 로 구현되어 있지만 높은 결합도를 가지고있습니다.

read와 send 함수는 Impure 합니다. 해당 함수는 원격서버와 네이티브 디바이스에서 동작하기 때문에 순수하지 않습니다.

여기서 문제는 부작용을 다루는 간단한 접근방식을 찾는것입니다. 객체 지향에서는 코드를 느슨하게 결합하는데 도움을주는 솔루션이 있습니다. (아마 인터페이스..). FP 에서는 다른방법으로 이를 해결하고자 합니다. 예를 들어 DSL 을 도입하여 네이티브 호출에 대한 결합도를 낮출 수 있습니다.

DSL을 사용하여 시나리오를 빌드할수있고 클라이언트 코드는 DSL 에서만 작동하여 네이티브 호출에 대한 종속성을 제거 할 수 있습니다. 이후 2개의 옵션을 가질수 있습니다.

1. 우리는 높은 수준의 명령을 네이티브 함수로 변환하는 DSL에 대한 네이티브 번역기를 사용할 있습니다.
2. 테스트 인터프리터를 만들어 시나리오를 별도로 테스트할 있습니다.

위 DSL 을 적용한 방법은 아래와 같습니다.

type DeviceName = String
type DataType = String
type TransformF a = Float -> ActionDsl a

data ActionDsl a                                   #A
  = ReadDevice DeviceName (a -> ActionDsl a)       #A
  | Transform (a -> Float) a (TransformF a)        #A
  | Correct (Float -> Float) Float (TransformF a)  #A
  | Send DataType DeviceName Float

transform (T.Kelvin  v) = v – 273.15    #B
transform (T.Celsius v) = v             #B
correction v = v – 12.5                 #B

scenario :: ActionDsl T.Temperature     #C
scenario =                              #C
    ReadDevice therm (\v ->             #C
    Transform transform v (\v1 ->       #C
    Correct correction v1 (\v2 ->       #C
    Send temp therm v2)))               #C

interpret :: ActionDsl T.Temperature -> IO ()      #D
interpret (ReadDevice n a) = do                    #D
    v <- T.read n                                  #D
    interpret (a v)                                #D
interpret (Transform f v a) = interpret (a (f v))  #D
interpret (Correct f v a)   = interpret (a (f v))  #D
interpret (Send t n v)      = C.send t n v         #D

readAndSend :: IO ()
readAndSend = interpret scenario

#A Embedded DSL for observing scenarios
#B Pure auxiliary functions
#C Straightforward pure scenario of reading and sending data
#D Impure scenario interpreter that uses native functions

Low coupled 함수형 코드 (Haskell)

위의 ActionDsl 은 아직 몇가지 단점이 존재하지만 지금은 이러한 세부사항은 무시하도록 합시다.

네이티브 호출과 프로그램 코드 사이에 DSL 을 두면 낮은 수준에서 느슨한 커플링과 낮은 의존도를 얻을수있습니다.

함수형에서 DSL 아이디어는 매우 일반적이고 자연스러운 기술입니다. 대부분의 FP 프로그램은 서로다른 도메인 부분을 처리하는 많은 작은 내부DSL 로 구축됩니다.

함수형에는 다른 훌륭한 패턴과 이디엄(idioms) 이 존재합니다. 이는 다음장에서 설명하겠습니다.

728x90