병렬처리하는 법을 알아보자.

예를 들어보자.
[1, 2, 3, 4, 5 ,6, 7, 3, 32, 12, 23, 18, 10] 리스트의 합계를 구해보자.

fold를 사용해 계산한다고 하면, 이렇게 될 것이다.

1 + 2 + ...
3 + 4 + ...
7 + 5 + ...
12 + 6 + ...
18 + 7 + ...
25 + 3 + ...
28 + 32 + ...
60 + 12 + ...
72 + 23 + ...
95 + 18 + ...
113 + 10
123

총 시간은 O(n)만큼 걸린다.
분할 정복을 사용해보자.

[1, 2, 3, 4, 5 ,6, 7,]
[3, 32, 12, 23, 18, 10]
두 리스트의 합으로 볼 수 있다.
더 잘게 쪼개면

[1, 2, 3, 4]
[5 ,6, 7,]
[3, 32, 12]
[23, 18, 10]
이렇게 계속 쪼갤 수 있다.
쪼갠 각 리스트의 합을 모두 더하면 전체 합을 구할 수 있죠.

자 그럼 이제 병렬 처리할 단위를 구할 수 있습니다.
쪼갠 리스트를 각 스레드에 할당해서 계산하면 되겠죠~

사실 이번 장에서는 병렬처리를 어떻게 구현하느냐는 관심없스요.
그냥 병렬처리를 사용하는 함수형 API를 만드는 것이 목적인 것이요.

위에서의 예제를 코드로 봐볼게요.

fun sum(ints: List<Int>): Int =  
    if (ints.size <= 1) {  
        ints.firstOrNull() ?: 0  
    } else {  
        val l = ints.subList(0, ints.size / 2)  
        val r = ints.subList(ints.size / 2, ints.size)  
        sum(l) + sum(r)  
    }

sum(l) + sum(r) 에서 각 계산을 병렬로 처리해야하죠! 근데 지금은 sum(l)만 주구장창 계산하다. sum(r)을 계산하기 시작할거에요.

그래서 이건 병렬 계산이에요~ 라는 걸 알려줄 자료형을 정해봐요.

Parallel을 줄여서 Par 이라고 할게요.

class Par<ITEM>(val get: ITEM)  

fun <ITEM> unit(item: () -> ITEM): Par<ITEM> = Par(item())

fun <ITEM> get(item: Par<ITEM>): ITEM = item.get  

병렬계산을 하고 계산 결과를 item에 담는 자료형을 만들었습니다.
예제에 Par을 써보죠

fun sum2(ints: List<Int>): Int =  
    if (ints.size <= 1) {  
        ints.firstOrNull() ?: 0  
    } else {  
        val l = ints.subList(0, ints.size / 2)  
        val r = ints.subList(ints.size / 2, ints.size)  
        val sumL: Par<Int> = unit { sum2(l) }  
        val sumR: Par<Int> = unit { sum2(r) }  
        sumL.get + sumR.get  
    }

sumL.get + sumR.get 가 호출되기 전까지는 sum(l) , sum(r) 가 평가되지 않는다.
하지만 get을 호출하면 sum(l)이 모두 평가되고 난 후 sum(r)이 순차적으로 평가 될 것이다.

병렬처리를 하고싶어 만들었지만 실제로는 순차처리되는 것이다..

위 코드에서 sumLsumR을 치환해보면 참조투명성을 깨는 것을 볼 수 있다.

unit { sum(l) }.get + unit { sum(r) }.get

결과는 똑같겠지만, unit { sum(l) } 이 모두 평가되어야 unit { sum(r) }이 초기화된다.
위에서 본 것과는 차이가 있다.
다만 get을 사용하지 않는다면 이런 참조 투명성을 해치는 부수효과가 없다.

이 부수효과를 해결하기 위해서 get 호출을 최대한 미뤄야한다.

어떻게 해야할까.

일단 get 호출을 하지 않는다는 것을 전제로하자.

그럼 sum 함수는 Par<Int> 를 반환하는 게 맞다.

fun sum3(ints: List<Int>): Par<Int> =  
    if (ints.size <= 1) {  
        unit { ints.firstOrNull() ?: 0 }  
    } else {  
        val l = ints.subList(0, ints.size / 2)  
        val r = ints.subList(ints.size / 2, ints.size)  
        map2(sum3(l), sum3(r)) { lx: Int, rx: Int -> lx + rx }  
    }

map2 함수를 만들어서 구현했다.

fun <ITEM1, ITEM2, RESULT> map2(item1: Par<ITEM1>, item2: Par<ITEM2>, block: (ITEM1, ITEM2) -> RESULT): Par<RESULT> =  
    unit { block(item1.get, item2.get) }

이렇게 정의할 수 있다.
재귀적인 경우 더이상 unit을 호출하지 않는다.

자 그럼 이제 논의할 것은 map2의 인자를 지연 계산으로 받을 것이냐이다.

map2가 엄격한 인자를 사용할 때 문제점은 다음과 같다.

sum(listOf(1,2,3,4))

map2(
    sum(listOf(1, 2)),
    sum(listOf(3, 4))
)

map2(
    map2(
        listOf(1),
        listOf(2)
    ),
    sum(listOf(3, 4))
)

map2(
    map2(
        listOf(1),
        listOf(2)
    ),
    map2(
        listOf(3),
        listOf(4)
    ),
)

이렇게 왼쪽을 완전히 계산한 다음 오른쪽을 계산하게 된다.
두 파라미터를 동시에 병렬 계산하지 않는 것이다.

두 파라미터를 동시에 병렬계산 하도록 하려면 map2를 지연계산으로 만들어야한다.
이렇게 결정하고

다음으로 볼 것은 map2는 항상 지연계산일 필요가 있냐는 것이다.
map2(
    unit { 1 },
    unit { 2 }
)

이 경우에는 굳이 병렬로 할 필요가 없다. 오히려 성능이 안좋아진다.
스레드 분기를 명확하게 만들어주자.
다른 스레드에서 실행돼요~ 라는 의미를 가지는 fork 함수를 만들겠어요.

fun sum4(ints: List<Int>): Par<Int> =  
    if (ints.size <= 1) {  
        lazyUnit { ints.firstOrNull() ?: 0 }  
    } else {  
        val l = ints.subList(0, ints.size / 2)  
        val r = ints.subList(ints.size / 2, ints.size)  
        map2(fork { sum4(l) }, fork { sum4(r) }) { lx: Int, rx: Int -> 
            lx + rx }
    }

그렇다면 인자가 적으면 별도의 스레드를 사용하지 않겠다. 는 어떻게 할 수 있을까.

fun sum4(ints: List<Int>): Par<Int> =  
    if (ints.size <= 1) {  
        lazyUnit { ints.firstOrNull() ?: 0 }  
    } else {  
        val l = ints.subList(0, ints.size / 2)  
        val r = ints.subList(ints.size / 2, ints.size)  
        if (l.size <= 2) {
            map2(sum4(l), fork { sum4(r) }) { lx: Int, rx: Int -> 
                lx + rx }
        } else {
            map2(fork { sum4(l) }, fork { sum4(r) }) { lx: Int, rx: Int -> 
                lx + rx }
        }
    }

이렇게하는 걸까.

자. 어쨌든 fork를 써서 "별도의 스레드에서 실행하겠어"라는 것을 만들어줬다.

그럼 unit은 지연 계산할 필요가 있을까?
fun <ITEM> unit(item: () -> ITEM): Par<ITEM> = Par(item())

unit 은 이렇게 만들었다.
앞에서 fork를 만들었으니, unit을 지연 계산하려면, fork를 사용하는 방법을 생각해 볼 수 있다.
그러면 엄격한 unit과 엄격하지 않은 unit을 만들 수 있다.

fun <ITEM> unit(item: ITEM): Par<ITEM> = Par(item)
fun <ITEM> lazyUnit(item: () -> ITEM): Par<ITEM> = 
    fork { unit(item()) }

unit같은 기본형 콤비네이터로, lazyUnit같은 파생형 콤비네이터를 만들어냈다.

스레드 분기는 언제해야할까?
  • fork를 호출했을 때 해야할까?
  • get으로 평가를 했을 때 스레드를 분기해야 할까?
  • fork에 스레드를 분기 책임이 있는 경우
    • fork의 구현은 스레드를 생성하고, 작업을 스레드 풀에 제출하는 방법을 알아야함
    • => 스레드풀 같은 곳에 전역적으로 접근이 가능해야함
    • => fork를 호출하는 시점에 스레드풀 같은 자원이 초기화 되어있어야함
    • 이는 프로그램에 여러 부분에서 원하는 대로 병렬성 전략을 제어할 수 있는 능력을 잃는다는 것
  • *get이 스레드 생성과 실행할 작업을 스레드 풀에 제출하는 책임을 가지는 게 더 적합하다.
    • ??

이제 fork를 통해서 병렬계산을하라고 '표시' 할 수 있고, Par는 해당 계산이 병렬 계산이라는 것을 알려주는 자료형이다.
사실상 fork와 Par는 병렬처리의 내부구현에 대해 알 필요가 없다.
Par는 그냥 값을 담는 컨테이너로만 생각하면 된다.

fun <ITEM> run(item: Par<ITEM>): ITEM = TODO()

run이란 함수를 만들어서 run이 스레드를 시작하거나, 스레드 풀에 제출하거나 하는 등의 방법을 통해 병렬성을 구현하게 한다.

병렬 처리는 어떻게 해야할까?

java.util.concurrent.ExecutorService 클래스를 사용하자.

interface Callable<ITEM> {  
    fun call(): ITEM  
}  

interface Future<ITEM> {  
    fun get(): ITEM  
    fun get(timeout: Long, timeUnit: TimeUnit): ITEM  
    fun cancel(evenIfRunning: Boolean): Boolean  
    fun isDone(): Boolean  
    fun isCancelled(): Boolean  
}  

interface ExecutorService {  
    fun <ITEM> submit(c: Callable<ITEM>): Future<ITEM>  
}

대충 코틀린으로 찌끄려보면 이렇다.

ExecutorService에는 Callable을 제출할 수 있다.
Future는 새로운 드레드에서 실행될 수도 있는 계산을 가리키는 핸들이다.
Future의 블록킹 메소드인 get을 호출해서 결과를 얻어올 수 있다.

ExecutorService를 사용해서 run을 구현해보자.
typealias Par<ITEM> = (ExecutorService) -> Future<ITEM>  
fun <ITEM> run(es: ExecutorService, item: Par<ITEM>): Future<ITEM> 
    = item(es)

Par 타입을 새로 정의하고 ExecutorService를 전달하도록 했다.
ExcutorSevice를 전달해야만 Future가 생긴다.

이제 우리가 만든 API를 탐구하고 다듬어보자.
object Pars {  
    fun <ITEM> unit(item: ITEM): Par<ITEM> = { es -> UnitFuture(item) }  

    data class UnitFuture<ITEM>(val item: ITEM) : Future<ITEM> {  
        override fun get(): ITEM = item  

        override fun get(timeout: Long, timeUnit: TimeUnit): ITEM = item  

        override fun cancel(evenIfRunning: Boolean): Boolean = false  

        override fun isDone(): Boolean = true  

        override fun isCancelled(): Boolean = false  
    }  

    fun <ITEM1, ITEM2, RESULT> map2(  
        item1: Par<ITEM1>,  
        item2: Par<ITEM2>,  
        block: (ITEM1, ITEM2) -> RESULT  
    ): Par<RESULT> = { es ->  
        val future1 = item1(es)  
        val future2 = item2(es)  
        UnitFuture(block(future1.get(), future2.get()))  
    }  

    fun <ITEM> fork(  
        item: () -> Par<ITEM>  
    ): Par<ITEM> = { es: ExecutorService ->  
        es.submit(object : Callable<ITEM> {  
            override fun call(): ITEM = item()(es).get()  
        })  
    }  
}

이렇게 Future의 구현체로 UnitFuture를 만들고 map2fork를 새로운 자료형을 통해 정의했다.

여기서 map2block()의 호출을 별도의 스레드에서 하지 않는다.
병렬성을 제어하려면 fork를 사용해야한다.
block()의 평가를 별도의 스레드에서 수행하고 싶다면 fork로 감싸주자.

Pars.fork {  
    Pars.map2(Pars.unit(1), Pars.unit(2)) { item1, item2 ->  
        item1 + item2  
    }  
}
  • 문제점

    1. map2구현은 타임아웃을 준수하지 않는다.
    2. fork 구현에서 Callable은 내부 작업이 완료될 때 까지 블록된다.
    3. Future에는 순수함수형 인터체이스가 없다.
      • 고로 사용자가 Future를 직접 다루지 못하게 한다.
      • 하지만, Future에 부수효과가 있더라도, 우리가 만든 Par API는 순수함수형으로 남는다. ??
  • 연습문제 7.3

    • Future의 타임아웃을 준수하도록 map2 구현을 수정하라

      fun <ITEM1, ITEM2, RESULT> map2_timeout(  
      item1: Par<ITEM1>,  
      item2: Par<ITEM2>,  
      block: (ITEM1, ITEM2) -> RESULT  
      ): Par<RESULT> = { es ->  
      val future1 = item1(es)  
      val future2 = item2(es)  
      TimedMap2Future(future1, future2, block)   
      }

data class TimedMap2Future<ITEM1, ITEM2, RESULT>(
val item1: Future,
val item2: Future,
val block: (ITEM1, ITEM2) -> RESULT
) : Future {
override fun get(): RESULT {
TODO("Not yet implemented")
}

override fun get(timeout: Long, timeUnit: TimeUnit): RESULT {  
    val timeoutMillis = TimeUnit.MICROSECONDS.convert(timeout, timeUnit)  
    val start = System.currentTimeMillis()  
    val a = item1.get(timeout, timeUnit)  
    val duration = System.currentTimeMillis() - start  
    val remainder = timeoutMillis - duration  
    val b = item2.get(remainder, TimeUnit.MICROSECONDS)  
    return block(a, b)  
}  

override fun cancel(evenIfRunning: Boolean): Boolean {  
    TODO("Not yet implemented")  
}  
override fun isDone(): Boolean {  
    TODO("Not yet implemented")  
}  
override fun isCancelled(): Boolean {  
    TODO("Not yet implemented")  
}  

}


- **연습문제 7.4**
    - lazyUnit을 사용해 (A) -> B 타입의 임의의 함수를 비동기적으로 결과를 평가하는 함수로 변환하는 asyncF를 작성하라.
```kotlin
fun <ITEM, RESULT> asyncF(f: (ITEM) -> RESULT): (ITEM) -> Par<RESULT> =
    { item ->
        lazyUnit { f(item) }
    }
기존 콤비네이터로 더 구현해보자.
sortPar
  • Par<List<Int>>를 정렬해보자.
  1. Par.run()해서 평가하고, 결과 리스트를 받아 정렬한 다음 unit을 이용해 다시 감싸기
  2. map2를 이용해 내부 리스트에 접근하기
fun sortPar(parList: Par<List<Int>>): Par<List<Int>> =  
    map2(parList, unit(Unit)) { list, _ -> list.sorted() }
  • map2를 통해서만 내부의 값을 바꿀 수 있다.

    • 그러니 우선 map2를 써야겠다.
    • 하나의 인자만 필요하니 다른 건 빈값을 넣자.
  • 이제는 Par<A>타입을 받아 Par<B>를 반환하는 map연산을 만들 수 있다.

  • fun <ITEM, RESULT> map(pa: Par<ITEM>, block: (ITEM) -> RESULT): Par<RESULT> = map2(pa, unit(Unit)) { a, _ -> block(a) }

  • 이제 다시, map으로 sortPar를 구현해보자.

  • fun sortPar2(parList: Par<List<Int>>): Par<List<Int>> = map(parList) { it.sorted() }

  • map2map을 구현하였다.

    • 이렇다는 것은, map2가 더 강력한 기본 요소인 것이다.

      parMap
  • 리스트에 대해 병렬로 map을 수행해보자.

fun <ITEM, RESULT> parMap(  
    list: List<ITEM>,  
    block: (ITEM) -> RESULT  
): Par<List<RESULT>> {  
    val fbs: List<Par<RESULT>> = list.map(asyncF(block))  
    TODO()  
}
  • 우선 List<ITEM>List<Par<RESULT>타입으로 바꿔주었다.

  • 그 다음은, List<Par<RESULT>>Par<List<RESULT>>로 바꿔주면 된다.

    • 이 연산은 무엇일까?
      • sequence이다.
  • 연습문제 7.5

    • run을 호출하지 않고 sequence를 작성하여라

      val <T> List<T>.head: T  
      get() = first()  

val List.tail: List
get() = this.drop(1)

val Nil = listOf()

fun sequence1(ps: List): Par =
when (ps) {
Nil -> unit(Nil)
else -> map2(
ps.head,
sequence1(ps.tail)
) { a: ITEM, b: List ->
listOf(a) + b
}
}

fun sequence(ps: List): Par =
when {
ps.isEmpty() -> unit(Nil)
ps.size == 1 -> map(ps.head) { listOf(it) }
else -> {
val l = ps.subList(0, ps.size / 2)
val r = ps.subList(ps.size / 2, ps.size)
map2(sequence(l), sequence(r)) { la, lb ->
la + lb
}
}
}

- 이렇게 2가지 버전으로 기본연산 `sequence`를 만들었다.

- 이제 `parMap`을 구현할 수 있다.
```kotlin
fun <ITEM, RESULT> parMap(  
    list: List<ITEM>,  
    block: (ITEM) -> RESULT  
): Par<List<RESULT>> {  
    val fbs: List<Par<RESULT>> = list.map(asyncF(block))  
    return sequence1(fbs)
}
  • 연습문제 7.6

    • 리스트의 원소를 병렬로 걸러내는 parFilter 구현

      fun <ITEM> parFilter(
      list: List<ITEM>, 
      block: (ITEM) -> Boolean
      ): Par<List<ITEM>> {  
      val pars: List<Par<ITEM>> = list.map { lazyUnit { it } }  
      return map(sequence(pars)) { la ->  
        la.flatMap { a ->  
            if (block(a)) listOf(a) else emptyList()  
        }  
      }
      }
정의한 API의 법칙을 형식화해보자.
  • 매핑(mapping)
  • 논리 스레드 분기(forking)

두 가지에 대해 알아보자.

매핑 법칙
map(unit(1)) { it + 1 } == unit(2)

이 식은 unit(1){ i + 1 }로 매핑한 것이 unit(2)와 같음을 뜻한다.
Par객체가 동치라는 것은 모든 올바른 ExecutorService에 대해 두 Par 객체가 내놓는Future의 결과가 같다는 뜻이다.

이 함수를 일반화 해보자.
map(unit(x), f) == unit(f(x))

이 법칙은 우리에게 몇가지 제약을 준다.

  1. unit 구현은 자신이 받는 값을 들여다 볼 수 없다. 그저 전달할 뿐이다.
    • ExecutorServiceCallable을 전달할 때도 비슷했다. Callable객체에 대한 어떤 가정도 할 수 없고 Callable에 따라 어떤 분기도 할 수 없다. 그저 실행할 뿐이다.
  2. map과 unit의 구현에서 다운캐스팅을 하거나 타입캐스팅을 금지한다.
앞서 본 규칙을 더 단순화해보자.
  • 어떤 x와 f를 사용하든 법칙이 성립해야한다.
  • f가 항등함수라면 어떨까? fun <A> id(a: A):A = a
val x = 1
val y = unit(x)
val f = { a: Int -> a + 1 }
val id = { a: Int -> a }

// 초기 법칙
map(unit(x), f) == unit(f(x))

// f를 id로 바꿔보자.
map(unit(x), id) == unit(id(x))
map(unit(x), id) == unit(x) // id(x) == x
map(y, id) == y // unit(x) == y
  • unit에 대한 언급 없이 map에 대해서만 성립하는 법칙을 만들었다.
  • map이 할 수 있는 일은 f의 결과를 y에 적용시키는 것이다.
    • map은 함수를 적용시키기 전에 예외를 던지거나 비정상 종료를 할 수 없다.
    • f가 항등함수인 id라면 y는 영향받지 않고 반환돼야한다.
      • 이를 map이 구조보존적이어야 한다 라고 말한다.
논리 스레드 분기의 법칙
  • fork는 병렬 계산의 결과에 영향을 끼치면 안된다. 라는 법칙이 있다.
fork { x } = x
  • fork는 주 스레드와 분기된 별도의 논리적 스레드에서 비동기적으로 처리된다는 점을 제외하면 x와 동일하게 일을 해야한다.
  • 이 단순한 속성은 fork 구현을 강하게 제약한다.
    • 이렇게 법칙을 만들었으면 법칙을 깨보려고 시도해봐야한다.
두 Par 인스턴스가 동등한지 검사하는 단언문 함수를 작성해보자.
infix fun <A> Par<A>.shouldBe(other: Par<A>) = { es ->
    if (this(es).get() != other(es).get())
        throw AssertionError("Par instance not equal")
}
  • 이 단언문 메소드를 사용하면 fork 구현에서 발생하는 미묘한 문제를 찾아낼 수 있다.
    • 스레드 풀이 고정된 ExecutorService를 쓰면 교착 상태에 빠지기 쉽다.
val es = Executors.newFixedThreadPool(1)

val a: Par<Int> = lazyUnit { 42 + 1 }
val b: Par<Int> = fork { a }

(a shouldBe b)(es)
  • 위 코드에서는 교착 상태가 발생한다.

  • 왜그럴까?

    • fork의 구현을 보자.

      fun <ITEM> fork(  
      item: () -> Par<ITEM>  
      ): Par<ITEM> = { es: ExecutorService ->  
      es.submit(object : Callable<ITEM> {  
        override fun call(): ITEM = item()(es).get()  
      })  
      }
  • fork 안에서 Callable을 받는다.

  • fork { a }fork { lazyUnit { 42 + 1 } }이고, 이는 fork { fork { unit() } } 이다.

    • fork 안에서 fork를 호출하고 있는것이다.
    • 그러다 보니 받은 ExecutorServiceCallable을 전달해서 평가하는데 그 안에서 또 ExecutorServiceCallable을 제출하는 것이다.
    • 근데 스레드 풀은 1개니까 교착상태가 발생한다.
  • 이렇게 fork 구현에 대한 반례를 찾았다.

    • 해결 방안
      1. 법칙이 성립하도록 구현을 수정
      2. 법칙이 언제 성립하는지를 더 명확히 기술하도록 법칙을 세밀화
        • e.g. 스레드풀은 제한없이 커질 수 있다.
        • 이런 법칙들은 문서화해야함
  • fork를 수정해서 고정 크기의 스레드 풀에서도 정상동작하게 할 수 있을지 보자.

  • fun <A> fork(pa: () -> Par<A>): Par<A> = { es -> pa()(es) }

  • pa를 바로 평가했다.

    • 이는 교착 상태를 방지해준다.
    • 하지만 논리적 스레드를 분기하지 않았다.
      • fork의 의도와 다르다.
      • 하지만, 필요할 때까지 계산을 인스턴스화 하는 것을 지연시키므로, 유용하다.
  • delay라는 이름으로 바꾸겠다.

  • fun <A> delay(pa: () -> Par<A>): Par<A> = { es -> pa()(es) }

콤비네이터를 일반적인 형태로 세분화 해보자.
  • API를 구현하며 새로운 콤비네이터가 필요할 때가 있다.
  • 새 콤비네이터를 만들기 전에 필요한 콤비네이터를 가장 일반적인 형태로 세분화할 수 있는지 생각해봐야한다.
  • 필요한 콤비네이터가 더 일반적인 콤비네이터의 특별한 경우일 수 있기 떄문이다.

세분화하는 과정의 예시를 살펴보자.

fun <ITEM> choice(
    condition: Par<Boolean>, 
    t: Par<ITEM>, 
    f: Par<ITEM>
): Par<ITEM>
  • condition의 결과에 따라 t, f를 실행하고 싶다.
  • condition의 평가를 블록한 상태로 기다리고 그 결과를 통해 어느 한쪽을 실행하도록 처리하면 된다.
fun <ITEM> choice(
    condition: Par<Boolean>, 
    t: Par<ITEM>, 
    f: Par<ITEM>
): Par<ITEM> = { es: ExecutorService -> 
    when (run(es, condition).get()) {
        true -> run(es, t)
        false -> run(es, f)
    }
}
의심해보자.
  • Boolean을 사용하고 두 개 중에 한개만 택한다는 것은 좀 임의적인 것 같다.

  • 두개여야 할 이유는 무엇일까, N개를 받도록 변경해보자.

  • 연습문제 7.10

    • choiceN을 구현하라.

    • choiceN을 이용해 choice를 구현하라.

      fun <ITEM> choiceN(n: Par<Int>, choices: List<Par<ITEM>>): Par<ITEM>
      = { es: ExecutorService ->  
        run(es, choices[run(es, cond).get()])  
      }
의심해보자.
  • 왜 List일까. Map으로 해서 1:1로 찾을 수 있지 않을까?

  • 연습문제 7.11

    • choiceMap 콤비네이터를 구현하라.

      fun <K, V> choiceMap(
      key: Par<K>,
      choices: Map<K, Par<V>>
      ): Par<V> = { es: ExecutorService ->  
      run(es, choices.getValue(run(es, key).get()))  
      }
의심해보자.
  • Map도 너무 임의적이지 않나

  • 실제로 여기서 맵은 (K) -> Par<V>를 반환하는 것만 한다.

  • 모든 케이스를 아우르는 API를 만들자.

  • 연습문제 7.12

    • 새 기본요소 chooser를 구현하라.

    • chooser를 사용해서 choice, choiceN, choiceMap을 구현하라.

      fun <A, B> chooser(pa: Par<A>, choices: (A) -> Par<B>): Par<B>
      = { es: ExecutorService ->  
        run(es, choices(run(es, pa).get()))  
      }

fun choice_2(
cond: Par,
t: Par,
f: Par
): Par = chooser(cond) {
when (it) {
true -> t
false -> f
}
}

fun choiceN_2(
cond: Par,
choices: List
): Par = chooser(cond) {
choices[it]
}

fun <K, V> choiceMap_2(
cond: Par,
choices: Map<K, Par>
): Par = chooser(cond) {
choices.getValue(it)
}


- 시그니처를 자세히 보자.
    - `Par<A>`를 받아서 `Par<B>`를 반환한다. 
    - `flatMap`의 시그니처와 동일하다!

그렇다.
`choice`의 가장 기본연산은 `flatMap`이 었던 것이다.

###### 의심해보자.
- 과연 `flatMap`은 기본연산일까. 더 일반화 하지는 못할까?
- `flatMap`은 두 단계로 세분화 할 수 있다.
    - Mapping
    - Flatten
- Flatten 연산을 `join`이라고 하자. 
    - `join`은 모든 X타입에 대해 `Par<Par<X>>`를 `Par<X>`로 변환한다.
    - 개념적으로 보면, `run`을 호출하면 내부 계산을 실행한 후 내부 계산의 완료를 기다렸다가 결과를 돌려주는 병렬계산이라고 할 수 있다.

- 연습문제 7.13
    - join을 구현하라.
    - join을 써서 flatMap을 구현하라.
    - flatMap을 써서 join을 구현하라.


```kotlin
fun <ITEM> join(item: Par<Par<ITEM>>): Par<ITEM> 
    = { es: ExecutorService ->  
        run(es, run(es, item).get())  
    }  

fun <ITEM, RESULT> flatMap(
    item: Par<ITEM>, 
    block: (ITEM) -> Par<RESULT>
): Par<RESULT> = join(map(item, block))

Kotlin으로 코드를 작성하다보면 보게되는 연산자들이 있습니다.

?. , ?: , !! 모두 Null 타입을 처리하기위해 사용하는 연산자들입니다.

Java와 비교하면서 천천히 알아보겠습니다.

In Java
private void doSomething(String str) {
    System.out.println(str.length());
}

위의 코드는 안전한 코드일까요?
.
.
.
아닙니다.

str 이 null 일 가능성이 있기 때문입니다. null에 대해 length()를 호출하면 NullPointerException이 발생하죠.

private void doSomething(String str) {
    if (str != null) {
        System.out.println(str.length());
    }
}

이렇게 써야 안전한 코드라고 할 수 있겠습니다.

In Kotlin

Kotlin은 어떨까요?

private fun doSomething(str: String) {
    println(str.length)
}

위의 코드는 안전한 코드일까요?
.
.
.
맞습니다.

왜일까요?
Kotlin은 null 이 가능한 타입과 불가능한 타입을 확실히 구분합니다.

String 타입은 절대 null이 될 수 없는 타입입니다.
String? 타입이 nullable 타입입니다.

따라서 str은 String 타입이므로 null check를 안해도 됩니다.

private fun doSomething(str: String?) {
    if (str != null) {
        println(str.length)
    }
}

String? 같은 nullable 타입일 때만 null check를 해주면 됩니다.
하지만 코틀린에서 제공해주는 ?. , ?: , !! 를 사용하면 더 섹시하게 처리할 수 있습니다.

?. Safe call operator
private fun doSomething(str: String?) {
    val length = str?.length  
    println(length)
    // println(str?.length)
}

str?.lengthlength 변수에 할당해서 사용했습니다.
?.는 safe call operator라고 합니다.

str.length 를 호출 하면 str이 null일 수 도 있기 때문에 NPE가 발생할 확률이 있습니다.
(Kotlin에선 컴파일 오류)

애초에 NPE가 왜 발생할까요?
우선 length() 는 String 클래스 안에 정의된 함수(메소드)입니다.

str변수에 String 인스턴스가 할당돼있지 않은 상태에서 String 클래스의 함수를 호출하기 때문입니다.
null.length() 를 호출하는 꼴입니다. null에는 당연히 length() 라는 함수가 없습니다.
그래서 NPE가 발생합니다.

safe call operator를 사용해서 str?.length 호출하게되면 str이 null일 경우 뒤의 호출을 무시하고 식의 전체 결과가 null로 반환됩니다.

val doubleLength = str?.length?.toDouble()이렇게 chaining하여 사용할 수도 있습니다.

또, Kotlin은 Scope function 이라는 걸 제공합니다.
https://kotlinlang.org/docs/scope-functions.html

private fun doSomething(str: String?) {
    str?.let { notNullStr ->
        println(notNullStr)
    }
}

let 을 사용해서 null 안정성을 확보해도 됩니다.

질문: str?.length의 타입은 무엇일까요?

?: Elvis operator

세워서 보면 엘비스 프레슬리를 닮아서 이름 지어진 연산자입니다.

?: 널이라면? 으로 해석하시면 편할 거 같습니다.

private fun doSomething(str: String?) {
    val str2 = str ?: "Hello"
    println(length)
}

str 이 널이라면 str2"Hello" 를 할당한다. 라는 뜻입니다.

자바로 보면 대충 이렇겠죠.

String str2 = "";
if (str == null) {
    str2 = "Hello";
} else {
    str2 = str;
}

물론 safe call과 함께 이용할 수도 있습니다.
val doubleLength = str?.length?.toDouble() ?: 0.0

값 대신 함수를 종료하거나 예외를 던질 수도 있습니다.
val str2 = str ?: return
val str2 = str ?: throw Exception()

!! Not-null assertion operator

얘는 절대 null 아님으로 해석하시면 될 거 같습니다.

private fun doSomething(str: String?) {
    println(str!!.length)
}

이렇게 하면 컴파일 오류 없이 length 를 호출할 수 있습니다.
하지만 런타임에서 NPE가 터질 확률이 있죠. 그래서 웬만하면 사용하지 말라는 연산자이고,
공식문서에도 for NPE-lovers라는 표현이 나옵니다.

여러분은 어떻게 생각하시나요? !! 은 무조건 쓰면 안되는 걸까요?

SSL/TLS란?

  • HTTP를 이용하여 데이터를 전송할 때, 우리가 주고받는 데이터는 암호화 되어있지 않다.
  • HTTP 통신 방식에 암호화를 추가한 방식이 HTTPS이다.
    • HTTPS에서 사용하는 암호화를 위해 사용하는 프로토콜이 SSL/TLS이다.

SSL, TLS

SSL(Secure Sockets Layer)은 TLS(Transport Layer Security)의 전신으로 시대를 거쳐 1.0, 2.0 그리고 1996년에 3.0이 출시되었다.

이후 1999년 SSL 3.0을 기초로 한 TLS 1.0이 출시되었고 1.1, 1.2 버전을 거쳐 2018년에 TLS 1.3이 출시되었다.

SSL은 현재는 거의 사용하지 않지만, TLS와 같은 의미로 사용되고 있다.

(이하로 SSL이라고 쓰겠다.)

SSL 인증서

  • SSL은 SSL인증서가 있는 웹사이트만 실행할 수 있다.
  • SSL 인증서는 신뢰할 수 있는 기관(CA)에서 발급한다.
  • SSL인증서에는 웹사이트의 공개 키가 포함되어있다.
    • SSL인증서는 공개 키 암호화 방식(RSA)을 통해 데이터를 암호화한다.
    • 웹 서버에 비밀 키가 존재한다.

SSL 원리

  1. 서버의 공개 키/ 비밀 키를 생성한다.
  2. CA(인증된 기관)에 공개 키와 기타 정보를 전달하여 SSL인증서를 생성한다.
    • SSL인증서는 CA의 비밀 키로 암호화 된다.
  3. 서버에 인증서를 저장하고 내 서버에 접속한 클라이언트에게 인증서를 보내준다.
  4. 클라이언트(브라우저)는 인증서를 CA의 공개 키로 복호화한다.
    • 브라우저에는 CA들의 공개 키가 내장되어 있다.
    • 복호화 성공 여부를 통해 CA에서 발급한 인증서인지 확인한다.
  5. 복호화 한 인증서에 들어있는 서버의 공개 키를 이용해서 통신한다.

SSL Handshake

  1. TCP Handshaking
    • 3-way-handshake를 한다.
    • SSL 통신을 하기위해 TCP연결이 수립 되어야 한다.
  2. SSL Handshaking
    1. Client Hello (Client → Server)
      • 클라이언트가 생성한 랜덤 데이터와 클라이언트가 사용가능한 암호화 방식을 서버로 전송한다.
      • 세션 키를 전송한다
    2. Server Hello (Server → Client)
      • 서버가 생성한 랜덤 데이터와 선택한 암호화 방식을 전송한다.
      • SSL 인증서를 전송한다.
    3. 클라이언트 확인
      • SSL 인증서를 CA의 공개 키를 이용해서 복호화한다.
      • 복호화가 성공했다면 해당 인증서는 CA에서 발급한 인증서라 신뢰할 수 있다는 뜻이다.
      • 복호화하여 서버의 공개 키를 얻을 수 있다.
    4. 대칭 키 암호화를 위한 pre master secret key 생성 (Client → Server)
      • 클라이언트가 생성한 랜덤 데이터 + 서버 랜덤 데이터를 통해 특정값 생성
      • 이 값을 서버의 공개키로 암호화
      • 서버로 전송
    5. session key 생성
      • pre master secret key를 받아 서버 비밀 키로 복호화
      • 이를 통해 session key를 생성하여 클라이언트와 공유
      • session key를 사용하여 데이터를 대칭 키 암호화 방식으로 주고받음

TLS 1.3

HTTPS 패킷 분석(TLS 1.2와 TLS 1.3)

SNI 란?

SNI 차단이 뭐야? TLS 1.3은 해결책이 될 수 있을까.

인증서 체인

ROOT CA 인증서는 무엇인가?

인증서 체인 🔗

154. [Security] SSL과 인증서 구조 이해하기 : CA (Certificate Authority) 를 중심으로

HTTPS 및 SSL을 사용한 보안 | Android 개발자 | Android Developers

'CS > 네트워크' 카테고리의 다른 글

HTTP 버전  (0) 2023.10.22
쿠키와 세션  (0) 2021.12.26
HTTP의 Stateless  (0) 2021.12.26
네트워크의 구성요소  (0) 2021.07.19
컴퓨터 네트워크 첫 걸음  (0) 2021.07.19

HTTP란?

HTTP란 TCP/IP 네트워크 아키텍처 기준으로 보면 어플리케이션 계층에서 사용되는 프로토콜이다.

클라이언트-서버 구조에서 통신할 때 사용되며, Request와 Response가 존재한다.

HTTP 구조

HTTP의 구조는 크게 Start line + Header + Body로 구성된다.

  • Request
    • Start line
      • HTTP 메소드와, 타겟 경로, HTTP 버전이 명시된다.
    • Header
      • Host, cookie, user-agent 등의 정보가 포함된다.
    • Body
      • 서버로 보낼 정보를 담는다.
      • GET / HTTP/1.1 Host: developer.mozilla.org Accept-Language: fr
  • Response
    • Start line
      • HTTP 버전, 응답 상태 정보가 명시된다.
    • Header
      • 서버 정보, 날짜, 응답에 대한 정보 등이 들어간다.
    • Body
      • 서버가 응답한 리소스가 담겨있다.
      • HTML, JSON, TEXT등 여러 형식이 올 수 있다.
      • HTTP/1.1 200 OK Date: Sat, 09 Oct 2010 14:28:02 GMT Server: Apache Last-Modified: Tue, 01 Dec 2009 20:18:22 GMT ETag: "51142bc1-7449-479b075b2891b" Accept-Ranges: bytes Content-Length: 29769 Content-Type: text/html <!DOCTYPE html... (here comes the 29769 bytes of the requested web page)

HTTP 특징

HTTP/HTTPS 차이

  • HTTP는 암호화 되지 않은 데이터를 전송한다.
  • HTTP에 SSL, TLS을 결합 한 것이 HTTPS이다.
  • HTTPS 웹사이트는 인증된 기관에서 발급한 SSL/TLS 인증서를 획득해야한다.
    • SSL/TLS이란?
      • TLS는 SSL의 업그레이드 버전이다.
      • RSA암호화를 사용한다.

인증과정

  1. HTTPS 웹 사이트를 방문한다.
  2. 브라우저는 서버의 SSL 인증서를 요청한다.
  3. 서버는 public 키가 포함된 SSL 인증서를 보내준다.
  4. 브라우저가 CA의 인증서인지 검증하고, public 키를 이용해서 request를 암호화 한다.
  5. 서버에서 private key를 이용하여 메세지를 해독한다. 그리고 세션키를 암호화해서 브라우저에 전송한다.
  6. 브라우저에서 세션키를 사용해서 서버와 통신한다.

HTTP 버전

HTTP 1.0 / 1.1

  • HTTP의 초기버전(0.9라고 부름)은 한 줄로만 구성되어 있었다.
    • 리소스 요청에 대한 응답만 할 수 있다. (GET 메소드만 존재)
    • ```
    • Request
      GET /target.html
    • Response```
  • HTTP 1.0
    • 헤더가 추가되었다.
    • 버전정보와 메소드 정보를 전송한다.
      • GET, HEAD, POST 메소드
    • 상태 코드(200, 302, 404..)를 응답으로 보내줘 요청에 대한 성공/실패를 알 수 있다.
    • 헤더에 Content-Type을 명시해 HTML 이외에 데이터도 전송할 수 있음
    • 요청 하나당 커넥션을 생성하기 때문에 매번 3-way-handshake을 해야한다.
      • HTTP도 TCP 프로토콜 위에서 동작하므로, 통신을 하려면 3-way-handshake과정을 거쳐야함.
  • HTTP 1.1
    • Persistent connection 추가
      • 지정한 timeout동안 커넥션을 닫지 않는다.
      • HTTP 1.0 단점 개선
    • Pipelining 추가
      • 앞의 요청의 응답을 기다리지 않고 다음 요청을 보냄
      • 응답은 요청을 보낸 순서대로 온다.
    • Head Of Line Blocking (HOLB)
      • 앞의 요청이 너무 오래걸리면 뒤의 요청은 블럭킹 되는 문제가 있다.
    • 헤더 중복 문제

HTTP 2.0

  • HTTP 1.1 의 확장이며 성능개선을 한 프로토콜이다.
  1. 전송 방식 전환
    • HTTP 1.1 에서는 일반 텍스트에 개행문자로 헤더와 바디를 구분했지만 2.0에서는 바이너리 프레임으로 분할해서 전송한다.
    • 바이너리 형식이 전송속도와 파싱속도가 빠르고 오류 발생 가능성이 낮다.
  2. Multiplexed Streams
    • 요청의 프레임이 각 스트림을 통해 전달되고 응답을 받는다.
    • 한 커넥션 안에 여러 스트림이 존재하며, 이전 요청의 응답 여부와 상관없이 동시에 여러 요청을 처리할 수 있다.
  3. Server Push
    • 서버가 클라이언트가 요청하지 않은 리소스를 보내줄 수 있다.
      • *클라이언트에게 필요한
  4. Header Compression
    • 이전에 전송된 Header 를 table로 만들어 저장
    • 이전 Header에 포함된 내용을 제외한 새로운 내용을 허프만 인코딩으로 압축해서 전송
  • HTTP단의 HOLB는 없지만 TCP단의 HOLB는 존재

HTTP 3.0

  • QUIC(Quick UDP Internet Connection)를 기반으로 나온 HTTP의 새로운 버전
  • QUIC
    • UDP 기반의 전송 프로토콜
    • TCP의 단점을 극복하기위해 고안됨

RESTful API

출처

https://velog.io/@neity16/HTTP-HTTP-버전-별-특징#http-10

https://aws.amazon.com/ko/compare/the-difference-between-https-and-http/

https://developer.mozilla.org/ko/docs/Web/HTTP/Overview#http_기반_api

'CS > 네트워크' 카테고리의 다른 글

SSL/TLS란? SNI, Intermediate-Chain  (1) 2023.10.22
쿠키와 세션  (0) 2021.12.26
HTTP의 Stateless  (0) 2021.12.26
네트워크의 구성요소  (0) 2021.07.19
컴퓨터 네트워크 첫 걸음  (0) 2021.07.19

+ Recent posts