들어가며
소프트웨어는 수많은 객체와 컴포넌트가 협력하며 동작합니다.
이때 중요한 것은 각 객체가 어떻게 협력하느냐입니다.
만약 협력이 구체적인 구현에 강하게 결합되어 있다면, 작은 변경에도 전체 시스템이 흔들릴 수 있습니다.
반대로 협력이 메시지 중심으로 이루어진다면, 내부 구현이 달라지더라도 전체 흐름은 안정적이고 유연하게 유지됩니다.
이번 글에서는 객체지향에서 말하는 메시지와 메서드의 차이, 그리고 이를 통해 만들어지는 느슨한 결합과 다형성을 두 가지 예시로 살펴봅니다.
- TypeScript로 구현한 결제 서비스
- React의 실무 ErrorBoundary 다형성
1. 객체지향 협력: 메시지와 메서드
객체지향에서 협력은 메시지(message)를 주고받으며 이루어집니다.
- 메시지: "이 일을 해달라"는 요청(what)
- 메서드: 그 메시지를 실제로 처리하는 구현(how)
이 구조 덕분에 협력자는 "무엇을 원하는지"만 알면 되고, "어떻게 처리되는지"는 몰라도 됩니다.
TypeScript 결제 서비스 예시
// 메시지 계약
interface PaymentGateway {
processPayment(amount: number): void;
}
// 협력자
class OrderService {
constructor(private gateway: PaymentGateway) {}
checkout(amount: number) {
console.log("🛒 결제를 시작합니다...");
this.gateway.processPayment(amount); // 메시지 전송
}
}
// 카드 결제 (메서드 구현)
class CreditCardPayment implements PaymentGateway {
processPayment(amount: number): void {
this.validateCard();
this.connectToBank();
this.completeTransaction(amount);
}
private validateCard() { console.log("카드 검사"); }
private connectToBank() { console.log("은행 연결"); }
private completeTransaction(a: number) { console.log(`${a}원 카드 결제 완료`); }
}
// PayPal 결제 (메서드 구현)
class PaypalPayment implements PaymentGateway {
processPayment(amount: number): void {
this.authenticateUser();
this.sendPayment(amount);
}
private authenticateUser() { console.log("PayPal 인증"); }
private sendPayment(a: number) { console.log(`${a}원 PayPal 결제 완료`); }
}
// 사용
const order1 = new OrderService(new CreditCardPayment());
order1.checkout(10000);
const order2 = new OrderService(new PaypalPayment());
order2.checkout(20000);
OrderService는 "processPayment"라는 메시지만 보냅니다.
각 결제 객체는 메시지를 자신만의 방식으로 해석하고 처리합니다.
새로운 결제 방식이 추가되더라도 OrderService는 변하지 않습니다.
즉, 메시지 중심의 협력 → 느슨한 결합 → 다형성 확보라는 객체지향의 본질을 확인할 수 있습니다.
2. React 예제: ErrorBoundary와 에러 타입별 다형성
React에서는 컴포넌트는 props를 통해 컴포넌트간 협력합니다.
여기서 props는 인터페이스(메시지)이고, 컴포넌트 내부 로직은 객체의 캡슐화된 메서드라 할 수 있습니다.
실무에서는 단순한 에러 처리를 넘어, 에러 타입별로 다른 사용자 경험을 제공해야 합니다.
아래 예시를 통해 동일한 인터페이스를 의존하는 Fallback 컴포넌트의 다형성에 대해 설명해보도록 하겠습니다.
FallbackProps 인터페이스(메시지)
interface FallbackProps {
error: Error;
resetErrorBoundary: () => void;
}
ErrorBoundary는 오류가 발생했을 때 FallbackProps라는 인터페이스에 의존합니다.
FallbackProps 에만 의존하고 있을 뿐 이에 대한구체적인 처리 방식은 fallback 컴포넌트가 자율적으로 결정할 수 있습니다.
에러 타입 분류
이 예시엣머는 에러 타입에 따른 Fallback UI를 통해 다형성에 대해 설명합니다.
export enum ErrorType {
NETWORK = 'NETWORK', // 네트워크 연결 실패
AUTH = 'AUTH', // 인증/권한 오류
DATA = 'DATA', // 데이터 파싱 오류
TIMEOUT = 'TIMEOUT', // 요청 시간 초과
UNKNOWN = 'UNKNOWN' // 예상치 못한 오류
}
export class AppError extends Error {
constructor(
message: string,
public type: ErrorType,
public retryable: boolean = true
) {
super(message);
}
}
에러 타입별 Fallback 구현
// 1. 네트워크 에러 - 재시도 중심
function NetworkErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const [retryCount, setRetryCount] = useState(0);
const handleRetry = () => {
setRetryCount(prev => prev + 1);
resetErrorBoundary();
};
return (
<div className="error-container">
<h3>연결 문제 발생</h3>
<p>네트워크 연결을 확인하고 다시 시도해주세요.</p>
<button onClick={handleRetry}>
다시 시도 ({retryCount}/3)
</button>
</div>
);
}
// 2. 인증 에러 - 로그인 리다이렉트
function AuthErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const handleLogin = () => {
window.location.href = '/login';
};
return (
<div className="error-container">
<h3>인증이 필요합니다</h3>
<p>세션이 만료되었습니다. 다시 로그인해주세요.</p>
<button onClick={handleLogin}>로그인하기</button>
</div>
);
}
// 3. 데이터 에러 - 대안 데이터 제공
function DataErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const handleLoadCache = () => {
// 캐시된 데이터 로드 로직
resetErrorBoundary();
};
return (
<div className="error-container">
<h3>데이터 로딩 실패</h3>
<p>일시적으로 데이터를 불러올 수 없습니다.</p>
<button onClick={handleLoadCache}>캐시된 데이터 보기</button>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
);
}
SmartErrorBoundary: 자동 Fallback 선택
function getAppropriateErrorComponent(error: Error) {
if (error instanceof AppError) {
switch (error.type) {
case ErrorType.NETWORK:
return NetworkErrorFallback;
case ErrorType.AUTH:
return AuthErrorFallback;
case ErrorType.DATA:
return DataErrorFallback;
default:
return GenericErrorFallback;
}
}
return GenericErrorFallback;
}
function SmartErrorBoundary({ children, context }: {
children: React.ReactNode,
context: string
}) {
return (
<ErrorBoundary
FallbackComponent={(props) => {
const FallbackComponent = getAppropriateErrorComponent(props.error);
return <FallbackComponent {...props} />;
}}
onError={(error, errorInfo) => {
// 실무에서는 Sentry 등으로 에러 로깅
console.error('Error logged:', { error, context, errorInfo });
}}
>
{children}
</ErrorBoundary>
);
}
최종 결과물
export default function App() {
return (
<div>
{/* 네트워크 의존적인 컴포넌트 */}
<SmartErrorBoundary context="user-profile">
<UserProfileComponent />
</SmartErrorBoundary>
{/* 인증이 필요한 컴포넌트 */}
<SmartErrorBoundary context="dashboard">
<DashboardComponent />
</SmartErrorBoundary>
{/* 복잡한 데이터 처리 컴포넌트 */}
<SmartErrorBoundary context="analytics">
<AnalyticsComponent />
</SmartErrorBoundary>
</div>
);
}
ErrorBoundary는 단지 메시지(FallbackProps)를 전달할 뿐입니다. 각 fallback은 메시지를 받아 에러 타입별로 다른 방식으로 처리합니다.
동일한 메시지 → 다른 결과가 발생하는 지점에서 다형성이 드러납니다.
하위 객체(컴포넌트) 대신 인터페이스를 의존함으로써 다음의 구조적 이득을 얻을 수 있습니다.
하위 객체의 세부 구현이 변경에 따른 상위 객체에 변경의 전파를 제한할 수 있습니다.
FallbackProps이라는 인터페이스에만 호환된다면 다형성을 구현할 수 있습니다.
새로운 Fallback 유형이 필요한 순간에도 상위 컴포넌트의 변경을 최소화화여 변경에 닫혀있고 확장에 열려있는 유연한 구조를 얻을 수 있었습니다.
마무리
객체지향에서 협력은 메시지와 메서드의 분리를 통해 이루어집니다.
- 메시지는 무엇을 원하는지만 표현합니다.
- 메서드는 어떻게 처리할지를 각 객체가 자율적으로 결정합니다.
TypeScript 결제 예시에서는 OrderService가 결제 객체에 "processPayment"라는 메시지를 보내고,
React 예시에서는 ErrorBoundary가 fallback 컴포넌트에 FallbackProps 인터페이스(메시지 그룹)를 전달했습니다.
두 경우 모두 협력자는 인터페이스(메시지)에만 의존하고, 구체적인 구현은 협력 객체에 위임됩니다.
이러한 느슨한 결합이 바로 객체지향의 강력한 힘이며, 변화에 유연하게 대응할 수 있는 핵심입니다.
특히 React의 실무 예시에서 보았듯이, 동일한 메시지 계약을 통해 에러 상황별로 완전히 다른 사용자 경험을 제공할 수 있습니다.
'react' 카테고리의 다른 글
| 2025년에 돌아보는 react-query (0) | 2025.12.10 |
|---|---|
| FSD Architecture TypeScript 예시 (0) | 2025.08.29 |
| [설계] 대규모 프로젝트에서 확장 가능한 의존성 설계 feat. Feature-Sliced Design (0) | 2025.08.24 |
| [설계] 리액트에서 API 통신 추상화하기 (0) | 2025.08.23 |
| Next.js에서 SearchParams 상태 관리: Context vs nuqs (3) | 2025.08.21 |