코드 리팩토링 기법: 프로덕션에서 정말 동작하는 방법
요약
코드는 '아름다운' 코드가 아니라 '운영하기 안전한' 코드여야 한다. 함수가 일곱 가지를 하면 다음 장애의 원인을 숨긴다. Extract Method부터 Move Method까지 여섯 가지 리팩토링 기법을 MTTR 기준으로 배운다.
코드 리팩토링 기법: 프로덕션에서 정말 동작하는 방법
코드 리팩토링 기법은 미학의 문제가 아니다. 새벽 3시에 디버깅해야 할 범위를 줄이는 것이 핵심이다. 지속적 배포를 하는 플랫폼 팀은 코드 가독성보다 배포 안전성을 먼저 생각한다. 여기서는 프로덕션 환경에서 실제로 동작하는 여섯 가지 리팩토링 기법을 다룬다.
대부분의 팀이 리팩토링을 잘못된 타이밍에 하는 이유
흔한 조언은 코드 냄새를 볼 때 리팩토링하라는 것이다: 중복, 긴 메서드, 깊은 중첩. 틀린 건 아니지만, 플랫폼 팀 입장에서 봤을 때는 최고의 신호가 아니다.
가장 효과 높은 신호는 같은 모듈에서 되풀이되는 장애다. 포스트모템이 계속 같은 서비스, 같은 파일, 같은 함수를 가리킨다면, 그것이 리팩토링 대상이다. 코드가 못생겨서가 아니라, 현재 MTTR를 깎아먹고 있기 때문이다.
2024년 스택오버플로우 개발자 설문조사에 따르면, 개발자의 62%가 기술 부채를 주요 스트레스 요인으로 꼽았다. 문제는 대부분 팀이 "리팩토링 스프린트"를 일정에 짜놓고는 실제로는 미루고 미룬다는 것이다. 리팩토링이 성공하려면 분기별 청소가 아니라 배포 이벤트와 연결된 지속적인 활동이어야 한다.
프로덕션 팀에서 보이는 패턴 하나: 기능을 추가하고 배포한 후에 리팩토링하지 말고, 기능을 추가하기 전에 먼저 리팩토링하라.

Extract Method: 배포 폭발 범위를 줄이는 리팩토링
Extract Method는 가장 많이 쓰이는 리팩토링 기법이다. 너무 많은 걸 하는 로직 블록을 이름 있는 함수로 뽑아낸다. 원리는 간단하다. 하지만 포스트모템을 몇 번 겪고 나서야 운영 임팩트를 알게 된다.
한 가지만 하는 메서드는 배포 폭발 범위가 좁다. 실패할 때 한 곳에서 실패하고, 호출 스택이 명확하고, 테스트가 이를 걸러내야 한다. 일곱 가지를 하는 메서드는 일곱 배의 실패 공간을 만든다.
배포 전 실전 테스트: "이 함수가 하는 일을 한 문장으로 설명할 수 있나? 단, 'and'를 쓸 수 없다." 아니면 Extract Method의 대상이다.
함수를 단순히 짧게 만드는 게 목표라면 스킵하라. 뽑아낸 단위가 명확한 책임과 독립적인 테스트를 가질 때만 추출하라.
TypeScript 예시:
// Before
async function processUserEvent(event: UserEvent): Promise<void> {
const user = await db.users.findById(event.userId);
if (!user) throw new Error('User not found');
const plan = await billing.getActivePlan(user.id);
if (plan.status !== 'active') return;
await analytics.track('event_processed', { userId: user.id });
await notifications.send(user.email, buildEventPayload(event));
}
// After
async function processUserEvent(event: UserEvent): Promise<void> {
const user = await requireUser(event.userId);
if (!isEligibleForProcessing(await billing.getActivePlan(user.id))) return;
await recordAndNotify(user, event);
}"After" 버전은 세 개의 호출 지점을 가진다. 각각 테스트 가능하고, 이름 붙일 수 있고, 독립적으로 바꿀 수 있다. 청구 체크가 타임아웃을 던지기 시작하면, 스택 트레이스는 isEligibleForProcessing을 가리킨다. 새벽 3시에 60줄짜리 함수를 마음속으로 파싱해야 하는 상황은 아니다.
Replace Conditional with Polymorphism: 포스트모템이 가리킬 지점
조건문은 쌓인다. 기능 플래그로 두 가지 경로를 배포했다. 여섯 달 뒤 여덟 개의 경로, 중첩된 삼항 연산자, 그리고 아무도 지우기 확신하지 못하는 isLegacyUser 불린 파라미터가 있다.
Replace Conditional with Polymorphism은 이를 다루는 기법이다. 타입에 따라 분기하는 함수 대신, 각 경우를 다루는 서브클래스나 구현을 만든다.
이 리팩토링의 운영상 근거: 조건문 분기는 비대칭적으로 실패한다. 테스트한 경로는 행복한 경로다. 프로덕션을 깨트린 경로는 여섯 달 전 급하게 추가했던 것이고 스테이징에서는 한 번도 안 됐다.
Polymorphism은 분기를 없애지 않는다. 각 분기를 명시적이고 테스트 가능한 단위로 만든다. 새 사용자 타입의 Canary 배포는 기존 구현에 손을 대지 않는다.
// Before
function calculateRolloutPercentage(user: User, feature: Feature): number {
if (user.plan === 'enterprise') return 0;
if (user.cohort === 'beta') return 100;
if (feature.flags.includes('gradual')) return feature.percentage;
return 0;
}
// After: 각 전략은 독립적으로 테스트 가능하고 배포 가능
interface RolloutStrategy {
calculatePercentage(user: User, feature: Feature): number;
}
class EnterpriseRolloutStrategy implements RolloutStrategy {
calculatePercentage(): number { return 0; }
}
class BetaCohortRolloutStrategy implements RolloutStrategy {
calculatePercentage(): number { return 100; }
}
class GradualRolloutStrategy implements RolloutStrategy {
calculatePercentage(user: User, feature: Feature): number {
return feature.percentage;
}
}새로운 경우를 추가할 때마다 모든 기존 경우를 이해해야 하는 함수가 있다면, 투자할 가치가 있다. 지난 1년간 안정적인 두 경로만 있다면 스킵해도 된다.

Preparatory Refactoring: 모든 배포 전의 게이트
Preparatory Refactoring은 대부분의 가이드에 안 나온다. 구조적 변환이 아니기 때문이다. 그냥 의사결정이다: 새 기능을 배포하기 전에, 그 기능이 손댈 코드를 깨끗이 하라.
운영상 근거는 간단하다. 어차피 이 코드를 읽고 수정할 것이다. 복잡한 함수를 이해하는 비용은 기능을 추가할 때든 새벽 3시에 장애를 디버깅할 때든 같다. 유일한 차이는 장애 시간에는 시계가 돈다는 것이다.
지속적 배포를 하는 플랫폼 팀에서는, Preparatory Refactoring이 테스트 커버리지를 강제한다. 테스트 없이는 코드를 안전하게 재구조화할 수 없다. 따라서 프로세스는 이렇게 된다: 기존 동작을 확인하는 characterization 테스트 작성, 리팩토링으로 변경 쉽게 만들기, 기능 배포.
이것이 기능 플래그가 코드 리팩토링과 직결되는 지점이다. 잘 구조화된 Rollout 전략은 제어 평면(플래그가 활성화됐나)을 비즈니스 로직(활성화되면 뭐하나)과 분리하는 코드를 요구한다. 그 분리가 없다면, Preparatory Refactoring이 Canary를 시작하기 전에 그것을 만든다.
Replace Temp with Query: 작은 기법의 큰 운영 효과
이건 표준 리팩토링 문헌에서 과소평가된다. Replace Temp with Query는 계산값을 보관하는 임시 변수를 메서드 호출로 바꾼다는 뜻이다.
운영상 보상: 메서드는 메모이제이션되거나, 캐시되거나, 독립적으로 최적화될 수 있다. 임시 변수는 로컬 상태라서 관찰 불가능하고, 독립적으로 테스트할 수 없고, 주변 함수 전체를 이해하지 않으면 바꿀 수 없다.
관찰성 관점에서: 메서드 호출은 추적(trace)에 나타난다. 로컬 변수는 안 나타난다. "지난 10분간 calculateEligibility의 모든 호출을 보여달라"는 쿼리는 실행할 수 있다. "함수 processEvent 안의 로컬 변수 eligible을 보여달라"는 불가능하다.
// Before
async function shouldEnrollUser(user: User): Promise<boolean> {
const plan = await billing.getActivePlan(user.id);
const eligible = plan.status === 'active' && plan.tier !== 'free';
return eligible;
}
// After
async function isUserEligibleForEnrollment(user: User): Promise<boolean> {
return isPlanActiveAndPaid(await billing.getActivePlan(user.id));
}
function isPlanActiveAndPaid(plan: Plan): boolean {
return plan.status === 'active' && plan.tier !== 'free';
}isPlanActiveAndPaid는 이제 청구 서비스 모킹 없이 단위 테스트 가능하다. 스택 트레이스에 나타난다. 청구 레이턴시가 문제가 되면 캐시 버전으로 바꿀 수 있다.
Encapsulate Collection: 서비스 경계 너머로 상태 유출 멈추기
플랫폼 팀은 분산 시스템에서 일한다. 내부 상태 유출이 장애의 불균형하게 큰 비중을 차지한다. 리스트를 그대로 노출한다. 소비자가 추가한다. 이제 두 서비스는 명시적 계약 없이 암묵적으로 결합돼 있다.
Encapsulate Collection은 내부 컬렉션을 직접 노출하지 말고, 그것을 조작하는 메서드만 노출한다는 뜻이다.
미학 선호도 문제가 아니다. 배포 안전 근거다. 새 서비스 버전을 배포할 때 소비자가 내부 데이터 구조 변화를 몰라도 되면, 배포 폭발 범위가 줄어든다. 원시 리스트를 노출하고 소비자가 순서에 로직을 짜면, 안전하다고 생각한 배포 중에 깨질 보이지 않는 의존성을 만든 것이다.
// Before: 내부 상태를 노출
class FeatureFlagSet {
public flags: FeatureFlag[] = [];
}
// After: 통제된 인터페이스
class FeatureFlagSet {
private flags: FeatureFlag[] = [];
add(flag: FeatureFlag): void {
if (this.flags.some(f => f.key === flag.key)) return; // dedup
this.flags.push(flag);
}
isEnabled(key: string, context: RolloutContext): boolean {
return this.flags.find(f => f.key === key)?.evaluate(context) ?? false;
}
count(): number { return this.flags.length; }
}add 메서드는 이제 중복 제거를 강제한다. isEnabled 메서드는 계측할 수 있다. flags가 공개 배열일 땐 둘 다 불가능했다.
아무도 예약하지 않는 리팩토링: 기능을 객체 간에 이동하기
Move Method와 Move Field는 플랫폼 팀에서 가장 무시받는 리팩토링 기법이다. 가장 높은 수준의 자신감이 필요하기 때문이다: 기능이 정말 다른 곳에 있어야 하는지 확신해야 하고, 모든 호출 지점을 업데이트해야 한다.
그런데 진정한 기술 부채는 여기에 쌓인다. UserService 위에 메서드가 있는데 실제로는 청구 시스템을 쿼리하고 플랜 타입에 따라 의사결정한다. 그 메서드는 잘못된 곳에 있다. UserService와 청구 도메인 사이에 암묵적 결합을 만든다. 청구 티어 운영 방식을 바꾸려면, 그 로직이 세 서비스에 흩어져 있다는 걸 알게 된다.
기능을 올바른 소유자에게 이동하는 규율이 시간이 지나면서 모듈 경계를 정직하게 유지한다. 그 없이는 모든 게 의존하는 Utils 클래스, 또는 청구, 알림, 분석, 세션 관리를 아는 신 같은 객체 UserService가 된다.
이동하기 전 테스트: 이 메서드를 현재 클래스에서 삭제하고 새 클래스에 복사한다면, 소비자가 알아야 할 필요가 있을까? 아니면 이동하라.

리팩토링이 배포 위험이 되는 시점
리팩토링은 자체 배포 폭발 범위를 가진다. 경험 많은 플랫폼 팀은 기능 배포처럼 신중하게 다룬다.
알아야 할 세 가지 실패 양식:
테스트 커버리지 없이 리팩토링. 테스트가 없으면 동작이 보존됐는지 검증할 수 없다. 이건 CI 파이프라인의 린트 규칙이어야 한다: 커버리지 임계값 아래의 모듈을 건드리는 PR은 테스트를 추가하거나 예외를 정당화해야 한다.
동시에 동작 변경과 리팩토링. 규칙: 리팩토링하는 커밋은 관찰 가능한 동작을 바꾸면 안 된다. 동작을 바꾸는 커밋은 리팩토링하면 안 된다. 둘을 섞으면 코드 리뷰가 불가능해지고 장애 조사가 어려워진다. 리팩토링 PR이 새 기능도 추가한다면 분리하라.
과도한 추출. Extract Method를 너무 많이 적용하면 단일 작업의 로직이 12개 함수, 3개 파일에 흩어진다. 배포 폭발 범위는 이제 전체 모듈이다. Extract Method는 뽑은 단위가 이름 붙일 수 있는 정체성과 테스트 가능한 동작을 가질 때만 적용하라. 줄 수 목표를 맞추려고 하는 게 아니다.
McKinsey의 2024년 소프트웨어 현대화 연구에 따르면, 체계적 리팩토링 접근이 임시방편 청소보다 40-50% 빠르게 완료된다. 체계적 부분이 중요하다: 전후 코드 품질 지표를 측정하고, 리팩토링을 배포 이벤트와 연결하고, PR 템플릿으로 강제하는 팀이 성과를 유지한다. 분기별 스프린트로 몰아서 하는 팀은 그 스프린트가 영원히 미뤄지는 걸 본다.
시리즈 B 규모에서 건강한 리팩토링 문화가 어떻게 보이나
50명일 때 모든 엔지니어가 코드베이스를 알고 리팩토링이 협업처럼 느껴진다. 200명일 때 아무도 전체 그림을 모르고, 리팩토링은 조율 문제가 된다.
시리즈 B-D 규모에서 유지되는 것:
코드를 소유한 팀이 리팩토링을 소유한다. 전담 리팩토링 팀이 아니다. 청소를 플랫폼 팀에 넘기면 리팩토링을 안전하게 하는 문맥 이해를 잃는다.
"이 PR은 리팩토링을 포함하나?"를 묻는 PR 템플릿. 구조 변경과 동작 변경은 별개 리뷰 트랙을 타간다.
큰 리팩토링 전 배포 폭발 범위 추정. 40개 호출자가 12개 서비스에 있는 메서드를 이동하기 전에, 마이그레이션 계획을 작성한다. 기능 배포처럼.
포스트모템 리뷰가 명시적으로 묻는다: "X가 리팩토링됐다면 이 코드를 디버깅하기 더 쉬웠을까?" 이렇게 미학 판단 대신 증거 기반 우선순위를 만든다.
이 가이드의 코드 리팩토링 기법들은 한 번 돌고 끝나는 체크리스트가 아니다. 지속적으로 적용하는 렌즈다: 기능 전, 배포 전, 장애 후. 리팩토링을 청소 의식이 아니라 배포 실천으로 다루는 팀이 시간이 지나면서 배포 폭발 범위를 줄인다.
지난 포스트모템이 지난주 Extract Method와 커버리지 게이트를 거친 모듈이었다면 어땠을까?