클린 코드 심화 가이드 — 인지 복잡도·코드 스멜·안전한 리팩터링·SOLID·프로덕션 패턴
이 글의 핵심
클린 코드는 “규칙 암기”가 아니라 읽기·변경·검증 비용을 줄이는 공학입니다. 메트릭·스멜·안전한 변경·설계 원칙·프로덕션 관점을 한 흐름으로 묶었습니다.
들어가며
클린 코드는 이름 짓기와 들여쓰기만의 문제가 아닙니다. 코드가 읽기 어려운 정도를 측정하고, 반복되는 설계 결함(코드 스멜)을 알아보고, 검증 가능한 작은 단계로 고치고, 변경 이유에 따라 모듈을 나누고, 로그와 장애, 배포까지 고려하는 일 전체를 포함합니다. 이 글은 책의 목차를 따라가기보다, 리뷰와 리팩터링을 할 때 실제로 판단 기준으로 쓸 수 있는 내용 위주로 정리했습니다.
인지 복잡도(Cognitive Complexity) 메트릭
왜 또 다른 복잡도인가
순환 복잡도(Cyclomatic Complexity)는 코드의 독립 경로 수를 세기 때문에, if 네 개가 네 단계로 중첩되어 있든 나란히 나열되어 있든 같은 점수를 줍니다. 하지만 사람에게는 중첩된 쪽이 훨씬 읽기 어렵습니다. 인지 복잡도(Cognitive Complexity)는 SonarSource가 2017년에 제안한 지표로, 코드를 위에서 아래로 읽을 때 머릿속에 유지해야 하는 상태에 가중치를 둡니다. 기본 철학은 선형 흐름은 싸고, 흐름을 끊는 구조와 중첩은 비싸다는 것입니다.
계산 직관(요지)
SonarSource 명세의 규칙을 요약하면 다음과 같습니다. 다른 도구도 대체로 이 정의를 따릅니다.
- 함수는 0점에서 시작합니다.
if,else if,else,switch,for,while,catch, 삼항 연산자처럼 선형 흐름을 끊는 구조마다 1점이 붙습니다.try와finally자체는 점수가 없습니다.- 이 구조가 다른 구조 안에 중첩되어 있으면 중첩 깊이만큼 추가 점수가 붙습니다. 같은
if라도 세 겹 안에 있으면 1점이 아니라 4점입니다.else와else if는 중첩 가산을 받지 않습니다. a && b && c처럼 같은 논리 연산자가 이어지면 한 묶음에 1점이고,a && b || c처럼 연산자가 바뀔 때마다 1점이 더 붙습니다.- 재귀 호출에도 1점이 붙습니다.
반대로 switch 전체는 case가 몇 개든 1점이라서, 순환 복잡도는 높지만 읽기 쉬운 switch는 인지 복잡도로는 불리하지 않습니다. 정확한 규칙은 사용하는 분석기(SonarQube, eslint-plugin-sonarjs 등) 문서를 확인하세요. 숫자 자체가 정답은 아니고, 리뷰에서 “왜 여기만 읽기 힘든가”를 이야기하게 만드는 계기로 쓰는 것이 핵심입니다.
임계값을 어떻게 쓰는가
SonarQube의 기본 규칙은 함수당 15점을 넘으면 경고합니다. 이 값을 넘으면 분해 후보로 보고, 넘지 않더라도 변경이 잦은 코드에는 더 낮은 기준을 적용하는 식이 실용적입니다. 메트릭을 CI의 하드 게이트로 걸면 점수를 맞추려고 의미 없이 함수를 쪼개는 부작용이 생기기 쉽습니다. 변경 빈도와 결함 이력이 높은 파일(핫스팟)과 함께 보는 보조 지표로 쓸 때 팀의 저항도 적고 효과도 큽니다.
인지 복잡도를 낮추는 리팩터링 방향
- 조기 반환(early return)으로 중첩을 펼쳐 정상 경로가 들여쓰기 없이 아래로 흐르게 합니다.
if (a && !b || c && d)같은 조건을isEligibleForRefund처럼 이름 있는 불리언이나 함수로 뽑아 하나의 질문으로 바꿉니다.- 같은 키에 따라 동작이 늘어나는
switch/if연쇄는 맵이나 전략 객체로 바꿔 데이터로 표현합니다. - 예외를 흐름 제어에 쓰지 않습니다. 예상 가능한 실패는
Result나 오류 코드 같은 반환값으로 표현합니다.
아래는 같은 동작을 중첩 구조에서 선형 흐름으로 바꾼 단순한 예입니다.
// 높은 중첩: 한 화면에 “가능한 모든 경로”가 겹쳐 보임
function canShip(order: Order): boolean {
if (order.items.length > 0) {
if (order.address != null) {
if (!order.address.invalid) {
if (order.payment.status === "captured") {
return true;
}
}
}
}
return false;
}
// 조기 반환: 각 조건이 “왜 불가한지”를 이름으로 밀어냄
function canShip(order: Order): boolean {
if (order.items.length === 0) return false;
if (order.address == null) return false;
if (order.address.invalid) return false;
return order.payment.status === "captured";
}
앞의 규칙대로 세어 보면 첫 번째 형태는 중첩 가산 때문에 1+2+3+4=10점이고, 두 번째는 if 세 개로 3점입니다. 점수보다 중요한 차이는 두 번째 형태에서는 각 줄이 “이 조건이면 배송 불가”라는 독립된 문장으로 읽힌다는 점입니다. 다만 조기 반환이 여러 함수에 흩어져 같은 도메인 규칙이 여기저기서 중복되면 오히려 추적이 어려워지므로, 배송 가능 조건 같은 정책은 한 함수에 모아 두는 것이 좋습니다.
코드 스멜 탐지 패턴
코드 스멜은 버그가 아니라 설계 결함의 징후입니다. 아래 항목은 규칙이 아니라 경험적으로 알려진 신호이고, 이름은 대부분 마틴 파울러의 『리팩터링』에서 온 것입니다.
긴 함수(Long Method)와 긴 클래스(Large Class)
- 신호: 스크롤 한 화면을 넘기거나, 주석 없이는 “한 문단 요약”이 어렵습니다.
- 의미: 응집도가 떨어지거나, 여러 변경 이유가 한 덩어리에 섞였을 가능성이 큽니다.
- 다음 행동: 의미 단위로 메서드를 추출하고, 변경 이유별로 모듈을 나눌 수 있는지 검토합니다.
기능 편애(Feature Envy)
- 신호: 클래스 A의 메서드가 B의 필드·getter를 연달아 건드립니다.
- 의미: 책임이 A가 아니라 B에 가깝다는 뜻일 수 있습니다.
- 다음 행동: 메서드를 B로 옮기거나, 여러 객체에 걸친 규칙이면 도메인 서비스로 끌어올립니다.
원시 집착(Primitive Obsession)
- 신호: 이메일, 금액, 전화번호를 모두
string이나number로 여기저기 전달합니다. - 의미: 형식과 범위 검증이 호출부마다 흩어져 중복되고, 어떤 곳은 검증을 빠뜨립니다. 금액을 부동소수점
number로 다루다 반올림 오차가 생기는 것도 이 스멜의 전형적인 결과입니다. - 다음 행동: 값 객체(Value Object)·브랜디드 타입으로 감쌉니다.
데이터 뭉치(Data Clumps)
- 신호: 같은 세 필드가 여러 함수 시그니처에 반복됩니다.
- 의미: 개념이 하나로 묶일 준비가 되었습니다.
- 다음 행동: 구조체·클래스·레코드로 묶으며, 생성 시점에 검증합니다.
산탄총 수술(Shotgun Surgery)
- 신호: 작은 요구사항 하나를 반영하는데 여러 파일을 동시에 고쳐야 합니다.
- 의미: 하나의 개념이 여러 곳에 흩어져 있거나, 중복된 로직이 따로따로 관리되고 있을 수 있습니다.
- 다음 행동: 흩어진 로직을 한 모듈로 모으거나, 파사드로 진입점을 하나로 줄입니다.
발산적 변경(Divergent Change)
- 신호: 한 클래스가 서로 다른 이유로 자주 바뀝니다. 산탄총 수술과 정반대 모양입니다.
- 의미: 단일 책임 원칙 위반이 의심됩니다.
- 다음 행동: 변경 이유별로 타입·모듈을 쪼갭니다.
임시 필드(Temporary Field)
- 신호: 객체가 특정 메서드 시퀀스에서만 의미 있는 필드를 갖습니다.
- 의미: 상태 머신이나 중간 DTO로 표현하는 편이 안전할 수 있습니다.
- 다음 행동: 그 필드들을 별도 컨텍스트 객체로 옮겨 범위를 좁히거나, 메서드 인자로 명시적으로 전달합니다.
추측성 일반화(Speculative Generality)
- 신호: “나중에 필요할 수도” 있는 훅·추상화가 사용처 없이 남아 있습니다.
- 의미: YAGNI 위반이며, 읽는 비용만 남았을 가능성이 큽니다.
- 다음 행동: 실제 요구가 생길 때까지 제거합니다. 필요해지면 그때 리팩터링으로 다시 도입하는 편이, 쓰이지 않는 추상화를 유지하는 것보다 쌉니다.
스멜을 찾았다고 모두 바로 고칠 필요는 없습니다. 변경 빈도와 결함 이력을 보고 영향이 큰 곳부터 작은 단위로 고쳐 보는 것이 비용 대비 효과가 좋습니다. 한 번도 수정되지 않는 오래된 모듈의 긴 함수보다, 매주 수정되는 모듈의 중간 길이 함수가 먼저입니다.
리팩터링 안전 기법
리팩터링은 외부에서 관찰되는 동작을 유지한 채 구조를 개선하는 것입니다. 안전하다는 판단은 감이 아니라 테스트와 도구 같은 증거로 만들어야 합니다.
특성화 테스트(Characterization Test)
레거시에 테스트가 없을 때 현재 동작을 그대로 사실로 받아들여 고정하는 테스트입니다. 마이클 페더스가 『레거시 코드 활용 전략』에서 이름 붙인 기법으로, 현재 동작이 옳은지는 따지지 않습니다. 입력, 출력, 로그, 생성 파일 같은 관찰 가능한 결과를 먼저 고정해 두고, 리팩터링 후에도 같은지 확인합니다. 버그처럼 보이는 동작도 일단 그대로 고정하고, 고치는 일은 리팩터링과 분리된 별도 변경으로 처리해야 어느 쪽 때문에 결과가 바뀌었는지 헷갈리지 않습니다.
골든 마스터(Golden Master)
배치 작업, 컴파일러, 포맷터처럼 출력이 많은 경우에는 승인된 출력 전체를 기준선으로 두고 차이만 검사합니다. 난수나 현재 시각에 의존하는 부분은 시드를 고정하거나 시계를 주입해 통제해야 결과가 매번 같아집니다.
작은 단계와 컴파일·타입 보조
한 PR에 구조 개선과 기능 추가를 섞지 않습니다. 섞이면 리뷰어가 동작 변화와 구조 변화를 구분할 수 없고, 문제가 생겼을 때 되돌리기도 어렵습니다. 이름 바꾸기, 이동, 추출을 단계별로 나누고, 정적 타입 언어라면 컴파일러가 깨진 참조를 목록으로 보여 주게 합니다. 동적 언어라면 계약 테스트와 스모크 테스트를 짧게 자주 돌립니다.
기능 플래그·스트랭글러 무화과(Strangler Fig)
외부에 노출된 API나 UI를 한 번에 갈아엎기 어렵다면, 새 구현을 옆에 두고 플래그나 라우팅으로 트래픽을 조금씩 옮깁니다. 레거시의 한가운데를 고치는 대신 가장자리부터 새 코드로 감싸 들어가는 방식이라, 문제가 생기면 플래그만 되돌리면 됩니다.
브랜치 바이 추상화(Branch by Abstraction)
라이브러리나 저장소를 통째로 교체할 때, 먼저 임시 추상 계층을 두어 호출부가 인터페이스만 보게 만든 뒤 구현을 하나씩 갈아 끼웁니다. 긴 수명의 기능 브랜치 대신 메인 브랜치에서 교체를 진행할 수 있어, 병합 충돌과 한꺼번에 바꾸는 위험을 줄입니다.
자동 리팩터링 도구 우선
이름 바꾸기, 메서드 추출, 시그니처 변경처럼 기계적으로 처리할 수 있는 변환은 IDE 리팩터링 기능이나 jscodeshift, OpenRewrite 같은 코드모드 도구에 맡깁니다. 손으로 수십 곳을 고치다 보면 한두 곳을 빠뜨리기 쉽습니다. 사람은 이름과 모듈 경계를 정하는 일에 집중합니다.
SOLID 원칙 실전 적용
SOLID는 변경 비용을 예측 가능하게 만들기 위한 공용어입니다. 외울 약어로 보기보다 아래처럼 코드 리뷰에서 던지는 질문으로 쓰면 실무에서 쓸모가 큽니다.
단일 책임(SRP)
- 질문: “이 모듈이 바뀌는 이유는 몇 가지인가?”
- 실전: HTTP 처리, 도메인 규칙, 저장 로직이 한 클래스에 있으면 어느 하나를 바꿔도 나머지 테스트가 함께 흔들립니다. 경계를 나누되, 파일만 늘어나는 과도한 분할을 피하려면 실제로 따로 바뀌는 축을 기준으로 나눕니다.
개방·폐쇄(OCP)
- 질문: “새 종류를 추가할 때 기존 코드를 ‘수정’해야 하는가?”
- 실전: 새 종류가 추가될 때마다 같은
if/switch연쇄가 여러 곳에서 늘어난다면 전략 패턴, 레지스트리, 다형성을 검토합니다. 반대로 분기가 한 곳에만 있고 거의 늘지 않는다면switch그대로가 가장 읽기 쉽습니다.
리스코프 치환(LSP)
- 질문: “부모 타입을 쓰는 자리에 자식을 넣어도 계약이 깨지지 않는가?”
- 실전: 오버라이드에서 “지원하지 않음” 예외를 던지거나, 부모보다 강한 전제 조건을 요구하는 것은 계약 위반 신호입니다. 정사각형이 직사각형을 상속했다가 너비만 바꾸는 코드가 깨지는 예가 유명합니다. 이런 경우 상속보다 합성이 안전합니다.
인터페이스 분리(ISP)
- 질문: “클라이언트가 실제로 쓰지 않는 메서드에 의존하고 있지 않은가?”
- 실전: 거대한
UserService인터페이스는 읽기 전용 조회와 관리자용 변경 명령으로 나눕니다. 조회만 하는 화면이 관리자 메서드의 시그니처 변경 때문에 다시 빌드되는 일이 사라집니다.
의존 역전(DIP)
- 질문: “고수준 정책이 구체 구현(프레임워크·DB 드라이버)에 직접 고정되어 있는가?”
- 실전: 인터페이스(포트)는 도메인 쪽에 두고, 구현(어댑터)은 인프라 쪽에 둡니다. 모든 클래스에 인터페이스를 두는 것은 과잉입니다. 테스트에서 대체해야 하거나 실제로 구현이 바뀔 수 있는 경계(DB, 외부 API, 시계)에 집중합니다.
SOLID를 “과하게” 적용할 때의 부작용
- 추상화 남발: 구현이 하나뿐인 인터페이스가 늘어나면 “정의로 이동”을 두 번씩 눌러야 하고 탐색 비용이 커집니다.
- 프레임워크와의 싸움: DI 컨테이너 설정이 비즈니스 로직보다 길어질 수 있습니다.
- 성능 민감 경로: 촘촘한 루프 안의 가상 호출은 인라인을 막아 최적화를 방해할 수 있습니다.
원칙은 트레이드오프를 설명하는 공용어로 쓰고, 어떤 원칙을 의도적으로 어겼다면 그 이유를 리뷰와 ADR에 남깁니다.
프로덕션 클린 코드 패턴
개발자 PC에서 깔끔해 보이던 코드가 운영에서 문제를 일으키는 원인은 대개 코드 모양이 아니라 관측 가능성 부족, 부분 실패 처리 미흡, 경계 조건과 동시성, 배포·롤백 절차를 고려하지 않은 변경에 있습니다.
경계에서의 검증과 오류 모델
- 입력 경계: HTTP, 큐, RPC를 받는 지점에서 스키마 검증을 끝내고, 내부 코드는 이미 검증된 타입만 받게 합니다. 그러면 내부 함수마다 null 검사와 형식 검사를 반복하지 않아도 됩니다.
- 출력 경계: 외부 계약(API 버전, 필드 호환성)은 어댑터 한 곳에서 매핑합니다.
- 오류 전파: 예외와 결과 타입 중 어느 쪽을 쓸지 팀 표준을 정하고, 재시도해도 되는 오류와 안 되는 오류, 클라이언트 오류(4xx)와 서버 오류(5xx)를 구분합니다. 이 구분이 흐리면 잘못된 요청을 무한히 재시도하거나, 일시적 장애를 사용자 잘못으로 돌려보내게 됩니다.
멱등성·중복 처리
클라이언트, 프록시, 메시지 큐는 모두 실패하면 재시도합니다. 그래서 같은 요청이 두 번 도착하는 것을 정상 상황으로 가정해야 합니다. 멱등 키를 받아 이미 처리한 요청인지 확인하거나, 정확히 한 번 처리를 보장할 수 없다면 적어도 두 번 처리해도 결과가 같도록 설계합니다. 코드가 아무리 깔끔해도 결제 핸들러가 재시도 때문에 중복 결제를 만든다면 그 코드는 운영에서 실패한 코드입니다.
로깅·메트릭·추적
- 구조화 로그: 요청을 추적하는 trace id를 모든 로그의 표준 필드로 넣습니다. 그래야 여러 서비스에 흩어진 로그를 한 요청 단위로 모아 볼 수 있습니다.
- 로그 레벨:
info는 정상 업무 흐름,error는 누군가 조치해야 하는 실패에만 씁니다. 재시도로 복구되는 일시적 오류까지error로 남기면 알림이 무뎌집니다. - 메트릭: 지연 시간 분포(평균이 아닌 백분위), 오류율, 포화도를 대시보드로 확인할 수 있게 둡니다.
설정·비밀·기능 플래그
비밀 값은 코드에 넣지 않고 환경 변수나 시크릿 저장소에서 주입합니다. 기능 플래그는 제거 시점을 정해 두지 않으면 분기만 늘어나 영원히 남는 if가 되므로, 플래그를 만들 때 제거 담당자와 기한을 함께 정해 두는 것이 좋습니다.
동시성과 자원 수명
공유 가변 상태는 락, 원자적 연산, 불변 데이터 구조 중 하나로 보호해야 합니다. 부하가 낮은 개발 환경에서는 드러나지 않다가 운영 트래픽에서만 나타나는 경쟁 상태는 코드 스타일 점수와 상관없이 시스템을 무너뜨립니다. 파일 핸들, 커넥션, 타이머처럼 수명이 있는 자원도 누가 언제 해제하는지가 코드에서 드러나야 합니다.
배포·롤백 친화적 변경
배포 중에는 옛 버전과 새 버전의 코드가 동시에 돌아갑니다. 그래서 데이터베이스 스키마는 새 컬럼 추가, 양쪽에 쓰기, 새 컬럼에서 읽기로 전환, 옛 컬럼 정리처럼 단계적으로 바꿔야 어느 단계에서든 롤백할 수 있습니다(expand-contract 패턴). 컬럼 이름을 한 번에 바꾸는 마이그레이션은 코드가 아무리 깔끔해도 배포하는 순간 옛 버전을 깨뜨립니다.
문서화의 최소 단위(ADR)
“왜 여기서는 SOLID를 어겼는가”, “왜 이 경로에만 캐시를 허용하는가” 같은 결정은 ADR(Architecture Decision Record) 한 장으로 남기면 충분합니다. 코드는 무엇을 하는지는 보여 주지만 왜 다른 선택지를 버렸는지는 보여 주지 못하기 때문입니다.
자주 묻는 질문 (FAQ)
Q. 기능 편애(Feature Envy)는 코드에서 어떻게 알아보고 고치나요?
A. 클래스 A의 메서드가 자기 필드보다 다른 클래스 B의 필드나 getter를 연달아 호출하고 있다면 기능 편애를 의심할 수 있습니다. 이는 그 로직의 책임이 A가 아니라 데이터를 가진 B에 더 가깝다는 신호이므로, 메서드를 B로 옮기거나 여러 객체에 걸친 규칙이라면 도메인 서비스로 끌어올립니다. 다만 전략 객체나 방문자 패턴처럼 의도적으로 로직을 데이터와 분리한 경우도 있으니 설계 의도를 먼저 확인하는 것이 좋습니다.
같이 보면 좋은 글
- const·noexcept·[[nodiscard]]로 C++ 인터페이스 의도 명확히 하기
- [[nodiscard]]로 반환값 무시 막기
- Tailwind CSS로 컴포넌트·토큰 정리하는 실전 패턴 | 디자인 시스템
- TanStack Query 캐싱이 실제로 동작하는 방식: staleTime·gcTime, select로 리렌더 줄이기, 커스텀 훅