최근 테스트 코드 작성, 리팩토링, 깔끔한 코딩 등에 대해 얘기하고 생각해보면서 이런 작업들 이전에 기본적으로 당연히 깔고 가야할 근본적인 원칙이 어떤게 있을까 찾아보다가 소프트웨어 개발의 다섯 가지 설계 원칙인 SOLID 원칙에 대해 보았습니다. 이 원칙을 프론트 엔드 작업과 연관해서 정리하는 글을 작성하려합니다.
SOLID 원칙은 소프트웨어 개발의 다섯 가지 설계 원칙을 나타내는 약어입니다. 각 원칙은 소프트웨어를 더 견고하고 유지보수하기 쉽게 만들어줍니다. 이러한 원칙은 소프트웨어의 재사용성, 유연성, 확장성을 높이는 데 도움이 됩니다. SOLID는 들어보지 못했더라도 SRP-단일책임원칙( SOLID 중 S )에 대해선 리액트 공식문서에도 언급될만큼 다양한 곳에서 언급되는 원칙입니다.
이러한 원칙들은 소프트웨어 개발에서 코드의 유지보수성과 확장석을 높이기 위한 중요한 개념이고 개발자로 일해본 경험이 부족하면 책을 통해서만 이해하기는 어려울 수 있습니다. 따라서 각 원칙 별로 프론트엔드에서 사용되는 예시를 적용하며 설명 해보도록 하겠습니다.
클래스는, 오직 하나의 대해서만 책임져야 한다.
만약 클래스가 여러가지 작업을 책임져야 한다면, 이는 버그 발생 가능성을 높입니다. 당신이 많은 기능중 한가지를 변경할때, 당신이 모르는 사이에 다른 기능에 영향을 줄 수 있기 때문입니다.
SRP의 목적은 행동들을 분리하는 것이고, 이로 인해 당신이 어떤 기능을 수정하더라도, 연관없는 기능에는 영향이 가지 않게 될 것입니다.
하지만, 이 원칙을 섣불리 코딩에 적용하면 '책임 = 동작' 으로 오해하고 단일한 동작으로 컴포넌트를 쪼개는 행동을 할 수 있습니다. 컴포넌트가 너무 많이 쪼개지면 전체 로직을 이해하기 어렵고 공수가 많아지므로 단일한 동작을 갖도록 코딩하는 것은 순수한 함수 한정으로 되어야 합니다. 즉, SRP 원칙을 지키며 컴포넌트를 설계하는 것은 요구 사항을 전달하는 책무 단위로 설계한다고 볼 수 있습니다.
벡엔드와 달리 프론트엔드는 직접적으로 클라이언트와 맞닿아있는 영역이라 좀 더 많은 연관이 있는데 각 영역의 요구사항을 명확히 하고 영역을 잘 구분하여 의존성 없는 독립적인 컴포넌트를 만들어 각 책무의 변경사항에도 유연하게 대처할 수 있도록 설계하고 구현하는 것이 중요합니다.
예를 들어, 로그인 폼 컴포넌트를 생각해보겠습니다. 이 컴포넌트는 사용자의 아이디와 비밀번호를 입력받고, 로그인 버튼을 누를 때 인증 요청을 보내는 역할을 합니다. 이 컴포넌트는 로그인과 관련된 기능만을 담당하며, 다른 기능과는 분리됩니다.
만약 이 로그인 폼 컴포넌트에 추가적인 기능을 구현하려면, 예를 들어 "비밀번호 찾기" 기능을 추가하고 싶다면, 단일 책임 원칙에 따라 새로운 컴포넌트를 생성하여 해당 기능을 담당하도록 해야 합니다. 이렇게 하면 로그인 폼 컴포넌트는 여전히 로그인과 관련된 책임만을 가지고 있으며, 새로운 컴포넌트는 "비밀번호 찾기" 기능에 대한 책임을 가지게 됩니다.
이와 같이 단일 책임 원칙을 적용하면 각 컴포넌트는 명확하고 한정된 역할을 수행하게 되어 코드의 가독성과 유지보수성이 향상됩니다. 또한, 새로운 기능을 추가하거나 변경할 때 다른 컴포넌트에 영향을 주지 않으므로 코드의 안정성과 확장성도 높아집니다.
클래스는 확장에는 개방적이어야 하고, 변경에는 폐쇄적이어야 한다.
클래스의 코드를 변경하는것은 해당 클래스를 사용하고 있는 모든 시스템에 영향을 주게 됩니다. 클래스에 추가 기능을 부여하고 싶다면, 가장 이상적인 접근방법은 기존 기능을 변경하는것이 아닌 새로운 함수를 추가하는 것 입니다.
OCP의 목적은 클래스의 존재하는 기능의 변경 없이 해당 클래스의 기능을 확장시키는 것 입니다. 이로인해 사용중인 클래스의 변경으로 인한 버그 발생을 피할 수 있습니다.
예시로 어떠한 서비스 운영 도중 추가/변경/삭제 되는 서비스가 있다고 가정했을 때, OCP를 적용하지 않은 코드를 보면
sections.map((section) => {
if(section.type === "one"){
return section.items.map((item) => <Component1 item={item} />);
} else if(type === "two"){
return section.items.map((item) => <Component2 item={item} />);
}
}
이 코드는 섹션이 추가될 경우 else-if를 추가하여 구현해야하는데 이 구조는 확장에 닫혀있다 라고 볼 수 있습니다. 그러므로 확장에 개방 될 수 있게 바꾸면
sections.map((section) =>
<Section section={section}>
{section.items.map((item) =>
<Component section={section} item={item} />
}
</Section>
이제 섹션을 추가/삭제 하여도 이 코드는 변할 일이 없습니다. 이러한 식으로 어떠한 서비스에 대해 컴포넌트를 설계할 때는 최대한 데이터의 변화나 서비스 로직의 변경에도 유연하게 대처하기 편한 구조를 가지는 것이 중요하다고 볼 수 있습니다.
만약 S가 T의 서브타입이라면, T는 어떠한 경고도 내지 않으면서, S로 대체가 가능합니다.
자식 클래스는 언제나 부모 클래스로 대체될 수 있어야 합니다. 즉, 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용해도 기능에 문제가 없어야 합니다.
자식 클래스는 부모 클래스가 할 수 있는 모든 것을 할 수 있어야 하는데(상속) 그러므로 자식 클래스는 부모 클래스처럼 똑같은 요청에 대해 똑같은 응답을 할 수 있어야 하고, 응답의 타입 또한 같아야 합니다.
만약 자식 클래스가 위의 조건을 충족하지 않으면, 자식 클래스가 완전히 변경되어 LSP에 위배된다는 것을 뜻합니다.
LSP의 목적은 일관성을 유지하여 부모 클래스 또는 자식 클래스를 오류 없이 동일한 방식으로 사용할 수 있도록 하는 것입니다.
다양한 형태의 버튼 컴포넌트를 만들어야 할 상황을 가정하면, 기본 버튼 컴포넌트(BaseButton)를 먼저 만들고, 이를 상속받아 특별한 기능을 가진 버튼 컴포넌트(SpecialButton)를 만들 수 있습니다.
class BaseButton {
onClick() {
// 기본 동작
}
}
class SpecialButton extends BaseButton {
onClick() {
// 특별한 동작
super.onClick();
}
}
이 경우에 LSP는 SpecialButton이 BaseButton의 역할을 완전히 대체할 수 있어야 함을 의미합니다. 즉, BaseButton에서 예상하는 모든 동작이 SpecialButton에서도 동일하게 작동해야 합니다. 만약 SpecialButton이 BaseButton의 일부 동작을 변경하거나 제거한다면, 이는 LSP를 위반하는 것이 됩니다.
따라서 LSP를 따르기 위해서는 상속받은 메소드를 재정의할 때 기존의 동작을 보존하는 것이 중요합니다. 이를 통해 어떤 버튼 컴포넌트를 사용하더라도 일관된 동작을 기대할 수 있습니다.
클라이언트는 사용하지 않는 메서드에 대해 의존적이지 않아야 합니다.
클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원칙입니다.
ISP의 목적은 액션 집합을 더 작은 액션 집합으로 쪼개서, 클래스가 필요한 액션들만 실행할 수 있도록 하는 것 입니다.
React에서는 컴포넌트 간의 상호작용을 위해 props를 사용합니다. 이때 각 컴포넌트는 자신이 필요로 하는 props만을 받아야 하며, 불필요한 props를 받아서는 안됩니다. 예를 들면
// ISP를 위반하는 예
function PhotoTextComponent({photo, text}) {
return (
<div>
<img src={photo} alt="" />
<p>{text}</p>
</div>
);
}
function TextComponent({photo, text}) {
return (
<p>{text}</p>
);
}
// ISP를 준수하는 예
function PhotoTextComponent({photo, text}) {
return (
<div>
<img src={photo} alt="" />
<p>{text}</p>
</div>
);
}
function TextComponent({text}) {
return (
<p>{text}</p>
);
}
이렇게 하면 컴포넌트 간의 종속성을 줄이고, 각 컴포넌트를 독립적으로 유지하고 확장하는데 도움이 됩니다.
상위 모듈이 하위 모듈에 직접 의존하는 것이 아니라, 둘 다 추상화에 의존하도록 설계해야 한다는 원칙입니다. 이 원칙을 통해 모듈 간의 결합도를 줄이고 유연성을 높일 수 있습니다.
상위 모듈 (또는 클래스): 도구와 함께 동작하는 클래스.
하위 모듈 (또는 클래스): 수행하기 위한 도구.
추상: 두 클래스를 연결하는 인터페이스
구체: 도구가 동작하는 방법
DIP는, 액션을 수행할때 클래스가 도구와 융합되면 안된다고 말합니다. 보다 좋은 방법은 인터페이스와 융합하여 클래스와 도구를 연결하는 것 입니다.
두 클래스와 인터페이스는 어떻게 도구가 동작하는지 알 수 없어야 합니다. 하지만, 도구는 인터페이스 사양을 충족해야 합니다.
DIP의 목적은 인터페이스를 통해 상위 클래스이 하위 클래스에 대해 의존성을 가지는 것을 줄이는 것 입니다.
예를 들어, API 서버로부터 데이터를 가져와야 하는 상황에서 데이터를 가져오는 로직을 직접 컴포넌트 안에 작성하면, 컴포넌트는 특정 API 서버에 직접적으로 의존하게 됩니다. API 서버가 변경되면 컴포넌트도 함께 변경해야 하기 때문에 유지보수가 어렵습니다.
이 문제를 해결하기 위해 DIP를 적용할 수 있습니다. 데이터를 가져오는 로직을 별도의 서비스로 분리하고, 이 서비스를 통해 데이터를 가져오도록 합니다. 이렇게 하면 컴포넌트는 서비스에 의존하게 되며, API 서버가 변경되더라도 서비스만 수정하면 됩니다.
DIP를 통해 결합도를 줄이고 코드의 유연성을 높이는 예시를 보면
// API 서비스
class APIService {
getData() {
// API 호출 로직
}
}
// 컴포넌트
class MyComponent {
constructor(service) {
this.service = service;
}
render() {
const data = this.service.getData();
// 데이터를 사용한 렌더링 로직
}
}
const service = new APIService();
const component = new MyComponent(service);
위 코드에서 MyComponent는 APIService에 의존하지 않고, 대신 service에 의존합니다. 이렇게하면 APIService가 변경되어도 MyComponent는 변경하지 않아도 됩니다.
※ 참고: ISP, DIP 실제 업무 적용 예시
위 예시 중 ISP와 DIP를 적용 하는 예시를 보여주는데에 한계가 있다고 생각하여 좀 더 실무 적용에 도움을 줄만한 타 블로그 글 적용 예시를 링크로 첨부합니다. 링크 중
ISP, DIP : Interface Segregation Principle, Dependency Inversion Principle
이 부분을 확인하면 됩니다.
참고 자료
https://blog.siner.io/2020/06/18/solid-principles/
https://fe-developers.kakaoent.com/2023/230330-frontend-solid/