...loading
2025-03-10
어떻게 하면 더 좋은 코드를 구현할 수 있을까 고민하는 요즘, 카카오의 기술 블로그를 훑어보다 공부하고 싶은 내용이 있어서 포스팅을 한다. 프론트엔드와 SOLID 원칙에 대한 내용이다. SOLID 원칙을 자격증이나 학교 전공 수업에서 접한 경험은 있지만, OOP 구현과 관련된 내용이라 생각하여 프론트엔드 개발경험과는 거리가 멀다고 생각해왔다. 포스팅을 읽어보며, 리액트와 같이 주로 함수형 컴포넌트를 사용하는 프론트엔드 개발경험에도 적용가능하며 고민해야한다는 것을 느낀다.
SOLID 원칙은 소프트웨어 개발의 설계 원칙을 나타내는 약어다. 유지보수에 용이하고 더 높은 품질의 코드를 설계하기 위해 적용할 수 있다. 어디까지 법칙이 아닌 원칙이기 때문에 절대적으로 지켜야만 하는 것은 아닐 수 있다. 하지만 좋은 설계를 위해 무엇을 고려해야하는지 알려주는 기준점을 제시한다.
SOLID 원칙에 들어가기 앞서, 해당 원칙에 대전제가 되는 법칙이 있다. 콘웨이 법칙이다. 법칙인 만큼 매우 중요한 내용이다. 콘웨이가 언급한 내용처럼, 소프트웨어의 설계가 조직의 소통 구조를 녹여내지 못한다면 어딘가 잘못된 구조라는 것이다. 그리고 이러한 콘웨이 법칙을 지키기 위해서 SOLID 원칙은 좋은 방법론이 될 수 있다. 특히 SOLID 원칙의 시작인 SRP(단일 책임 원칙)은 조직의 구조와 모듈의 설계를 연결시킬 수 있는 중요한 원칙이다.
아티클의 저자는 SRP 원칙을 가장 강조하면서 말하고 있다. 그만큼 중요하고, 개발자가 오해하여 받아들일 수도 있는 개념이라 말한다. SRP는 직역하면 단일 책임 원칙을 의미한다. 하나의 모듈은 여러가지가 아니라 하나의 책임만을 전담하게 설계되어야 한다는 의미라 할 수 있겠다. 그리고 프론트엔드로 적용하면 모듈=컴포넌트로 적용할 수 있을 것 같다. 하지만 초급 개발자로서 실수할 수 있는 부분은 '책임=동작'으로 해석하는 것이다. 컴포넌트가 단일 동작을 단위로 설계된다면, 과하게 컴포넌트가 분리되고 비효율적인 설계로 확장된다. 책임=동작으로 적용 가능한 부분은 컴포넌트가 아닌 함수며, 이는 클린-아키텍처라기 보다는 클린-코드의 영역이라 볼 수 있다.
“SRP의 최종 버전은 다음과 같다. 하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.” - 로버트 C. 마틴(클린 아키텍처)
로버트 마틴이 강조한 설명을 보면 모듈(컴포넌트)의 책임은 하나의 액터라 말한다. 여기서 액터는 사용자/이해관계자 집단을 의미한다. 나아가, 이 액터는 소프트웨어 설계를 완성하기 위한 각 요구사항들에 책무를 지닌다. 그리고 이러한 맥락을 모두 고려할 때, SRP란 소프트웨어의 동작이 아니라 '요구사항을 전달하는 책무 단위'를 기반으로 설계되어야 한다. 그리고 최종적으로 이러한 책무 기반의 설계로 완성된 컴포넌트는 조직 간의 커뮤니케이션 도구로 기능한다. 이처럼 '요구사항을 전달하는 책무 단위'로 설계된 구조의 장점은 다음과 같이 요약할 수 있다.
컴포넌트에 대한 요구사항을 명확히 파악하고 전달하여, 의존성 없는 독립적인 컴포넌트 설계 가능.
각 컴포넌트가 단일한 요구사항만을 수행하는 구조로 설계되기에, 요구사항이 변경된다고 하더라도 사이드이펙트 없이 유연하게 대처할 수 있음.
요구사항의 책무 및 명세를 명확히 정의하기에, 이를 기반으로 조직 간 커뮤니케이션의 역할로 기능.
이번에는 SRP를 적용 가능한 사례를 살펴보겠다. 아래의 코드는 로그인이나 회원가입 창에서 사용할 수 있는 기본적인 입력창의 내용이다. 유저가 입력한 텍스트에 대한 유효성 검사와 함께, 시각적인 UI를 렌더링한다.
import { useState } from 'react'; const UsernameInput = () => { const [value, setValue] = useState(''); const [error, setError] = useState(''); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const newValue = e.target.value; setValue(newValue); // 비즈니스 로직 (유효성 검사) if (newValue.length < 3) { setError('Username must be at least 3 characters'); } else { setError(''); } }; return ( <div> <input value={value} onChange={handleChange} /> {error && <p style={{ color: 'red' }}>{error}</p>} </div> ); }; export default UsernameInput;
위 코드는 큰 문제가 없을 수 있다. 하지만 아래와 같은 가정을 고려해보자.
현재 비지니스 로직을 담당하는 기획팀과 UI를 담당하는 디자인팀의 역할이 나눠져 있다.
만약 콘웨이 법칙과 함께 SRP 원칙을 고려한다면, 현재 하나의 컴포넌트 내부에 여러 액터의 책무가 스며들어있는 상황이다. 유효성 검사(기획 책무 - 비지니스 로직) 그리고 입력창 UI(디자인 책무 - UI)에 대한 복수의 책무를 하나의 컴포넌트에서 처리하는 것이다. 따라서 각 책무 별로 컴포넌트를 분리하여 SRP를 준수할 수 있다.
import { useState } from 'react'; const useUsername = () => { const [value, setValue] = useState(''); const [error, setError] = useState(''); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const newValue = e.target.value; setValue(newValue); // 비즈니스 로직 (길이 검사) if (newValue.length < 3) { setError('Username must be at least 3 characters'); } else { setError(''); } }; return { value, error, handleChange }; }; export default useUsername;
import useUsername from './useUsername'; const UsernameInput = () => { const { value, error, handleChange } = useUsername(); return ( <div> <input value={value} onChange={handleChange} /> {error && <p style={{ color: 'red' }}>{error}</p>} </div> ); }; export default UsernameInput;
OCP는 개방-폐쇄 원칙으로 확장은 열려 있되, 수정은 닫혀 있도록 설계하는 원칙이다. 이를 컴포넌트 설계에 적용한다면, 요구사항이 변경될 때 기존 코드를 수정하는 것이 아니라 새로운 코드를 추가함으로써 변경에 대응하도록 한다. OCP 설계의 장점은 기존 코드를 변경하지 않고 새로운 변경사항을 추가할 수 있다는 점, 그리고 이를 통해 예상치 못한 버그를 줄임으로서 유지보수성을 높이는 것에 있다. 즉 OCP를 통해 요구사항의 변경에 유연하게 안전하게 대처할 수 있다.
프론트엔드 개발자라면 한 번쯤은 만드는 버튼 컴포넌트로 예시를 살펴보자.
type ButtonProps = { type: 'primary' | 'secondary'; label: string; }; const Button ({ type, label } : ButtonProps ) { let style = {}; if (type === 'primary') { style = { backgroundColor: 'blue', color: 'white' }; } else if (type === 'secondary') { style = { backgroundColor: 'gray', color: 'black' }; } // 버튼 속성이 추가되면 else if 추가 return <button style={style}>{label}</button>; }; export default Button;
해당 컴포넌트는 전달되는 props값에 따라 여러 유형의 버튼을 반환할 수 있도록 구현되었다. 나쁘지 않은 코드처럼 보인다. 하지만 만약 버튼 유형에 대한 요구사항이 추가된다면 else if문이 계속하여 추가되며 기존 코드가 계속하여 변경된다는 단점이 있다. OCP 원칙을 통해 코드를 리팩토링하면 이러한 문제를 해결할 수 있다.
// buttonStyles.ts export default buttonStyles = { primary : { backgroundColor: 'blue', color: 'white' }, secondary : {.backgroundColor: 'gray', color: 'black' } danger : {.backgroundColor: 'red', color: 'white' } // 추가된 버튼 유형의 스타일 } // OcpButton.tsx import buttonStyles from "./buttonStyles.ts" type OcpButtonProps = { type: keyof typeof buttonStyles; label: string; }; const OcpButton ({ type, label } : OcpButtonProps ) { let style = buttonStyles[type] return <button style={style}>{label}</button>; }; export default Button;
해당 코드는 OCP를 적용하여 수정한 버튼 컴포넌트다. 만약 버튼 유형을 추가해야할 경우, buttonStyles
객체에 필요한 데이터만 추가하는 방식을 통해 기존 버튼 컴포넌트의 코드변경 없이 유연한 확장성을 보장한다.
리스코프 치환 원칙은 보통 클래스의 상속 관계를 설명하며, 클래스 간 상속을 올바르게 사용하기 위한 핵심적인 원칙이다. 이에 따르면 올바르게 부모를 상속한 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다. 자식 클래스는 부모의 상속자로서, 자신만의 부가적인 기능을 포함하면서도 부모 클래서에서 정의된 동작을 일관되게 유지해야한다는 것이다.
그리고 이러한 원칙은 다형성을 갖춘 클래스를 설계하도록 함으로써, 코드의 확장성과 재사용성을 보장한다. 또한 코드 구조의 일관성이 유지되기 때문에, 요구사항의 수정에 유리하다.
한편, LSP를 아키텍처의 관점으로 보았을 때 상속 관계는 좀 더 넓은 의미로 적용될 수 있다. 간단하게 is-a
를 만족하느냐로 상속 관계를 판별할 수 있다. 참새는 새다
처럼 명확한 관계를 갖는 것이 상속 관계다. 그리고 이렇게 명확하게 이어진(상속) 관계에서 예상치 못한 행동을 하지 않아야하는게 LSP의 핵심이다.
개발을 하다 보면, 위의 상황 같이 명확하게 정의된 인터페이스에서 예상치 못한 작업을 수행하는 경우가 있다. 예를 들어 POST 요청을 담당하는 API에서 POST 이외의 엉뚱한 기능을 추가로 수행되고 있는 상황을 들 수 있겠다. 이처럼 정의한 내용과 구현 내용의 불일치로 is-a
관계가 성립되지 않은 경우를 LSP에 어긋난 상황이라 볼 수 있고, 꼭 해결하는 것이 중요하다. LSP를 충족하지 않은 확실한 부분은 물론, 애매한 부분까지 세심하게 관리하지 않는다면 이는 결국 기술부채로 이어질 수 있기 때문이다.
ISP는 클라이언트가 사용하지 않는 인터페이스는 구현하지 않는 원칙이다. 즉, 사용하지 않는 기능은 강제하지 말라는 것을 의미한다. ISP의 위반 사례는 보통 하나의 통상적인 인터페이스를 정의하여, 여러 클라이언트에서 사용하게 되는 경우에 발생한다. 이러한 상황에서, 클라이언트 공통 인터페이스로부터 자신이 사용하지 않는 기능들까지 전달받게 된다.
리액트를 통한 프론트엔드 개발에서 ISP의 위반 사례는 흔히 접할 수 있다. 특히 통상적으로 사용하기 위해 타입스크립트로 정의한 Props 인터페이스가 그러하다. 이러한 경우 정말 필요한 단위로 인터페이스를 재구성하여 분리하도록 한다.
또한 한 번 더 리마인드할 개념이 SRP다. SRP를 잘 준수하여 모듈을 분리할 경우, ISP 또한 자연스럽게 적용될 가능성이 높다. 각 책무만 담당하는 모듈로 프로그래밍하면서, 모듈이 필요 이상으로 커지는 것을 예방하기 때문이다.
DIP는 상위(고수준) 모듈이 하위(저수준) 모듈에 의존하지 않는 것을 원칙으로 삼는다. 즉, 상위 모듈이 구체적인 함수나 클래스에 직접 의존하면 안된다는 것이다. 이러할 경우 하위 모듈에 수정이 발생한다면 상위 모듈 또한 변경사항이 커지게 된다. 따라서 DIP를 준수하는 상위 모듈은 추상화된 하위 모듈의 인터페이스를 통해 의존하도록 설계된다.
const fetchData = async () => { const response = await fetch('https://api.example.com/data'); const data = await response.json(); return data; }; const DataComponent = () => { const [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, []); return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>; }; export default DataComponent;
위의 예시 또한 큰 문제가 없어 보인다. 하지만 컴포넌트(상위 모듈)의 코드가 구체적인 API 함수(하위 모듈)에 직접적으로 의존하고 있는 상황이다.
// 1. 인터페이스 정의 interface DataFetcher { fetchData: () => Promise<any>; } // 2. 하위 구현체 생성 : Api 로직 const apiFetcher: DataFetcher = { fetchData: async () => { const response = await fetch('https://api.example.com/data'); const data = await response.json(); return data; }, }; // 3. 상위 모듈 : 추상화된 인터페이스를 통해 하위 모듈을 전달 받도록 설계 type Props = { fetcher: DataFetcher; }; const DataComponent = ({ fetcher }: Props) => { const [data, setData] = useState(null); useEffect(() => { fetcher.fetchData().then(setData); }, [fetcher]); return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>; }; // 4. 상위 모듈에 의존성 주입 ( 구체적인 구현체 전달 ) const App = () => { return <DataComponent fetcher={apiFetcher} />; }; export default App;
DIP를 적용한 코드다. 하위 모듈의 구현체를 분리하고, 이에 대한 인터페이스를 추상화하였다. 그리고 상위 모듈에서는 추상화된 인터페이스를 통해 하위 모듈을 전달 받도록 한다. 이를 통해 모듈 간의 결합도를 낮추었고, 하위 모듈이 변경되더라도 상위 모듈에서 문제가 발생하지 않도록 개선할 수 있다.
이상 프론트엔드와 SOLID 원칙에 대한 글을 마친다. 확실히 새로운 개념을 머리 속에 집어넣고, 요약하여 나의 포스팅으로 소화하려다보니 쉽지 않다. 원문의 저자가 설명한 것처럼 SOLID 원칙에서 정말 중요한 것은 SRP(단일 책임 원칙)라 생각이 든다. 조직의 구성과 함께 분리되는 책무와, 또 다시 그 책무를 완수하기 위해 분리되는 컴포넌트를 분리하는게 앞으로 고민해야할 방향성 같다. 또한 SRP를 적용할 때 ISP, DIP 또한 자연스레 적용됨을 느낀다. 각 책무를 분리함으로써 필요한 인터페이스만이 설계 되고, 모듈 간의 불필요한 결합성이 낮아지는 것이다. 따라서 (다 중요하지만) 단 하나만 기억한다면 SRP가 아닐까 싶다. 생소하고 소화하기 어려운 내용이였지만, 원문 뿐 아니라 다행히 너무 잘 정리해주신 글들이 많아서 도움을 받을 수 있었다.
Comments