객체 지향 설계 5원칙 S.O.L.I.D을 알아보자

먼저 읽으면 좋은 글

객체 지향 프로그래밍의 4가지 특징과 5가지 설계 원칙

객체 지향 설계란?

객체 지향 설계는 변경에 유연한 설계를 위한 다양한 프로그래밍 패러다임 중 하나입니다.

소프트웨어는 요청한 기능을 구현한다고 끝나지 않습니다. 끊임없이 요구사항이 추가되고 개발자는 그에 맞춰서 소프트웨어를 변경해갑니다.

그렇기 때문에 많은 개발자들이 변경으로 인해 복잡한 코드를 읽고 수정하고 에러를 경험합니다.

여기서 개발자는 유연한 설계가 필요하다는 것을 느끼게 되었고 다양한 프로그래밍 패러다임이 나오게 되었습니다.

그 중 하나가 객체 지향 설계입니다.

객체 지향 설계는 메시지를 식별하고, 그 메시지를 처리할 책임을 가질 객체를 식별하여 의미를 부여하는 것으로부터 시작합니다.

객체를 만들 때는 요청 받은 메시지가 처리할 가장 적합할 객체를 찾고, 그 객체를 통해 문제를 해결하는 것을 객체 지향 설계의 핵심이라고 볼 수 있습니다.

객체 지향 설계 5원칙

객체지향 설계를 하기 위해 선언된 5가지 원칙이 있습니다. 앞 글자를 따서 SOLID란 이름으로 쉽게 외우고 있는데요.

이 원칙은 OOP의 4가지 특징과 더불어 객체 지향 프로그래밍 단골 면접 질문 중 하나입니다.

그리고 이 원칙을 기반으로 디자인 패턴에 대해 공부하면 왜 5원칙이 필요한 지 알 수 있습니다. 또한 아카텍처 설계 또한 이 원칙을 지켜 설계하기 때문에 잘 알아두는 것이 중요합니다.

객체 지향 설계 5원칙은 결국 코드를 확장하고 유지 보수 관리를 쉽게 하며, 복잡성을 제거해 리팩토링에 소요되는 시간을 줄이는 것입니다.

이를 통해 개발 생산성을 올리면서 투입되는 자원을 절약할 수 있습니다.

객체 지향 설계 5원칙에 나오는 SOLID는 특정 프레임워크나 언어에 종속되지 않습니다. 즉, 다양한 객체지향 언어나 프레임워크에 적용해서 사용할 수 있는 원칙입니다. 원칙이라는 것은 독립성을 가지고 있기 때문에 객체 지향 설계가 아닌 다른 프로그래밍 패러다임에서는 통용되지 않지만 객체 지향 설계를 도입한 언어는 이 원칙에 따라 코드를 작성해야 합니다.

각 원칙은 어떤 문제를 풀 것인가에 따라 적용되는 것이 있고, 적용이 필요 없는 것이 있습니다. SOLID 원칙은 코드가 가진 문제를 해결하기 위해 나온 것이니 무조건 적용해야 한다는 강박은 가지지 않아도 됩니다.

단일 책임 원칙 – Single Responsibility Principle)

단일 책임의 원칙은 모듈이 변경되는 이유가 한가지 여야 한다는 원칙입니다. 여기서 변경의 이유가 한가지라는 의미는 해당 모듈이 여러 대상 또는 액터들에 대한 책임을 가져서는 안되고, 오직 하나의 액터에 대해서만 책임을 가져야 한다는 것을 의미합니다.

만약 어떤 모듈이 여러 액터에 대한 책임을 가지고 있으면, 여러 액터가 변경을 요구할 때 모듈을 수정해야 하는 이유가 여러개가 될 수 있습니다. 반면 하나의 책임만 가지고 있다면, 특정 액터의 변경 요청을 특정할 수 있기 때문에 해당 모듈를 변경하는 이유와 시점을 명확히 알 수 있습니다. 코드를 통해 살펴보죠.

@Service
public class UserService {

	private final UserRepository userRepository;

	public void addUser(String email) {
		  String capitalCaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
      String lowerCaseLetters = "abcdefghijklmnopqrstuvwxyz";
      String specialCharacters = "!@#$";
      String numbers = "1234567890";
      String combinedChars = capitalCaseLetters + lowerCaseLetters + specialCharacters + numbers;
      Random random = new Random();
      char[] password = new char[length];

      password[0] = lowerCaseLetters.charAt(random.nextInt(lowerCaseLetters.length()));
      password[1] = capitalCaseLetters.charAt(random.nextInt(capitalCaseLetters.length()));
      password[2] = specialCharacters.charAt(random.nextInt(specialCharacters.length()));
      password[3] = numbers.charAt(random.nextInt(numbers.length()));
   
      for(int i = 4; i< length ; i++) {
         password[i] = combinedChars.charAt(random.nextInt(combinedChars.length()));
      }

		  final String encryptedPassword = sb.toString();
		  final User user = User.builder()
				  .email(email)
				  .password(String.valueOf(password)).build();

		  userRepository.save(user);
	}
}

위 코드를 보면 분명 유저를 등록하는 코드라고 되어 있지만 메서드 안에는 패스워드를 만드는 코드까지 함께 들어가 있습니다. 이는 유저 등록에 패스워드 생성이라는 책임이 한 메서드에 들어가 있기 때문에 이 코드를 리팩토링하여 단일 책임 원칙을 지킬 수 있도록 수정할 것입니다.

책임이 하나여야 하는 이유는 위와 같은 코드에서는 비밀번호 로직을 바꾸거나, 유저 등록 시 새로운 기능 추가, 기존 기능 수정을 할 때 복잡해지기 때문입니다.

@Service
public class UserService {

	private final UserRepository userRepository;

  public void addUser(String email, String pw) {

    String password = createPassword();
    User user = User.builder()
        .email(email)
        .password(String.valueOf(password)).build();

    userRepository.save(user);
  }

  private String createPassword() {
    String capitalCaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    String lowerCaseLetters = "abcdefghijklmnopqrstuvwxyz";
    String specialCharacters = "!@#$";
    String numbers = "1234567890";
    String combinedChars = capitalCaseLetters + lowerCaseLetters + specialCharacters + numbers;
    Random random = new Random();
    char[] password = new char[length];

    password[0] = lowerCaseLetters.charAt(random.nextInt(lowerCaseLetters.length()));
    password[1] = capitalCaseLetters.charAt(random.nextInt(capitalCaseLetters.length()));
    password[2] = specialCharacters.charAt(random.nextInt(specialCharacters.length()));
    password[3] = numbers.charAt(random.nextInt(numbers.length()));

    for (int i = 4; i < length; i++) {
      password[i] = combinedChars.charAt(random.nextInt(combinedChars.length()));
    }

    return String.valueOf(password);
  }
}

위와 같이 하나의 메서드, 하나의 클래스는 하나의 책임만 가지도록 코드를 작성해야 합니다.

이렇게 작업하면 추후 createPassword() 메서드를 다른 메서드에서 사용가능하며, 다른 클래스에서 공용으로 사용한다면 따로 클래스를 생성하여 Password 처리용 클래스를 만들 수 있습니다.

해당 원칙을 통해 프로그램의 유지보수 성을 높이는데 큰 도움이 됩니다.

그렇다고 무조건 기능을 단일화 하는 것은 아닙니다. 책임의 범위는 상황에 따라 다르기 때문입니다.

해답은 없지만 어떤 이유로 단일 책임 원칙을 적용하는지 이해가 되기를 바랍니다.

사람도 하나에 집중해서 일을 잘 처리하듯 클래스도 하나의 기능만 책임지도록 코드를 작성한다는 생각으로 개발하면 좋겠습니다.

개방 폐쇄 원칙 – Open Closed Principle

개방 폐쇄 원칙은 확장에는 열려있어야 하며, 수정에 대해서는 닫혀있어야 한다는 원칙입니다.

  • 확장에 대해 열려있다란? – 요구 사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장 가능하게 한다.
  • 수정에 대해 닫혀있다란? – 기존 코드를 수정하지 않고 애플리케이션 동작을 추가하거나 변경할 수 있다.

이 원칙의 뜻은 추상화 사용을 통한 관계 구축을 권장하는 원칙이다.

다형성과 확장을 가능하게 하는 객체지향의 장점을 극대화하는 기본 설계 원칙이라 보면 된다.

이 말은 우리가 구체화 된 코드에 의존하는 것이 아니라 인터페이스 같은 추상화 된 코드를 활용하여 기능을 추가하거나 수정할 때 해당 기능을 사용하는 코드의 변화가 없어야 합니다.

class Car {
  String type;

  Car(String type) {
    this.type = type;
  }
}

class Driver {
  public void drive(Car car) {
    if (car.type.equals("Benz")) {
      System.out.println("Benz Start!!");
      System.out.println("Benz Stop!!");
    } else if(car.type.equals("Kia")) {
      System.out.println("Kia Start!!");
      System.out.println("Kia Stop!!");
    }
  }
}

public class Main {
  public static void main(String[] args) {
    Driver driver = new Driver();

    Car benz = new Car("Benz");
    Car kia = new Car("Kia");

    driver.drive(benz);
    driver.drive(kia);
  }
}

위 코드는 확장에 매우 불리한 코드로 구성되어 있습니다.

Car라는 클래스에 다른 회사들을 구성하여 Driver 클래스에서 drive 메서드를 실행하고 있습니다. 이 과정에서 회사 이름이 늘어나면 코드가 계속해서 변경되고, 늘어납니다. Main 클래스에서 현대라는 다른 회사가 들어가면 Driver 클래스에 코드를 추가로 작성해야 합니다.

이것은 확장을 할 때 기존 코드를 수정해야 하는 상황이기 때문에 개방 폐쇄 원칙을 제대로 지키지 않은 상황입니다. 그렇기 때문에 위 코드를 아래와 같이 변경해서 개방 폐쇄 원칙을 지켜보겠습니다.

interface ICar {
  void start();

  void stop();
}

class Benz implements ICar {

  public void start() {
    System.out.println("Benz Start!!");
  }

  public void stop() {
    System.out.println("Benz Stop!!");
  }
}

class Kia implements ICar {

  public void start() {
    System.out.println("Kia Start!!");
  }

  public void stop() {
    System.out.println("Kia Stop!!");
  }
}

class Driver {
  public void drive(ICar car) {
    car.start();
    car.stop();
  }
}

public class Main {
  public static void main(String[] args) {
    Driver driver = new Driver();

    ICar benz = new Benz();
    ICar kia = new Kia();

    driver.drive(benz);
    driver.drive(kia);
  }
}

위와 같이 인터페이스로 추상화를 시킨 후 각 회사에 상속시켜 구현체를 만듭니다. 이렇게 만들게 되면 추후 회사를 추가해도 Driver 클래스를 수정할 필요가 없어집니다. 이로써 확장에는 열려있고, 수정에는 닫혀있는 코드가 완성됩니다.

사실 어렵게 생각할 것이 없습니다. 우리가 추상화를 할 때 사용하는 인터페이스와 추상 클래스, 상속을 통한 클래스 관계 구축을 기억한다면 이 원칙은 쉽게 적용할 수 있습니다.

리스코프 치환 원칙 – Liskov Substitution Principle

리스코프 치환 원칙은 하위 타입은 상위 타입을 대체할 수 있어야 한다는 것입니다. 이 말은 해당 객체를 사용하는 클라이언트는 상위 타입이 하위 타입으로 변경되어도, 차이점을 인식하지 못한 채 상위 타입의 퍼블릭 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다는 뜻이죠.

사실 앞선 원칙에서 활용한 코드에서 이와 같은 방식의 코드를 작성했었다.

public class Main {
  public static void main(String[] args) {
    Driver driver = new Driver();

    ICar benz = new Benz();
    ICar kia = new Kia();

    driver.drive(benz);
    driver.drive(kia);
  }
}

위 코드를 보면 ICar 인터페이스에 Benz 클래스, Kia 클래스를 선언한 모습을 볼 수 있습니다. 우리가 흔히 사용하는 코드 중에 인터페이스에 구현체를 넣는 코드가 바로 리스코프 치환 원칙에 기반한 코드라는 것을 기억하면 쉽습니다.

리스코프 치환 원칙은 다형성을 지원하기 위한 원칙이라고 정의할 수 있습니다. 그래서 다형성을 매우 중요시 여기는 객체지향 설계에서는 중요한 원칙입니다.

단, 아래와 같이 코드를 작성하면 안됩니다.

class Car {
  int go(int speed) {
    return speed * 10;
  }
}

class Kia extends Car {
  String go(int speed) {
    return "기아차는 스피드가 " + speed + "KM 입니다.";
  }
}

public class Main {
  public static void main(String[] args) {
    Car kia = new Kia();
    kia.go(10); // 에러 발생
  }
}

위 코드는 리스코프 치환 원칙을 위배한 코드입니다. 바로 go 메서드 반환 값이 다르기 때문이죠. 그래서 Car 클래스에 Kia 클래스를 선언하면 오류가 발생합니다. 이런 이슈가 생기지 않도록 오버로딩을 할 때 부모 클래스와 자식 클래스의 메소드 타입과 매개변수는 그대로 따라가야 합니다.

인터페이스는 기본적으로 구현이 없고, 상속 받는 클래스가 구현할 때 인터페이스의 반환 값과 매개 변수가 다르다면 오류가 발생하기 때문에 위와 같은 상황이 발생하지 않습니다.

이 원칙이 중요한 가장 큰 이유는 협업하는 개발자 사이의 신뢰를 위한 원칙이기 때문입니다. 특정 라이브러리를 사용하기 위해 코드를 가져왔습니다. 그리고 원하는 기능을 사용했는데 뜬금없는 기능이 튀어 나온다면 해당 라이브러리를 신뢰할 수 없게 됩니다.

그런 이유로 리스코프 치환 원칙은 하위 클래스를 상위 클래스로 선언 받더라도 의도대로 동작만 하면 문제 없이 지켜집니다.

인터페이스 분리 원칙 – Interface Segregation Principle

객체가 충분히 높은 응집도의 작은 단위로 설계하더라도, 목적과 관심이 다른 클라이언트가 있다면 인터페이스를 통해 적절하게 분리해 줄 필요가 있습니다. 즉, 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공해야 합니다.

인터페이스 분리 원칙을 준수함으로써 모든 클라이언트가 자신의 관심에 맞는 퍼블릭 인터페이스만을 접근하여 불필요한 간섭을 최소화 할 수 있습니다.

ISP 원칙은 인터페이스의 단일 책임을 강조하고 있습니다. 또한, 인터페이스 분리를 통해 설계하는 원칙입니다.

주의해야 할 점은 한번 인터페이스를 분리하여 구성했다면 추가적으로 인터페이스를 분리하는 행위는 지양해야 한다.

interface ICar {
  void start();

  void stop();

  void kiaEngineCheck();

  void benzEngineCheck();
}

class Benz implements ICar {

  public void start() {
    System.out.println("Benz Start!!");
  }

  public void stop() {
    System.out.println("Benz Stop!!");
  }

  public void kiaEngineCheck() {
    System.out.println("Kia 엔진 체크 시스템");
  }

  public void benzEngineCheck() {
    System.out.println("Benz 엔진 체크 시스템");
  }
}

class Kia implements ICar {

  public void start() {
    System.out.println("Kia Start!!");
  }

  public void stop() {
    System.out.println("Kia Stop!!");
  }

  public void kiaEngineCheck() {
    System.out.println("Kia 엔진 체크 시스템");
  }

  public void benzEngineCheck() {
    System.out.println("Benz 엔진 체크 시스템");
  }
}

위 코드는 인터페이스 분리 원칙을 위반한 코드가 포함되어 있습니다. 바로 kiaEngineCheck 메소드와 benzEngineCheck 메소드 입니다. 이 메소드는 다른 클래스에서 필요 없는 기능을 구현하게 만들기 때문에 가능하면 아래 코드처럼 각 클래스에서 따로 구현하거나 공통으로 사용할 수 있는 메소드로 설계해야 합니다.

아니면 인터페이스의 책임을 분리하여 다중 상속을 통해 인터페이스의 책임을 하나로 만들어야 합니다.

위 코드를 한번 수정해보겠습니다.

interface ICar {
  void start();

  void stop();

  void benzEngineCheck();
}

class Benz implements ICar {

  public void start() {
    System.out.println("Benz Start!!");
  }

  public void stop() {
    System.out.println("Benz Stop!!");
  }

  public void benzEngineCheck() {
    System.out.println("Benz 엔진 체크 시스템");
  }
}

class Kia implements ICar {

  public void start() {
    System.out.println("Kia Start!!");
  }

  public void stop() {
    System.out.println("Kia Stop!!");
  }

  public void kiaEngineCheck() {
    System.out.println("Kia 엔진 체크 시스템");
  }
}

인터페이스에 있던 여러 책임의 메소드를 각 클래스로 옮겨서 구현하게 했습니다.

이런식으로 인터페이스에 과도한 책임 부여는 중복 코드 및 문제를 발생시킬 수 있습니다.

이 점을 명심하시고, 인터페이스 분리 원칙을 적용하면 되겠습니다.

의존 역전 원칙 – Dependency Inversion Principle

의존 역전 원칙은 어떤 클래스를 참조해서 사용해야 하는 상황이 생긴다면, 그 클래스를 직접 참조하는 것이 아닌 대상의 상위 요소인 인터페이스나 추상 클래스를 참조하는 원칙을 의미합니다.

앞서 만든 코드들 중 대부분이 인터페이스를 활용하고 있습니다. 직접 클래스를 호출해서 사용하는 것이 아닌 상속 받는 인터페이스를 참조해서 사용해야 한다는 원칙입니다. 이 원칙은 어떻게 보면 인터페이스에 의존하라는 말로 보입니다.

의존 관계를 맺을 때 변화하기 쉬운 것이나 자주 변화하는 것보다는 변화하기 어려운 것에 의존하라는 의미입니다.

인터페이스 같은 경우 한번 설계되면 잘 변하지 않습니다. 그렇기 때문에 변화하지 않고 상위 요소로 많이 사용되는 인터페이스에 의존성을 전가하는 경우가 많습니다.

우리가 흔히 Spring Framework를 사용하면서 DI와 IoC를 활용한다면 대부분 인터페이스를 활용합니다. 여기서 인터페이스를 활용한 이유는 의존 역전 원칙을 적용하기 위함이라고 추측할 수 있죠.

DI를 공부하셨던 분들은 알다시피 의존 역전 원칙을 지키는 이유는 클래스간의 결합도를 낮추기 위함입니다.

객체 지향 설계 의존 역전 원칙
객체 지향 설계 5원칙 S.O.L.I.D을 알아보자 3

앞서 구현한 코드를 그림으로 그리면 위와 같습니다. 이렇듯 Driver클래스는 ICar 인터페이스를 의존하여 구현체를 활용할 수 있습니다. 이를 통해 추후 ICar를 상속 받는 구현체 클래스가 생기더라도 Driver는 그대로 ICar를 사용하여 새로 생성 된 구현체를 가져다 사용할 수 있습니다.

List<String> myList = new ArrayList<String>();
  
Set<String> mySet = new HashSet<String>();

Map<Integer, String> myMap = new HashMap<Integer, String>();

위 코드는 Java에서 흔히 봤던 List와 Set, Map 인터페이스 입니다. 코드를 사용할 때 구현체를 원하는 것을 넣어 사용하면서 구현체에 대한 의존성을 낮춰 코드 유연성을 높여줍니다. 이렇듯 변화에 영향받지 않도록 하는 것이 의존 역전 원칙입니다.

마무리

객체 지향 설계 원칙의 SOLID는 결국 다형성과 추상화에 대한 방법을 이야기하고 있습니다. 구현체를 의지하지 않고, 인터페이스와 추상 클래스를 활용하면서 최대한 결합성을 낮추고, 어떤 상황에서도 유연하고 확장 가능한 코드를 작성하고자 나온 원칙입니다.

마지막으로 하나의 기능에 하나의 책임만 가지도록 코드를 작성함으로써 쉽게 모듈을 가져오고, 제거할 수 있는 코드를 작성해야 합니다.

참조

https://mangkyu.tistory.com/194

https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%EC%84%A4%EA%B3%84%EC%9D%98-5%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99-SOLID

Leave a Comment