_ 개발

객체 지향 설계 원칙 5가지 - SOLID

옴뇽뇽 2024. 11. 8. 18:41

 

 

현업 개발자로서 다양한 프로젝트, 팀단위 작업을 진행하게 되면서 코드의 품질을 높이고 유지 보수를 고려한 개발에 중요성을 느끼는 때가 많다. 초발에 구현된 코드가 시간이 지나고 살을 덧입히는 과정에서 점점 복잡해지고, 이로 인해 간단한 수정조차 많은 시간을 소모하게 되는 상황이 생기는 경우가 있다. 

 

이러한 문제를 해결하는데 있어 중요한 역할을 하는 것이 바로 객체 지향 설계 원칙인 SOILD이다. SOLID는 개발자가 직면하는 코드의 복잡성, 유지보수의 문제를 해결하기 위해 아주 유용한 가이드라인이 되는데, 이번 포스팅에서 SOLID가 의미하는 다섯 가지 설계 원칙과 각 원칙이 실제로 어떻게 적용될 수 있는지 알아보도록 하자!

 

 

목 차

객체 지향 프로그래밍 위한 5가지 설계 원칙 : SOLID

SRP (Single Responsibility Principle) : 단일 책임 원칙

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

LSP (Liskov Substitution Principle) : 리스코프 치환 원칙

ISP (Interface Segregation Principle) : 인터페이스 분리 원칙

DIP (Dependency Inversion Principle) : 의존 역전 원칙

 

객체 지향 프로그래밍 위한 5가지 설계 원칙 : SOLID

SOLID는 객체 지향 설계의 다섯 가지 주요 원칙을 의미하며, 소프트웨어 설계의 유연성, 확장성, 유지보수성을 높이는 데 중점을 둔 설계 원칙이다. SOLID 원칙을 통해 코드의 결합도를 낮추고, 이해하기 쉽고, 수정 및 확장이 쉬운 구조를 만들 수 있게 된다.

객체 지향 프로그래밍 위한 5가지 설계 원칙 : SOLID

 

SRP (Single Responsibility Principle) : 단일 책임 원칙

한 클래스는 단 하나의 책임만 가져야 한다. 
  • 하나의 클래스는 하나의 기능 또는 책임만 수행해야 하며, 이를 초과하는 여러 책임을 가져서는 안 된다. 
  • 하나의 클래스가 여러 책임을 가지게 되면 각 책임이 서로 의존하게 되어, 클래스 수정 시 다른 기능에도 영향을 미칠 가능성이 커진다.
  • 각 클래스가 하나의 책임만 찾게 되면 클래스가 더 명확하고 이해하기 쉬워지며, 수정 사항이 발생했을 때 해당 책임 관련된 코드(클래스)만 수정하면 되기 때문에 유지보수에 용이하다.
/** (예시1-1) SRP 위반 **/
public class UserService {
    public void createUser(User user) { /* 사용자 생성 메서드 */ }
    public void sendWelcomeEmail(User user) { /* 환영 이메일 발송 메서드 */ }
}
// 사용자서비스(UserService)에서는 사용자과 관련된 책임(기능)들만 해야하는데 
// 사용자 생성(createUser) 이 외 환영 이메일 발송(sendWelcomeEmail) 기능까지 포함하며 
// 단일 책임 원칙(SRP)을 위반했다.

/** (예시1-2 ) SRP 준수 **/
public class UserService {
    public void createUser(User user) { /* 사용자 생성 코드 */ }
}

public class EmailService {
    public void sendWelcomeEmail(User user) { /* 환영 이메일 발송 메서드 */ }
}
// 사용자와 직접적인 관련이 없는 환영 이메일 발송(sendWelcomeEmail) 기능은
// 별도의 이메일서비스(EmailService)가 책임지도록 한다.

 

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

클래스는 확장에 열려 있어야 하고, 수정에는 닫혀 있어야 한다. 
  • 새로운 기능이 필요할 때 기존 코드를 수정하는 대신 확장을 통해 기능을 추가해야 한다.
  • 이를 통해 코드의 안정성을 높이고, 유지보수가 용이해 진다.
// (예시2) OCP
public interface DiscountPolicy { /*할인 정책 인터페이스 */
    double applyDiscount(double amount); /*할인 적용 메서드 */
}

public class ChristmasDiscount implements DiscountPolicy {
    public double applyDiscount(double amount) {  /*할인 적용 메서드 구현*/
        return amount * 0.9; // 10% 할인
    }
}

public class NewYearDiscount implements DiscountPolicy { /* 할인 정책 인터페이스 */
    public double applyDiscount(double amount) {  /* 할인 적용 메서드 구현 */
        return amount * 0.8; // 20% 할인
    }
}
// 할인 정책이라는 인터페이스 두고, 
// 필요에 따라 이를 구현할 할인 정책 클래스를 추가하여 (ChristmasDiscount, NewYearDiscount)
// 각각 요구에 맞는 할인 정책을 적용하는 기능(applyDiscount)을 구체화하여 구현한다.

 

LSP (Liskov Substitution Principle) : 리스코프 치환 원칙

하위 클래스는 언제나 상위 클래스를 대체할 수 있어야 한다.
  • 상위 클래스 타입의 객체가 하위 클래스 타입으로 교체(치환)되더라도, 프로그램 기능상 문제가 발생하지 않아야 한다.
  • 객체 지향 프로그래밍의 다형성 특성을 실천하기 위한 원칙이다.
  • 상속 관계에 있어 하위 클래스가 상위 클래스의 기능을 온전히 대처할 수 있기 때문에 상위 클래스에 의존하는 코드의 안정성이 높아진다.
// (예시 3-1) LSP 위반
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;
    }
}

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;
    }
}

public class Main {
    public static void main(String[] args) {
        Rectangle rectangle = new Square();
        rectangle.setWidth(5);
        rectangle.setHeight(10);

        System.out.println("Area: " + rectangle.getArea()); // 100이출력됨
    }
}
// 상위 클래스인 직사각형(Rectagle) 타입으로 정사각형(Square) 인스턴스를 생성하고
// 면적을 구하는 getArea을 실행했을 때 예상하는 결과 값인 5*10=50이 아닌 10*10=100이 나온다.
// 이는 정사각형이 가로(width)와 세로(Height)를 다르게 설정할 수 없는 제약을 가지기 때문이다.
// (예시 3-2) LSP 준수
interface Shape {
    int getArea();
    public void setWidth(int width);
}

class Rectangle implements Shape {
    private int width;
    private int height;

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

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

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

class Square implements Shape {
    private int side;

    public void setSide(int side) {
        this.side = side;
    }

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

class Circle implements Shape {
    private int radius;

    public void setRadius(int radius) {
        this.radius = radius;
    }

    @Override
    public int getArea() {
        return (int) (Math.PI * radius * radius);
    }
}
// 면적을 구하는(getArea) 메서드만 갖는 모양(Shape) 공통 인터페이스를 정의하여, 
// 이를 상속 받는 도형 클래스(Rectangle, Square, Circle)들을 별개로 구현한다.
// 각 도형이 가지는 고유 메서드는 각각의 클래스에 따라 정의하고,
// 공동 메서드인 getArea에 대해 각각 도형에 맞게 구체적으로 구현하도록 한다.

ISP (Interface Segregation Principle) : 인터페이스 분리 원칙

클라이언트는 자신이 사용하지 않는 인터페이스는 구현하지 않아야 한다.
  • 인터페이스를 통합하여 하나로 사용하기 보다는 여러 개의 구체적인 인터페이스로 분리하여 각 인터페이스가 특정 클라이언트에 필요한 기능만 포함해야 한다.
  • 인터페이스가 특정 클라이언트에 맞추어 설계되므로 클라이언트가 불필요한 의존성을 가지지 않게 되어, 사용하지 않는 인터페이스에 변경/수정되더라도 영향을 받지 않는다.
  • 코드가 깔끔하게 정리되어 가독성이 높아지고, 인터페이스의 재사용성이 높아진다.
// (예시4) ISP
public interface Printer {
    void print();
}

public interface Scanner {
    void scan();
}

public class MultiFunctionPrinter implements Printer, Scanner {
    public void print() { /* 프린트 기능 */ }
    public void scan() { /* 스캔 기능 */ }
}

public class SimplePrinter implements Printer {
    public void print() { /* 프린트 기능만 구현 */ }
}
// 프린터와 스캐너는 각 기능이 다르므로 분리된 인터페이스로 정의한다.
// 기본프린터(SimplePrinter)는 Scanner 인터페이스를 구현하지 않아도 되므로 
// 인터페이스 분리 원칙을 준수한다.

 

DIP (Dependency Inversion Principle) : 의존 역전 원칙

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
  • 구체적인 클래스에 의존하지 않고, 추상화된 상위 클래스 또는 인터페이스에 의존해야한다.
  • 이를 통해 클래스/모듈 간 결합도를 낮추어, 유연하게 구현부를 변경할 수 있도록하여 확장성을 높인다.
  • 객체 지향 프로그래밍의 추상화 특성을 실현하기 위한 원칙이다.
// (예시5) DIP 
public interface MessageSender {
    void sendMessage(String message);
}

public class EmailSender implements MessageSender {
    public void sendMessage(String message) { /* 이메일 발송 */ }
}

public class NotificationService {
    private final MessageSender messageSender;

    public NotificationService(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    public void send(String message) {
        messageSender.sendMessage(message);
    }
}
// NotificationService는 MessageSender 인터페이스에 의존하므로, 
// 구체적인 구현 클래스(EmailSender)가 바뀌어도 코드를 수정할 필요가 없다.

 


Ref.