Backend/Java

Java - SOLID 5원칙이란?

chillmyh 2023. 12. 18. 23:17

 

Java에서의 SOLID 원칙은 객체 지향 프로그래밍의 다섯 가지 설계 원칙을 의미합니다. 

이 원칙은 소프트웨어 설계의 유연성, 유지보수성, 확장성을 높이기 위해 사용됩니다.



1. 단일 책임 원칙 (Single Responsibility Principle - SRP):
   - 한 클래스는 단 하나의 책임만 가져야 합니다.
   - 상황 예시: 예를 들어, 게시글 관리 클래스가 게시글 작성, 읽기, 수정, 삭제 등의 여러 기능을 수행하는 대신, 각각의 책임을 분리하여 게시글 작성, 게시글 읽기, 게시글 수정, 게시글 삭제를 각각의 클래스로 나눌 수 있습니다.

각 SOLID 원칙에 대한 상황 예시와 코드를 함께 설명해드리겠습니다.

아래는 게시글 관리 클래스가 단일 책임 원칙을 지키지 않는 경우를 보여줍니다.

// 게시글 관리 클래스
public class PostManager {
    public void createPost(String content) {
        // 게시글 생성 로직
    }

    public void readPost(int postId) {
        // 게시글 조회 로직
    }

    public void updatePost(int postId, String newContent) {
        // 게시글 수정 로직
    }

    public void deletePost(int postId) {
        // 게시글 삭제 로직
    }
}


이 클래스는 게시글의 생성, 조회, 수정, 삭제와 같은 여러 기능을 모두 포함하고 있어 단일 책임 원칙을 위배합니다. 

아래는 단일 책임 원칙을 준수하는 예시입니다.

// 게시글 생성 클래스
public class PostCreator {
    public void createPost(String content) {
        // 게시글 생성 로직
    }
}

// 게시글 조회 클래스
public class PostReader {
    public void readPost(int postId) {
        // 게시글 조회 로직
    }
}

// 게시글 수정 클래스
public class PostUpdater {
    public void updatePost(int postId, String newContent) {
        // 게시글 수정 로직
    }
}

// 게시글 삭제 클래스
public class PostDeleter {
    public void deletePost(int postId) {
        // 게시글 삭제 로직
    }
}


위 코드에서 각 클래스는 하나의 책임만을 가지고 있으며, 게시글 생성, 조회, 수정, 삭제 기능이 분리되어 있습니다.



2. 개방-폐쇄 원칙 (Open-Closed Principle - OCP):
   - 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하지만, 수정에는 닫혀 있어야 합니다.
   - 상황 예시: 새로운 기능을 추가할 때 기존의 코드를 수정하지 않고 확장만으로 새로운 기능을 구현할 수 있도록 설계합니다. 예를 들어, 도형 클래스에 새로운 도형을 추가할 때, 도형 클래스를 수정하지 않고 새로운 도형 클래스를 추가하여 기존 도형 클래스에 영향을 주지 않고 기능을 확장할 수 있습니다.

도형 클래스에 새로운 도형을 추가할 때 OCP를 준수하지 않는 경우입니다.

// 도형 클래스
public class Shape {
    public void draw() {
        // 도형을 그리는 기본 로직
    }
}


위 클래스는 새로운 도형을 추가하기 위해 기존 코드를 수정해야 하는 문제가 있습니다. 

아래는 OCP를 준수하는 방법입니다.

// 도형 인터페이스
public interface Shape {
    void draw();
}

// 원 클래스
public class Circle implements Shape {
    @Override
    public void draw() {
        // 원을 그리는 로직
    }
}

// 사각형 클래스
public class Rectangle implements Shape {
    @Override
    public void draw() {
        // 사각형을 그리는 로직
    }
}


새로운 도형을 추가할 때 기존 코드를 수정하지 않고도 인터페이스를 구현하여 확장할 수 있습니다.



3. 리스코프 치환 원칙 (Liskov Substitution Principle - LSP):
   - 하위 클래스는 상위 클래스를 대체할 수 있어야 합니다.
   - 상황 예시: 상속 관계에서 서브 클래스는 슈퍼 클래스의 기능을 포함하고 있어야 하며, 상속 관계에서 부모 클래스 타입으로 사용되는 곳에서도 문제 없이 자식 클래스를 사용할 수 있어야 합니다.
아래는 리스코프 치환 원칙을 어긴 경우입니다.

// 직사각형 클래스
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 calculateArea() {
        return this.width * this.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.height = height;
        this.width = height;
    }
}


위 코드에서 정사각형(Square)은 직사각형(Rectangle)의 특수한 형태로 구현되었지만, 이는 LSP를 위배합니다. 

이러한 문제를 해결하기 위해 아래와 같이 코드를 수정할 수 있습니다.

// 도형 인터페이스
public interface Shape {
    int calculateArea();
}

// 직사각형 클래스
public class Rectangle implements Shape {
    protected int width;
    protected int height;

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

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

    @Override
    public int calculateArea() {
        return this.width * this.height;
    }
}

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

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

    @Override
    public int calculateArea() {
        return this.side * this.side;
    }
}


이제 정사각형과 직사각형은 각자의 특성에 맞게 독립적으로 구현되어 있으며, 상속 관계에서 발생하는 문제를 해결했습니다.


4. 인터페이스 분리 원칙 (Interface Segregation Principle - ISP):
   - 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 합니다.
   - 상황 예시: 큰 인터페이스를 여러 개의 작은 인터페이스로 분리하여 클라이언트가 필요한 기능에만 의존하도록 설계합니다. 예를 들어, 인터페이스를 통해 음악 재생, 일시 정지, 정지 등의 기능을 각각 분리하여 필요한 기능만 사용할 수 있도록 합니다.

아래는 인터페이스 분리 원칙을 위반한 예시입니다.

// 작업 인터페이스
public interface Workable {
    void work();
    void eat();
}

// 로봇 클래스
public class Robot implements Workable {
    @Override
    public void work() {
        // 로봇의 작업 기능
    }

    @Override
    public void eat() {
        // 로봇의 식사 기능
    }
}


위 코드에서 인터페이스에 있는 eat() 메서드는 로봇에게는 적용되지 않는 기능입니다. 

이를 해결하기 위해 ISP를 준수하는 방식으로 수정할 수 있습니다.

// 작업 인터페이스
public interface Workable {
    void work();
}

// 식사 인터페이스
public interface Eatable {
    void eat();
}

// 로봇 클래스
public class Robot implements Workable {
    @Override
    public void work() {
        // 로봇의 작업 기능
    }
}


이제 각 클래스는 필요한 인터페이스만 구현하여, 각각의 책임에 따라 분리되었습니다.


5. 의존 역전 원칙 (Dependency Inversion Principle - DIP):
   - 추상화에 의존해야 하며, 구체화에는 의존하면 안 됩니다.
   - 상황 예시: 상위 수준 모듈은 하위 수준 모듈에 의존해서는 안 되며, 추상화에 의존해야 합니다. 즉, 클래스 간의 의존성을 인터페이스와 추상 클래스에 의존하도록 만들어야 합니다.

아래는 의존 역전 원칙을 위반한 예시입니다.

// 전구 클래스
public class LightBulb {
    public void turnOn() {
        // 전구를 켜는 로직
    }
}

// 스위치 클래스
public class Switch {
    private LightBulb bulb;

    public Switch() {
        this.bulb = new LightBulb();
    }

    public void toggle() {
        // 전구를 켜거나 끄는 로직
        bulb.turnOn();
    }
}


위 코드에서 Switch 클래스는 LightBulb 클래스에 직접 의존하고 있습니다.

이를 해결하기 위해 DIP를 준수하는 방식으로 수정할 수 있습니다.

// 전구 인터페이스
public interface Switchable {
    void turnOn();
}

// 전구 클래스
public class LightBulb implements Switchable {
    @Override
    public void turnOn() {
        // 전구를 켜는 로직
    }
}

// 스위치 클래스
public class Switch {
    private Switchable device;

    public Switch(Switchable device) {
        this.device = device;
    }

    public void toggle() {
        // 전구를 켜거나 끄는 로직
        device.turnOn();
    }
}


이제 Switch 클래스는 추상화된 Switchable 인터페이스에 의존하며, 실제 전구 구현은 이 인터페이스를 구현함으로써 연결됩니다. 

이로써 더 유연하고 확장 가능한 코드를 얻을 수 있습니다.