본문 바로가기

FE

현대의 SOLID 법칙 (번역)

stackoverflowblog 의 "Why SOLID principles are still the foundation for modern software architecture" 를 번역한 글입니다

이 글은 멀티클라우드 관리 플랫폼을 구축하는 SpaceONE 팀의 프론트엔드 파트 스터디 주제로 사용되었습니다.

 

SOLID 원칙이란, 오랜시간을 통해 검증된 품질있는 소프트웨어 제작 원칙입니다. 하지만, [멀티 패러다임 프로그래밍]과 [클라우드 컴퓨팅] 의 세계에서도 통할까요? SOLID가 그 자체 뿐만이 아니라 비유적으로 보았을 때 무엇을 대표하는지 알아보고, 왜 여전히 의미가 있으며, [모던 컴퓨팅]에서 어떻게 적용되는지 예시를 들어가며 설명해드리겠습니다.

SOLID 란?

SOLID 원칙은 Robert C.에 의하여 2000년대 초에 창안되었습니다. 이것은 높은 품질의 [OOP] 을 생각하는 방법을 위해 발의되었습니다. 전체적으로, SOLID 원칙은 코드를 어떻게 쪼갤지, 어떤 부분들을 선택적으로 노출시켜야 하며, 기존 코드가 타 코드에 쓰일지에 대한 생각을 하게 하였습니다. 각 글자의 원래의 의미를 설명한 다음, [OOP] 외에도 적용될 수 있는 확장된 의미를 추가로 알아보겠습니다.

바뀐 것들

2000년대 초에는, [Java]와 [C++] 이 왕이었습니다. 당시 제가 대학 수업을 들을 때만 해도, 모두 [Java] 를 선택하였고, 대부분의 과제와 강의에서 쓰였죠. 자바의 인기는 책, 회의, 그리고 강의 등등을 생산해 내는 영세산업을 아예 탄생시키게 되었으며, 이 변화는 사람들이 단지 코드를 쓸 줄 알 뿐만이 아니라 좋은 코드를 쓸 수 있게 도와주었습니다.

그 이후로, 소프트웨어 산업 내의 변화들은 엄청났습니다. 몇 가지 주목할 만한 것들은:

  • [동적 타이핑 언어], 예를 들면 [Python], [Ruby], 그리고 특히 [Javascript] 의 인기는 몇몇 산업과 기업에서 [Java] 를 추월할 정도로 인기가 많습니다.
  • [비 객체지향 패러다임], 특히 [함수형 프로그래밍] 은 위에 언급한 것과 같은 새 언어들에서 흔히 볼 수 있습니다. 심지어 [Java] 도 스스로 [lamdas] 를 소개할 정도입니다! [메타프로그래밍] 같은 기술들 또한 인기를 얻었습니다. 뿐만 아니라, Go와 같이 객체지향의 향은 은은히 내면서, 정적 타입은 있으면서도 상속은 포함하지 않는 언어들도 있습니다. 이들은 [class]들과 [상속]은 현대 소프트웨어에서 과거보다는 덜 중요하다는 것을 의미합니다.
  • [오픈소스 소프트웨어] 가 급증하였습니다. 과거에는 고객들에게 [닫힌소스 소프트웨어] 들이 제공된 반면, 요즘은 [오픈소스 소프트웨어] 를 [의존성] 으로 흔하게 볼 수 있죠. 이로인해 라이브러리를 쓸 때 의무적이던 [로직] 과 [데이터 숨기기] 들은 그리 중요하지 않게 되었습니다.
  • [Microservice 와 SaaS] 는 폭발적인 관심을 얻고 있습니다. 하나의 큰 프로그램을 배포하는 것보다 작은 서비스로 배포하거나 [third party] 로 제공하는 편이 선호됩니다.

정리해보면, [Class], [Interface], [데이터 숨기기], [다형성] 과 같이 SOLID 가 정말로 관심을 가졌던 것들은 더 이상 프로그래머들이 매일 다루는 것이 아닙니다.

바뀌지 않은 것들

[소프트웨어 산업] 은 많은 방면에서 다르지만, 바뀌지 않은 것과 바뀌지 않을 것 같은 것들이 존재합니다:

  • 코드는 사람에 의하여 작성되고 수정된다. 코드는 한번 쓰이면 많이, 정말 많이 읽힙니다. 항상 문서화 잘 된 코드와 API 들이 요구됩니다. (내부적이든 외부적이든)
  • 코드는 모듈로 구성된다. 몇 언어에서는 [Class] 가 모듈입니다. 이외에는 개별 [소스 파일] 이 될 수 있구요. [Javascript] 에서는 [object] 를 export 할 수도 있습니다. 그럼에도 불구하고, 코드를 구분하고, 유닛을 경계하여 구성하는 방법이 존재합니다. 그러므로, 코드를 어떻게 그룹화할지에 대한 결정이 필요합니다.
  • 코드는 내부적으로나 외부적으로 쓰일 수 있습니다. 어떤 코드는 당신이 쓰거나 당신의 팀이 쓰기 위해 작성됩니다. 어떤 코드는 다른 팀이 쓰거나 외부 고객이 (API 를 통해) 사용되기도 합니다. 이는 어떤 코드를 “보여주고" 어떤 코드를 “숨길지" 에 대해 결정이 필요하다.

“현대의" SOLID

위에서 말했듯, 다섯가지 SOLID 원칙을 재정의하여, 더욱 일반적으로 만들고 [OO], [FP], [multi-paradigm programming] 에 적용할 수 있도록 하며, 몇가지 예시를 제공할 것입니다. 많은 경우에서 이 원칙들은 전체 서비스, 시스템 등에 적용이 가능합니다!

주의점: [module] 이라는 단어를 코드의 문단을 그룹화하는 것으로 쓸 것입니다. [class] 가 될 수도, [module] 이 될 수도, file 등등이 될 수도 있습니다.

Single responsibility principle; 단일 책임 원칙

기존 정의: “클래스를 변경하는 이유는 단 한 가지여야 한다 (클래스는 한 개의 책임을 가져야 한다.)”

만약 [class] 에 다양한 역할을 부여하게 되면, 어떤 역할 중 하나라도 변경될 때마다 코드를 수정해야 합니다. 이는 단 한개의 기능변경이 다른 기능에 영향을 끼칠 가능성이 올라갑니다.

예를 들면, 절대 만들어져서는 안되는 Frankenclass 가 있습니다:

class Frankenclass {
   public void saveUserDetails(User user) {
       //...
   }
 
   public void performOrder(Order order) {
       //...
   }
 
   public void shipItem(Item item, String address) {
       // ...
   }
}

새로운 정의: 각각의 [module] 은 단 한 가지 일만 해야하며, 그 일을 잘 해야 한다.

이 원칙은 [높은 응집] 과 깊은 연관이 있습니다. 특히, 당신의 코드에서 여러가지 역할 혹은 목적을 함부로 섞으면 안됩니다.

아래는 [Javascript] 를 사용한 FP 버전의 예시입니다:

const saveUserDetails = (user) => { ... }
const performOrder = (order) => { ...}
const shipItem = (item, address) => { ... }
 
export { saveUserDetails, performOrder, shipItem };
 
// calling code
import { saveUserDetails, performOrder, shipItem } from "allActions";

이는 [microservice] 디자인에도 적용이 가능합니다. 만약 위의 세 함수 역할을 하는 하나의 서비스가 있다면, 이는 너무 과한 행동을 하는 것입니다.

Open-closed principle; 개방-폐쇄 원칙

기존 정의: 소프트웨어 개체는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야한다

이것은 자바 같은 언어의 디자인 중 하나입니다. - 클래스를 만들 수 있고, 확장시킬 수 있습니다. ([subclass] 를 만듦으로써). 하지만, 기존의 코드를 수정할 수는 없습니다.

“확장에 개방" 의 이유는, [class] 작성자의 의존도를 제한하기 위해서입니다. -만약 당신이 [class] 의 변경이 필요하면, 당신은 원본 작성자에게 수정을 계속 요청하거나, 당신이 직접 deep-dive 하여 수정을 하겠죠. 더군다나, 수정한 [class] 가 다양한 방면에서 문제가 생기기 시작할 수 있고, 이는 [단일 책임 원칙] 을 저해합니다.

“수정에 폐쇄" 의 이유는, 다운스트림 소비자를 전부 신뢰할 수 없고, 코드를 미숙련자의 손으로부터 보호되어야 하기 때문입니다. (The reason for closing classes for modification is that we may not trust any and all downstream consumers to understand all the “private” code we use to get our feature working, and we want to protect it from unskilled hands.)

class Notifier {
   public void notify(String message) {
       // send an e-mail
   }
}
 
class LoggingNotifier extends Notifier {
   public void notify(String message) {
       super.notify(message); // keep parent behavior
       // also log the message
   }
}

새로운 정의: 재작성하는 것 대신, [module] 을 사용하거나 추가할 수 있어야 한다.

이는 [OOP] 세계에서는 쉽게 다가옵니다. 반면 [FP] 세계에서는 수정을 허용하기 위해서는 외부의 [Hook points] 를 선언해주어야만 합니다. 다음은 당신의 함수에 추가적인 함수를 전달하여 before, after [hook] 뿐 아니라 기본 동작까지 override 할 수 있는 예시입니다.

// library code
 
const saveRecord = (record, save, beforeSave, afterSave) => {
  const defaultSave = (record) => {
   // default save functionality
  }
 
  if (beforeSave) beforeSave(record);
  if (save) {
    save(record);
  }
  else {
    defaultSave(record);
  }
  if (afterSave) afterSave(record);
}
 
// calling code
 
const customSave = (record) => { ... }
saveRecord(myRecord, customSave);

Liskov substitution principle; 리스코프 치환 원칙

기존 정의: 부모 객체와 이를 상속한 자식 객체가 있을 때, 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있다

(자료형 S가 자료형 T의 하위형이라면 필요한 프로그램의 속성의 변경 없이 자료형 T의 객체를 자료형 S의 객체로 교체할 수 있어야 한다는 원칙)

이는 [OOP] 의 기본 속성입니다. 이는 [subclass] 를 [부모 class] 대신에 사용이 가능해야한다는 의미입니다. 이렇게하면 [contract] 에 신뢰감을 높일 수 있습니다.-당신은 T타입을 지닌 모든 객체가 T처럼 행동할 것이라는 것에 안전히 의존할 수 있습니다. 아래는 예시입니다.

class Vehicle {
   public int getNumberOfWheels() {
       return 4;
   }
}
 
class Bicycle extends Vehicle {
   public int getNumberOfWheels() {
       return 2;
   }
}
 
// calling code
public static int COST_PER_TIRE = 50;
public int tireCost(Vehicle vehicle) {
    return COST_PER_TIRE * vehicle.getNumberOfWheels();
}   
  
Bicycle bicycle = new Bicycle();
System.out.println(tireCost(bicycle)); // 100

새로운 정의: 만약 어떤 것들이 같은 방식으로 행동한다고 선언되면, 한 가지가 나머지를 대체할 수 있어야 한다.

동적 언어에서, 이 원칙에서 가져갈 수 있는 중요한 점은 당신의 프로그램이 어떤 동작을 하기 위한 “약속"을 했다면(가령 [interface] 혹은 [function] 에 대한 [implement]) , 당신은 “약속" 을 계속 지켜나가야한다는 것입니다.

많은 동적언어는 이를 완수하기 위해 [duck typing]을 사용합니다. 결국 함수는 공식적으로나 비공식적으로 자신의 입력이 일정한 방식대로 행동한다고 가정하고 그대로 진행한다는 것을 의미합니다.

여기 [Ruby] 를 사용한 예시가 있습니다.

# @param input [#to_s]
def split_lines(input)
 input.to_s.split("\\n")
end

이 경우, 이 함수는 input 의 [type]에 대해 신경쓰지 않습니다. 그저 자신이 to_s 라는 함수를 포함하고 있으며, 그 함수가 다른 모든 to_s 함수처럼 행동할 것이라고 인지하고 있는 상태입니다. 즉, input 을 string 으로 바꿔줄거라고 추론합니다 이 행동들은 많은 동적 언어의 경우에 강제로 시행될 일이 없기에, 형식적인 기술이라기보다는 암묵적인 규율에 가깝게 됩니다.

여기 [FP] 인 [Typescript] 를 사용한 예시가 있습니다. 이 경우, [고차함수](high-order function) 가 filter 함수를 가지며, 이는 하나의 enumeric 입력과 boolean 값을 return 할 것을 예상합니다.

const isEven = (x: number) : boolean => x % 2 == 0;
const isOdd = (x: number) : boolean => x % 2 == 1;
 
const printFiltered = (arr: number[], filterFunc: (int) => boolean) => {
 arr.forEach((item) => {
   if (filterFunc(item)) {
     console.log(item);
   }
 })
}
 
const array = [1, 2, 3, 4, 5, 6];
printFiltered(array, isEven);
printFiltered(array, isOdd);

Interface segregation principle; 인터페이스 분리 원칙

기존 정의: 객체는 자신이 호출하지 않는 메소드에 의존하지 않아야 한다.

[OOP] 에서는 당신의 [class] 에 [view] 를 제공하는 것을 떠올릴 수 있습니다. 모든 구현체를 주는 것보다, 상위에 관련된 메소드를 포함한 [interface] 를 만들고, 클라이언트들에게 이 메소드를 쓸건지 물어보는 것이 선호됩니다.

단일 책임 원칙과 마찬가지로, 이는 시스템간의 연결을 줄이고, 클라이언트가 관련 없는 기능에 대해 알거나 의존해야할 필요가 없게 됩니다.

여기 단일책임원칙을 충족하는 예시가 있습니다:

class PrintRequest {
   public void createRequest() {}
   public void deleteRequest() {}
   public void workOnRequest() {}
}

이 코드의 “바꿔야 할 이유" 는 일반적으로 한 가지입니다. -모두 동일한 도메인에 속하는 printRequest 와 관련이 있으며, 세 가지 방법 모두 상태를 변경할 수 있습니다. 하지만 요청을 생성하는 클라이언트와 처리하는 클라이언트가 다를 수 있습니다. 아래처럼 [interface] 를 분리하는 것이 더 적합합니다.

interface PrintRequestModifier {
   public void createRequest();
   public void deleteRequest();
}
 
interface PrintRequestWorker {
   public void workOnRequest()
}
 
class PrintRequest implements PrintRequestModifier, PrintRequestWorker {
   public void createRequest() {}
   public void deleteRequest() {}
   public void workOnRequest() {}
}

새로운 정의: 클라이언트들에게 필요한 것 외에는 보여주지 마라.

클라이언트들이 알아야 하는 정보만 공개하세요. 이는 문서화 생성기에 “public” 함수 혹은 “routes” 만 output으로 생성하고, “private” 는 생성하지 않는 것을 의미합니다.

[microservice] 세계에서는, 투명성을 강화하기 위해 문서 혹은 [true separation] 을 사용할 수 있습니다. 예를 들어, 당신의 외부 고객은 user로 로그인했지만, 내부 서비스에서는 user 의 리스트를 가져오거나 추가적인 속성이 필요할 수 있습니다. 당신은 메인 서비스를 호출하는 “external only” 유저 서비스를 분리해서 생성하거나, 유저들에게 특정 문서를 제공하여 내부 정보를 숨길 수 있습니다.

Dependency inversion principle; 의존성 역전 원칙

기존 정의: 구체가 아닌 추상에 의존하라 (객체는 저수준 모듈보다 고수준 모듈에 의존해야한다.)

[OOP] 에서는, 최대한 많이, 구체적인 [class] 보다는 [interface] 에 의존해야한다는 의미입니다. 이는 코드가 최소한의 표면에 의존한다는 것을 보증합니다. - 사실은, 이는 전혀 코드에 의존하지 않고, 단지 코드가 어떻게 행동하야할 지에 대한 정의를 계약하는 것 뿐입니다. 다른 원칙들과 함께, 이것은 한 개의 파손이 다른 곳까지 파손시킬 수 있는 위험도를 줄여줍니다. 여기 간단한 예시가 있습니다.

interface Logger {
   public void write(String message);
}
 
class FileLogger implements Logger {
   public void write(String message) {
       // write to file
   }
}
 
class StandardOutLogger implements Logger {
   public void write(String message) {
       // write to standard out
   }
}
 
public void doStuff(Logger logger) {
   // do stuff
   logger.write("some message")
}

만약 logger 가 쓰이는 코드를 작성하고 있다면, 파일을 작성하는 것에 대해 스스로 제한하고 싶지 않을 것입니다. 당신이 신경 쓸 영역이 아니기 때문입니다. 당신은 그저 write 메소드를 호출하고, 구체적인 class 를 작성합니다.

기존 정의: 구체가 아닌 추상에 의존하라 (객체는 저수준 모듈보다 고수준 모듈에 의존해야한다.)

옙, 이 경우, 저는 정의를 있는 그대로 두었습니다! 가능할 때마다 추상화한다는 개념은 여전히 중요한 것입니다. 아무리 요즘은 추상화하는 메커니즘이 엄격한 객체지향의 세계보다 덜 강할지라도요.

실질적으로, 이는 대부분 [리스코프 치환 원칙] 에서 논의된 것들과 동일합니다. 주된 차이점은, 기본적인 구현이 없다는 것입니다. 이로 인해, [duck typing], [hook functions] 와 연관된 문제는 동일하게 적용됩니다.

또한 추상화를 [microservice] 세계에 적용할 수 있습니다. 예를 들어, 당신은 서비스 간의 직접 통신을 [Kafka], [RabbitMQ] 같은 메시지 버스 또는 큐 플랫폼으로 대체가 가능합니다. 이렇게 하면 특정 서비스가 메시지를 선택하여 작업할 필요 없이 하나의 위치로 메시지를 보낼 수 있습니다.

결론

“SOLID” 를 한 번 더 재정의 하자면:

  • 코드를 읽는 사람을 놀라게 하지 마십시오.
  • 코드를 쓰는 사람을 놀라게 하지 마십시오.
  • 코드를 읽는 사람을 화나게 하지 마십시오.
  • 당신의 코드에 일정한 경계를 두십시오.
  • 붙어있어야 할 코드를 붙이고, 떨어뜨려야 할 코드를 떨어뜨리십시오.

좋은 코드는 여전히 좋은 코드고, 그건 바뀌지 않을 겁니다. SOLID는 그것을 연습할 수 있는, 뭐, 견고한 (영어 = solid)한 기반이죠!

'FE' 카테고리의 다른 글

vite 환경변수 안 될 때  (0) 2022.12.29
GTM (Google Tag Manager) does not working  (0) 2022.06.02
의문의 신인 "the vuex master" 개발자 등장  (1) 2022.03.22
뿌-듯  (0) 2021.12.27
개발팀 동료평가/셀프피드백 문항  (3) 2021.08.05