코딩 스타일 가이드는 보통 언어별로 존재한다. Python에는 PEP 8이 있고, Java에는 Google Java Style Guide가 있으며, Go에는 Effective Go가 있다. Rust 프로젝트를 시작하면 rustfmt와 Clippy가 스타일을 잡아준다. 이렇게 언어별 컨벤션은 잘 정비되어 있지만, 막상 여러 언어를 오가며 작업할 때 일관된 설계 철학이 없으면 코드는 언어만 다를 뿐 같은 문제를 반복한다.
LLM를 활용해 Java, Rust, TypeScript, Python, Go 등 여러 언어로 동시에 작업하면서 이 문제가 더 선명해졌다. AI가 코드를 생성할 때 적용할 수 있는 언어 독립적인 설계 원칙이 필요했다. 단순히 “중첩을 줄여라”, “이름을 명확히 해라” 수준이 아니라, 왜 그렇게 해야 하는지 근거가 있는 원칙을 기술한다.
철학적 뿌리
이 코딩 스타일은 세 사람의 사상을 중심으로 구성된다.
Kent Beck은 XP(Extreme Programming)와 TDD를 통해 “동작하는 코드를 빠르게, 그리고 지속적으로 개선하라”는 관점을 제시했다. 그의 핵심 통찰은 소프트웨어 설계의 목표가 완벽한 구조를 한 번에 만드는 것이 아니라, 변경하기 쉬운 상태를 항상 유지하는 것이라는 점이다.
Martin Fowler는 리팩토링을 체계화했다. 그에게 리팩토링은 기능을 추가하기 전에 코드를 정돈하는 별도의 단계가 아니다. 코드를 읽고 이해하고 수정하는 모든 과정에 자연스럽게 녹아 있어야 하는 개발 리듬이다.
Eric Evans는 DDD(Domain-Driven Design)를 통해 코드가 비즈니스 도메인의 언어를 반영해야 한다고 주장했다. 코드베이스가 도메인 전문가와 개발자가 함께 쓰는 언어로 작성될 때, 요구사항의 변화를 코드에 정확하게 반영할 수 있다.
이 세 관점은 겉으로는 다르지만 하나의 방향을 가리킨다. 코드는 읽히고, 이해되고, 변경될 것이다. 그 변경을 쉽게 만드는 것이 설계의 본질이다.
핵심 원칙
변화 용이성을 최우선으로
코드를 작성할 때 가장 먼저 고려해야 할 것은 성능이 아니다. “이 코드를 3개월 후에 누군가가 수정할 수 있는가”가 먼저다.
성능이 중요하지 않다는 뜻이 아니다. 성능은 병목이 측정된 이후에 최적화하는 것이고, 설계 단계에서 추측으로 복잡성을 추가하는 것은 오히려 변경 비용을 높인다. 지금 당장의 성능 이득보다 미래의 유지보수 비용이 대부분의 프로젝트에서 훨씬 크다.
마찬가지로, 미래 요구사항을 추측해 미리 추상화를 만드는 것도 피한다. Rule of Three를 따른다. 동일한 패턴이 세 번 반복된 이후에야 추상화를 도입한다. 그 이전의 추상화는 종종 잘못된 방향으로 코드를 묶어버린다.
코드는 의도를 말해야 한다
짧은 코드가 좋은 코드가 아니다. 코드는 “무엇을 하는지”가 아니라 왜 존재하는지를 설명해야 한다.
// 나쁜 예: 무엇을 하는지는 알겠지만 왜 하는지 모른다
if user.role == 1 && user.status == 0 {
send_email(user);
}
// 좋은 예: 의도가 드러난다
if user.is_eligible_for_notification() {
send_email(user);
}주석을 추가하기 전에 먼저 코드 구조를 개선해야 한다. 주석이 필요하다는 신호는 코드가 충분히 설명적이지 않다는 뜻이다.
명시적 선언도 같은 원칙의 연장이다. 언어가 접근 제한자나 타입을 기본값으로 추론해 주더라도, 독자가 그 기본값을 알고 있다고 가정하지 않는다. 기본값이더라도 명시적으로 선언하는 것이 의도를 더 분명하게 전달한다.
// 반환 타입을 TypeScript가 추론에 맡김
function findUser(id: string) {
return users.find(u => u.id === id);
}
// 반환 타입을 명시해 의도를 드러냄
function findUser(id: string): User | undefined {
return users.find(u => u.id === id);
}반환 타입이 User | undefined라는 사실은 호출하는 쪽에서 반드시 인지해야 할 정보다. 추론에 맡기면 IDE가 알려주지만, 코드 리뷰나 diff를 보는 상황에서는 보이지 않는다.
리터럴 숫자나 문자열을 코드에 직접 쓰는 것도 피해야 한다. 매직 넘버는 의도를 숨기고, 같은 값이 여러 곳에 흩어지면 변경 시 모든 곳을 찾아야 한다.
# 나쁜 예: 1609.344 가 무엇인지 알 수 없다
def mile_to_metre(mi: float) -> float:
return mi * 1609.344
# 좋은 예: 의도와 단위가 드러난다
ONE_MILE_IN_METRES = 1609.344
def mile_to_metre(mi: float) -> float:
return mi * ONE_MILE_IN_METRES주석도 같은 원칙을 따른다. “주석보다 코드 구조 개선”이 우선이지만, 불가피하게 주석이 필요하다면 “무엇”이 아닌 “왜” 를 써야 한다. 코드를 읽으면 무엇을 하는지는 보인다. 코드로는 보이지 않는 것이 왜 그렇게 했는지다.
# 나쁜 예: 코드가 이미 말하는 것을 반복
# 금액이 1000 이상이면 5% 할인
if total >= 1000:
total *= 0.95
# 좋은 예: 코드로 보이지 않는 이유를 기록
# 2018년 VIP 캠페인 약정. 1000 이상 주문에 5% 할인 (기획서 #1234)
if total >= 1000:
total *= 0.95도메인 언어를 코드에 반영한다
개발자와 도메인 전문가가 서로 다른 언어를 쓰는 순간부터 번역 비용이 발생한다. 요구사항의 “주문 취소”가 코드에서는 cancelOrder인 곳도 있고 deleteOrder인 곳도 있다면, 어떤 함수가 어떤 요구사항에 해당하는지 추적하는 일이 불필요하게 복잡해진다.
유비쿼터스 언어(Ubiquitous Language) — 팀 내에서 합의된 도메인 용어 — 를 코드 전반에 일관되게 사용한다. 범용 유틸리티는 도메인 개념이 먼저 정의된 이후에 만든다.
작고 되돌릴 수 있는 단위로 변경한다
변경은 항상 작은 단위로 한다. 한 번의 커밋에 하나의 의도만 담는다. 이는 리뷰를 쉽게 만들고, 문제가 생겼을 때 되돌리기 쉽게 만든다.
코드는 항상 동작하는 상태를 유지해야 한다. 큰 리팩토링도 중간 상태를 거치면서 각 단계가 테스트를 통과하는 방식으로 진행한다.
기존 컨텍스트의 관례를 따른다
새 코드를 작성할 때는 자신의 스타일 원칙을 적용할 수 있다. 그러나 기존 코드베이스에 합류하거나 기존 파일을 수정할 때는 다르다. 그 파일 또는 프로젝트에서 이미 사용 중인 관례가 자신의 취향보다 우선한다.
코드베이스 전체에서 두 가지 스타일이 혼재하는 것이 어느 한 스타일이 “더 나쁜” 것보다 더 나쁘다. 일관성 자체가 읽기 쉬운 코드의 조건이기 때문이다. 기존 스타일이 마음에 들지 않는다면, 팀과 합의해 전체를 한 번에 바꾸는 것이 옳다. 일부만 바꾸는 것은 그 코드를 읽는 다음 사람에게 판단 비용을 넘기는 행위다.
불확실할 때는 질문한다
구현을 시작하기 전에, 요구사항에 모호한 부분이 있다면 추측으로 채우지 않는다. 추측은 나중에 수정 비용을 높이는 잘못된 설계의 씨앗이 된다. 더 간단한 접근 방법이 보인다면 그것을 먼저 언급한다.
설계 원칙
작고 조합 가능한 구조
함수는 하나의 책임만 가진다. 조건문과 중첩의 깊이는 최대 2단계로 제한한다. 이 두 규칙은 서로 연결되어 있다. 함수가 여러 책임을 가지면 중첩이 깊어지고, 중첩이 깊어지면 흐름을 추적하기 어려워진다.
복잡한 조건문은 의도를 드러내는 메서드로 분리한다. “Tell, Don’t Ask” 원칙을 따른다. 객체의 상태를 물어서 외부에서 결정하는 대신, 객체에게 행동을 요청한다.
// Ask 방식: 외부에서 상태를 물어 결정
if (order.status === 'pending' && order.items.length > 0) {
order.status = 'confirmed';
}
// Tell 방식: 객체에게 행동을 위임
order.confirm();메서드 분리 전이라도, 복잡한 조건식은 의미 있는 이름의 임시 변수로 먼저 분해한다. 이름을 붙이는 것만으로 조건의 의도가 드러난다.
# 나쁜 예: 조건식이 무엇을 판단하는지 한눈에 들어오지 않는다
if (file.open(path, "w") is not None) and is_valid(path) or is_cached(path):
process(file)
# 좋은 예: 임시 변수로 의도를 명시한다
file_is_accessible = (file.open(path, "w") is not None) and is_valid(path)
result_is_cached = is_cached(path)
if file_is_accessible or result_is_cached:
process(file)도메인 모델에 행동을 담는다
빈약한 도메인 모델(Anemic Domain Model)은 데이터 클래스에 비즈니스 로직이 서비스 레이어에 분산된 형태다. 이 패턴은 처음에는 단순해 보이지만, 도메인이 복잡해질수록 서비스 레이어가 비대해지고 로직 파악이 어려워진다.
데이터와 행동을 함께 둔다. 도메인 로직은 객체 내부에 캡슐화된다. 이렇게 하면 도메인 개념이 코드에서 명확히 드러나고, 관련된 로직이 한 곳에 모인다.
추상화는 중복이 드러난 이후에
추상화의 목적은 중복 제거다. 중복이 없는데 추상화를 만들면 오히려 코드 탐색 비용이 늘어난다. 추상화 계층이 하나 추가될 때마다 독자는 그 계층을 이해하는 비용을 지불한다.
패턴이 드러난 이후에만 추상화한다. 미래 확장을 위한 인터페이스나 팩토리를 미리 만들지 않는다. YAGNI(You Aren’t Gonna Need It) — 지금 필요하지 않은 것은 만들지 않는다.
같은 이유로 타입은 기본적으로 확장에 닫혀 있어야 한다. 상속을 허용하는 것은 API를 공개하는 것과 같다. 파생 클래스가 어디서 어떻게 사용될지 예측하지 못한 채 상속을 열어두면, 나중에 내부 구현을 변경할 때 파생 클래스가 예상치 못한 방식으로 깨질 수 있다. Java의 final, Kotlin의 기본 봉인 클래스, Rust의 enum 밀봉 패턴 — 언어마다 표현 방식은 다르지만 원칙은 같다. 확장이 필요하다는 것이 명확해졌을 때 열면 된다. 처음부터 열어놓으면 닫기가 어렵다.
접근 권한은 최소한으로 정의한다
클래스, 메서드, 변수의 공개 범위는 필요한 최소한으로 제한한다. 공개 범위를 넓게 열어두면 의존하는 곳이 늘어나고, 나중에 변경할 때 파급 범위를 추적하기 어렵다.
private으로 선언한 멤버는 언제든 변경하거나 삭제할 수 있다. 반면 public으로 열어둔 순간 그것은 외부와의 계약이 된다. 호환성을 깨지 않으려면 변경 전에 모든 호출 지점을 확인해야 한다. Alibaba 가이드의 표현을 빌리면, “느슨한 접근 제어는 모듈 간 해로운 결합을 만든다.”
실천 방법은 단순하다. 새 멤버를 선언할 때 일단 가장 좁은 범위로 시작한다. 범위를 넓히는 것은 나중에 할 수 있지만, 이미 열어둔 API를 다시 닫는 것은 훨씬 어렵다.
추상 타입으로 참조한다
변수를 선언할 때 구현 타입이 아닌 추상 타입(인터페이스, 추상 클래스)으로 선언한다. 구체적인 구현은 할당 오른쪽에만 드러난다.
// 나쁜 예: 구현 타입으로 선언하면 구현 교체 시 선언부도 변경해야 한다
ArrayList<User> users = new ArrayList<>();
// 좋은 예: 추상 타입으로 선언, 구현은 우변에만
List<User> users = new ArrayList<>();구현을 LinkedList나 다른 자료구조로 교체할 때, 선언 타입이 List라면 선언부를 건드리지 않아도 된다. 이 습관이 쌓이면 구현 변경의 파급 범위가 자연스럽게 줄어든다. 이는 “변화 용이성”의 작고 구체적인 실천이다.
변수는 사용 직전에 선언한다
변수의 선언 위치와 사용 위치가 멀수록, 코드를 읽는 사람이 더 많은 문맥을 기억해야 한다. 변수는 처음 사용하는 지점 바로 위에 선언한다.
# 나쁜 예: 선언과 사용이 멀다
result = None
items = fetch_items()
filtered = [x for x in items if x.active]
# ... 다른 처리들 ...
result = calculate(filtered)
# 좋은 예: 필요한 순간에 선언
items = fetch_items()
filtered = [x for x in items if x.active]
result = calculate(filtered)스코프를 좁히는 것도 같은 원칙이다. 변수가 살아있는 범위가 좁을수록 그 변수에 대한 추론이 단순해진다. 블록 밖에서 쓰이지 않는 변수는 블록 안에서 선언한다. 변수 하나를 여러 용도로 재사용하는 것도 금지한다. 이름이 같아도 다른 의미를 담은 순간 독자는 혼란에 빠진다.
리팩토링은 개발의 리듬이다
Martin Fowler는 “리팩토링은 기능 추가와 교대로 이루어지는 것”이라고 말했다. 기능을 추가하기 전, 기존 코드의 구조가 새 기능을 받아들이기 어렵다면 먼저 구조를 개선한다. 이것은 별도의 “리팩토링 스프린트”가 아니라 매일의 개발 습관이다.
리팩토링이 필요한 신호는 명확하다.
- 동일한 코드가 3번 이상 반복될 때
- 이름이 의도를 충분히 설명하지 못할 때
- 함수가 여러 책임을 가지기 시작할 때
- 도메인 개념이 코 드에서 사라지고 있을 때
이 신호를 무시하고 기능을 계속 쌓으면 기술 부채가 된다. 기술 부채는 복리로 쌓인다. 초기에 10분이면 고칠 수 있던 것이 나중에는 며칠짜리 작업이 된다.
죽은 코드는 즉시 제거한다. 주석 처리된 코드, 사용하지 않는 함수, 오래된 설정이 코드베이스에 남아있으면 독자에게 불필요한 질문을 남긴다. “이 코드가 나중에 쓰일 코드인가, 안전하게 지워도 되는가”를 판단하는 비용이 생기는 것이다. git이 모든 이력을 기록한다. 삭제가 곧 소실이 아니다. 언제든 복구할 수 있다는 것을 알면 삭제를 두려워할 이유가 없다.
네이밍은 도메인의 번역이다
이름 짓기는 설계 행위다. 변수나 함수의 이름을 짓는 순간, 개발자는 그 개념을 어떻게 이해하고 있는지를 선언한다.
data, util, helper, manager는 아무것도 말하지 않는 이름이다. 이런 이름이 등장하면 그 개념의 역할이 아직 명확히 정의되지 않았다는 신호다. 개념을 먼저 명확히 하고, 그 개념에 맞는 이름을 붙인다.
축약어는 피한다. usr보다 user가, cfg보다 config가 낫다. 타이핑 시간의 차이는 미미하지만 읽는 시간의 차이는 크다. 코드는 작성 횟수보다 읽히는 횟수가 훨씬 많다.
구현 방식이 아닌 의도를 표현한다.
# 구현 방식을 드러내는 이름
def get_user_from_db(user_id): ...
def loop_and_calculate_total(items): ...
# 의도를 드러내는 이름
def find_user(user_id): ...
def calculate_order_total(items): ...동일한 개념에 여러 이름을 쓰지 않는다. fetch, get, retrieve, load가 같은 의미로 혼용되면 코드베이스 전체에서 패턴을 파악하기 어려워진다.
경계 조건은 도메인의 일부다
경계 조건을 예외적인 케이스로 취급하는 것이 흔한 실수다. “재고가 없는 경우”, “사용자가 로그인하지 않은 경우”, “결제가 실패한 경우” — 이것들은 예외가 아니라 도메인이 정상적으로 다루어야 하는 상태다.
null, 빈 값, 실패 상태를 명확한 타입이나 개념으로 표현한다. “없음”을 null로 암묵적으로 표현하는 대신, Option<T>, Maybe<T>, Result<T, E> 같은 명시적인 타입을 활용한다. 이렇게 하면 컴파일러가 경계 조건 처리를 강제하고, 숨겨진 가정이 사라진다.
Invalid state는 가능한 한 타입 수준에서 표현 불가능하게 만든다. 런타임에 방어 코드를 쌓는 것보다, 그 상태 자체가 존재할 수 없는 타입 설계가 더 견고하다.
컬렉션을 반환하는 함수는 null 대신 빈 컬렉션을 돌려준다. null을 반환하면 호출하는 쪽에서 항상 null 체크를 강요받고, 체크를 빠뜨리면 런타임 오류로 이어진다. “결과 없음”은 null이 아니라 빈 컬렉션으로 표현하는 것이 더 자연스럽고 안전하다.
# 나쁜 예: null 반환으로 호출자에게 null 체크 부담
def find_orders(user_id: str) -> list | None:
if not user_id:
return None
# 좋은 예: 빈 리스트로 "결과 없음"을 표현
def find_orders(user_id: str) -> list:
if not user_id:
return []호출하는 쪽은 null 체크 없이 일관되게 순회할 수 있다. 이 패턴은 Optional/Result 타입과 같은 방향을 가리킨다. “없음”을 명시적인 값으로 표현해 암묵적인 null 전파를 막는 것이다.
null/None과 빈 값(0, "", [])은 의미가 다르다. null은 “값이 설정되지 않았다”는 의미이고, 빈 값은 “값이 있지만 비어있다”는 의미다. 이 두 개념을 혼용하면 조건문이 두 상태를 동시에 삼켜 숨겨진 버그가 생긴다.
count = 0 # 유효한 값: 재고가 0개
count = None # 다른 의미: 재고를 아직 조회하지 않음
# 나쁜 예: 0과 None을 동시에 처리 — 의도가 모호하다
if not count:
fetch_inventory()
# 좋은 예: 두 상태를 명시적으로 구분한다
if count is None:
fetch_inventory()이 원칙의 방향은 하나다. 오류를 런타임에서 컴파일 타임으로 끌어올리는 것. 런타임 오류는 운영 환경에서 발견되고, 컴파일 타임 오류는 코드를 작성하는 시점에 발견된다. 문자열 리터럴 대신 언어가 제공하는 타입 참조를 쓰는 것(C#의 nameof(), TypeScript의 keyof, Rust의 stringify!)도 같은 전략이다. 이름을 바꿀 때 문자열은 조용히 깨지지만, 타입 참조는 컴파일러가 오류를 낸다. 컴파일러를 협력자로 만드는 것이 목표다.
에러는 침묵해서는 안 된다. 오류를 무시하거나 의미 없는 기본값으로 덮으면 문제가 숨어든다. fallback 로직은 반드시 그 의도를 드러내야 한다. 임시 처리가 코드에 남아 있으면 그것은 기술 부채다.
assert는 프로덕션 입력 검증 수단이 아니다. Python의 -O 플래그, Java의 어서션 기본 비활성화, C/C++의 NDEBUG — 언어를 가리지 않고 assert는 빌드 설정으로 제거될 수 있다. 이것을 사용자 입력이나 외부 데이터 검증에 쓰면 운영 환경에서 검증이 조용히 사라진다. assert는 “이 시점에 이 조건은 반드시 참이어야 한다”는 개발자 간의 불변식(invariant) 선언이다. 프로덕션에서의 입력 검증은 명시적인 예외로 처리한다.
# 나쁜 예: assert는 -O 플래그로 실행 시 무시된다
def set_port(port: int) -> None:
assert 1024 <= port <= 65535, f"Invalid port: {port}"
# 좋은 예: 명시적 예외로 항상 검증된다
def set_port(port: int) -> None:
if not (1024 <= port <= 65535):
raise ValueError(f"Port must be 1024–65535, got {port}")예외를 던질 때는 반드시 Error/Exception 객체를 사용한다. 문자열이나 일반 객체를 던지면 스택 트레이스가 사라진다. 스택 트레이스 없이는 예외가 어디서 발생했는지 추적할 수 없다.
// 나쁜 예: 스택 트레이스가 없다
throw 'Invalid user id';
throw { message: 'not found', code: 404 };
// 좋은 예: 스택 트레이스가 보존된다
throw new Error('Invalid user id');
throw new NotFoundError('User not found');타입 캐스팅은 런타임 검사를 동반해야 한다. 컴파일러가 타입 변환을 허용했다고 해서 런타임에도 안전하다는 보장은 없다. TypeScript의 as, Java의 다운캐스팅, Python의 cast() — 이것들은 컴파일러에게 “내가 책임진다”고 말하는 것이다. 그 책임은 런타임 검사로 뒷받침되어야 한다.
// 나쁜 예: 런타임 검사 없는 단언
(x as User).getName();
// 좋은 예: instanceof로 검사 후 사용
if (x instanceof User) {
x.getName();
}switch/match 문에는 항상 default 케이스를 포함한다. default가 없으면 나중에 새 enum 값이나 케이스가 추가될 때 조용히 아무것도 하지 않는 버그가 생긴다. 이것은 “침묵하는 실패”의 전형이다. 처리할 내용이 없더라도 default를 명시적으로 두면 의도가 드러나고, 나중에 케이스가 추가될 때 컴파일러나 린터가 경고를 줄 수 있다.
switch (status) {
case 'active':
enable();
break;
case 'inactive':
disable();
break;
default:
// 알 수 없는 상태 — 명시적으로 처리하지 않음을 선언
throw new Error(`Unhandled status: ${status}`);
}예외(Exception)는 진짜 예외적인 상황에만 쓴다. 비즈니스 분기를 처리하기 위해 예외를 발생시키고 catch로 흐름을 제어하는 것은 두 가지 문제를 동시에 일으킨다. 첫째, 예외 객체 생성과 스택 캡처는 일반 조건문보다 훨씬 비싸다. 둘째, try-catch로 감싸진 흐름은 독자가 “이게 정상 경로인가, 오류 경로인가”를 판단하기 어렵게 만든다. 예측 가능한 상태는 조건문으로, 진짜 예외적인 상황만 예외로 표현한다.
# 나쁜 예: 예외를 제어 흐름으로 사용
def find_user(user_id: str) -> User:
try:
return user_map[user_id]
except KeyError:
return create_guest_user() # 정상 분기인데 예외로 표현
# 좋은 예: 조건문으로 정상 흐름을 표현
def find_user(user_id: str) -> User:
if user_id not in user_map:
return create_guest_user()
return user_map[user_id]성능은 측정이 먼저다
Donald Knuth의 말은 지금도 유효하다. “Premature optimization is the root of all evil.” 측정 없는 최적화는 대부분 엉뚱한 곳에 복잡성을 추가한다.
성능 문제는 다음 순서로 접근한다.
- 먼저 올바르게 동작하게 만든다
- 병목을 측정한다
- 병목이 확인된 부분만 최적화한다
알고리즘 복잡도는 의식적으로 선택한다. O(n²) 이상의 복잡도가 코드에 들어갈 때는 명시적인 이유가 있어야 한다. 반복문 내부에서 DB나 네트워크 호출을 하는 N+1 패턴은 대규모 데이터에서 가장 흔하고 심각한 성능 문제다.
데이터 접근은 필요한 것만 한다. SELECT *는 불필요한 데이터를 가져오고 인덱스 활용을 방해한다. 같은 데이터를 반복 조회한다면 캐싱 전략을 검토한다. 트랜잭션 범위는 필요한 최소한으로 유지한다. 범위가 넓을수록 락 경합이 발생할 가능성이 높아진다.
획득한 리소스(커넥션, 파일, 소켓)는 반드시 해제한다. 리소스 누수는 즉각 드러나지 않아서 운영 환경에서 뒤늦게 발견되는 경우가 많다.
공유 가변 상태는 동시성 문제의 주요 원인이면서, 동시에 테스트 신뢰성을 해치는 주범이기도 하다. 전역 가변 상태가 있으면 테스트가 실행 순서에 따라 결과가 달라진다. 테스트 A가 전역 상태를 변경한 채로 끝나면 테스트 B의 결과가 오염된다. 이런 테스트는 단독으로 실행하면 통과하지만 전체 스위트에서 실패하거나, 그 반대 현상이 생긴다. 전역 가변 상태를 최소화하는 이유는 동시성 안전성과 테스트 독립성, 두 가지 모두다.
안티 패턴 요약
좋은 설계 원칙을 이해하는 것만큼 나쁜 패턴을 인식하는 것도 중요하다. 다음 패턴들은 개별 언어와 무관하게 반복적으로 나타난다.
| 안티 패턴 | 증상 | 해결 방향 |
| 성급한 추상화 | 중복이 없는데 인터페이스와 레이어가 넘침 | 3회 반복 후 추상화 |
| 빈약한 도메인 모델 | 서비스 레이어에 비즈니스 로직 집중 | 도메인 객체에 행동 부여 |
| 깊은 중첩 구조 | 조건문 4단계 이상 | 조기 반환, 메서드 분리 |
| 암묵적 경계 처리 | null 반환, 침묵하는 실패 | 명시적 타입과 에러 전달 |
| 테스트 없는 핵심 로직 | 리팩토링 시 회귀 발생 | 핵심 경로 테스트 먼저 |
| 측정 없는 성능 최적화 | 가독성이 나쁜 “최적화된” 코드 | 프로파일링 후 접근 |
| N+1 쿼리 | 반복문 내 DB 호출 | 배치 조회, JOIN 활용 |
| 무분별한 상속 계층 | 기반 클래스 수정이 파생 클래스를 깨뜨림 | 타입 기본 봉인, 합성 우선 |
| 스타일 혼재 | 한 파일 안에 두 가지 관례가 공존 | 기존 관례 따르거나 전체 일괄 변경 |
| 매직 넘버 사용 | 코드에 의미 불명의 숫자/문자열 리터럴 직접 등장 | 의미 있는 이름의 상수로 대체 |
| 구현 타입으로 참조 | ArrayList list처럼 구현체를 변수 타입으로 선언 | 추상 타입으로 선언, 구현은 우변에만 |
| 변수 재사용 | 하나의 변수를 다른 용도로 재활용 | 용도마다 별도 변수 선언 |
| 예외를 제어 흐름으로 사용 | try-catch로 정상 분기를 표현 | 예측 가능한 상태는 조건문으로 처리 |
| 죽은 코드 방치 | 주석 처리 코드·미사용 함수가 코드베이스에 잔존 | 즉시 삭제, 이력은 git이 보관 |
| 과도한 접근 공개 | 불필요하게 public/internal로 선언 | 가장 좁은 범위로 시작해 필요 시 확장 |
| assert로 프로덕션 검증 | assert가 빌드 설정으로 제거되어 검증이 사라짐 | 명시적 예외(ValueError 등)로 항상 검증 |
| null과 빈 값 혼용 | if not value가 None과 0/""/[]을 동시에 처리 | 두 상태를 명시적으로 구분해 처리 |
| 예외를 문자열/객체로 던짐 | throw 'error'로 스택 트레이스 소실 | 반드시 Error/Exception 객체로 던짐 |
| 런타임 검사 없는 타입 캐스팅 | (x as Foo)만으로 안전하다고 가정 | instanceof·typeof 검사 후 사용 |
| switch default 누락 | 새 케이스 추가 시 조용히 무시됨 | 항상 default 포함, 미처리 케이스 명시 |
이 기준을 공통으로 쓰는 이유
이 코딩 스타일은 특정 언어의 컨벤션이 아니다. Java, Rust, TypeScript, Python, Go — 어떤 언어로 작업하더라도 적용 가능한 설계 철학이다.
언어별 공식 스타일 가이드는 별도로 존재한다. 이 글의 원칙과 함께 참조하면 언어 특유의 관용구와 포매팅 기준을 맞출 수 있다.
| 언어 | 공식 스타일 가이드 | 핵심 도구 |
| Java | Google Java Style Guide | Checkstyle, google-java-format |
| Python | PEP 8 | Black, Flake8, Ruff |
| Go | Effective Go | gofmt (빌드에 내장) |
| Rust | Rust API Guidelines | rustfmt, Clippy |
| TypeScript | Google TypeScript Style Guide | ESLint, Prettier |
Google Java Style Guide의 핵심만 짚으면: 클래스명은 UpperCamelCase, 메서드·변수는 lowerCamelCase, 상수는 UPPER_SNAKE_CASE, 들여쓰기는 2칸, 줄 길이는 100자다. 재정의 메서드에는 @Override를 항상 붙이고, 잡은 예외를 조용히 무시할 때는 반드시 주석으로 이유를 남긴다.
LLM을 통해 AI와 함께 코드를 작성할 때, AI가 코드를 생성하는 기준이 명확해야 한다. “좋은 코드”의 정의가 모호하면 AI는 매번 다른 스타일의 코드를 만들어낸다. 이 기준을 ~/.claude/rules/coding-style.md에 두고 모든 프로젝트에 공통으로 적용하면, AI와의 협업에서도 일관된 설계 판단이 나온다.
핵심은 단순하다. 읽기 쉽고, 도메인을 반영하며, 변경하기 쉬운 코드. 이 세 가지를 유지하는 것이 언어를 불문하고 좋은 코드의 본질이다.



