어 나 갱수.

[OOP] 객체지향 프로그래밍의 설계원칙 SOLID 에 대해 알아보자 !! 🤚 본문

Java

[OOP] 객체지향 프로그래밍의 설계원칙 SOLID 에 대해 알아보자 !! 🤚

김경수 2023. 12. 4. 20:08
728x90

이번 블로그에서는 객체지향 프로그래밍의 설계원칙 5가지인 SOLID에 대해 알아보도록 하겠습니다.

평소에 객체지향 언어인 Java로 많은 개발을 하지만 객체지향 설계원칙에 준수하면서 개발하고 있는 느낌이 들지 않아서 이번 기회에 객체지향에 대해 알아보고 더 객체지향 설계원칙에 준수하면서 개발을 해보려고 합니다!!

 

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

SOLID란 객체지향 프로그래밍을 하면서 지켜야 할 5가지 원칙으로 각각 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), DIP(의존 역전 원칙), ISP(인터페이스 분리 원칙) 이렇게 구성되어 있습니다. SOLID 원칙을 준수하면서 개발을 하면 시간이 지나도 변경에 용이하고 유지보수, 확장에 쉬운 소프트웨어를 개발하는데 도움이 됩니다.


1. 단일 책임 원칙(SRP, Single Responsibility Principle)

로버트마틴의 SOLID 원칙 중에 가장 의미가 잘못 전달된 원칙으로 SRP를 뽑았는데 그 이유가 로버트마틴이 말한 SRP원칙에서는 "하나의 모듈은 하나의 책임을 가져야 한다" 이렇게 전달하고 있습니다. 그런데 이렇게 모호한 원칙으로 해석하면 안 됩니다.

대신 "모듈을 변경하는 이유는 한 가지여야 한다." 이렇게 받아들여야 합니다. 여기서 변경하는 이유가 한 가지여야 한다는 말의 의미는 해당

모듈이 여러 대상 또는 액터들에 대해 책임을 가져서는 안 되고, 오직 하나의 액터들에 대한 책임만 가져야 한다는 것을 의미합니다.

 

만약 모듈이 여러 액터들에 대한 책임을 가지고 있다고 가정을 해보겠습니다.

여러 액터들로부터 변경사항에 대한 요구가 들어온다면 해당 모듈을 변경하는 이유가 많아질 수밖에 없습니다. 그러나 모듈이 특정 액터에 대한 책임을 가지고 있다면 특정 액터에 대한 책임만 가지면 되니 하나의 모듈에 대한 변경사항 이유도 줄어들 수 있고, 모듈에 대한 책임도 줄 수 있습니다.

 

실제 코드로 한 번 설명해 드리겠습니다.

다음과 같이 email과 pw를 입력하고 pw를 암호화하고 User를 생성하고 데이터베이스에 저장하는 로직이 있다고 가정하겠습니다.

@Service
@RequiredArgsConstructor
public class UserService {

	private final UserRepository userRepository;

	public void addUser(final String email, final String pw) {
		final StringBuilder sb = new StringBuilder();

		for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
			sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
		}

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

		userRepository.save(user);
	}
}

요구사항

  • 비밀번호 암호화 방식을 조금 변경할 필요가 있어 보입니다.
  • User를 생성할 때 Role에 대한 정의도 필요해 보입니다.

위와 같은 새로운 요구사항이 발생했을 때 위와 같은 클래스를 변경하는 이유는 두 가지가 됩니다.

첫 번째로 비밀번호 암호화 방식을 바꾸는 이유, 두 번째로 User를 생성할 때 Role을 추가하는 이유 이렇게 두 가지가 됩니다.

이렇게 하나의 클래스를 변경하는 이유가 하나가 아닌 이유는 UserService가 다양한 책임을 가지고 있기 때문입니다.

이렇게 설계를 하지 않으려면 UserService에서 비밀번호 암호화에 대한 책임을 분리시켜야 합니다.

아래와 같이 비밀번호를 암호화시키는 클래스를 따로 만들고 UserService로부터 추상화하고, 해당 클래스를 사용할 수 있도록 구성하면 

더 이상 비밀번호 암호화 수정에 대한 로직 수정을 UserService에서 할 필요가 없습니다. 

이런 식으로 UserService에서 비밀번호 암호화에 대한 책임을 분리시켰습니다.

@Component
public class SimplePasswordEncoder {

	public void encryptPassword(final String pw) {
		final StringBuilder sb = new StringBuilder();

		for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
			sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
		}

		return sb.toString();
	}
}

@Service
@RequiredArgsConstructor
public class UserService {

	private final UserRepository userRepository;
	private final SimplePasswordEncoder passwordEncoder;

	public void addUser(final String email, final String pw) {
		final String encryptedPassword = passwordEncoder.encryptPassword(pw);

		final User user = User.builder()
				.email(email)
				.pw(encryptedPassword).build();

		userRepository.save(user);
	}
}

제가 처음에 생각한 단일책임원칙은 하나의 클래스에 하나의 기능만 있으면 된다고 생각했습니다. 예를 들어 로그인 클래스에는 로그인

기능만 있으면 그건 단일책임원칙을 준수하는 코드라고 이렇게 생각했는데 지금 생각해 보면 아주 잘못된 생각인 거 같습니다.

하나의 클래스에 하나의 기능만 있어도 여러 개의 책임을 가지고 있으면 그건 단일책임원칙을 준수한 코드라고 말할 수 없습니다.

 

단일책임원칙을 지키게 된다면 코드를 변경할 때 수정해야 할 대상이 명확해집니다. 위와 같이 비밀번호 암호화 방식에 대해 변경이 필요하면 SimplePasswordEncoder클래스만 수정하면 되기 때문 때 대상파악에도 명확해집니다. 단일책임원칙의 장점은 시스템이 커질 수 록 그 장점을 극대화로 느낄 수 있습니다. 지금 저 코드는 간단한 UserService이지만 시스템이 크다면 클래스마다 서로 많은 의존성을 가지며 더 복잡해지지만 저 원칙을 잘 지키게 된다면 변경요청이 왔을 때 빨리 수정대상을 파악하고 그 부분만 수정하면 되기 때문입니다.

 

단일책임원칙을 잘 적용하면 적절하게 책임을 분리시키고 서로 영향을 주지 않도록 잘 추상화시킴으로써 더 변화에 손쉽게 대응할 수 있는 소프트웨어를 설계할 수 있을 거 같습니다.

2. 개방 폐쇄 원칙(OCP, Open-Closed Principle)

개방 폐쇄 원칙은 확장에는 열려있고 수정에 대해서는 닫혀있어야 한다는 원칙으로, 각각이 갖는 의미는 다음과 같다.

 

  • 확장에는 열려있다 : 새로운 기능에 대한 요구사항이 들어왔을 때 새로운 동작을 추가하여서 애플리케이션을 확장시킬 수 있다.
  • 수정에는 닫혀있다 : 기존의 코드를 수정하지 않고 새로운 코드만 추가함으로써 애플리케이션의 동작을 추가하거나 수정할 수 있다.

다시 말하자면 요구사항의 변경이나 추가사항이 발생하더라도 기존의 구성요소는 수정이 일어나지 말아야 한다는 의미입니다.

 

OCP를 실제 코드에서 적용 시키는 방법으로는 새로운 기능이 추가되어도 기존의 클래스를 수정하는게 아니라 추상화된 인터페이스를 통해 추상화된 인터페이스를 상속받는 새로운 기능을 하는 클래스를 만드는 방법입니다. 이러한 방법으로 새로운 기능들을 확장시키는 것입니다.

예를 들어, 우리가 온라인 쇼핑몰 시스템을 개발하고 있다고 가정해보겠습니다. 이 시스템에서는 다양한 결제 방법을 지원해야 합니다. 처음에는 신용카드 결제만 지원했지만, 시간이 지남에 따라 삼성페이, 암호화 결제 등 다양한 결제 방법을 추가해야 할 필요성이 생겼습니다.
OCP를 적용시키지 않은 클래스라면 기존의 클래스를 수정하면서 기능 추가를 해야합니다.

하지만 OCP를 적용한 코드라면 기존의 클래스를 수정할 필요가 없습니다. 결제 방법을 추상화하여 인터페이스(예: PaymentMethod)를 정의하고, 각 결제 방법(신용카드, 암호화폐 등)은 이 인터페이스를 구현하는 방식으로 설계할 수 있습니다. 새로운 기능을 추가하려면 단지 PaymentMethod 인터페이스를 구현하는 새로운 클래스를 추가하기만 하면 되므로, 기존의 코드를 변경할 필요가 없습니다.

 

로버트마틴은 기존의 구성요소를 쉽게 확장해서 재사용하기 위해 OCP를 중요하게 생각했습니다.

OCP는 관리가능하고 재사용 가능한 코드를 만드는 기반이며, OCP를 가능하게 하는 중요 메커니즘은 추상화와 다형성이라고 설명하고 있습니다.

 

적용방법

  • 변경할 것과 변경하지 않을 것을 분명히 구분합니다.
  • 이 두 모듈이 만나는 지점을 인터페이스로 정의합니다.
  • 의존할 때 구현하는 구현체에 의존하기보다 정의된 인터페이스에 의존하도록 코드를 작성합니다.

주의점

OCP원칙을 잘 지키기 위해서는 객체 간의 추상화를 잘 지킬 필요가 있습니다.

보통 추사라는 개념에 대해 '구체적이지 않은' 정도의 개념으로 알 고 있습니다. 하지만 그래디부치에 의하면 "추상화란 다른 모든 객체로부터 식별될 수 있는 객체의 본질적인 특징"이라고 정의하고 있습니다. 

 

만약에 OCP를 구성하면서 상속구조를 이상하게 설계한다면 LSP나 ISP와 같은 원칙을 자연스럽게 위반하게 됩니다.

또한 OCP는 DIP의 설계 원칙이기도 합니다.

 

3. 리스코프치환원칙(LSP, Listov Substitution Priciple)

리스코프 치환 원칙은 바바라 리스코프가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것입니다.

부모객체는 언제나 자식객체로 교체할 수 있어야 한다는 게 리스코프 치환 원칙의 포인트입니다. 

리스코프 치환 원칙은 올바른 상속을 위해 자식 객체의 확장이 부모 객체의 방향을 온전히 따르도록 권고하는 원칙입니다.

 

교체한다는 말은, 자식객체에서는 부모객체에서 가능한 최소한의 행위가 수행이 된다는 것이 보장되어야 한다는 의미입니다.

 

즉, 부모객체의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때 코드가 원래 의도대로 작동해야 한다는 의미입니다. 이것을 부모 클래스와 자식 클래스 사이의 행위가 일관성이 있다고 말합니다. 이것은 저희가 자바를 처음 배울 때 많이 나온 다형성과 아주 유사한 내용입니다. 다형성을 잘 사용하기 위해서는 클래스끼리 상속을 시키고 타입을 통합할 수 있게 설정하고, 업캐스팅을 해도 메서드끼리 동작할 때 문제가 없어야 합니다. 

 

잘못된 메소드 오버로딩

아래 코드에서는 Animal이라는 부모클래스가 있고 Animal을 상속받는 Eagle클래스가 존재합니다.

아래 코드에서 상속받고 있는 자식클래스 Eagle에서 부모클래스인 Animal의 go메서드를 반환타입, 매개변수등을 자기 맘대로 변경하였습니다. 메서드 오버로딩을 부모 클래스 Animal에서 하는 것이 아닌 자식클래스 Eagle에서 동작시켰기 때문에 LSP 위반 원칙인 것입니다.

class Animal {
    int speed = 100;

    int go(int distance) {
        return speed * distance;
    }
}

class Eagle extends Animal {
    String go(int distance, boolean flying) {
        if (flying)
            return distance + "만큼 날아서 갔습니다.";
        else
            return distance + "만큼 걸어서 갔습니다.";
    }
}

public class Main {
    public static void main(String[] args) {
        Animal eagle = new Eagle();
        eagle.go(10, true);
    }
}

리스코프 치환 원칙에서는 부모 객체를 자식 객체가 완전히 대체해도 아무런 문제가 없도록 권고합니다. 위에 예시 상황처럼 올바르지 못한 상속관계는 제거하고, 부모 객체를 완전히 대체할 수 있는 자식객체만 상속할 수 있도록 코드를 설계해야 합니다.

 

리스코프 치환원칙에서 또 중요한 게 가급적 자식객체에서 메서드 오버라이딩을 할 때 부모객체에서 구현된 메서드와 의도가 다르게 오버라이딩 하지 않는 것이 중요합니다.

 

4. 인터페이스 분리 원칙(ISP, Interface Segregation Principle)

인터페이스 분리 원칙이랑 객체는 자신이 호출하지 않는 메소드에 의존하지 않아야한다는 원칙이다.

 

구현할 객체에게 무의미한 메소드 구현을 방지하기 위해 반드시 필요한 메소드만을 상속/구현하도록 권고합니다. 만약 상속할 객체의 규모가 너무 크다면, 해당 객체의 메소드를 작은 인터페이스로 나누는 것이 좋다.

 

예시

 

아래 SmartPhone 인터페이스에서는 스마트폰이라면 가지고 있을 만한 기능들을 포함하고 있다.

interface ISmartPhone {
    void call(String number); // 통화 기능
    void message(String number, String text); // 문제 메세지 전송 기능
    void wirelessCharge(); // 무선 충전 기능
    void AR(); // 증강 현실(AR) 기능
    void biometrics(); // 생체 인식 기능
}

 

만약 최신 휴대폰 클래스를 구현한다면 최신 스마트폰은 모든 기능들을 가지고 있기 때문에 ISP원칙을 잘 만족할 수 있다.

class S20 implements ISmartPhone {
    public void call(String number) {
    }

    public void message(String number, String text) {
    }

    public void wirelessCharge() {
    }

    public void AR() {
    }

    public void biometrics() {
    }
}

class S21 implements ISmartPhone {
    public void call(String number) {
    }

    public void message(String number, String text) {
    }

    public void wirelessCharge() {
    }

    public void AR() {
    }

    public void biometrics() {
    }
}

 

근데 스마트폰 인터페이스는 최신 휴대폰 뿐만 아니라 예전 기종의 스마트폰 즉 다양한 기능을 포함하지 않는 스마트폰도 구현해야 한다.

아래와 같이 S3모델을 구현하면 지원하지 않는 기능이 3개나 있기 때문에 ISP원칙을 만족할 수 없는 것이다.

결국 필요하지도 않은 기능들을 어쩔 수 없이 구현해야 하는 낭비가 발생할 것입니다.

class S3 implements ISmartPhone {
    public void call(String number) {
    }

    public void message(String number, String text) {
    }

    public void wirelessCharge() {
        System.out.println("지원 하지 않는 기능 입니다.");
    }

    public void AR() {
        System.out.println("지원 하지 않는 기능 입니다.");
    }

    public void biometrics() {
        System.out.println("지원 하지 않는 기능 입니다.");
    }
}

 

따라서 각 구현체에 맞는 인터페이스를 잘 설계해야 합니다.

인터페이스를 잘 분리하고 구현체가 필요한 기능만을 선별하여 인터페이스를 구현하면 ISP원칙을 잘 만족시킬 수 있습니다.

 

interface IPhone {
    void call(String number); // 통화 기능
    void message(String number, String text); // 문제 메세지 전송 기능
}

interface WirelessChargable {
    void wirelessCharge(); // 무선 충전 기능
}

interface ARable {
    void AR(); // 증강 현실(AR) 기능
}

interface Biometricsable {
    void biometrics(); // 생체 인식 기능
}

 

이런 식으로 인터페이스를 잘 분리해서 설계하면 더 좋은 설계가 될 수 있다.

이러면 WirelessChargable, ARable, Biometricsable 기능이 필요한 S21(최신 기기) 모델에서는 저 인터페이스에 있는 기능들을 구현하면 되고 저 3개의 기능을 지원하지 않는 S3(예전 기기)모델에서는 인터페이스를 구현할 필요가 없습니다.

 

class S21 implements IPhone, WirelessChargable, ARable, Biometricsable {
    public void call(String number) {
    }

    public void message(String number, String text) {
    }

    public void wirelessCharge() {
    }

    public void AR() {
    }

    public void biometrics() {
    }
}

class S3 implements IPhone {
    public void call(String number) {
    }

    public void message(String number, String text) {
    }
}

 

SRP와 ISP 원칙 사이의 관계

SRP가 클래스의 단일 책임 원칙이라면, ISP는 인터페이스의 단일 책임 원칙이라고 생각하면 됩니다.

5. 의존성 역전 원칙(DIP, Dependency Injection Principle)

고차원 모듈은 저 차원 모듈에 의존하면 안 된다. 이 두 모듈은 구체화 말고 추상화에 의존해야 한다.

로버트 C. 마틴은 DIP에 대해 이렇게 말했습니다. 

 

  • 고차원 모듈(High Level Module) : Interface/abstraction 같이 저 차원 모듈을 조종하는 모듈
  • 저차원 모듈(Low Level Module) : 고차원 모듈이 일을 할 수 있게 도와주는 작은 모듈들

예를 들어 고차원 모듈은 인터페이스이고 저차원 모듈은 그 인터페이스들을 구현하는 구현체 같은 거라고 이해하시면 될 거 같습니다.

 

DIP는 Dependency Injection Principle의 약어로, 의존성 역전 원칙을 의미한다.

DIP의 핵심은 구체화에 의존하지 말고 추상화에 의존해라 이다.

고수준 모듈은 저수준 모듈의 어떤 것도 import 해선 안 된다. 둘 다 추상화에 의존해야 한다.

추상화는 구체적인 것에 의존하면 안된다. 구체적인 것(구현체)은 추상화에 의존해야 한다.

 

 

 

728x90