들어가며
소프트웨어 개발에서 가장 중요한 개념 중 하나는 추상화(Abstraction)입니다. 우리가 매일 작성하는 코드에서 함수를 만들고, 컴포넌트를 분리하고, 모듈을 구성하는 모든 행위가 바로 추상화의 과정입니다.
하지만 단순히 코드를 나누는 것만으로는 충분하지 않습니다. 적절한 추상화 수준을 유지하고, 동등한 수준의 추상화를 함께 배치해야만 읽기 쉽고 유지보수가 가능한 코드를 작성할 수 있습니다.
이 글에서는 React와 TypeScript를 사용한 실제 예제를 통해 추상화의 핵심 개념들을 살펴보겠습니다.
1. 추상화를 하는 이유는 무엇인가?
추상화는 복잡한 시스템을 이해하고 관리하기 위한 핵심 도구입니다. 다음과 같은 이유로 추상화가 필요합니다:
복잡성 관리
우리의 뇌는 한 번에 처리할 수 있는 정보의 양이 제한적입니다. 추상화를 통해 맥락 파악에 불필요한 세부사항을 숨기고 핵심 개념에 집중할 수 있습니다.
// ❌ 추상화 없는 코드 - 모든 세부사항이 노출됨
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/user')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
})
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<img src={user.avatar} alt="Avatar" />
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// ✅ 추상화를 적용한 코드 - 핵심 로직에 집중
function UserProfile() {
const { user, loading, error } = useUser();
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <EmptyState message="No user found" />;
return <UserCard user={user} />;
}
재사용성 증대
일반화를 통해 추상화된 컴포넌트와 함수는 다양한 상황에서 재사용할 수 있습니다.
// 추상화된 커스텀 훅
function useApiData<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, [url]);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('API request failed');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
return { data, loading, error, refetch: fetchData };
}
// 다양한 곳에서 재사용 가능
const { data: users } = useApiData<User[]>('/api/users');
const { data: posts } = useApiData<Post[]>('/api/posts');
변경에 대한 영향 범위 제한
추상화는 변경사항이 시스템 전체에 미치는 영향을 제한합니다.
이는 객체를 참조하지 말고 추상화된 인터페이스를 참조하라는 객체지향 SOLID의 DIP에 해당합니다.
// API 통신 로직을 추상화
class UserService {
private static baseUrl = '/api/users';
static async getUser(id: string): Promise<User> {
const response = await fetch(`${this.baseUrl}/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
static async updateUser(id: string, data: Partial<User>): Promise<User> {
const response = await fetch(`${this.baseUrl}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Failed to update user');
return response.json();
}
}
// API 변경 시 UserService만 수정하면 됨
2. 추상화 수준은 무엇인가?
추상화 수준이란 얼마나 구체적인지 또는 얼마나 추상적인지를 나타내는 정도입니다. 같은 기능이라도 바라보는 관점에 따라 다른 추상화 수준에서 표현될 수 있습니다.
추상화 수준의 계층
// 높은 추상화 수준 - 비즈니스 로직에 집중
function ShoppingCart() {
const { items, total, addItem, removeItem } = useCart();
return (
<div>
<CartItemList items={items} onRemove={removeItem} />
<CartSummary total={total} />
<CheckoutButton />
</div>
);
}
// 중간 추상화 수준 - UI 컴포넌트 조합
function CartItemList({ items, onRemove }: CartItemListProps) {
return (
<ul>
{items.map(item => (
<CartItem
key={item.id}
item={item}
onRemove={() => onRemove(item.id)}
/>
))}
</ul>
);
}
// 낮은 추상화 수준 - 구체적인 UI 구현
function CartItem({ item, onRemove }: CartItemProps) {
return (
<li className="cart-item">
<img src={item.image} alt={item.name} />
<div className="item-info">
<h3>{item.name}</h3>
<span>${item.price}</span>
</div>
<button onClick={onRemove}>Remove</button>
</li>
);
}
적절한 추상화 수준 선택하기
// ❌ 너무 높은 추상화 - 구체적인 제어가 어려움
function MagicComponent({ data }: { data: any }) {
// 내부에서 모든 것을 처리하려고 함
return <div>Magic happens here</div>;
}
// ❌ 너무 낮은 추상화 - 세부사항에 매몰됨
function UserName({ firstName, lastName, middleName, prefix, suffix }: UserNameProps) {
return (
<span>
{prefix && `${prefix} `}
{firstName}
{middleName && ` ${middleName}`}
{` ${lastName}`}
{suffix && ` ${suffix}`}
</span>
);
}
// ✅ 적절한 추상화 수준
interface User {
name: {
first: string;
last: string;
display: string; // 이미 포맷된 이름
};
}
function UserName({ user }: { user: User }) {
return <span>{user.name.display}</span>;
}
3. 왜 동등한 수준의 추상화 모듈을 함께 배치해야 하는가?
동등한 추상화 수준을 유지하는 것은 코드의 가독성과 이해하기 쉬운 구조를 만들기 위해 필수적입니다.
핵심은 각 모듈이 명확한 역할을 가지고, 세부적인 구현은 내부에 위임하며, 자신의 역할과 관련된 부분만 외부에 노출하는 것입니다.
모듈의 역할과 캡슐화
동등한 추상화 수준을 유지하려면 각 모듈이 단 하나의 명확한 역할을 가져야 합니다. 그리고 그 역할과 관련되지 않은 세부사항은 내부에 숨기고, 필요한 인터페이스만 노출해야 합니다.
// ❌ 역할이 섞여있는 컴포넌트
function UserDashboard() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
// 데이터 페칭 로직 (낮은 수준)
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
});
}, []);
// 데이터 변환 로직 (중간 수준)
const formatUserData = (user) => ({
...user,
displayName: `${user.firstName} ${user.lastName}`,
isActive: user.lastLoginDate > Date.now() - 30 * 24 * 60 * 60 * 1000
});
// UI 렌더링 로직 (높은 수준)
return (
<div>
<h1>User Dashboard</h1>
{loading ? (
<div>Loading...</div>
) : (
<div>
{users.map(user => {
const formattedUser = formatUserData(user);
return (
<div key={user.id}>
<span>{formattedUser.displayName}</span>
<span>{formattedUser.isActive ? '활성' : '비활성'}</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ✅ 역할별로 분리하고 적절히 캡슐화
// 1. 데이터 관리의 역할 - 세부 구현은 내부에 숨김
function useUsers() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
setLoading(true);
setError(null);
try {
// 복잡한 데이터 페칭 로직은 내부에 숨김
const response = await UserService.getUsers();
const processedUsers = response.map(processUserData);
setUsers(processedUsers);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
// 데이터 변환 로직도 내부에 캡슐화
const processUserData = (user: RawUser): User => ({
...user,
displayName: `${user.firstName} ${user.lastName}`,
isActive: user.lastLoginDate > Date.now() - 30 * 24 * 60 * 60 * 1000
});
// 역할과 관련된 인터페이스만 노출
return { users, loading, error, refetch: fetchUsers };
}
// 2. UI 표시의 역할 - 사용자 정보 표시에만 집중
function UserCard({ user }: { user: User }) {
return (
<div className="user-card">
<span className="user-name">{user.displayName}</span>
<span className={`status ${user.isActive ? 'active' : 'inactive'}`}>
{user.isActive ? '활성' : '비활성'}
</span>
</div>
);
}
// 3. 대시보드 조합의 역할 - 컴포넌트 구성에만 집중
function UserDashboard() {
const { users, loading, error } = useUsers();
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<DashboardHeader title="User Dashboard" />
<UserList users={users} />
</div>
);
}
역할 기반 모듈 설계
각 모듈은 자신의 역할에만 집중하고, 다른 역할의 세부사항을 알 필요가 없어야 합니다.
// 인증 관리의 역할만 담당
class AuthService {
private static token: string | null = null;
// 인증과 관련된 기능만 노출
static async login(credentials: LoginCredentials): Promise<User> {
const response = await this.makeAuthRequest('/auth/login', credentials);
this.token = response.token;
this.storeToken(response.token); // 내부 구현 숨김
return response.user;
}
static logout(): void {
this.token = null;
this.clearStoredToken(); // 내부 구현 숨김
this.redirectToLogin(); // 내부 구현 숨김
}
static isAuthenticated(): boolean {
return this.token !== null && this.isTokenValid(); // 내부 구현 숨김
}
// 내부 구현 - 외부에서 접근할 필요 없음
private static storeToken(token: string): void {
localStorage.setItem('auth_token', token);
}
private static clearStoredToken(): void {
localStorage.removeItem('auth_token');
}
private static isTokenValid(): boolean {
// 토큰 유효성 검사 로직
return true;
}
}
// API 통신의 역할만 담당
class ApiClient {
private static baseURL = process.env.REACT_APP_API_URL;
// HTTP 통신과 관련된 기능만 노출
static async get<T>(endpoint: string): Promise<T> {
return this.makeRequest('GET', endpoint);
}
static async post<T>(endpoint: string, data: unknown): Promise<T> {
return this.makeRequest('POST', endpoint, data);
}
// 내부 구현 - 세부적인 HTTP 처리 로직 숨김
private static async makeRequest<T>(
method: string,
endpoint: string,
data?: unknown
): Promise<T> {
const headers = this.buildHeaders(); // 내부 구현
const config = this.buildConfig(method, headers, data); // 내부 구현
const response = await fetch(`${this.baseURL}${endpoint}`, config);
return this.handleResponse<T>(response); // 내부 구현
}
private static buildHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
const token = AuthService.getToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
}
// 비즈니스 로직의 역할만 담당
class UserService {
// 사용자 관리와 관련된 비즈니스 로직만 노출
static async getUsers(): Promise<User[]> {
const rawUsers = await ApiClient.get<RawUser[]>('/users');
return rawUsers.map(this.transformUser); // 내부 변환 로직
}
static async createUser(userData: CreateUserRequest): Promise<User> {
this.validateUserData(userData); // 내부 검증 로직
const rawUser = await ApiClient.post<RawUser>('/users', userData);
return this.transformUser(rawUser);
}
// 내부 구현 - 데이터 변환 로직 숨김
private static transformUser(rawUser: RawUser): User {
return {
id: rawUser.id,
name: rawUser.name,
email: rawUser.email,
displayName: `${rawUser.firstName} ${rawUser.lastName}`,
isActive: this.calculateActiveStatus(rawUser.lastLoginDate)
};
}
private static validateUserData(userData: CreateUserRequest): void {
if (!userData.email.includes('@')) {
throw new Error('Invalid email format');
}
// 기타 검증 로직...
}
}
추상화 수준 혼재의 문제점
// ❌ 서로 다른 추상화 수준이 혼재됨
function OrderProcess() {
const [order, setOrder] = useState(null);
// 높은 수준의 비즈니스 로직
const processOrder = () => {
validateOrder();
calculateTotal();
submitOrder();
};
// 낮은 수준의 DOM 조작
const submitOrder = () => {
const form = document.getElementById('order-form');
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(response => {
if (response.ok) {
window.location.href = '/success';
} else {
alert('Order failed');
}
});
};
return (
<div>
<OrderForm />
<button onClick={processOrder}>Submit</button>
</div>
);
}
동등한 추상화 수준으로 정리
// ✅ 동등한 추상화 수준으로 구성
function OrderProcess() {
const { submitOrder, isSubmitting } = useOrderSubmission();
const { validateOrder } = useOrderValidation();
const handleSubmit = async () => {
const isValid = await validateOrder();
if (isValid) {
await submitOrder();
}
};
return (
<div>
<OrderForm />
<SubmitButton
onClick={handleSubmit}
loading={isSubmitting}
/>
</div>
);
}
// 각각의 관심사를 동등한 수준으로 분리
function useOrderSubmission() {
const [isSubmitting, setIsSubmitting] = useState(false);
const submitOrder = async (orderData: OrderData) => {
setIsSubmitting(true);
try {
await OrderService.submit(orderData);
NavigationService.redirectToSuccess();
} catch (error) {
NotificationService.showError('Order submission failed');
} finally {
setIsSubmitting(false);
}
};
return { submitOrder, isSubmitting };
}
function useOrderValidation() {
const validateOrder = async (): Promise<boolean> => {
// 검증 로직
return true;
};
return { validateOrder };
}
계층적 구조의 이점
// 높은 수준: 애플리케이션 로직
function App() {
return (
<Router>
<Layout>
<Routes>
<Route path="/users" element={<UserManagement />} />
<Route path="/orders" element={<OrderManagement />} />
</Routes>
</Layout>
</Router>
);
}
// 중간 수준: 페이지 수준 조합
function UserManagement() {
return (
<PageContainer>
<PageHeader title="User Management" />
<UserList />
<UserActions />
</PageContainer>
);
}
// 낮은 수준: 구체적인 UI 구현
function UserList() {
const { users, loading } = useUsers();
if (loading) return <LoadingSpinner />;
return (
<List>
{users.map(user => (
<UserListItem key={user.id} user={user} />
))}
</List>
);
}
모듈 간 의존성과 인터페이스 설계
동등한 추상화 수준을 유지하기 위해서는 모듈 간의 의존성도 신중하게 설계해야 합니다. 높은 수준의 모듈은 낮은 수준의 모듈을 사용할 수 있지만, 그 반대는 안 됩니다.
// ✅ 올바른 의존성 방향 (높은 수준 → 낮은 수준)
// 높은 수준: 비즈니스 플로우 조합
function OrderProcessPage() {
const { processOrder, isProcessing } = useOrderProcessing();
const { validatePayment } = usePaymentValidation();
const handleSubmit = async (orderData: OrderData) => {
const isPaymentValid = await validatePayment(orderData.payment);
if (isPaymentValid) {
await processOrder(orderData);
}
};
return (
<OrderForm onSubmit={handleSubmit} loading={isProcessing} />
);
}
// 중간 수준: 주문 처리 로직
function useOrderProcessing() {
const [isProcessing, setIsProcessing] = useState(false);
const processOrder = async (orderData: OrderData) => {
setIsProcessing(true);
try {
// 낮은 수준의 서비스들을 조합
const validatedOrder = OrderValidator.validate(orderData);
const savedOrder = await OrderService.create(validatedOrder);
await PaymentService.process(savedOrder.payment);
await EmailService.sendConfirmation(savedOrder);
NotificationService.showSuccess('주문이 완료되었습니다');
} catch (error) {
NotificationService.showError('주문 처리 중 오류가 발생했습니다');
} finally {
setIsProcessing(false);
}
};
return { processOrder, isProcessing };
}
// 낮은 수준: 구체적인 서비스 구현
class OrderService {
// 주문 데이터 관리에만 집중
static async create(orderData: ValidatedOrderData): Promise<Order> {
// 구체적인 API 호출 및 데이터 처리
return ApiClient.post<Order>('/orders', orderData);
}
static async findById(id: string): Promise<Order> {
return ApiClient.get<Order>(`/orders/${id}`);
}
}
class PaymentService {
// 결제 처리에만 집중
static async process(paymentData: PaymentData): Promise<PaymentResult> {
// 구체적인 결제 API 연동
return PaymentGateway.processPayment(paymentData);
}
}
// ❌ 잘못된 의존성 방향 (낮은 수준이 높은 수준을 참조)
class DatabaseService {
static async saveUser(user: User): Promise<void> {
// 낮은 수준의 서비스가 높은 수준의 UI 로직을 알고 있음 - 문제!
await this.executeQuery('INSERT INTO users...', user);
// 이런 코드는 추상화 수준을 혼재시킴
NotificationComponent.showToast('사용자가 저장되었습니다'); // ❌
UserDashboard.refreshUserList(); // ❌
}
}
// ✅ 올바른 설계 - 각 계층이 자신의 역할에만 집중
class DatabaseService {
static async saveUser(user: User): Promise<void> {
// 데이터 저장에만 집중
return this.executeQuery('INSERT INTO users...', user);
}
}
// 상위 계층에서 조합
function useUserManagement() {
const saveUser = async (user: User) => {
try {
await DatabaseService.saveUser(user); // 낮은 수준 서비스 사용
NotificationService.showSuccess('사용자가 저장되었습니다'); // 같은 수준
refreshUserList(); // 같은 수준
} catch (error) {
NotificationService.showError('저장 실패');
}
};
return { saveUser };
}
인터페이스를 통한 역할 명시
TypeScript의 인터페이스를 활용하여 각 모듈의 역할을 명확하게 정의할 수 있습니다.
// 각 계층의 역할을 인터페이스로 명시
interface UserRepository {
// 데이터 영속성의 역할
findById(id: string): Promise<User | null>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
}
interface UserService {
// 비즈니스 로직의 역할
createUser(userData: CreateUserRequest): Promise<User>;
updateUserProfile(id: string, updates: UserProfileUpdate): Promise<User>;
deactivateUser(id: string): Promise<void>;
}
// 구현체는 역할에만 집중
class DatabaseUserRepository implements UserRepository {
async findById(id: string): Promise<User | null> {
// 데이터베이스 접근 로직만 포함
const result = await db.query('SELECT * FROM users WHERE id = ?', [id]);
return result ? this.mapToUser(result) : null;
}
// 다른 역할의 로직은 포함하지 않음
}
class UserBusinessService implements UserService {
constructor(private userRepo: UserRepository) {} // 의존성 주입
async createUser(userData: CreateUserRequest): Promise<User> {
// 비즈니스 규칙과 검증 로직만 포함
this.validateUserData(userData);
const user = new User(userData);
return this.userRepo.save(user); // 낮은 수준 서비스 활용
}
// UI나 HTTP 관련 로직은 포함하지 않음
}
마무리
추상화는 복잡한 소프트웨어 시스템을 관리하기 위한 핵심 도구입니다. 적절한 추상화를 통해 우리는:
- 복잡성을 관리하고 핵심 로직에 집중할 수 있습니다
- 재사용 가능한 컴포넌트를 만들어 개발 효율성을 높일 수 있습니다
- 변경에 대한 영향 범위를 제한하여 유지보수성을 향상시킬 수 있습니다
추상화 수준을 일관되게 유지하는 것은 코드의 가독성과 이해하기 쉬운 구조를 만드는 데 필수적입니다. 핵심은 각 모듈이 명확한 단일 역할을 가지고, 세부 구현은 내부에 캡슐화하며, 자신의 역할과 관련된 인터페이스만 외부에 노출하는 것입니다.
같은 함수나 모듈 내에서는 동등한 수준의 추상화를 유지하고, 서로 다른 추상화 수준은 명확히 분리해야 합니다. 또한 모듈 간의 의존성 방향을 올바르게 설계하여 높은 수준의 모듈이 낮은 수준의 모듈을 사용하도록 해야 합니다.
React와 TypeScript를 사용할 때도 이러한 원칙들을 적용하여, 커스텀 훅으로 비즈니스 로직을 추상화하고, 컴포넌트를 적절한 수준으로 분리하며, 서비스 계층을 통해 데이터 처리 로직을 캡슐화하는 것이 중요합니다.
좋은 추상화는 시간이 지나도 변하지 않는 핵심 개념을 찾아내고, 그것을 중심으로 시스템을 구성하는 것입니다. 각 모듈의 역할을 명확히 정의하고 적절히 캡슐화함으로써 오래도록 유지보수 가능한 코드를 작성할 수 있습니다.
'react' 카테고리의 다른 글
| 리액트에서 API 통신 추상화하기 (3) | 2025.08.23 |
|---|---|
| Next.js에서 SearchParams 상태 관리: Context vs nuqs (3) | 2025.08.21 |
| 컴포넌트 설계: 시나리오별 인터페이스 분리 전략 (2) | 2025.08.17 |
| React에서 선언적 표현을 위한 모듈 격리: 클로저 디펜던시 문제 해결하기 (3) | 2025.08.09 |
| 바퀴의 재발명을 멈추고 비즈니스 로직에 집중하기 (5) | 2025.08.09 |