coding-style | domain-driven-design | refactoring | clean-code | software-design

언어 독립적인 도메인 중심 코딩 원칙과 실천법 (Domain-Centric Coding Principles)

DDD·Clean Code·Refactoring의 핵심을 통합한 도메인 중심 코딩 원칙과 안티패턴·실패 패턴을 19개 주제로 체계화합니다.

Mimul
MimulJanuary 29, 2026 · 222 min read · Last Updated:

LLM을 활용해 Java, Rust, TypeScript, Python, Go 등 여러 언어로 동시에 작업하다 보면 언어별 스타일 가이드만으로는 부족하다는 것을 느끼게 된다. AI가 코드를 생성할 때 “중첩을 줄여라”, “이름을 명확히 해라” 수준이 아니라, 왜 그렇게 해야 하는지 근거가 있는 원칙이 필요하다. 이 원칙은 Domain-Driven Design(DDD), Clean Code, Code Complete, Refactoring의 핵심 내용을 통합하여, 비즈니스 도메인을 최우선으로 삼으면서도 읽기 쉽고 유지보수성이 높으며 결함이 적은 코드를 만드는 것을 목표로 한다. AI가 코드를 생성하고 수정하는 빈도가 높아질수록, 코드가 얼마나 명확하고 의도적으로 작성되었는지가 AI의 추론 품질과 직결되기 때문에 이 원칙들은 AI 시대에 더욱 중요해진다.

1. 목적과 철학 (Philosophy)

코드는 기술적 산출물이기 이전에, 도메인 지식을 안전하게 보존하고 발전시키는 그릇 이다. 소프트웨어의 본질은 문법의 우아함이나 프레임워크 활용 능력, 또는 기술적 기교에 있지 않다. 비즈니스와 도메인의 행동을 명확하게 인코딩하여, 지금의 개발자뿐 아니라 미래의 개발자가 안전하게 이해하고, 수정하고, 확장할 수 있게 하는 것이 소프트웨어가 존재하는 이유다.

따라서 이 문서가 추구하는 코드 품질의 기준은 단순히 “동작하는 코드”가 아니라, 사람이 읽고 신뢰할 수 있는 코드 다. 좋은 코드는 작성하는 순간보다 읽히는 순간이 훨씬 많다. 코드는 컴파일러가 아니라 사람에게 먼저 말을 걸어야 한다.

팀이 추구하는 우선순위

코드를 작성할 때 상충하는 가치가 생기는 경우, 팀은 아래의 우선순위를 기준으로 판단한다. 각 항목은 독립된 규칙이 아니라 서로를 강화하는 하나의 철학적 체계로 이해해야 한다.

첫째, 도메인 명확성(Domain Clarity)을 최우선으로 삼는다.

코드는 비즈니스와 도메인의 행동을 있는 그대로 표현해야 한다. 변수명, 함수명, 모듈 구조 모두 도메인 언어를 중심으로 결정하며, 비즈니스 로직은 해당 도메인 객체 안에 위치시켜 코드를 읽는 사람이 기술적 세부사항보다 도메인의 의도를 먼저 파악할 수 있어야 한다. 도메인 언어가 코드 전반에 일관되게 흐를 때, 코드는 비로소 팀 공통의 언어가 된다.

둘째, 명시적 의도(Explicit Intent)를 암시적 관례보다 항상 우선한다.

코드는 컴파일러가 아니라 사람에게 먼저 말을 걸어야 한다. 작성한 사람만 아는 관례나 맥락에 의존하는 표현보다, 읽는 사람 누구에게나 의도가 드러나는 표현을 선택한다. “왜 이렇게 했는가”가 코드 자체에서 읽혀야 하며, 주석으로만 설명되는 코드는 명시성이 부족한 코드다.

셋째, 변경 안전성(Change Safety)을 설계의 기준으로 삼는다.

코드는 한 번 작성되고 수십 번 수정된다. 변경이 발생했을 때 파급 범위가 작고 예측 가능해야하며, 수정이 필요한 지점이 직관적으로 드러나야 한다. 변경에 취약한 설계는 아무리 우아해 보여도 좋은 설계가 아니다. 안정성과 예측 가능성은 변경 안전성의 다른 이름이며, 예외 상황과 경계 조건도 암묵적 전제가 아닌 명시적 처리로 표현한다.

넷째, 인지 부하 감소(Cognitive Load Reduction)를 가독성의 실질적 기준으로 삼는다.

좋은 코드는 읽는 사람이 머릿속에 동시에 담아야 할 정보의 양을 줄인다. 코드 라인 수를 줄이거나 압축적으로 표현하는 것이 목표가 아니라, 읽는 순간의 인지 부담을 줄이는 것이 목표다. 복잡한 로직은 의미 있는 단위로 나누고, 흐름이 위에서 아래로 자연스럽게 읽히도록 구성한다.

다섯째, 진화 가능한 설계(Evolutionary Design)를 추구하되, 조급한 확장은 경계한다.

좋은 설계는 처음부터 완벽할 필요가 없다. 지금의 요구사항을 명확하게 해결하면서, 미래의 변경이 자연스럽게 수용될 수 있는 구조를 지향한다. 추상화와 재사용은 반드시 필요한 시점에, 반드시 필요한 수준만큼만 도입한다. 아직 오지 않은 요구사항을 위한 유연성은 유연성이 아니라 복잡성이다.

여섯째, 성능 최적화는 측정된 병목에만 적용한다.

확인되지 않은 성능 문제를 위한 최적화는 조기 최적화(Premature Optimization)로 간주한다. 최적화는 반드시 측정 결과를 근거로 하며, 위의 우선순위를 희생하는 최적화는 충분한 이유와 명시적인 주석을 동반해야 한다.

의식적으로 배제하는 것들

좋은 코드를 만들기 위해 추구할 것만큼이나, 의식적으로 배제해야 할 것도 중요하다.

1) 영리하지만 불필요하게 복잡한 추상화(Clever Abstractions)는 지양한다 (KISS).

추상화가 코드를 단순하게 만드는 것이 아니라 이해를 어렵게 만든다면, 그 추상화는 오히려 부채다. 코드 라인 수를 과도하게 줄이려는 시도도 마찬가지다. 간결함과 압축은 다르다. 읽는 사람의 인지 부하를 줄이는 것이 목표이지, 타이핑의 양을 줄이는 것이 목표가 아니다.

2) 프레임워크 순수성(Framework Purity)을 맹목적으로 우선시하는 설계는 경계한다.

프레임워크는 도구이지 목적이 아니다. 도메인 로직이 프레임워크의 구조에 종속되어서는 안 된다. 프레임워크의 관례가 도메인의 명확성과 충돌할 때, 도메인이 우선한다.

3) 조급한 재사용(Premature Reuse)은 지양한다.

중복이 나쁜 것은 사실이지만, 섣부른 공통화는 불필요한 결합을 만들고 변경을 어렵게 한다. 재사용의 필요성은 패턴이 충분히 반복된 이후에 판단한다.

4) 이론적 우아함(Theoretical Elegance)을 실용성보다 우선하지 않는다.

실제 팀이 이해하고 운용할 수 있는 코드가, 패턴 교과서에 나올 법한 완벽한 설계보다 항상 낫다. 설계의 아름다움은 팀이 그것을 자신 있게 수정할 수 있을 때 완성된다.

AI/LLM 코드 생성과 인간 협업에 대한 원칙

AI는 코드 작성의 조력자이지, 코드 품질의 책임자가 아니다. AI가 생성한 코드도 이 문서의 기준과 동일하게 검토되어야 하며, 생성된 코드를 그대로 수용하는 것은 리뷰를 생략하는 것과 같다.

AI를 활용한 코딩, 리팩토링, 코드 리뷰 시, 이 문서와 언어별 상세 가이드는 AI에게 부여되는 명시적 지침으로 기능한다. AI는 이 철학을 이해하고 일관되게 적용하는 방향으로 코드를 생성하거나 평가해야 한다. 특히 도메인 명확성, 명시적 의도, 변경 안전성을 최우선으로 반영하고, 불필요한 추상화나 측정되지 않은 최적화를 스스로 제안하지 않아야 한다.

인간 개발자는 AI의 결과물에 대해 최종 판단과 책임을 가진다. AI가 생성한 코드라도 팀의 우선순위와 도메인 지식에 비추어 반드시 검토하고, 필요하다면 주저 없이 수정한다. AI의 자신감은 코드의 정확성을 보장하지 않는다. 검토하지 않은 코드는 작성하지 않은 코드와 같다.

2. 코드 조직 구조 (Project Structure)

구조는 도메인을 반영해야 한다. 코드를 어떻게 배치하고 나누느냐는 단순한 파일 정리의 문제가 아니다. 디렉토리 구조와 모듈 경계는 팀이 도메인을 어떻게 이해하고 있는지를 드러내는 설계 결정이다. 기술 스택이나 프레임워크의 관례가 아니라, 비즈니스 도메인의 개념과 변화 축(change axis) 을 기준으로 코드를 조직한다. 구조를 보는 것만으로 이 시스템이 어떤 문제를 풀고 있는지 파악할 수 있어야 한다.

디렉토리 구조 원칙

1) Feature-First 구조를 기본으로 한다

디렉토리를 구성할 때 기술 계층(controller, service, repository)을 최상위 기준으로 삼는 Layer-First 방식보다, 도메인 기능(order, payment, member)을 최상위 기준으로 삼는 Feature-First 방식을 기본으로 채택한다.

Layer-First 구조는 기술적 역할이 명확히 드러나지만, 하나의 기능을 수정하려면 여러 디렉토리를 동시에 탐색해야 하고 도메인 개념이 구조에서 사라진다. 반면 Feature-First 구조는 하나의 기능 단위로 응집된 코드가 한 곳에 모여 있어, 변경의 파급 범위를 예측하기 쉽고 도메인 의도가 디렉토리 이름 자체에 드러난다.

2) 패키지 구성 원칙

각 기능 패키지 내부는 계층(layer) 기준으로 다시 나눈다. 계층 구분은 의존성 방향을 강제하고 Domain Layer를 보호하기 위한 수단이다. 계층 내 세부 구조는 언어별 상세 가이드를 따른다.

모듈을 분리할 때는 다음 기준을 적용한다. 변화의 이유가 다른가, 배포 단위가 다른가, 팀의 소유권이 다른가 — 이 세 가지 중 하나라도 해당한다면 분리를 검토한다. 반대로, 단순히 코드량이 많다거나 기술적 역할이 다르다는 이유만으로 모듈을 나누는 것은 조급한 분리다.

계층 아키텍처와 의존성 방향 (SoC)

1) Domain Layer는 최우선으로 보호된다

시스템은 다음 네 계층으로 구성되며, 의존성은 항상 바깥에서 안쪽 방향 으로만 흐른다. 안쪽 계층은 바깥 계층을 알지 못하고, 알아서도 안 된다.

  • Domain Layer 는 비즈니스 규칙과 도메인 지식이 살아있는 핵심이다. 이 계층은 프레임워크, 데이터베이스, 외부 API, 라이브러리로부터 철저히 격리되어야 한다. Domain Model이 ORM 어노테이션이나 프레임워크 어노테이션, HTTP 응답 구조, 외부 라이브러리의 타입에 오염되는 순간, 도메인 지식은 기술 세부사항에 잠식된다.
  • Infrastructure Layer 는 외부 의존성을 Adapter/Port 패턴으로 격리한다. DB, 외부 API, 메시징 시스템 등 기술적 세부사항은 Domain Layer가 정의한 계약(Port)을 구현하는 형태로 존재한다. 덕분에 기술 스택이 바뀌어도 Domain Layer는 변경되지 않는다.
  • Application Layer 는 도메인 객체들의 협력을 조율하는 얇은 계층이다. 비즈니스 규칙을 직접 보유하지 않고, Domain Layer에 위임한다. 트랜잭션 경계와 Use Case의 흐름을 관리하는 것이 이 계층의 책임이다.

2) 순환 참조는 절대 허용하지 않는다

모듈 간 의존성은 반드시 단방향이어야 한다. A가 B를 참조하면 B는 A를 참조할 수 없다. 순환 참조는 결합도의 가장 위험한 형태로, 변경의 파급 범위를 예측할 수 없게 만들고 독립적인 테스트와 배포를 불가능하게 한다. 순환 참조가 발생했다면, 그것은 책임 분리가 잘못되었다는 신호이므로 설계를 재검토해야 한다.

비즈니스 규칙은 반드시 Domain Model 내부에 명시적으로 표현 되어야 한다. 중요한 규칙이 Controller, SQL 쿼리, UI 검증, 프레임워크 어노테이션, 설정 파일 등에 흩어져 있으면, 코드를 읽는 것만으로 비즈니스 의도를 파악할 수 없게 된다.

3) 상속보다 위임(Composition over Inheritance)을 선호한다

코드 재사용과 확장성을 높이기 위한 수단으로 상속보다 위임을 우선 검토한다. 상속은 부모와 자식 사이에 강한 결합을 만든다. 부모 클래스의 내부 구현이 변경되면 자식 클래스가 영향을 받고, 상속 계층이 깊어질수록 변경의 파급 범위를 예측하기 어렵다. 위임은 필요한 기능을 가진 객체를 내부에 보유하고 해당 기능을 사용하는 방식으로, 결합도가 낮고 런타임에 위임 대상을 교체하는 것도 가능하다. “IS-A” 관계가 명확히 성립하는 경우에만 상속을 사용하고, “HAS-A” 또는 기능 재사용이 목적이라면 위임을 선택한다.

4) 개방/폐쇄 원칙(OCP)을 의식한다

소프트웨어 엔티티(클래스, 모듈, 함수)는 확장에 대해 열려 있고 수정에 대해 닫혀 있어야 한다. 새로운 요구사항이 생겼을 때 기존 코드를 직접 수정하지 않고 새 코드를 추가하는 방식으로 대응할 수 있어야 한다. 이를 위해 변경 가능성이 높은 부분(외부 API 연계, 비즈니스 규칙, 데이터 포맷)을 인터페이스나 추상 클래스로 격리하고, 구체 구현은 주입되도록 설계한다. 이미 언급한 DIP(의존성 역전 원칙)와 맞닿아 있으며, 두 원칙이 함께 적용될 때 변경에 강한 구조가 만들어진다.

공개 API 경계

1) 공개 범위는 최소화한다

모듈의 공개 API(public interface)는 외부에 드러낼 필요가 있는 것만 노출한다. 내부 구현 세부사항은 철저히 은닉한다. 공개 범위가 넓을수록 변경 시 영향받는 범위가 넓어지고, 외부에서 내부 구현에 의존하는 코드가 생겨 유지보수가 어려워진다.

내부 구현이 외부로 노출된 코드는 리팩토링의 자유를 잃는다. 외부에 공개된 것은 계약(contract)이 되고, 그 계약은 변경 비용을 높인다.

2) 안정적인 API와 내부 구현을 구분한다

공개 API는 한 번 외부에 노출되면 변경이 어렵다. 따라서 안정성이 높고 변화가 적은 것만 공개 API로 승격한다. 아직 설계가 확정되지 않았거나 변경 가능성이 높은 것은 내부로 유지하고, 필요한 시점에 점진적으로 공개한다.

모듈이 외부에 제공하는 진입점(entry point)을 명확히 정의하고, 그 경계 밖에서는 내부 구조를 직접 참조하지 않는다. 경계를 넘는 모든 통신은 공개 인터페이스를 통해서만 이루어진다.

구조 설계 시 피해야 할 것들

좋은 구조만큼이나, 잘못된 구조의 패턴을 인식하는 것도 중요하다.

1) 과도한 레이어링은 복잡성을 증가시킨다.

모든 요청이 의미 없이 다섯 개 이상의 계층을 통과하도록 설계된 구조는, 이해와 탐색 비용을 높이고 실제 도메인 로직이 어디에 있는지 찾기 어렵게 만든다. 계층은 목적이 있을 때만 도입한다.

2) Framework-driven Design은 지양한다.

프레임워크의 패키지 구조나 관례를 그대로 따르느라 도메인 구조가 희생되는 설계는 옳지 않다. 프레임워크는 도구이며, 도메인이 프레임워크에 종속되어서는 안 된다.

3) 의미 없는 공통 모듈 덤핑은 피한다.

utils, common, helper 같은 이름의 모듈에 관련 없는 로직이 누적되는 것은 설계 포기의 신호다. 공통 코드는 명확한 도메인 개념을 가질 때만 공통 모듈로 분리하며, 그렇지 않으면 각자의 맥락에 두는 것이 낫다.

3. 네이밍 규칙 (Naming)

이름은 설계 행위다. 이름을 짓는 것은 단순한 표기 선택이 아니라 설계 행위다. 좋은 이름은 코드를 읽는 사람에게 의도, 역할, 제약, 부수효과 가능성까지 전달한다. 반대로 나쁜 이름은 코드의 동작을 숨기고, 읽는 사람이 코드를 실행하거나 주석을 뒤져야만 의도를 파악할 수 있게 만든다.

이름은 코드에서 가장 많이 읽히는 요소다. 작성 비용은 한 번이지만 독해 비용은 수십 번 반복된다. 좋은 이름 하나가 주석 열 줄보다 낫고, 나쁜 이름 하나가 버그의 온상이 된다.

일반 원칙

1) 의도와 도메인을 드러내는 이름을 사용한다

이름은 무엇(what) 인지와 왜(why) 존재하는지를 드러내야 한다. 기술적 구현 방식이 아니라 도메인 개념과 비즈니스 행동을 기준으로 이름을 결정한다. 도메인 전문가와 개발자가 일상적으로 사용하는 동일한 언어(Ubiquitous Language)를 코드, 테스트, 문서, 커밋 메시지 전반에 일관되게 사용한다. 코드에서 쓰는 용어와 기획서나 회의에서 쓰는 용어가 다르다면, 그 간극이 오해와 버그의 출발점이 된다.

2) 축약은 최소화한다

축약어는 작성자의 타이핑 시간을 줄이지만, 읽는 사람의 해독 시간을 늘린다. 팀 전체가 명백히 공유하는 약어(예: id, url, dto)가 아닌 이상 축약하지 않는다. 길더라도 명확한 이름이 짧지만 모호한 이름보다 항상 낫다.

3) 이름은 검색 가능해야 한다

이름은 코드베이스 전체에서 검색했을 때 의미 있는 결과를 돌려줄 수 있어야 한다. 단일 문자 변수나 숫자 리터럴은 검색이 불가능하다. 범위가 작은 루프 인덱스를 제외하면, 단일 문자 이름은 사용하지 않는다.

타입별 네이밍 기준

1) 클래스 (Class)

클래스 이름은 명사 또는 명사구로 짓는다. 클래스가 표현하는 도메인 개념이 이름에 직접 드러나야 한다. 클래스가 무슨 일을 하는지가 아니라, 무엇을 나타내는지를 이름으로 표현한다. 클래스 이름에 Manager, Handler, Processor, Helper 같은 모호한 접미사가 붙기 시작하면, 그 클래스가 너무 많은 책임을 갖고 있다는 신호다. 이름이 명확히 붙지 않는다면 설계를 먼저 재검토한다.

2) 함수 / 메서드 (Function / Method)

함수 이름은 동사 또는 동사구 로 짓는다. 함수가 수행하는 행동을 명확히 드러내야 하며, 부수효과(side effect)가 있다면 이름에서 예상 가능해야 한다. 반환값이 boolean인 함수는 is, has, can, should 등의 접두사를 붙여 질문 형태로 표현한다.

변수 (Variable)

변수는 저장하는 값의 도메인 의미를 드러내야 한다. 타입 이름을 그대로 변수명으로 사용하는 것은 금지한다. 컬렉션 변수는 복수형 으로 명명한다. 단수형 컬렉션 이름은 단일 객체와 혼동을 유발한다.

1) 상수 (Constant)

상수는 코드에 등장하는 이유와 비즈니스 의미를 이름으로 드러낸다. 값 자체를 이름에 포함시키지 않는다. 상수명은 언어별 컨벤션에 따르되, 의미를 최우선으로 한다. 매직 넘버와 매직 스트링은 절대 코드에 직접 사용하지 않는다. 의미 없는 리터럴 값은 코드의 의도를 숨기고, 변경 시 누락의 원인이 된다.

2) 인터페이스 (Interface)

인터페이스는 능력(capability) 또는 역할(role) 을 표현하는 이름을 사용한다. 구현 세부사항이 아닌 계약(contract)의 의미를 담아야 한다. 언어에 따라 I 접두사 또는 able 접미사 등의 컨벤션은 언어별 상세 가이드를 따른다. 인터페이스 이름이 구현체와 지나치게 유사하거나(IOrderService / OrderServiceImpl), 의미가 중첩된다면, 인터페이스의 추상화 수준을 다시 검토한다.

3) 이벤트 (Event)

이벤트는 이미 발생한 사실 을 표현하므로, 과거형으로 명명한다. 이벤트 이름은 도메인 전문가가 사용하는 비즈니스 언어를 그대로 반영한다. 이벤트를 처리하는 핸들러는 on 접두사를 붙여 이벤트에 반응하는 역할임을 명확히 한다.

4) 비동기 함수 (Async Function)

비동기 함수임을 이름에서 명시적으로 드러내는 것이 원칙이다. 언어 수준에서 비동기 여부가 타입이나 키워드로 충분히 표현되는 경우(예: TypeScript의 Promise<T>, Kotlin의 suspend)에는 별도 접미사 없이도 괜찮지만, 팀 컨벤션을 일관되게 유지한다. 비동기 함수와 동기 함수를 같은 이름으로 혼용하지 않는다. 혼용은 호출하는 쪽에서 비동기 여부를 코드를 읽지 않으면 알 수 없게 만든다.

5) 테스트명 (Test Name)

테스트 이름은 어떤 상황에서, 어떤 동작을 했을 때, 어떤 결과가 나오는지 를 명확히 드러내야 한다. 테스트는 문서의 역할도 겸하기 때문에, 이름만 읽고도 무엇을 검증하는지 이해할 수 있어야 한다. 테스트 이름에 도메인 언어를 사용하면, 테스트 목록이 곧 비즈니스 명세가 된다.

금지 패턴

1) util / helper / common 남용

utils, helpers, common 같은 이름의 모듈이나 클래스는 관련 없는 로직의 쓰레기통이 되기 쉽다. 이런 이름이 붙은 순간, 그 안에 무엇이 있는지 이름만으로 알 수 없게 된다. 공통 코드가 필요하다면, 그 코드가 속한 도메인 개념을 먼저 찾는다. 명확한 도메인 개념이 없다면 각자의 맥락에 두는 것이 낫다. 억지로 공통화하면 불필요한 결합만 생긴다.

2) Manager / God Object

하나의 클래스가 너무 많은 책임을 가질 때 흔히 Manager, Controller(도메인 수준), Coordinator, Orchestrator 같은 이름이 붙는다. 이런 이름은 설계가 무너지고 있다는 경고 신호다. 클래스가 표현하는 도메인 개념을 이름으로 특정할 수 없다면, 책임이 과도하게 집중되어 있는 것이다. 이 경우 이름을 고치기 전에 설계를 먼저 분리해야 한다.

3) data / info / temp 등 의미 없는 이름

data, info, result, temp, obj, value 같은 이름은 아무것도 설명하지 않는다. 모든 변수는 데이터이고, 모든 반환값은 결과이며, 모든 객체는 객체다. 이런 이름은 읽는 사람에게 맥락과 의미를 전혀 전달하지 못한다.

4) 기술 용어가 도메인 용어를 압도하는 경우 (Technical Naming Dominance)

기술적 구현 방식이 도메인 개념보다 이름에서 더 크게 드러나는 것을 경계한다. 코드는 기술 시스템이기 전에 도메인 시스템이다. 도메인 전문가가 코드의 이름을 보고 낯설게 느낀다면, 그 이름은 재검토 대상이다.

4. 코드 스타일 (Code Style)

스타일은 일관성이 목적이다. 코드 스타일 규칙의 목적은 특정 방식이 다른 방식보다 절대적으로 우월해서가 아니다. 팀 전체가 동일한 방식으로 코드를 작성하면, 읽는 사람이 형식에 소비하는 인지 자원을 줄이고 내용에 집중할 수 있게 된다. 일관성은 이해 속도, 리뷰 품질, 유지보수성을 동시에 높인다.

포맷팅처럼 기계적으로 검사 가능한 것은 자동화한다. formatter와 linter가 강제할 수 있는 규칙은 도구에 위임하고, 사람은 도메인 이해와 설계 판단에 집중한다. 스타일 논쟁은 자동화로 종결한다.

포맷팅 (Formatting)

1) 들여쓰기와 줄 길이

들여쓰기 방식(스페이스/탭)과 단위는 언어별 상세 가이드에서 정의하며, 프로젝트 내에서 혼용하지 않는다. 들여쓰기 깊이가 깊어질수록 코드의 복잡도가 높아지고 있다는 신호이므로, 깊은 들여쓰기는 로직 분리의 기회로 인식한다. 줄 길이는 한 줄에 담을 수 있는 정보의 양을 제한하여 가로 스크롤 없이 코드를 읽을 수 있게 한다. 구체적인 최대 길이는 언어별 상세 가이드를 따른다. 줄이 길어진다면 표현식이 너무 많은 일을 하고 있다는 신호일 수 있으며, 의미 있는 단위로 분리를 검토한다.

2) 공백

공백은 코드의 논리적 단위를 시각적으로 구분하는 수단이다. 관련 있는 코드는 가까이, 관련 없는 코드는 공백 한 줄로 구분한다. 공백 없이 모든 로직을 붙여 쓰는 것은 문단 구분 없는 글과 같다.

3) Import 정렬

import 구문은 자동 정렬 도구에 위임한다. 그룹 구분과 순서 기준은 언어별 상세 가이드에서 정의하며, 미사용 import는 항상 제거한다. 와일드카드 import(import *)는 어떤 심볼이 어디서 오는지 숨기므로 원칙적으로 사용하지 않는다.

4) Trailing Comma

컬렉션, 파라미터, 인수 목록의 마지막 항목에 trailing comma를 허용하는 언어에서는 일관되게 사용한다. Trailing comma는 항목을 추가하거나 순서를 변경할 때 diff를 깔끔하게 유지하고, 코드 리뷰 시 불필요한 변경 노이즈를 줄인다.

5) 대칭 원리(Symmetry Principle)를 지킨다

쌍을 이루는 연산(open/close, lock/unlock, acquire/release, init/cleanup)은 반드시 짝이 맞게 처리한다. 한쪽을 수행했으면 예외가 발생하더라도 반드시 반대쪽도 수행해야 하며, 이를 지키지 않으면 리소스 누수, 데드락, 불완전한 초기화 같은 버그가 발생한다. 언어가 제공하는 자동 해제 메커니즘(try-with-resources, using, RAII)을 활용하면 컴파일러와 런타임이 대칭을 보장하므로 적극 활용한다.

표현식 스타일 (Expression Style)

1) Early Return과 Guard Clause로 중첩을 최소화한다

중첩(nesting)이 깊어질수록 코드를 이해하기 위해 머릿속에 유지해야 하는 문맥이 늘어난다. 중첩은 복잡성의 가장 직접적인 지표이며, 읽는 사람의 인지 부하를 높인다.

  • Guard Clause 는 함수의 진입 조건을 초반에 빠르게 검사하고 조기에 반환하여, 이후 코드가 항상 유효한 상태에서 실행됨을 보장한다. 조건을 충족하지 못하는 경우를 먼저 처리하고, 핵심 로직은 들여쓰기 없이 평탄하게 유지한다.
  • Early Return 은 함수 내에서 조건이 충족된 시점에 즉시 반환하여, 이후 로직이 불필요한 분기를 타지 않도록 한다. “정상 흐름(happy path)“이 코드의 메인 흐름으로 읽히도록 유지한다.

2) 조건식을 단순하고 명시적으로 유지한다

복잡한 boolean 표현식은 의도를 드러내는 이름의 변수나 메서드로 추출한다. 조건식을 읽는 것만으로 무엇을 검사하는지 알 수 있어야 하며, 조건의 의미를 해독하기 위해 코드를 거슬러 올라가야 한다면 추출을 고려한다. 삼항 연산자는 단순하고 명확한 경우에만 사용한다. 중첩 삼항 연산자는 어떤 경우에도 사용하지 않는다. 읽는 데 한 번 이상 눈이 멈춘다면, 일반 조건문으로 풀어낸다. 복잡성은 상태(state) → 결합도(coupling) → 분기(branching) → 코드량(code volume) 순으로 줄이는 것이 효과적이다. 분기를 줄이는 가장 좋은 방법은 애초에 잘못된 상태가 만들어지지 않도록 모델을 설계하는 것이다.

함수 스타일 (Function Style)

1) 함수는 하나의 일만 한다 (SRP)

함수는 한 가지 일을 하고, 그 일을 잘 해야 한다. 함수의 추상화 수준은 일관되어야 하며, 하나의 함수 안에서 고수준 오케스트레이션과 저수준 구현 세부사항이 섞이면 안 된다(SLAP). 함수 길이에 절대적인 기준은 없지만, 한 화면(약 20~30줄)을 넘기 시작하면 책임이 과도하게 집중되고 있다는 신호로 인식한다. 함수가 길어지는 이유는 대부분 한 가지 이상의 일을 하기 때문이다. 명확한 의미 단위로 분리하고, 분리된 함수에 도메인 의미가 담긴 이름을 붙인다.

2) 파라미터 개수를 제한한다

파라미터가 많아질수록 함수의 책임이 불명확해지고, 호출하는 쪽에서 인수의 순서와 의미를 기억해야 하는 부담이 늘어난다. 파라미터는 원칙적으로 3개 이하를 권장하며, 그 이상이 필요하다면 관련 파라미터를 묶는 도메인 객체(Value Object, DTO)로 추출한다. boolean 파라미터는 함수가 두 가지 일을 하고 있다는 신호다. 가능하면 두 개의 명확한 함수로 분리한다.

3) 부수효과(Side Effect)를 명시적으로 관리한다

함수가 외부 상태를 변경하거나, 외부 시스템에 영향을 미치거나, I/O를 수행한다면 그 함수는 부수효과를 가진다. 부수효과는 나쁜 것이 아니지만, 예측 불가능한 부수효과 는 버그의 가장 흔한 원인이다. 부수효과가 있는 함수와 없는 함수를 명확히 구분하고, 부수효과는 이름에서 예상 가능하도록 한다. 값을 계산하는 함수가 예상치 못하게 외부 상태를 변경하거나, 데이터베이스에 쓰는 행위를 숨기는 것은 가장 위험한 패턴이다. 공유 가변 상태(shared mutable state)는 최대한 피한다. 공유 가변 상태는 동시성 문제의 근원이며, 어디서 상태가 변경되었는지 추적하기 어렵게 만들어 테스트 신뢰성을 저하시킨다.

4) 순수 함수(Pure Function)를 우선적으로 선호한다

순수 함수는 동일한 입력에 항상 동일한 결과를 반환하며, 외부 상태를 변경하지 않는다. 순수 함수는 테스트가 쉽고, 이해하기 쉬우며, 예측 가능하다. 이 세 가지는 모두 변경 안전성과 직결된다. 모든 함수를 순수 함수로 만들 수는 없다. 데이터베이스 저장, 외부 API 호출, 로깅은 본질적으로 부수효과를 동반한다. 그러나 비즈니스 규칙을 표현하는 로직은 최대한 순수 함수로 구성하고, 부수효과를 동반하는 부분은 함수의 바깥 경계로 밀어내는 구조를 지향한다.

5. 타입 시스템 활용 (Type System)

타입은 도메인 지식을 인코딩하는 수단이다. 타입 시스템은 컴파일러가 오류를 잡아주는 도구이기 이전에, 도메인 개념을 코드로 표현하는 설계 수단 이다. 좋은 타입 설계는 잘못된 상태를 런타임 이전에 차단하고, 코드를 읽는 사람에게 도메인의 제약과 의도를 타입 자체로 전달한다.

타입을 단순히 데이터의 저장 형식으로만 사용하는 것은 타입 시스템의 절반만 활용하는 것이다. 타입이 도메인 개념을 온전히 표현할 때, 컴파일러는 비즈니스 규칙의 첫 번째 수호자가 된다. 이 섹션은 언어별 차이가 크므로 언어 공통 원칙 만 정의한다. 구체적인 문법과 구현 방식은 언어별 상세 가이드를 따른다.

강한 타입을 선호한다

가능한 한 강한 타입(strong type)을 사용한다. 타입이 약하거나 모호할수록, 컴파일러가 잡아낼 수 있었던 오류가 런타임으로 밀려난다. Any, Object, unknown, dynamic 같은 최상위 타입은 타입 시스템을 사실상 무력화하며, 코드의 의도를 숨긴다.

타입 캐스팅은 런타임 검사를 동반해야 한다. 컴파일러가 허용했다고 안전한 것이 아니다. 명시적인 타입 검사 없는 강제 캐스팅은 타입 시스템을 우회하는 행위이며, 런타임 오류의 원인이 된다.

Primitive Obsession을 방지한다

String, int, boolean 같은 기본 타입만으로 도메인 개념을 표현하는 것을 Primitive Obsession 이라 한다. 이는 코드에서 반복적으로 나타나는 가장 흔한 안티패턴 중 하나다.

기본 타입은 도메인 제약을 표현하지 못한다. String으로 선언된 email은 형식이 올바른지 보장하지 않고, int로 선언된 price는 음수가 될 수 없다는 규칙을 담지 못한다. 기본 타입을 그대로 사용하면 동일한 타입의 값을 혼용하는 버그(예: customerIdorderId를 바꿔 전달)를 컴파일러가 잡을 수 없다.

도메인 개념을 표현하는 값은 Value Object 로 감싼다. Value Object는 유효성 검증을 생성 시점에 수행하며, 해당 도메인 개념에 속하는 행동을 함께 보유한다.

Nullable 처리 원칙

null은 “값이 없음”을 표현하는 가장 오래된 방법이지만, 가장 위험한 방법이기도 하다. null은 타입 시스템 밖에 있어 컴파일러가 추적하지 못하고, 런타임에서야 NullPointerException 으로 드러난다. 또한 null의 의미가 “아직 모름”, “존재하지 않음”, “선택적 값” 중 무엇인지 코드만으로 구분할 수 없다.

1) null 대신 명시적인 Optionality를 사용한다

언어가 제공하는 명시적인 Optional 타입(Option<T>, Optional<T>, Maybe<T>, T?)을 사용하여 “값이 없을 수 있다”는 사실을 타입으로 표현한다. 이렇게 하면 호출하는 쪽에서 null 여부를 반드시 처리하도록 타입 시스템이 강제하고, 예상치 못한 null 전파를 방지한다.

2) null 전파를 허용하지 않는다

null을 반환받은 쪽에서 null 여부를 확인하지 않고 다음 레이어로 전달하는 것은 null 전파 지옥을 만드는 지름길이다. null 여부는 발생 지점 가장 가까운 곳에서 처리하며, null을 다음 레이어로 흘려보내지 않는다. if (x != null) 체크를 단순히 추가하는 것은 근본 원인을 숨기는 임시방편이다. null이 발생한다면 그 지점의 설계를 재검토해야 한다.

Enum 사용 기준

열거된 상태와 선택지를 표현할 때는 String이나 int 상수 대신 Enum 을 사용한다. Enum은 허용되는 값의 범위를 타입으로 제한하며, 새로운 값이 추가될 때 컴파일러가 처리되지 않은 케이스를 감지할 수 있게 한다.

Enum은 단순한 상수 집합을 넘어 도메인 행동을 보유할 수 있다. 상태에 따라 다른 행동이 필요하다면, switch/when 문 대신 Enum 자체에 행동을 정의하는 것을 검토한다.

switch/match 문에는 항상 default 또는 else 케이스를 포함한다. 새로운 Enum 값이 추가될 때 조용히 실패하는 버그를 방지하기 위해서다. 언어가 exhaustive check를 지원한다면 적극 활용한다.

Value Object 사용 기준

Value Object는 도메인 개념을 타입으로 표현하면서, 유효성 규칙과 도메인 행동을 함께 보유하는 불변 객체다. 다음 기준 중 하나 이상에 해당하면 Value Object 도입을 검토한다.

  • 기본 타입에 유효성 제약 이 있는 경우 (예: 이메일 형식, 양수만 허용하는 금액)
  • 같은 타입의 값이 다른 도메인 의미 를 가지는 경우 (예: latitudelongitude는 모두 Double이지만 혼용하면 좌표가 뒤집힘)
  • 특정 값에 속하는 도메인 행동 이 존재하는 경우 (예: Money.add(), Email.getDomain())
  • 유효성 검증 로직이 여러 곳에서 중복 되는 경우

Value Object는 불변(immutable) 으로 설계한다. 상태가 변경되어야 한다면 새로운 Value Object를 반환한다. 불변성은 공유해도 안전하고, 동시성 문제에서 자유롭다.

Generic 사용 기준

Generic은 타입 안전성을 유지하면서 재사용 가능한 코드를 작성하기 위한 수단이다. Generic을 사용할 때는 타입 파라미터의 의미가 명확해야 하며, 타입 파라미터 이름은 단일 문자(T, E) 보다 의미를 드러내는 이름을 사용한다.

단, Generic의 복잡도가 코드를 이해하기 어렵게 만들 정도라면 과도한 추상화일 수 있다. Generic이 인지 부하를 줄이는 것이 아니라 오히려 높인다면, 명시적인 타입을 사용하는 것이 낫다. “이 Generic을 이해하려면 타입 파라미터가 어떻게 사용되는지 모두 추적해야 한다”면 설계를 재검토한다.

Type Alias 사용 기준

Type Alias(타입 별칭)는 기존 타입에 도메인 의미를 부여하는 가벼운 방법이다. Value Object를 도입할 정도는 아니지만, 타입의 의미를 명확히 하고 싶을 때 사용한다.

단, Type Alias는 컴파일러 수준에서 타입 안전성을 보장하지 못하는 언어도 있다. 언어에 따라 Type Alias가 단순한 문서화 수단에 그치는 경우, 유효성 검증이 필요하다면 Value Object를 선택한다.

잘못된 상태 자체를 불가능하게 설계한다

타입 시스템의 궁극적인 목표는 잘못된 상태를 표현할 수 없게 만드는 것 이다. 런타임에 방어 코드를 여러 겹 쌓는 것보다, 그런 상태 자체가 생성되지 않도록 모델링하는 것이 근본적인 해결책이다.

상태 전이(state transition)가 있는 도메인 개념은 허용되는 전이만 타입 수준에서 표현한다. “주문은 취소된 이후에 배송될 수 없다”는 규칙이 조건문이 아닌 타입 구조로 강제될 때, 그 규칙은 코드 어디에서도 우회될 수 없다.

비즈니스 불변 조건(Business Invariant)은 도메인 모델이 직접 보호한다. 불변 조건이란 시스템이 어떤 상태에 있든 반드시 참이어야 하는 규칙이다. “계좌 잔액은 음수가 될 수 없다”, “주문 항목은 최소 하나 이상이어야 한다”처럼 도메인 규칙 자체가 불변 조건이다. 이 조건을 Service 계층의 조건문으로 분산시키면, 조건이 누락되거나 우회되는 경로가 생긴다. 불변 조건 위반은 도메인 모델 내부에서 즉시 감지하고 의미 있는 도메인 예외로 반환한다.

여러 Aggregate에 걸친 불변 조건은 단일 트랜잭션으로 강제할 수 없다. 이 경우 결과적 일관성(eventual consistency)을 의식적으로 선택하고, 불변 조건 위반이 발생했을 때의 보상 로직을 설계 시점에 명시한다. “지금 당장 일관성을 보장할 수 없다”는 사실을 암묵적으로 숨기지 않고, 시스템이 언제, 어떻게 일관성을 회복하는지를 코드 수준에서 표현한다.

6. 데이터와 상태 관리 (State & Data)

상태는 복잡성의 근원이다. 소프트웨어의 복잡성은 상태(state) → 결합도(coupling) → 분기(branching) → 코드량 (code volume) 순으로 증가한다. 상태가 가장 먼저 나오는 것은 우연이 아니다. 상태는 복잡성의 가장 근본적인 원천이며, 상태를 잘 관리하는 것이 시스템의 예측 가능성과 변경 안전성을 결정한다.

상태 관리의 목표는 상태를 완전히 없애는 것이 아니다. 상태는 소프트웨어의 본질적인 구성 요소다. 목표는 상태의 범위를 최소화하고, 상태 변경이 발생하는 지점을 예측 가능하게 만들며, 잘못된 상태가 존재할 수 없도록 설계하는 것 이다.

상태 관리 (State Management)

1) 가변 상태(Mutable State)를 최소화한다

변경 가능한 상태는 그것이 어디서, 언제, 누구에 의해 변경되었는지 추적해야 하는 부담을 만든다. 가변 상태가 많을수록 코드를 이해하기 위해 실행 흐름 전체를 머릿속에 유지해야 하며, 테스트의 신뢰성도 떨어진다. 변수와 필드는 기본적으로 불변(immutable) 으로 선언하고, 변경이 반드시 필요한 경우에만 가변으로 선언한다. 불변으로 선언된 것이 많을수록, 가변으로 선언된 것이 더 눈에 띄고 주의가 집중된다.

2) 불변 객체(Immutable Object)를 우선적으로 설계한다

불변 객체는 생성된 이후 상태가 변경되지 않는다. 불변 객체는 공유해도 안전하고, 동시성 환경에서 별도의 동기화 없이 사용할 수 있으며, 디버깅 시 상태 변경 경로를 추적할 필요가 없다. 특히 Value Object, 도메인 이벤트, DTO는 불변으로 설계하는 것을 기본으로 한다. 도메인 엔티티처럼 상태 변경이 본질적인 경우에도, 변경 메서드를 통해서만 상태가 바뀌도록 캡슐화하고 외부에서 직접 필드를 수정할 수 없게 한다.

3) 공유 가변 상태(Shared Mutable State)는 가장 위험하다

여러 곳에서 동시에 접근하고 변경할 수 있는 상태는 소프트웨어에서 가장 위험한 형태의 복잡성이다. 공유 가변 상태는 실행 순서에 따라 결과가 달라지는 레이스 컨디션(race condition) 의 직접적인 원인이며, 테스트 환경에서 재현하기 어려운 버그를 만들어낸다. 공유 가변 상태는 다음 세 가지 방향으로 해소한다.

  • 불변으로 만들기: 공유하되 변경하지 않는다.
  • 공유하지 않기: 변경하되 공유하지 않는다. 상태를 필요한 범위 안으로 가두고, 외부로 노출하지 않는다.
  • 변경을 직렬화하기: 공유와 변경이 모두 필요하다면, 변경이 한 번에 하나씩만 발생하도록 보장한다.

전역 상태(global state)와 싱글톤의 가변 필드는 공유 가변 상태의 전형이다. 특별한 이유가 없는 한 전역 가변 상태는 사용하지 않는다.

데이터 모델 (Data Model)

1) DTO와 Domain Model을 명확히 분리한다

DTO(Data Transfer Object)와 Domain Model은 목적과 책임이 근본적으로 다르다. 이 두 가지를 하나의 객체로 혼용하면 도메인 모델이 외부 기술 요소에 오염되고, 계층 간 결합도가 높아진다.

구분Domain ModelDTO
목적비즈니스 규칙과 도메인 행동 표현계층 간 또는 시스템 간 데이터 전달
행동도메인 행동을 보유함데이터 운반만 담당 (행동 없음)
유효성생성 시점에 도메인 규칙으로 검증입력/출력 형식 검증
변경 원인비즈니스 요구사항 변경API 스펙, 외부 시스템 변경
불변성원칙적으로 불변 또는 제어된 가변일반적으로 가변 허용

Domain Model이 직렬화 어노테이션, HTTP 응답 구조, 데이터베이스 컬럼 매핑에 직접 오염되는 것을 막는다. Domain Model은 기술 세부사항을 알지 못해야 하고, DTO가 그 변환을 담당한다.

2) Serialization 정책

직렬화/역직렬화는 Infrastructure Layer의 책임이다. Domain Model이 직렬화 방식을 알아서는 안 된다. 직렬화 형식(JSON, Protobuf, Avro 등)이 변경되어도 Domain Model은 영향받지 않아야 한다. 역직렬화 시 외부에서 들어오는 데이터는 신뢰하지 않는다. 외부 경계(Trust Boundary)에서 입력 검증을 수행하고, 검증된 데이터만 Domain Model로 변환한다. 역직렬화된 객체를 검증 없이 Domain Model로 직접 매핑하는 것은 외부 입력을 도메인 내부로 그대로 통과시키는 것이다.

3) Schema Evolution (스키마 진화)

시스템은 시간이 지남에 따라 데이터 구조가 변경된다. 스키마 변경은 하위 호환성을 의식적으로 관리해야 한다. 특히 이벤트 소싱, 메시지 큐, 외부 API를 사용하는 환경에서는 스키마 변경이 기존 데이터와 소비자에게 미치는 영향을 항상 먼저 검토한다.

스키마를 진화시킬 때는 다음 원칙을 따른다.

  • 추가는 안전하다: 새로운 필드 추가는 일반적으로 하위 호환이 가능하다.
  • 삭제와 이름 변경은 위험하다: 기존 소비자가 해당 필드에 의존할 수 있다. Deprecated 단계를 거쳐 점진적으로 제거한다.
  • 타입 변경은 가장 위험하다: 기존 데이터와 새로운 스키마가 충돌할 수 있다. 타입 변경은 새로운 필드를 추가하고 기존 필드를 단계적으로 마이그레이션하는 방식을 사용한다.
  • 버전 관리: API 스펙이나 이벤트 스키마가 변경될 때는 버전을 명시적으로 관리한다.

동시성 (Concurrency)

1) Thread Safety를 설계 단계에서 결정한다

Thread safety는 나중에 추가하기 어렵다. 클래스나 함수를 설계할 때 동시성 환경에서의 사용 여부를 먼저 명확히 결정하고, 그 결정을 문서화한다. “이 객체는 단일 스레드에서만 사용된다”, “이 메서드는 thread-safe하다” 같은 사실은 코드를 읽는 사람이 추론하게 두지 말고 명시한다. Thread safety를 확보하는 가장 효과적인 방법은 공유 가변 상태를 없애는 것 이다. 불변 객체는 별도의 동기화 없이 thread-safe하다.

2) Lock 정책

Lock은 동시성 문제를 해결하는 수단이지만, 잘못 사용하면 데드락(deadlock), 성능 저하, 기아 상태(starvation)를 유발한다. Lock을 사용해야 할 때는 다음 원칙을 따른다.

  • Lock의 범위를 최소화한다: Lock을 유지하는 시간이 길수록 병목이 된다. Lock 범위 안에서는 도메인 연산만 수행하고, 외부 I/O(DB, 네트워크 호출)는 Lock 범위 밖으로 꺼낸다.
  • Lock 순서를 일관되게 유지한다: 여러 Lock을 동시에 획득해야 할 때 항상 동일한 순서로 획득한다. 순서가 다르면 데드락이 발생할 수 있다.
  • Lock보다 더 나은 대안을 먼저 검토한다: 불변 객체, 스레드 로컬 상태, 원자적 연산(atomic operation), 락프리(lock-free) 자료구조를 먼저 고려한다.

3) Actor / Message Passing 모델

공유 메모리 기반 동시성의 대안으로, Actor 모델 또는 Message Passing 방식을 활용할 수 있다. 이 방식은 상태를 각 Actor가 독립적으로 소유하고, Actor 간 통신은 메시지를 통해서만 이루어지므로 공유 가변 상태 자체가 발생하지 않는다. 분산 시스템이나 높은 동시성이 요구되는 환경에서는 이 모델이 Lock 기반 동시성보다 더 안전하고 예측 가능한 설계를 가능하게 한다. 단, 도입 시 팀의 운용 경험과 시스템의 실제 복잡도를 먼저 검토한다. 단순한 시스템에 Actor 모델을 도입하는 것은 과도한 아키텍처(Over Engineering)가 될 수 있다.

트랜잭션 보장 (Transaction Guarantee)

트랜잭션 범위는 최소화한다. 트랜잭션이 길수록 Lock을 오래 유지하고, 동시 처리 성능이 저하되며, 데드락 가능성이 높아진다. 트랜잭션은 비즈니스 규칙상 원자적으로 처리되어야 하는 최소 단위에만 적용한다.

트랜잭션 내부에서 외부 I/O를 실행하지 않는다. 트랜잭션 내에서 외부 API 호출, 메시지 발행, 이메일 전송을 실행하면 외부 시스템의 응답 시간만큼 Lock이 유지된다. 외부 I/O는 트랜잭션 커밋 이후에 실행한다. 트랜잭션 커밋 직후 외부 API 호출이 실패하는 경우를 위해, 외부 발행 전 Outbox 패턴(DB에 발행 예정 이벤트를 기록하고 별도 프로세스가 발행)을 고려한다.

Application Layer가 트랜잭션 경계를 결정한다. 트랜잭션 경계는 도메인 규칙이 아니라 Use Case의 원자성 요구사항이 결정한다. Domain Layer는 트랜잭션을 모른다. Domain Model 내부에서 트랜잭션을 시작하거나 커밋하는 것은 계층 위반이다.

분산 트랜잭션이 필요한 설계는 재검토한다. 여러 서비스나 DB에 걸친 ACID 트랜잭션은 구현과 운용 복잡도가 매우 높다. 분산 트랜잭션이 필요한 상황이라면 먼저 Aggregate 경계가 올바른지 점검한다. 불가피하게 여러 서비스를 조율해야 한다면 Saga 패턴 을 사용한다. Saga는 각 서비스가 로컬 트랜잭션을 실행하고, 실패 시 이전 단계를 취소하는 보상 트랜잭션(compensating transaction) 을 순차적으로 실행한다.

데이터 일관성 (Data Consistency)

일관성 수준을 의식적으로 선택한다. 모든 데이터가 항상 강한 일관성(strong consistency)을 가져야 하는 것은 아니다. 강한 일관성은 성능과 가용성을 희생한다. 결과적 일관성(eventual consistency)은 일시적인 불일치를 허용하되 최종적으로 일관성이 보장된다. 어느 쪽을 선택하든, 그 선택은 명시적이어야 한다. “이 데이터는 일시적으로 낡을 수 있다”는 사실을 암묵적으로 숨기는 것이 가장 위험하다.

결과적 일관성을 선택했다면 불일치 기간을 설계한다. 결과적 일관성은 “언젠가는 일관성이 맞춰진다”는 막연한 기대가 아니라, 불일치가 허용되는 최대 시간과 일관성 복구 메커니즘을 코드 수준에서 명시해야 한다.

  • 이벤트 기반 동기화: 상태 변경이 이벤트로 발행되고, 다른 서비스가 이를 구독해 자신의 상태를 업데이트한다. 이벤트 소비가 실패했을 때의 재처리 정책과 중복 처리 방지(멱등성)를 함께 설계한다.
  • 보상 트랜잭션: 분산 환경에서 일부 단계가 실패했을 때 이미 완료된 단계를 취소하는 로직을 미리 구현한다. 보상 로직이 없는 Saga는 반쪽짜리 설계다.
  • 읽기 전용 복제본의 지연: 읽기 전용 복제본(read replica)은 쓰기 원본보다 데이터가 늦게 반영된다. 방금 쓴 데이터를 읽기 복제본에서 즉시 읽어야 하는 경우(read-your-writes 일관성)에는 원본에서 읽도록 분기한다.

Optimistic Locking과 Pessimistic Locking을 구분해 사용한다. 충돌 가능성이 낮고 성능이 중요한 경우 낙관적 잠금(version 필드로 충돌 감지) 을 사용한다. 충돌이 빈번하거나 충돌 비용이 크면 비관적 잠금(선점 Lock) 을 사용한다. 낙관적 잠금 충돌 시 재시도 전략을 함께 설계한다. 충돌을 감지하고도 사용자에게 의미 있는 피드백 없이 조용히 덮어쓰는 것은 데이터 손실이다.

상태 설계의 금지 패턴

Anemic Domain Model 은 데이터만 있고 행동이 없는 객체다. 데이터 클래스만 놓고 비즈니스 로직을 Service에 모두 몰아넣으면, 도메인 규칙이 코드 전반에 흩어지고 객체의 상태가 외부에서 자유롭게 조작된다. 상태와 행동은 함께 있어야 한다.

무분별한 Setter 노출 은 캡슐화를 무너뜨린다. Setter가 모든 필드에 열려 있으면 객체의 상태를 누구든 어디서든 변경할 수 있게 되어, 상태 변경의 추적이 불가능해진다. 상태 변경은 의미 있는 도메인 행동 메서드를 통해서만 이루어져야 한다.

Silent State Mutation 은 이름에서 상태를 변경한다는 것을 알 수 없는 함수가 내부적으로 상태를 변경하는 패턴이다. 상태를 변경하는 메서드는 이름에서 그 사실이 드러나야 하며, 값을 반환하는 것처럼 보이는 함수가 부수적으로 상태를 변경하는 것은 가장 위험한 숨겨진 부수효과다.

7. 에러 처리 (Error Handling)

에러는 도메인의 일부다. 에러 처리는 코드의 부속품이 아니라 시스템 설계의 핵심이다. 어떤 에러가 발생할 수 있는지, 그것이 복구 가능한지, 누가 책임지는지를 명확히 정의하는 것은 비즈니스 규칙만큼 중요한 설계 결정이다. 에러를 잘못 다루면 두 가지 방향으로 실패한다. 에러를 침묵시키면 문제가 숨어있다가 훨씬 나중에, 훨씬 이해하기 어려운 형태로 터진다. 에러를 과도하게 전파하면 호출 스택 전체가 에러 처리 로직으로 오염되고, 정상 흐름(happy path)이 에러 처리 코드 속에 묻힌다. 목표는 에러가 발생한 지점에서 의미 있게 처리되고, 정상 흐름이 코드의 주인공으로 읽히는 구조를 만드는 것이다.

에러의 세 가지 분류

에러를 일관되게 다루기 위해 먼저 세 가지 유형으로 명확히 구분한다. 이 구분은 에러를 어떻게 처리할지 결정하는 기준이 된다.

1) Assertion (프로그래머 가정)

Assertion은 “이 시점에는 반드시 이 조건이 참이어야 한다”는 프로그래머의 불변식 (invariant)을 선언하는 수단이다. 외부 입력을 검증하는 용도가 아니라, 코드 내부의 논리적 일관성을 확인하는 용도로만 사용한다. Assertion은 개발/테스트 환경에서 프로그래머의 실수를 조기에 발견하기 위한 도구다. 프로덕션 환경의 외부 입력 검증 수단으로 사용하면 안 된다. Assertion이 실패했다면 그것은 비즈니스 에러가 아니라 코드 자체의 버그를 의미한다.

2) Validation (외부 입력 검증)

Validation은 시스템 경계(Trust Boundary)에서 외부로부터 들어오는 입력이 기대하는 형식과 범위를 만족하는지 검사하는 것이다. 사용자의 입력, API 요청, 외부 시스템의 데이터가 여기에 해당한다. Validation은 예외가 아닌 명시적인 검증 결과 로 처리한다. 사용자가 잘못된 값을 입력하는 것은 예외적인 상황이 아니라 예상 가능한 시나리오이며, 사용자에게 명확한 피드백을 돌려줘야 한다.

3) Domain Error (비즈니스 실패)

Domain Error는 비즈니스 규칙이 위반되었을 때 발생하는 에러다. “재고가 부족한 상태에서 주문을 시도함”, “이미 취소된 주문을 다시 취소하려 함”처럼 도메인 규칙상 허용되지 않는 상태 전이가 이에 해당한다. Domain Error는 프로그래머의 실수도, 시스템 장애도 아니다. 비즈니스 흐름의 일부로 명시적으로 모델링하고, 호출하는 쪽이 반드시 처리하도록 강제해야 한다.

예외(Exception) 사용 기준

예외는 진짜 예외적인 상황, 즉 정상적인 프로그램 흐름에서는 발생해서는 안 되는 상황에만 사용한다. 예측 가능한 비즈니스 분기를 예외로 처리하는 것은 예외를 흐름 제어 수단으로 남용하는 것이며, 성능 저하와 가독성 저하를 동시에 초래한다.

예외를 사용하기에 적절한 상황은 다음과 같다.

  • 시스템이 복구할 수 없는 상태 (메모리 부족, 필수 리소스 접근 불가)
  • 프로그래머의 실수로 발생한 계약 위반 (잘못된 인자, null이 허용되지 않는 곳에 null 전달)
  • 인프라 수준의 장애 (DB 연결 실패, 외부 API 타임아웃)

반면 다음 상황은 예외가 아닌 다른 수단으로 처리한다.

  • 사용자 입력 오류 → Validation 결과 반환
  • 비즈니스 규칙 위반 → Domain Error 또는 Result 타입 반환
  • 존재하지 않는 리소스 조회 → Optional/Result 반환

예외를 던질 때는 반드시 적절한 예외 객체를 사용한다. 단순 문자열이나 기본 타입을 throw하면 스택 트레이스가 사라져 디버깅이 극도로 어려워진다.

Result / Either 사용 기준

Result 타입(또는 Either<Error, Value>)은 실패 가능성을 타입으로 표현하는 수단이다. 함수가 성공할 수도, 실패할 수도 있다는 사실을 반환 타입 자체에 드러냄으로써, 호출하는 쪽이 반드시 실패 케이스를 처리하도록 타입 시스템이 강제한다.

Result 타입은 다음 상황에서 예외보다 적합하다.

  • 예측 가능한 비즈니스 실패: 발생 가능성이 알려져 있고, 호출하는 쪽이 반드시 처리해야 하는 Domain Error
  • 흐름 제어가 필요한 실패: 실패 시 대안 로직을 실행하거나 복구를 시도해야 하는 경우
  • 합성 가능한 연산: 여러 실패 가능한 연산을 체이닝할 때 예외보다 선형적으로 읽힘

언어별로 Result 타입을 표현하는 방법이 다르므로 구체적인 구현은 언어별 상세 가이드를 따른다. 실패 가능성이 타입으로 드러나고 처리가 강제되는 것이 핵심이다.

복구 가능(Recoverable) / 불가능(Unrecoverable) 구분

에러를 처리하기 전에 반드시 먼저 결정해야 할 것은, 이 에러가 복구 가능한가 불가능한가이다.

  • 복구 가능한 에러 는 시스템이 정상 상태를 유지하면서 대응 가능한 에러다. 비즈니스 규칙 위반, 사용자 입력 오류, 일시적인 외부 시스템 장애가 여기에 해당한다. 이런 에러는 호출 스택 어딘가에서 명시적으로 처리하고, 적절한 피드백이나 대안 흐름으로 이어져야 한다.
  • 복구 불가능한 에러 는 시스템이 정상적으로 계속 동작할 수 없는 상태다. 필수 설정값 누락, 데이터 무결성 손상, 복구 불가능한 인프라 장애가 여기에 해당한다. 복구 불가능한 에러는 즉시 실패하고(fail-fast), 충분한 컨텍스트와 함께 로그를 남긴 후 시스템을 중단하거나 상위 레이어에 위임한다.

Fail-Fast 정책

Fail-Fast 는 잘못된 상태를 발견한 즉시 명시적으로 실패하는 원칙이다. 잘못된 상태를 감지했음에도 계속 진행하면, 에러가 훨씬 나중에 훨씬 이해하기 어려운 형태로 드러난다.

Fail-Fast가 적용되어야 하는 상황은 다음과 같다.

  • 생성자나 팩토리 메서드에서 유효하지 않은 입력을 받았을 때
  • 필수 의존성이 null이거나 초기화되지 않았을 때
  • 프로그래머가 가정한 불변식이 위반되었을 때

잘못된 값을 기본값으로 대체하거나 조용히 무시하는 것은 문제를 숨기는 가장 위험한 패턴이다. 에러는 침묵하지 않는다.

Logging 기준

로그는 디버깅 도구이기 이전에 시스템의 상태를 기록하는 문서 다. 로그를 무분별하게 남기면 정작 중요한 정보를 노이즈 속에서 찾을 수 없게 되고, 로그를 너무 적게 남기면 장애 상황에서 원인을 추적할 수 없다. 로그 레벨은 다음 기준으로 구분하여 일관되게 사용한다.

레벨사용 기준예시
ERROR즉각적인 대응이 필요한 복구 불가능한 실패DB 연결 실패, 외부 API 연결 불가
WARN정상 동작하지만 주의가 필요한 상황재시도 발생, 느린 쿼리, Deprecated API 호출
INFO시스템의 주요 상태 변경주문 생성, 결제 완료, 서비스 시작/종료
DEBUG개발/진단 목적의 상세 흐름함수 진입/종료, 중간 계산값

로그 메시지에는 무슨 일이 발생했는지, 어떤 컨텍스트(식별자, 입력값)에서 발생했는지를 반드시 포함한다. “에러 발생”, “실패”처럼 컨텍스트 없는 메시지는 아무런 도움이 되지 않는다. 예외를 로그로 기록할 때는 예외 객체를 반드시 함께 전달하여 스택 트레이스가 기록되게 한다. 스택 트레이스 없는 예외 로그는 발생 위치를 알 수 없어 디버깅이 불가능하다.

같은 에러를 여러 레이어에서 중복으로 로그하지 않는다. 예외를 catch하여 로그를 남기고, 다시 throw하면 같은 에러가 스택 전체에서 중복 기록된다. 로그는 에러를 최종 처리하는 지점에서 한 번만 남긴다.

사용자 오류(User Error) vs 시스템 오류(System Error)

에러를 외부로 노출할 때 사용자 오류와 시스템 오류를 반드시 구분한다.

  • 사용자 오류 는 사용자의 잘못된 입력이나 허용되지 않는 행동에서 비롯된다. 사용자 오류의 응답에는 무엇이 잘못되었는지, 어떻게 수정해야 하는지 를 명확하고 이해하기 쉬운 언어로 전달한다.
  • 시스템 오류 는 인프라 장애, 프로그래머 실수, 예상치 못한 상태에서 비롯된다. 시스템 오류의 응답에는 내부 구현 세부사항, 스택 트레이스, 시스템 경로 등 보안에 민감한 정보를 절대 노출하지 않는다. 사용자에게는 추적 가능한 에러 ID만 제공하고, 상세 정보는 내부 로그에만 기록한다.

에러 처리 금지 패턴

  • Silent Failure 는 예외를 catch한 후 아무것도 하지 않거나, 의미 없는 기본값으로 대체하는 패턴이다. 문제가 발생했다는 사실을 숨기고, 에러가 훨씬 나중에 전혀 다른 곳에서 이해하기 어려운 형태로 드러나게 만든다. 에러는 어떤 경우에도 침묵해서는 안 된다.
  • 예외를 흐름 제어에 사용하는 것 은 성능 저하와 가독성 저하를 동시에 초래한다. “존재 여부 확인”을 예외로 구현하는 것이 대표적인 사례다.

8. 비동기 처리 (Async & Concurrency)

비동기는 복잡성을 수반한다. 비동기 처리는 성능과 응답성을 높이는 강력한 수단이지만, 동시에 코드의 복잡성을 크게 높이는 영역이다. 실행 순서가 보장되지 않고, 에러가 예상치 못한 시점에 발생하며, 리소스 누수와 경쟁 조건(race condition)이 눈에 보이지 않게 숨어 있을 수 있다.

비동기 코드에서 발생하는 버그는 재현이 어렵고, 디버깅이 오래 걸리며, 프로덕션에서만 나타나는 경우가 많다. 비동기를 도입할 때는 “이 비동기 처리가 실패하면 어떻게 되는가”, “취소되면 어떻게 되는가”, “타임아웃이 발생하면 어떻게 되는가” 를 반드시 설계 단계에서 결정해야 한다. 재시도, 멱등성, 취소 처리를 나중에 붙이는 것은 가장 흔한 실패 패턴 중 하나다.

Async/Await 원칙

1) 비동기는 일관되게 전파한다

비동기 함수는 호출 스택 전체에 걸쳐 일관되게 비동기로 처리해야 한다. 비동기 함수 내에서 동기 블로킹 호출을 수행하면 비동기의 이점이 사라지고, 스레드 풀을 고갈시키는 원인이 된다. 비동기 컨텍스트에서 동기 블로킹을 수행하는 것은 가장 위험한 안티패턴 중 하나다.

2) 비동기 흐름은 명시적이고 선형적으로 유지한다

비동기 코드는 실행 흐름이 분기되고 뒤엉키기 쉽다. 가능한 한 async/await를 사용하여 코드를 동기 코드처럼 위에서 아래로 선형적으로 읽히게 유지한다. 콜백 중첩(callback hell)은 비동기 코드를 이해하기 어렵게 만드는 가장 직접적인 원인이므로 피한다. 비동기 함수는 3섹션에서 다룬 네이밍 원칙에 따라 비동기임을 이름에서 명확히 드러낸다. 언어와 팀 컨벤션에 따라 Async 접미사를 붙이거나, 반환 타입으로 비동기 여부를 충분히 표현한다.

3) 비동기 에러는 반드시 처리한다

비동기 에러는 동기 코드와 달리 호출하는 쪽에 자동으로 전파되지 않는 언어와 런타임이 있다. 처리되지 않은 비동기 에러(unhandled promise rejection, unobserved task exception 등)는 조용히 사라져 디버깅을 불가능하게 만든다. 모든 비동기 작업의 에러는 반드시 명시적으로 처리하거나, 전역 에러 핸들러에서 최종적으로 포착되도록 보장한다.

취소(Cancellation)

취소는 선택 사항이 아니라 비동기 작업의 필수 설계 요소다. 사용자가 요청을 취소하거나, 타임아웃이 발생하거나, 상위 작업이 실패했을 때 진행 중인 비동기 작업은 정리되어야 한다. 취소를 고려하지 않으면 불필요한 작업이 계속 실행되고, 리소스가 낭비되며, 이미 의미가 없어진 결과가 시스템 상태를 변경하는 문제가 발생한다.

언어가 제공하는 취소 메커니즘(CancellationToken, AbortSignal, coroutine cancellation 등)을 적극 활용한다. 취소 신호를 전파받은 작업은 현재 작업을 정리하고, 보유한 리소스를 반환한 후 조용히 종료해야 한다.

취소와 실패는 다르다. 취소는 예외적인 오류가 아니라 정상적인 흐름의 일부이며, 취소로 인한 종료는 에러 로그가 아닌 적절한 수준의 로그로 처리한다.

타임아웃(Timeout)

모든 외부 시스템 호출에는 타임아웃을 설정한다. 타임아웃 없이 외부 API, DB, 네트워크 호출을 수행하면 외부 시스템의 응답 지연이 자신의 시스템 전체를 마비시킬 수 있다. 하나의 느린 외부 호출이 스레드 풀을 고갈시키고, 연쇄적으로 다른 요청들을 실패하게 만드는 것이 Cascading Failure의 가장 흔한 원인이다.

타임아웃 값은 코드에 하드코딩하지 않고 설정으로 관리한다. 서비스와 환경에 따라 적절한 타임아웃이 다르며, 실제 측정값과 SLA를 기반으로 설정한다.

타임아웃 발생 시 처리 방침을 명확히 정의한다. 단순히 에러를 반환하는 것이 적절한지, 재시도가 필요한지, 폴백(fallback) 응답을 제공해야 하는지를 설계 단계에서 결정한다.

재시도(Retry)

일시적인 장애(transient failure)는 대부분의 분산 시스템에서 피할 수 없다. 네트워크 순간 단절, 일시적인 DB 부하, 외부 API의 일시적 과부하는 재시도로 해결될 수 있다. 단, 재시도는 잘못 설계하면 장애를 악화시키는 원인이 된다.

1) 재시도 전에 반드시 멱등성을 확인한다

재시도 가능한 작업은 멱등(idempotent) 해야 한다. 같은 요청을 여러 번 실행해도 동일한 결과가 보장되지 않으면, 재시도는 중복 처리, 중복 결제, 중복 발송 같은 심각한 문제를 만든다. 멱등성이 보장되지 않는 작업에는 재시도를 적용하지 않거나, 멱등성 키를 사용하여 중복 처리를 방지한다.

2) Exponential Backoff와 Jitter를 사용한다

즉시 재시도를 반복하면 이미 부하 상태인 외부 시스템에 폭발적인 트래픽을 쏟아붓게 된다. 재시도 간격은 지수적으로 증가(Exponential Backoff) 시키고, 여러 클라이언트가 동시에 재시도하는 것을 방지하기 위해 Jitter(무작위 오차) 를 추가한다.

3) 재시도 횟수와 중단 조건을 명확히 정의한다

재시도는 무한히 반복하지 않는다. 최대 재시도 횟수를 명확히 정의하고, 모든 재시도가 실패했을 때의 처리 방침(에러 반환, 폴백, Dead Letter Queue 전송)을 설계 단계에서 결정한다. 모든 재시도가 실패한 후의 처리는 재시도 로직만큼 중요하다. 재시도해서는 안 되는 에러를 구분한다. 인증 실패(401), 권한 없음(403), 잘못된 요청(400) 같은 클라이언트 에러는 재시도로 해결되지 않는다. 재시도는 일시적인 서버/인프라 오류에만 적용한다.

배압(Backpressure)

Backpressure 는 소비자(consumer)가 처리할 수 있는 속도보다 생산자(producer)가 빠르게 데이터를 생성할 때, 소비자가 생산자의 속도를 제어하는 메커니즘이다. Backpressure 없이 무한히 데이터를 받아들이면 메모리가 고갈되고 시스템 전체가 응답 불가 상태에 빠진다.

스트리밍, 이벤트 처리, 메시지 큐 소비 등 데이터 흐름이 있는 모든 구조에서 Backpressure를 고려한다. 처리 속도를 초과하는 데이터에 대한 정책을 명확히 결정한다.

  • 버퍼링(Buffering): 처리 가능할 때까지 일정량을 대기열에 보관. 버퍼 크기와 버퍼 초과 시 정책을 명시한다.
  • 드로핑(Dropping): 처리 불가능한 메시지를 버린다. 어떤 메시지를 버릴지 기준이 명확해야 하며, 드롭된 메시지는 반드시 로그로 기록한다.
  • 생산 속도 제한: 소비자가 처리 가능한 속도로 생산자를 직접 제한한다.

동시성 제한(Concurrency Limit)

무제한 동시 실행은 리소스를 고갈시킨다. 특히 외부 시스템 호출, DB 쿼리, 파일 I/O는 동시 실행 수를 제한하지 않으면 연결 풀 고갈, 외부 API Rate Limit 초과, 메모리 압박으로 이어진다. 동시성 제한은 시스템의 용량 설계와 직결된다. 다음 상황에서는 반드시 동시성 한도를 설정한다.

  • 외부 API 호출: Rate Limit이 있는 외부 API는 동시 요청 수와 초당 요청 수를 제한한다.
  • DB 쿼리: 동시 쿼리 수가 커넥션 풀 크기를 초과하면 대기가 발생하고 타임아웃으로 이어진다.
  • 대용량 데이터 처리: 대량 배치 처리는 메모리와 CPU 사용량을 고려하여 동시 처리 단위를 제한한다.

동시성 한도는 설정으로 관리하고, 실제 측정값을 기반으로 조정한다. 처음부터 최적값을 알 수는 없으므로, 보수적으로 시작하고 모니터링 결과에 따라 조정한다.

Failure Semantics (실패 의미론)

메시지 전달 보장 수준을 명시한다. 비동기 통신에서 “메시지를 보냈다”와 “메시지가 처리되었다”는 다르다. 시스템이 어떤 전달 보장을 제공하는지를 설계 시점에 결정하고, 코드와 운영 정책 모두 이에 맞춰야 한다.

  • At-most-once: 메시지가 유실될 수 있지만 중복 처리는 없다. 로그성 이벤트처럼 일부 유실이 허용되는 경우에 적합하다.
  • At-least-once: 메시지가 반드시 처리되지만 중복 전달될 수 있다. 재시도 기반 시스템의 기본값이다. 소비자 측 멱등성이 반드시 보장되어야 한다.
  • Exactly-once: 정확히 한 번 처리를 보장한다. 구현 복잡도가 가장 높고, 실제로는 at-least-once + 멱등 소비자로 대체하는 경우가 많다.

Partial Failure(부분 실패)를 명시적으로 처리한다. 분산 환경에서는 일부 단계는 성공하고 나머지는 실패하는 상황이 언제든 발생한다. 부분 실패를 무시하거나 단순히 로그만 남기는 것은 데이터 불일치로 이어진다. 각 비동기 작업에 대해 “이 단계가 실패했을 때 시스템은 어떤 상태가 되는가”를 설계 단계에서 명시하고, 복구 경로(재시도, 보상, 알림)를 코드에 구현한다.

Dead Letter Queue(DLQ)는 무시하지 않는다. 재시도를 모두 소진한 실패 메시지는 DLQ로 이동한다. DLQ는 조용한 데이터 손실을 막는 마지막 안전망이다. DLQ에 메시지가 쌓이면 알람이 울려야 하고, 담당자가 원인을 분석하고 재처리하는 프로세스가 있어야 한다. DLQ를 만들고 모니터링하지 않는 것은 쓰레기통을 만들고 비우지 않는 것과 같다.

비동기 처리 설계 시 피해야 할 것들

  • Fire-and-Forget의 남용 은 비동기 작업을 실행하고 결과를 전혀 추적하지 않는 패턴이다. 작업이 실패해도 알 수 없고, 리소스가 누수되어도 감지할 수 없다. Fire-and-Forget이 필요한 경우에도 에러는 반드시 포착하여 로그를 남기고, 작업의 생명주기를 추적할 수 있어야 한다.
  • 동시성 제어 없는 공유 상태 변경 은 레이스 컨디션의 직접적인 원인이다. 여러 비동기 작업이 동일한 가변 상태에 동시에 접근한다면, 그 상태는 반드시 동기화되거나, 불변으로 만들거나, 단일 작업만 접근하도록 설계해야 한다.
  • 비동기에서의 동기 블로킹 은 스레드 풀 고갈의 원인이 된다. 비동기 컨텍스트에서 .Result, .Wait(), Thread.Sleep() 같은 동기 블로킹 호출은 데드락과 성능 저하를 유발한다.
  • 멱등성 없는 재시도 설계 는 나중에 붙이면 안 된다. 재시도, 멱등성, 취소 처리는 기능 구현 후 추가하는 것이 아니라 처음 설계할 때부터 고려해야 한다. 이것을 나중으로 미루는 것은 반드시 문제가 되어 돌아온다.

9. API 설계 (API Design)

API는 계약이다. API는 호출하는 쪽과 구현하는 쪽 사이의 계약(contract) 이다. 계약은 한번 공개되면 변경 비용이 높아지고, 소비자가 많을수록 변경은 더 어려워진다. 좋은 API는 처음부터 명확한 의도를 드러내고, 잘못된 사용이 어렵게 설계되며, 변경이 필요할 때 소비자에게 최소한의 영향을 준다.

API 설계는 내부 구현을 어떻게 숨기느냐의 문제이기도 하다. 잘 설계된 API는 내부 구현이 바뀌어도 소비자가 영향받지 않도록 충분한 추상화 경계를 제공한다. API 경계 너머로 내부 구현 세부사항이 새어나오는 순간, 그 세부사항은 계약의 일부가 되어 변경을 어렵게 만든다.

함수 API

1) 입력과 출력을 명확하게 설계한다

함수의 시그니처는 그 자체로 문서여야 한다. 함수 이름, 파라미터 타입, 반환 타입만 보고도 무엇을 받아서 무엇을 돌려주는지, 실패할 수 있는지를 파악할 수 있어야 한다. 시그니처를 이해하기 위해 구현을 열어봐야 한다면 설계를 재검토한다.

  • 입력 은 가능한 한 구체적인 도메인 타입을 사용한다. String, Long 같은 기본 타입보다 OrderId, Email, Money 같은 Value Object를 파라미터로 받으면, 호출하는 쪽이 잘못된 값을 전달하는 것을 컴파일 타임에 방지할 수 있다.
  • 출력 은 실패 가능성을 타입으로 드러낸다. 실패할 수 있는 함수는 예외로만 실패를 표현하는 것보다, Result<T, E>, Optional<T> 등으로 반환 타입에서 실패 가능성을 명시하여 호출하는 쪽이 반드시 처리하도록 강제한다.

2) 부수효과(Side Effect)를 명시적으로 드러낸다

함수 API에서 가장 중요한 계약 중 하나는 부수효과의 존재 여부 다. 값을 계산하는 것처럼 보이는 함수가 내부에서 DB에 쓰거나 외부 API를 호출한다면, 그것은 숨겨진 부수효과다. 명령(Command)과 조회(Query)를 명확히 분리한다. 상태를 변경하는 함수는 상태를 변경한다는 사실이 이름에서 드러나야 하며, 값을 반환하는 함수는 부수효과 없이 순수하게 값을 돌려줘야 한다. 이 원칙을 Command Query Separation(CQS) 라 하며, API를 예측 가능하게 만드는 핵심 원칙이다.

3) Optional Parameter 기준

선택적 파라미터는 신중하게 사용한다. 선택적 파라미터가 많아지면 함수가 여러 가지 모드로 동작하기 시작하고, 각 조합마다 동작이 다를 수 있어 이해와 테스트가 어려워진다. 선택적 파라미터가 3개 이상 필요하다면 파라미터 객체 패턴(Parameter Object) 을 검토한다. 관련 파라미터를 하나의 객체로 묶으면 호출 코드가 명확해지고, 향후 파라미터가 추가되어도 기존 호출 코드를 변경하지 않아도 된다. 서로 다른 동작 모드가 필요하다면, 선택적 파라미터 하나로 동작을 분기하는 대신 의미가 명확한 별도의 함수를 제공하는 것이 낫다.

네트워크 API

1) REST / gRPC 기준

네트워크 API의 방식(REST, gRPC, GraphQL 등)은 팀과 시스템의 요구사항에 따라 결정한다. 어떤 방식을 선택하든 다음 원칙은 공통으로 적용된다.

  • 도메인 개념을 URI와 메서드로 표현한다: REST API에서 리소스는 도메인 개념을 반영해야 하며, HTTP 메서드는 행위의 의미를 충실히 따른다. URI에 동사를 사용하지 않고, 리소스는 명사로 표현한다.
  • 도메인 행동을 명확히 표현한다: 단순 CRUD로 표현하기 어려운 도메인 행동은 의미 있는 서브리소스나 액션 엔드포인트로 표현한다.

2) Versioning

API는 한번 공개되면 소비자가 생기고, 소비자가 있는 한 함부로 변경할 수 없다. 버전 관리 전략은 API를 처음 설계할 때부터 결정하고 적용한다. 변경이 필요한 이후에 버전 전략을 세우는 것은 이미 늦다. 하위 호환되는 변경(backward compatible)과 파괴적 변경(breaking change)을 명확히 구분한다.

변경 유형예시처리 방법
하위 호환 가능선택적 필드 추가, 새 엔드포인트 추가기존 버전 유지, 바로 적용
파괴적 변경필드 삭제/이름 변경, 타입 변경, 동작 변경새 버전으로 분리, 이전 버전 Deprecation 후 단계적 제거

Deprecated된 API는 충분한 유예 기간을 두고 단계적으로 제거한다. 단, 기존 API를 영원히 유지하는 것도 기술 부채다. Deprecated 시점부터 제거 일정을 명시적으로 공개하고, 소비자의 마이그레이션을 적극적으로 지원한다.

3) Idempotency (멱등성)

네트워크는 신뢰할 수 없다. 클라이언트는 응답을 받지 못했을 때 요청을 재시도한다. 재시도가 중복 처리를 일으키면 중복 결제, 중복 발송, 중복 생성 같은 심각한 문제가 발생한다. 상태를 변경하는 모든 API는 멱등성을 고려해야 한다. GET, PUT, DELETE는 본질적으로 멱등하게 설계할 수 있지만, POST는 명시적인 멱등성 처리가 필요하다. Idempotency Key 를 헤더로 받아 같은 키의 요청이 재시도될 때 동일한 결과를 반환하고 중복 처리하지 않도록 설계한다. 멱등성 처리는 기능 구현 후 추가하는 것이 아니라 처음 설계할 때 반드시 고려한다. 나중에 추가하면 기존 데이터와 처리 방식을 모두 고려해야 하므로 비용이 기하급수적으로 높아진다.

4) Pagination

목록을 반환하는 API는 반드시 페이지네이션을 제공한다. 페이지네이션 없는 목록 API는 데이터가 증가할수록 응답 크기가 무한정 늘어나고, 결국 타임아웃과 메모리 고갈로 이어진다. 페이지네이션은 기능이 안정된 후 추가하는 것이 아니라, 처음 API를 설계할 때 기본으로 포함한다. 페이지네이션 방식은 사용 목적에 따라 선택한다.

  • Offset 기반 페이지네이션 은 구현이 간단하고 특정 페이지로 이동이 쉽지만, 데이터가 추가되거나 삭제될 때 페이지 경계가 흔들리는 문제가 있다. 대량 데이터에서 깊은 오프셋은 DB 성능에 심각한 영향을 준다.
  • Cursor 기반 페이지네이션 은 마지막으로 받은 항목의 커서를 기반으로 다음 페이지를 가져온다. 데이터 추가/삭제 시 안정적이고, 대량 데이터에서도 성능이 일관되게 유지된다. 무한 스크롤처럼 연속적인 데이터 탐색에 적합하다.

5) Error Response 형식

에러 응답은 일관된 형식 을 유지한다. API마다 에러 형식이 다르면 클라이언트가 에러 처리 로직을 중복 구현해야 하고, 에러의 원인을 파악하기 어렵다. 에러 응답에는 다음 정보를 포함한다.

  • 에러 코드: 기계가 처리할 수 있는 식별자. HTTP 상태 코드만으로 원인을 특정하기 어려우므로 도메인 에러 코드를 별도로 제공한다.
  • 메시지: 사람이 읽을 수 있는 설명. 사용자에게 보여줄 메시지와 내부 진단용 메시지를 구분한다.
  • 추적 ID: 서버 로그와 연결할 수 있는 식별자. 내부 정보는 절대 노출하지 않는다.
  • 필드 오류: 입력 검증 실패 시 어떤 필드가 왜 실패했는지 구체적으로 전달한다.

HTTP 상태 코드는 의미에 맞게 정확히 사용한다. 모든 에러에 200을 반환하고 바디에서 에러를 표현하는 것은 HTTP를 단순 전송 수단으로만 사용하는 것이며, 클라이언트의 에러 감지를 어렵게 만든다.

상황HTTP 상태 코드
성공200 OK, 201 Created, 204 No Content
입력 오류, 도메인 규칙 위반400 Bad Request
인증 필요401 Unauthorized
권한 없음403 Forbidden
리소스 없음404 Not Found
멱등성 충돌409 Conflict
서버 오류500 Internal Server Error
외부 의존성 장애502 Bad Gateway, 503 Service Unavailable

API 설계 시 피해야 할 것들

  • 내부 구현 세부사항을 API로 노출하는 것 은 구현을 계약으로 만든다. DB 테이블 구조가 API 응답에 그대로 반영되거나, 내부 에러 메시지와 스택 트레이스가 응답에 포함되면, 내부 구현을 변경할 때 소비자가 영향을 받는다.
  • Deprecated API를 영원히 유지하는 것 은 기술 부채다. 기존 API를 깨뜨리지 않으려는 배려가 결국 신구 API를 병렬로 무한정 유지하는 부담으로 이어진다. Deprecated 시점부터 제거 일정을 명확히 공개하고, 소비자와 함께 마이그레이션을 진행한다.
  • 페이지네이션 없는 목록 API 는 처음에는 문제가 없어 보이지만, 데이터가 쌓이면 반드시 장애로 돌아온다. 목록 API는 처음부터 페이지네이션을 기본으로 설계한다.
  • 일관성 없는 에러 응답 형식 은 클라이언트 개발자의 불필요한 부담을 만든다. 팀 전체가 에러 응답 형식을 표준화하고, 모든 API에서 동일한 형식을 사용한다.

10. 의존성 관리 (Dependencies)

의존성은 부채다. 외부 라이브러리와 프레임워크는 개발 속도를 높이고 검증된 솔루션을 제공한다. 그러나 모든 의존성은 동시에 부채 이기도 하다. 의존성을 추가하는 순간, 그 라이브러리의 버그, 보안 취약점, 파괴적 업그레이드, 라이선스 변경, 프로젝트 유지 중단 가능성을 함께 떠안는다. 좋은 의존성 관리는 “필요한 것을 올바른 방식으로 사용하고, 그 의존성이 시스템 전체에 퍼지지 않도록 격리하는 것”이다. 의존성이 많을수록, 그것이 코드 전반에 깊게 퍼져있을수록 나중에 교체하거나 제거하는 비용은 기하급수적으로 높아진다.

외부 라이브러리 채택 기준

1) 라이브러리 도입 전에 반드시 자문한다

외부 라이브러리를 추가하기 전에 다음 질문에 답할 수 있어야 한다. 이 질문들에 명확히 답할 수 없다면 도입을 보류한다.

  • 이미 잘 만들어진 것을 직접 구현하려 하지 않는다. 인증(JWT, OAuth), 로깅, 캐시, 날짜/시간 처리, 페이징, 유효성 검사, 국제화처럼 검증된 라이브러리가 존재하는 문제를 직접 구현하는 것은 시간 낭비이자 잠재적 버그의 원인이다. 바퀴를 재발명하지 않는다.
  • 반대로 팀이 운용할 수 있는지 먼저 확인한다. 기술적으로 뛰어나더라도 팀이 운용 경험이 없는 라이브러리나 프레임워크를 도입하는 것은 위험하다. Kubernetes, Kafka, 이벤트 소싱 프레임워크처럼 운용 복잡도가 높은 기술은 팀의 역량과 시스템의 실제 요구사항을 먼저 냉정하게 평가한다.

2) 의존성의 범위를 최소화한다

라이브러리는 필요한 범위에서만 사용하고, 코드 전반에 퍼지지 않도록 격리한다. 특히 외부 라이브러리의 타입과 인터페이스가 Domain Layer까지 침투하면, 라이브러리를 교체하거나 제거할 때 도메인 코드 전체를 수정해야 하는 상황이 발생한다. 외부 라이브러리는 Adapter/Port 패턴으로 격리 한다. Domain Layer는 외부 라이브러리를 직접 의존하지 않고, Infrastructure Layer가 라이브러리를 사용하여 Domain이 정의한 인터페이스를 구현한다. 라이브러리가 바뀌어도 도메인 코드는 영향받지 않는다.

버전 정책

1) 버전은 명시적으로 고정한다

의존성 버전은 명시적으로 고정하고, 와일드카드(*, latest, ^, ~)로 자동 업그레이드되도록 두지 않는다. “최신 버전을 자동으로 사용한다”는 정책은 외부 라이브러리의 파괴적 변경이 언제든지 빌드를 깨뜨릴 수 있다는 의미이며, 재현 가능한(reproducible) 빌드를 불가능하게 만든다. 버전 업그레이드는 의도적이고 계획적으로 수행한다. 자동 업그레이드가 아닌 팀이 인식하고 검토한 후 업그레이드한다.

2) 업그레이드는 정기적으로, 작게 수행한다

버전 업그레이드를 오래 미루면 여러 버전의 변경사항이 누적되어 한 번에 업그레이드하는 비용이 기하급수적으로 높아진다. 의존성을 최신 상태에 가깝게 유지하되, 한 번에 하나씩 작은 단위로 업그레이드하고 각 업그레이드마다 테스트로 안전성을 검증한다. 메이저 버전 업그레이드는 파괴적 변경을 동반할 수 있으므로, 별도의 작업으로 분리하여 충분한 검토 후 진행한다.

Transitive Dependency 관리

Transitive Dependency 는 직접 의존하는 라이브러리가 또 다른 라이브러리에 의존하는 것이다. 직접 추가하지 않았지만 자동으로 포함되는 이 의존성들은 버전 충돌, 보안 취약점, 예상치 못한 동작의 원인이 된다. 주기적으로 의존성 트리를 확인하고, 직접 사용하지 않지만 포함되는 라이브러리 목록을 파악한다. 같은 라이브러리의 서로 다른 버전이 동시에 포함되는 버전 충돌이 발생하면 명시적으로 버전을 지정하여 해결한다. 불필요하게 포함되는 Transitive Dependency는 exclusion 설정으로 제거한다. 사용하지 않는 코드를 배포 산출물에 포함시키는 것은 공격 표면(attack surface)을 넓히고 빌드 크기를 불필요하게 늘린다.

# 의존성 트리 확인 (언어별 도구)
mvn dependency:tree          # Maven
gradle dependencies          # Gradle
npm ls                       # Node.js
pip show <package>           # Python
cargo tree                   # Rust

Lockfile 정책

Lockfile 은 의존성의 정확한 버전과 무결성 해시를 기록한 파일이다. Lockfile이 있으면 팀 전체와 CI/CD 환경에서 항상 동일한 버전의 의존성을 사용하는 것이 보장된다. Lockfile은 반드시 버전 관리(VCS)에 포함한다. Lockfile을 .gitignore에 추가하거나 커밋하지 않으면 환경에 따라 다른 버전의 의존성이 설치되어 “내 로컬에서는 되는데 CI에서는 안 된다”는 문제가 발생한다. Lockfile은 자동으로 업데이트되도록 두지 않는다. 의존성 버전 변경은 의도적인 행위이며, 코드 변경과 동일하게 리뷰 과정을 거쳐야 한다. Lockfile의 변경은 반드시 명시적인 업그레이드 커맨드 실행의 결과여야 한다.

Vendor 정책

Vendoring 은 외부 의존성의 소스 코드를 프로젝트 저장소에 직접 포함시키는 방식이다. 네트워크 없이 빌드가 가능하고, 외부 패키지 저장소의 장애나 패키지 삭제에 영향받지 않는 장점이 있다. Vendoring은 다음 상황에서 검토한다.

  • 외부 네트워크 접근이 제한된 폐쇄망 환경
  • 외부 패키지 저장소의 가용성을 보장할 수 없는 환경
  • 패키지 공급망 보안(supply chain security)이 중요한 환경

반면 Vendoring은 저장소 크기를 크게 늘리고, 의존성 업그레이드를 수동으로 관리해야 하는 부담이 있다. 대부분의 환경에서는 Lockfile과 패키지 저장소 캐시를 활용하는 것이 더 실용적이다. 팀의 환경과 보안 요구사항을 기준으로 Vendoring 여부를 결정하고, 결정한 정책을 일관되게 적용한다.

보안 검토 기준

1) 의존성 취약점은 코드 취약점과 동일하게 취급한다

외부 라이브러리의 보안 취약점은 직접 작성한 코드의 취약점과 동일한 위험을 초래한다. 오히려 외부 라이브러리의 취약점은 공개적으로 알려져 있어 공격자가 적극적으로 탐색하는 대상이다. 의존성 취약점 검사는 CI/CD 파이프라인에 자동화하여, 알려진 취약점(CVE)이 있는 의존성이 프로덕션에 배포되지 않도록 차단한다.

# 언어별 보안 취약점 검사 도구
npm audit                     # Node.js
pip-audit / safety            # Python
cargo audit                   # Rust
mvn org.owasp:dependency-check # Java
trivy                         # 컨테이너 및 다양한 언어 지원
snyk                          # 다양한 언어 지원, SaaS

2) 라이선스를 반드시 확인한다

외부 라이브러리의 라이선스는 사용 조건을 정의한다. GPL 같은 카피레프트 라이선스를 상업용 프로젝트에 사용하면 법적 문제가 발생할 수 있다. 라이브러리를 도입하기 전에 라이선스를 확인하고, 팀의 라이선스 정책과 호환되는지 검토한다.

라이선스 유형특징상업적 사용 시 주의사항
MIT, Apache 2.0, BSD자유로운 사용 허용저작권 표시 필요
LGPL라이브러리로 링크 시 사용 가능라이브러리 자체 수정 시 공개 의무
GPL소스 코드 공개 의무상업적 프로젝트에 주의 필요
AGPL네트워크를 통한 서비스에도 적용SaaS 제품에 특히 주의 필요
상용 라이선스사용 조건이 개별적으로 정의됨계약 내용 확인 필요

3) 공급망 보안(Supply Chain Security)에 주의한다

외부 라이브러리 자체가 악성 코드를 포함하거나, 신뢰할 수 있는 라이브러리와 이름이 유사한 악성 패키지를 설치하도록 유도하는 Typosquatting 공격이 증가하고 있다. 패키지 이름과 게시자를 신중하게 확인하고, 공식 저장소와 패키지 서명을 검증하는 습관을 갖는다. 인기 있는 라이브러리라도 관리자 계정이 탈취되거나 악성 코드가 삽입된 버전이 배포될 수 있다. 의존성 업그레이드 시 변경사항을 확인하고, 검증되지 않은 새 버전을 즉시 프로덕션에 적용하는 것을 지양한다.

11. 테스트 (Testing)

테스트는 안전망이자 설계 도구다. 테스트는 버그를 잡는 도구이기 이전에, 코드가 올바르게 설계되었는지를 반영하는 거울 이다. 테스트하기 어려운 코드는 대부분 설계가 잘못된 코드다. 반대로 테스트하기 쉬운 코드는 의존성이 명확하고, 책임이 분리되어 있으며, 도메인 로직이 제자리에 있는 코드다. AI 코딩 도구가 일상이 된 지금, “테스트가 있다”는 사실보다 “테스트가 올바른 것을 검증하고 있다”는 신뢰 가 더 중요해졌다. AI가 생성한 테스트는 겉으로는 그럴듯해 보이지만, 구현 세부사항에 강하게 결합되거나 의미 없는 Assertion으로 가득 찬 경우가 많다. 리팩토링 한 번에 테스트 수십 개가 한꺼번에 깨진다면, 그 테스트들은 방어망이 아닌 짐이다.

테스트 철학

1) 구현이 아닌 동작을 테스트한다

테스트의 역할은 코드가 어떻게(How) 동작하는지가 아니라 무엇을(What) 하는지를 보장하는 것이다. 메서드 이름을 바꾸고, 반복문을 재귀로 교체하고, 중간 변수를 제거하는 순수 리팩토링은 외부 동작을 바꾸지 않는다. 이런 변경에 테스트가 깨진다면, 그 테스트는 동작이 아닌 구현을 잠근 것이다.

2) Classicist TDD를 기본으로 선택한다

  • Mockist(London School) 는 협력 객체를 Mock으로 교체하고 객체 간 상호작용을 검증한다.
  • Classicist(Chicago School) 는 가능한 한 실제 협력 객체를 사용하고 최종 상태와 반환값을 검증한다.

팀은 Classicist TDD를 기본으로 선택 한다. 이유는 명확하다. AI 도구는 코드를 리팩토링하거나 재생성할 때 내부 구현을 자주 바꾼다. Mockist 방식은 구현에 결합되므로, AI가 내부를 바꿀 때마다 테스트가 따라서 깨진다. Classicist 방식의 테스트는 동작이 동일하면 내부 구현이 어떻게 바뀌든 통과한다. 단, 두 방식은 배타적이지 않다. 내부 로직은 상태로, 외부 부수효과는 상호작용으로 검증하는 것 이 원칙에 부합한다. 이메일 발송, 메시지 큐 발행, 외부 API 호출처럼 부수효과 자체가 비즈니스 요구사항인 경우는 상호작용 검증이 적합하다.

3) 통합 테스트를 단위 테스트보다 우선한다

단위 테스트는 빠르지만 실제 시스템과 멀어질수록 검증력이 떨어진다. 유스케이스 하나를 실제 DB와 함께 통합 테스트로 검증하면, 그 경로 위의 단위 테스트 여러 개를 대체한다. 단위 테스트는 통합 테스트로 커버하기 어려운 복잡한 도메인 로직과 엣지 케이스에만 집중한다.

4) 의미 있는 소수의 테스트가 신뢰도 낮은 다수보다 가치 있다

테스트 커버리지 숫자를 높이려고 단순 getter, 프레임워크 초기화 코드, 설정 상수까지 테스트하는 것은 실행 비용만 높이고 실제 버그를 잡지 못한다. 테스트를 추가하기 전에 “이 테스트가 없으면 실제 버그가 통과할 수 있는가” 를 먼저 묻는다. 답이 “아니오”라면 테스트를 추가하지 않는 것이 낫다.

테스트 구조

1) Unit / Integration / E2E 정의와 권장 비율

테스트는 목적과 범위에 따라 세 레이어로 구분하며, 각 레이어의 역할을 명확히 한다.

레이어목적권장 비율
Unit알고리즘, 도메인 규칙, 경계 조건, 복잡한 분기 검증많음 (비자명한 로직에 집중)
Integration유스케이스 + 실제 DB/캐시/큐와 함께 검증유스케이스당 1~3개
E2E핵심 사용자 경로 (주로 API 수준)경로당 1개

비율보다 중요한 것은 방향성이다. 하단으로 갈수록 테스트가 많고 빠르며, 상단으로 갈수록 적고 느리다. 표의 숫자는 최솟값이지 상한선이 아니다. 도메인 복잡도와 비즈니스 리스크가 높다면 더 많은 테스트를 작성해야 한다. 테스트는 다음을 반드시 포함해야 한다.

  • 인증, 결제, 권한 체크 등 비즈니스 핵심 로직
  • 복잡한 분기 로직과 상태 전환 로직
  • 과거에 버그가 발생했던 경로 (회귀 테스트)

다음은 생략할 수 있다.

  • 권한 검증, 분기가 없는 순수 CRUD (통합 테스트 하나로 충분)
  • 프레임워크 자체 기능 (DI, 라우팅, 모듈 등록)
  • 삭제 예정 코드

통합 테스트 성능은 개발자 피드백 루프에 직접 영향을 준다. 통합 테스트 하나당 100~300ms 수준을 목표로 하며, 트랜잭션 롤백과 테스트 컨테이너 재사용을 적극 활용한다.

2) Mock / Stub / Fake 구분과 모킹 경계

모킹 기준을 기계적으로 결정하지 않는다. 핵심 판단 기준은 “내가 제어할 수 있는가, 결정적(deterministic)인가” 이다.

반드시 대체해야 하는 것

  • 외부 결제 API, OAuth 서버, 서드파티 알림 서비스 → HTTP 레벨 Fake 서버 권장
  • 시스템 시계, 파일 시스템, 난수 생성기 → 주입 가능한 인터페이스로 교체
  • 메시지 큐, 외부 네트워크 소켓 등 프로세스 경계

피해야 할 대체

  • DB/ORM을 Mock으로 대체하는 것 (인덱스, 트랜잭션 격리 등 실제 동작을 재현 불가)
    • 단위 테스트: In-memory Fake Repository 사용
    • 통합 테스트: 실제 DB (Testcontainers 등) 사용
  • 순수 함수, 유틸리티 클래스 (대체할 이유 없음)
  • 내가 직접 소유한 Value Object, DTO, 도메인 엔티티

Mock, Stub, Fake는 용도가 다르다. Mock은 상호작용 검증 에 특화되어 과도하게 사용하면 리팩토링 내성을 떨어뜨린다. Stub은 고정된 응답만 반환 하며 호출 여부를 검증하지 않는다. Fake는 실제로 동작하는 간단한 구현체 로 상태 기반 검증에 가장 잘 어울린다. Mock을 최대한 줄이고 Fake와 실제 구현을 늘리는 방향을 지향한다.

내부 의존성을 Test Double로 대체하는 것은 다음 세 조건 중 하나 이상에 해당할 때만 허용한다.

  1. 비결정성 제거 — 시스템 시계, 난수 등 테스트를 불안정하게 만드는 요소
  2. 재현 불가능한 실패 시뮬레이션 — 실제 인프라에서 만들기 어려운 에러 케이스
  3. 피드백 루프 단축 — 순수 계산 로직 검증 시 DB 기동 비용이 과도할 때

3) Fixture 관리

Fixture(테스트 데이터 준비)는 테스트 코드에서 가장 중복이 많이 발생하는 부분이다. 중복된 Fixture 코드는 테스트 유지보수를 어렵게 만들고, 변경 시 누락 가능성을 높인다. 도메인 객체 생성은 Builder 패턴이나 팩토리 메서드 를 사용하여 중복을 제거한다. Fixture는 테스트가 검증하는 내용과 무관한 속성을 기본값으로 채우고, 테스트와 관련된 속성만 명시적으로 지정하도록 설계한다. 테스트 간 Fixture를 공유할 때는 공유된 상태가 테스트 간 간섭을 일으키지 않도록 주의한다. 각 테스트는 자신의 Fixture를 독립적으로 소유해야 한다.

품질 기준

1) Flaky 테스트는 절대 커밋하지 않는다

불안정한(Flaky) 테스트는 팀의 테스트 신뢰도를 가장 빠르게 갉아먹는다. CI가 무작위로 실패하면 개발자들은 점점 CI 결과를 무시하게 되고, 결국 자동화된 회귀 테스트 스위트 전체가 무용지물이 된다. Flaky 테스트는 절대 커밋하지 않는다. 이미 코드베이스에 들어왔다면 24시간 내에 격리한다. 격리 시에는 단순히 skip/ignore 처리가 아니라 이슈 링크 + 담당자 지정 + 기한 설정 + 원인 가설 기록 을 반드시 포함한다.

흔한 Flaky 테스트의 근본 원인은 다음과 같다.

  • 공유 전역 상태 (테스트 간 격리 실패)
  • 실제 시스템 시계(new Date(), LocalDateTime.now()) 의존
  • 테스트 실행 순서에 의존하는 코드
  • 시드 없는 난수 생성
  • 비동기 처리 미완료로 인한 Race Condition
  • 병렬 실행 시 자원 충돌 (포트, DB row 공유)

중요한 통찰은, 많은 Flaky 테스트의 진짜 원인이 테스트 코드가 아니라 소프트웨어 설계 문제 에 있다는 것이다. Flaky 테스트는 단순한 테스트 문제가 아니라 아키텍처 개선의 신호 로 읽어야 한다. sleep() 삽입, 재시도 루프, 타임아웃 증가는 증상 완화일 뿐이다. 근본 원인을 해결하지 않은 채 장기적으로 유지해서는 안 된다.

2) 테스트는 결정적(Deterministic)이어야 한다

동일한 코드에 대해 항상 동일한 결과를 반환해야 한다. 비결정성은 회피하는 것이 아니라 제거 해야 한다.

  • 시스템 시계 → 주입 가능한 Clock 인터페이스로 교체
  • UUID/난수 생성기 → 테스트에서 제어할 수 있도록 주입 구조로 변경
  • 외부 API 응답 → Fake 서버나 Stub으로 고정

비결정적 출력(타임스탬프, LLM 응답, 순서가 보장되지 않는 집합)을 스냅샷 테스트로 저장하지 않는다. 비결정적 필드가 포함된 경우 부분 비교(toMatchObject)를 사용하여 핵심 비즈니스 필드에만 집중한다.

3) 테스트 Assertion은 관찰 가능한 결과를 검증한다

모든 테스트는 AAA(Arrange-Act-Assert) 패턴 을 명확하게 구분하여 작성한다. 검증 대상은 외부에서 관찰 가능한 결과 만이다.

  • 함수의 반환값
  • DB에 저장된 실제 상태
  • API 응답
  • 이벤트 발생 여부

내부 메서드 호출 순서나 협력 객체에 전달된 인자는 외부에서 관찰할 수 없는 구현 세부사항이므로 검증 대상이 아니다. Assertion이 없는 테스트, expect(result).toBeDefined() 같은 의미 없는 Assertion은 테스트가 존재하지만 실제 버그를 잡지 못하는 허울뿐인 테스트다.

4) 테스트 네이밍 — 행동 기반 템플릿

테스트 이름은 구현 방식이 아닌 관찰 가능한 동작 을 설명해야 한다. 테스트가 실패했을 때 이름만 보고 무엇이 깨졌는지 즉시 파악할 수 있어야 한다. 권장 템플릿은 <action>_<expected_behavior>when<condition> 형식으로 한다. 동사는 일관되게 사용한다(create, get, update, delete, returns, fails, rejects). works, handles, processes 같은 모호한 표현은 피한다. 테스트 이름을 먼저 쓰면 무엇을 테스트할지 명확해진다는 부수효과가 있다. “어떤 조건에서 어떤 행동이 어떤 결과를 낳는가”를 한 문장으로 표현할 수 없다면, 테스트 대상 자체가 불분명한 것이다.

도메인 엔티티와 테스트 설계

서비스 레이어에 비즈니스 로직이 쌓이면 테스트가 급격히 복잡해지는 신호가 나타난다. 순수한 계산 로직 하나를 검증하기 위해 DB를 통째로 띄워야 하고, DB 없이는 로직을 실행조차 할 수 없게 된다. 이때는 도메인 엔티티를 추출 해야 할 시점이다. 다음 중 하나라도 해당하면 추출을 검토한다.

  • 같은 데이터를 대상으로 하는 비즈니스 로직이 2개 이상의 서비스에 흩어져 있을 때
  • 중요한 불변식(Invariant)이 여러 서비스에 중복으로 체크되고 있을 때
  • 순수 로직인데 테스트를 위해 매번 DB를 띄워야 할 때

도메인 엔티티 메서드는 순수 메모리 기반 단위 테스트가 가능해진다. DB나 Mock 없이 밀리초 단위로 빠르게 실행되며, 결과 상태를 검증하는 상태 기반 테스트가 자연스럽게 작성된다.

12. 문서화 (Documentation)

문서화는 코드와 함께 진화해야 한다. 문서화는 코드를 작성한 후 별도로 수행하는 부가 작업이 아니다. 좋은 문서는 코드가 전달하지 못하는 “왜(Why)” 를 채운다. 코드는 “무엇을(What)”, “어떻게(How)“를 표현하지만, 왜 그런 설계를 선택했는지, 비즈니스 의도가 있는지, 어떤 트레이드오프를 감수했는지, 어떤 제약과 리스크가 존재했지는 코드만으로 전달되지 않는다.

문서화의 가장 큰 실패는 두 가지다. 첫째는 문서가 없는 것 이고, 둘째는 코드와 동기화되지 않아 잘못된 정보를 제공하는 것 이다. 잘못된 문서는 없는 것보다 나쁘다. 읽는 사람이 잘못된 전제를 가지고 코드를 수정하게 만들기 때문이다.

문서화 전략의 우선순위는 다음과 같다. 코드 자체가 읽힌다 → 코드 내 주석으로 보완한다 → 별도 문서로 기록한다. 별도 문서가 필요한 상황은 코드와 주석으로 표현할 수 없는 설계 의도, 시스템 간 상호작용, 공개 API 계약을 다룰 때다.

코드 문서화

1) Self-Documenting Code를 최우선으로 한다

최고의 문서는 주석이 없어도 읽히는 코드다. 변수명, 함수명, 클래스명이 의도를 명확히 드러낸다면, 그 코드는 스스로 문서가 된다. 주석을 작성하기 전에 먼저 “코드 자체를 더 명확하게 만들 수 없는가”를 묻는다. 코드를 읽는 것만으로 이해가 어렵다면, 그것은 대부분 주석이 부족한 것이 아니라 코드의 명시성이 부족한 것이다. 주석으로 덮기 전에 코드를 먼저 개선한다.

2) 주석 허용 기준

주석은 코드의 메커니즘을 설명하는 것이 아니라, 코드만으로 전달할 수 없는 맥락 을 전달하기 위해 존재한다. 다음 경우에만 주석을 허용한다.

  • 왜(Why)를 설명하는 주석은 가장 가치 있다. 특정 구현 방식을 선택한 이유, 감수한 트레이드오프, 존재하는 제약사항을 설명한다. 이런 정보는 코드에서 읽을 수 없고, 작성자만 알고 있는 중요한 맥락이다.
  • 복잡한 알고리즘이나 비자명한 로직에 대한 설명 주석 은 허용한다. 단, 주석이 코드를 번역하는 수준(“i를 1 증가시킨다”)이라면 삭제한다.
  • 공개 API의 계약 주석 은 파라미터의 의미, 반환값, 예외 조건, 사용 제약을 명시한다. 특히 팀 외부에서 사용하는 라이브러리나 모듈의 공개 인터페이스는 계약을 문서화한다.

다음 주석은 허용하지 않는다.

  • 코드를 그대로 번역하는 주석 (// i를 1 증가시킨다)
  • 오래되어 코드와 맞지 않는 주석 (잘못된 정보는 없는 것보다 나쁨)
  • 사용하지 않는 코드를 주석으로 남겨두는 것 (버전 관리 시스템이 이력을 보관)
  • // 수정 금지, // 건드리지 마세요 같은 경고성 주석만 있고 이유가 없는 것

3) TODO / FIXME 정책

TODOFIXME는 작업이 필요하다는 의도는 좋지만, 맥락과 책임자 없이 코드베이스에 쌓이면 영원히 해결되지 않는 기술 부채가 된다. TODOFIXME를 작성할 때는 반드시 “필수 포함 정보: 이유 + 담당자 또는 이슈 링크 + 예상 시점” 정보를 포함한다. 담당자와 이슈 링크가 없는 TODO/FIXME는 PR 리뷰에서 보완을 요청한다. 정기적으로 코드베이스의 TODO/FIXME 목록을 검토하고, 해결되지 않은 것은 이슈로 등록하거나 삭제한다.

API 문서

1) OpenAPI / 계약 우선 설계

네트워크 API는 계약(contract) 이다. API 문서는 소비자가 API를 이해하고 사용하기 위해 필요한 모든 정보를 제공해야 한다. 불완전하거나 부정확한 API 문서는 소비자의 불신과 불필요한 커뮤니케이션 비용을 만든다. REST API는 OpenAPI(Swagger) 명세 를 사용하여 문서화한다. OpenAPI 명세는 코드와 함께 관리되며, API가 변경될 때 명세도 함께 업데이트되도록 리뷰 프로세스에 포함한다. 가능하면 코드에서 명세를 자동 생성 하되, 자동 생성만으로는 충분하지 않은 경우가 많다. 자동 생성된 명세에 비즈니스 의미, 제약 조건, 예시를 추가로 기술한다. 자동 생성의 편의성과 명세의 품질 사이의 균형을 팀 컨벤션으로 정의한다.

2) Examples 우선

API 문서에서 예시(example)는 가장 먼저 읽히는 부분 이다. 파라미터 설명과 타입 정의보다 실제 요청/응답 예시가 API를 이해하는 데 훨씬 효과적이다. 모든 엔드포인트에는 실제로 동작하는 예시를 포함한다. 예시는 최소한 다음을 포함한다.

  • 정상 케이스: 가장 일반적인 성공 요청과 응답
  • 에러 케이스: 주요 에러 상황별 응답 (입력 오류, 권한 없음, 리소스 없음 등)
  • 경계 케이스: 선택적 파라미터, 빈 목록, 최대값 등

API 문서는 소비자의 관점에서 작성한다. 내부 구현 방식이 아니라 소비자가 알아야 할 것에 집중한다. “이 API를 처음 보는 개발자가 예시만 보고 사용할 수 있는가”를 기준으로 문서 품질을 평가한다.

3) API 변경 사항 관리

API가 변경될 때는 문서도 반드시 함께 업데이트한다. API 변경과 문서 업데이트를 별도 작업으로 두지 않는다. Deprecated된 엔드포인트, 파라미터, 필드는 문서에 명확히 표시하고 제거 일정을 명시한다.

문서화 금지 패턴

  • 코드 이력을 주석으로 남기는 것 은 버전 관리 시스템이 있는 이유를 모르는 것이다. “2024-03-15 홍길동 수정”, /* 구버전 코드 */ 같은 주석은 즉시 삭제한다. 이력은 git loggit blame으로 확인한다.
  • 코드와 동기화되지 않은 주석 은 없는 것보다 나쁘다. 코드는 변경되었는데 주석이 과거 동작을 설명하면, 읽는 사람이 잘못된 가정으로 코드를 수정하게 만든다. 코드를 변경할 때 관련 주석도 반드시 함께 검토하고 업데이트한다.
  • 자명한 코드에 대한 주석 은 읽는 사람을 무시하는 것이다. // i를 1 증가시킨다, // 생성자 같은 주석은 코드 노이즈만 늘린다.
  • 문서를 위한 문서 는 만들지 않는다. 실제로 읽히지 않거나, 읽혀도 가치를 제공하지 못하는 문서는 유지보수 부담만 늘린다. 문서를 작성하기 전에 “이 문서를 읽을 사람이 누구이고, 무엇을 얻어가야 하는가”를 먼저 정의한다.

13. 로깅과 관측성 (Observability)

관측성은 시스템을 이해하는 능력이다. 코드를 작성하는 것만큼 중요한 것이 실행 중인 시스템을 이해하는 능력 이다. 관측성 (Observability)은 시스템 내부를 들여다볼 수 있는 창이다. 관측성이 갖춰지지 않은 시스템은 장애가 발생했을 때 무슨 일이 일어나고 있는지 알 수 없고, 성능 문제가 어디서 발생하는지 찾을 수 없으며, 비즈니스 이상 징후를 사전에 감지할 수 없다.

관측성의 세 기둥은 로그(Logs), 메트릭(Metrics), 트레이스(Traces) 다. 이 세 가지는 각각 다른 질문에 답한다. 로그는 “무슨 일이 있었는가”, 메트릭은 “시스템이 얼마나 잘 동작하고 있는가”, 트레이스는 “이 요청이 어떤 경로로 처리되었는가”를 알려준다. 세 가지를 함께 사용할 때 시스템의 상태를 입체적으로 파악할 수 있다.

관측성은 기능 개발 후 추가하는 것이 아니라, 처음 설계할 때 함께 고려해야 한다. 나중에 로그를 추가하려면 코드 전체를 다시 살펴야 하고, 중요한 컨텍스트 정보가 이미 범위 밖으로 사라진 경우가 많다.

Structured Logging (구조화 로깅)

1) 로그는 기계가 처리할 수 있는 형식으로 남긴다

단순 텍스트 로그는 사람이 읽기에는 직관적이지만, 대량의 로그를 검색하고 집계하고 분석하는 데는 한계가 있다. 구조화 로그(Structured Logging) 는 로그를 키-값 쌍의 구조화된 형식(JSON 등)으로 기록하여 로그 수집 시스템이 색인하고 검색할 수 있게 만든다. 구조화 로그는 특정 주문 ID와 관련된 모든 로그를 즉시 조회하거나, 특정 에러 코드의 발생 빈도를 집계하는 것을 가능하게 한다. 로그 분석 도구의 성능을 최대한 활용하려면 로그가 구조화되어 있어야 한다.

2) 로그 레벨을 일관되게 적용한다

로그 레벨은 로그의 심각도와 용도를 구분하는 기준이다. 레벨을 일관되게 적용하지 않으면 중요한 에러가 노이즈 속에 묻히거나, 불필요한 알림이 넘쳐 팀이 로그를 무시하게 된다.

레벨사용 기준알림 여부
ERROR즉각 대응이 필요한 복구 불가능한 실패. 비즈니스 흐름이 중단됨즉시 알림
WARN정상 동작하지만 주의가 필요한 상황. 방치하면 문제로 발전 가능집계 후 알림
INFO시스템의 주요 상태 변경. 운영 관점에서 의미 있는 이벤트알림 없음
DEBUG개발/진단 목적의 상세 흐름. 프로덕션에서는 기본 비활성화알림 없음

ERROR는 정말 “지금 즉시 누군가가 봐야 한다”는 상황에만 사용한다. 예상 가능한 비즈니스 예외(주문 취소, 결제 실패 등)를 ERROR로 기록하면 진짜 장애를 구분하기 어려워진다.

3) 로그 메시지에는 맥락을 포함한다

“에러 발생”, “처리 실패” 같은 컨텍스트 없는 메시지는 아무런 도움이 되지 않는다. 로그를 보는 사람이 어떤 요청에서, 어떤 데이터로, 무슨 일이 발생했는지 파악할 수 있어야 한다. 모든 로그에 포함해야 할 필드를 팀 표준으로 정의한다.

{
  "timestamp": "ISO 8601 형식",
  "level": "ERROR | WARN | INFO | DEBUG",
  "message": "무슨 일이 발생했는지",
  "traceId": "분산 추적 ID",
  "spanId": "스팬 ID",
  "service": "서비스 이름",
  "userId": "요청한 사용자 (PII 정책 준수)",
  "requestId": "요청 고유 ID"
}

같은 에러를 여러 레이어에서 중복으로 로그하지 않는다. 예외를 catch하여 로그를 남기고 다시 throw하면 동일한 에러가 스택 전체에서 중복 기록된다. 에러는 최종 처리 지점에서 한 번만 기록한다.

Trace ID (분산 추적)

모든 요청에 추적 ID를 부여한다. 마이크로서비스나 복잡한 시스템에서 하나의 사용자 요청이 여러 서비스를 거쳐 처리된다. 장애가 발생했을 때 어느 서비스의 어느 지점에서 문제가 생겼는지 추적하려면, 동일한 요청을 처리한 모든 로그를 연결할 수 있는 공통 식별자 가 필요하다. 모든 요청의 진입점(API Gateway, HTTP 서버)에서 고유한 Trace ID 를 생성하고, 이후 모든 처리 과정의 로그와 메트릭에 Trace ID를 포함한다. 서비스 간 호출 시 Trace ID를 HTTP 헤더나 메시지 메타데이터로 전파하여 전체 요청 흐름을 추적 가능하게 만든다. 외부에서 Trace ID가 전달된 경우(예: X-Trace-Id 헤더) 그것을 사용하고, 없으면 새로 생성한다. 사용자에게 에러를 응답할 때는 Trace ID를 응답에 포함하여 지원팀이 해당 요청의 로그를 즉시 조회할 수 있게 한다.

Span으로 성능 병목을 파악한다. Span 은 Trace 내의 개별 작업 단위다. DB 쿼리 하나, 외부 API 호출 하나, 특정 비즈니스 로직 처리 시간 등을 Span으로 측정하면, 전체 요청 처리 시간 중 어느 부분이 얼마나 걸리는지 파악할 수 있다. OpenTelemetry 같은 표준 계측 라이브러리를 사용하면 Trace와 Span을 일관된 방식으로 수집하고 Jaeger, Zipkin, Tempo 같은 분산 추적 시스템에 전송할 수 있다. 직접 구현하지 않고 표준을 따른다.

Metrics (메트릭)

시스템의 건강 상태를 숫자로 측정한다. 메트릭 은 시스템의 상태를 시간의 흐름에 따라 수치로 표현한다. 로그가 “무슨 일이 있었는가”를 기록한다면, 메트릭은 “시스템이 얼마나 잘 동작하고 있는가”를 지속적으로 측정한다. 메트릭은 다음 네 가지 유형을 기본으로 수집한다.

시스템 메트릭 (인프라)

  • CPU, 메모리, 디스크 사용률
  • 네트워크 I/O
  • JVM 힙 사용량, GC 빈도 (JVM 기반 서비스)

애플리케이션 메트릭 (서비스)

  • 요청 처리량 (RPS, Requests Per Second)
  • 응답 시간 (P50, P90, P99 레이턴시)
  • 에러율
  • 동시 처리 중인 요청 수

비즈니스 메트릭 (도메인)

  • 초당 주문 생성 수
  • 결제 성공/실패율
  • 사용자 가입/이탈률
  • 장바구니 전환율

비즈니스 메트릭은 기술적 장애를 감지하는 것 외에도, 기능 배포 후 비즈니스 지표에 이상이 없는지 확인하는 데 사용한다. 배포 후 주문 전환율이 급격히 떨어졌다면, 에러가 없더라도 뭔가 잘못된 것이다.

SLI(Service Level Indicator)와 SLO(Service Level Objective)를 명시적으로 정의한다. “응답 시간 P99 500ms 이하”, “에러율 0.1% 미만” 같은 기준을 사전에 정의하고, 이를 기준으로 알림을 설정한다. 기준 없이 알림을 설정하면 오탐(false positive)이 넘쳐 팀이 알림을 무시하게 된다.

Audit Log (감사 로그)

비즈니스적으로 중요한 행위는 반드시 기록한다. 감사 로그 는 “누가, 언제, 무엇을, 어떻게 변경했는가”를 추적하는 기록이다. 일반 운영 로그와 달리, 감사 로그는 비즈니스 책임과 규정 준수 를 위해 존재한다. 법적 요구사항, 보안 감사, 이상 거래 탐지, 분쟁 해결에 사용된다.

다음 행위는 반드시 감사 로그로 기록한다.

  • 사용자 인증 이벤트 (로그인, 로그아웃, 로그인 실패, 비밀번호 변경)
  • 권한 변경 (역할 부여, 권한 수정)
  • 중요 데이터 접근 (개인정보, 결제 정보 조회)
  • 주요 도메인 상태 변경 (주문 생성/취소/환불, 계정 정지/해제)
  • 설정 변경 (시스템 설정, 요금 정책 변경)
  • 관리자 행위

감사 로그는 일반 애플리케이션 로그와 분리하여 저장 한다. 일반 로그는 저장 기간이 짧을 수 있지만, 감사 로그는 법적 요구사항에 따라 수년간 보존해야 하는 경우가 있다. 감사 로그는 삭제되거나 수정될 수 없어야 하며, 접근 권한을 별도로 관리한다.

// 감사 로그 구조 예시
{
  "auditTimestamp": "2024-03-15T14:23:01.234Z",
  "eventType": "ORDER_CANCELLED",
  "user": {
    "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "username": "mimul@fittobe.com",
    "role": "CUSTOMER",
    "ip": "192.168.1.100"
  },
  "resource": {
    "type": "Order",
    "id": "ORD-2024-001",
    "customerId": "cust_12345"
  },
  "changes": {
    "before": { "status": "CONFIRMED" },
    "after": { "status": "CANCELLED" }
  },
  "reason": "고객 요청 취소",
  "traceId": "a3f2c891-4d12-4b3e"
}

감사 로그는 기능 개발 후 추가하는 것이 아니라 중요 기능을 설계할 때 함께 고려한다. 나중에 감사 로그를 추가하면 이미 발생한 이벤트의 이력이 없고, 코드 전체를 다시 살펴봐야 하는 부담이 생긴다.

PII 정책 (개인정보 보호)

1) 로그에 개인정보를 절대 기록하지 않는다

로그와 메트릭은 여러 시스템을 거쳐 저장되고 다양한 사람이 접근할 수 있다. 로그에 개인정보(PII, Personally Identifiable Information)가 포함되면 의도하지 않은 개인정보 유출이 발생할 수 있으며, GDPR, 개인정보보호법 등 규정 위반으로 이어진다.

로그에 절대 포함해서는 안 되는 정보:

  • 비밀번호, 인증 토큰, API 키, 세션 토큰
  • 신용카드 번호, 계좌번호, CVC
  • 주민등록번호, 여권번호, 운전면허번호
  • 이메일 주소, 전화번호, 주소 (식별이 가능한 수준)
  • 생년월일과 이름의 조합

2) 데이터 마스킹과 토큰화

개인정보를 포함할 수밖에 없는 경우(예: 감사 로그에서 접근자 추적), 다음 방법으로 처리한다.

  • 마스킹(Masking): 일부 문자를 *로 대체한다. 디버깅에 충분한 정보를 제공하면서 완전한 개인정보는 숨긴다.
  • 토큰화(Tokenization): 실제 값 대신 추적 가능한 토큰(익명 식별자)으로 대체한다. 토큰과 실제 값의 매핑은 별도의 안전한 저장소에 보관한다.
  • 로그 수집 파이프라인에서 PII를 자동 탐지하고 제거 하는 것도 실용적인 방법이다. Logstash, Fluentd 같은 로그 수집 도구에서 정규식 패턴으로 PII를 자동 마스킹하면, 개발자가 실수로 PII를 포함한 로그를 남기더라도 저장소에 도달하기 전에 필터링된다.

3) 데이터 보존 정책

로그 데이터는 목적에 따라 보존 기간을 다르게 적용한다.

로그 유형권장 보존 기간이유
일반 운영 로그30~90일장애 대응 및 디버깅
보안/접근 로그1년 이상보안 감사 요구사항
감사 로그3~7년법적 요구사항 (산업별 상이)
메트릭 (고해상도)15~30일저장 비용 대비 활용도
메트릭 (집계)1~2년용량 계획 및 트렌드 분석

보존 기간이 지난 데이터는 자동으로 삭제되도록 정책을 설정한다. 특히 개인정보가 포함된 로그는 보존 기간이 지나면 반드시 삭제되어야 하며, 이것이 자동화되지 않으면 “나중에 정리하자”는 상태로 무기한 방치되기 쉽다.

관측성 설계 시 피해야 할 것들

  • 로그 없는 침묵 은 에러 처리의 가장 위험한 패턴이다. 예외를 catch하고 아무것도 기록하지 않으면, 시스템에 문제가 발생했다는 사실을 알 방법이 없다. 에러는 어떤 경우에도 침묵해서는 안 된다.
  • 의미 없는 로그 과잉 도 침묵만큼 해롭다. 모든 메서드 진입/종료를 INFO 레벨로 기록하거나, 루프마다 로그를 남기면 중요한 정보가 노이즈 속에 묻힌다. 로그는 운영에 필요한 정보를 담아야 하며, 불필요한 로그는 저장 비용과 검색 비용만 높인다.
  • 컨텍스트 없는 로그 는 디버깅을 불가능하게 만든다. “Order failed”, “Error occurred” 처럼 어떤 주문이, 어떤 사용자의, 어떤 이유로 실패했는지 알 수 없는 로그는 로그가 없는 것과 다르지 않다.
  • 알림 피로(Alert Fatigue) 는 너무 많은 알림이 발생하여 팀이 알림을 무시하게 되는 상태다. 알림은 실제로 대응이 필요한 상황에만 발생해야 하며, 명확한 기준 없이 설정된 알림은 오히려 중요한 알림을 놓치게 만든다.

14. 보안 (Security)

보안은 설계 이전에 존재하는 사고 방식이다. 보안은 개발이 완료된 후 추가하는 기능이 아니다. 제품이 어떻게 동작해야 하는가 가 아니라, 어떻게 악용될 수 있는가 를 먼저 생각하는 사고 방식이다. 설계 단계에서 Trust Boundary나 인가 모델이 잘못 정의되면, 나중에 코드 패치나 보안 기능을 덧붙이는 것으로는 근본적인 문제를 해결하기 어렵다. 구조 자체가 취약하게 설계된 경우가 대부분이기 때문이다.

보안 취약점의 대부분은 새로운 공격 기법이 아니라 반복되는 패턴 에서 발생한다. 입력 검증 부재, 권한 검증 누락, 기본값 신뢰, 시크릿 하드코딩이 OWASP Top 10의 다수 항목을 관통하는 근본 원인이다. 이 원인들을 설계와 코드 리뷰 체크리스트에 명시적으로 포함하는 것이 실질적인 출발점이다.

보안 설계 철학

1) 악용 시나리오를 기능 명세와 함께 작성한다

기능을 설계할 때 “이 기능이 어떻게 정상 동작하는가”와 함께 “이 기능이 어떻게 악용될 수 있는가” 를 함께 작성한다. 기능 명세(Functional Specification)와 악용 명세(Abuse Specification)를 함께 작성하는 습관이 보안 설계의 출발점이다.

특히 주의해야 할 것이 비즈니스 로직 취약점(Business Logic Vulnerabilities) 이다. 이 취약점은 코드 자체는 정상적으로 동작하지만, 공격자가 여러 기능을 예상치 못한 순서나 조합으로 사용할 때 문제가 발생한다. 할인 코드 중복 적용, 결제 흐름 우회, 비정상적인 상태 전환 순서 조작이 대표적인 사례다. SAST나 DAST 같은 자동화 도구로는 거의 탐지되지 않기 때문에, 설계 단계에서 악용 시나리오를 고려하는 것이 유일한 방어 수단이다.

2) Threat Modeling으로 공격 관점을 설계에 통합한다

Threat Modeling은 단순히 보안 문서를 작성하는 작업이 아니라, 설계 단계부터 공격자의 관점을 의식적으로 통합하는 지속적인 사고 과정 이다. 보안 팀만의 전유물이 아니라 설계자, 개발자, 아키텍트 모두가 참여하는 협업 활동이어야 효과를 발휘한다.

Threat Modeling의 핵심 도구인 STRIDE 는 시스템이 받을 수 있는 위협을 체계적으로 분류한다.

범주설명주요 대응 방안
Spoofing신원 위장강력한 인증, 토큰 검증, 세션 관리
Tampering데이터 위조/변조입력 검증, 무결성 검증, 디지털 서명
Repudiation행위 부인감사 로그, 불변 로그, 디지털 서명
Information Disclosure민감 정보 노출데이터 암호화, 최소 응답 원칙
Denial of Service서비스 거부Rate Limiting, 타임아웃, 리소스 보호
Elevation of Privilege권한 상승최소 권한 원칙, 객체 수준 인가 검증

Threat Modeling은 한 번 하고 끝내는 것이 아니다. 기능이 추가되거나 크게 변경될 때마다 해당 부분의 Data Flow와 Trust Boundary를 다시 검토한다.

3) 침해를 전제로 설계한다 (Assume Breach)

보안은 모든 공격을 완전히 차단하는 것이 아니라, 공격의 성공을 지연시키고 피해 범위를 최소화 하는 전략이다. Defense in Depth 는 여러 계층의 방어를 쌓아, 하나가 뚫리더라도 다음 계층이 공격을 탐지하거나 지연시킨다. 코드 레벨에서는 스키마 검증, 인가 검증, 에러 처리, 감사 로그가 각각 독립적인 방어 계층으로 작동한다.

최소 권한 원칙(Principle of Least Privilege) 을 적용하여 하나의 계정이 탈취당하더라도 전체 시스템이 노출되지 않도록 한다. 안전한 시스템이란 “뚫리지 않는 시스템”이 아니라, 뚫리더라도 쉽게 무너지지 않는 시스템 이다.

신뢰 경계 (Trust Boundary)

신뢰 경계를 코드로 명시적으로 표현한다. Threat Modeling에서 그린 Trust Boundary는 다이어그램에만 그려놓으면 아무 의미가 없다. 경계를 실제 코드에서 명시적으로 표현하고 강제하는 것 이 진짜 보안이다. 신뢰 경계를 넘는 데이터는 반드시 검증한다. “내부 서비스끼리 주고받는 거니까 안전할 거야” 라는 가정은 위험하다. 내부라고 해서 예외를 두는 순간, 그 지점이 공격 표면이 된다.

실제 코드에서 신뢰 경계가 나타나는 주요 지점은 다음과 같다.

  • 외부 입력 경계: HTTP 요청 파라미터, 헤더, 바디, 파일 업로드, 환경 변수
  • 서비스 간 경계: 마이크로서비스 API 호출, 메시지 큐 메시지, 외부 서드파티 응답
  • 권한 경계: 일반 사용자 → 관리자 권한 전환, 민감 기능 접근 시점

“검증 없이는 신뢰하지 않는다(Never trust, always verify)” 가 원칙이다. 허용 목록(Allowlist) 기반 검증을 우선적으로 고려한다. 차단 목록(Blocklist)은 새로운 공격 패턴을 놓치기 쉽다.

입력 검증과 Injection 방지

모든 외부 입력을 신뢰하지 않는다. 사용자 입력, API 요청, 파일 업로드, 외부 시스템의 응답까지 외부에서 오는 모든 데이터는 검증 없이 사용하지 않는다. 입력 검증은 경계에서 한 번, 철저하게 수행한다. 사용자가 입력한 내용은 절대 “코드”나 “명령어”로 실행하지 않고 “데이터”로만 취급 한다. eval, exec, spawn에 사용자 입력을 직접 전달하지 않는다.

  • Injection 방지 원칙: 입력을 실행 컨텍스트와 분리한다.
  • XSS 방지: 프레임워크의 자동 이스케이프를 활성화하고, innerHTML, dangerouslySetInnerHTML, raw 필터 사용을 금지한다.
  • Path Traversal 방지: 사용자 입력으로 파일 경로를 구성할 때 ../를 통한 경로 탈출을 방지하고, 허용된 기본 디렉토리 내에서만 경로가 해석되도록 검증한다.

Secret 관리

코드에 비밀값을 절대 하드코딩하지 않는다. API 키, 비밀번호, JWT 서명 키, 인증서 같은 비밀값은 코드에 직접 작성하지 않는다. 한 번 커밋된 비밀값은 이력에 영구히 남고, 저장소가 공개되는 순간 즉시 노출된다. 비밀값이 실수로 포함되었다면, 커밋 이력 삭제만으로는 부족하다. 즉시 폐기하고 새로 발급해야 한다.

비밀값은 환경 변수 또는 전용 비밀값 관리 서비스(AWS Secrets Manager, HashiCorp Vault, Azure Key Vault 등)를 통해 주입한다. .env 파일은 로컬 개발 편의용으로만 사용하고 버전 관리에 포함하지 않는다. .env.example처럼 실제 값 없이 필요한 키 목록만 버전 관리에 포함한다.

인증과 인가 (Authentication & Authorization)

인증과 인가를 명확히 구분하고 철저히 구현한다. 인증(Authentication) 은 “당신이 누구인가”를 확인하는 것이고, 인가(Authorization) 는 “당신이 무엇을 할 수 있는가”를 결정하는 것이다. 두 가지는 반드시 분리되어야 하며, 인증에 성공했다고 모든 자원에 접근할 수 있는 것이 아님을 코드가 보장해야 한다.

인증 구현 기준:

  • 비밀번호는 반드시 강력한 해시 함수(bcrypt, Argon2, scrypt)로 저장한다. MD5, SHA-1, SHA-256을 비밀번호 해시에 사용하지 않는다.
  • JWT 토큰은 검증 시 알고리즘을 명시적으로 지정한다. alg: none 공격을 방지하기 위해 허용할 알고리즘을 화이트리스트로 관리한다.
  • 토큰의 만료 시간을 반드시 설정하고 검증한다.
  • 민감한 작업에는 재인증을 요구한다.

인가 구현 기준:

  • 모든 리소스 접근에 인가 체크를 적용한다. “관리자만 접근 가능”한 기능이 인증 여부만 확인하고 역할(Role)을 검증하지 않는 것은 인가 결함이다.
  • IDOR(Insecure Direct Object Reference) 를 방지한다. 리소스 ID를 직접 노출하는 경우, 요청한 사용자가 해당 리소스의 소유자인지 반드시 검증한다.
  • 인가 로직은 UI에서만 처리하지 않는다. 버튼을 숨기는 것은 UX이지 보안이 아니다. 서버에서 반드시 인가를 재검증한다.
  • 인가는 기능 개발 후 추가하는 것이 아니라 처음 설계할 때 함께 설계한다.

Secure Default (보안 기본값)

기본 상태가 가장 안전한 상태여야 한다. 보안을 위해 추가적인 설정이 필요한 것이 아니라, 보안을 낮추기 위해 명시적인 설정이 필요하도록 설계한다. 모든 응답에 보안 HTTP 헤더를 기본으로 포함한다.

헤더권장값목적
Strict-Transport-Securitymax-age=31536000; includeSubDomainsHTTPS 강제
X-Content-Type-OptionsnosniffMIME 타입 스니핑 방지
X-Frame-OptionsDENY 또는 SAMEORIGINClickjacking 방지
Content-Security-Policy정책 명시XSS 완화
Referrer-Policystrict-origin-when-cross-origin정보 누출 방지
X-Powered-By제거기술 스택 노출 방지
Server버전 제거서버 정보 노출 방지

에러 응답에 내부 구현 세부사항을 절대 노출하지 않는다. 스택 트레이스, DB 쿼리, 내부 경로는 로그에만 기록하고 응답에는 추적 ID만 포함한다. 프로덕션 환경에서 디버그 모드를 반드시 비활성화하고, CORS는 허용할 오리진을 명시적 화이트리스트로 관리한다.

Dependency Scanning (의존성 취약점 검사)

알려진 패턴을 자동화 도구로 지속적으로 검사한다. 외부 라이브러리의 CVE는 내 코드의 취약점과 동일하다. 보안 취약점의 패턴은 반복된다. 이 패턴을 알고 자동화 도구로 지속적으로 검사하는 것이 Shift Left Security의 핵심이다. 배포 후에 발견하면 수습 비용이 기하급수적으로 증가한다.

SAST → SCA → 시크릿 탐지를 PR 머지 전 CI 게이트에 포함한다.

분류도구적합한 상황
SASTSemgrep코드 작성·PR 단계 정적 분석
DASTOWASP ZAP, Nuclei스테이징·QA 단계 런타임 취약점 탐지
SCADependabot, OSV-Scanner의존성 CVE 탐지
SCA + 통합Trivy컨테이너 이미지·파일시스템·시크릿·설정 오류 통합 스캔
SBOMsyft공급망 보안, 의존성 구성 요소 가시화
시크릿 탐지Gitleaks, trufflehogGit 이력 전체 시크릿 패턴 탐지

2025 OWASP Top 10에서 A03 소프트웨어 공급망 실패 가 독립 항목으로 분리된 점을 주목한다. 외부 의존성은 이제 독립적인 공격 표면이다. SBOM(Software Bill of Materials)은 이 공격 표면을 가시화하여, 새로운 CVE 공개 시 영향받는 컴포넌트를 즉시 특정할 수 있게 한다. 다만 도구는 보안 지식의 대체재가 아니라 보완재 다. 자동화 도구는 알려진 CVE와 시크릿 패턴을 탐지하지만, 비즈니스 로직 결함이나 인가 오류는 찾지 못한다. OWASP 패턴 지식 없이 도구만 실행하면 알림 피로만 발생한다.

취약점 발견 시 심각도별 대응 기준을 명확히 정의한다.

심각도대응 기준
Critical즉시 대응. 배포 차단 또는 즉시 핫픽스
High다음 릴리스 전에 반드시 해결. CI 배포 차단
Medium계획된 스프린트 내에 해결
Low백로그 관리, 정기 검토

OWASP Top 10 체크리스트

코드 리뷰와 설계 검토 시 OWASP Top 10 항목을 체크리스트로 활용한다. 입력 검증 부재, 권한 검증 누락, 기본값 신뢰가 다수 항목의 공통 근본 원인임을 인식한다.

항목설계 실패 원인핵심 대응
A01 접근 제어 우회권한 검증 누락객체 수준 인가 검증, IDOR 방지
A02 보안 설정 오류기본값 신뢰Secure Default, 디버그 모드 비활성화
A03 공급망 실패외부 의존성 신뢰SCA 자동화, SBOM 관리
A04 암호화 결함민감 데이터 처리 미흡bcrypt/Argon2, TLS 강제
A05 인젝션입력 검증 부재Parameterized Query, 출력 이스케이프
A06 안전하지 않은 설계설계 단계 보안 누락Threat Modeling, 악용 명세
A07 인증 실패인증 메커니즘 약점JWT 알고리즘 고정, 토큰 만료 검증
A08 무결성 실패업데이트·데이터 검증 부재서명 검증, 웹훅 위조 방지
A09 로깅·경보 실패탐지·감시 미비감사 로그, 이상 행위 알림
A10 예외 처리 취약점오류 처리 결함내부 정보 미노출, fail-safe 처리

보안 금지 패턴

  • 보안을 나중에 추가하는 것: 인증 체크, 인가 검증, 입력 검증을 기능 구현 후 추가하면 항상 빠트리는 지점이 생긴다. 처음 설계할 때 함께 고려한다.
  • UI에서만 보안을 적용하는 것: 버튼을 숨기거나 클라이언트에서만 검증하면 API를 직접 호출하는 공격자에게는 무의미하다. 보안 검증은 반드시 서버에서 수행한다.
  • 에러 메시지에 내부 정보를 노출하는 것: 스택 트레이스, DB 쿼리, 내부 경로가 응답에 포함되면 공격자에게 공격 표면 정보를 제공하는 것과 같다.
  • 보안 라이브러리를 직접 구현하려는 것: 암호화 알고리즘, 인증 프로토콜, 토큰 검증 로직은 검증된 라이브러리를 사용한다. “직접 만든 암호화”는 거의 항상 취약하다.
  • AI가 생성한 코드를 무검토로 사용하는 것: AI 도구는 보안 취약점 패턴을 그대로 재현하는 경향이 있다. AI가 생성한 코드도 SAST와 코드 리뷰를 통해 반드시 검토한다. 또한 AI 에이전트가 읽는 컨텍스트(코드, 이슈, PR)에 악의적인 지시가 삽입될 수 있는 간접 프롬프트 인젝션 위험도 인식한다.

15. 성능 (Performance)

성능 최적화는 측정에서 시작한다. 성능은 중요하다. 그러나 측정되지 않은 성능 문제는 존재하지 않는 문제 다. “이 부분이 느릴 것 같다”는 직관은 종종 틀린다. 실제 병목은 예상하지 못한 곳에서 발생하는 경우가 많다. 측정 없이 최적화하는 것은 지도 없이 목적지를 찾는 것과 같다.

성능과 가독성, 유지보수성은 종종 상충한다. 최적화된 코드는 일반적으로 더 복잡하고 이해하기 어렵다. 이 트레이드오프를 정당화하려면 측정된 데이터 가 있어야 한다. 데이터 없는 최적화는 근거 없는 복잡성 추가일 뿐이다.

Premature Optimization 금지

조기 최적화는 코드의 적이다. Donald Knuth가 말했듯, “조기 최적화는 모든 악의 근원이다.” 아직 문제로 확인되지 않은 부분을 최적화하면 다음과 같은 부작용이 발생한다.

  • 코드 복잡성이 불필요하게 높아지고 가독성이 떨어진다
  • 도메인 의도가 최적화 코드 뒤에 숨어 이해하기 어려워진다
  • 나중에 요구사항이 바뀌면 복잡하게 최적화된 코드를 다시 단순화해야 한다
  • 실제 병목이 아닌 곳에 시간을 투자하여 의미 없는 개선에 그친다

최적화는 반드시 측정된 병목에만 적용한다. 코드를 먼저 명확하고 단순하게 작성하고, 실제로 성능 문제가 발생했을 때 프로파일링으로 원인을 확인한 후 최적화한다. 성능 최적화가 가독성과 유지보수성을 희생하는 경우, 반드시 다음을 동반한다.

  • 최적화 이전 코드의 벤치마크 수치
  • 최적화 이후의 벤치마크 수치
  • 왜 이 최적화가 필요했는지에 대한 명시적인 주석
  • 관련 이슈 또는 측정 결과 링크

Measurement First (측정 우선)

문제를 확인하기 전에 최적화하지 않는다. 성능 개선의 순서는 반드시 측정 → 분석 → 최적화 → 재측정 이다. 이 순서를 지키지 않으면 개선했다고 생각한 것이 실제로는 아무 효과가 없거나 오히려 나빠질 수 있다. 측정해야 할 지표를 명확히 정의한다. 막연히 “빠르게”가 아니라 구체적인 수치 목표를 정의한다.

  • 응답 시간: P50, P95, P99 레이턴시 (평균은 이상값에 왜곡되므로 Percentile 사용)
  • 처리량: 초당 요청 수(RPS), 초당 트랜잭션 수(TPS)
  • 리소스 사용량: CPU, 메모리, 네트워크 I/O, 디스크 I/O
  • 에러율: 응답 시간 지연과 에러율은 함께 측정해야 실제 사용자 경험을 반영함

기준선(Baseline)을 먼저 측정한다. 최적화 전의 상태를 측정하지 않으면 최적화가 얼마나 효과적이었는지 알 수 없다. 기준선이 없는 최적화는 개선이 아니라 추측이다.

프로덕션 환경을 대표하는 조건에서 측정한다. 로컬 환경이나 개발 서버의 측정값은 프로덕션과 크게 다를 수 있다. 데이터 크기, 동시 사용자 수, 네트워크 조건이 유사한 환경에서 측정해야 의미 있는 결과를 얻는다.

Profiling 기준

병목은 데이터로 찾는다. 프로파일링(Profiling) 은 코드의 어느 부분이 실제로 시간과 자원을 소비하고 있는지 측정하는 과정이다. 직관과 추측이 아닌 데이터로 병목을 찾는 유일한 방법이다.

프로파일링을 시작하는 시점:

  • 사용자 또는 모니터링에서 성능 저하가 보고되었을 때
  • SLO(Service Level Objective) 기준을 지속적으로 위반할 때
  • 배포 후 메트릭이 이전 버전 대비 유의미하게 나빠졌을 때
  • 예상보다 인프라 비용이 급격히 증가할 때

프로파일링이 필요 없는 시점:

  • “느릴 것 같다”는 직관만 있을 때
  • 실제 사용자나 모니터링에서 문제가 보고되지 않았을 때
  • 코드를 처음 작성하는 단계

프로파일링 절차:

  1. CPU 프로파일링: 어떤 함수가 CPU 시간을 가장 많이 소비하는지 파악한다. 전체 실행 시간의 80%를 차지하는 20%의 코드를 찾는다.
  2. 메모리 프로파일링: 어디서 메모리가 할당되고 해제되는지, 메모리 누수가 있는지 파악한다.
  3. I/O 프로파일링: DB 쿼리, 네트워크 호출, 파일 I/O 중 어느 것이 병목인지 파악한다.
  4. Trace 분석: 분산 시스템에서 요청의 전체 흐름에서 어느 서비스, 어느 구간이 지연을 만드는지 파악한다.

프로파일링은 프로덕션에 가장 가까운 환경에서 수행한다. 프로파일러를 붙이면 성능이 일부 저하되는데, 이 영향이 일관되게 적용되면 상대적인 비교는 여전히 유효하다. 80/20 법칙을 기억한다. 대부분의 성능 문제는 코드의 소수 지점에서 발생한다. 프로파일링 결과에서 가장 많은 시간을 차지하는 상위 3-5개 지점에 집중한다.

Allocation 정책

불필요한 메모리 할당을 줄인다. 메모리 할당과 해제(GC)는 성능 병목의 숨겨진 원인 이다. 빈번한 소규모 할당은 가비지 컬렉터에 압박을 주고 예측 불가능한 지연(GC pause)을 유발한다.

획득한 모든 리소스는 반드시 즉시 해제한다. DB 연결, 파일 핸들, 네트워크 소켓, 스레드처럼 명시적으로 해제해야 하는 리소스는 사용이 끝나는 즉시 반환한다. 리소스를 해제하지 않으면 누수(leak)가 발생하고, 누수가 쌓이면 시스템 전체 성능이 점진적으로 저하된다. 언어가 제공하는 자동 해제 메커니즘(try-with-resources, using, RAII 등)을 적극 활용한다.

Hot Path에서의 할당을 최소화한다. 매우 자주 실행되는 경로(hot path)에서의 반복적인 객체 생성은 GC 압박의 주요 원인이다. 이 경우에만 객체 풀링(Object Pool), 사전 할당, 재사용 등의 최적화를 측정 기반으로 검토한다.

컬렉션 크기를 미리 지정한다. 컬렉션의 크기를 미리 알 수 있다면, 초기 용량을 지정하여 동적 확장 시 발생하는 재할당을 방지한다.

공유 가변 상태(Shared Mutable State)는 성능과 안전성 양면에서 문제가 된다. 동기화가 필요한 공유 상태는 락 경합(lock contention)을 유발하고 동시성 성능을 저하시킨다. 불변 객체를 선호하고, 공유를 최소화하는 것이 성능과 안전성을 동시에 높이는 방법이다.

Caching 기준

캐시는 트레이드오프를 동반한다. 캐시는 성능 개선의 가장 강력한 수단 중 하나지만, 동시에 데이터 일관성 문제 라는 근본적인 복잡성을 도입한다. 캐시는 “최적화 도구”이기 전에 “시스템 복잡도를 높이는 설계 결정”이다. 캐시를 도입하기 전에 반드시 그 비용을 이해해야 한다.

캐시 도입 기준:

캐시는 다음 조건 중 하나 이상을 만족할 때만 도입한다.

  • 프로파일링으로 해당 데이터 조회가 실제 병목임이 확인되었을 때
  • 데이터가 상대적으로 자주 읽히고 드물게 변경될 때 (높은 Read/Write 비율)
  • 동일한 요청이 반복되는 패턴이 존재할 때
  • 원본 데이터 접근 비용이 명확히 높을 때 (외부 API, 복잡한 연산)

캐시를 도입하기 전에 다음 질문에 답할 수 있어야 한다.

  • 이 데이터가 변경되면 캐시는 어떻게 무효화(invalidate)되는가?
  • 캐시와 원본 데이터가 불일치할 때 시스템은 어떻게 동작하는가?
  • 캐시가 없을 때(Cold Start, Cache Miss)의 동작은 정상적인가?
  • 캐시 키를 어떻게 설계할 것인가? 캐시 키 충돌 가능성은 없는가?

캐시 무효화 전략을 명확히 정의한다. 캐시 무효화는 컴퓨터 과학에서 가장 어려운 문제 중 하나다. “데이터가 변경되면 캐시도 변경된다”는 전제를 코드에서 보장하지 않으면, 캐시는 오래된 데이터를 계속 제공하는 버그 공장이 된다.

캐시 레이어를 명확히 구분한다. 캐시는 여러 레이어에 존재할 수 있다. 각 레이어의 목적과 범위, 무효화 방식이 서로 명확히 정의되어야 한다.

캐시 레이어범위적합한 데이터주의사항
In-Process 캐시단일 인스턴스설정, 코드 테이블인스턴스 간 불일치 가능
분산 캐시 (Redis 등)모든 인스턴스 공유세션, 사용자 데이터네트워크 지연, 직렬화 비용
HTTP 캐시클라이언트/CDN정적 리소스, API 응답Cache-Control 헤더 설계
DB 쿼리 캐시DB 레이어복잡한 집계 쿼리쿼리 파라미터별 무효화

캐시 히트율(Hit Rate)을 측정한다. 캐시가 실제로 효과적인지 확인하는 유일한 방법이다. 히트율이 낮다면 캐시 키 설계가 잘못되었거나, 데이터의 접근 패턴이 캐시에 적합하지 않은 것이다.

캐시가 없는 상태(Cache Bypass)에서도 시스템이 정상 동작해야 한다. 캐시는 성능 최적화 수단이지 기능의 필수 구성 요소가 아니다. 캐시 서버 장애 시 원본 데이터에서 직접 서비스할 수 있어야 한다. 캐시에 의존하여 원본 데이터를 삭제하거나 원본 접근 경로가 없는 구조는 캐시 장애가 곧 서비스 장애가 된다.

DB 성능 기준

N+1 문제와 불필요한 쿼리를 방지한다. N+1 쿼리 문제 는 ORM 사용 시 가장 흔히 발생하는 성능 문제다. 목록을 조회한 후 각 항목에 대해 개별 쿼리를 실행하면, N개 항목에 대해 N+1번의 쿼리가 발생한다.

쿼리는 필요한 데이터만 조회한다. SELECT *는 사용하지 않고, 실제로 필요한 컬럼만 명시한다. 대량 데이터를 메모리에 올리지 않고 페이지네이션 또는 스트리밍으로 처리한다.

인덱스는 측정된 쿼리 패턴을 기반으로 추가한다. 모든 컬럼에 인덱스를 추가하는 것은 쓰기 성능을 저하시킨다. 실행 계획(EXPLAIN)을 확인하여 실제로 인덱스가 사용되는지 검증한다.

16. 설정과 환경 구성 (Configuration)

설정은 코드와 분리되어야 한다. 설정(Configuration)은 동일한 코드베이스가 서로 다른 환경(개발, 스테이징, 프로덕션)에서 다르게 동작하게 만드는 모든 값 이다. 데이터베이스 연결 정보, 외부 API 엔드포인트, 타임아웃 값, 기능 플래그가 모두 설정의 범주에 해당한다.

설정과 코드를 분리해야 하는 이유는 단순하다. 코드는 빌드하고 배포하면 변경이 어렵지만, 설정은 환경마다 달라야 한다. 설정이 코드 안에 박혀 있으면 환경을 바꿀 때마다 코드를 수정하고 재배포해야 하며, 그 과정에서 비밀값이 코드 이력에 영구히 남게 된다.

The Twelve-Factor App의 세 번째 원칙인 “Config: 설정을 환경 변수에 저장하라” 가 수년간 업계 표준으로 자리 잡은 이유가 여기에 있다. 설정과 코드를 엄격히 분리함으로써 동일한 아티팩트를 모든 환경에 배포할 수 있고, 환경 간 차이를 명시적으로 관리할 수 있다.

Environment Variable 정책

1) 설정값은 환경 변수로 주입한다

실행 환경에 따라 달라지는 모든 값은 코드에 하드코딩하지 않고 환경 변수 로 주입한다. 비밀값(Secret)은 물론이고, 환경마다 다른 엔드포인트, 타임아웃, 포트 번호도 환경 변수로 관리한다.

2) 필수 설정은 애플리케이션 시작 시점에 검증한다

필수 환경 변수가 설정되지 않은 상태로 애플리케이션이 실행되어 런타임에 예상치 못한 오류가 발생하는 것을 방지한다. Fail-Fast 원칙 을 설정에도 적용한다. 필수 설정이 누락되었다면 애플리케이션 시작 시점에 명확한 에러 메시지와 함께 즉시 실패한다.

3) 환경 변수 네이밍 규칙

환경 변수명은 팀 전체가 일관된 규칙을 따른다.

  • 대문자 스네이크 케이스 를 사용한다 (DATABASE_URL, MAX_RETRY_COUNT)
  • 서비스/컴포넌트 접두사 로 충돌을 방지한다 (ORDER_SERVICE_TIMEOUT, PAYMENT_API_KEY)
  • 의미를 드러내는 이름 을 사용한다. DB1, KEY2 같은 이름은 무엇을 위한 설정인지 알 수 없다

모든 환경 변수 목록과 설명, 기본값, 필수 여부를 .env.example 파일에 문서화한다. 신규 팀원이 이 파일만 보고 환경을 구성할 수 있어야 한다.

Runtime Configuration (런타임 설정 변경)

재배포 없이 변경 가능한 설정을 분리한다. 모든 설정이 애플리케이션 시작 시점에만 읽히는 것은 아니다. 런타임에 재배포 없이 변경 가능해야 하는 설정 을 별도로 관리하면 운영 유연성이 크게 높아진다.

런타임 설정 변경이 필요한 상황:

  • 트래픽 급증 시 타임아웃, 재시도 횟수, 동시성 한도를 즉시 조정
  • 장애 상황에서 특정 외부 시스템 호출을 차단 (Circuit Breaker 임시 설정)
  • A/B 테스트나 단계적 기능 출시를 위한 비율 조정
  • 비용 절감을 위해 일부 기능을 임시로 비활성화

런타임 설정 변경은 감사 로그 와 함께 관리한다. 누가, 언제, 어떤 설정을 어떻게 바꿨는지 추적할 수 없으면 장애 원인 분석이 어려워진다. 런타임 설정이 변경된 사실을 코드에서 인식할 수 있도록 폴링(Polling) 또는 이벤트 기반 갱신 메커니즘을 설계한다. 설정이 변경되었는데 코드가 여전히 이전 값을 사용하는 상황은 런타임 설정의 의미를 없앤다.

설정 관리 금지 패턴

  • 설정값을 코드에 직접 박는 것 은 환경 간 이동을 불가능하게 만든다. 타임아웃, 재시도 횟수, URL, 포트 번호 등 환경에 따라 달라질 수 있는 모든 값은 코드 밖으로 꺼낸다.
  • 비밀값을 설정 파일에 포함하는 것 은 보안 사고의 가장 흔한 원인이다. application.yaml, config.json 같은 파일에 비밀번호나 API 키가 포함되면 버전 관리 시스템에 영구히 노출된다. 비밀값은 반드시 비밀값 관리 서비스 또는 환경 변수로만 관리한다.
  • 환경 변수를 검증 없이 사용하는 것 은 디버깅하기 어려운 런타임 에러를 만든다. 필수 환경 변수는 시작 시점에 일괄 검증하고, 타입 변환(문자열 → 정수, 불리언)도 이 시점에 수행하여 타입 오류가 런타임에 발생하지 않도록 한다.

17. Change Process (작업 흐름)

설계, 구현, 코드 리뷰, 리팩토링 판단을 할 때마다 “이번 결정에서 가장 중요한 평가 기준은 무엇인가?” 를 먼저 명확히 선언하고, 어떤 가치를 우선하며 어떤 가치를 양보하는지 트레이드오프를 명시적으로 기록한다.

기본 작업 흐름

  1. 변경할 도메인 의도와 영향을 충분히 이해한다.
  2. Preparatory Refactoring으로 구조 개선
  3. 최소한의 기능 변경 수행
  4. 테스트 추가/갱신
  5. Boy Scout Rule 적용하여 전체 정리
  6. 리뷰에서 가독성, 중복, 복잡도를 중점 확인

의사결정 평가축 (8개)

평가축주요 근거 및 출처설명
목적 적합성Value-Focused Thinking (Ralph L. Keeney, 1992)결정의 출발점인 “진짜 가치와 목적”을 최우선으로 삼아야 한다는 Keeney의 핵심 사상
제약 적합성Requirements Engineering (기능/비기능 요구사항), Systems EngineeringFunctional & Non-functional requirements를 체계적으로 고려하는 전통적 접근
실현 가능성INCOSE Systems Engineering Handbook의 Feasibility Analysis기술적·경제적·운영적·스케줄 feasibility를 기술적·조직적 관점으로 압축
품질 영향ISO/IEC 25019:2023 (Product Quality Model)Maintainability, Reliability, Security, Performance 등 소프트웨어 품질 국제 표준
시간 효과Technical Debt (Ward Cunningham, 1992)단기 vs 장기 관점에서의 비용·가치 트레이드오프
위험 및 불확실성Decision Analysis / Decision Quality의 Uncertainty 평가불확실성에 대한 명시적 체크
무결성Architecture Decision Records (ADR, Michael Nygard)아키텍처 결정의 일관성과 장기적 무결성을 유지하기 위한 기록 수단
합의 가능성Decision Quality의 Commitment to Action + Stakeholder Buy-in좋은 결정이라도 실행 의지와 합의가 없으면 실패한다

원칙을 어기는 코드와 의사결정은 특정 패턴으로 반복된다. 아래에는 코드 레벨에서 즉시 피해야 할 Anti-Pattern과, 조직·프로세스 수준에서 누적되는 실패 패턴을 나누어 정리한다.

18. 금지하는 Anti-Patterns

코드 레벨에서 강력히 금지하는 Anti-Patterns

  • Anemic Domain Model — 데이터만 있고 행동(비즈니스 규칙)이 거의 없는 객체
  • DB-First / UI-First Domain Modeling — 데이터베이스 구조나 화면 설계에서 도메인 모델을 역으로 유도하는 것
  • Technical Naming Dominance — 기술적 이름이 도메인 개념을 압도하는 것
  • God Class / God Object — 하나의 클래스가 너무 많은 책임을 가져 이해와 유지보수가 매우 어려워지는 클래스
  • Primitive Obsession — 도메인 개념을 String, int, boolean 등의 기본 타입으로만 표현하는 것
  • Feature Envy — 메서드가 자신의 데이터보다 다른 클래스의 데이터에 과도하게 의존하는 코드
  • Long-lived Duplicated Logic & Deep Nesting (DRY 위반) — 중복 로직 방치와 과도하게 깊은 코드 중첩
  • Shared Mutable State — 공유 가변 상태로 인한 암묵적 의존과 예측 불가능한 버그
  • Silent Failure — 예외를 잡아서 무시하거나 의미 없는 기본값으로 대체하는 행위
  • Magic Numbers & Magic Strings — 의미 없는 리터럴 값을 직접 코드에 사용하는 것
  • Excessive Null Checking / Null Propagation Hell — Null을 기본값으로 남용하고 과도한 null 체크가 난무하는 설계
  • Giant Files / Oversized Modules — 하나의 파일이나 모듈이 지나치게 커져 책임이 모호해지는 것
  • Utility Dumping Grounds — 의미 없는 Utils, Helper, Common 클래스에 로직을 마구 dump하는 행위
  • Dead Code — 도달할 수 없는 코드, 사용되지 않는 변수·메서드·클래스가 코드베이스에 잔류하는 것. 버전 관리 시스템이 이력을 보관하므로 미사용 코드는 주석 처리하지 않고 삭제한다.

설계 / 아키텍처 레벨 Anti-Patterns

  • Big-bang Rewrite — 점진적 리팩토링 대신 한 번에 대규모 재작성을 시도하는 것
  • Speculative Abstraction (YAGNI) — 실제 필요하지 않은 미래를 위한 과도한 추상화
  • Excessive Layering / Meaningless Layering — 불필요하게 많은 레이어로 인한 복잡도 증가
  • Hidden Side Effects — 숨겨진 부수효과 (action at a distance)
  • Cyclic Dependencies — 순환 의존성
  • Deep Inheritance — 과도한 상속 계층 구조
  • Framework-driven Design — 도메인보다 프레임워크 구조에 맞추는 설계
  • Meta-programming Abuse — 과도한 메타프로그래밍과 리플렉션 남용
  • Inconsistent Naming — 일관성 없는 명명 규칙
  • Context-heavy Architecture — 지나치게 많은 컨텍스트를 요구하는 복잡한 아키텍처
  • Prompt-dependent Code Understanding — AI 프롬프트에 의존해야만 이해 가능한 코드

19. 실패 패턴들

다음은 코드, 조직, 프로세스 수준에서 반복되는 실패 패턴들이다. 위 Anti-Pattern이 코드 레벨의 즉각적 문제라면, 아래는 팀과 프로젝트 단위에서 누적되는 구조적 실패다.

바퀴의 재발명

  • 인증(Spring Security, JWT 등), 로깅(Logback, Log4j, SLF4J, Winston, Serilog), 캐시(Redis, Ehcache, Caffeine, Guava Cache), 날짜, 알림, 국제화, 페이징, 유효성 검사, 상태관리 등은 라이브러리가 있는데 자체적으로 구현하는 것.
  • DB 제약 조건으로 충분히 해결할 수 있는 것을 애플리케이션 코드에서 if문으로 검증하는 것.

Over Engineering

  • DBA가 없는 팀에 복잡한 샤딩 + 멀티마스터 DB 구성을 처음부터 설계하는 것.
  • 24시간 온콜 체계가 없는 팀인데 멀티 리전 고가용성 아키텍처를 도입하는 것.
  • Kubernetes나 Kafka 운영 경험이 거의 없는 팀이 본격 도입하는 것.
  • 상태가 ‘신청 → 승인 → 확정’ 정도로 단순한데 이벤트 소싱 + CQRS를 도입하는 것.
  • 하루 500건 정도의 CSV 처리를 Kafka + Consumer Group으로 구성하는 것.
  • 간단한 입력 검증(필수, 자리수, 정규식)에 Drools 같은 룰 엔진을 도입하는 것.
  • SRE 전담 인력이 없는 팀에 SLO, Error Budget, Chaos Engineering을 도입하는 것.

땜방식 대응

  • 경계값 오류(Off-by-one, 빈 배열, 최대값, 최소값, 인덱스 범위 초과)를 고려하지 않은 채 배포하는 것.
  • null/nil/None 체크 안하거나 NullPointerException만 피하려고 if (x != null)을 단순 추가하는 것. 원인은 그대로 둔다.
  • 타입을 안 쓰거나, Any/unknown/Object 남발 (특히 TS, Python)
  • Controller나 Service에 도메인 로직을 직접 추가하는 것.
  • 도메인 규칙을 무시하고 화면에서만 validation을 하는 것.
  • 테스트가 깨지면 기대값을 실제 구현에 맞춰 수정하는 것.
  • 하드코딩 (URL, API 키, 설정값, 매직 넘버)

준비 안된 출발

  • 요구사항이 아직 불명확한데 화면, API, DB를 먼저 만드는 것.
  • 에러 발생 시 업무 처리가 정해지지 않았는데 예외 처리 코드를 먼저 작성하는 것.
  • 권한 정책이 미정인데 UI에서만 숨기는 것.
  • 데이터 이관 방침이 결정되기 전에 이관 스크립트를 먼저 작성하는 것.
  • 성능 요구사항(건수, 응답시간)이 정해지지 않았는데 Redis 캐시를 처음부터 도입하는 것.

너무 이른 추상화

  • 구현이 아직 하나밖에 없는데 Strategy 패턴을 도입하는 것.
  • 검색 조건이 name과 status 두 개뿐인데 독자 DSL을 설계하는 것.
  • 설정 항목이 거의 변하지 않을 것 같은데 미리 DB 기반 설정 관리 화면을 만드는 것.

핵심 제약을 사후에 추가하는 역순 개발 관행

  • CRUD 기능을 먼저 만든 뒤, 나중에 권한 체크를 추가하는 것.
  • 데이터 갱신 기능을 만든 뒤에 감사 로그를 뒤늦게 추가하는 것.
  • 삭제 기능을 만든 후에 “이력 관리가 필요하다”는 요구를 받고 대응하는 것.
  • 동기 연동을 만든 후 재시도·멱등성(idempotency) 처리를 나중에 붙이는 것

“한 번 데였다고 다 금지하는” 반지성적 규칙

  • TypeScript 도입을 “타입 관리할 사람이 없다”며 거부하고 계속 JavaScript로 개발하는 것.
  • 과거에 빌드 자동화가 실패한 경험이 있다고 해서 CI/CD를 포기하고 수동 빌드·배포를 유지하는 것
  • 과거 Lambda/Stream API로 버그가 난 적이 있다고 해서, 모든 반복문을 확장 for문으로 강제하는 것.
  • Optional 사용을 금지하고, null 체크를 호출하는 쪽에 모두 떠넘기는 것.

과거에 대한 과도한 배려

  • 기존 API를 깨뜨리지 않으려고 신규 API와 기존 API를 병렬로 계속 유지하는 것.
  • 오류 거부감 때문에 is_deleted, is_archived, is_suspended 같은 DB 칼럼이 계속 쌓이는 것.

이 문서가 제안하는 원칙들은 특정 언어나 프레임워크에 종속되지 않는다. 좋은 코드란 도메인의 행동을 명확하게 인코딩하여 미래의 개발자가 안전하게 이해하고 변경할 수 있게 하는 코드다. AI가 코드를 생성하고 수정하는 빈도가 높아질수록, 코드가 얼마나 명확하게 의도를 드러내는지가 AI의 추론 품질을 결정한다. 원칙을 외우는 것이 아니라, 코드를 작성할 때마다 “이 코드가 도메인의 의도를 명확히 드러내는가”를 스스로 묻는 습관이 출발점이다.


Mimul

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

Related ArticlesView All

Related StoriesView All