Rust vs Kotlin vs Java

Rust의 컴파일 타임 vs Kotlin/Java의 런타임

특징 Rust (async / Future)  Kotlin Coroutines  Java Virtual Threads
비동기 실행 모델 Future 기반 (poll-driven) Suspend 함수 기반 (Continuation) 사용자 모드 스레드 기반 (Thread API)
상태 관리 컴파일 타임에 상태 기계로 변환 런타임에서 상태를 관리 런타임에서 사용자 모드 스레드 관리
최적화 컴파일 타임 최적화 (zero-cost abstraction) 일부 컴파일 타임, 주로 런타임 최적화 주로 런타임 최적화
런타임 필요성 런타임 스케줄러 필요 (Tokio, async-std 등) 코루틴 디스패처 필요 Java 런타임 자체에서 관리
제어권 개발자에 의해 언어&라이브러리 차원의 키워드로 명시적 으로 제어권 반환해야함  개발자에 의해 언어&라이브러리 차원의 키워드로 명시적으로 제어권 반환 해야함  명시적 제어권 반환하는 키워드가 새로 생긴건 없으며, 기존 자바 동기 코드를 사용해도 내부에서 알아서 비동기 처리 
난이도 중간  중간  쉬움 & 세밀한 제어 및 부모-자식관계 관리등 안됨 

 

Rust의 컴파일 타임

Rust의 비동기 프로그래밍 모델에서는 컴파일 타임에 많은 작업을 수행하여 효율적인 비동기 실행을 지원합니다. Rust의 비동기 실행 모델은 async/await 키워드와 Future 트레이트를 기반으로 하며, 다른 언어들에 비해 컴파일 타임 최적화가 더 강력한 특징을 가지고 있습니다.

Rust의 async와 Future 트레이트

  • Rust에서 **async fn**은 **컴파일 타임에 상태 기계(state machine)**로 변환됩니다.
  • async fn이 호출되면 미리 생성된 상태 기계 객체가 반환되며, 이 객체는 Future 트레이트를 구현합니다.
  • Future는 poll 기반의 실행 모델로, 런타임에서 필요한 시점에 실행됩니다.

Rust의 강점: 컴파일 타임에 많은 것을 결정

  • Rust는 async fn을 Future 객체로 변환하여, 비동기 함수의 모든 상태를 컴파일 타임에 정의합니다.
  • 이는 런타임 오버헤드를 줄이고, 높은 성능과 안전성을 보장합니다.
  • 비동기 코드의 런타임 비용을 최소화하기 위해 설계되었으며, 이 점에서 Kotlin이나 Java보다 더 낮은 레벨의 최적화를 제공합니다.

사실 위의 말 가지고는 이해 할 수 가 없습니다.  세상은 좋은 선생님들 덕분에 돌아갑니다. :-) 
아래에 직접 구현된 예시가 있습니다. (컴파일 타임에 코루틴이 구현되는 방식 - 예제 코드 솔직히 킹왕짱.) 
https://github.com/cfsamson/Asynchronous-Programming-in-Rust/tree/main/ch08 

 

Asynchronous-Programming-in-Rust/ch07 at main · cfsamson/Asynchronous-Programming-in-Rust

Asynchronous Programming in Rust, published by Packt - cfsamson/Asynchronous-Programming-in-Rust

github.com

핵심코드만 발췌)

// coroutine fn async_main() {
//     println!("Program starting");
//     let txt = http::Http::get("/600/HelloAsyncAwait").wait;
//     println!("{txt}");
//     let txt = http::Http::get("/400/HelloAsyncAwait").wait;
//     println!("{txt}");

// }


// 위의 코드가 아래 코드로 컴파일 시점에 변경 // 

// 설명) 상태를 가진 코루틴 객체가 첫번째 wait만나기 전까지 상태를 저장하고, 자식 비동기 호출하고 
// 이런식으로 함수가 특정 위치부터 이전 상태를 기반으로 재실행됨 


fn async_main() -> impl Future<Output=String> {
    Coroutine0::new()
}

enum State0 {
    Start,
    Wait1(Box<dyn Future<Output = String>>),
    Wait2(Box<dyn Future<Output = String>>),
    Resolved,
}

struct Coroutine0 {
    state: State0,
}

impl Coroutine0 {
    fn new() -> Self {
        Self { state: State0::Start }
    }
}


impl Future for Coroutine0 {
    type Output = String;

    fn poll(&mut self, waker: &Waker) -> PollState<Self::Output> {
        loop {
        match self.state {
                State0::Start => {
                    // ---- Code you actually wrote ----
                    println!("Program starting");

                    // ---------------------------------
                    let fut1 = Box::new( http::Http::get("/600/HelloAsyncAwait"));
                    self.state = State0::Wait1(fut1);
                }

                State0::Wait1(ref mut f1) => {
                    match f1.poll(waker) {
                        PollState::Ready(txt) => {
                            // ---- Code you actually wrote ----
                            println!("{txt}");

                            // ---------------------------------
                            let fut2 = Box::new( http::Http::get("/400/HelloAsyncAwait"));
                            self.state = State0::Wait2(fut2);
                        }
                        PollState::NotReady => break PollState::NotReady,
                    }
                }

                State0::Wait2(ref mut f2) => {
                    match f2.poll(waker) {
                        PollState::Ready(txt) => {
                            // ---- Code you actually wrote ----
                            println!("{txt}");

                            // ---------------------------------
                            self.state = State0::Resolved;
                            break PollState::Ready(String::new());
                        }
                        PollState::NotReady => break PollState::NotReady,
                    }
                }

                State0::Resolved => panic!("Polled a resolved future")
            }
        }
    }
}



Kotlin/Java의 런타임

코틀린 코루틴의 협력적 스케줄링 (Cooperative Scheduling)

특징:

  • 스레드나 작업이 자발적으로 제어권을 넘겨주는 방식으로 동작합니다.
  • 작업 중간에 명시적으로 제어권을 반환하는 지점 (yield, await, suspend 등)을 코드에 삽입해야 합니다.
  • 런타임 스케줄러는 이 반환 지점에서 다른 작업으로 전환합니다.

장점:

  • 컨텍스트 스위칭 비용이 매우 낮음: 협력적 스케줄링은 커널의 간섭 없이 사용자 공간에서 이루어지므로 매우 빠름.
  • 개발자가 작업 흐름을 더 세밀하게 제어할 수 있음.
  • 대량의 경량 작업을 실행할 때 효율적.

단점:

  • 비협력적인 코드가 있다면 문제가 발생: 작업이 제어권을 반환하지 않으면, 다른 작업이 실행되지 못하고 시스템이 멈출 수 있음.
  • 협력적 스케줄링은 블로킹 작업을 자체적으로 처리하지 못하므로, I/O 작업 등을 비동기로 처리해야 함.

동작 방식

  1. 협력적 스케줄러가 관리하는 작업은 사용자 공간에서 실행됩니다.
  2. 각 작업은 명시적으로 제어권을 반환해야 다른 작업이 실행될 수 있습니다.
  3. 모든 작업은 동일한 스레드에서 실행될 수 있습니다 (멀티스레드 환경도 가능).
import kotlinx.coroutines.*
import kotlin.random.Random

suspend fun fetchData(id: Int): String {
    delay(Random.nextLong(100, 500)) // 비동기 대기 (명시적 제어권 반환)
    return "Data for request $id"
}

fun main() = runBlocking {
    val jobs = List(100) { id -> // 100개의 코루틴 생성
        launch {
            val data = fetchData(id)
            println("Coroutine $id: $data on thread ${Thread.currentThread().name}")
        }
    }

    jobs.forEach { it.join() } // 모든 작업이 끝날 때까지 대기
}

delay() 호출 시 제어권을 반환해 다른 작업이 실행됩니다.

 

Java Virtual Thread - OS 커널 스레드와 분리된 사용자 모드 스레드 (User-mode Threads)

특징:

  • 사용자 모드 스레드는 OS 커널 스레드와 독립적으로 관리됩니다.
  • Java Virtual Threads와 같이 사용자 공간에서 구현된 가벼운 스레드입니다.
  • 작업 실행은 OS의 스케줄러가 아닌 사용자 모드 스케줄러에서 관리됩니다.

장점:

  • OS 커널 스레드의 제한 없이 대량의 사용자 모드 스레드를 실행할 수 있음.
  • 스레드 생성과 스위칭이 커널 스레드보다 훨씬 빠름.
  • 기존의 동기 프로그래밍 모델(블로킹 호출 포함)을 유지하면서 비효율을 제거 가능.

단점:

  • I/O 작업이나 블로킹 호출은 내부적으로 비동기로 처리되어야 함.
  • 커널 스레드에 비해 구현이 복잡할 수 있음.

동작 방식

  1. 사용자 모드 스케줄러는 사용자 모드 스레드를 관리하며, 이 스레드는 필요 시 커널 스레드에서 실행됩니다.
  2. 사용자 모드 스레드가 실행을 중단하면(예: I/O 대기) 커널 스레드가 다른 사용자 모드 스레드를 실행합니다.
  3. 이 과정은 투명하게 이루어지며, 기존 동기 코드를 그대로 사용할 수 있습니다.

Java의 Virtual Threads는 사용자 모드 스레드입니다.

  • **Thread.startVirtualThread**를 통해 가벼운 사용자 모드 스레드를 생성합니다.
  • 내부적으로 스케줄러가 I/O 대기 및 실행을 관리하며, OS 스레드가 비효율적으로 차단되지 않습니다.
import java.util.concurrent.*;
import java.util.Random;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        Random random = new Random();

        for (int i = 0; i < 100; i++) { // 100개의 Virtual Threads 생성
            int id = i;
            executor.submit(() -> {
                try {
                    Thread.sleep(random.nextInt(400) + 100); // 블로킹 호출
                    System.out.println("Virtual Thread " + id + ": Data fetched on thread " + Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES); // 모든 작업이 끝날 때까지 대기
    }
}

 

협력적 스케줄링과 사용자 모드 스레드의 차이

특징협력적 스케줄링사용자 모드 스레드

작업 전환 방식 작업이 명시적으로 제어권을 반환해야 함 사용자 모드 스케줄러가 작업 전환을 관리
스케줄링 비선점형 (Non-preemptive) 선점형 (Preemptive)
I/O 처리 비동기 I/O와 결합하여 동작 내부적으로 비동기 I/O 처리, 블로킹 호출과 호환
스레드 관리 단일 OS 스레드에서 실행 가능 여러 사용자 모드 스레드가 OS 스레드에서 매핑되어 실행
사용 예시 Kotlin 코루틴, Python async/await Java Virtual Threads, Go 언어의 goroutine
성능 컨텍스트 스위칭 비용이 낮음 더 많은 스레드를 처리할 수 있지만 스위칭 비용이 더 높음
코드 스타일 비동기 코드 (async/await, suspend) 동기 스타일과 비동기 스타일 모두 지원

Rust Future와 다른 모델 비교

  Rust Future Kotlin Coroutine Java Virtual Threads  Go Goroutine
스케줄링 방식 협력적 스케줄링 협력적 스케줄링 선점형 스케줄링 선점형 스케줄링
제어권 반환 명시적 (Poll::Pending) 명시적 (suspend, yield) 자동 자동
실행 트리거 Lazy (스케줄러 필요) Lazy (런타임 필요) Eager (즉시 실행 가능) Eager (즉시 실행 가능)
I/O 처리 방식 논블로킹 논블로킹 동기 호출도 비동기로 처리 가능 논블로킹
스케줄러 제어 가능성 개발자가 직접 제어 (poll 호출) Dispatchers로 스케줄링 제어 JVM 스케줄러 자동 관리 Go의 GMP 스케줄러 자동 관리
사용 스타일 비동기 (async/await, poll) 비동기 (async/await) 동기 (Thread API와 동일) 동기 (go 키워드)

별책부록1)

Java Virtual ThreadsKotlin Coroutines 모두 결국 JVM 스레드 풀(예: ForkJoinPool) 위에서 실행됩니다.
둘 모두 OS Thread를 직접 사용 할 때 이루어지는 블로킹 및  컨텍스트 스위칭을 줄여서 동시성 처리 효율을 극대화 하는 방향으로 설계됩니다. Java Virtual Threads 를 사용하면 학습곡선이 낮아집니다. 명시적인 동시성 처리를 안하니깐~
대신 디테일한 조정을 못하게 되겠지요. Go언어의 Goroutine생각하면 Kotlin Coroutine보다 Java Virtual Threads가 훨씬 잘 쓰일듯. (한데.. 코프링이 워낙 좋아서 또.. 몰러)

좀 편하게 살자 !!!    (https://tech.kakaopay.com/post/ro-spring-virtual-thread/
)


JVM과 OS 스레드 관계

  • JVM은 기본적으로 OS 스레드를 기반으로 동작합니다.
  • Java에서의 **스레드(Thread)**는 OS 커널 스레드에 직접 매핑됩니다.
  • 따라서 Java의 스레드 API를 통해 생성한 스레드는 JVM에서 OS 스레드로 매핑되어 커널 스케줄러에 의해 관리됩니다.


별책부록2)

Kotlin Coroutines에서는 간단히 처리할 수 있지만 Java Virtual Threads로는 더 많은 코드와 복잡한 제어가 필요한 경우의 예제

시나리오 1: 부모-자식 관계로 작업 그룹 관리

  1. 문제:
    • 100개의 작업을 병렬로 실행하지만, 특정 조건에서 중간에 멈춰야 합니다(예: 하나의 작업이 실패하면 전체 작업을 취소).
    • 모든 작업이 끝난 후, 결과를 종합해서 처리해야 합니다.
  2. 목표:
    • 작업의 그룹 단위 관리 (부모-자식 관계).
    • 작업 중 하나라도 실패하면 나머지 작업을 취소.
    • 모든 작업이 성공하면 결과를 합산.

시나리오 2: 병렬 작업에서 타임아웃과 취소 관리

  1. 문제:
    • 100개의 작업을 병렬로 실행하며, 각각 최대 300ms 안에 완료되어야 합니다.
    • 작업이 실패하거나 타임아웃이 발생하면, 해당 작업을 취소하고 나머지 작업은 계속 진행합니다.
    • 결과를 모두 모아 성공적으로 완료된 작업만 처리합니다.

Kotlin Coroutines 구현

Kotlin 코루틴은 **구조화된 동시성(Structured Concurrency)**를 제공하므로, 부모-자식 관계를 자연스럽게 관리할 수 있습니다. coroutineScope나 supervisorScope를 사용해 간단히 구현 가능합니다.

//// 시나리오 1

import kotlinx.coroutines.*
import kotlin.random.Random

suspend fun fetchData(id: Int): Int {
    delay(Random.nextLong(100, 500)) // 비동기 대기
    if (Random.nextBoolean()) throw RuntimeException("Error in task $id") // 실패 가능성
    return id * 10
}

fun main() = runBlocking {
    try {
        val results = coroutineScope { // 부모 코루틴 스코프
            (1..100).map { id ->
                async { fetchData(id) } // 자식 코루틴 실행
            }.awaitAll() // 모든 결과를 기다림
        }
        println("All tasks completed successfully: ${results.sum()}")
    } catch (e: Exception) {
        println("Failed: ${e.message}")
    }
}


//// 시나리오 2


import kotlinx.coroutines.*
import kotlin.random.Random

suspend fun fetchData(id: Int): Int {
    delay(Random.nextLong(100, 500)) // 비동기 대기
    if (Random.nextBoolean()) throw RuntimeException("Error in task $id") // 실패 가능성
    return id * 10
}

fun main() = runBlocking {
    val results = (1..100).map { id ->
        async {
            try {
                withTimeout(300) { fetchData(id) } // 300ms 안에 완료되지 않으면 취소
            } catch (e: Exception) {
                null // 실패하거나 타임아웃 발생 시 null 반환
            }
        }
    }.awaitAll()

    val successfulResults = results.filterNotNull()
    println("Successful results: ${successfulResults.sum()}")
}

Kotlin 코루틴 동작 방식:

  1. **coroutineScope**는 부모 코루틴으로, 자식 코루틴들의 생명 주기를 관리합니다.
  2. 작업 중 하나라도 실패하면, 나머지 작업을 자동으로 취소합니다.
  3. 결과를 awaitAll()로 간단히 모아서 처리합니다.

Java Virtual Threads 구현

Java Virtual Threads는 구조화된 동시성을 기본적으로 제공하지 않으므로, 비슷한 기능을 구현하려면 더 많은 코드와 수작업이 필요합니다

/// 시나리오 1

import java.util.concurrent.*;
import java.util.*;
import java.util.stream.*;
import java.util.Random;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        List<Future<Integer>> futures = new ArrayList<>();
        Random random = new Random();

        // 부모 작업: 결과 수집
        try {
            for (int i = 1; i <= 100; i++) {
                final int id = i;
                futures.add(executor.submit(() -> {
                    try {
                        Thread.sleep(random.nextInt(400) + 100); // 블로킹 대기
                        if (random.nextBoolean()) throw new RuntimeException("Error in task " + id); // 실패 가능성
                        return id * 10;
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }));
            }

            // 결과 처리
            int sum = 0;
            for (Future<Integer> future : futures) {
                sum += future.get(); // 작업 완료 대기 및 결과 수집
            }
            System.out.println("All tasks completed successfully: " + sum);
        } catch (ExecutionException e) {
            System.out.println("Failed: " + e.getCause().getMessage());

            // 자식 작업 취소
            for (Future<Integer> future : futures) {
                future.cancel(true);
            }
        } finally {
            executor.shutdown();
            executor.awaitTermination(1, TimeUnit.MINUTES);
        }
    }
}

/// 시나리오 2

import java.util.concurrent.*;
import java.util.*;
import java.util.stream.*;
import java.util.Random;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        Random random = new Random();
        List<Future<Integer>> futures = new ArrayList<>();

        for (int i = 1; i <= 100; i++) {
            final int id = i;
            futures.add(executor.submit(() -> {
                try {
                    Thread.sleep(random.nextInt(400) + 100); // 블로킹 대기
                    if (random.nextBoolean()) throw new RuntimeException("Error in task " + id);
                    return id * 10;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }));
        }

        List<Integer> results = new ArrayList<>();
        for (Future<Integer> future : futures) {
            try {
                results.add(future.get(300, TimeUnit.MILLISECONDS)); // 300ms 타임아웃 설정
            } catch (TimeoutException | ExecutionException e) {
                future.cancel(true); // 타임아웃 시 작업 취소
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        System.out.println("Successful results: " + results.stream().mapToInt(Integer::intValue).sum());
        executor.shutdown();
    }
}

Java Virtual Threads 동작 방식:

  1. Virtual Threads는 ExecutorService에서 관리되며, 각 작업은 Future 객체로 반환됩니다.
  2. 작업 실패 시 예외를 처리하려면 추가적인 로직(try-catch와 future.cancel())이 필요합니다.
  3. 구조화된 동시성이 제공되지 않으므로, 부모-자식 관계를 직접 관리해야 합니다.

고품질의 코드란 무엇일까?  예외처리가 잘 된 ? 주석이 잘 달린? 성능,메모리 최적화된? 변수명이 좋은? 
다 옳은 말이다. 추가적으로 관련 프레임워크/라이브러리의 이해가 잘된 코드, 도메인을 잘 이해한 코드도 물론이고..

이 글에서 고품질의 코드란 새로운 기능을 구현 할 때 최대한 기존 구현된 코드는 건드리지 않는 구조라고 정하고
그것에 대한 이야기를 써 본다.

이 글은 아래에  나오는 지식 기반을 엮어서 설명한다. 

  • Ralph E. Johnson, Martin Fowler et al. DI,DIP,IoC(Dependency Injection etc) 
  • 마틴파울러의 리팩토링 - Refactor Conditional To Polymorphism
  • 로버트 C. 마틴 클린코드 -  Data/Object Anti-Symmetry
  • 버틀란트메이어 객체지향 소프트웨어 설계 - Open/Closed Principle
  • Gof의 디자인패턴 - Visitor Pattern (사실 Visitor말고도 수 많은 패턴들이 해당된다)
  • 더블 디스패칭 - Visitor패턴에서 엘리먼트(데이터)가 하나이고 Visitor (행위자)가 여럿일 경우 비슷 
  • Duncan McGregor, Nat Pryce의 Java to Kotlin - Open to Sealed Classes

위에 나오는 법칙들은 모두 한가지를 가르키는데 변경은 좁은/분리된 범위에서 하게 하자이다.

먼저 리펙토링(Refactor Conditional To Polymorphism)은 if 문의 중첩으로 코딩된 코드를 다형성으로 처리하여 if 문을 밑으로 더 길게 수정하는 행위보다는 해당 부분은 전혀 건드리지 말고, 다른 파일에 새로운 타입의 구현을 작성하기게 만들자라는 것이고, 그 방식을 다형성의 힘을 이용했다.  Data/Object Anti-Symmetry는 다형성을 이용해서 구현을 하게 되면 생기는 문제도 있음을 지적한다. 우리가 개발을 하다보면 동종의 타입들이 늘어나는 경우도 있고, 행위가 늘어나는 경우가 있다.예를들어 Shap(도형) 이라면 draw라는 작동방식은 한정되어 있지만 Circle, Rectange,Star,Square,Ellipse 등 타입은 계속 늘어날 가능성이 높은 경우가 있고, 반대로 타입은 남자,여자로 거의 정해졌지만 행위는 먹다,자다,놀다,걷다 등으로 무한정 늘어날 가능성이 높은 경우가 있을 것이다. 이때 후자의 경우 다형성 기반으로 코딩을 한다면 행동하나가 늘어 날 때 마다 모든 서브클래스의 파일을 찾아서 수정해 주어야하는 고생을 해야 할 것이라서, 그냥 타입만 구분하고 그것에 강결합되 있는 인터페이스의 동작을 작성하지 말고, 파일하나에 행위 하나를 작성한다면 새로운 행위가 추가되더라도 기존 파일에는 전혀 영향이 없이 새로운 파일을 만들어서 추가하면 될 것이다. 이런 것들은 OCP와도 일맥상충하는데 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계가 되어야 한다는 의미입니다.

이제 말보단 코드로 봅시다. 코틀린의 sealed class를 사용합니다.

sealed interface Error

class FileReadError(val file: String): Error
class DatabaseError(val source: String): Error
class RuntimeError : Error

위와 같은 코드가 있다고 합시다. Error라는 인터페이스를 구현하는 타입이 3가지가 있습니다.
만약 Error 라는 타입의 가지고 있는 행위는 log()만 있을 확률이 높고, Error 하위 타입이 늘어날 가능성이 높은 어플리케이션을 님이 만들고 있다면 이것은 다형성을 이용하는게 좋을 것입니다.

sealed interface Error {
    fun log()
}

class FileReadError(val file: String): Error {
    override fun log() { println("Error while reading file $file") }
}
class DatabaseError(val source: String): Error {
    override fun log() { println("Error while reading from database $source") }
}
class RuntimeError : Error {
    override fun log() { println("Runtime error") }
}

위의 클래스들이 모두 다른 파일에 작성되어 있다면, 기존 파일은 전혀 신경 쓰지 않고 다른 파일 하나 더 만들면 됩니다.
하지만 log 말고 다른 메소드들이 계속 생긴다면? 그 때 마다 다른 파일들 모두 찾아야 겠지요.

이젠 타입은 정해져있고 log()라는 행위 말고, 다른 행위들이 생긴다고 생각해 봅시다.

fun log(e: Error) = when(e) {
    is FileReadError -> { println("Error while reading file ${e.file}") }
    is DatabaseError -> { println("Error while reading from database ${e.source}") }
    is RuntimeError ->  { println("Runtime error") }
}

이때는 이런식으로 파일 하나에는 하나의 행위만 들어가기 때문에 , 다른 행위를 위해 다른 파일에서만 추가하면  됩니다.

이번에는 Visitor 패턴이며  이 패턴 역시 OCP에 관련된 기존 구현을 건드리지 않으면서 행위를 수행합니다.
예를 봅시다.

class Itninerary (
    val id : String,
    val route : Route,
    val accomodations: List<Accomodation> = emptyList()
        ){
}


대략 이런 여행계획서 엔터티가 있다고 합시다. 계획서 안에는 어디를 방문하는지에 관한 Route와 숙소들의 리스트가 있습니다. 해당 여행 계획에 대한 경비를 산출해주는 로직을 만든다고 할 경우 가장 먼저 떠오르는 것은 Route와 Accomodation의 내용을 읽어서 더해주면 될 거 같습니다. 

class CostSummaryCalculator {

    fun calc(itninerary: Itninerary): CostSummary {

        var total : BigDecimal = BigDecimal.ZERO
        
        itninerary.accomodations.forEach {
            total.add(it.cost.amount)
        }

        itninerary.route.paths.forEach{
            total.add(it.cost.amount)
        }
        
        // ...

        return CostSummary(Money.of(total, Currency.WON))
    }
}

그런데 이런식으로 구현 하게 되면 여행 계획에 무엇인가 행위 (숙박,교통요금,관광..) 가 추가 될 때 마다 이 기존 calc 함수는 변경되야 할 것입니다. 그럼 기존 코드는 안건드리고 새로운 코드만 추가해서 해결 하려면 어떻게 해야 할 까요?

interface CostCalc {
    fun addCostsTo(calc: CostSummaryCalculator);
}
interface ItnieraryItem : CostCalc

data class Path(val cost : Money) :  ItnieraryItem {
    override fun addCostsTo(calc: CostSummaryCalculator) {
        calc.addCost(cost)   
    }
}
class Route :  ItnieraryItem{
    val paths = mutableListOf<Path>()
    override fun addCostsTo(calc: CostSummaryCalculator) {
        paths.forEach{
            it.addCostsTo(calc)
        }
    }

    fun addPath(path: Path) {
        paths.add(path)
    }

}

class Accomodation(val cost : Money) : ItnieraryItem{

    override fun addCostsTo(calc: CostSummaryCalculator) {
        calc.addCost(cost) 
    }

}

class Itninerary  (
    val id : String,
    val items: List<ItnieraryItem> = emptyList()
        ){

    fun addCostTo(calc: CostSummaryCalculator){
        items.forEach {
            it -> it.addCostsTo(calc)
        }
    }
}


data class CostSummary (val cost: Money)

class CostSummaryCalculator {

    val list = mutableListOf<Money>()
    fun addCost(money: Money) {
        list.add(money)
    }

    fun getTotal() : BigDecimal {
        return list.sumOf { it.amount }
    }
}


이렇게 하게 되면 새로운 여행아이템이 등장해도 해당 아이템에만 addCost 메소드를 추가 구현 해 주면 됩니다. Visitor패턴의 accept 가 addCostTo가 될 것이고, Visitor 는 CostSummaryCalulator이며 visit 에는 Money가 들어가네요.
(Visitor패턴이 모든곳에 좋은 건 아니며 위의 코드도 부실합니다. 설명하고자하는 것에 집중된 단순 예제 일 뿐) 



기존의 언어를 가지고 무엇인가 만들다 보면 그 코드의 쓰임새에 있어서 패턴/정형화가 된다.
그것을 구체적으로 이름 붙히고 규율을 정해 놓으면 사람들이 이해하기 더 쉬워 지는건 인지상정.

예를들어 고양이과 동물을 다 고양이라고 하고, 호랑이에 대해서는 메우 큰 고양이, 사자에 대해서는 목에 털이 많은 고양이라고 말을 해도 알아먹기야 하겠지만, 고양이과를 사자,호랑이로 명확히 나누어서 소통하면 더 빨리 이해 할 수 있게 될 것고 효율적이게 될것이다. 프로그래밍 언어도 마찬가지이다.

새로운 언어는 보통 이전 언어/ 경쟁 대상 언어보다 더 키워드가 많아 지게 되는데 예를들어
C++에는 없던 interface 키워드가 자바에서는 생겼는데  C++에는 비록 interface가 없더라도 class가 그 역할을 겸하였지만, 자바에서는 Interface라고 키워드를 추가하였기 때문에 자바 개발자들은 인터페이스를 적용하는면에 있어서 더 나아졌다. 즉 처음 자바로 프로그래밍을 공부한 사람은 그냥 그게 너무 당연한 Class와 구분된 기능인 것이다. 

마찬가지로 보통 자바 개발자들은 class에 대해 보통 한가지를 떠올리겠지만. 자바 class를 많이 사용한 자바구루들은 어떤 정형화 된 패턴들을 발견 할 수 있었을 것이다. 이런식으로 사용되는 클래스 저런 제한이 있는 클래스.. 

코틀린에서는 신생언어 답게 이런 것들을 세분화 시켜서 키워드를 추가하였다. 
그런 덕분에 코틀린을 공부한 사람은 자연스럽게 Class를 더 품격(?) 높게 구분해서 사용 할 수 있는 힘을 갖게 되었다. 

data class, value class, sealed class, enum class 등이 바로 이런 것들이다. 






 

이 말에 대한 비트겐슈타인의 진정한 함의는 모르겠지만,  문장 자체를 그대로 생각해 보면 소프트웨어 개발과 정말 잘 맞는 듯 하다. 이에 따라서 개발자는 

- 영어를 잘 아는게 중요하다?
- 글쓰기를 잘 하는게 중요하다?
- 패턴을 잘 아는게 중요하다?


개발에 추상화란 매우 중요한 요소이다. 소프트웨어 개발에 있어서 추상화를 하기 위해 사용되는 단어들은 생각보다 어려운 단어들이 아니다. Convert, Proxy, Execute, Provider, Factory, Map, Command, Future, Journal, Match, Invocate, Filter, Channel등 근데 막상 개발 할 때 저런 중학생용 영어를 사용하는데 어려움을 겪는다.

즉 개발에 있어서 영어를 원어민처럼 하는거와는 상관 없어 보인다. 언어의 한계가 의미하는 바는 단어를 많이 아는지 와는 크게 상관없고, 흔한 그 단어들이 얼마나 나의 머리속에 잘 추상화되어 자리잡혀있는지가 중요해보인다. 물론 다양한 어휘를 적재적소에 잘 사용 했던 사람이 코딩을 함에 있어서 유리할 것이란 것은 자명하다. 소프트웨어 개발은 공학이기도 하지만 문학이기도 하니까...

글쓰기를 잘 하는 건 그런 추상화를 잘 하게 해주는데 큰 도움을 줄 것이다.
만약 당신이 구체적인 것들을 지칭하는 것에 대해서 공통적인  무엇을 뽑아내야 할 때 사용할 만한 단어를 생각해 내는 것은 
먼저 공통된 무엇인가를 뽑아낼 감각과 의지가 있느냐와 동시에 어떻게 그것을 구체화 할 것이냐에 대한 기능적인 지식이 있어야 할 것이다. 

예를들어 각 OS별로 버튼을 그리는 구체적인 방식은 다를지라도 공통적으로 draw 라든지 paint라는 메소드를 사용 할 것이며, 다양한 블록체인에 접근하기 위한 공통메소드로는 connect 라든지 sendTransaction 따위가 있을 것인데, 그것들의 구체적인 구현체를 선택할 때 사용 할 수 있는 인터페이스의 단어로는 Provider, Factory 등을 사용 할 수 있을 것 같다.


Factory.of("Metamask") ,  Factory.of("Windows") 라든지
Provider.of("Metamask"), Provider.of("Windows") 식으로 사용 할 수 있을 거란 말이다.

근데 이런 단어들을  떠올리게 하고 영감을 주는데 있어 도움을 주는 것은 평소에 읽기,쓰기를 많이 해본 인문학적 수련이나  수 많은 오픈소스를 읽는 것이겠지만 선현들이 만들어 놓은 잘 알려진 패턴을 학습하는게 가장 효율적이지 않을까?  



잘 짜여진 코드가 무엇일까요??

코드를 잘 짠다는 것은 매우 어려운 일입니다. 많이 아는 것도 중요하지만, 많이 바꾸는 것도 중요합니다. 
코드에 애정이 있다면 자연스럽게 많이 바꾸게 될 것이고, 아름답게 가꾸고 싶어질 것이며 자연스레 코드는 점점 더 좋아 질 것입니다. 물론 공부를 안하면 애정이 있어도 어떻게 아름답게 해야하는지 모를 수도 있겠지요.하지만 애정이 있다면 역시 공부를 하게 되겠죠?? - 한번에 좋은 코드를 짜는 건 세상 어떤 프로그래머도 불가능 합니다.- 

소설가들은 좋은 글을 작성 하기 위해서 한 문장에 대해서 한 달 이상 고민을 한 적도 있다고 합니다.
근데 그렇게 쓰여진 문장을 몇 년 후에 다시 보았을 때, 후회가 밀려오기도 한다고도 합니다.
더 좋은 문장이 생각나기도 하기 때문입니다. 

국어/영어 자체를 뛰어나게 잘 안다고 해서 소설(글짓기)를 잘하는 것과는 또 다른 영역의 문제입니다. 
SF 소설을 쓰기 위한 컨텍스트를 조사하고 이해 하기 위해 수년간 해당 분야 전공을 공부하는 경우도 있다고 합니다. 역사소설도 마찬가지일 것입니다. 

이 글에서는 잘 짜여진 코드에 대해서만 언급합니다.
즉 코드를 잘 짜는 것과 해당 주제를 잘 수행하는 것과는 조금 다른 면이 있습니다. (이 글에선 구분해서 설명합니다.) 

유려하게 글을 잘 썼지만, 팩트가 엉망이면 .......

좋은 작품을 만들기 위해서는 해당 주제를 잘 수행하기 위해서는 아래에 나올 코드를 잘 짜는 것에 추가적으로 해당 주제에 대한 깊은 공부를 해야 합니다.  따라서 아래의 글은 "해당 주제에 대해 용도에 맞게 정확히 실행되는 코드" 라는 점을 가정하고 그 코드가 잘 짜여진 것 처럼 느껴지려면 어떤 점이 나타나야 하는지에 대해서 서술합니다.

잘 짜여진 코드는 아는 만큼 보이며, 아는게 많아도 상호간 기준에 따라 조금은 다르게 판단 할 수도 있습니다.
이제 제가 느끼는  잘 짠 코드가 가지는 요소들에 대해 11가지로 정리해 보겠습니다.
(여유가 생길 때마다  좀 더 풀어서 설명 하도록 하죠) 

1. 변경이 쉽다. 

ㅡ 변경함에 있어서 해당요소의 경계가 명확해야 합니다.
ㅡ 변경함에 있어서 주변소스의 변경은 최소화 해야 합니다.

2. 반복이 없다

ㅡ 반복되는 부분을 하나로 만듭니다.

3. 이름짓기를 잘한다.

ㅡ 추상층이 높아 질 수록 보편적으로 사용하는 키워드를 사용하며, 가급적 GoF패턴의 명칭들은 그 의도와 비슷한 경우에만 사용합니다. (자바쪽에 특히 오용된것들이 많습니다 )

4. 해당언어의 이디엄과 추구하는 방향에 맞춰 코딩한다.

ㅡ 예를들어 자바 코드짜는데 C처럼 짜면안되겠죠. 마찬가지로 코틀린 코드 짜는데 자바처럼 짜면 후지다라는 느낌이 듭니다.

5. 팀이 정한 컨벤션에 일치한다.

ㅡ 예외처리(가까이서 처리? 멀리서 처리? 등)나 로깅의 경우 답이 없기 때문에 일치화가 중요합니다.
ㅡ 탭사용규정,줄바꿈규정 등등

6. 바퀴를 재발명하지 않는다.

ㅡ 이미 잘 짜여진 기술이 있고, 변경 필요가 없다면 있는것을 사용해서 의사소통 비용줄임. 예를 들어 JVM에서 프록시 패턴을 굳이 스스로 만들기 보단 다이나믹 프록시 사용합니다.  스프링지원기술들 사용하는것도 같은 맥락입니다.

물론 발명 할 필요성은 추후에 검토 합니다. C++의 STL이나 자바의 컬렉션이나 보편적 성능일뿐이지 특정상황에선 엄청느리니까요

7. 후임자가 실수 할 여지를 줄인다

ㅡ 제약 상황을 많이 만들어 넣어서 후임자가  잘못하면 가급적 컴파일 타이밍에 에러를 내게 합니다. 

8. 추상화가 잘 된 코드

ㅡ 코드 흐름에 대해서 추상화(일반화)를 잘 해서 뽑아 내면,  유연성을 가지게 됩니다.
    다만
오버엔지니어링 이슈가 있습니다.


9.잘 알려진 패턴이 적절하게 적용된 코드

ㅡ 익히 알려진 패턴을 사용하는 것은 대게 좋은 설계를 선택하게 만들며, 의사소통 비용도 줄 일 수 있습니다.


10. 시대흐름에 맞는 코드 

ㅡ 모던C++, 모던자바를 사용합니다. 언어의 업그레이드에 대해서 관심을 갖습니다.


11.테스트 친화적 코드

ㅡ 테스트 하기 쉬운 코드는 대게 좋은 설계를 의미합니다. 


이런것들이 잘 적용되어 작성된 코드를 보면 감동이 밀려 오지요

20년 경험으로 두서 없이 적어 보았습니다. 당연히 모든 경우가 아래의 경우에 해당하진 않습니다. 
회사 규모,제품과 회사 지향점, 리더들의 특성 및  본인의 위치등에 따라 달라 질 수 있으며 
정해진 답이 없기에  맞다 틀리다 보다는 본인의 경험에 대해서 댓글에 적어주면 후배들이 보기 좋을 거 같습니다.

SI 와 정부과제)

장점 :

1. 새로운 기술 분야/도메인의 도전을 할 기회가 비교적 쉽게/자주 생깁니다.
  1-1. 새로운 기술분야를 도전(경험)할 기회가 있다. 전공이 아닐지라도(인공지능,빅데이터,블록체인등등) 
  1-2. 새로운 도메인 분야를 도전(경험) 할 수 있다. (은행,병원,통신 등등)
2. Low risk / Low Return이다. 즉 정부 돈, 갑의 돈으로 할 수 있으니 부담이 없습니다.
3. 결과물의 품질에 대한 기대치가 낮으므로 해당 스트레스는 덜 합니다.

4. 갑들이 다 망하기전에 내(을)가 망하지 않습니다. 밑에서 주워먹으니까..위에 몇개라도 있으면 됩니다. 
5. 글쓰기를 연습해 볼 수 있습니다. 제안서를 50페이지 잘 써보는것은 중요한 통찰력을 줍니다. (업계현황분석, 기술분석, 제품의 목적 및 로드맵 분석, 시장현황 분석등등) 
6. 진입장벽이 낮습니다. Low risk이기도하고 틀에 박힌 업무를 하는 경우 신규채용의 허들이 매우 낮아 질 수 있습니다.
7. 팀원뿐만 아니라 발주처(국가조직 및 외부회사)와 지속적인 의사소통을 하며 다양한 사람을 만날 수 있다. 개발 이외의 것들에 대한 경험도 많아 집니다.
8. 이직이 비교적 쉽습니다. 프리를 할 기회도 비교적 많습니다.

단점:

1. 발주처와 수주관계에 있어 갑,을 이 명확하기 때문에 고객사로부터 심리적 스트레스를 받을 수 있습니다. 이와 동시에 외부에서 일 할시 소속감이 없어 질 수 있습니다.
2. 정부과제도 그렇고 굳이 쓸 필요가 없어보이는 문서를 써야하는게 괴롭습니다.
3. 5명이 해야 할 일을 혼자 하나의 프로젝트를 맡아서 하는 경우 중압감이 있을 수 있습니다. 
    (다만 계약이 5명으로 되있다고 해도 혼자 할 수있는 분량일 가능성도 높습니다.) 
4. 제안서의 내용은 보통 과하고 애매하기 때문에 , 제안서를 작성한  사업부와 마찰이 생길 수 있으며,
    그걸 구현하는 완벽주의자에겐 고통일수도..
5. 도메인이 자주 달라져서 힘듭니다. 계속 도메인을 배워야 합니다.
6. 대기업이 아닌 이상 연봉이 높진 않습니다. High Return이 아니니 당연합니다.
7. DevOps나 AWS와 최신버전등 먼가 신선해 보이는 경험을 할 확률이 줄어듭니다.
8. 정부과제를 따내기 위한 억지 문서를 만들 때 자괴감이 들 수 있습니다.
9. 먼가를 해 냈다는 성취감이 비교적 적습니다.
10. 계속 똑같은 강도로 일을 해야 돈을 벌 수 있습니다.


서비스)

장점 :

1. 24시간 서비스가 돌아가기 때문에 적절한 긴장감을 갖게 되며,  서비스에 대해 애정이 있다면 오너쉽이 비교적 잘 생깁니다. 
2. 정부과제나 SI에 비해서 테스트코드,소스리뷰가 더 필수적입니다. 역시 실시간성을 갖기 때문입니다. 
3. 한번 잘 개발해 놓으면 투입은 적고 수익이 많이 나는 캐쉬카우가 됩니다. 다른 것을 도전 할 수 있습니다.
4. 고객사로부터 심리적 스트레스를 받을게 없습니다. 다만 오너와 소비자에서 스트레스 받습니다. 
5. 문서 쓸 일이 비교적 적습니다.
6. DevOps나 AWS등 먼가 신선해 보이는 경험을 할 확률은 큽니다.
7. 한번 서비스를 해보면 또 다른 것을 해보는데 자신감이 붙습니다. 이에 따라 기술 스타트업을 창업할 자양분을 얻을 수 있습니다.

단점)

1. 새로운 기술 분야/도메인의 도전을 할 일이 비교적 없습니다. 
2.. High risk / High Return이라 중압감이 큽니다.서비스 죽거나~해당 시간에 구현을 못했을 경우등
3. 오너/팀/소비자에서 스트레스 받습니다. 회사 내에서 서로 탓할 꺼리가 많아집니다.
4. 깊이 있는 기술력이 보다는 빠르고 기민하게 요구하는일이 많습니다. 대신 더 완벽성을 필요로합니다.
5. 뭘 해야할지 뚜렷한 로드맵이 없거나 자주 바뀌는 경우가 있습니다.
6. 망하기 쉽습니다. (오너가 갈아타기 비교적 쉽습니다) 
7. 먼가 한방터지면 크게 벌것 같지만, 본인이 거기에 포함되는 일은 없을 겁니다. 


솔루션/미들웨어) 

장점:
1. 어느 한 분야에 대한 장인이 될 가능성을 높여줍니다. 
2. 특정언어에 대한 많은 것을  깊이있게 사용 해 볼 기회가 생깁니다.

    (각종패턴,동시성,리플렉션,템플릿, DSL 등) 
3. 알고리즘/자료구조를 직접 구현 해 볼 기회가 더 자주 생깁니다. (표준이 아닌 상황에 맞는 구현체) 
4. 기술적으로 성공 하고 사업적으로 성공 해 냈다면 성취감이 매우 큽니다.
5. 성공적인 솔루션을 가지고 있으면, 서비스보다 더 효율적인 캐쉬카우가 됩니다.
6. 엉덩이 무거운 스타일의 분에게 적합합니다.
7. 기민함보다는 충실함이 중요한 분에게 적합합니다.
8.뛰어난 1명이 100명보다 중요합니다.  뛰어난 장인이 있을 가능성이 크며 그에게 배울 수 있습니다. 
(이 분야의 장점은 스승이 공개되어 있다는 겁니다. 어느 회사에 들어가지 않아도 됩니다. 오픈소스가 열려 있으며,배움은 오로지 자신에게 달려있습니다. 기인에 의해서 자신의 실력이 급상승하는 상황은 없습니다.) 


단점:
1. 성공하기 어렵기 때문에 사업적 성취감을 느낄 가능성은 크지 않습니다. (기술적 성취감은 자주느낌) 
2. 새로 조인했을때 보편적이지 않은 기술들에 대한  경험이 부족으로 오너쉽을 쉽게 갖기 힘들 수 있으며, 그때 의욕이 떨어 질 수 있습니다. 
3. 어려운 도전과제가 많습니다. 자존감이 떨어질수도..

4. 이직이 비교적 쉽지 않습니다. 
5. 솔루션을 가지고 SI할 확률이 매우 높습니다. 그때 SI의 단점은 모두 가져옵니다.


각 업종에 대해서 살펴보았습니다.

누군가에겐 좋은 사람도 누군가에겐 나쁜 사람이 되는 곳이 직장이며, 
점점 좋아질 확률 보단 점점 나빠질 확률이 높은 곳도 직장입니다. (엔트로피? ㅎ) 
불안정성이 가득한 직장 생활이지만, 개발자라는 직업은 비교적 안정적이라고 생각합니다.

저는 사람마다 자신의 적성에 맞는 곳이 따로 있다고 생각합니다. 
어떤 곳에서는 실패하거나 적응하지 못했다고 해서, 당신이 실패한건 아닙니다.
더 좋은 곳에서 더 좋은 결과를 낳을 수 있으리라 생각합니다. (다만 코딩을 대하는 자세는 어딜 가나 잘 안 바뀝니다. 매사 진지하게 대하는 버릇이 필요합니다.)
자신에게 좋은 직장을 찾는데 용기를 내시길 바랍니다. 인생 새옹지마입니다.


PS) 업무강도와 연봉은 현재 회사상황이 어떠냐, 얼마나 버는 회사냐, 어떤 리더들이 있냐에 따라서 달라집니다. 어떤 분야의 회사냐, 어떤 직군이냐 보다는~

누구나 실수/실패의 상황을 마주하게 되며, 이것은 당신의 실수일 경우도 있고, 타인의 실수 있을 수 도 있으며, 서버/네트워크상에서 일어나는 실패(장애)일 수 도 있다. 이런 다양한 실수/실패는 개발자의 삶과 항상 함께 하는 것이기 때문에, 어떻게 이것을 다루는지가 관건이 된다. 이 글에서는 어떻게 실수/실패등 의도치 않은 상황을 처리하는지, 다양한 패턴들과 함께 살펴보겠다.  

1. 디폴트값 처리 

val value = getValue() 
val gretting = value?: "hi"

정상적인 상황하에서 값을 얻지 못하였을 경우 우리는 디폴트값을 할당하여 사용 할 수가 있다. 디폴트을 사용할 수 없는 경우에는 대개 예외를 던지거나 실패값을 리턴해서 상위에서 처리하길 기대 할 수 밖에 없다.

2. Require / Check / Assert  

보통 의도치 않은 결과를 받았을때 무시하고 조용히 넘어가기도 하나 "나 문제 있어요" 라고 공격적으로 알리기도 한다. 코틀린언어로 설명하면 (대부분의 언어가 동일한 기능을 제공한다) 아래와 같은 키워드들이 존재 한다.

require : 주로 파라미터가 기대하는 값인지에 대한 검사 
check : 주로 객체가 기대하는 상태를 가지고 있는지에 대한 검사 
assert : 테스팅 모드에서 어떤 값이 true 인지를 체크하는 검사 (JVM에서는 -ea옵션을 줬을때 작동) 

fun pop(num: Int = 1): List<T> {
	require(num <= size) { "Cannot remove more elements than current size" }
    
    check(isOpen)

	val ret = collection.take(num) 
    collection = collection.drop(num) 
	assert(ret.size == num) 
	return ret 
}


3. 예외 상황

무엇인가 원하는 결과가 발생되지 않았을 경우 당장 적절한 해법이 없을 때,  우리는 예외 처리를 하게 되는데, 예외 처리라는 것은 굉장히 까다롭다. 해당 예외를 발생한 가장 가까운 곳에서 처리 할 수도 있으며, (가장 잘아니깐) 가장 먼곳까지 거슬러 올라가서 처리 될 수도 있다. 또한 예외 발생시 프로그램을 죽여야 하는 경우도 있고, 살리되 로그라든지 클라이언트측에 잘 설명해야 하는 경우도 있다. 

젤 어려운 부분 중 하나는 모두 되돌릴 것인가? 이미 벌어진 일은 무시,포기할 것인가? 인데 
그냥 살리는게 중요 포인트라면 예외 발생시 그냥 로그정도 출력하든지 클라이언트에게 알려주고 지나치면 된다. 그 메소드를 호출한 사람은 아마 한번 더 호출해 보겠지. 아니면 그 사람은 망하더라도 다른 모든 사람들에게는 서비스가 진행 될 수도 있고.. 하지만 전체 흐름을 되돌려야한다는 문제의식을 갖게되는 어플리케이션이라면 정말 거대한 도전이 된다. 트랜잭션정합성, Redo,Undo,커맨드패턴 ,사가패턴 같은 단어들을 떠올려야 하기때문이다.

자 갤럭시 7 을 생산하는 공장에서 갤럭시 7 에 들어가는 전자기판(pcb)을 검사하는 프로그램이 있다고 해보자.  그 프로그램은 기판을 검사하기 위해 많은 지식이 사용자에 의해 학습되어야 한다. 그 기판에는 어떤 부품들이 있고 어떻게 생겼고 어떤 방식으로 불량이 아닌지를 확인하는등.. 이것을 학습된 검사조건들 이라고 하자.

이 검사조건 중 하나의 값을 30-> 50 으로 올릴때  발생하는  시나리오가 아래와 같이  흘러 간다고 하자.

a. 50을 입력받아서 
b. 50을 포함한 새로운 임시 객체를 만들어서 적용해 나간다.
c. 변경 된 데이터를 많은 뷰에 적용시키고 (즉 화면뷰에도 50으로 바뀌고 , 리스트뷰에도 바뀌고) 
d. 네트워킹을 통해 중앙저장소에도 바꾸어주고 
e. 검사조건을 모아놓은 메모리 자료구조를 수정한다. 
f. 로컬 xml 파일에 새로운 50을 써준다.
g.완료 (버튼 활성화)

이렇게 메소드들이 호출된다고 할때 갑자기  d에서 문제가 생기면 어떻게 될까?  메모리에는 이 어플리케이션이 갖는 최종적인 마지막 값이 50으로 갱신되지 못한 채 예외가 생겨서 30으로 남겨져 있다. 그리고 검사조건들을 저장해놓은 파일도 갱신되지 못하여 나중에 프로그램을 시작할때 30으로 읽어들일 것이다. 하지만 화면뷰와 중앙저장소의 값은 50인데??

먼가 문제가 생겼을때 돌이키는 방법으로는 메멘토패턴 혹은 커맨드패턴을 주로 사용하는데 커맨드 패턴인 경우 undo / redo 를 처리 할때 execute 와 unexecute 함수를 가지고 내부에 이전정보,이후정보를 속성으로 가진 커맨드 객체를 이용한다. 근데 이때 unexecute 에서 처리해 줘야할 것들이 간단치 않다는게 문제이다. 저 전체를 한 묶음으로 묶어서 처리하는 트랜잭션 처리시  데이타베이스처럼 상대적이고 일관되고 한정된 로직에 있는 것도 아니고 메소드의 역할에 따라 다른 행동에 대해 어떻게 트랜잭션 처리를 일관되게 할 수 있을까?  맨붕이다..

4. 예외를 던질 것인가? 리턴 값으로 fail을 던질 것인가?  

함수에서 문제가 생겼을때 문제 상황을 외부로 전파하는 방식은 대표적으로 2가지가 있다. 

- 리턴을 null이나 Failure/Option/Try 객체를 리턴한다. (가까운곳에서 처리하길 기대)
- 예외를 던진다. (먼곳에서 처리되길 기대) 

예외를 통한 처리 보다는 리턴을 통해 처리하는 것을 우선시 하며, 예외는 좀 더 처리하기 힘든 상황에 대한 핸들링을 하면 된다. (애매할 수 있는데, 가장 윗단에서 처리되길 기대하는 상황? 예로 백엔드의 controller나 UI프로그램의 시작지점), 예외는 사용자의 가독성이 약하며, (자바처럼 강제하지 않는다면 더더욱), 프로그래머가 무심코 넘기기 쉽다. 그리고 정확한 진단을 내리는데 두리뭉실 할 수가 있으며, try~catch문은 컴파일러가 최적화하는데도 방해하곤 한다. 


5. 커맨드패턴 

커맨드 패턴은 보통 메소드(동사)를 클래스(명사)화 하여 사용하여, 행위를 수집 할 수 있게 한다.
보통 실행/역실행의 2가지 메소드를 포함하는데 무슨 말인고 하니... 아래 코드를 보자. 

class Painter {
  fun draw(){ .. }
  fun remove(){ .. }
}

보통 클래스는 위처럼 명사이고 메소드는 동사임을 알 수 있다.
여기서 draw라는 동사(메소드)를 명사(클래스) 화 해보면 

interface Command {
  fun execute()
  fun unexecute() 
} 

class DrawCommand (val start: Point, val end: Point) : Command {
 fun execute() {
    // draw start to end 
 }
 
 fun unexecute() {
   // remove start to end 
 } 
}

위처럼 DrawCommand라는 클래스가 생기고, 메소드로는 실행/실행취소 2가지 주요 메소드가 만들어진다. 
이렇게 DrawCommand라는 클래스를 만들게 되면, 그림판에서 무엇을 열심히 그리는 동안 그려지는 과정을 내부적으로 Undo 스택을 만들어서 push해 놓게 하였다면, 나중에 내가 잘못 드로잉을 했구나 라고 깨닫게 되었을때, 스택을 되돌리며 unexecute를 실행해서 다시 실수를 "원상복구" 할 수 있게 된다. 이때 unexecute의 구현은 어떤 command냐에 따라서 천차만별 달라 질 수 있을 것이다. 이런 어려움은 위의 3번에서 설명한 부분이다.


6. 서킷브레이커

외부의 서비스에 요청 콜을 날리고 나서 대개는 바로 응답을 받으나, 외부 서비스의 문제 혹은 네트워크의 문제로 응답이 안오거나 늦어질 경우가 있다. 이때 아무런 조치를 안 해 놓는다면 매번 느려진 응답을 기다리느라 장애가 전파되며 전체 시스템에 문제가 생길 수 있으며,  클라이언트측을 화나게 할 수 있다. 문제가 있으면 바로 문제가 있다고 알려주는게 나을 것이며, 1번의 디폴트값 처리식으로 처리 할 수도 있을 것이다. 이런 상황에서 사용 하는게 서킷브레이커인데, 문제가 생기면 더 이상 호출하지 않도록 중간에서 차단시켜 주는 방식이다. 문제가 생기자마다 차단 한 후에 n번까지는 기다려 줄 수 도 있겠으며, wait 인터벌을 다양하게 가져갈 수 도 있을 것이다. 

문제가 해결되었음을 감지한 경우에도 처리하는 방식이 달라질 수 있는데, 모든 리퀘스트를 정상상태와 같이 처리 하게 할 수도 있겠고, 이게 또 무슨 문제가 일어날 지 모르니 살살 간을 보면서 시나브로 오픈하게 만들 수도 있겠다.  
 

7. Consistent Hashing 

어떤 데이터들을 N개의 노드에 분산시켜서 저장한다고 해보자. 혹은 리퀘스트를 분산시켜서 처리 한다고 해보자.
이때 가장 먼저 쉽게 떠오르는 방식은 데이터 d를  hash(d) mod n 해서 나온 노드에서 처리하게 만드는 것이다. 근데 이런 방식은 노드 하나 이상이 실패(장애)가 생기는 경우 큰 비용을 초래하게 되는데, 기존 노드에 있던 데이터들이 모두 재계산되어야 하기 때문이다. (hash(d) mod n <-- 이 값이 달라지니깐)

Consistent Hashing 은 이런 경우를 최소비용으로 해결하는 멋진 방법이다. 이것은 일련의 해시 값을 가상의 링(ring)에 순서대로 나열했다고 가정하고 각 노드는 링에서 각자 맡은 범위만 처리하는 방법이다. 만약 노드를 추가하면 특정 노드(많은 데이터를 가진 노드)가 맡고 있던 범위를 분할하여 새 노드에 할당한다. 노드를 삭제할 때는 링에서 삭제된 노드가 맡고 있던 범위를 인접 노드가 맡는다. 따라서 서비스 중에 노드의 추가/삭제로 인해 영향을 받는 노드 수를 최소화할 수 있다. 

8. Saga패턴

 
마이크로서비스 도입의 환상을 깨주는 가장 큰 문제는 트랜잭션 처리일 것이다. 하나의 단일 RDBMS를 사용 할 경우, 신경 쓰지 않아도 되는 ACID문제가 , 여러 서비스에서 여러 DB를 사용하며, 해당 서비스간에 강력한 의존성을 가지고 있다면 지옥으로 다가오게 된다. CID는 어느정도 참아줄 수 있다고 해도, A(원자성)에 문제가 생기다면 큰 손실을 입을 것이기 때문이다. 즉 A가 B에게 돈을 1억 줄때, 양쪽이 모두 변화가 있어야지, 한쪽만 변화가 있다면?? OTL ..이때 해결방법으로 분산트랜잭션이라는 허상을 사용 할 수 도 있는데, two way commit은 제대로 작동하기엔 너무 부족하며 좀 더 나은 방식으로 우리는 Saga패턴을 고려 할 수 있다.  (그래서 마이크로 서비스를 설계 할 때, 서비스끼리 너무 많은 상태를 공유/의존하는 것을 지양해야 한다) 

사가 패턴에서는 보상이라는 개념으로 (위에 커맨드패턴에서 말하는 것 처럼, unexecute()) 이벤트의 흐름 중 전체가 완결되지 않았다면, 뒤로 롤백하면서 하나씩 Undo(보상)하는 것이다.이 흐름의 조율을 중앙에서 매니징 할 수 도 있고, 각 서비스들이 서로 이벤트를 전달하며 분산해서 처리 할 수도 있다 


9. 이중화,복제   

동일한 것(서비스,데이터)을 n개로 두고,  사용하게 하는 모든 것을 총칭.
듀얼디바이스(RAID,PSU등) , L4/L7 스위치, HAProxy, NginX, Zuul, ELB, API Gateway 등등 

 

'코딩'은 소설가의 '글쓰기'와 비슷하다고 생각 합니다. 

소설가가 '글쓰기'를 잘하기 위해 매일 매일 습작을 하듯이..'코딩'을 잘하기 위해 서는 매일 매일 코딩 하는 버릇을 들이고, 평생 '장인' 정신을 가지고 노력해야하는 일인거 같습니다.  글쓰기는 잘하는 사람이 코딩도 잘할거라는 확신이 있습니다. (여담으로 자신의 생각을 표현하는데 적극적인 작가형 사람은 무엇이든 만들어내는걸 잘 할 것이고,  짧은 생각으로 비판만 하는 습관만 있는 사람이 독창적으로 무엇인가 스스로 만들어 내기 힘들 거란건 충분히 어리짐작 할 수 있겠지요.)

재능과 반복

지나가는 길에 방망이 깍는 노인을 보고, 일기장에 "오늘 방망이 깍는 노인을 봤다, 신기했다" 라고 단편적으로 적는것과 그것을 주제로 수필을 쓰는 능력은 천지차이 겠지요. 꼬아보기,상상력 및 끈기가 필요합니다. 토지같은 장편소설을 글쓰기 하는것은 스프링프레임워크 혹은 데이타베이스를 코딩하는것과 일견 일맥상통 합니다. 글쓰기와 코딩은 이미 모든것을 준비(설계) 해 놓고 쓰는 것이 아니라, 쓰다 보면 쓸거리가 더 생기게 마련인 반복적인 작업 입니다. 어떤 분야든 재능이 중요하지만 반복을 통해 극복가능하리라 생각합니다.

언어의 풍미

글쓰기/코딩을 잘 하려면 해당 언어에 대한 풍부한 지식을 가지고 있어야 합니다. 자기가 알고 있는 어휘가 많을수록(습관도 들여진) 단어와 그 단어와 어울리는 연속된 단어와 문장들을 서술 할 수 있게 됩니다. 언어를 잘 알면 가독성도 좋아지며 적은 묘사만으로 풍부한 해석을 가능케도 합니다.  대부분의 언어는 단순히 자/모음을 가지고 있지만 언어의 풍미는 상당히 다를 겁니다. 해당 언어의 풍미를 살리기 위해서는 네이티브가 아니고서는 파악하기 오래 걸릴 겁니다. 즉 언어를 깊게 배운다는 것은 문화를 배우는 것을 포함합니다. 

클리세와 디자인 패턴 

수천년의 시간동안 건축에 대한 보편적인 구조/배치등이 정립되어 왔듯이 아침드라마 작가가 되기 위해서는 수 많은 이전 아침드라마에서의 클리세를 참고해서 글을 쓰면 주부들의 마음을 뺏을 수 있을 것입니다. 코딩 또한 기존 선배들이 작성했던 방식을 알고 있다면 일반적으로 유연하고 읽기 좋은 코드를 작성 할 수 있을 것 입니다.

컨텍스트(기반기술)

두 작업(코딩,소설) 모두 해당 컨텐츠에 관련된 컨텍스트를 연구하고 수집해야 합니다. 목적과 주제를 분명히 알고 집중 해야 합니다. 쓰기로 마음 먹은 주제에 대해서 옳은 방향으로 잘 풀어 나가려면, 근거가 되는 컨텍스트에 대해서 면밀하게 조사/분석을 해야 합니다. 해당 컨텍스트를 근거로 실질적 글쓰기는 설계되고 구축이 되어져 가게 됩니다. 컨텍스트에 대한 지식이 부족하면 글쓰기가 마음먹은데로 진행되지 않으며, 의미 없는 고민에 빠지게 됩니다. 그래서 문서나 API를 조사하고 읽는법이 중요한것입니다. 어려운 조립식 장난감의 설명서를 잘 읽고 적용도 잘하는 사람들이 있죠. 

잘 읽혀지기  

소설과 마찬가지로 코드는 쓰는 시간 보다 읽혀지는 시간이 훨씬 많습니다. 쓰는 사람도 자기가 쓴 글을 쓰는 시간보다 훨씬 많이 읽어보게 되며, 독자나 유지,업데이트를 해야하는 사람들은 더더욱 그러하겠지요. 따라서 잘 읽혀지는 것은 매우 중요합니다. 두고 두고 다른사람들이 보아야 할 것이고 고쳐야 할 것이기 때문에 잘 읽히고, 어떤 부분은 편하게 어떤 부분은 불편하게 사용하도록 변주(變奏)가 필요하며 조율(調律)을 해나가야 합니다. 그리고 한 문장에 하나의 주장을 담아서 간략히 문장을 만들어야 합니다. 만연체로 쓰여진 문장은 읽기 괴롭지요.

근데 "잘 읽혀진다"라는 의미는 단순하진 않습니다. 평소 풍부한 어휘와 배경지식을 많이 가지고 있는 토종 한국인에게 읽혀지는 "토지"에 대한 읽기는 한국어를 어지간히 배운 외국인이라고 어려울 것이기 때문입니다. 그들에게는 모든 언어에서 공통으로 사용하는 단순한 어휘와 문장구조로 쓰여진것이 외국인에겐 더 가독성이 좋겠지요. 그렇다고 토지를 그렇게 작성하는게 옳은거라 생각하진 않습니다.

퇴고와 점진적 향상 

마지막으로 이런 모든것을 반복적으로 고쳐가며 개선시키는 퇴고의 과정이 반드시 필요합니다. 아무리 천재소설가라도 천재 개발자라도 퇴고 없이는 부족한 결과물만 나올 뿐입니다. 보면 볼 수록 쓰면 쓸 수록 나아집니다. 쓰는 것을 두려워 하면 안됩니다.누구나 처음은 초라하며 고쳐가며 좋아집니다. 주변사람들의 의견은 많은 도움을 줄 수 있습니다.  많은 피드백을 받을 수 있는 인기 오픈소스의 품질이 좋은 이유도 그것입니다. 


p.s


코딩은 공학과 문학의 조화에 있습니다.
공학을 확실한 답이 있는 것(예: 상황에 맞는 자료구조, 정해진 스펙 구현)이라고 정의 하고 ,
문학은 답이 자유분방한 것(예: 예외 처리 설계, 추상화)이라고 정의 한다고 가정 해 볼 경우에
보통 개발자들이 평소에 마주치는 대다수 고민들은 문학적 질문과 답에 있다고 생각합니다.
그래서 더욱더 코딩을 글쓰기와 비견되는 거 같습니다.

+ Recent posts