관리 메뉴

나만을 위한 블로그

[JAVA] SOLID 원칙이란? - 1 - 본문

JAVA

[JAVA] SOLID 원칙이란? - 1 -

참깨빵위에참깨빵 2021. 7. 19. 23:37
728x90
반응형

원래 SOLID 원칙은 객체지향 5대 원칙이라 불린다. 그래서 비단 자바 뿐 아니라 객체지향으로 설계된 언어 모두에 적용되는 원칙이라고 생각한다.

그러나 내가 아는 객체지향 언어는 자바, 코틀린 뿐인데 코틀린은 아직 미숙하므로 자바 관점에서 SOLID 원칙이 무엇인지에 대해 확인해보려고 한다.

 

SOLID란 단어는 자바의 클래스 부분을 공부하다가 이름만 봤었는데, 당시에는 클래스와 객체, 메서드를 이해하는 것만으로도 벅차서 넘겼지만 지금은 그 때에 비해 아주 조금은 나아진 상태가 됐다고 생각해서 SOLID에 대해 확인해보려고 한다.

먼저 SOLID 원칙의 SOLID는 5개 원칙들의 앞글자를 따온 것이다.

 

  • SRP(Single Responsibility Principle) : 단일 책임 원칙
  • OCP(Open/Closed Principle) : 개방 폐쇄 원칙
  • LSP(Liskov Substitution Principle) : 리스코프 치환 원칙
  • ISP(Interface Segregation Principle) : 인터페이스 분리 원칙
  • DIP(Dependency Inversion Principle) : 의존관계 역전 원칙

 

이 원칙들을 하나의 포스팅에서 다루기에는 양이 많기 때문에 이 포스팅에선 단일 책임 원칙, 개방 폐쇄 원칙만 확인하고 나머지 3개 원칙은 2번째 포스팅에서 정리한다.

각 원칙들에 대해 확인해보기 전에, 먼저 SOLID 원칙이 정확히 무엇이고 왜 나오게 되었는지 그 배경을 확인해보자.

SOLID라는 단어는 따로 존재하지만 이 경우 5개 원칙들의 앞글자를 따서 지어진 것이기 때문에 사전적 정의는 생략하고 바로 위키피디아에서 뭐라고 설명하는지 확인해봤다.

https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84) 

 

SOLID (객체 지향 설계) - 위키백과, 우리 모두의 백과사전

 

ko.wikipedia.org

SOLID란 로버트 마틴이 2000년대 초반에 명명한 객체지향 프로그래밍 및 설계의 5가지 기본 원칙을 마이클 페더스가 두문자어 기억술로 소개한 것이다. 프로그래머가 시간이 지나도 유지보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 함께 적용할 수 있다.
SOLID 원칙들은 소프트웨어 작업에서 프로그래머가 소스코드가 읽기 쉽고 확장하기 쉽게 될 때까지 소프트웨어 소스코드를 리팩토링하여 코드 냄새를 제거하기 위해 적용할 수 있는 지침이다. 이 원칙들은 애자일 소프트웨어 개발과 적응적 소프트웨어 개발의 전반적 전략의 일부다.

 

두문자어는 낱말의 머리글자를 모아서 만든 준말이다. 뉴스에 흔히 나오는 UN, NATO, DNA 등이 그것이다.

또한 SOLID는 프로그래머가 소스코드를 리팩토링해서 코드 냄새를 제거하기 위해 사용하는 거라고 한다. 코드 냄새? 코드에서 냄새가 난다는 것 같은데, 무슨 의미일까?

 

https://ko.wikipedia.org/wiki/%EC%BD%94%EB%93%9C_%EC%8A%A4%EB%A9%9C

 

코드 스멜 - 위키백과, 우리 모두의 백과사전

코드 스멜(code smell←코드 냄새)은 컴퓨터 프로그래밍 코드에서 더 심오한 문제를 일으킬 가능성이 있는 프로그램 소스 코드의 특징을 가리킨다.[1] 이 용어는 1990년대 말 켄트 벡과 워즈위키에

ko.wikipedia.org

코드 스멜(냄새)은 프로그래밍 코드에서 더 심오한 문제를 일으킬 가능성이 있는 프로그램 소스 코드의 특징을 가리킨다. 코드 스멜은 애자일 프로그래머가 쓰는 용어기도 하다. 무엇이 코드 스멜인지 아닌지의 여부를 결정하는 일은 주관적인 것으로 언어와 개발자, 개발 방법에 따라 다양하다.
- 응용 프로그램 수준의 스멜 : 중복 코드, 억지로 꾸민듯한 복잡성
- 클래스 수준의 스멜 : 커다란 클래스, 기능에 대한 욕심, 부적절한 관계, 거부된 유산, 게으른 클래스, 리터럴의 과도한 사용 등
- 메서드 수준의 스멜 : 너무 많은 매개변수, 긴 메서드, 과도하게 긴 식별자, 과도하게 짧은 식별자, 과도한 데이터의 반환

 

대충 문제가 될 수 있는 코드라는 것 정도인 듯하다. 그럼 SOLID 원칙의 단일 책임 원칙부터 확인해보자.

 

단일 책임 원칙?

 

단일 책임 원칙에 대해선 위키백과에서 아래와 같이 말하고 있다.

객체 지향 프로그래밍에서 단일 책임 원칙이란 모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 함을 일컫는다. 클래스가 제공하는 모든 기능은 이 책임과 주의 깊게 부합해야 한다...(중략)...로버트 마틴은 책임을 변경하려는 이유로 정의하고, 어떤 클래스나 모듈은 변경하려는 단 하나의 이유만을 가져야 한다고 결론 짓는다.
보고서를 편집, 출력하는 모듈을 생각해 보자. 이 모듈은 2가지 이유로 변경될 수 있다. 첫 번째로 보고서의 내용 때문에 변경될 수 있다. 두 번째로 보고서의 형식 때문에 변경될 수 있다. 이 2가지 변경은 하나는 실질적이고 다른 하나는 꾸미기 위한 매우 다른 원인에 기인한다. 단일 책임 원칙에 의하면 이 문제의 두 측면이 실제로 분리된 두 책임 때문이며, 따라서 분리된 클래스나 모듈로 나눠야 한다. 다른 시기에 다른 이유로 변경되야 하는 2가지를 묶는 것은 나쁜 설계일 수 있다.
한 클래스를 한 관심사에 집중하도록 유지하는 것이 중요한 이유는, 이것이 클래스를 더 튼튼하게 만들기 때문이다. 앞의 예를 계속 살펴보면 편집 과정에 변경이 일어날 경우 같은 클래스의 일부로 있는 출력 코드가 망가질 위험이 대단히 높다.

 

코드로 살펴보자. 예를 들어 손님이라는 클래스가 있는데 아래의 형태를 갖고 있다고 해보자.

import java.util.List;

public class Customer
{
    String name;
    int age;
    long bill;
    List<Item> listOfItems;

    public Customer(String name, int age)
    {
        this.name = name;
        this.age = age;
    }

    // 청구서 계산은 손님의 책임이 아니다
    private long calculateBill(long tax)
    {
        for (Item item : listOfItems)
        {
            bill += item.getPrice();
        }
        bill += tax;
        this.setBill(bill);
        return bill;
    }

    // 보고서 생성은 손님의 책임이 아니다
    public void generateReport(String reportType)
    {
        if (reportType.equalsIgnoreCase("CSV"))
        {
            System.out.println("SCV 보고서 생성");
        }
        if (reportType.equalsIgnoreCase("XML"))
        {
            System.out.println("XML 보고서 생성");
        }
    }

    public String getName()
    {
        return name;
    }

    public void setName(String name)
    {
        this.name = name;
    }

    public int getAge()
    {
        return age;
    }

    public void setAge(int age)
    {
        this.age = age;
    }

    public long getBill()
    {
        return bill;
    }

    public void setBill(long bill)
    {
        this.bill = bill;
    }

    public List<Item> getListOfItems()
    {
        return listOfItems;
    }

    public void setListOfItems(List<Item> listOfItems)
    {
        this.listOfItems = listOfItems;
    }
}

 

이 클래스의 문제점은 2개 있다. 청구서 계산에 변경사항이 생기면 손님 클래스를 바꿔야 하고, 생성할 보고서 유형을 하나 더 추가할 때에도 이 손님 클래스를 바꿔야 한다.

보통 손님은 청구서를 떼거나 보고서를 만들지 않는다. 그러므로 이 기능들에 관해선 다른 클래스를 만들어야 한다.

손님 클래스는 이렇게 변경되어야 한다.

import java.util.List;

public class Customer
{
    String name;
    int age;
    long bill;
    List<Item> listOfItems;

    public Customer(String name, int age)
    {
        this.name = name;
        this.age = age;
    }

    public String getName()
    {
        return name;
    }

    public void setName(String name)
    {
        this.name = name;
    }

    public int getAge()
    {
        return age;
    }

    public void setAge(int age)
    {
        this.age = age;
    }

    public long getBill()
    {
        return bill;
    }

    public void setBill(long bill)
    {
        this.bill = bill;
    }

    public List<Item> getListOfItems()
    {
        return listOfItems;
    }

    public void setListOfItems(List<Item> listOfItems)
    {
        this.listOfItems = listOfItems;
    }
}

 

그리고 청구서 계산과 보고서 생성 기능은 각각 클래스를 만들어 그 안에 넣었다.

import java.util.List;

public class BillCalculator
{
    public long calculateBill(Customer customer, long tax)
    {
        long bill = 0;
        List<Item> listsOfItems = customer.getListOfItems();
        for (Item item : listsOfItems)
        {
            bill += item.getPrice();
        }
        bill += tax;
        customer.setBill(bill);
        return bill;
    }
}
public class ReportGenerator
{
    public void generateReport(Customer customer, String reportType)
    {
        if(reportType.equalsIgnoreCase("CSV"))
        {
            System.out.println("SCV 보고서 생성");
        }
        if(reportType.equalsIgnoreCase("XML"))
        {
            System.out.println("XML 보고서 생성");
        }
    }
}

 

위 코드를 보면 알겠지만 앞으로 청구서 계산 기능에서 바꿔야 할 사항이 있으면 고객 클래스 말고 BillCalculator 클래스에서 수정하면 된다. 마찬가지로 다른 보고 유형을 추가하려면 ReportGenerator 클래스에서 변경하면 된다.

이것이 SOLID 중 단일 책임 원칙에 관한 내용이다. 하나의 클래스에 너무 많은 책임을 지우지 말라는 게 요지같다.

 

개방 폐쇄 원칙?

 

다음은 개방 폐쇄 원칙이다. 이건 또 뭔가?

https://ko.wikipedia.org/wiki/%EA%B0%9C%EB%B0%A9-%ED%8F%90%EC%87%84_%EC%9B%90%EC%B9%99

 

개방-폐쇄 원칙 - 위키백과, 우리 모두의 백과사전

개방-폐쇄 원칙(OCP, Open-Closed Principle)은 '소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다'는 프로그래밍 원칙이다. 상세설명[편집]

ko.wikipedia.org

개방 폐쇄 원칙은 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다는 프로그래밍 원칙이다. 소프트웨어 개발 작업에 이용된 많은 모듈 중 하나에 수정을 가할 때 그 모듈을 이용하는 다른 모듈을 줄줄이 고쳐야 한다면, 이와 같은 프로그램은 수정하기가 어렵다. 개방-폐쇄 원칙은 시스템의 구조를 올바르게 재조직(리팩토링)하여 나중에 이와 같은 유형의 변경이 더 이상의 수정을 유발하지 않도록 하는 것이다. 개방-폐쇄 원칙이 잘 적용되면 기능을 추가하거나 변경해야 할 때 이미 제대로 동작하고 있던 원래 코드를 변경하지 않아도, 기존 코드에 새 코드를 추가함으로써 기능의 추가나 변경이 가능하다.
- 개방-폐쇄 원칙의 2가지 속성
1. 확장에 대해 열려 있다 : 이것은 모듈의 동작을 확장할 수 있다는 것을 의미한다. 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 동작을 추가해 모듈을 확장할 수 있다. 즉, 모듈이 하는 일을 변경할 수 있다.
2. 수정에 대해 닫혀 있다 : 모듈의 소스 코드, 바이너리 코드 등을 수정하지 않아도 모듈의 기능을 확장하거나 변경할 수 있다. 그 모듈의 실행 가능한 바이너리 형태나 링크 가능한 라이브러리를 건드릴 필요가 없다.
- 추상화를 통한 개방-폐쇄 원칙
객체 지향 프로그래밍 언어에서는 고정되기는 해도 제한되지는 않은, 가능한 동작의 묶음을 표현하는 추상화가 가능하다. 모듈은 추상화를 조작할 수 있다. 이런 모듈은 고정된 추상화에 의존하기 때문에 수정에 닫혀 있을 수 있고 반대로 추상화의 새 파생 클래스를 만드는 것을 통해 확장도 가능하다. 따라서 추상화는 개방-폐쇄 원칙의 핵심 요소다.

확장에 열려 있고 수정에는 닫혀 있는 걸 개방 폐쇄 원칙이라고 하는 것 같다. 위키백과의 내용만으로도 대략 어떤 내용인지 감이 올 것 같지만 확실하게 알기 위해 인터넷을 찾아봤다.

 

https://steady-coding.tistory.com/378

 

[SOLID] 개방 폐쇄 원칙(OCP)이란?

안녕하세요? 제이온입니다. 저번 시간에는 단일 책임 원칙에 대해서 알아보았습니다. 오늘은 개방 폐쇄 원칙을 설명하겠습니다. 개방 폐쇄 원칙 (Open-Closed Principle)의 정의 개방 폐쇄 원칙은 "확

steady-coding.tistory.com

개방 폐쇄 원칙은 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다를 의미한다. 좀 더 쉽게 풀어 쓰자면 기능을 변경하거나 확장할 수 있으면서 그 기능을 쓰는 코드는 수정하지 않는다를 뜻한다.

이것 또한 예제 코드를 확인해보자.

계산기 기능을 만든다 가정하고, 먼저 최상위 인터페이스를 만들어 준다. 아직 인터페이스 안에 추상 메서드를 작성하지는 않았다.

public interface CalculatorOperation
{
}

 

그리고 두 숫자를 더하고 CalculatorOperation 인터페이스를 구현한 Addition 클래스를 정의한다.

public class Addition implements CalculatorOperation
{
    private double left;
    private double right;
    private double result = 0.0;

    public Addition(double left, double right)
    {
        this.left = left;
        this.right = right;
    }

    public double getLeft()
    {
        return left;
    }

    public void setLeft(double left)
    {
        this.left = left;
    }

    public double getRight()
    {
        return right;
    }

    public void setRight(double right)
    {
        this.right = right;
    }

    public double getResult()
    {
        return result;
    }

    public void setResult(double result)
    {
        this.result = result;
    }
}

 

그리고 Subtraction(빼기) 클래스를 정의한다. 여기부터 뭔가 잘못됐음을 느꼈지만 잘못된 예시를 일부러 보여주는 것이니 그러려니 하고 넘어갔다.

public class Subtraction implements CalculatorOperation
{
    private double left;
    private double right;
    private double result = 0.0;

    public Subtraction(double left, double right)
    {
        this.left = left;
        this.right = right;
    }

    public double getLeft()
    {
        return left;
    }

    public void setLeft(double left)
    {
        this.left = left;
    }

    public double getRight()
    {
        return right;
    }

    public void setRight(double right)
    {
        this.right = right;
    }

    public double getResult()
    {
        return result;
    }

    public void setResult(double result)
    {
        this.result = result;
    }
}

 

이제 계산기 작업을 수행할 기본 클래스를 정의한다.

import java.security.InvalidParameterException;

public class Calculator
{
    public void calculate(CalculatorOperation operation)
    {
        if (operation == null)
        {
            throw new InvalidParameterException("기능을 실행할 수 없습니다");
        }

        if (operation instanceof Addition)
        {
            Addition addition = (Addition) operation;
            addition.setResult(addition.getLeft() + addition.getRight());
        }
        else if (operation instanceof Subtraction)
        {
            Subtraction subtraction = (Subtraction) operation;
            subtraction.setResult(subtraction.getLeft() + subtraction.getRight());
        }
    }
}

 

그냥 봤을 땐 이게 뭐가 문제야 싶을 수 있다. 그러나 개방 폐쇄 원칙을 지킨 코드라고는 보기 어렵다.

계산기는 사칙연산 기능이 모두 제공되는데 위의 코드로는 더하기, 빼기만 가능하다. 곱하기와 나누기 기능을 추가해달라는 요구사항이 들어오면 Calculator 클래스의 계산 방법을 변경해야 한다.

 

그럼 OCP를 지키도록 하려면 어떻게 해야 할까? 위에서 calculate()의 코드는 새로운 요구사항이 생길 때마다 바뀐다.

따라서 이 코드를 추출해 추상화 계층에 넣으면 OCP를 지킬 수 있게 될 것이다. 그러려면 각 작업을 해당 클래스에 위임해야 한다.

맨 처음 만들었던 최상위 인터페이스 안에 아래의 추상 메서드를 만들어준다.

public interface CalculatorOperation
{
    void perform();
}

 

이렇게 하면 Addition 클래스에선 위의 인터페이스의 추상 메서드를 재정의해, 두 숫자를 더하는 로직을 구현할 수 있게 된다.

public class Addition implements CalculatorOperation
{
    private double left;
    private double right;
    private double result = 0.0;

    @Override
    public void perform()
    {
        result = left + right;
    }

    public Addition(double left, double right)
    {
        this.left = left;
        this.right = right;
    }

    public double getLeft()
    {
        return left;
    }

    public void setLeft(double left)
    {
        this.left = left;
    }

    public double getRight()
    {
        return right;
    }

    public void setRight(double right)
    {
        this.right = right;
    }

    public double getResult()
    {
        return result;
    }

    public void setResult(double result)
    {
        this.result = result;
    }

}

 

더하기가 이렇게 변했다면 빼기도 부호만 바뀌고 다른 부분은 같을 것이다.

public class Subtraction implements CalculatorOperation
{
    private double left;
    private double right;
    private double result = 0.0;

    @Override
    public void perform()
    {
        result = left - right;
    }

    public Subtraction(double left, double right)
    {
        this.left = left;
        this.right = right;
    }

    public double getLeft()
    {
        return left;
    }

    public void setLeft(double left)
    {
        this.left = left;
    }

    public double getRight()
    {
        return right;
    }

    public void setRight(double right)
    {
        this.right = right;
    }

    public double getResult()
    {
        return result;
    }

    public void setResult(double result)
    {
        this.result = result;
    }

}

 

나누기는 아래처럼 구현한다고 가정한다.

public class Division implements CalculatorOperation
{
    private double left;
    private double right;
    private double result;

    @Override
    public void perform()
    {
        if (right != 0)
        {
            result = left / right;
        }
    }
}

 

이제 Calculator의 calculate()는 새 연산자가 생길 때마다 새 로직을 이 악물고 구현할 필요가 없어졌다.

import java.security.InvalidParameterException;

public class Calculator
{
    public void calculate(CalculatorOperation operation)
    {
        if (operation == null)
        {
            throw new InvalidParameterException("기능을 실행할 수 없습니다");
        }

        operation.perform();
    }
}

 

이렇게 하면 Calculator 클래스는 확장에는 열린 상태지만 수정에는 닫힌 상태로 존재할 수 있게 된다.

반응형
Comments