Kim-Baek 개발자 이야기

클린 아키텍처 - 6장 함수형 프로그래밍 본문

개발

클린 아키텍처 - 6장 함수형 프로그래밍

김백개발자 2022. 2. 2. 23:19

함수형 프로그래밍의 핵심은 바로 람다 계산법이다. 람다 계산법은 프로그래밍보다 먼저 등장한 개념인데, 어떤 것인지 사례를 보면서 살펴보도록 하자.

정수를 제곱하기

public class Squint { 

	public static void main(String args[]){
    	for(int i = 0; i<25; i++){
        	System.out.pring(i*i);
        }
    }
}

25까지 정수의 제곱을 출력하는 코드를 자바로 작성하면 다음과 같다.

(println (take 25 (map (fn [x] (* x x)) (range)))

Clojure라는 함수형 언어로 같은 것을 작성하면 이렇게 나온다.

  • range 라는 함수를 통해서 0부터 끝없는 정수 리스트를 가져온다.
  • map 함수에서 제곱을 계산하는 익명함수를 호출하여, 모든 정수의 제곱을 구하는 리스트를 생성한다
  • take 함수는 앞에서 25개까지의 항목만 잘라 전달한다
  • println 함수는 앞에서 나온 값을 출력한다

클로저는 함수로 구성이 되어있고, 변수에 대한 저장이 없이 함수의 계산으로만 이루어진 것을 알 수 있다. 자바는 그와 다르게 가변변수라는 상태가 변할 수 있는 값을 사용한다. for 문에서 사용한 i 가 변경이 되는 값이다. 함수형 언어는 변수는 변경되지 않는 값이다.

불변성과 아키텍처

변수의 가변성은 여러가지 문제를 일으킬 수 있다. race condition, deadloc, concurrent update 문제가 모두 가변 변수로 인해서 발생하는 것이다. 어떤 변수도 갱신되지 않는다면 이런 문제는 발생하지 않는다. 가변성에 의한 동시성 문제를 한번 살펴보도록 하자.

CPU가 어떤 작업을 하기 위해서 데이터를 필요할 때, RAM의 데이터를 Cache Memory에 읽고 사용한다. 쓰기를 할 때도 마찬가지인데, 매번 RAM에 데이터를 쓰거나 읽는 것이 아닌, Cache Memory에서 읽고 쓰기를 하고, 적정 시점에 RAM 에 업데이트를 한다. 이 과정에서 동시성의 문제가 발생한다.

public class StopThread {

    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {

        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });

        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

이 코드는 backgrounThread 가 실행된 후, 1초뒤에 정지될 것으로 보이는 코드이다. 하지만 실제 동작은 그렇게 되지 않을 수 있다.

CPU1 이 backgroundThread 를 사용하고, CPU2 가 메인쓰레드를 사용한다고 해보자. CPU1에서는 stopRequested의 값을 false 로 가지고 있을 것이다. CPU2 에서는 해당 값을 true 로 업데이트하고, RAM에 업데이트 했다. 하지만 처음에 말했듯, RAM에서 매번 데이터를 가져가는 것이 아니라 적정시점에 데이터를 가져가게 된다.

이렇게 되면 CPU1의 cache memory에서 언제 stopRequested를 업데이틀 할 지 모르기 때문에 제대로 동작할지를 보증할 수 가 없게 된다. 이걸 가시성의 문제라고 한다.

이를 해결하기 위해서는 자바에서는 volatile 이라는 키워드를 쓴다. volatile 로 선언된 변수는 RAM에 직접 해당 변수를 읽고 쓰기를 하겠다는 뜻이다.

public class IncremantThread {

    private static int count;

    public static void main(String[] args) throws InterruptedException {

        Thread backgroundThread = new Thread(() -> {
            for (int i = 0; i < 100; ++i) {
                ++count;
            }
        });
        backgroundThread.start();

        for (int i = 0; i < 100; ++i) {
            ++count;
        }

        TimeUnit.SECONDS.sleep(5);
        System.out.println(count);

    }
}

이 코드를 통해서 우리는 count가 200이 될 것이라고 확신할 수 있을까? 앞서 말했던 가변변수를 사용하고 있기 때문에 그렇지 않다는 것을 알 수 있다.

RAM에 저장된 2라는 값을 동시에 두 개의 쓰레드가 가져갔다고 해보자. 두 쓰레드 모두 값을 하나씩 증가시킨 3의 값을 캐시메모리에 지닐 것이다. 그리고 둘 다 RAM에 쓸 때 3의 값을 넣을 것인데, 두 개가 동시에 같은 값을 쓴다는 것에서 200이 나올 수 없다는 것을 알 수 있다.

이러한 문제를 동시 접근의 문제라고 하고, 자바는 synchronized 키워드를 사용한다. lock 을 사용하여 쓰레드가 공유 자원에 접근 시 하나의 쓰레드만 접근할 수 있도록 하는 것이다. 

가변성의 분리

완벽한 불변성을 갖기는 어렵지만, 애플리케이션에서 가변 컴포넌트와 불변 컴포넌트를 분리하는 것을 할 수 있다. 가변 컴포넌트에서는 동시성 문제에 노출되는데 이를 트랜잭션 메모리와 같은 방법으로 가변 변수를 보호한다.

(def counter (atom 0)) ; counter 를 0으로 초기화
(swap! counter inc)    ; counter 를 안전하게 증가

클로저에서 atom 은 swap! 이라는 함수를 통해서만 값이 변경이 된다. swap에는 변경할 atom 변수, 다른 하나는 새로운 값을 계산할 함수가 들어간다. inc라는 함수가 단순히 1을 증가하는 함수라고 해보자.

swap! 함수는 Compare and swap 알고리즘을 쓴다.

  • count 의 값을 읽은 후 inc 함수에 전달한다. 
  • inc 함수가 값을 계산 후 반환하면 counter 값은 잠기고, inc 함수로 전달했던 주소 값과 비교한다.
  • 만약 주소 값이 같다면 inc 함수가 반환한 값이 counter 저장되고 잠금을 해제한다.
  • 값이 다르다면 처음부터 재시도 한다.
In computer science, compare-and-swap (CAS) is an atomic instruction used in multithreading to achieve synchronization. It compares the contents of a memory location with a given value and, only if they are the same, modifies the contents of that memory location to a new given value. This is done as a single atomic operation. The atomicity guarantees that the new value is calculated based on up-to-date information; if the value had been updated by another thread in the meantime, the write would fail. The result of the operation must indicate whether it performed the substitution; this can be done either with a simple boolean response (this variant is often called compare-and-set), or by returning the value read from the memory location (not the value written to it).

비교하는 값이 계산되어 나온 값이 아니라 주소 값을 비교한다는 것을 wiki 의 CAS 알고리즘 설명을 보면 알 수 있다.

이러한 atom 은 간단한 앱에는 적당하나 여러 변수가 있는 경우에는 적절하지 않다.

이벤트 소싱

저장 공간과 처리 능력의 한계는 점점 발전하고 있다. 메모리가 많아지고 기계가 빨라질 수록 가변 상태를 적게 사용할 수 있다. 계좌 잔고를 관리하는 앱을 만들었다고 해보자.

이벤트 잔고
10,000원 입금 10,000원
5,000원 출금 5,000원
3,000원 입금 8,000원

기존에는 이렇게 잔고를 저장하고, 잔고의 데이터를 계속 수정해 나갔을 것이다. 하지만 이벤트 소싱은 다르다. 계좌 잔고를 변경하는 것이 아니라 트랜잭션 자체 (이벤트)를 저장하는 것이다.

이벤트 잔고
10,000원 입금 10,000원
5,000원 출금 5,000원
3,000원 입금 8,000원

이렇게 트랙잭션을 모두 저장하는 방식인 것이다. 어떤 상태가 필요하면 시작점부터 다 계산을 해서 주는 것이다. 이 방식은 당연히 엄청나게 큰 데이터 저장소가 필요할 것이다. 하지만 기존에는 CRUD 의 모든 행위가 필요했다면, 이벤트 소싱은 CR만 필요하고 변경과 삭제는 전혀 필요 없기 때문에 동시성 문제는 발생하지 않는다.

반응형
Comments