객체 지향 프로그래밍의 기본 개념은 작은 독립적인 단위의 객체를 만들어 조립하는 것입니다. 이 개념이 정립되고 다듬어지면서, 프로그래밍의 세계에서 큰 빛을 발하게 되었습니다. 하지만, 단순히 클래스와 객체를 많이 사용한다고 해서 좋은 코드가 만들어지는 것은 아닙니다.
좋은 객체 지향 설계를 위해서는 객체들 간의 관계를 신중하게 설정하고, 초기 설계 단계에서 많은 노력을 기울여야 합니다. 객체 지향 프로그래밍은 그 자체로 복잡함이 따르지만, 이러한 복잡함을 관리하기 위한 여러 원칙과 방법이 발전해왔습니다.
그 중에서도 프로그래밍 세계에서 널리 통용되는 SOILD 원칙에 대해 정리해보았습니다.
SOLID 원칙이란?
SOLID는 좋은 설계를 위한 다섯 가지 원칙으로, 이를 지키면 견고한 소프트웨어를 만들 수 있습니다. 또한, 코드의 유지 보수성과 확장성을 크게 향상시킬 수 있습니다. 이 원칙들은 처음 접할 때는 다소 어려울 수 있지만, 이해하고 나면 코드 설계 시 중요한 이정표가 됩니다.
아직 개발 경험이 부족해서인지, SOLID 원칙을 완벽히 이해하고 적용하는 것이 어렵게 느껴집니다. 그러나 이 원칙들을 공부하면서 컴포넌트를 어떻게 분리하고 설계해야 할지 더 깊이 고민하게 되었습니다. 프론트엔드 개발자로서 어떻게 코드를 모듈화하고, 각 컴포넌트의 역할과 책임을 명확히 할 수 있을지 판단할 수 있는 능력은 정말 많이 중요하다고 생각합니다. 이 SOLID 원칙을 코드에 잘 녹여내기 위해 끊임없이 사고하는 훈련이 앞으로 나의 개발 능력에 큰 도움이 될 것이라고 생각합니다.
SOLID 원칙은 다음과 같은 다섯 가지로 구성되어 있습니다.
- SRP (Single Responsibility Principle) - 단일 책임 원칙
- OCP (Open Close Principle) - 개방 폐쇄 원칙
- LSP (Liskov Substitution Principle) - 리스코프 치환 원칙
- ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
- DIP (Dependency Inversion Principle) - 의존성 역전 원칙
이 5가지 원칙들은 서로 독립된 개별적인 개념이 아니라, 서로 개념적으로 연관되어 있습니다. 원칙끼리 서로가 서로를 이용하기도 하고 포함하기도 합니다.
1. SRP (Single Responsibility Principle) - 단일 책임 원칙
단일 책임 원칙은 하나의 클래스가 오직 하나의 기능만을 담당해야 하며, 클래스가 제공하는 모든 서비스가 하나의 책임을 수행하는 데 집중되어야 한다는 원칙입니다. 쉽게 말해, 클래스를 변경해야 하는 이유는 단 하나여야 한다는 것입니다.
하나의 클래스에 여러 책임(기능)이 있다면, 특정 기능이 변경될 때 다른 기능에도 영향을 미치게 되어, 수정해야 할 코드가 많아질 수 있습니다. 예를 들어, A 기능을 수정하면 B 기능도 수정해야 하고, 다시 C 기능까지 수정해야 하는 상황이 발생할 수 있습니다. SRP 원칙을 따르면 한 책임의 변경이 다른 책임으로 연쇄적으로 영향을 미치는 문제를 줄일 수 있습니다.
핵심은 책임을 분리하는 것뿐만 아니라, 분리된 클래스들 간의 관계에서 복잡도를 줄이도록 설계하는 것입니다.
이 SRP에 대해서는, 리액트 공식문서에서도 언급될만큼 중요한 원칙입니다.
SRP 원칙이 위반된 사례와 그 코드를 수정한 정말 간단한 예제를 들어보겠습니다.
import React, { useEffect, useState } from 'react';
const UserProfile = () => {
const [user, setUser] = useState(null);
useEffect(() => {
// API 호출 (데이터 가져오기)
fetchUser();
}, []);
const fetchUser = async () => {
const response = await fetch('/api/user');
const data = await response.json();
setUser(data);
};
return (
<div>
{user ? (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
) : (
<p>Loading...</p>
)}
</div>
);
};
export default UserProfile;
여기서 UserProfile 컴포넌트는 데이터를 가져오는 것과 UI를 렌더링하는 두 가지 책임을 모두 가지고 있습니다. 위의 코드에서 SRP 원칙을 준수하도록 수정하면 다음과 같습니다.
// userService.js (API 호출을 위한 서비스)
export const fetchUser = async () => {
const response = await fetch('/api/user');
const data = await response.json();
return data;
};
// UserProfile.js (UI 컴포넌트)
import React, { useEffect, useState } from 'react';
import { fetchUser } from './userService';
const UserProfile = () => {
const [user, setUser] = useState(null);
useEffect(() => {
const getUserData = async () => {
const data = await fetchUser();
setUser(data);
};
getUserData();
}, []);
return (
<div>
{user ? (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
) : (
<p>Loading...</p>
)}
</div>
);
};
export default UserProfile;
API 호출이 별도의 서비스로 분리되었습니다. 이제 ‘UserProfile’ 컴포넌트는 이제 UI 렌더링만 담당하게 됩니다. API 호출 코드에 문제가 있다면 userService.js의 코드를 수정하면 되고, UI에 문제가 있다면 UserProfile.js를 수정하면 됩니다.
2. OCP (Open Close Principle) - 개방 폐쇄 원칙
개방 폐쇄 원칙은 소프트웨어 구성 요소(컴포넌트, 클래스, 모듈, 함수 등)가 확장에는 열려 있고, 변경(수정)에는 닫혀 있어야 한다는 원칙입니다.
간단히 말하면, 요구사항이 변경될 때 기존 코드를 변경하는 것이 아니라 새로운 코드를 추가하는 방향을 추구하는 원칙입니다. 이렇게 하면 코드의 안정성을 유지하면서 새로운 요구 사항을 수용할 수 있습니다.
프론트엔드에서 자주 접할 수 있는 컴포넌트 확장 상황을 통해 OCP를 적용하는 예제를 살펴봅니다.
다음은 버튼 컴포넌트를 구현하는 예제입니다. 버튼의 스타일을 변경할 때마다 기존 코드를 수정해야 하는 방식입니다.
// Button.js
import React from 'react';
const Button = ({ type, onClick, children }) => {
let className = 'btn';
if (type === 'primary') {
className += ' btn-primary';
} else if (type === 'secondary') {
className += ' btn-secondary';
}
return (
<button className={className} onClick={onClick}>
{children}
</button>
);
};
export default Button;
물론 위처럼 작성될 일은 많이 없겠지만, 예시 설명을 위해 극적으로 상황을 가정해보았습니다.
이 코드에서는 버튼의 스타일을 변경하기 위해 ‘Button’ 컴포넌트의 코드를 수정해야 합니다. 새로운 버튼 스타일이 필요할 때마다 if문을 추가해야하는 불편함이 있습니다.
OCP 원칙을 준수하도록 Button 컴포넌트를 수정하여 버튼 스타일을 확장할 때 기존 코드를 수정하지 않고 새로운 스타일을 추가할 수 있습니다.
// Button.js
import React from 'react';
// Base Button component
const Button = ({ className, onClick, children }) => {
return (
<button className={className} onClick={onClick}>
{children}
</button>
);
};
// Primary Button component
const PrimaryButton = (props) => {
return <Button className="btn btn-primary" {...props} />;
};
// Secondary Button component
const SecondaryButton = (props) => {
return <Button className="btn btn-secondary" {...props} />;
};
export { PrimaryButton, SecondaryButton };
이 리팩토링된 코드에서는 각 버튼 스타일을 별도의 컴포넌트로 정의하여 필요에 따라 새로운 버튼 스타일을 쉽게 추가할 수 있습니다. 새로운 버튼 스타일을 추가하려면 기존 Button 컴포넌트를 변경할 필요 없이 새로운 버튼 컴포넌트를 추가하면 됩니다.
3. LSP (Liskov Substitution Principle) - 리스코프 치환 원칙
리스코프 치환 원칙(Liskov Substitution Principle, LSP)은 서브 타입(자식 클래스)은 언제나 기반 타입(부모 클래스)으로 교체할 수 있어야 한다는 것을 의미합니다. 즉, 서브 타입은 항상 기반 타입과 호환되어야 하며, 이 원칙을 통해 객체 지향 프로그래밍의 핵심 개념인 다형성을 올바르게 활용할 수 있습니다.
핵심은 부모 클래스의 행동 규약을 자식 클래스가 위반하지 않아야 한다는 것!
프론트엔드 개발에서 LSP를 적용하는 대표적인 예시는 컴포넌트 설계와 상속을 통해 발생할 수 있습니다. 이 포스트에서는 React를 예시로 들어 LSP를 정리해봤습니다.
LSP를 준수하는 경우
React 컴포넌트에서 상위 컴포넌트를 확장한 하위 컴포넌트가 부모 컴포넌트의 규약을 어기지 않고, 부모 컴포넌트로 치환 가능할 때 LSP가 준수된다고 할 수 있습니다.
// 부모 컴포넌트
class Button extends React.Component {
render() {
return (
<button onClick={this.props.onClick}>
{this.props.label}
</button>
);
}
}
// 자식 컴포넌트
class IconButton extends Button {
render() {
return (
<button onClick={this.props.onClick}>
<span className="icon">{this.props.icon}</span>
{this.props.label}
</button>
);
}
}
// 사용 예시
function App() {
return (
<div>
<Button label="Click me" onClick={() => alert('Button clicked!')} />
<IconButton label="Click me" icon="👍" onClick={() => alert('IconButton clicked!')} />
</div>
);
}
위 예제에서 IconButton은 Button 컴포넌트를 확장하여 기능을 추가했지만, Button의 기본 동작을 변경하지 않았습니다. Button의 onClick과 label 프로퍼티는 그대로 사용되며, 추가로 icon 프로퍼티를 받습니다. 따라서 IconButton은 Button의 대체물로 사용할 수 있으며, LSP를 준수합니다.
LSP를 위반하는 경우
반대로, 부모 컴포넌트의 규약을 위반하는 경우는 LSP를 위반하는 것입니다.
// 부모 컴포넌트
class Button extends React.Component {
render() {
return (
<button onClick={this.props.onClick}>
{this.props.label}
</button>
);
}
}
// 자식 컴포넌트 (LSP 위반)
class DisabledButton extends Button {
render() {
// 부모 컴포넌트의 행동 규약을 위반: 버튼 클릭 불가로 변경
return (
<button onClick={() => { /* 아무 것도 하지 않음 */ }} disabled>
{this.props.label}
</button>
);
}
}
// 사용 예시
function App() {
return (
<div>
<Button label="Click me" onClick={() => alert('Button clicked!')} />
<DisabledButton label="Can't click me" onClick={() => alert('DisabledButton clicked!')} />
</div>
);
}
DisabledButton 컴포넌트는 Button을 확장하지만, Button의 onClick 행동 규약을 어기고 있습니다. 부모 컴포넌트는 클릭 시 onClick 함수가 실행될 것을 기대하지만, DisabledButton은 의도적으로 클릭 기능을 제거하고 버튼을 비활성화합니다. 이로 인해 DisabledButton은 Button의 대체물로서 적합하지 않으며, LSP를 위반하게 됩니다.
하지만 위의 예시는 클래스를 사용했을 때입니다.
React에서는 함수형 프로그래밍을 주로 활용하게 되는데, 그렇다면 함수형 프로그래밍에서는 어떻게 LSP 원칙을 적용할 수 있을까요?
컴포넌트 간 명시적인 부모-자식 관계가 없더라도, 대체 가능성과 일관된 동작을 염두에 두고 설계한다면 LSP 원칙을 적용할 수 있다고 생각했습니다.
즉, 단순히 비슷한 역할을 수행하는 컴포넌트가 아니라, 대체 가능성과 일관된 동작이 유지되는 컴포넌트를 설계하는 것입니다.
이런 상황에서, 하나의 컴포넌트를 다른 컴포넌트로 교체했을 때 기대되는 동작이 동일하게 유지된다면 LSP가 적용됐다고 볼 수 있습니다.
이 사고를 기반으로, 버튼 컴포넌트의 예시를 들어보겠습니다.
import React from 'react';
// 기본 버튼 컴포넌트
function Button({ label, onClick }) {
return (
<button onClick={onClick}>
{label}
</button>
);
}
// 확장된 버튼 컴포넌트
function IconButton({ label, icon, onClick }) {
return (
<button onClick={onClick}>
<span className="icon">{icon}</span>
{label}
</button>
);
}
// 사용 예시
function App() {
return (
<div>
<Button label="Click me" onClick={() => alert('Button clicked!')} />
<IconButton label="Click me" icon="👍" onClick={() => alert('IconButton clicked!')} />
</div>
);
}
여기서 IconButton은 Button의 역할을 확장한 형태로, Button과 동일한 방식으로 사용됩니다. Button의 기본적인 동작을 변경하지 않으면서도 아이콘을 추가하여 기능을 확장했습니다. 따라서 IconButton은 Button으로 대체 가능하며, LSP를 준수합니다.
그렇다면 다음 코드를 살펴봅시다.
import React from 'react';
// 기본 버튼 컴포넌트
function Button({ label, onClick }) {
return (
<button onClick={onClick}>
{label}
</button>
);
}
// 비일관된 버튼 컴포넌트
function LinkButton({ label, href }) {
return (
<a href={href}>
{label}
</a>
);
}
// 사용 예시
function App() {
return (
<div>
<Button label="Click me" onClick={() => alert('Button clicked!')} />
<LinkButton label="Go to Google" href="https://www.google.com" />
</div>
);
}
위의 예제에서 LinkButton은 Button과 유사한 UI를 제공하지만, onClick 이벤트가 아닌 href 속성을 사용하여 링크로 동작합니다. LinkButton을 Button으로 교체했을 때 동일한 인터페이스를 제공하지 않으며, 클릭 이벤트 처리라는 예상 동작이 변경된다. 이는 LSP를 위반하는 예시입니다.
이렇게 함수형 프로그래밍에서 LSP는 컴포넌트 간의 대체 가능성을 통해 준수될 수 있다고 생각해봤습니다. 정답이 아닐 수 있겠지만, 이와 같이 LSP 원칙을 염두에 두고 컴포넌트를 설계하면, 컴포넌트 간의 일관성을 유지하고 더 견고하고 유지보수 가능한 코드를 작성할 수 있을 것입니다.
4. ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
ISP, 즉 인터페이스 분리 원칙은 클래스가 자신이 사용하지 않는 인터페이스에 의존하지 않도록 인터페이스를 목적에 맞게 잘게 분리해야 한다는 설계 원칙입니다.
한 클래스가 다른 클래스에 의존할 때, 가능한 최소한의 인터페이스만 사용하도록 하는 것이 핵심입니다. 이를 통해 코드의 유연성과 유지보수성을 높이고, 불필요한 의존성으로 인한 변경의 영향을 최소화할 수 있습니다.
이 원칙은 프론트엔드 개발에서도 중요한 역할을 합니다. 프론트엔드에서는 주로 컴포넌트의 설계와 API 통신에서 ISP를 적용할 수 있습니다. 특히, 컴포넌트가 서로 다른 기능을 수행해야 할 때, 불필요한 의존성을 피하고 필요한 부분만 노출하는 인터페이스를 정의하는 것이 중요합니다.
아래의 예시에서, React 컴포넌트 설계에서 ISP를 어떻게 적용할 수 있는지 살펴봅시다.
잘못된 설계
// 인터페이스를 구현한 단일 컴포넌트
function UserProfile({ user, onFollow, onSendMessage }) {
return (
<div>
<h1>{user.name}</h1>
<button onClick={onFollow}>Follow</button>
<button onClick={onSendMessage}>Send Message</button>
</div>
);
}
위의 UserProfile 컴포넌트는 onFollow와 onSendMessage라는 두 가지 기능을 제공합니다. 하지만 만약 어떤 상황에서는 사용자 프로필을 표시하기만 하고, 팔로우나 메시지 전송 기능이 필요 없다면? 불필요한 의존성을 가진 인터페이스가 발생하게 됩니다. 이는 인터페이스 분리 원칙을 위반하는 예시입니다.
ISP를 준수한 설계
// 팔로우 기능을 분리한 컴포넌트
function UserFollow({ onFollow }) {
return <button onClick={onFollow}>Follow</button>;
}
// 메시지 전송 기능을 분리한 컴포넌트
function UserMessage({ onSendMessage }) {
return <button onClick={onSendMessage}>Send Message</button>;
}
// 사용자 프로필 컴포넌트
function UserProfile({ user, withFollow, withMessage }) {
return (
<div>
<h1>{user.name}</h1>
{withFollow && <UserFollow onFollow={withFollow} />}
{withMessage && <UserMessage onSendMessage={withMessage} />}
</div>
);
}
위의 예시에서는 UserFollow와 UserMessage 컴포넌트를 별도로 분리하여 필요한 기능만 UserProfile에 포함하도록 설계했습니다. 이렇게 하면 UserProfile 컴포넌트는 불필요한 인터페이스에 의존하지 않으며, 필요한 경우에만 해당 기능을 포함할 수 있습니다.
이러한 설계는 유지보수성과 확장성을 높이는 동시에, 의존성을 최소화하여 더 유연한 코드를 작성할 수 있도록 도와줍니다.
5. DIP (Dependency Inversion Principle) - 의존성 역전 원칙
의존성 역전 원칙(DIP)은 클래스 간의 결합도를 낮추기 위한 원칙으로, 구현 세부 사항이 아닌, 추상화된 상위 요소(인터페이스 또는 추상 클래스)에 의존하도록 설계해야 한다는 것을 의미합니다.
즉, 구체적인 구현 클래스에 의존하지 말고, 인터페이스나 추상 클래스와 같은 상위 요소에 의존하라는 것입니다. 이는 변화가 잦거나 불안정한 구현에 의존하지 않고, 비교적 변화가 적은 안정적인 추상화에 의존함으로써 코드의 유연성과 유지보수성을 높이는 것을 목표로 합니다.
프론트엔드 개발에서 DIP를 적용하는 대표적인 방법 중 하나는 서비스나 API 호출을 처리할 때 인터페이스를 사용하는 것입니다. 예를 들어, 데이터를 가져오는 API 서비스가 있다고 가정해보겠습니다.
잘못된 설계
class ApiService {
fetchData() {
return fetch('<https://api.example.com/data>').then(response => response.json());
}
}
class DataComponent {
constructor(apiService) {
this.apiService = apiService;
}
loadData() {
this.apiService.fetchData().then(data => {
console.log(data);
});
}
}
// 직접적으로 ApiService에 의존
const apiService = new ApiService();
const dataComponent = new DataComponent(apiService);
dataComponent.loadData();
위의 예시에서 DataComponent는 ApiService라는 구체적인 클래스에 직접 의존하고 있습니다. 만약 ApiService가 변경된다면 DataComponent도 변경해야 하므로, 두 클래스 간의 결합도가 높아지게 됩니다.
DIP를 준수한 설계
// 추상화된 인터페이스
class IDataService {
fetchData() {
throw new Error("Method not implemented.");
}
}
// 구현 클래스 1
class ApiService extends IDataService {
fetchData() {
return fetch('<https://api.example.com/data>').then(response => response.json());
}
}
// 구현 클래스 2
class MockService extends IDataService {
fetchData() {
return Promise.resolve({ mock: 'data' });
}
}
class DataComponent {
constructor(dataService) {
this.dataService = dataService;
}
loadData() {
this.dataService.fetchData().then(data => {
console.log(data);
});
}
}
// 인터페이스에 의존하여 구현
const apiService = new ApiService();
const mockService = new MockService(); // 테스트 시에는 MockService를 사용
const dataComponent = new DataComponent(apiService);
dataComponent.loadData();
위의 예시에서는 IDataService라는 추상화된 인터페이스를 통해 ApiService와 MockService를 구현했습니다. 이제 DataComponent는 구체적인 구현이 아닌, 추상화된 IDataService에 의존하게 됩니다.
이로 인해 ApiService가 변경되더라도 IDataService 인터페이스만 유지되면 DataComponent는 영향을 받지 않으며, 테스트나 다른 상황에서는 MockService와 같은 대체 구현체를 쉽게 사용할 수 있습니다.
그렇다면 함수형 컴포넌트에서는 어떻게 DIP 원칙을 적용해볼 수 있을까요?
저는 의존성을 주입하는 방식과 구체적인 구현 대신 추상화된 인터페이스나 함수에 의존하는 방식으로 구현할 수 있겠다고 생각했습니다.
함수형 컴포넌트에서는 클래스형 컴포넌트와 달리 인터페이스나 추상 클래스를 명시적으로 사용할 수는 없지만, 상위 컴포넌트로부터 필요한 함수나 데이터를 주입받아 처리함으로써 DIP를 적용할 수 있습니다.
예시 코드로 한 번 살펴봅시다.
잘못된 설계:
import React, { useEffect, useState } from 'react';
function DataComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// 직접적으로 API 호출 로직을 내부에 구현
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => setData(data));
}, []);
return (
<div>
{data ? JSON.stringify(data) : 'Loading...'}
</div>
);
}
export default DataComponent;
위의 DataComponent는 API 호출 로직을 직접적으로 포함하고 있어, API가 변경될 경우 컴포넌트도 변경해야 합니다. 이는 DIP를 위반한 구조입니다.
DIP를 준수한 설계:
import React, { useEffect, useState } from 'react';
function DataComponent({ fetchData }) {
const [data, setData] = useState(null);
useEffect(() => {
// 주입된 fetchData 함수를 사용하여 데이터 로드
fetchData().then(data => setData(data));
}, [fetchData]);
return (
<div>
{data ? JSON.stringify(data) : 'Loading...'}
</div>
);
}
export default DataComponent;
위의 DataComponent는 fetchData라는 함수를 상위 컴포넌트로부터 주입받아 사용합니다. 이제 DataComponent는 API 호출 로직에 의존하지 않고, fetchData 함수에만 의존하게 되므로 DIP를 준수하는 구조가 됩니다.
이렇게 구조가 바뀌면, 이제 상위 컴포넌트에서 실제 API 호출 함수 또는 테스트용 Mock 함수를 주입할 수 있습니다.
import React from 'react';
import DataComponent from './DataComponent';
function ApiService() {
return fetch('https://api.example.com/data').then(response => response.json());
}
function MockService() {
return Promise.resolve({ mock: 'data' });
}
function App() {
return (
<div>
{/* 실제 서비스용 */}
<DataComponent fetchData={ApiService} />
{/* 테스트용 */}
{/* <DataComponent fetchData={MockService} /> */}
</div>
);
}
export default App;
이 구조에서는 DataComponent가 어떤 데이터 소스에서 데이터를 가져오는지 알 필요가 없으며, ApiService나 MockService 등 원하는 함수를 주입할 수 있습니다.
컴포넌트를 설계할 때, 각각의 컴포넌트가 단일한 책임에 맞게 잘 분리되어 있다면, 불필요한 속성(props)이 없는 독립적인 컴포넌트로 구성될 수 있습니다.
그러나 컴포넌트만으로는 페이지를 완성할 수 없습니다. 페이지를 구성하기 위해서는 여러 컴포넌트를 조합(composition)해야 합니다.
이때, 인터페이스 분리 원칙(ISP)과 의존성 역전 원칙(DIP)은 컴포넌트 간의 결합도를 낮추고, 더 유연하게 컴포넌트를 조합할 수 있게 도와주는 중요한 설계 원칙입니다.
SOLID 원칙에 대한 내용을 정리하고 프론트엔드 개발에서의 활용 가능성을 살펴보았습니다. SOLID 원칙이 법칙이 아니라 원칙이기 때문에, 적용하는 기준이 상황과 개인에 따라 달라질 수 있고, 그 기준이 명확하지 않기 때문에 어렵게 느껴지기는 합니다.
하지만 꾸준히 SOLID 원칙을 염두에 두고 사고하며 개발에 임하면 좋은 코드를 작성하는 개발자로 성장할 수 있을 것이라 생각하기에 항상 인지하며 개발하려고 노력하고 있습니다.