이거 안 지키면 너도 🍝스파게티 코드 제조자?? (feat. SOLID)

조재민·2025년 4월 20일

⛹️발단

개발을 하다 보면 이런 말을 종종 듣을 수 있습니다.
"유지보수가 어렵다", "코드가 너무 얽혀 있다", "기능 하나 수정했는데 다른 데서 에러가 난다"는 문제를 자주 마주하게 됩니다.
이럴 때 등장하는 것이 바로 SOLID 원칙입니다.

SOLID는 객체지향 설계의 5가지 원칙을 뜻하며, 코드를 유지보수하기 쉽고 확장 가능하며 유연하게 만드는 데 도움을 줍니다.


🏆SOLID

SOLID는 5가지 원칙의 앞 글자를 딴 것입니다.

S : 단일 책임 원칙 (Single Responsibility Principle)
O : 개방/폐쇄 원칙 (Open/Closed Principle)
L : 리스코프 치환 원칙 (Liskov Substitution Principle)
I : 인터페이스 분리 원칙 (Interface Segregation Principle)
D : 의존 역전 원칙 (Dependency Inversion Principle)


S: 단일 책임 원칙

"한 클래스는 하나의 책임만 가져야 한다."
  • 즉, 클래스는 하나의 기능 또는 역할만 가져야 하며, 하나의 변경 이유만 있어야 합니다.

❌ 위반 예시

@Service
public class UserService {

    private static final Logger logger = LoggerFactory.getLogger(UserService.class);
	
    public static UserService createUserService() {
        logger.info("UserService 인스턴스 생성됨");
        return new UserService();
    }

    public void registerUser(String username) {
        logger.info("유저 등록 시도: {}", username);
        // 사용자 저장 로직 (예: DB 저장)
        System.out.println(username + " 등록 완료");
    }

    public void sendWelcomeEmail(String username) {
        logger.info("환영 이메일 전송: {}", username);
        // 이메일 전송 로직
        System.out.println("환영 이메일 전송 완료: " + username);
    }
}

문제점

UserService가 너무 많은 책임을 가지고 있습니다

  1. 인스턴스 생성 로직 (팩토리 메서드)
  2. 유저 등록 (비즈니스 로직)
  3. 이메일 전송 (외부 시스템 연동)
  4. 로깅 (로깅도 일종의 부가 책임)

✅개선한 예시

// 도메인 객체
class User {
    private String username;
	
    public User(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }
}

// 이메일 전송 책임
@Service
class EmailService {
    private final Logger logger = LoggerFactory.getLogger(EmailService.class);

    public void sendWelcomeEmail(User user) {
        logger.info("환영 이메일 전송: {}", user.getUsername());
        // 실제 이메일 전송 코드
        System.out.println("환영 이메일 전송 완료: " + user.getUsername());
    }
}

// 유저 등록 로직
@Service
class UserService {
    private final EmailService emailService;
    private final Logger logger = LoggerFactory.getLogger(UserService.class);

    public UserService(EmailService emailService) {
        this.emailService = emailService;
    }

    public void registerUser(User user) {
        logger.info("유저 등록 시도: {}", user.getUsername());
        // DB 저장 로직
        System.out.println(user.getUsername() + " 등록 완료");

        emailService.sendWelcomeEmail(user);
    }
}

// 팩토리 메서드 책임 분리
@Component
class UserServiceFactory {
    private final EmailService emailService;

    public UserServiceFactory(EmailService emailService) {
        this.emailService = emailService;
    }

    public UserService create() {
        Logger logger = LoggerFactory.getLogger(UserServiceFactory.class);
        logger.info("UserService 인스턴스 생성됨");
        return new UserService(emailService);
    }
}

요약

User: 도메인 모델
EmailService: 알림 전송 책임
UserService: 유저 등록 비즈니스 로직
UserServiceFactory: 팩토리 역할만 담당
Logger는 각 클래스 안에서 자기 책임 범위 내에서만 사용


O: 개방/폐쇄 원칙

“소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.”
  • 기존 코드를 수정하지 않고 기능을 확장할 수 있어야 합니다.

❌ 위반 예시

@Service
public class PaymentService {
    public void pay(String paymentType, int amount) {
        if (paymentType.equals("card")) {
            System.out.println("카드로 " + amount + "원 결제합니다.");
        } else if (paymentType.equals("kakao")) {
            System.out.println("카카오페이로 " + amount + "원 결제합니다.");
        } else if (paymentType.equals("naver")) {
            System.out.println("네이버페이로 " + amount + "원 결제합니다.");
        } else {
            System.out.println("지원하지 않는 결제 수단입니다.");
        }
    }
}

문제점

  1. 결제 수단 결정 로직 (if-else 조건 분기)
  2. 결제 실행 로직 (각 결제 방식의 세부 동작 수행)
  3. 결제 수단 추가/확장 처리 (OCP 위반의 핵심)

✅개선한 예시

// 결제 전략 인터페이스
public interface PaymentStrategy {
    boolean supports(String paymentType);  // 어떤 타입인지 판별
    void pay(int amount);                  // 결제 실행
}

// 카드 결제 구현체
public class CardPayment implements PaymentStrategy {
    @Override
    public boolean supports(String paymentType) {
        return paymentType.equals("card");
    }

    @Override
    public void pay(int amount) {
        System.out.println("💳 카드로 " + amount + "원 결제합니다.");
    }
}

// 카카오페이 결제 구현체
public class KakaoPayment implements PaymentStrategy {
    @Override
    public boolean supports(String paymentType) {
        return paymentType.equals("kakao");
    }

    @Override
    public void pay(int amount) {
        System.out.println("📱 카카오페이로 " + amount + "원 결제합니다.");
    }
}

// 결제 서비스
public class PaymentService {
    private final Map<String, PaymentStrategy> strategyMap;

    public PaymentService(List<PaymentStrategy> strategies) {
        this.strategyMap = new HashMap<>();
        for (PaymentStrategy strategy : strategies) {
            strategyMap.put(strategy.getType(), strategy);
        }
    }

    public void pay(String paymentType, int amount) {
        PaymentStrategy strategy = strategyMap.get(paymentType);
        if (strategy != null) {
            strategy.pay(amount);
        } else {
            System.out.println("🚫 지원하지 않는 결제 수단입니다: " + paymentType);
        }
    }
}

요약

  1. 개방/폐쇄 원칙에 따라 기존 코드를 변경하지 않고 확장할 수 있어야 합니다
  2. 조건문 기반으로 결제 수단을 처리하는 대신 전략 패턴을 사용하여 결제 방식의 유연한 확장을 가능하게 했습니다

L: 리스코프 치환 원칙

“프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.”
  • 상속받은 클래스(자식 클래스)는 부모 클래스의 행위를 일관성 있게 유지해야 합니다.

❌ 위반 예시

// 부모 클래스: 직사각형
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

// 자식 클래스: 정사각형
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // 정사각형은 너비와 높이가 항상 같아야 하니까
    }

    @Override
    public void setHeight(int height) {
        this.width = height; // 정사각형은 높이도 같게 맞춤
        this.height = height;
    }
}

문제점

  1. Rectangle을 상속받은 Square는 setWidth()와 setHeight()가 서로 영향을 줘 Rectangle을 훼손시킵니다
  2. getArea()에서 Rectangle은 width와 height가 서로 독립되어 있다고 생각했지만, Square에서는 그렇게 되지 않고 있습니다

✅개선한 예시

public interface Shape {
    int getArea();
}

// 직사각형
public class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

// 정사각형
public class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

요약

Rectangle과 Square의 상속관계에서 Shape과 Rectangle, Square과 Shape의 구현체로 변경해 서로의 특징을 보장할 수 있습니다.


I: 인터페이스 분리 원칙

“특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.”

❌ 위반 예시

public interface Machine {
    void print(Document doc);
    void scan(Document doc);
    void fax(Document doc);
}

// 단순 프린터
public class SimplePrinter implements Machine {
    public void print(Document doc) {
        System.out.println("프린트 완료");
    }

    public void scan(Document doc) {
        throw new UnsupportedOperationException("스캔 기능 없음");
    }

    public void fax(Document doc) {
        throw new UnsupportedOperationException("팩스 기능 없음");
    }
}

문제점

SimplePrinter는 프린트 기능만 필요한데도 스캔, 팩스 메서드를 구현해야 합니다 (불필요한 의존성 발생)
변경이 있을 경우, 쓰지 않는 메서드 때문에 부작용이 생길 수도 있습니다

✅개선한 예시

public interface Printer {
    void print(Document doc);
}

public interface Scanner {
    void scan(Document doc);
}

public interface Fax {
    void fax(Document doc);
}

public class SimplePrinter implements Printer {
    public void print(Document doc) {
        System.out.println("프린트 완료");
    }
}

public class MultiFunctionPrinter implements Printer, Scanner, Fax {
    public void print(Document doc) { /* 구현 */ }
    public void scan(Document doc) { /* 구현 */ }
    public void fax(Document doc) { /* 구현 */ }
}

요약

불필요한 메서드 강제 구현을 피하기 위해 인터페이스를 기능별로 분리


D: 의존 역전 원칙

프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다.”
  • 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 됩니다. 대신 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 합니다.

❌ 위반 예시

public class Kid {
    private Robot toy;

    public Kid(Robot toy) {
        this.toy = toy;
    }

    public void play() {
        toy.activate();
    }
}

public class Robot {
    public void activate() {
        System.out.println("🤖 로봇이 움직입니다!");
    }
}

문제점

Kid가 Lego를 장난감으로 삼는다면 Kid코드를 직접 고쳐야하는 문제가 발생합니다. 구체화에 의존하고 있음 → DIP 위반

✅개선한 예시

// 추상화
public interface Toy {
    void play();
}

// 구체화 1
public class Robot implements Toy {
    public void play() {
        System.out.println("🤖 로봇이 움직입니다!");
    }
}

// 구체화 2
public class Lego implements Toy {
    public void play() {
        System.out.println("🧱 레고로 탑을 쌓습니다!");
    }
}

// 고수준 모듈
public class Kid {
    private Toy toy;

    public Kid(Toy toy) {
        this.toy = toy;
    }

    public void play() {
        toy.play();
    }
}

요약

Kid는 Toy라는 추상화에만 의존하므로, 어떤 장난감을 쓰든 Kid 코드를 수정할 필요 없게 됩니다
즉, 새로운 장난감이 생겨도 쉽게 확장 가능하게 되었습니다


✨ SOLID 원칙을 적용하면 어떤 이점이 있을까?

1.유지보수가 쉬워집니다
하나 고치면 연쇄적으로 에러가 나는 현상 줄어듭니다.

2.새로운 기능 추가가 편해집니다
기존 코드 건드릴 필요 없이 확장할 수 있습니다.

3.협업이 쉬워집니다
각 클래스/모듈의 역할이 분명해서 다른 사람이 코드를 봐도 이해하기 쉬워집니다.

4.테스트 코드 작성이 쉬워집니다
의존성 분리 덕분에 단위 테스트나 Mocking이 쉬워집니다.

🏌️마무리하며

SOLID 원칙은 무조건 지켜야 하는 법칙은 아니지만, 객체지향 설계를 더 튼튼하고 유연하게 만들 수 있는 가이드라인입니다.
처음엔 어렵게 느껴질 수도 있지만, 점점 프로젝트를 진행하면서 "아, 그래서 이게 필요했구나!" 하고 체감하게 됩니다.

profile
Backend Developer

0개의 댓글