목록으로
ENGINEERING

DRY하게 살 것인가, WET하게 남을 것인가

DRY하게 살 것인가, WET하게 남을 것인가

들어가며

개발자에게는 본능이 있다. 코드의 중복을 발견하면 즉시 추상화하려는 본능. 같은 패턴이 반복되면 손가락이 근질거린다.

“공통 컴포넌트로 만들까?”, “이건 common 폴더로 넣자!”, “유틸 함수로 분리해야 해!” 이러한 외침들은 개발자들에게 매우 익숙한 풍경이다.

DRY(Don’t Repeat Yourself) 원칙은 우리에게 강렬하게 각인되어 있다. 『실용주의 프로그래머』가 이 개념을 제시한 이후, 개발자들에게 이 원칙은 반드시 지켜야 하는 것으로 여겨졌다. 코드 리뷰에서도 중복이 발견되면 댓글이 달린다. “이 부분 추상화할 수 있지 않을까요?”

그런데 이상하다. 당신의 추상화된 코드는 왜 자꾸 예외 케이스가 생길까? 공통 함수는 왜 자꾸 복잡해지는 걸까?

이 글은 Dan Abramov의 “The WET Codebase” 발표에서 영감을 받아, 실제 코드 예시를 중심으로 재구성했습니다.

추상화의 달콤한 유혹

당신도 이런 경험이 있을 것이다.

처음에는 간단한 할인 계산이었다.

  class Order {
  ...

  calculateTotal(): number {
    const discount = this.price * (this.discountRate / 100);
    return this.price - discount;
  }
} 

const order = new Order(50000, 10);
order.calculateTotal();

깔끔한 도메인 모델. 명확하고 이해하기 쉬운 비즈니스 로직. 완벽하다.

그런데 일주일 후, 회의에서 기획자가 이렇게 말한다.

“VIP 회원은 추가 할인이 들어가야 해요. 그리고 쿠폰도 적용해야 할 것 같아요..”

  class Order {
  ...

  calculateTotal(): number {
    let discount = this.price * (this.discountRate / 100);

    if (this.isVip()) {
      discount += this.price * 0.1;
    }

    if (this.hasCoupon()) {
      discount += this.coupon.amount;
    }

    return this.price - discount;
  }
}

아직까진 나쁘지 않다. if문이 여러개 들어가서 조금 불편하지만, 이 정도는 감수할 수 있다.

몇 달 후, 이번엔 마케팅 팀이 요구사항을 들고 왔다.

“블랙프라이데이가 찾아왔습니다. 시즌 할인이 추가되고, 특정 카테고리는 할인 중복 안 돼요. 그리고 멤버십 포인트 사용도 되고…”

“추상화를 하자~!”

  abstract class DiscountPolicy {
  abstract calculate(price: number): number;
}

class RateDiscountPolicy extends DiscountPolicy {
  constructor(private rate: number) {
    super();
  }

  calculate(price: number): number {
    return price * (this.rate / 100);
  }
}

class VipDiscountPolicy extends DiscountPolicy {
  calculate(price: number): number {
    return price * 0.2;
  }
}


class CouponDiscountPolicy extends DiscountPolicy {
  constructor(private amount: number) {
    super();
  }
  
  calculate(price: number): number {
    return this.amount;
  }
}

class SeasonalDiscountPolicy extends DiscountPolicy {
  constructor(
    private rate: number,
    private excludedCategories: string[]
  ) {
    super();
  }
  
  calculate(price: number, category?: string): number {
    if (category && this.excludedCategories.includes(category)) {
      return 0;
    }
    return price * (this.rate / 100);
  }
}

class Order {
  constructor(
    private price: number,
    private policies: DiscountPolicy[]
  ) {}

  calculateTotal(): number {
    const totalDiscount = this.policies
      .map(policy => policy.calculate(this.price))
      .reduce((sum, discount) => sum + discount, 0);
    
    return this.price - totalDiscount;
  }
}

깔끔해보인다! 확장할 수 있는 구조인 것 같다.

사용하는 쪽을 봐볼까?

  const order = new Order(50000, [
  new RateDiscountPolicy(10),
  new VipDiscountPolicy(),
  new CouponDiscountPolicy(5000),
  new SeasonalDiscountPolicy(20, ['electronics'])
]);

order.calculateTotal();

음… 조금 더러워 보이긴 하지만 괜찮다. 그래도 정책이 추가될때마다 Order 자체는 수정하지 않아도 되잖아? 라며 스스로를 위로해본다.

그런데 다음 주, 핫픽스가 생긴다.

“쿠폰이랑 시즌 할인은 중복 적용이 안돼요! 둘 중 더 큰 할인만 적용되어야 해요!”

정책 간의 의존성이 생긴다.

  enum DiscountStrategy {
  MAX = 'max',
  MIN = 'min',
  SUM = 'sum'
}

class ConflictingDiscountPolicy extends DiscountPolicy {
  constructor(
    private policies: DiscountPolicy[],
    private strategy: DiscountStrategy
  ){
    super()
  }

  calculate(price: number): number {
    const discounts = this.policies.map((policy) => policy.calculate(price));
    
    return this.adaptStrategy(this.strategy, discounts);
  }

  adaptStrategy(strategy: DiscountStrategy, discounts: number[]) {
    switch (strategy) {
      case DiscountStrategy.MAX:
        return Math.max(...discounts);
      case DiscountStrategy.MIN:
        return Math.min(...discounts);
      case DiscountStrategy.SUM:
        return discounts.reduce((sum, discount) => sum + discount, 0);
    }
  }
}

사용하는 쪽을 보자.

  const order = new Order(50000, [
  new RateDiscountPolicy(10),
  new VipDiscountPolicy(),
  new ConflictingDiscountPolicy(
    [
      new CouponDiscountPolicy(5000),
      new SeasonalDiscountPolicy(20, ['electronics']),
    ],
    DiscountStrategy.MAX
  ),
]);

“최대 할인 한도도 체크해야 해요.”

요구사항은 계속해서 확장된다.

  class MaxDiscountPolicy extends DiscountPolicy {
    constructor(
    private innerPolicies: DiscountPolicy[],
    private maxAmount: number
  ) {
    super();
  }
  
  calculate(price: number): number {
    const total = this.innerPolicies
      .map(p => p.calculate(price))
      .reduce((sum, d) => sum + d, 0);
    return Math.min(total, this.maxAmount);
  }
}

사용하는 곳을 보자.

  const order = new Order(50000, [
  new MaxDiscountPolicy(
    [
      new RateDiscountPolicy(10),
      new VipDiscountPolicy(),
      new ConflictingDiscountPolicy(
        [
          new CouponDiscountPolicy(5000),
          new SeasonalDiscountPolicy(15, ['electronics']),
        ],
        DiscountStrategy.MAX
      ),
    ],
    10000
  ),
]);

order.calculateTotal();

축하한다. 점점 코드가 복잡해지고 있다. 할인 프레임워크를 만들고 있는 기분이다.

이제 이것을 사용하는 개발자는

  1. 어떤 정책 클래스들이 있는지 알아야 하고
  2. 정책들을 어떤 순서로 조합해야 하는지 알아야 하고
  3. 어떤 정책이 다른 정책을 감싸야 하는지 알아야 하고
  4. 각 정책의 생성자에 뭘 넣어야 하는지 알아야 한다.

이것이 과연 좋은 설계일까? 필요했던 건 단지 최종 가격이었는데.

우리는 문제를 해결하고있는 것이 아니라, 할인 프레임워크를 설계하고 있었던 건 아닐까?

그렇다면 어떻게 했어야 했을까?

정답이 아닐 수 있다. 다만, 추상화하지 않고 비즈니스 로직을 명시적으로 표현하는 방법도 있다.

  class Order {
  ...

  calculateTotal(): number {
    const memberDiscount = this.calculateMemberDiscount();
    const couponDiscount = this.calculateCouponDiscount();
    const seasonalDiscount = this.calculateSeasonalDiscount();

    const finalDiscount = this.applyDiscountRules(
      memberDiscount,
      couponDiscount,
      seasonalDiscount
    );

    return this.price - finalDiscount;
  }

  private calculateMemberDiscount(): number {
    const discountRate = this.isVip() ? 0.05 : 0;
    return this.price * discountRate;
  }

  private calculateCouponDiscount(): number {
    return this.coupon?.amount ?? 0;
  }

  private calculateSeasonalDiscount(): number {
    const hasElectronics = this.items.some((item)=> item.category === 'electronics');
    
    // 전자제품은 할인 제외. 원래라면 아이템마다 할인율을 적용하고 합산해야 하지만, 넘어가자.
    if (hasElectronics) {
      return 0;
    }
    
    return this.price * 0.15;
  }

  private applyDiscountRules(
    memberDiscount: number,
    couponDiscount: number,
    seasonalDiscount: number
  ): number {
    
    // 정책상 쿠폰과 시즌 할인은 중복이 불가능하다
    // @see https://~~~ 문서링크
    const promotionDiscount = Math.max(couponDiscount, seasonalDiscount)

    const totalDiscount = memberDiscount + promotionDiscount;

    return Math.min(totalDiscount, MAX_DISCOUNT_AMOUNT)
  }
}

이렇게 하면 사용하는 곳에서는 내부 규칙을 몰라도 사용할 수 있다.

  const order = new Order(50000, customer, items)
const total = order.calculateTotal()

물론 현실의 이커머스에서는 더욱 복잡하고 다양한 할인 정책과 예외사항이 존재한다. 하지만 핵심은 같다.

코드를 읽고 사용하는 사람이

  1. 어떤 할인들이 있는지 한눈에 알 수 있다.
  2. 각 할인이 어떻게 계산되는지 명확하다.
  3. 할인 적용 규칙이 어떻게 되는지 명확하다.
  4. 새로운 할인 추가는 메서드 하나를 추가하면 된다.

추상화를 하지 않고도 이해하기 쉽고 사용하기 쉬운 코드를 작성할 수 있다.

중복을 두려워하지 마라

그렇다면 추상화를 피하고 중복을 선택하라는 말인가?

WET은 Write Everything Twice의 약자다. 많은 개발자들이 오해한다. “아무 생각 없이 복붙하라”는 뜻이 아니다.

WET의 핵심은 이것이다:

아직 추상화할 만큼 이해하지 못했다면, 중복을 통해 더 관찰하라.

두 코드가 비슷해 보인다고 그것이 같은 개념이라는 보장은 없다.

  • 우연히 닮은 것일 수도 있고
  • 아직 안정되지 않은 정책일 수도 있고
  • 다른 이유로 변경될 수도 있다

추상화는 개념이 충분히 드러난 이후에 해야 한다. 그 전까지의 중복은 탐색 과정이다.

중복 코드의 비용 < 잘못된 추상화의 비용

중복은 제거하기 쉽다. 잘못된 추상화는 되돌리기 어렵다.

언제 추상화해야 하는가?

추상화를 언제 해야 하는지에 대한 명확한 답은 없다. 하지만 몇 가지 신호는 있다.

1. 비슷한 개념인가?

두 코드가 비슷해 보이는 것과 같은 개념이라는 것은 다르다.

  function formatUserName(name: string) {
  return name.trim().toLowerCase();
}

function formatProductCode(code: string) {
  return code.trim().toLowerCase();
}

두 코드는 우연히 비슷할 뿐이다. 추후 정책이 변경된다면 언제든지 달라질 수 있다.

2. Rule of Three

한 번은 그냥 쓴다. 두 번째는 움찔하지만 일단 중복한다. 세 번째 나타나면 리팩토링한다.

  // 첫 번째
function processOrderA() {
  validate();
  calculate();
  save();
}

// 두 번째 (비슷하네?)
function processOrderB() {
  validate();
  calculate();
  save();
}

// 세 번째 (이제 패턴이 보인다.)
function processOrderC() {
  validate();
  calculate();
  save();
}

// 추상화
function processOrder(order: Order) {
  validate(order);
  calculate(order);
  save(order);
} 

미래를 예측하지 않고 성급하게 추상화하지 말자. 추상화는 발견하는 것이다.

3. 변경이 안정되었는가?

초기 프로젝트나 새로운 기능은 요구사항이 계속 바뀐다. 정책과 기획이 바뀐다면 근본적으로 모든 것이 바뀔 수 있기 때문에 섣불리 추상화하면 안 된다.

요구사항이 안정되고 난 후에 추상화해도 늦지 않다.

공짜 추상화는 없다.

추상화는 공짜가 아니다. 추상화에는 비용이 든다.

1. 인지 부하

추상화된 코드는 이해하는 데 시간이 걸린다.

  const total = price - (price * 0.1);

const total = discountCalculator
  .withStrategy(new PercentageStrategy(10))
  .calculate(price);

두 번째 코드를 이해하려면

  1. DiscountCalculator가 무엇인지 알아야 한다.
  2. withStrategy가 무엇인지 알아야 한다.
  3. PercentageStrategy가 무엇인지 알아야 한다.
  4. calculate가 무엇인지 알아야 한다.

2. 간접성

실제 동작을 파악하기 위해 여러 파일을 오가야 한다.

  Order.ts → DiscountCalculator.ts → DiscountStrategy.ts 
→ PercentageStrategy.ts → ... 

디버깅할 때 이 모든 파일을 열어봐야 한다.

반면 명시적인 코드는 한 곳에 다 있다.

3. 변경의 파급효과

추상화된 코드는 한 곳을 고치면 예상치 못한 곳이 깨진다.

  class DiscountCalculator {
  calculate(price: number): number {
    // "음수 가격은 0으로 처리하자"
    const validPrice = Math.max(0, price);
    return this.strategy.calculate(validPrice);
  }
}

이 변경은 DiscountCalculator를 사용하는 모든 곳에 영향을 준다.

  • A 기능: “좋아요! 정말 필요했어요”
  • B 기능: “어? 테스트가 깨졌네?”
  • C 기능: “음수 가격이 필요한데…”

추상화는 결합을 만든다. 결합은 변경을 어렵게 만든다.

4. 잘못된 추상화의 비용

잘못된 추상화를 바로잡는 것은 중복 코드를 정리하는 것보다 훨씬 어렵다.

  // 중복 코드 → 추상화: 쉽다
function A() { /* 같은 로직 */ }
function B() { /* 같은 로직 */ }

function shared() { /* 로직 */ }

// 잘못된 추상화 → 분리: 어렵다
function shared(옵션1, 옵션2, 옵션3...) {
  if (옵션1) { ... }
  if (옵션2) { ... }
  // 이미 많은 곳에서 사용 중
}

// 어떻게 나눌까? 기존 코드는 어떻게 마이그레이션할까?

중복은 안전하다. 잘못된 추상화는 위험하다.

추상화는 발견하는 것이다

좋은 추상화는 처음부터 설계하는 것이 아니다. 구체적인 코드에서 발견하는 것이다.

먼저 동작하는 코드를 만들고 패턴이 드러날 때까지 기다린 후에 추상화를 해도 늦지 않다.

  // 첫 번째 구현
class UserImporter {
  import(csvData: string): User[] {
    const rows = this.parseCSV(csvData);
    const users = rows.map(row => this.createUser(row));
    return users.filter(this.isValid); 
  }
}

// 두 번째 구현
class ProductImporter {
  import(csvData: string): Product[] {
    const rows = this.parseCSV(csvData);
    const products = rows.map(row => this.createProduct(row));
    return products.filter(this.isValid);
  }
}

패턴이 보인다.

  1. csv를 파싱한다
  2. 행을 객체로 변환한다.
  3. 유효성을 검사한다
  abstract class CsvImporter<T> {
  import(csvData: string): T[] {
    const rows = this.parseCSV(csvData);
    const items = rows.map(row => this.createItem(row));
    return items.filter(item => this.isValid(item));
  }

  protected abstract createItem(row: string[]): T;
  protected abstract isValid(item: T): boolean;
}

리팩토링은 언제든 할 수 있다

중복 코드는 언제든 합칠 수 있다. 하지만 잘못된 추상화를 되돌리는 것은 어렵다.

  // 중복 → 추상화: 안전한 마이그레이션
function processA() { ... }
function processB() { ... }

// 1. 먼저 추상화 함수를 만든다
function processCommon() { ... }

// 2. 하나씩 교체한다
function processA() { return processCommon(); }
// 테스트 → 문제없으면 다음
function processB() { return processCommon(); }

// 3. 안전하게 완료

반면 잘못된 추상화는:

  // 이미 100곳에서 사용 중인 잘못된 추상화
function complexAbstraction(a, b, c, d, ...) { ... }

// 어떻게 고치지? 
// - 100곳을 다 수정? → 위험
// - 버전을 나눠서 deprecate? → 복잡
// - 그냥 둔다? → 기술 부채

중복은 제거하기 쉽다. 추상화는 되돌리기 어렵다.

그러니 확신이 설 때까지 중복을 유지하는 것도 합리적인 선택이다.

마무리

DRY는 좋은 원칙이다. 하지만 많은 개발자들이 오해한다. DRY는 “지식의 중복”을 제거하라는 원칙이지, “코드의 모양”을 통일하라는 원칙이 아니다.

WET 역시 은탄환은 아니다. 위에서 중복된 것을 합치는 게 쉽다고는 했지만 현실의 코드는 중복된 코드 역시 여기저기서 사용되기 때문에 추상화를 통해 중복을 제거하는 것 역시 쉽지 않다.

당신의 코드베이스를 돌아보라. 아무도 건드리지 않는 코드가 있는가? 변경이 두려운 “추상 클래스”가 있는가? 10개의 플래그를 받는 “범용 함수”가 있는가?

그것들은 당신이 미래를 예측하려다 만든 유산이다.

좋은 추상화는

  • 이해하기 쉽다
  • 변경하기 쉽다
  • 명확한 책임을 갖는다
  • 같은 이유로 변경되는 것들을 묶는다

나쁜 추상화는

  • 모든 것을 하려 한다
  • 수많은 옵션과 플래그를 갖는다
  • 변경이 두렵다
  • 누구도 이해할 수 없다.

중복을 두려워하지 마라. 패턴이 명확해질 때까지 기다려라. 반복이 여러 번 됐을 때 추상화를 고민해도 늦지 않다.

추상화는 발견하는 것이지, 발명하는 것이 아니다. 코드에서 자연스럽게 드러나는 패턴을 추상화하라. 그리고 무엇보다, 완벽한 추상화에 집착하지 마라.

때로는 명시적이고 중복되었지만 이해하기 쉬운 코드가 완벽하게 추상화되었지만 아무도 이해할 수 없는 코드보다 낫다.

DRY하게 살 것인가, WET하게 남을 것인가?

답은 “둘 다”다.

중복을 보면 즉시 추상화하는 개발자는 복잡한 프레임워크를 만든다. 중복을 절대 용납하지 않는 개발자는 아무도 이해할 수 없는 코드를 만든다.

현명한 개발자는 언제 중복을 선택하고 언제 추상화할지 안다. 그리고 그 판단은 경험과 맥락에서 나온다.

완벽한 추상화를 추구하지 마라. 이해하기 쉬운 코드를 추구하라.

#DRY #WET #추상화 #개발 원칙 #나비어리