SOLID 간단히 살펴보기

Namu CHO
16 min readApr 30, 2023

--

SOLID란?

아주 간략히 말하자면,

한 번에 하나의 역할만 수행하도록 코드를 잘게 나누고

너무 구체적인 구현에 의존하지 않도록 적절한 추상화레벨을 유지하는 디자인 원칙이다.

아래에서 SOLID 원칙들을 하나하나 간단한 예시와 함께 살펴보자.

1. ‘S’ingle Responsibility Principle

Every software component should have one and only responsibility.

모든 소프트웨어 컴포넌트는 오직 단일한 책임을 가져야 한다.

여기서 컴포넌트란 클래스, 메서드, 함수, 모듈 등을 의미한다.

예를 들어 스마트폰은 현실세계에서 매우 사용하기 편리하지만 소프트웨어관점에서는 매우 잘못 구현된 구현이다. 왜냐하면 스마트폰 하나가 계산기, 전화기, 사진기 등 여러 역할을 하기 때문이다.

아래의 두 키워드를 통해 SRP를 잘 지키고 있는지 판단할 수 있다.

  1. Cohesion

Cohesion is the degree to which the various parts of a software component are related

응집력, 결합이란 사전적 의미를 지니지만 ‘통일성'이라고 이해하는 것이 더 좋다.

클래스 안에 있는 메서드들의 통일성이 떨어진다면, 그 클래스는 SRP를 지키지 못한 코드라고 할 수 있다.

위 코드에서는 스마트폰 클래스 안에서 전화와 관련한 메서드들과 카메라와 관련한 메서드들이 섞여있어 통일성이 저해되는 것을 볼 수 있다.

이 문제는 상단의 코드처럼 통일성 있는 메서들끼리 존재할 수 있도록 클래스를 나누어 구현함으로써 해결할 수 있다.

2. Coupling

Coupling is defined as the level of inter dependency between various software components

커플링은 두 컴포넌트간의 응집도를 나타낸다.

기존 맥 충전 어뎁터와 맥북의 관계가 바로 강하게 커플링된 관계라고 볼 수 있다. 맥북은 다른 충전기로 충전할 수 없고, 해당 어뎁터는 다른 기기를 충전할 수 없기 때문이다.

C타입 충전기로 핸드폰도, 맥북도 충전할 수 있는 지금 다시 해당 어뎁터를 보면 매우 불편한 시절을 보냈음을 알 수 있다. 이렇듯 낮은 결합도를 지닌 코드를 짜는 것이 재사용성도 높고 좋다.

아래 간단한 커스텀 인풋 컴포넌트 코드를 보자.

해당 코드에서는 인풋에 에러가 있을 시 에러메시지를 span 태그를 이용하여 보여주고 있다. 에러메시지 역할을 하는 컴포넌트가 인풋 내부에 구현되어있는 것이다.

이렇게 하면 에러메시지 역할을 하는 컴포넌트만 따로 사용할 수 없을 뿐더러 에러 메시지 관련한 부분을 수정하기 위해서는 무조건 인풋 컴포넌트 파일을 수정해야한다.

이 경우에는, 에러메시지를 담당하는 컴포넌트를 별도로 선언하여 사용하는 것이 loose coupling을 위한 문제해결방안이다.

SRP의 경우 SOLID 원칙에서 가장 중요한 원칙이다.

2. ‘O’pen Closed Principle

Software components should be closed for modification and open for extension

새로운 기능의 추가를 위해서 기존의 코드를 확장을 하는 것은 괜찮지만

기존의 코드를 바꿔버리는 것은 안 된다는 뜻이다.

예를 들어 코리안숏헤어 고양이 목욕 등을 담당하는 고양이 전문 살롱을 운영한다고 가정해보자.

// 코리안숏헤어 고양이 손님
class KoreanShortHair {
isClean = false;
// ...
}

// 살롱; 고객이 코리안숏헤어밖에 없다.
class CatSalon {
wash(cat: InstanceType<typeof KoreanShortHair>) {
cat.isClean = true;
return cat;
}
}
스핑크스 고양이, 이미지 출처: https://ipuppy.co.kr/53/?q=YToyOntzOjEyOiJrZXl3b3JkX3R5cGUiO3M6MzoiYWxsIjtzOjQ6InBhZ2UiO2k6Nzt9&bmode=view&idx=11240642&t=board

영업이 확장되면서 스핑크스 고양이 고객이 추가되었다고 가정해보자.

이 경우에 위의 코드를 계속 사용하기 위해서 아래와 같은 코드작업을 해야한다.

// 스핑크스 고양이 고객 클래스 추가
class SphynxCat {
// ...
maxOil = 100;
}


// 살롱 코드 수정
class CatSalon {
// 타입 추가
wash(cat: InstanceType<typeof KoreanShortHair | typeof SphynxCat>) {
cat.isClean = true;
return cat;
}
// 혹은 새로운 메서드 추가
washSphynx(cat: InstanceType<typeof SphynxCat>) {
cat.isClean = true;
return cat;
}
}

위 코드에서 볼 수 있듯이 타입 부분을 계속 추가하는 수정 작업을 거치거나,

새로운타입의 곡개고객마다 중복되는 코드를 가진 새로운 메서드를 매번 반복적으로 추가해줘야 하는 문제가 있다.

두 가지 방법 모두 isClean 대신 isclean으로 코드를 작성하여 에러가 발생하는 경우 등 에러가 발생할 확률이 추가적으로 높아지는 위험이 발생한다.

따라서 위의 고양이 살롱 코드는 아래와 같이 작성되는 것이 좋다.

// 앞으로 모든 고양이 고객들은 위의 기본 cat 클래스를 상속하여 만든다.
class Cat {
isClean = false;
}

class SphynxCat extends Cat {
// ...
maxOil = 100;
}

// isClean라는 기본 CatType을 상속하여 고양이 고객을 만들었으므로
// 앞으로 고양이 고객이 추가되어도 살롱의 wash 코드는 변경할 필요가 없다.
class CatSalon {
wash(cat: InstanceType<typeof Cat>) {
cat.isClean = true;
return cat;
}
}

OCP는 절대 기존의 코드를 수정하면 안 된다는 의미의 원칙이 아니다.

필요한 경우 당연히 기존에 사용하던 코드의 수정이 필요로 하다.

다만 그럴 경우에 기존의 동작이 문제가 없는지를 테스트 하는 등의 부가적인 비용이 매우 높아지기 때문에 비용과 효용을 잘 계산하여야 할 것이다.

따라서 OCP는 기존 코드의 수정을 최대한 하지 않을 수 있도록 미리 잘 설계를 해야한다는 중요성을 말하는 것이다.

3. ‘L’iskov Substitution Principle

Objects should be replaceable with their subtypes without affecting the correctness of the program

상위개념을 상속한 하위 개념으로 상위개념을 문제없이 대채할 수 있어야 한다는 의미의 원칙이다.

리스코프치환 원칙은 ‘Is-A’사고에서 발생하는 문제점을 해결해준다.

Is-A사고 방식이란 한국어로는 ‘~는 ~(이)다.'로 해석될 수 있으며,

아래가 그 예시이다.

  • ‘레이싱카는 자동차다.’
  • ‘스핑크스 고양이는 고양이다.'
  • ‘타조는 새다.’

위의 세 예시 모두 문제가 없어보이지만, 실제 프로그램에서 해당 방식으로 코드를 작성했을 경우 문제가 생길 수 있다.

예컨대 위에서 작성한 스핑크스 고양이의의 경우, 만일 Cat 클래스에 makeHairball 메서드가 있다고 가정해보자.

이 경우 스핑크스 고양이는 털이없는 특징상 헤어볼을 토하지 않기 때문에 모든 Cat 클래스로 만든 인스턴스들을 스핑크스 고양이 클래스로 만든 인스턴스로 치환할 수 없다는 문제가 생기게 된다.

마찬가지로 새에게 fly라는 메서드가 있을 경우 타조는 날지 못하기 때문에 모든 새 인스턴스들을 문제없이 대채할 수 없다.

리스코프치환 원칙을 지키기 위해 사용할 수 있는 방법은 2가지가 있다.

  1. Breaking the hierarchy

위에서 언급한 makeHairball 메서드 문제를 통해 살펴보자.

class Cat {
isClean = false;

makeHairball() {
return "hairball rainbow";
}
}

class SphynxCat extends Cat {
// ...

// makeHairball 메서드를 호출했을 시
// 원하는 결과인 "hairball rainbow" 스트링을 얻을 수 없으므로
// Cat 클래스의 인스턴스들을 SphynxCat로 대체할 수 없다.
makeHairball() {
return "";
}
}

이 문제를 해결하기 위해서는 기존의 상속관계를 깨고 새로운 상속관계를 만들어야 한다.

이 경우에는 Cat을 상속하는 HairyCat과 NoneHairyCat 두 클래스를 만든 뒤 makeHairball 메서드를 HairyCat 클래스에 구현해둔 다음 스핑크스 고양이는 NoneHairyCat을 상속하고 코리안숏헤어 고양이는 HairyCat을 상속하는 방식으로 문제를 해결할 수 있다.

2. Tell, don’t ask

예를 들어 스핑크스 고양이의 경우 기름이 많이 나오기 때문에 다른 고양이들보다 10분 더 추가적으로 씻어야 한다고 가정해보자.

이 경우 아래와 같이 코드를 작성 할 수 있다.

class CatSalon {
washTime = 10;
wash(cat: InstanceType<typeof Cat>) {
// cat의 클래스를 체크하여 스핑크스고양이 클래스일 경우 10분 더 샤워한다.
if (cat instanceof SphynxCat) {
this.washTime += 10;
}

for (let index = 0; index < this.washTime; index++) {
console.log("뽀짝뽀짝 샤워시간");
}
cat.isClean = true;
return cat;
}
}

위 코드가 바로 ask에 해당한다.

‘너 스핑크스 고양이니? 그렇다면 샤워 10분 추가!’라는 식으로 동작하고 있기 때문이다.

이제 Tell, don’t ask를 적용하여 코드를 수정해보자.

// Cat 클래스에서 washTime 값을 가지고 있는다.
class Cat {
isClean: boolean;
private washTime: number;

constructor(parameters: { isClean: boolean; washTime: number }) {
this.isClean = parameters.isClean;
this.washTime = parameters.washTime;
}

makeHairball(): string {
return "hairball rainbow";
}

// washTime을 리턴하는 게터 함수를 만들어준다.
getWashTime() {
return this.washTime;
}
}

// 이제 살롱에서는 washTime에 대한 질문을 하지 않아도 된다.
// cat이 tell, 말해주기 때문이다.
class CatSalon {
wash(cat: InstanceType<typeof Cat>) {
const washTime = cat.getWashTime();
for (let index = 0; index < washTime; index++) {
console.log("뽀짝뽀짝 샤워시간");
}
cat.isClean = true;
return cat;
}
}

리스코프 치환 원칙을 지키기 위해서는 단순히 ‘Is-A’ 사고를 통한 클래스 설계를 하는 것에 그치는 것이 아니라 충실한 테스트를 통한 에러 검증을 해야한다.

4. ‘I’nterface Segregation Principle

No client should be forced to depend on methods is does not used.

이 원칙은 사용하지 않는 인터페이스에 의존하면 안 된다는 원칙이다.

이에 대한 예시로 LSP의 1. Breaking the Hierarchy에서 봤던 makeHairball 예시를 다시 보자.

코드를 작성하는 사람은 Cat에 makeHairball 메서드가 있기 때문에 스핑크스 고양이도 당연히 makeHairball를 구현해 두었을 것이라고 생각하고 호출할 것이고 이는 에러로 이어질 것이다.

이렇듯 에러를 방지하기 위해서는 구현하지 않은 인터페이스에 의존하지 않아야 한다.

이를 해결하는 방법은 위에서 언급한 것과 같이 털이 있는 고양이와 털이 없는 고양이 클래스로 나누어서 구현하고 스핑크스 고양이의 경우 털이 없는 고양이 클래스를 상속받는 방식으로 해결할 수 있다.

이 해결책에서 느꼈겠지만, 이는 SRP와도 연관이 되어있다. 이렇듯 SOLID의 모든 원칙은 상호 보완적이다.

ISP를 위반하고 있는지를 확인 할 수 있는 세 가지 방법을 살펴보자.

  1. Fat Interfaces

한 인터페이스 안에 지나치게 많은 내용이 들어가 있다면 이는 ISP를 위반하고 있다는 신호가 될 수 있다.

2. Interface with low cohesion

맨 처음 SRP에서 언급했던 스마트폰을 다시 생각해보자. 사진찍기, 녹음하기, 전화하기 등 연관성이 낮아 보이는 인터페이스들이 한꺼번에 정의 되어있다면 ISP를 위반했을 가능성이 높다.

3. Empty method implementations

위에서 봤던 makeHairball 케이스가 바로 여기에 해당된다. 연관성이 없는 interface를 implement했기 때문에 메서드를 구체적으로 정의할 수 없어 빈 메서드로 남겨둔 케이스이기 때문에 ISP를 위반한 경우에 해당한다.

5. ‘D’ependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Abstraction should not depend on details. Details should depend on abstractions.

고차원의 모듈은 저차원의 모듈에 의존해서는 안되며, 둘 다 추상화에 의존해야 한다는 원칙이다.

이것은 코드를 더 모듈화하고 유지보수하기 쉽게 만드는 데 도움이 된다.

예시로 Cat salon에서 현재 재고를 확인하는 기능을 추가해보자.


// 현재 재고를 리턴하는 클래스를 만든다.
class QueryInventories {
getAllInventories() {
return { shampoo: 1, bigBrush: 2, smallBrush: 3 };
}
}


class CatSalon {
// ...

getAllInventories() {
// 위에서 생성한 클래스를 통해 현재 재고를 리턴받는다.
const queryInventories = new QueryInventories();
const inverntories = queryInventories.getAllInventories();
return inverntories;
}
}

위의 코드는 QueryInventories라는 구체적인 클래스에 의존하여 현재 재고를 확인한다.

앞으로 QueryInventories 클래스에서 getAllInventories 메서드가 사라진다거나 하는 변경 사항이 생길 경우 우리는 CatSalon에서 직접 해당 사항에 대한 업데이트를 처리해줘야 할 것이다.

이를 방지하기 위해 구체적인 클래스가 아닌 인터페이스 상속을 이용한 방식으로 코드를 수정해보자.

// 원하는 메서드를 정의한 인터페이스를 만든다.
interface IInventoryWarehouse {
getAllInventories: () => Record<string, number>;
}

// 위에서 만든 인터페이스를 상속하도록 한다.
class QueryInventoryWarehouse implements IInventoryWarehouse {
getAllInventories() {
return { shampoo: 1, bigBrush: 2, smallBrush: 3 };
}
}

// CatSalon 클래스 내에서 구체적인 클래스 인스턴스화를 피하기 위해,
// IInventoryWarehouse 인터페이스를 사용하여 객체를 생성하는 팩토리 클래스를 만든다.
class CatSalonInventoryWarehouseFactory {
create(): IInventoryWarehouse {
return new QueryInventoryWarehouse();
}
}


class CatSalon {
// ...

getAllInventories() {
// CatSalonInventoryWarehouseFactory를 사용하여
// IInventoryWarehouse 객체를 생성합니다.
const inventoryWarehouse: IInventoryWarehouse = new CatSalonInventoryWarehouseFactory().create();
const inventories = inventoryWarehouse.getAllInventories();
return inventories;
}
}

CatSalonInventoryWarehouse 인터페이스 대신에 IInventoryWarehouse 인터페이스를 사용한다.

또한 구체적인 QueryInventories 클래스 대신에 팩토리 클래스인 CatSalonInventoryWarehouseFactory를 사용하여 인스턴스를 생성한다.

이렇게 하면 CatSalon 클래스에서는 IInventoryWarehouse 인터페이스에만 의존하게 되므로 유지보수와 확장성이 향상된다.

결론

SOLID 원칙은 객체지향언어에서 더 좋은 코드를 쓰기위한 디자인 원칙이다.

객체지향언어가 아니더라도, 코드 분리, 필요없는 인터페이스를 상속받지 않는 등 내용 자체가 universal하기 때문에 개발자라면 한 번쯤은 꼭 숙지해야하는 원칙이라고 생각한다.

개인적으로는 SOLID원칙을 지키기만 한다면 코드를 봤을 때 같이 일하기 꺼려지는 개발자가 되는 것은 피할 수 있을 것이다.

참고

이 영상에서 추천 받은 강의를 수강하였다.

SOLID Principles: Introducing Software Architecture & Design:

https://www.udemy.com/course/solid-design/

프론트엔드와 SOLID 원칙:

https://fe-developers.kakaoent.com/2023/230330-frontend-solid/

--

--

Namu CHO
Namu CHO

Written by Namu CHO

외노자 개발자 나무 🇸🇬

No responses yet