SOLID 원칙 완벽 이해와 실전 적용: 리팩토링 전후 코드로 배우는 객체 지향 설계 전략
2025. 5. 22. 16:00
728x90

 SOLID란?

SOLID 원칙은 객체 지향 설계에서 유지보수성과 확장성을 높이기 위한 다섯 가지 핵심 원칙이다. Robert C. Martin(일명 Uncle Bob)이 정리한 이 원칙들은 실제로도 많은 OOP 기반 시스템에서 코드 품질과 구조 개선에 기여한다.

내가 직접 설계하고 리팩토링한 인증 서비스(auth-service)와 결제 서비스(escrow-service)에도 이 원칙들을 적용하며 실무적 감각을 쌓았다.


 S - 단일 책임 원칙 (Single Responsibility Principle, SRP)

  • 정의: 하나의 클래스는 하나의 책임만 가져야 한다.
  • 효과: 변경의 이유가 하나뿐이므로 코드 수정 시 다른 기능에 영향을 주지 않음.

예시 적용

  • RegisterUserUsecase / LoginUserUsecase / VerifyResetCodeUsecase 등 유즈케이스를 분리하여 각 기능이 독립적으로 변경될 수 있도록 함.
  • UserRepository는 DB 접근만, UserService는 비즈니스 로직만 담당하도록 구조화.

코드 예시

변경 전 (하나의 서비스에 모든 책임이 집중)

class UserService {
  constructor(private readonly ormRepository: Repository<User>) {}

  async register(data: RegisterUserDto) {
    const user = this.ormRepository.create(data);
    return await this.ormRepository.save(user);
  }

  async login(email: string, password: string) {
    // 비밀번호 비교 로직 포함
  }

  async verifyResetCode(code: string) {
    // 인증 코드 검증 로직 포함
  }
}

변경 후 (책임을 Usecase 단위로 분리)

class RegisterUserUsecase {
  constructor(private readonly userRepository: IUserRepository) {}

  async execute(dto: RegisterUserDto) {
    const user = UserFactory.create(dto);
    return await this.userRepository.save(user);
  }
}

class VerifyResetCodeUsecase {
  constructor(private readonly resetCodeRepository: IResetCodeRepository) {}

  async execute(dto: VerifyResetCodeDto) {
    const storedCode = await this.resetCodeRepository.getCode(dto.email);
    if (storedCode !== dto.code) throw new Error('인증 실패');
  }
}

 O - 개방-폐쇄 원칙 (Open/Closed Principle, OCP)

  • 정의: 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
  • 효과: 기존 코드를 건드리지 않고 새로운 기능을 추가할 수 있다.

예시 적용

  • 알림 기능 예시: NotificationService 인터페이스 정의 후, EmailNotification, SMSNotification 클래스를 별도로 구현.
  • 실제 인증 서비스에서 세션 저장소 변경 시 ISessionRepository 인터페이스만 유지하고 구현체를 바꿔도 영향이 없도록 설계함.

코드 예시

변경 전 (구현에 직접 의존)

class SessionService {
  private sessionStorage = new RedisClient();

  save(session: Session) {
    this.sessionStorage.set(session.id, session);
  }
}

변경 후 (인터페이스 기반 구조로 확장 가능)

interface ISessionRepository {
  save(session: Session): Promise<void>;
}

class RedisSessionRepository implements ISessionRepository {
  save(session: Session) {
    redisClient.set(session.id, session);
  }
}

class SessionService {
  constructor(private readonly sessionRepository: ISessionRepository) {}

  save(session: Session) {
    return this.sessionRepository.save(session);
  }
}

 L - 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

  • 정의: 서브타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다.
  • 효과: 다형성이 안전하게 작동함.

예시 적용

  • User → AdminUser, SellerUser처럼 사용자 타입을 분리할 때 공통된 부모 인터페이스나 클래스를 통해 기능이 유지되도록 구현.

코드 예시

abstract class User {
  constructor(public readonly id: string) {}
  abstract getRole(): string;
}

class AdminUser extends User {
  getRole() {
    return 'admin';
  }
}

class RegularUser extends User {
  getRole() {
    return 'user';
  }
}

function printUserRole(user: User) {
  console.log(user.getRole());
}

 I - 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

  • 정의: 하나의 일반적인 인터페이스보다, 여러 개의 구체적인 인터페이스를 사용하는 것이 낫다.
  • 효과: 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않게 된다.

예시 적용

  • IUserAuthRepository, IUserProfileRepository처럼 사용자 관련 인터페이스를 관심사별로 분리.
  • Redis 기반 인증 코드 검증 기능은 IResetCodeRepository로 따로 분리.

코드 예시

interface IUserAuthRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

interface IResetCodeRepository {
  getCode(email: string): Promise<string | null>;
  saveCode(email: string, code: string): Promise<void>;
}

 D - 의존성 역전 원칙 (Dependency Inversion Principle, DIP)

  • 정의: 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.
  • 효과: 유연하고 테스트 가능한 구조가 된다.

예시 적용

  • 모든 유즈케이스는 실제 구현체가 아닌 인터페이스(IUserRepository, ISessionRepository, IMailService 등)를 주입받음.

코드 예시

interface IMailService {
  sendVerificationCode(email: string, code: string): Promise<void>;
}

class MailService implements IMailService {
  sendVerificationCode(email: string, code: string) {
    // SMTP 전송 구현
  }
}

class SendResetCodeUsecase {
  constructor(private readonly mailService: IMailService) {}

  async execute(email: string, code: string) {
    await this.mailService.sendVerificationCode(email, code);
  }
}

 정리

원칙 정의 실무 적용 포인트
SRP 하나의 책임만 유즈케이스/서비스/레포 분리
OCP 확장엔 열려, 변경엔 닫힘 인터페이스 도입, 구현체 분리
LSP 부모 타입으로 자식 교체 가능 인터페이스 기반 설계
ISP 인터페이스는 작게, 명확하게 관심사 분리로 인터페이스 구성
DIP 구현보다 추상화에 의존 DI 기반 구조, 테스트 용이성

 마무리

SOLID는 "깨끗한 코드"의 철학이자, 실무에서 서비스 유지보수성과 테스트 가능성을 획기적으로 높여주는 무기이다.

단순히 "좋은 구조"라는 이유만으로 쓰기보다는, 실제 팀 협업과 기능 분리, 테스트 편의성에서 어떤 효과를 가져다주는지 느끼며 적용하는 것이 중요하다.

실무에서 한 번이라도 서비스 구조를 리팩토링해본 개발자라면, SOLID 원칙은 이론이 아니라 생존 전략이다.

반응형