본문 바로가기
TYLER SONGBlog
← Blog index
CS학습6 min read

구현: 명세를 동작하는 코드로 옮기는 과정과 검증 가능한 설계

구현(Implementation)은 요구사항과 설계를 실행 가능한 코드로 옮기고, 그 코드가 명세대로 동작함을 테스트로 증명하는 단계다. 인터페이스와 구현의 분리, 의존성 주입(DI), 테스트 주도 개발(TDD) 같은 원리를 통해 변경에 강한 코드를 만든다. 이 글에서는 TypeScript 예제로 설계에서 구현, 테스트까지의 흐름을 살펴본다.

By 송민성

1. 개념

구현(Implementation)은 요구사항 분석과 설계(Design)의 결과물을 실행 가능한 소스 코드로 옮기는 활동이다. 단순히 "동작하는 코드를 쓰는 일"이 아니라, 다음 세 가지를 함께 만족시키는 과정이다.

  • 정확성(Correctness): 명세(Specification)에서 정한 입력과 출력, 예외 상황을 만족한다.
  • 가독성(Readability): 다른 개발자가 의도를 읽고 수정할 수 있다.
  • 변경 용이성(Modifiability): 요구사항이 바뀔 때 수정 범위가 좁다.

설계가 "무엇을 어떤 구조로 만들 것인가"에 답한다면, 구현은 "그 구조를 어떤 코드로 표현하고 어떻게 검증할 것인가"에 답한다.

2. 왜 사용하는가

구현 단계를 체계적으로 다루는 이유는 코드가 곧 장기 유지보수 비용의 대부분을 결정하기 때문이다.

  • 명세가 모호하면 구현자마다 다른 동작을 만든다. 인터페이스(Interface)와 테스트로 명세를 코드 수준에서 고정한다.
  • 비즈니스 로직이 데이터베이스, 네트워크, 시간 같은 외부 요소에 직접 묶이면 테스트가 어렵다. 구현 시점에 의존성을 분리하면 단위 테스트(Unit Test)가 쉬워진다.
  • 작은 단위로 구현하고 검증하면 결함을 일찍 발견한다. 결함은 발견이 늦을수록 수정 비용이 커지는 경향이 있다.

3. 동작 원리

3.1 인터페이스와 구현의 분리

호출하는 쪽은 "무엇을 하는가"(계약, Contract)만 알고, "어떻게 하는가"는 모른다. 이렇게 하면 구현체를 교체해도 호출부는 바뀌지 않는다. 이를 다형성(Polymorphism)을 이용한 추상화(Abstraction)라고 한다.

3.2 의존성 주입(Dependency Injection, DI)

클래스가 필요한 협력 객체를 내부에서 직접 생성하지 않고, 생성자 등을 통해 외부에서 받는다. 운영 환경에서는 실제 구현체를, 테스트에서는 가짜 구현체(Fake)를 주입할 수 있다.

3.3 테스트 주도 개발(Test-Driven Development, TDD)

TDD는 다음 주기를 반복한다.

  1. Red: 아직 없는 기능에 대한 실패하는 테스트를 먼저 작성한다.
  2. Green: 테스트를 통과시키는 가장 단순한 코드를 작성한다.
  3. Refactor: 테스트가 통과하는 상태를 유지하며 코드 구조를 정리한다.

3.4 구현 순서의 일반 흐름

명세 확인 → 인터페이스 정의 → 테스트 작성 → 구현 → 리팩터링(Refactoring) → 코드 리뷰(Code Review) 순으로 진행한다.

4. 코드 예제

"쿠폰 할인이 적용된 주문 금액을 계산하는 서비스"를 구현한다. 현재 시간에 의존하는 만료 검사를 Clock 인터페이스로 분리해 테스트 가능하게 만든다. Node.js 18 이상에서 npx tsx order.ts로 실행할 수 있다.

typescript
// order.ts import assert from "node:assert/strict"; // 1) 계약(인터페이스) 정의 interface Clock { now(): Date; } interface Coupon { code: string; discountRate: number; // 0 초과 1 이하 expiresAt: Date; } // 2) 구현: 외부 의존성(시간)을 생성자로 주입받는다 class OrderService { constructor(private readonly clock: Clock) {} calculateTotal(prices: number[], coupon?: Coupon): number { if (prices.some((p) => !Number.isInteger(p) || p < 0)) { throw new RangeError("가격은 0 이상의 정수여야 합니다"); } const subtotal = prices.reduce((sum, p) => sum + p, 0); if (!coupon) return subtotal; if (coupon.discountRate <= 0 || coupon.discountRate > 1) { throw new RangeError("할인율은 0 초과 1 이하여야 합니다"); } if (this.clock.now() > coupon.expiresAt) { return subtotal; // 만료된 쿠폰은 무시 } return Math.floor(subtotal * (1 - coupon.discountRate)); } } // 3) 테스트: 고정된 시간을 돌려주는 가짜 Clock 주입 const fixedClock = (iso: string): Clock => ({ now: () => new Date(iso) }); const coupon: Coupon = { code: "WELCOME10", discountRate: 0.1, expiresAt: new Date("2026-12-31T23:59:59Z"), }; // 쿠폰 없음 assert.equal( new OrderService(fixedClock("2026-10-12T00:00:00Z")).calculateTotal([1000, 2000]), 3000 ); // 유효한 쿠폰: 3000 * 0.9 = 2700 assert.equal( new OrderService(fixedClock("2026-10-12T00:00:00Z")).calculateTotal([1000, 2000], coupon), 2700 ); // 만료된 쿠폰은 적용되지 않음 assert.equal( new OrderService(fixedClock("2027-01-01T00:00:00Z")).calculateTotal([1000, 2000], coupon), 3000 ); // 잘못된 입력은 예외 assert.throws( () => new OrderService(fixedClock("2026-10-12T00:00:00Z")).calculateTotal([-1]), RangeError ); console.log("모든 테스트 통과");

new Date()를 서비스 내부에서 직접 호출했다면 "만료 후" 시나리오를 재현하기 어렵다. 시간을 주입하자 같은 코드로 과거·미래 시점을 모두 검증할 수 있다.

5. 시간 복잡도 또는 성능 특성

위 calculateTotal은 가격 배열을 한 번 검사하고(some), 한 번 합산(reduce)하므로 가격 개수를 n이라 할 때 시간 복잡도는 O(n), 추가 공간은 O(1)이다.

구현 방식이 성능에 미치는 영향은 다음과 같이 정리할 수 있다.

  • 알고리즘 선택이 상수항 최적화보다 먼저 성능을 좌우한다.
  • 성능 최적화는 추측이 아니라 프로파일링(Profiling) 결과를 근거로 한다.
  • 인터페이스를 통한 호출(간접 호출)의 비용은 대부분의 웹 서비스에서 I/O 대기 시간에 비해 무시할 만한 수준인 경우가 많다. 다만 아주 빈번하게 호출되는 핫 패스(hot path)는 측정해서 판단한다.

6. 실무 사용 사례

  • 계층 분리: 컨트롤러(Controller) → 서비스(Service) → 리포지토리(Repository) 구조에서 서비스는 리포지토리 인터페이스에만 의존한다. 테스트에서는 인메모리 구현체로 교체한다.
  • 외부 API 연동: 결제, 메일 발송 같은 외부 호출을 인터페이스 뒤에 숨기고, 테스트에서는 호출 기록만 남기는 가짜 구현체를 쓴다.
  • 기능 플래그(Feature Flag): 같은 인터페이스의 신규·기존 구현을 설정값으로 전환하며 점진적으로 배포한다.
  • 코드 리뷰와 CI: 풀 리퀘스트마다 테스트와 정적 분석(Linter, Type Check)을 자동 실행해 구현 품질을 일정하게 유지한다.

7. 주의할 점

  • 과도한 추상화: 구현체가 하나뿐이고 바뀔 가능성도 없는데 인터페이스를 남발하면 읽어야 할 코드만 늘어난다. 외부 I/O나 변동 가능성이 큰 경계에 우선 적용한다.
  • 구현 세부사항에 묶인 테스트: 내부 호출 순서까지 검증하는 테스트는 리팩터링할 때마다 깨진다. 입력과 출력, 관찰 가능한 동작을 검증한다.
  • 부동소수점 금액 계산: 위 예제는 금액을 정수로 다루고 Math.floor로 절사했다. 금액에 소수 연산을 쓰면 오차가 생길 수 있으므로 최소 화폐 단위의 정수를 사용하고, 반올림·절사 정책은 명세로 정한다.
  • 예외 처리 누락: 정상 경로만 구현하고 경계값(빈 배열, 0, 음수, 만료 직전·직후)을 빠뜨리기 쉽다. 경계값은 테스트로 명시한다.
  • 리팩터링과 기능 변경 혼합: 한 커밋에 둘을 섞으면 리뷰와 원인 추적이 어렵다. 분리해서 변경한다.

8. 핵심 정리

  • 구현은 설계를 코드로 옮기고, 테스트로 명세 충족을 증명하는 활동이다.
  • 인터페이스와 구현을 분리하고 의존성 주입을 쓰면 시간·DB·네트워크 같은 외부 요소를 교체할 수 있어 단위 테스트가 쉬워진다.
  • TDD의 Red → Green → Refactor 주기는 작은 단위로 검증하며 구조를 개선하게 해준다.
  • 추상화는 변동 가능성이 큰 경계에 적용하고, 테스트는 내부 구조가 아닌 관찰 가능한 동작을 검증한다.
  • 성능 개선은 알고리즘의 복잡도를 먼저 점검하고, 측정 결과를 근거로 진행한다.