test

TDD 실전 예제: Red-Green-Refactor로 회원가입 기능 구현하기

TDD(Test Driven Development)는 “실패하는 테스트 → 최소 구현 → 리팩토링”의 순환 구조를 반복하며 점진적으로 품질 높은 코드를 만들어가는 개발 방식입니다. 이번 글에서는 TypeScript와 Jest 환경에서 Red-Green-Refactor 사이클을 따라가며 회원가입 기능을 실제로 구현해봅니다.


1. Red — 실패하는 테스트 작성

먼저 테스트 코드를 작성합니다.

user.service.spec.ts

import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { User } from './user';
 
describe('UserService', () => {
  it('registerUser() 호출 시 유효한 ID가 반환', () => {
    const email = 'hanpy@example.com';
    const password = 'hanpy123';
 
    const repo: jest.Mocked<UserRepository> = {
      save: jest.fn((user: User) => {
        user.id = 1;
        return user;
      }),
      // findByEmail: jest.fn(() => null),
 
    };
 
    const userService = new UserService(repo);
 
    const userId = userService.registerUser(email, password);
 
    expect(userId).toBeGreaterThan(0);
    expect(repo.save).toHaveBeenCalledTimes(1);
    expect(repo.save.mock.calls[0][0]).toEqual(
      expect.objectContaining({ email, password })
    );
  });
});
구문역할설명
describe()테스트 묶음클래스나 모듈 단위 그룹
it()개별 테스트 케이스기능별 시나리오 정의
expect(...).toBeGreaterThan(0)값 비교ID가 양수인지 확인
expect(...).toHaveBeenCalledTimes(1)호출 횟수 확인save()가 1회 호출되었는지
expect(...).toEqual(expect.objectContaining(...))객체 속성 비교호출 시 전달된 인자 검증

이 시점에는 아직 UserService가 없기 때문에 테스트는 실패(레드)합니다. 이 단계의 목적은 무엇을 구현해야 하는지 명확히 하는 것에 포인트를 둡니다.


2. Green — 최소한의 코드로 테스트 통과

Green 단계에서 최소 구현을 진행합니다.

user.ts

export class User {
  id?: number;
  constructor(public email: string, public password: string) {}
}

user.repository.ts

import { User } from './user';
 
export interface UserRepository {
  save(user: User): User;
  // findByEmail(email: string): User | null; // 이후 중복 체크에 사용
}

user.service.ts

import { User } from './user';
import { UserRepository } from './user.repository';
 
export class UserService {
  constructor(private readonly repository: UserRepository) {}
 
  registerUser(email: string, password: string): number {
    const user = new User(email, password);
    const saved = this.repository.save(user);
    return saved.id!;
  }
}

테스트가 통과하도록 가장 단순한 구현만 작성합니다. 이제 기능은 동작하지만, 구조는 아직 미흡합니다.


3. Red — 새로운 제약(유효성, 중복) 테스트 추가

유효하지 않은 이메일, 중복 이메일은 거부하는 테스트를 추가로 작성합니다.

user.service.spec.ts

import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { User } from './user';
 
describe('UserService - registerUser', () => {
  // ... (기존 코드)
 
  it('잘못된 이메일이면 예외가 발생한다', () => {
    const repo: UserRepository = {
      save: jest.fn(),
      findByEmail: jest.fn(() => null),
    };
    const userService = new UserService(repo);
 
    expect(() => userService.registerUser('not-an-email', 'pw')).toThrow('Invalid email');
  });
 
  it('이미 존재하는 이메일이면 예외가 발생한다', () => {
    const existing = new User('dup@example.com', 'hashed');
    existing.id = 1;
 
    const repo: UserRepository = {
      save: jest.fn(),
      findByEmail: jest.fn(() => existing),
    };
    const userService = new UserService(repo);
 
    expect(() => userService.registerUser('dup@example.com', 'pw')).toThrow('Email already exists');
    expect(repo.save).not.toHaveBeenCalled();
  });
});
 

추가 요구사항을 테스트로 정의하고 다시 실패시킵니다. 이렇게 테스트로 명세를 확장해 나가면, 코드가 안정적으로 진화합니다.


4. Green — 최소 구현으로 테스트 통과 구현

user.service.ts

import { User } from './user';
import { UserRepository } from './user.repository';
 
export class UserService {
  constructor(private readonly repository: UserRepository) {}
 
  registerUser(email: string, password: string): number {
    // 최소한의 검증(조악)
    if (!email.includes('@')) throw new Error('Invalid email');
 
    const exists = this.repository.findByEmail(email);
    if (exists) throw new Error('Email already exists');
 
    const user = new User(email, password); // 아직 해시도 안 함
    const saved = this.repository.save(user);
    return saved.id!;
  }
}

새 테스트를 통과시키기 위해 빠르게 수정합니다. 이 시점에서는 “정확히 동작하지만, 구조적으로 불안정한 코드”입니다. 코드를 확인하면 규칙은 지켰지만, 유효성/중복/보안 로직이 서비스에 뒤섞여 있고, 비밀번호 해시도 없습니다. 이제 리팩토링으로 설계를 정리합니다.


5. Refactor — 책임 분리와 설계 개선

email.ts

export class Email {
  private constructor(public readonly value: string) {}
  static parse(raw: string): Email {
    const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!re.test(raw)) throw new Error('Invalid email');
    return new Email(raw.trim().toLowerCase());
  }
}
  • 문자열을 그대로 쓰지 않고, 도메인 규칙(유효성·정규화)을 스스로 책임짐
  • 잘못된 이메일은 생성조차 불가능하게 만듦 (불변/안전성 확보)

errors.ts

export class DuplicateEmailError extends Error {
  constructor() {
    super('Email already exists');
  }
}
  • 단순 문자열 에러 대신, 의미 있는 타입으로 구분 가능하게 함

password.hasher.ts

export interface PasswordHasher {
  hash(plain: string): string;
}
  • 비밀번호 해시 방법(bcrypt, argon2 등)을 외부로 분리한 추상화
  • UserService는 "해시가 필요하다"는 사실만 알고, "어떻게 해시되는가"는 모름 (의존성 역전 원칙: DIP)

user.ts

import { Email } from './email';
 
export class User {
  id?: number;
  constructor(
    public readonly email: Email,
    public readonly passwordHash: string,
  ) {}
}
  • 이메일과 해시된 비밀번호를 가진 도메인 객체
  • Email 값 객체를 직접 필드로 포함 → 타입 수준에서 안전

user.repository.ts

import { User } from './user';
import { Email } from './email';
 
export interface UserRepository {
  save(user: User): User;                // 영속화 후 id 채워서 반환
  findByEmail(email: Email): User | null; // Email 값객체로 검색
}
  • 데이터 저장 방식(DB, 메모리 등)을 추상화
  • 도메인 로직은 저장소의 세부 구현을 몰라도 됨

user.service.ts

import { Email } from './email';
import { User } from './user';
import { UserRepository } from './user.repository';
import { PasswordHasher } from './password.hasher';
import { DuplicateEmailError } from './errors';
 
export class UserService {
  constructor(
    private readonly repository: UserRepository,
    private readonly hasher: PasswordHasher,
  ) {}
 
  registerUser(rawEmail: string, plainPassword: string): number {
    const email = Email.parse(rawEmail);
    if (this.repository.findByEmail(email)) throw new DuplicateEmailError();
 
    const passwordHash = this.hasher.hash(plainPassword);
    const saved = this.repository.save(new User(email, passwordHash));
    return saved.id!;
  }
}
  • 가입 시나리오의 절차(유효성 → 중복확인 → 해시 → 저장)를 조율
  • Email/PasswordHasher/Repository 등 추상화된 협력자에게 위임

user.service.spec.ts

import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { PasswordHasher } from './password.hasher';
import { Email } from './email';
import { User } from './user';
import { DuplicateEmailError } from './errors';
 
function mkRepo(overrides?: Partial<UserRepository>): jest.Mocked<UserRepository> {
  return {
    save: jest.fn((u: User) => ({ ...u, id: 1 })),
    findByEmail: jest.fn(() => null),
    ...overrides,
  } as jest.Mocked<UserRepository>;
}
 
function mkHasher(): jest.Mocked<PasswordHasher> {
  return { hash: jest.fn((p: string) => `hashed:${p}`) } as jest.Mocked<PasswordHasher>;
}
 
describe('UserService - registerUser (Refactor v2)', () => {
  it('정상 가입 시 ID 반환 + 비밀번호 해시', () => {
    const repo = mkRepo();
    const hasher = mkHasher();
    const sut = new UserService(repo, hasher);
 
    const id = sut.registerUser('Hanpy@Example.com', 'pw123');
 
    expect(id).toBe(1);
    // 저장된 사용자 검증
    const savedUser = repo.save.mock.calls[0][0];
    expect(savedUser.email.value).toBe('hanpy@example.com'); // 소문자 정규화
    expect(savedUser.passwordHash).toMatch(/^hashed:/);
 
    // 상호작용
    expect(repo.findByEmail).toHaveBeenCalledWith(Email.parse('Hanpy@Example.com'));
    expect(hasher.hash).toHaveBeenCalledWith('pw123');
  });
 
  it('잘못된 이메일이면 예외', () => {
    const sut = new UserService(mkRepo(), mkHasher());
    expect(() => sut.registerUser('bad', 'pw')).toThrow('Invalid email');
  });
 
  it('중복 이메일이면 DuplicateEmailError', () => {
    const email = Email.parse('dup@example.com');
    const existing = new User(email, '...');
    existing.id = 42;
 
    const repo = mkRepo({ findByEmail: jest.fn(() => existing) });
    const sut = new UserService(repo, mkHasher());
 
    expect(() => sut.registerUser('dup@example.com', 'pw')).toThrow(DuplicateEmailError);
    expect(repo.save).not.toHaveBeenCalled();
  });
});
  • UserService의 행위(가입, 중복, 검증)를 격리 검증
  • 실제 DB/해시 로직 대신 jest mock으로 빠르고 안전하게 테스트
파일책임
email.ts이메일의 유효성·정규화 책임 (값 객체)
errors.ts도메인 규칙 위반을 의미하는 명시적 예외
password.hasher.ts비밀번호 해시 알고리즘의 추상화 (DIP 핵심 포트)
user.ts사용자 도메인 엔티티 (Email 포함)
user.repository.ts저장소 추상화 (DB 접근 분리)
user.service.ts비즈니스 흐름 조율 (Use Case)
user.service.spec.ts행위 검증 테스트 (mock 기반)

Red-Green 사이클을 반복하며 얻은 코드를 리팩토링합니다. 이 단계에서 도메인 객체로 책임을 이동하고, 의존성을 분리해 유지보수성과 확장성을 높입니다.

추가로 만약 hasher 부분 사용부분을 bcrypt로 만든다면, 아래정도로 참고하면 될 것 같습니다.

// bcrypt-password.hasher.ts
import { PasswordHasher } from './password.hasher';
import bcrypt from 'bcryptjs'; // npm install bcryptjs
 
export class BcryptPasswordHasher implements PasswordHasher {
  private readonly saltRounds = 10;
 
  hash(plain: string): string {
    return bcrypt.hashSync(plain, this.saltRounds);
  }
 
  // (선택) 비밀번호 검증 메서드도 종종 추가함
  verify(plain: string, hashed: string): boolean {
    return bcrypt.compareSync(plain, hashed);
  }
}

실제 실무 사용 시에는 aync로 아래와 같이 비동기로 사용해야합니다.

// bcrypt-password.hasher.ts (async)
import { PasswordHasher } from './password.hasher';
import bcrypt from 'bcryptjs';
 
export class AsyncBcryptPasswordHasher implements PasswordHasher {
  private readonly saltRounds = 10;
 
  async hash(plain: string): Promise<string> {
    return bcrypt.hash(plain, this.saltRounds);
  }
 
  async verify(plain: string, hashed: string): Prmise<boolean> {
    return bcrypt.compare(plain, hashed);
  }
}
 
// password.hasher.ts (async 버전)
export interface PasswordHasher {
  hash(plain: string): Promise<string>;
  verify?(plain: string, hashed: string): Promise<boolean>;
}

정리

단계목표결과
Red실패하는 테스트로 요구 명세 정의“무엇을 만들어야 하는가” 명확화
Green최소 구현으로 테스트 통과기능 확보
Refactor구조 개선 및 책임 분리품질 향상