본문 바로가기

Programmer Jinyo/Scala & FP

Scala의 모나드(Monad)에 대한 정리


투명한 기부를 하고싶다면 이 링크로 와보세요! 🥰 (클릭!)

바이낸스(₿) 수수료 평생 20% 할인받는 링크로 가입하기! 🔥 (클릭!)

들어가기에 앞서, 혹시 모를 수 있는 용어정리

더보기

Generic : 다양한 메소드를 사용할 때 클래스를 사용해 객체를 인스턴스화 할 때 필요한 Type을 지정해줄 수 있도록 프로그래밍하는 문법이다.

아래와 같은 예시가 있곘다.

 

class GenericStack<T> {
    stack: T[];
    constructor() { 
        this.stack = [];
    }
    addItem(item: T): void {
        this.stack.push(item);
    }
    toString(): string { 
        return this.stack.join(', ');
    }
    get(index?: number): any { 
        return index ? this.stack[index] : this.stack;
    }
}
const stringStack = new GenericStack<string>();
const numberStack = new GenericStack<number>();

// 이렇게라던지, 

function identify<T>(arg: T): any {
    return arg;
}
const numberArg = identify<number>(5);
const stringArg = identify<string>('STRING');

 

 

Parameterized Type : 위의 제네릭 타입에서 실질적으로 파라미터를 넣은 상태로 호출하는 것.

 

const stringStack = new GenericStack<string>();
const numberStack = new GenericStack<number>();
//위와 같이 선언할 때 파라미터화된 타입이 사용된다.
stringStack.addItem('hi');
numberStack.addItem(1);

 

Type Class : 상속을 하지 않고 기능을 확장할 수 있게 해주는 패턴이다. 여러개의 타입에 대한 오버로딩을 지원하는 것을 polymorphism의 예시라고 할 수 있다. 이와 비슷하게 Type Class는 객체지향에서 기능을 확장할때 주로 사용하는 상속 등을 사용하지 않고 코드의 수정 없이 기능을 확장할 수 있다. Type Class는 아래의 3가지 주요 컴포넌트로 구성된다.

 

- the type class itself (Type Class 본체)

 

타입 클래스는 우리가 특정 기능을 구현하기 위해 일반적인 특징을 표현하는 것을 말한다. 실제 사용 시 Generic Type으로 연산로직을 오버로딩시킨다.

@ trait Logger[T] {
      def logging(value:T):String
}
defined trait Mergeable

 


- instances for particular types  (세부 타입별 연산을 구현한 인스턴스)

타입 클래스 인스턴스는 스칼라의 표준 타입에 대한 구현을 미리 작성해 두거나 우리가 쓰고자 하는 커스텀 타입에 대한 구현을 정의한 object를 말한다. 위에서 정의한 Type Class를 구현하여 Generic type T의 실제 타입별로 연산 로직 코드를 구현한다. implicit 으로 해당 인스턴스를 선언해 두면 해당 타입에 대해서는 컴파일러가 자동으로 해당 구현체로 일을 한다. 아래는 int와 string에 대해서 서로 다른 일을 하는 코드이다.

 

@ import java.time._
import java.time._

@ object Loggers {
      implicit val i = new Logger[Int] {
          def logging(value:Int):String = LocalDateTime.now().toString + " - " + value.toString
      }

      implicit val s = new Logger[String] {
          def logging(value:String):String = LocalDateTime.now().toString + " - " + value
      }
  }
defined object Loggers

 


- interface methods that we expose to users (Type Calss를 사용하는 인터페이스)

인터페이스는 사용자들이 사용하게 될 메소드를 정의 하는 것을 말하며 해당 메소드는 implicit 파라미터로 기능을 전달받게 된다. 그리고 이러한 인터페이스는 Object로 정의하거나 Syntax로 정의할 수 있다.

이제 실제 사용할 함수를 정의해보자. Object를 활용하는 방법은 아래와 같은 코드로 구현 된다. 싱글톤으로 제공하고 있다. 

object Log {
    def logging[T](value:T)(implicit logger:Logger[T]):String = {
        logger.logging(value);
    }
}
defined object Log

@ import Loggers._
import Loggers._

@ Log.logging(100000)
res5: String = "2019-10-08T17:26:06.570 - 100000"

@ Log.logging("this is test")
res7: String = "2019-10-08T17:33:29.346 - this is tes

 

위처럼 구현하는 것 말고도 Syntax처럼 호출할 수 있게 implicit class 기능을 활용할 수도 있다.

@ object Log {
    implicit class LogConverter[T](value:T) {
        def logging(implicit logger:Logger[T]):String = {
              logger.logging(value);
        }
    }
}
defined object Log 

@ import Loggers._
import Loggers._

@ import Log._
import Log._

@ "test".logging
res6: String = "2019-10-10T10:56:55.778 - test"

@ 1000.logging
res7: String = "2019-10-10T10:57:03.095 - 1000"

 <특정 타입 변수 or 값>.<Type Class를 사용하는 인터페이스> 형식으로 프로그램 Syntax처럼 호출하는 것을 볼 수 있다. implicit class 변환이 cool~~ 한 기능을 해주고 있는 것을 볼 수 있다. (ㅎㅎ... scala implicit 공부... 해야겠다....)

 

 

Functor : 펑터는 typeclass이다. 아래는 정의를 설명한 그림이다.

 Functor은 fmap이 어떻게 적용되어야 하는지 정의하고 있는 함수이다. 이때, fmap은

 위와 같이 일을 한다.

 즉, function과 functor을 입력받고 함수를 적용한 새로운 functor을 리턴해주는 일을 한다.

 

 


흔히, Monad의 기능에 대해 설명하는 글들이 설명하는 것.

 

일반적인 함수의 조합

def f : Int => Boolean =
  (a:Int) => if (a > 0) true else false

def g : Boolean => String =
  (b:Boolean) => if (b) "Good" else "Bad"

//  f와 g 함수의 조합  g ∘ f  
def h(c:Int) : String = g(f(c))
//  또는 
def h : Int => String = f andThen g 

위는 일반적인 함수의 조합이다.

 

f : A => B

g : B => C 를 수행하는 어떤 함수가 있을 때 둘의 연산을 조합하는 것은

h : A=>C = g(f(c))를 통해서 가능하다.

 

option등으로 감싸고 같은 행동을 해 보기

def fOpt : Int => Option[Boolean] =
  (a:Int) => if (a > 0) Some(true) else if (a < 0) Some(false) else None

def gOpt : Boolean => Option[String] =
  (b:Boolean) => if (b) Some("Good") else Some("Bad")

이때, 무엇인가를 감싸서 내보내 준다고 생각 해 보자. (Option과 같은)

 

f : A => T[B]

g : B => T[C]

 

이 과정은 g(f(A)) 로 묶일 수 없다.

이 묶인 것을 풀어주는 (모나드에 소속된) 연산이 필요하다.

그 역할을 하는 게 flatMap이라는 연산이다.

 

flatMap을 통해서 해결하기

def hOpt(x:A) = fOpt(_) flatMap gOpt

//혹은
def hOpt = fOpt(_:A) flatMap gOpt

fOpt(~)에 flatMap 연산을 적용시켜 gOpt 함수에 넣어준다는 뜻이다.

 

 

 

자 이제 모나드에 소속된 연산이 뭔지, 모나드는 뭘 하는지 알지 못하지만 대략적으로 뭔가가 이루어지는 과정은 보았다.

 

 

대체 모나드란 무엇일까?

 

 

모나드 Laws에 대한 설명 슬라이드이다.

자세히 읽고 있자면 체한 음식이 솔솔 내려가듯 느릿 느릿 이해가 된다.

 

Monad는 어떤 값을 감싸는 Wrapper인데, 그 중에서도 위의 3개의 Laws를 만족해야만 Monad가 된다.

 

그 중 처음보면 헷갈릴 수 있는 표기가

결합법칙(associativity) :

m flatMap f flatMap g 는 m flatmap ( x => f(x) flatMap g )

의 경우 오른쪽 식을 먼저 계산해도 같은 결과를 보장해야 한다는 뜻이다.

더하기로 보면 (1+2) + 3 과 1 + (2+3) 이 같은 결과임을 보장하는 것 처럼.

 

 

그리고 이 것을 만족하는 Wrapper은 다음과 같은 두가지 기능을 제공한다.

 

1. unit (혹은 identity라고도 불림): Monad는 특정 값에 대한 감싸기 규칙을 만든다.

2. flatMap (다른 곳에서는 bind 혹은 >>= 연산자로도 쓰임) : 감싼 값에 대해 꺼낼 수 있는 방법을 제공한다. 그 값을 가지고 원하는 형식으로 변형하고, 그 값을 감싸서 반환한다.

 

 

이제, 본격 Scala 코드에서 예를 들어보자. (소스는 아래 카카오 참고 링크에서 가져왔읍니다)

 

아래와 같은 Async interface가 정의되어 있다고 하자.

// 세션 정보를 이용하여 유저 정보를 가져옴
def getUser(session: String): Future[User] = userApi.getAsync(session)

// 특정 유저의 주문 정보를 가져옴
def getOrder(userId: Int): Future[Order] = orderApi.getAsync(userId)

// 특정 주문의 상품 내역을 가져옴
def getOrderItems(orderId: Int): Future[List[Item]] = itemApi.getAsync(orderId)

이것을 Blocking IO를 통해서 실행 해 보자. Future에 값이 들어 있기 때문에 Await.result를 사용한다.

// 유저 정보를 가져옴
val userFuture = getUser(user_session)
val user = Await.result(userFuture, timeout)  // Thread waiting during I/O

// 주문 정보를 가져옴
val orderFuture = getOrder(user.id)
val order = Await.result(orderFuture, timeout)  // Thread waiting during I/O

// 특정 주문의 상품 내역을 가져옴
val itemsFuture = getOrderItems(order.id)
val items = Await.result(itemsFuture, timeout)  // Thread waiting during I/O
println(s"## User Order Items : ${items}")

위의 코드는 비동기가 아니다. await을 사용하기 때문이다.

이는 flatMap 연산자를 통해서

1. Future의 결과를 벗기고

2. 그 결과를 새로운 함수에 넣기

과정을 반복해서 결국 itemsFuture에서 해당 입력을 받게 된다.

// flatMap을 이용한 3개의 future를 compostion해서 결과를 얻을 수 있습니다.

val itemsFuture : Future[List[Item]] =
  getUser(user_session).flatMap { case user =>
    getOrder(user.id).flatMap { case order =>
      getOrderItems(order.id)
    }
  }
itemsFuture.foreach { items =>
  println(s"## User Order Items : ${items}")
}

위와 같은 callback 패턴들을 for comprehension으로 줄일 수 있다.

// for comprehension을 이용하여 callback을 사용하지 않고 3개의 함수를 합성하였다.
val itemsFuture : Future[List[Item]] = for {
  user <- getUserId(user_session)
  order <- getOrder(user.id)
  items <- getOrderItems(order.id)
} yield items

itemsFuture.map { items =>
  println(s"## User Order Items : ${items}")
}

python의 for comprehension과는 기능이나 목적이 조금 다른데, 그냥 읽어지는 그대로 보면 된다.

윗줄에서 user을 추출하고, 그걸 다시 getOrder넣고 order 받아오고, order넣고 item 받아오고, 마지막으로 items를 yeild하면 그게 itemsFuture에 쏙 들어간다.

 

이 과정이 위에서 말한 T[A] -> f(A) 후 T[B]를 리턴받는 과정임을 볼 수 있다.

이를 통해 flatMap을 사용하는 코드보다 가독성이나 유지보수면에서 이득을 취할 수 있게 된 것 같아 보인다.

이때 scala async를 사용하면 조금 더 깔끔하게 바꿀 수 있다.

build.sbt에 아래 의존성을 추가하자.

libraryDependencies += "org.scala-lang.modules" %% "scala-async" % "0.9.5"

그리고 그 후 async await 키워드를 사용하여 비동기 프로그래밍을 동기 프로그래밍과 비슷하게 구현 가능하다.

import scala.async.Async.{async, await}

// non blocking 영역이 된다.
async {
  // async 영역 안에서의 await는 thread를 blocking 하지 않는다.
  val user = await(getUser(userSession))
  val order = await(getOrder(user.id))
  val items : List[Item] = await(getOrderItems(order.id))
  println(s"## User Order Items : ${items}")
}

우리는 위와 같이 flatMap이나 for comprehention을 활용하는 scala의 monad 타입을 처리하는 과정을 살펴보았다.

 

 

 

그럼 Scala에서 Monad는 어떤 종류의 것들이 있을까?

 

Scala에는 많은 타입이 flatMap과 for comprehention을 활용할 수 있는 monad 혹은 monadic type으로 제공된다.

(*Monadic이란 monad의 법칙을 모두 만족시키지는 못하지만 그럼에도 monad스럽게 사용할 수 있다는 의미)

예를 들어

Option[T]

Try[T]

List[T]

Future[T]

...

등등등이 그 예시이다. flatMap 함수가 있다면 Monad type 친구들이라고 생각해도 크게 무리가 없을 것이라고 한다.

(Future이나 Try 등은 결합법칙을 만족하지 못해서 pure Monad는 아니고 Monadic이라고 부르는게 맞다고 한다)

 

 

 

 

 

 

 

참고 링크

https://github.com/enshahar/BasicFPinScala/blob/master/Intermediate/Monad.md

https://okky.kr/article/377198

https://tech.kakao.com/2016/03/03/monad-programming-with-scala-future/

https://velog.io/@victor/Generic-%EC%A0%9C%EB%84%A4%EB%A6%AD%EC%9D%B4%EB%9E%80

http://adit.io/posts/2013-04-17-functors,_applicatives,_and_monads_in_pictures.html

https://www.bench87.com/#!/content/21?id=21

https://signal9.co.kr/scala_type_class/ (https://webcache.googleusercontent.com/search?q=cache:q3TiseIeY6EJ:https://signal9.co.kr/scala_type_class/+&cd=14&hl=ko&ct=clnk&gl=kr)