complexity | software-design | domain-driven-design | programming

소프트웨어 복잡성의 원인과 줄이는 법, State부터 Deep Module까지

State/Control이 만드는 복잡성의 원인부터 Immutable, Deep Module, 진화하는 아키텍처, 분산 시스템의 Essential Complexity까지 복잡성을 줄이는 이론과 실천을 정리합니다.

Mimul
MimulAugust 04, 2021 · 96 min read · Last Updated:

Fred Brooks는 1986년 ”No Silver Bullet“에서 소프트웨어를 어렵게 만드는 성질로 복잡성(Complexity), 순응성(Conformity), 변경 용이성(Changeability), 비가시성(Invisibility) 네 가지를 꼽았다. 그런데 2006년 벤 모즐리와 피터 마크스는 논문 ”Out of the Tar Pit“에서 이 네 가지 중 진짜 근본 원인은 복잡성 하나뿐이라고 정리한다. 순응성은 기존 시스템의 복잡한 제약에 맞춰야 하는 문제이고, 비가시성은 복잡한 구조를 눈에 보이는 형태로 그려낼 수 없다는 문제이며, 변경 용이성이 어려운 이유도 결국 시스템이 복잡해서 어디를 건드려야 할지 알 수 없기 때문이다. 네 가지 성질은 서로 다른 이름을 달고 있지만 뿌리를 파보면 하나로 수렴한다.

이 결론에 무게가 실리는 이유는 복잡성이 소프트웨어의 신뢰성, 납기, 보안, 성능 문제 대부분의 공통 원인으로 지목되기 때문이다. 어떤 문제든 해결하려면 먼저 시스템을 이해해야 하는데, 그 이해를 가로막는 것이 다름 아닌 복잡성이다. Dijkstra는 “스스로 만든 복잡함에 짓눌리지 않으려면 명료하고 얽힘 없고 단순하게 유지해야 한다”고 했고, 1990년 튜링상 수상 연설에서 Corbató는 “야심 찬 시스템의 일반적인 문제는 복잡성”이라며 복잡성은 어려움을 곱절로 늘리는 방식으로 작동하기 때문에 단순함과 우아함의 가치를 강조해야 한다고 말했다. Backus는 1977년 튜링상 연설에서 기존 언어들의 복잡함과 취약함을 지적하며 “프로그램을 사고하도록 돕는 강력한 방법론이 절실히 필요하다”고 했다. Hoare는 1980년 튜링상 연설에서 한발 더 나아간다. “돈으로 살 수 없는 단 하나의 품질이 있다면 신뢰성이다. 신뢰성의 대가는 철저한 단순함을 추구하는 것”이라고 하면서, 소프트웨어를 설계하는 방법은 두 가지뿐이라고 정리한다. 결함이 없는 게 뻔히 보일 만큼 단순하게 만들거나, 결함이 있는지조차 보이지 않을 만큼 복잡하게 만들거나. 그리고 첫 번째 방법이 훨씬 어렵다고 덧붙인다.

단순함이 어렵다는 이 결론은 “Out of the Tar Pit” 저자들도 그대로 받아들인다. 논문에는 “Simplicity is Hard”라는 소제목이 등장한다. 다만 저자들은 이 어려움 앞에서 체념하지 않는다. 복잡성이 어디서 생기는지 원인을 추적할 수 있다면 그중 상당수는 피할 수 있다는 것이 논문의 낙관이다. 이 글은 그 추적 경로를 따라간다. 먼저 복잡성이 실제로 어디서 생겨나는지 살펴본 뒤, 그중 피할 수 없는 부분과 피할 수 있는 부분을 가르는 기준을 세운다. 그다음 복잡성이 가장 위험해지는 지점을 짚고, 단순함을 만드는 구체적인 선택들과 그 선택을 코드 구조로 옮기는 설계 기법을 정기결제 지갑 서비스라는 하나의 예제로 이어서 확인한다.

시스템의 복잡성은 어디서 오는가

Out of the Tar Pit“은 대규모 시스템에 남아 있는 복잡성의 원인을 상태(State), 제어(Control), 코드량(Code Volume) 세 가지로 좁히고, 여기에 언어의 과도한 자유도라는 네 번째 요인을 덧붙인다.

상태가 만드는 복잡성

고객센터에 전화를 걸었을 때 “다시 시도해보세요”, “새로고침 해보세요”, “프로그램을 재시작하세요”, 심하면 “운영체제를 재설치하고 프로그램을 다시 까세요”라는 답을 들어본 적이 있다면, 상태(state)가 만드는 복잡성을 몸으로 겪은 것이다. 이런 답변이 낯설지 않은 이유는 실제로 효과가 있기 때문이고, 효과가 있는 이유는 많은 시스템이 상태를 다루는 데서 오류를 내기 때문이며, 오류가 나는 이유는 상태가 존재하는 것만으로 프로그램이 이해하기 어려워지기 때문이다.

문제의 크기는 숫자로도 드러난다. 상태를 저장하는 비트 하나를 추가할 때마다 프로그램이 가질 수 있는 전체 상태의 수는 두 배로 늘어난다. 상태가 몇 개만 쌓여도 사람이 머릿속으로 시뮬레이션할 수 있는 범위를 순식간에 넘어선다. 더 나쁜 것은 오염(contamination)이다. 상태를 갖지 않는 함수라도 내부에서 상태를 가진 다른 함수를 호출하는 순간, 그 함수 역시 상태의 맥락 안에서만 이해할 수 있게 된다. 낙타의 코끝만 천막 안으로 들여놓으면 결국 몸통까지 따라 들어온다는 속담 그대로다. 테스트도 마찬가지로 취약해진다. 어떤 입력에 대한 테스트 결과는 다른 입력에 대해 아무것도 말해주지 않는다는 것이 테스트의 근본적 한계인데, 상태가 있으면 같은 입력이라도 시스템이 어떤 상태에 있었는지에 따라 결과가 달라진다. 입력의 조합만으로도 감당하기 벅찬데, 여기에 상태의 조합까지 곱해지는 셈이다.

상태 비트 수에 따른 전체 상태 조합 수12345678상태를 저장하는 비트 수240220200180160140120100806040200가능한 상태 조합 수

비트 하나가 늘 때마다 막대는 배로 뛴다. 필드 몇 개만 더해도 사람이 머릿속으로 따라갈 수 있는 범위를 곧바로 벗어나는 이유가 이 그래프에 그대로 드러난다.

다음 코드는 정기결제 지갑의 충전 로직을 상태를 직접 변경하는 방식으로 짠 것이다.

Before

class Wallet {
    private long balance;
    private int retryCount;
    private boolean locked;

    void charge(long amount) {
        if (locked) {
            retryCount++;
            if (retryCount > 3) {
                throw new IllegalStateException("잠금 해제 필요");
            }
            return;
        }
        balance += amount;
        retryCount = 0;
    }
}

이 코드가 옳은지 확인하려면 charge를 한 번 호출한 결과만으로는 부족하다. 호출 시점에 locked가 어떤 값이었는지, retryCount가 몇이었는지에 따라 같은 amount를 넣어도 결과가 달라진다. balance, retryCount, locked 세 필드는 서로 다른 시점에 서로 다른 이유로 바뀌므로, 이 객체가 가질 수 있는 상태의 조합은 세 필드 각각의 가능한 값을 모두 곱한 만큼 존재한다. 필드 하나를 더 추가할 때마다 이 조합은 배로 불어난다.

제어가 만드는 복잡성

제어(control)는 일이 벌어지는 순서에 관한 것이다. 문제는 프로그래머가 순서에 관심이 없는 경우에도, 대부분의 명령형 언어가 순서를 명시하도록 강제한다는 데 있다.

a = b + 3;
c = d + 2;
e = f * 4;

이 세 줄은 서로 값을 주고받지 않는다. 어떤 순서로 실행되든 결과는 같다. 그런데도 언어의 의미론상 이 코드는 위에서 아래로 순서대로 실행된다고 규정되어 있다. 이 코드를 읽는 사람은 실제로는 무의미한 이 순서를 일단 유효한 제약으로 가정하고 읽기 시작해서, 다시 살펴본 뒤에야 순서가 무관하다는 것을 확인해야 한다. 짧은 예제에서는 이 확인이 순식간에 끝나지만, 코드가 복잡해질수록 순서가 정말 무의미한지 판단하는 일 자체가 만만치 않은 작업이 된다. 판단을 잘못하면 발견하기 어려운 버그로 이어진다. 프로그래머가 요청하지도 않은 순서를 언어가 강제로 부여하고, 그 부여된 순서가 실제로는 무관하다는 것을 다시 확인하는 이중의 낭비가 발생하는 셈이다.

동시성이 더해지면 문제는 한 단계 더 나빠진다. 상태가 있는 두 스레드가 뒤섞이면 똑같은 초기 상태와 똑같은 입력으로 테스트를 두 번 실행해도 같은 결과가 나온다는 보장이 사라진다. 입력이 같아도 상태에 따라 결과가 달라지는 문제에, 이번에는 실행 순서에 따라서도 결과가 달라지는 문제가 겹친다.

코드량과 언어의 자유도

코드량 자체는 부차적인 원인이다. 상태와 제어를 다루는 코드가 결국 코드량으로 쌓이기 때문에 따로 떼어 말할 필요가 없어 보이지만, 두 가지 이유로 독립적으로 짚을 가치가 있다. 측정하기 가장 쉬운 지표라는 점, 그리고 다른 원인들과 나쁜 방향으로 상호작용한다는 점이다. Brooks는 이 비선형 증가를 essential complexity의 증거로 들었지만, Dijkstra는 다른 의견을 냈다. 지적 노력이 프로그램 길이의 제곱에 비례해서 늘어난다는 법칙이 있다는 주장이 있지만 그 법칙이 증명된 적은 없으며, 추상화를 제대로 활용하면 이해에 드는 노력이 프로그램 길이에 비례하는 정도로 그칠 수 있다는 것이다. 상태와 제어를 잘 관리한 시스템에서는 코드량이 늘어나도 복잡성이 비선형으로 폭증하지 않는다는 뜻이다. 코드량의 비선형 증가는 코드 자체의 숙명이 아니라, 상태와 제어를 방치했을 때 나타나는 결과에 가깝다.

네 번째 원인은 언어가 허용하는 자유도다. C의 자유로운 메모리 관리가 대표적이다.

Wallet *w = malloc(sizeof(Wallet));
// ... 사용
free(w);   // 호출을 잊으면 누수, 두 번 호출하면 오류

해제 시점과 여부를 프로그래머가 직접 판단해야 하고, 판단을 틀려도 그 대가는 한참 뒤 프로그램이 실행되는 도중에야 드러난다. 언어가 강제하는 규칙 없이는 이런 실수와 오용이 반드시 일어난다. 가비지 컬렉션이 복잡성을 줄인 이유는 새로운 규칙을 추가해서가 아니라 수동 메모리 관리라는 자유도 자체를 없앴기 때문이다. 같은 원리가 상태에도 적용된다. 상태를 쓰지 말라고 아무리 권고해도 언어가 상태 자체를 허용하는 한 문제는 남는다.

네 가지 원인은 서로 독립적으로 작동하지 않는다. 상태와 제어가 결합하면 상태에 따라 분기하는 프로그램이라는, 둘을 따로 다룰 때보다 훨씬 이해하기 어려운 결과물이 나온다. 원인을 알아보기 어려울 정도로 시스템이 복잡해지면 이미 있는 기능인지 확인하는 일조차 버거워져서 같은 기능이 중복으로 만들어진다. 마감이 임박했다면 이 경향은 더 심해진다. 복잡성은 새로운 복잡성을 부르는 방향으로 저절로 굴러간다. 이 악순환을 끊으려면 의식적인 개입이 필요하고, 그 개입의 첫 단계는 어떤 복잡성을 없앨 수 있고 어떤 복잡성은 없앨 수 없는지 가르는 일이다.

Essential Complexity와 Accidental Complexity

Brooks는 “No Silver Bullet”에서 소프트웨어의 어려움을 본질(essence)에서 오는 것과 우연(accident)에서 오는 것으로 나누고, 소프트웨어의 복잡성은 우연이 아니라 본질적인 속성이라고 결론 내렸다. 지금 남아 있는 복잡성 대부분이 피할 수 없는 것이라는 뜻이다. “Out of the Tar Pit” 저자들은 이 결론에 동의하지 않는다. 복잡성 자체는 소프트웨어의 필연적 속성이 아니며 단순하면서도 여전히 소프트웨어인 프로그램을 얼마든지 만들 수 있고, 현재 시스템에 존재하는 복잡성의 상당 부분은 본질적이지 않다는 것이 저자들의 반박이다.

이 반박을 뒷받침하려면 본질과 우연을 가르는 기준이 있어야 한다. 저자들이 세운 기준은 이렇다. Essential Complexity 는 사용자가 보는 문제 자체에 내재한 복잡성이다. Accidental Complexity 는 그 나머지 전부다. 개발팀이 이상적인 세계에서는 다룰 필요가 없었을 복잡성, 이를테면 성능 문제나 언어와 인프라의 한계에서 오는 복잡성이 여기 속한다.

이 기준을 더 엄격하게 벼리면 이렇게 정리된다. 사용자가 무엇을 원하는지에 해당하는 요구사항, 즉 기능 요구사항(무엇을 할 수 있어야 하는가)과 비기능 요구사항(얼마나 빨라야 하는가, 얼마나 안정적이어야 하는가, 데이터가 얼마나 안전해야 하는가)은 그 자체로 사용자의 문제에 속하므로 essential이다. 반면 그 요구사항을 만족시키기 위해 개발팀이 고르는 기술적 메커니즘, 이를테면 어떤 자료구조를 쓸지, 노드끼리 어떤 프로토콜로 조율할지, 어떤 캐싱 전략을 쓸지는 accidental이다. 이 둘을 가르는 실용적인 시험은 대안의 존재 여부다. 같은 요구사항을 만족시키는 구현 방법이 하나가 아니라 여럿 있을 수 있다면, 그 구현 방법을 선택하면서 생기는 복잡성은 문제 자체가 아니라 풀이가 만든 것이므로 accidental이다.

이 정의에서 눈여겨볼 대목은 “essential”이라는 말을 일상어보다 훨씬 엄격하게 쓴다는 점이다. 어떤 구현체에 필수적이라거나 소프트웨어 일반에 필수적이라는 뜻이 아니라, 오로지 사용자의 문제에 필수적인지만 따진다. 이 기준으로 보면 비트, 바이트, 트랜지스터, 전기, 심지어 컴퓨터 자체도 essential이 아니다. 사용자의 문제와는 아무 관계가 없기 때문이다. 기준을 이렇게까지 엄격하게 잡은 이유는 판단 기준을 하나로 통일하기 위해서다. 사용자가 그 존재조차 모르는 것이라면 정의상 essential일 수 없다. 스레드풀이나 반복문의 카운터 변수를 사용자에게 물어보면 대부분 무슨 말인지도 모른다. 이런 것들은 정의상 accidental이다.

이 기준을 가상의 실험으로 확인해보자. 이상적인 세계에서는 성능을 걱정할 필요가 없고, 언어와 인프라가 필요한 모든 것을 지원한다고 하자. 이 세계에서 개발팀이 할 일은 사용자의 비공식 요구사항을 형식 요구사항으로 바꾸고, 그 형식 요구사항을 범용 인프라 위에서 그대로 실행하는 것뿐이다. 이 경로에 필요 없는 상태, 필요 없는 순서 제어가 있다면 그것은 정의상 accidental이다. 정기결제 지갑에서 사용자에게 지금까지 적립된 총 포인트를 보여주는 기능이 있다고 하자. 충전 내역을 순회해서 매번 합산하지 않고 별도의 totalEarnedPoints 필드에 저장해두고 충전할 때마다 갱신하는 방식을 택했다면, 이 필드는 파생 데이터를 상태로 만든 것이므로 accidental complexity에 해당한다. 충전 내역만 있으면 총 적립 포인트는 언제든 다시 계산할 수 있어서, 원칙적으로는 상태로 들고 있을 필요가 없기 때문이다.

이 구분이 실무에서 갖는 힘은 “복잡한 게 원래 그런 것”이라는 체념을 반박할 근거를 준다는 데 있다. 복잡성 전부가 문제 자체에서 나온 것이라면 손댈 방법이 없지만, 상당수가 도구와 구현 방식의 산물이라면 그 부분은 없앨 수 있는 대상이 된다. essential complexity에 대해서는 사용자와 함께 다루는 수밖에 없지만, accidental complexity는 개발팀의 선택으로 줄일 수 있는 영역이다.

이 글을 처음 쓴 2021년 이후 accidental complexity를 줄이는 도구도 늘었다. LLM 기반 코딩 도구는 라이브러리 마이그레이션이나 보일러플레이트 작성처럼 사용자의 문제와 무관한 반복 작업을 대신 처리하고, TLA+ 같은 정형 검증 도구는 상태 전이 규칙을 사람이 눈으로 좇는 대신 기계로 증명하게 한다. 다만 두 도구 모두 무엇이 essential인지는 판단해주지 않는다. 이 절에서 세운 기준은 도구가 바뀌어도 그대로 적용된다.

Unknown Unknowns, 모른다는 사실조차 모르는 상태

John Ousterhout는 ”A Philosophy of Software Design“에서 복잡성을 시스템의 구조와 관련해 이해하거나 수정하기 어렵게 만드는 모든 것이라고 정의한다. 코드의 크기나 기능의 개수가 아니라 개발자가 겪는 어려움 자체로 복잡성을 규정한다는 뜻이다. 그래서 좀처럼 손대지 않는 부분이 아무리 복잡해도 체감되는 복잡성에는 큰 영향을 주지 않고, 자주 건드리는 부분은 조금만 복잡해도 어려움이 크게 느껴진다.

Ousterhout는 이 어려움이 드러나는 방식을 변경 전파, 인지 부하, Unknown Unknowns 세 가지 증상으로 나누고, 이 증상을 낳는 원인을 의존성(dependencies)과 모호성(obscurity) 두 가지로 좁힌다. 의존성은 소프트웨어에서 완전히 없앨 수는 없지만 그 수를 줄이고 남은 것을 단순하게 만들 수는 있다. 모호성은 중요한 정보가 코드 어디에도 명시되지 않은 상태로, Ousterhout는 방대한 문서화가 필요하다는 사실 자체가 설계가 잘못됐다는 신호인 경우가 많다고 지적한다. 두 원인과 세 증상은 다음처럼 이어진다.

의존성(Dependencies)

변경 전파

인지 부하

모호성(Obscurity)

Unknown Unknowns

변경 전파는 작은 변경 하나가 여러 곳의 수정을 요구하는 것이고, 인지 부하는 작업을 완료하는 데 알아야 할 것이 너무 많은 것이다. 이 두 증상은 코드 어디에서나 나타날 수 있다. 책임이 뒤섞인 클래스도 변경 전파를 일으키고, 관계가 얽힌 수많은 클래스도 인지 부하를 일으킨다. Unknown Unknowns는 사정이 다르다. 무엇을 해야 하는지, 제안한 해법이 실제로 동작할지조차 알 수 없는 상태를 가리키며, 원인과 결과가 사건이 벌어지기 전까지는 드러나지 않는다.

세 증상 중에서도 Unknown Unknowns가 가장 나쁘다. 이유는 단순하다. 변경 전파나 인지 부하는 코드를 들여다보면 관찰할 수 있는 문제지만, Unknown Unknowns는 사건이 실제로 터지기 전까지는 그 존재조차 알 방법이 없다. 두 원인 중에서는 모호성이 이 증상과 직접 맞닿아 있다. 정보가 숨어 있을수록 Unknown Unknowns는 늘어난다.

이 개념이 왜 실무에서 중요한지는 Phillip Armour가 2000년 논문 “The Five Orders of Ignorance”에서 제시한 무지(ignorance)의 다섯 단계를 보면 분명해진다. Armour는 소프트웨어를 완성된 제품이 아니라 지식을 쌓아가는 매체로 본다. 이 관점에서 무지는 다음처럼 층을 이룬다. 0단계는 알고 있고 그것을 동작하는 시스템으로 보여줄 수 있는 상태(답을 가진 상태)다. 1단계는 모르지만 무엇을 모르는지는 특정할 수 있는 상태(질문을 가진 상태)다. 2단계는 모른다는 사실 자체를 모르는 상태(질문조차 세우지 못하는 상태)다. 3단계는 2단계를 어떻게 찾아내야 하는지 그 방법조차 모르는 상태다. 4단계는 이 다섯 단계 모델 자체의 존재를 모르는 상태다.

Armour는 진짜 문제가 2단계와 3단계에 있다고 본다. 2단계는 견적이 크게 빗나가는 이유, 프로젝트가 “90% 완료”에서 멈춘 채 좀처럼 끝나지 않는 증상, Brooks가 말한 2차 시스템 효과를 설명한다. 정기결제 지갑을 예로 들면, 계좌 등록이 실패할 수 있는 경우의 수를 설계 시점에 다 헤아렸다고 믿었지만 운영에 들어간 뒤 은행 점검 시간, 한도 초과, 명의 불일치 같은 경우가 새로 튀어나오는 상황이 2단계 무지다. 설계 시점에는 그런 경우가 있는지조차 몰랐으므로 질문으로 세울 수도 없었다.

3단계에 대한 Armour의 통찰이 이 개념 전체에서 가장 힘이 센 대목이다. 설계 리뷰든 테스트 주도 개발이든, 뒤에서 다룰 EventStorming이든, 소프트웨어 개발 방법론은 모두 3단계를 다루는 절차다. 그런데 이 절차가 하는 일은 답을 내놓는 것이 아니라 2단계에 머물러 있던 무지를 1단계의 질문으로 끌어올리는 것뿐이다. 방법론을 도입하면서 “이걸 따르면 놓치는 게 없어지나요”라고 묻는다면 그 자체가 오해다. 방법론이 실제로 돌려주는 것은 답이 아니라 질문이며, 그 질문에 답하는 일은 방법론이 아니라 여전히 사람의 몫으로 남는다.

이 통찰은 Ousterhout가 짚은 obscurity와 정확히 맞물린다. obscurity를 줄인다는 것은 결국 코드에 숨어 있던 2단계의 무지를 1단계의 질문으로 끌어올리는 작업이다. 뒤에서 다룰 Deep Module과 정보 은닉이 이 obscurity를 줄이는 구체적인 기법이고, Stratified Design 역시 층 사이의 의존을 정리해 dependencies를 줄이는 접근이라는 점에서 이 절의 문제의식과 직접 이어진다. Ousterhout는 이 두 기법이 지향하는 목표를 명확한 시스템(obvious system)이라는 한마디로 정리한다. 깊이 생각하지 않고 낸 빠른 추측이 대체로 맞아떨어지는 시스템이라는 뜻이다.

Simple Made Easy, Rich Hickey의 통찰

Rich Hickey는 2011년 강연 “Simple Made Easy”에서 두 단어를 정확히 구분하는 데서 논의를 시작한다. Simple의 어원은 라틴어 “sim”(하나)과 “plex”(접힘, 꼬임)이 합쳐진 말로, 하나로 꼬여 있지 않은 상태를 가리킨다. 무언가가 얽혀 있는지 아닌지는 관찰할 수 있는 사실이므로 Simple은 객관적 속성이다. 반면 Easy의 어원은 “가까이 있다”는 뜻의 라틴어에서 왔다. 물리적으로 가까이 있어서 설치하자마자 쓸 수 있다는 뜻일 수도 있고, 이미 익숙한 것과 비슷하다는 뜻일 수도 있고, 자신의 실력 범위 안에 있다는 뜻일 수도 있다. 세 의미 모두 누구에게 가까운가에 좌우된다. 그래서 Easy는 상대적 속성이다. 나에게 쉬운 것이 다른 사람에게는 어려울 수 있다.

Hickey는 이 구분 위에 complect(뒤얽다)라는 개념을 세운다. 복잡성의 근원은 원래 분리되어야 할 것들을 뒤얽는 데 있다는 것이다. 하나를 이해하려면 거기 얽혀 있는 모든 것을 함께 머릿속에 담아야 하므로, 이 부담은 조합론적으로 늘어난다. 상태(state)가 결코 단순할 수 없는 이유도 여기 있다. 상태는 값(value)과 시간(time)을 뒤얽는다. 같은 메서드를 같은 인자로 호출해도 호출 시점에 따라 결과가 달라진다면, 그 복잡성은 캡슐화의 경계를 뚫고 바깥으로 새어 나온다. 앞서 본 Wallet의 charge가 겪은 문제, 즉 같은 amount를 넣어도 호출 시점의 retryCountlocked 값에 따라 결과가 달라지던 현상이 이 뒤얽힘의 구체적인 사례다.

Hickey는 이런 식으로 뒤얽히기 쉬운 선택들과, 그 대신 택할 수 있는 단순한 선택들을 짝지어 제시한다.

뒤얽히기 쉬운 선택대안
객체(state, identity, value가 함께 묶임)값(value)
메서드(함수와 상태가 결합)함수
상속(타입들을 뒤얽음)조합 가능한 다형성
switch·패턴 매칭(여러 결정이 한 곳에 닫힘)조합 가능한 다형성
반복문(무엇을 할지와 어떻게 할지가 뒤얽힘)집합 연산
ORM(데이터와 표현이 뒤얽힘)선언적 데이터 조작
조건문 분기(규칙이 코드 곳곳에 흩어짐)규칙의 명시적 분리

이 표에서 반복되는 원리는 하나다. 데이터는 있는 그대로 데이터로 남겨두라는 것이다. Hickey는 정보는 단순하며 정보로 할 수 있는 유일한 일은 그것을 망치는 것뿐이라고 말한다. 객체로 감싸는 순간 정보는 그 객체의 정체성과 상태에 뒤얽힌다.

여기서 짚어야 할 함정은 Easy를 Simple로 착각하는 일이다. 익숙한 도구, 손에 익은 프레임워크를 고르면 당장은 빠르게 시작할 수 있다. 문제는 그 속도가 오래가지 않는다는 데 있다. 초반에는 눈에 띄게 빠르지만, 얽혀 있던 것들이 서로 발목을 잡기 시작하면서 스프린트마다 생산성이 급격히 떨어진다. 반대로 Simple을 먼저 택하면 초반에는 사려 깊게 설계할 시간이 필요해서 느려 보이지만, 장기적으로는 속도가 꺾이지 않고 유지된다. Hickey는 이 선택을 프로그래머 개인의 취향이 아니라 책임의 문제로 본다. 단순함은 선택이며, 단순한 시스템을 갖지 못했다면 그것은 개발자의 잘못이라는 것이 그의 결론이다. 이 선택을 실행하려면 무엇을 뒤얽힌 채로 둘지 감지하는 안목이 필요하다. 이어서 이 안목을 실제 처방으로 옮긴 이론들을 살펴본다.

복잡성을 줄이는 방법

지금까지 다룬 이론들은 복잡성이 어디서 오고 왜 위험한지를 진단했다. 이 진단에 각 이론이 실제로 내놓는 처방은 서로 다르다. State를 겨냥하는 처방이 있고, 모듈 설계를 겨냥하는 처방이 있고, 아키텍처를 겨냥하는 처방이 있다. 분산 시스템과 현장의 실무로 시야를 넓히면 처방은 두 개 더 늘어난다.

Immutable, Declarative, Functional

“Out of the Tar Pit” 저자들은 State가 복잡성의 근원이라는 진단에서 그치지 않고 구체적인 처방을 내놓는다. Immutable(불변), Declarative(선언형), Functional(함수형) 세 가지다. 세 처방은 서로 다른 각도에서 상태 하나를 겨냥한다. 바꾸는 대신 새로 만들고, 어떻게 바꿀지가 아니라 무엇을 원하는지만 적고, 부수효과 없는 함수로 로직을 짠다.

불변성이 겨냥하는 문제는 앞서 본 오염이다. Wallet의 balance, retryCount, lockedcharge를 호출할 때마다 제자리에서 바뀌었고, 그래서 이 객체를 이해하려면 호출 시점의 상태까지 함께 알아야 했다. 값을 바꾸는 대신 새 값을 만들어 돌려주면 이 문제는 사라진다.

After

record Wallet(long balance, int retryCount, boolean locked) {
    Wallet charge(long amount) {
        if (locked) {
            int nextRetry = retryCount + 1;
            if (nextRetry > 3) {
                throw new IllegalStateException("잠금 해제 필요");
            }
            return new Wallet(balance, nextRetry, locked);
        }
        return new Wallet(balance + amount, 0, false);
    }
}

charge는 이제 balance를 바꾸지 않고 새 Wallet을 만들어 돌려준다. 호출 전의 인스턴스는 그대로 남아 있으므로, 어떤 코드가 이 인스턴스를 들고 있든 그 값은 절대 달라지지 않는다. 결과를 알려면 호출 시점의 인자만 알면 되고, 그전에 무슨 일이 있었는지는 알 필요가 없다. 테스트도 같은 이유로 단순해진다. 입력과 출력의 쌍만 확인하면 되고, 이전 테스트가 남긴 상태가 다음 테스트에 영향을 줄까 걱정할 필요가 없다.

선언형은 상태가 아니라 제어를 겨냥한다. 무엇을 원하는지만 적고 어떤 순서로 계산할지는 실행 엔진에 맡긴다. SQL의 SELECT 문이 그렇다. 어떤 인덱스를 타고 어떤 순서로 행을 훑을지는 옵티마이저가 정하고, 호출자는 원하는 결과 집합의 조건만 적는다. 앞서 본 반복문과 집합 연산의 대비가 정확히 이 처방이다. 반복문은 무엇을 할지와 어떻게 순회할지를 한 코드에 묶지만, 집합 연산은 순회 방식을 라이브러리 뒤로 감추고 조건만 남긴다.

함수형은 이 둘을 코드 조직 원리로 묶는다. 함수가 상태 없이 입력만으로 출력이 정해지면(순수 함수), 오염은 원천적으로 성립하지 않는다. 뒤에 나올 실천 절에서 다시 확인하겠지만, 계산 로직은 순수 함수로 남기고 상태를 다루는 코드는 얇은 셸 하나로 좁히는 구조(Functional Core, Imperative Shell)가 이 세 처방을 코드에 옮긴 형태다.

Deep Module, 인터페이스는 얕게 구현은 깊게

John Ousterhout는 “A Philosophy of Software Design”에서 좋은 설계란 곧 복잡성을 줄이는 설계라고 잘라 말한다. 그리고 복잡성을 다루는 방법은 두 가지뿐이라고 덧붙인다. 없애거나, 감추거나. 없앨 수 없는 복잡성이라면 남은 선택은 그 복잡성을 아는 사람의 수를 줄이는 것뿐이고, 이 선택을 코드 구조로 만든 것이 모듈이다.

Ousterhout는 모듈의 값어치를 인터페이스와 구현의 비율로 잰다. 인터페이스는 그 모듈을 쓰기 위해 알아야 하는 것이고, 구현은 인터페이스 뒤에 감춰진 것이다. 이 비율이 클수록, 즉 알아야 할 것은 적은데 감춰진 기능은 클수록 그 모듈은 깊다(deep module). Unix의 파일 I/O가 대표적이다. open, read, write, close 네 개의 호출만 알면 파일을 다룰 수 있지만, 그 뒤에는 디스크 블록 배치, 캐싱, 파일 시스템 저널링 같은 방대한 구현이 숨어 있다. 반대로 인터페이스가 구현만큼 복잡한 모듈은 얕다(shallow module). getter와 setter만 나열한 클래스가 전형적인 예다. 그 클래스를 쓰려면 안의 필드 구조를 사실상 다 알아야 하므로, 클래스로 감싼 값어치가 거의 없다.

깊은 모듈을 만드는 구체적인 기준은 복잡성을 누가 떠안을지 판단하는 데 있다. Ousterhout는 이를 복잡성을 아래로 끌어내리기라 부른다. 어떤 값을 호출자에게 넘길지, 구현이 알아서 정하게 할지 선택해야 할 때마다 후자를 택하라는 것이다. 호출자가 결정할 이유가 없는 구현 세부(타임아웃 값, 재시도 횟수 같은)를 인자로 받게 만드는 순간 인터페이스는 얕아진다.

여기서 흔히 저지르는 반대 방향의 실수가 classitis다. Ousterhout는 얕은 클래스를 잘게 쪼개기만 하는 습관을 이렇게 부르며 경계한다. 클래스 수를 늘린다고 저절로 깊어지지 않는다. 감추는 지식이 서로 다를 때만 쪼갤 값어치가 있다.

깊은 모듈을 지향하는 것과 classitis를 피하는 것은 같은 원칙의 양면이 아니라 서로 반대 방향으로 당기는 trade-off다. 지식을 감추겠다고 계속 쪼개다 보면 조각 하나하나의 인터페이스는 작아지지만, 원래 하나였던 일을 끝내려는 호출자는 그 작은 인터페이스 여러 개를 모두 배우고 순서에 맞게 엮어야 한다. 조각 각각은 깊어 보여도, 그 조각들을 조합해야 하는 호출자의 관점에서는 알아야 할 것의 총량이 줄지 않고 여러 클래스에 걸쳐 흩어질 뿐이다. 개별 모듈의 깊이와 그 모듈들을 엮어 쓰는 호출자가 겪는 깊이는 다른 질문이라는 뜻이다. 그래서 쪼갤지 말지를 가르는 기준은 하나로 좁혀진다. 나누려는 두 부분이 서로 다른 지식을 감추면서도 각자 독립적으로 이해되고 호출될 수 있는가. 두 조각을 늘 같이 알아야만 하나의 일이 끝난다면, 그 분할은 감추기가 아니라 흩어놓기이므로 나누지 않는 편이 낫다. 뒤에 나올 지갑 예제에서 이 trade-off를 코드로 확인한다.

Evolutionary Architecture, 완성이 아니라 진화

Neal Ford, Rebecca Parsons, Pat Kua는 ”Building Evolutionary Architectures“에서 진화형 아키텍처를 여러 차원에 걸쳐 가이드된 점진적 변화를 첫 번째 원칙으로 지원하는 아키텍처라고 정의한다. 이 정의가 뒤집는 통념은 하나다. 아키텍처를 처음에 완벽하게 설계해서 고정하는 대상으로 보는 시각이다. 저자들은 대신 아키텍처가 요구사항의 변화, 트래픽의 변화, 조직의 변화에 맞춰 계속 바뀌는 것이 정상이라고 본다. 이 변화를 무계획하게 방치하지 않도록, 아키텍처가 지켜야 할 특성(성능, 보안, 확장성 등)마다 Fitness Function이라는 자동화된 검증을 걸어둔다. 코드가 바뀔 때마다 단위 테스트가 기능을 검증하듯, 아키텍처가 바뀔 때마다 Fitness Function이 그 특성이 여전히 지켜지는지 검증한다. 예를 들어 모듈 간 순환 의존성 개수를 CI에서 자동으로 측정해 임계값을 넘으면 빌드를 실패시키거나, API 응답시간이 목표치를 넘는지 배포 파이프라인에서 검사하는 것이 Fitness Function의 실제 형태다.

이 접근이 실제로 지키려는 값은 유연성이다. 그런데 유연성은 말처럼 단순한 개념이 아니다. 유연성(flexibility)과 범용성(generality)을 구분해야 한다. 유연성은 기존 동작을 깨지 않고 합리적인 비용으로 변경을 흡수할 수 있는 정도이고, 범용성은 하나의 구현이 얼마나 넓은 입력과 맥락을 다룰 수 있는지를 가리킨다. 얼핏 같은 말처럼 들리지만 두 축은 독립적으로 움직인다.

예를 들어보자. ID 하나를 받아 본인확인 여부만 돌려주는 함수는 다룰 수 있는 입력이 좁다(범용성이 낮다). 그런데 본인확인 제공사를 다른 곳으로 바꿔도 이 함수의 시그니처와 호출자는 그대로다. 유연성은 높다. 반대로 이 함수를 임의의 파라미터 맵을 받도록 넓히면 범용성은 올라가지만, 호출자는 이제 이 제공사가 어떤 키를 요구하는지 알아야 한다. 제공사를 바꾸면 그 키 구조도 함께 바뀌므로 호출자 코드가 깨진다. 범용성을 높이려던 시도가 유연성을 깎아 먹은 셈이다. 뒤에 나올 지갑 예제의 IdentityCheck 인터페이스가 앞쪽, 좁지만 유연한 형태를 택한 경우다.

이 구분이 실무에서 중요한 이유는 Fowler가 Speculative Generality라 부르는 함정과 맞닿아 있기 때문이다. 언젠가 이런 게 필요할지도 모른다는 생각으로 훅과 확장 지점을 미리 만들어두는 습관은 겉보기에 유연해 보이지만, 넓힌 입력 범위에 대해 호출자에게 아무 약속도 해주지 못한다면 유연성이 아니라 다루기 힘든 범용성만 남는다. Evolutionary Architecture가 Fitness Function으로 지키려는 것이 바로 이 경계다. 아키텍처를 넓히는 매 순간마다 그 확장이 실제로 기존 호출자를 안전하게 유지하는지, 아니면 다루는 범위만 넓힌 것인지를 계속 측정해야 진화가 유연성으로 이어진다.

분산 시스템의 복잡성은 누구의 몫인가

Laura Nolan은 “Essential Complexity in Systems Architecture” 발표에서 Brooks와 “Out of the Tar Pit”의 구분을 분산 시스템에 그대로 적용한다. Essential complexity는 문제 자체에 내재한 것이고 accidental complexity는 구현 세부에서 오는 것이라는 정의는 바뀌지 않는다. 다만 분산 시스템에서는 이 구분이 직관과 어긋나는 지점이 있다. 시스템을 여러 서버에 걸쳐 복제하고 조율하는 작업이 너무 당연하고 불가피하게 느껴져서, 마치 그 자체가 essential complexity인 것처럼 보인다.

Nolan이 든 Google Photon 사례를 보면 이 착시가 분명해진다. Photon이 풀어야 했던 업무 요구사항은 공유 식별자를 기준으로 두 개의 로그 스트림을 결합한다는 한 문장으로 요약된다. 그런데 이 결합을 검색 결과 규모로, 몇 분 안에, 데이터센터 하나가 통째로 죽어도 끊기지 않게 처리해야 한다는 조건이 붙자 dispatcher, EventStore, joiner, 그리고 강한 일관성을 보장하는 IdRegistry까지 갖춘 시스템이 필요해졌다. 이 구성 요소들은 광고주도 사용자도 존재조차 모른다. 앞서 세운 정의를 그대로 적용하면 이들은 사용자의 문제에 내재한 것이 아니므로 accidental complexity다.

여기서 헷갈리기 쉬운 지점을 짚어야 한다. 규모, 실시간성, 데이터센터 장애 내성 자체는 비즈니스가 실제로 요구하는 조건이므로 essential complexity에 속한다. 반면 그 조건을 만족시키기 위해 dispatcher와 joiner를 조합할지, 아니면 다른 구조를 쓸지는 구현자가 고르는 여러 대안 중 하나일 뿐이다. Nolan이 비교하는 Bigtable류의 command & control 방식(중앙 컨트롤러가 모든 노드의 작업을 조율하는 구조)과 Dynamo류의 peer-to-peer 방식(노드끼리 자율적으로 상태를 조율하는 구조)이 이 대안들이다. 전자는 중앙 컨트롤러가 상태를 조율해서 이해하기는 쉽지만 컨트롤러가 병목이 되고, 후자는 노드끼리 자율적으로 조율해서 확장에는 유리하지만 장애 진단이 어려워진다. 어느 쪽을 택하든 같은 essential 요구(규모, 실시간성, 내성)를 만족시키는 구현 방식의 선택이므로, 그 구현이 만드는 복잡성은 accidental complexity로 남는다.

그렇다면 분산 시스템 자체가 강제하는, 구현으로도 없앨 수 없는 essential complexity는 무엇인가. 네트워크는 지연되거나 끊기고, 메시지는 순서가 바뀌거나 중복으로 도착할 수 있다는 사실이다. 이 성질은 프로토콜을 아무리 정교하게 짜도 사라지지 않는다. 사용자가 여러 데이터센터에 걸쳐 서비스를 끊김 없이 쓰길 원하는 이상, 그 요구에서 네트워크 분단 가능성과 지연은 논리적으로 따라 나오기 때문이다. dispatcher나 joiner 같은 구성 요소는 이 분단과 지연을 견디기 위해 구현자가 고른 수단이지만, 분단과 지연이라는 조건 자체는 수단이 아니라 문제의 일부다.

이 구분을 앞서 본 정기결제 지갑 예제로 옮겨보면 더 선명해진다. 지갑 서비스가 성장해서 여러 리전에 걸쳐 서비스되고, 충전과 잔액 조회가 초당 수만 건씩 발생한다고 하자. 이 시점에서 비즈니스가 실제로 요구하는 조건은 두 가지로 좁혀진다. 사용자가 어느 리전에서 충전하든 그 내역이 유실되어서는 안 되고, 잔액 조회는 충전 직후에도 실시간에 가까운 정확한 값을 돌려줘야 한다는 것이다. 이 두 요구(무유실, 실시간에 가까운 일관성)는 사용자의 문제에 직접 속하므로 essential complexity다. 그런데 이 요구를 만족시키는 방법은 하나가 아니다. 한 가지 방법은 잔액을 직접 갱신하는 대신 충전·사용·송금 같은 사건을 순서대로 남기는 이벤트 소싱을 쓰고, 그 사건들을 리전마다 비동기로 복제한 뒤 조회 전용 모델을 따로 두는 CQRS로 잔액을 빠르게 읽어내는 것이다. 이 구조를 택하는 순간 이벤트 스토어, 리전 간 복제 파이프라인, 조회 모델을 갱신하는 프로젝션 로직이 새로 생긴다. 사용자는 이 중 어느 것의 존재도 모른다. 무유실과 실시간에 가까운 일관성이라는 essential complexity를 만족시키기 위해 개발팀이 고른 하나의 구현 방식일 뿐이며, 분산 트랜잭션으로 잔액을 직접 갱신하는 다른 방식으로도 같은 요구를 만족시킬 수 있다. 그러므로 이벤트 소싱과 CQRS가 끌고 들어오는 복잡성은 accidental complexity로 남는다.

이 구분을 실무에 옮기면 방향은 하나다. 비즈니스가 요구하는 조건(essential complexity)과 그 조건을 만족시키는 구체적인 아키텍처(accidental complexity)를 분리하고, 후자가 도메인 로직으로 새어 들어가지 않도록 막는 것이다. 앞서 본 Deep Module과 이어서 볼 Stratified Design이 정확히 이 역할을 한다. 인프라의 복잡성은 인터페이스 뒤로, 층 아래로 감추고, 그 위에서 일하는 사람은 비즈니스가 실제로 요구하는 조건만 상대하게 만든다.

의존성이 쌓아 올리는 우발적 복잡성

TechTarget의 ”How to prevent accidental complexity in software development“은 이론이 아니라 현장에서 우발적 복잡성이 실제로 어디서 새어 들어오는지를 짚는다. 그중 의존성 관리가 특히 눈에 띈다. 하나의 라이브러리는 보통 여러 의존성을 갖고, 그 의존성들도 저마다 또 다른 의존성을 갖는다. 이 연쇄 구조 자체는 문제가 아니다. 문제는 업데이트를 미루는 습관이다. 업데이트를 몇 번 건너뛸 때마다 지금 쓰는 버전과 최신 버전 사이의 격차는 벌어지고, 그 격차가 벌어질수록 다음 업데이트에서 마주칠 호환성 문제도 커진다.

이렇게 쌓인 격차는 결국 작은 업데이트 여러 번이 아니라 한 번의 큰 마이그레이션으로 돌아온다. 그리고 이 마이그레이션은 정확히 우발적 복잡성의 정의에 들어맞는다. 사용자는 라이브러리가 몇 버전인지 관심이 없다. 그런데도 개발팀은 몇 주씩 이 작업에 매달리고, 그동안 실제 비즈니스 요구사항에 쓸 시간은 줄어든다. 정기결제 지갑의 IdentityCheck, AccountCheck 구현체가 감싸는 본인확인·계좌 조회 API의 SDK도 같은 처지에 놓인다. 업데이트를 미룰수록 나중에 몰아서 갱신해야 할 몫이 쌓이는 것은, 업무 규칙을 담은 decideRegistration과는 무관하게 하위 층에서만 벌어지는 일이다.

TechTarget이 제안하는 처방은 간단하다. 업데이트를 미루지 않도록 정기적인 일정을 정해서 강제하고, 지원이 끊긴 구버전에 머무르지 않는 것이다. 업데이트를 전체 시스템에 한 번에 반영하는 대신 카나리 배포로 일부 트래픽에만 먼저 적용해서 위험을 줄이는 방법도 함께 제시한다. 격차가 작을 때 자주 갱신하는 편이, 격차가 쌓인 뒤 한 번에 갱신하는 것보다 감당해야 할 우발적 복잡성이 작다는 것이 이 처방의 핵심이다.

Stratified Design, 복잡성을 다루는 설계 전략

다섯 가지 처방이 방향을 가리켰다면, 그 방향을 코드의 층 구조로 만드는 구체적인 방법이 Stratified Design이다. Stratified Design은 Hal Abelson과 Gerald Sussman이 1987년 MIT AI 메모에서 처음 제시한 개념이다. 원래는 Lisp을 예로 든 논문이었지만, Eric Normand는 저서 “Grokking Simplicity”에서 이 개념을 함수형 프로그래밍 전반에 맞게 다시 풀어썼다. 핵심 주장은 세 가지로 요약된다.

첫째, 각 층은 그 층에 특화된 언어다. 층마다 고유한 원시 개념과 조합 수단, 추상화 수단을 갖추고, 위층은 오로지 이 어휘만으로 구성되며 아래층의 구현 세부에는 손대지 않는다. 둘째, 폐쇄성(closure)이다. 부품을 조합한 결과는 다시 같은 층의 부품으로 취급할 수 있어서, 같은 층 안에서 조합을 얼마든지 반복할 수 있다. 셋째, 층별 교체 가능성이다. 아래층의 구현을 다른 것으로 바꿔도 위층은 영향을 받지 않는다. Stratified Design이 주는 변경 용이성은 바로 이 교체 가능성에서 나온다.

이 설계가 실제로 무엇을 다르게 만드는지, 정기결제 지갑 서비스로 확인해보자. 요구사항은 이렇다. 사용자는 지갑에 코인을 충전해서 쓰거나 송금할 수 있다. 은행 계좌를 등록해두면 거기서 지갑으로 충전할 수 있는데, 이는 필수가 아니다. 계좌를 등록하려면 외부 API로 본인확인을 거쳐야 하고, 계좌번호가 실재하는지도 외부 API로 확인해야 한다. 이메일이 블랙리스트에 있으면 등록할 수 없고, 이메일은 전체 사용자 사이에서 유일해야 한다. 처음 등록하는 사용자에게는 500코인을 지급한다.

이 요구사항을 서비스, 도메인, 인프라라는 흔한 책임 기반 계층으로 나눈다고 하자. 그런데 계좌 등록, 본인확인, 블랙리스트 조회, 계좌 실재 확인까지 거의 모든 로직이 외부 API 호출이나 데이터 조회에 의존한다. 그 결과 서비스 계층은 외부 I/O를 순서대로 호출하는 오케스트레이션으로 채워지고, 정작 도메인 계층에는 남는 로직이 거의 없다.

Before

class WalletApplicationService {
    void registerAccount(UserId userId, BankAccount account, Email email) {
        if (blacklist.contains(email)) throw new RejectedException();     // 인프라 조회
        if (!identityApi.verify(userId)) throw new RejectedException();   // 외부 API
        if (!accountApi.exists(account)) throw new RejectedException();   // 외부 API
        userRepository.linkAccount(userId, account);                      // 저장소
        if (userRepository.isFirstRegistration(userId)) {
            walletRepository.credit(userId, 500);                         // 저장소
        }
    }
}

이 코드에서 도메인 로직이라 부를 만한 부분은 사실상 없다. 블랙리스트 검사, 본인확인, 계좌 확인, 최초 가입 판단이 전부 외부 호출과 뒤섞여 있어서, 어디까지가 업무 규칙이고 어디부터가 기술적 절차인지 코드만 봐서는 구분할 수 없다.

이 실패는 우연이 아니다. 서비스, 도메인, 인프라라는 계층 이름은 검증하고 저장하고 적립한다는 처리 순서를 그대로 옮겨놓은 것에 가깝다. Ousterhout는 이런 구성을 시간적 분해(temporal decomposition)라고 부르고, 코드를 무엇을 아는가가 아니라 언제 일어나는가로 나누면 지식이 여러 모듈에 걸쳐 새어나간다고 지적한다. 계좌가 실재하는지 판단하는 지식과 이메일이 블랙리스트에 있는지 판단하는 지식은 서로 무관한데도, 둘 다 “먼저 검증한다”는 순서 하나로 묶여 같은 서비스 계층에 나란히 놓인다.

Stratified Design은 다른 기준으로 층을 나눈다. 책임의 종류가 아니라 추상도로 나눈다. 추상도가 낮은 층일수록 구현 기술에 가깝고 변화가 적으며, 추상도가 높은 층일수록 업무 요건에 가깝고 자주 바뀐다. 층을 건너뛰어 직접 호출하는 경로는 두지 않는다.

After

// 하위 층: 외부 세계와 맞닿는 원시 연산 (구현에 가깝고, 자주 바뀌지 않는다)
interface IdentityCheck { boolean verify(UserId id); }
interface AccountCheck { boolean exists(BankAccount account); }
interface Blacklist { boolean contains(Email email); }

// 중간 층: 원시 연산을 업무 어휘로 조합한다 (등록 가능 여부 판정)
record RegistrationDecision(boolean allowed, String reason) {}

RegistrationDecision decideRegistration(
        UserId userId, BankAccount account, Email email,
        IdentityCheck identity, AccountCheck accountCheck, Blacklist blacklist) {
    if (blacklist.contains(email)) return new RegistrationDecision(false, "블랙리스트");
    if (!identity.verify(userId)) return new RegistrationDecision(false, "본인확인 실패");
    if (!accountCheck.exists(account)) return new RegistrationDecision(false, "계좌 미실재");
    return new RegistrationDecision(true, null);
}

// 상위 층: 업무 규칙만으로 구성된다 (최초 가입 보너스라는 정책)
long welcomeBonus(boolean isFirstRegistration) {
    return isFirstRegistration ? 500 : 0;
}

하위 층 · 원시 연산

중간 층 · 업무 어휘로 조합

상위 층 · 업무 규칙

층을 건너뛰는 금지된 호출

welcomeBonus

decideRegistration

IdentityCheck

AccountCheck

Blacklist

실선은 인접한 층끼리만 넘나드는 comfortable layers를, 점선은 welcomeBonusIdentityCheck를 건너뛰어 직접 부르는 금지된 경로를 나타낸다. 이 경로가 열리는 순간 decideRegistration이 감추고 있던 어휘와 교체 가능성이 동시에 깨진다.

중간 층인 decideRegistrationIdentityCheck, AccountCheck, Blacklist라는 하위 층의 어휘만으로 구성되어 있고, 그 구현이 외부 API인지 로컬 파일인지는 알지 못한다. 상위 층인 welcomeBonus는 한 걸음 더 나아가 불리언 값 하나만 받는다. 이 값이 저장소 조회로 나온 것인지 캐시로 나온 것인지조차 이 함수는 관심이 없다. 본인확인 API가 다른 제공사로 바뀌어도 IdentityCheck 구현체 하나만 갈아 끼우면 되고, decideRegistrationwelcomeBonus는 한 줄도 바뀌지 않는다. 앞서 책임 기반으로 나눈 코드에서는 API 제공사가 바뀌면 응용 서비스 코드 안의 호출 부분을 직접 찾아 고쳐야 했던 것과 대조적이다.

이 대조는 레이어드 아키텍처라는 이름이 실제로는 서로 다른 두 계보를 가리켜 왔다는 사정과 맞닿아 있다. POSA의 Layers 패턴은 추상도를 기준으로 층을 나누는 전통을 따른다. 반면 지금 실무에서 흔히 쓰는 프레젠테이션, 애플리케이션, 데이터 3계층은 물리적 배치 단위로 출발한 개념이 논리적 책임 구분으로 옮겨온 것이다. 이름은 같은 “층”을 쓰지만 나누는 기준이 다르다. 위에서 본 것처럼 요구사항 대부분이 외부 I/O에 의존하는 서비스에서는 책임 기반 구분이 도메인 계층을 텅 비게 만들기 쉬운데, 이는 잘못 설계해서가 아니라 애초에 다른 기준으로 나눈 층을 같은 이름으로 부른 결과에 가깝다.

추상도로 층을 나누면 상위 층은 하위 층의 구현을 몰라도 되는 경계, 즉 추상화 장벽(abstraction barrier)이 생긴다. 이 장벽은 인접한 층끼리만 넘나드는 규율(comfortable layers)로 지켜진다. 상위 층이 두 단계 아래 층을 건너뛰어 직접 호출하면, 그 사이 층이 제공하려던 어휘와 교체 가능성이 동시에 깨진다.

실천, 복잡성을 줄이는 구체적인 접근

지금까지 다룬 개념들은 각자 다른 각도에서 같은 결론을 가리킨다. 상태와 제어를 뒤얽지 말 것, 사용자의 문제에 속하지 않는 것은 걷어낼 것, 정보를 숨기지 말고 의존을 정리할 것. 이 결론들을 실제 코드 구조로 옮기는 방법을, 정기결제 지갑 예제로 이어서 확인한다.

방법을 적용하기 전에 먼저 할 일이 있다. 지금 코드에 정말 복잡성이 있는지, 있다면 어떤 종류인지 진단하는 것이다. 앞서 다룬 개념은 그대로 진단 질문으로 바꿀 수 있다.

질문해당하는 복잡성
이 객체가 가질 수 있는 상태 조합이 몇 개인가?State가 만드는 복잡성
이 실행 순서가 실제로 의미가 있는가, 언어가 강제한 우연의 순서인가?Control이 만드는 복잡성
이 상태·필드·카운터를 사용자에게 물어보면 알아들을까?Accidental Complexity
이 모듈을 이해하는 데 방대한 문서가 필요한가?Obscurity
이 모듈을 다른 모듈 없이는 이해하거나 수정할 수 없는가?Dependencies
인터페이스가 구현보다 복잡한가?Shallow Module
클래스를 쪼갠 이유가 서로 다른 지식을 감추기 위해서인가?Classitis

진단에서 걸리는 항목이 곧 적용할 방법을 가리킨다.

가장 먼저 할 일은 앞서 본 Immutable, Functional 처방을 상태와 로직의 분리로 옮기는 것이다. 흔히 Functional Core, Imperative Shell이라 불리는 구조다. 코인 적립 계산을 예로 들면, 적립액이 얼마인지 계산하는 규칙과 그 결과를 저장소에 반영하는 절차를 다른 함수로 나눈다.

// Functional Core: 상태가 없고, 입력만으로 결과가 정해진다
long calculateEarnedPoints(long chargedAmount, boolean isFirstCharge) {
    long base = chargedAmount / 100;               // 충전액 100원당 1코인
    long bonus = isFirstCharge ? 500 : 0;
    return base + bonus;
}

// Imperative Shell: 저장소를 읽고 쓰는 절차만 담당한다
void charge(UserId userId, long amount) {
    boolean isFirst = walletRepository.isFirstCharge(userId);
    long earned = calculateEarnedPoints(amount, isFirst);
    walletRepository.credit(userId, amount + earned);
}

calculateEarnedPoints는 같은 입력에 대해 언제 호출해도 같은 결과를 내놓는다. 상태가 없으므로 테스트도 입력과 출력의 쌍만 확인하면 끝난다. 상태를 다루는 부분은 charge 하나로 좁혀지고, 이 함수만 상태와 관련한 문제(오염, 조합 폭발)를 신경 쓰면 된다. 나머지 로직은 상태의 맥락에서 벗어난다.

두 번째는 essential과 accidental을 가르는 질문을 설계 결정마다 던지는 것이다. 이 상태를, 이 필드를, 이 카운터를 사용자에게 물어보면 무슨 말인지 알아들을까. 앞서 총 적립 포인트 필드의 예에서 봤듯, 사용자가 이해하지 못하는 상태는 정의상 accidental이다. 재시도 횟수, 캐시, 락 플래그처럼 시스템 내부 사정으로 생긴 상태는 최대한 도메인 로직 바깥, 즉 Imperative Shell 쪽으로 밀어낸다.

세 번째는 앞서 본 Deep Module 원칙을 그대로 적용하는 것이다. IdentityCheck, AccountCheck, Blacklist 인터페이스는 상위 층이 그 뒤에 무엇이 있는지 몰라도 되게 만들어서, 상위 층을 읽는 사람이 알아야 할 정보의 총량을 줄인다. IdentityCheck.verify(UserId id)가 재시도 횟수나 타임아웃 값을 인자로 받지 않는 것도 복잡성을 아래로 끌어내리기를 그대로 지킨 결과다. 이런 값은 호출자가 결정할 이유가 없는 구현 세부이므로 구현 쪽이 스스로 정한다. 이 셋을 하나로 합치지 않은 이유도 classitis의 경계와 같다. 셋으로 나눈 이유는 개수를 늘리기 위해서가 아니라 본인확인 제공사, 계좌 등록 기관, 블랙리스트 데이터 소스라는 서로 무관한 지식을 각각 감추기 때문이다. 도메인 주도 설계가 컨텍스트마다 모델을 따로 두라고 권하는 것도 같은 맥락이다. 하나의 거대한 모델에 모든 관심사를 욱여넣으면, 한 관심사를 이해하기 위해 다른 관심사의 세부까지 함께 알아야 하는 상황이 벌어진다.

이 세 가지는 결국 하나의 규율로 수렴한다. Stratified Design의 층 구조 안에서 각 층은 그 층에서 essential한 것만 다루고, 그보다 아래층의 accidental한 구현 세부는 인터페이스 뒤로 감춘다. 상태는 가장 바깥의 얇은 층으로 몰아넣는다. 이 규율을 지키는 데는 자동으로 검증해주는 도구가 없다. 계층을 건너뛰어 편의상 하위 API를 직접 호출하고 싶은 유혹은 항상 있고, 그 유혹에 한 번씩 질 때마다 층은 조용히 무너진다.

세 가지 방법을 정리하면 다음과 같다.

방법핵심 질문Wallet 예제에서
상태와 로직 분리상태 없이 계산할 수 있는 부분은 어디까지인가?calculateEarnedPoints / charge
Essential·Accidental 판정사용자가 이 상태를 이해하는가?재시도 횟수·캐시는 Imperative Shell로
Deep Module 적용인터페이스 뒤에 지식을 감췄는가? 너무 잘게 쪼개지 않았는가?IdentityCheck, AccountCheck, Blacklist

분류값이 만드는 복잡성

위 세 가지 방법은 State가 만드는 오염, Accidental Complexity, 그리고 Ousterhout가 짚은 obscurity·dependencies를 겨냥한다. 그런데 실무에서는 이 목록에 없는 원인이 하나 더 자주 부딪힌다. 분류(구분) 값이다. 회원 등급, 결제 수단, 배송 상태처럼 몇 가지 값 중 하나를 갖는 필드는 그 자체로는 무해해 보이지만, 그 값을 검사하는 조건문이 코드베이스 곳곳에 반복해서 나타나면서 복잡성을 만든다.

정기결제 지갑이 계좌이체, 카드, 보유 포인트 세 가지 수단으로 충전할 수 있다고 하자. 수단마다 수수료율과 충전 한도가 다르다.

Before

long calculateFee(ChargeMethod method, long amount) {
    if (method == ChargeMethod.CARD) {
        return (long) (amount * 0.025);
    } else if (method == ChargeMethod.BANK_TRANSFER) {
        return 500;
    } else {
        return 0;
    }
}

문제는 이 분기가 여기 하나로 끝나지 않는다는 데 있다. 충전 한도를 검사하는 메서드에도, 영수증 문구를 만드는 메서드에도 같은 세 갈래 분기가 따로 나타난다. 새로운 충전 수단이 하나 추가되면 이 분기들을 전부 찾아 고쳐야 하고, 하나라도 놓치면 그 수단만 조용히 잘못 동작한다. 앞서 본 변경 전파가 분류값이 만드는 형태로 나타난 것이다.

분기의 본문을 메서드로 독립시킨다

가장 먼저 할 일은 각 분기 안의 로직을 이름 붙은 메서드로 뽑아내는 것이다.

long calculateFee(ChargeMethod method, long amount) {
    if (method == ChargeMethod.CARD) {
        return calculateCardFee(amount);
    } else if (method == ChargeMethod.BANK_TRANSFER) {
        return calculateBankTransferFee(amount);
    } else {
        return calculatePointFee(amount);
    }
}

분기 자체는 그대로지만, 각 갈래가 무엇을 하는지 이름으로 드러나고 본문의 세부는 읽지 않아도 된다. 다음 단계들을 위한 준비 작업이다.

else를 없애면 분기가 단순해진다

else로 이어진 분기는 한 갈래를 읽으면서도 나머지 조건을 함께 의식해야 한다. 조기 반환으로 각 갈래를 독립시키면 그 부담이 사라진다.

long calculateFee(ChargeMethod method, long amount) {
    if (method == ChargeMethod.CARD) {
        return calculateCardFee(amount);
    }
    if (method == ChargeMethod.BANK_TRANSFER) {
        return calculateBankTransferFee(amount);
    }
    return calculatePointFee(amount);
}

각 if문은 자신의 조건과 반환값만 신경 쓰면 되고, 앞의 조건이 거짓이었다는 사실을 기억할 필요가 없다.

복문은 단문으로 나눈다

조건 하나에 여러 판단이 들어가면 그 자체가 작은 분기 폭발이 된다.

Before

if (method == ChargeMethod.CARD && amount > 1_000_000 && !user.isVerified()) {
    throw new RejectedException();
}

이 한 줄은 “카드 결제인가”, “고액인가”, “미인증 사용자인가” 세 가지 판단이 겹친 것이다. 이름 붙은 변수로 쪼개면 각 판단이 무엇을 의미하는지 조건문 밖에서도 읽힌다.

After

boolean isLargeCardCharge = method == ChargeMethod.CARD && amount > 1_000_000;
boolean isUnverifiedUser = !user.isVerified();
if (isLargeCardCharge && isUnverifiedUser) {
    throw new RejectedException();
}

분류별 로직을 클래스로 나눈다

메서드 추출은 한 메서드 안의 분기를 정리했을 뿐, 수수료 계산과 한도 검사와 영수증 문구에 흩어진 분기는 그대로 남아 있다. 이 분기들을 한데 모으려면 갈래마다 클래스를 만든다.

class CardCharge {
    long calculateFee(long amount) { return (long) (amount * 0.025); }
    long limit() { return 5_000_000; }
}

class BankTransferCharge {
    long calculateFee(long amount) { return 500; }
    long limit() { return 10_000_000; }
}

class PointCharge {
    long calculateFee(long amount) { return 0; }
    long limit() { return 1_000_000; }
}

수수료와 한도라는, 수단마다 따로 흩어져 있던 지식이 각 클래스 하나로 모인다.

나뉜 클래스를 같은 형으로 다룬다

클래스는 나눴지만 호출하는 쪽에서 여전히 어느 클래스를 쓸지 분기해야 한다면 나눈 보람이 없다. 공통 인터페이스를 두면 이 분기가 사라진다.

After

interface Charge {
    long calculateFee(long amount);
    long limit();
}

class CardCharge implements Charge { /* 위와 동일 */ }
class BankTransferCharge implements Charge { /* 위와 동일 */ }
class PointCharge implements Charge { /* 위와 동일 */ }
long calculateFee(Charge charge, long amount) {
    return charge.calculateFee(amount);
}

세 갈래이던 분기가 한 줄의 위임으로 줄어든다. 조건 분기를 다형성으로 대체하는 리팩터링이다. Hickey가 짝지은 표에서 본 “switch·패턴 매칭 → 조합 가능한 다형성”이 코드 위에서 실제로 벌어지는 모습이 이것이다.

분류별 인스턴스는 어디서 만드는가

인터페이스로 분기는 없앴지만 질문 하나가 남는다. ChargeMethod.CARD라는 값을 받아 CardCharge 인스턴스를 돌려주는 코드는 어딘가에 있어야 한다. 이 매핑을 호출하는 곳마다 새로 작성하면 분기가 자리만 옮겨 다시 흩어진다. 한 곳에 모아야 한다.

class ChargeFactory {
    private static final Map<ChargeMethod, Charge> CHARGES = Map.of(
        ChargeMethod.CARD, new CardCharge(),
        ChargeMethod.BANK_TRANSFER, new BankTransferCharge(),
        ChargeMethod.POINT, new PointCharge()
    );

    Charge resolve(ChargeMethod method) {
        return CHARGES.get(method);
    }
}

ChargeMethod와 구현체 사이의 대응은 이 팩토리 하나만 알면 되고, 나머지 코드는 Charge 인터페이스만 상대한다.

enum에 행동을 직접 실어도 된다

수단마다 의존하는 외부 자원이 없다면, 인터페이스와 팩토리 없이 enum 상수에 행동을 바로 실을 수 있다.

enum ChargeMethod {
    CARD {
        long calculateFee(long amount) { return (long) (amount * 0.025); }
    },
    BANK_TRANSFER {
        long calculateFee(long amount) { return 500; }
    },
    POINT {
        long calculateFee(long amount) { return 0; }
    };

    abstract long calculateFee(long amount);
}

method.calculateFee(amount) 한 줄로 끝나고, 인터페이스도 팩토리도 필요 없다. 다만 이 방식은 수단마다 카드 결제 대행사 클라이언트처럼 주입받아야 할 협력 객체가 있으면 쓰기 어렵다. enum 상수는 생성자 인자를 정적으로 고정할 수는 있어도, 요청마다 달라지는 의존성을 넘겨받는 구조에는 맞지 않는다. 그런 경우에는 앞의 인터페이스와 팩토리 조합으로 돌아간다.

분류값이 아니라 분류 객체를 다룬다

지금까지의 변화를 한 문장으로 요약하면, ChargeMethod라는 원시 값을 여기저기서 검사하던 코드가 Charge라는 객체를 호출하는 코드로 바뀌었다. 이 차이가 중요하다. 원시 값을 검사하는 방식에서는 새 분기가 필요할 때마다 그 값을 아는 모든 곳을 찾아야 하지만, 객체를 호출하는 방식에서는 새 수단 하나(Charge 구현체 하나)를 추가하고 팩토리에 등록하는 것으로 끝난다. 분류값을 사용하는 코드가 아니라 분류 자체를 표현하는 객체를 만드는 것, 이것이 이 절 전체를 관통하는 원칙이다.

상태 전이 규칙도 같은 문제를 겪는다

분류값이 종류가 아니라 시간에 따라 바뀌는 상태일 때도 같은 병이 난다. 어떤 상태에서 어떤 상태로 바뀔 수 있는지를 규칙으로 두지 않고 호출부마다 조건으로 확인하면, 그 규칙은 지키는 곳과 놓치는 곳으로 갈린다.

Before

enum ChargeStatus { PENDING, APPROVED, FAILED, CANCELLED }

if (charge.status() == ChargeStatus.PENDING) {
    charge.setStatus(ChargeStatus.APPROVED);
}

규칙을 한곳에 모으면 호출부는 그 규칙을 알 필요가 없어진다.

After

private static final Map<ChargeStatus, Set<ChargeStatus>> ALLOWED = Map.of(
    ChargeStatus.PENDING, Set.of(ChargeStatus.APPROVED, ChargeStatus.FAILED),
    ChargeStatus.APPROVED, Set.of(ChargeStatus.CANCELLED)
);

void transitionTo(ChargeStatus next) {
    if (!ALLOWED.getOrDefault(status, Set.of()).contains(next)) {
        throw new IllegalStateException(status + "에서 " + next + "로 전이할 수 없다");
    }
    status = next;
}

이 전이 테이블이 지키는 자리는 Immutable Data Model에서 다룬 롱텀 이벤트의 현재상태 컬럼과 같다. 그 글은 데이터 모델 수준에서 상태 하나만 UPDATE로 남기고 나머지는 사실을 INSERT하는 방법을 다뤘는데, 여기서는 그 상태가 바뀔 수 있는 경로 자체를 코드 한 곳에 가두는 방법을 다뤘다. 지향점은 같다. 규칙이 흩어지지 않게 한곳에 모으는 것이다.

이렇게 나눈 CardCharge, BankTransferCharge, PointCharge 세 클래스도 앞서 본 classitis의 경계 대상이다. 이들을 나눈 이유가 셋으로 쪼갰기 때문이 아니라 각각 다른 수수료 정책과 한도라는 서로 무관한 지식을 감추기 때문이라는 점은 IdentityCheck, AccountCheck, Blacklist를 나눈 이유와 같다. 감추는 지식이 다르지 않다면, 여기서도 인터페이스를 쪼개는 것은 깊이가 아니라 인지 부하만 늘릴 뿐이다.

Essential Complexity에는 이 도구들이 닿지 않는다

지금까지 다룬 기법을 되짚어 보면 전부 하나의 대상을 겨냥한다. Deep Module도, classitis를 경계하는 것도, 복잡성을 아래로 끌어내리는 것도, 조건 분기를 다형성으로 대체하는 것도, Stratified Design의 층 구조도, 결국 코드를 어떻게 배치할 것인가에 관한 도구다. essential complexity는 배치를 아무리 잘 바꿔도 줄어들지 않는다. 코드 구조가 아니라 업무 자체에서 나오는 복잡성이기 때문이다.

Deep Module이 완전히 무력한 것은 아니다. essential complexity라도 인터페이스 뒤로 감추면 그것을 몰라도 되는 사람의 수는 줄어든다. 다만 그 복잡성을 정말 알아야 하는 사람, 이를테면 등록 가능 여부를 판정하는 규칙 자체를 정해야 하는 사람에게는 그대로 남는다. essential complexity는 감출 수는 있어도 없앨 수는 없다.

이 남는 부분에는 앞서 말한 “사용자와 함께 다루는 것” 말고는 통하는 방법이 없다. 다른 글에서 이 일에 이름을 붙인 적이 있다. 업무가 본래 지닌 복잡성을 어떻게 다룰지 생각하는 일이 모델링이고, 언어와 프레임워크가 강요하는 복잡성을 다루는 일이 인코딩이다. 이 글이 지금까지 다룬 기법은 전부 인코딩 쪽이었다.

모델링에는 코드 구조와는 다른 도구가 쓰인다. EventStorming은 업무 담당자와 개발자가 한자리에 모여 업무 사건을 시간순으로 나열하며 업무 전체의 흐름을 드러내고, Example Mapping은 규칙 하나를 놓고 그 규칙이 성립하는 예와 성립하지 않는 예를 모아 규칙의 정확한 경계를 캐낸다. 둘 다 코드를 짜기 전에, 업무를 아는 사람에게서 복잡성을 발견하는 도구다. Wallet 예제로 보면 “블랙리스트에 오른 이메일은 왜 등록을 막는가”, “본인확인 실패와 계좌 미실재를 같은 거부로 취급해도 되는가” 같은 질문에 답을 아는 사람은 코드가 아니라 업무 담당자다.

발견한 규칙을 어디까지 하나의 단위로 묶을지도 별도의 판단이 필요하다. 바운디드 컨텍스트는 하나의 모델과 언어가 유효한 경계이고, 애그리거트는 함께 지켜야 할 불변조건이 유효한 경계다. 이 경계를 잘못 그으면 essential complexity가 원래 크기보다 부풀려진 채로 코드에 들어온다. 이 판단 기준은 경계와 분해에서 다뤘다.

정리하면 복잡성을 다루는 축은 둘이다. 이 글이 다룬 축은 이미 발견된 복잡성을 코드 구조 안에 배치하는 인코딩이고, 모델링이라는 축은 코드를 쓰기 전에 그 복잡성이 무엇인지부터 캐내는 일이다. 인코딩 없이 모델링만 잘하면 옳게 캐낸 규칙이 코드에서 다시 흩어지고, 모델링 없이 인코딩만 잘하면 잘못 캐낸 규칙을 깔끔하게 감춘 코드가 나온다. 둘 다 있어야 한다.

결론

Hoare의 말대로 소프트웨어를 설계하는 쉬운 길은 결함이 안 보일 만큼 복잡하게 만드는 것이고, 어려운 길은 결함이 뻔히 보일 만큼 단순하게 만드는 것이다. 이 글에서 다룬 개념들은 그 어려운 길을 걷기 위한 지도에 가깝다. 상태와 제어라는 원인을 알면 어디를 먼저 손볼지 알 수 있고, Essential과 Accidental을 가르면 포기해야 할 복잡성과 없앨 수 있는 복잡성을 구분할 수 있다. Unknown Unknowns를 경계하면 obscurity와 dependencies를 줄이는 작업에 우선순위를 둘 수 있고, Simple Made Easy는 이 모든 판단의 기준을 뒤얽혀 있는가라는 질문 하나로 압축해준다. 이 판단을 실제 처방으로 옮기면 State를 걷어내는 Immutable과 Declarative, 모듈을 깊게 만드는 Deep Module, 완성이 아니라 진화를 전제하는 아키텍처가 나오고, 분산 시스템과 현장으로 시야를 넓히면 Essential Complexity의 경계를 다시 긋는 일과 의존성을 방치하지 않는 습관이 더해진다. Stratified Design은 이 모든 판단을 층이라는 구체적인 코드 구조로 옮기는 방법을 준다. 이 방법들이 최종적으로 겨냥하는 지점은 하나다. 깊이 생각하지 않고 낸 빠른 추측이 대체로 맞아떨어지는, 명확한 시스템(obvious system)이다.

“Out of the Tar Pit” 저자들은 논문의 목적이 낙관할 근거를 주는 데 있다고 밝혔다. 그 낙관의 근거는 복잡성 전부가 필연은 아니라는 사실에 있다. 지금 시스템이 복잡하게 느껴진다면, 그 복잡성 중 상당 부분은 사용자의 문제가 아니라 지금까지의 선택이 만든 결과다. 선택으로 생긴 것은 다른 선택으로 되돌릴 수 있다. 다만 그 되돌리는 작업은 한 번의 리팩터링으로 끝나지 않는다. 뒤얽힘을 알아채는 안목과 층을 지키는 규율은 계속 유지해야 하는 습관에 가깝다. 단순함은 어렵다. 그리고 그 어려움을 감수할 가치가 있다는 것이, 여기서 살펴본 모든 논의가 도달하는 결론이다.

참조 사이트


Mimul

Written byMimul
Mimul is a programmer, technologist, exercise enthusiast and more.
Connect

Related SnippetsView All

Related ArticlesView All