들어가며
객체지향 프로그래밍에서 클래스를 설계할 때 우리는 종종 단순히 "어떤 속성과 메서드가 필요한가?"라는 구현 중심적 사고에 빠지곤 합니다. 하지만 좋은 클래스 설계를 위해서는 보다 체계적이고 다각도적인 접근이 필요합니다.
개념 관점 설계는 도메인 안에 존재하는 개념과 개념들 상의 관계를 표현합니다. 도메인이란 사용자들이 관심을 가지고 있는 특정 분야나 주제를 말하며 소프트웨어는 도메인에 존재하는 문제를 해결하기 위해 개발됩니다. 이 관점은 사용자가 도메인을 바라보는 관점을 반영합니다. 따라서 실제 도메인 규칙과 제약을 최대한 유사하게 반영하는 것이 핵심입니다.
명세 관점에 이르면 사용자의 영역인 도메인을 벗어나 개발자의 영역인 소프트웨어로 초점이 옮겨집니다. 명세 관점은 도메인의 개념이 아니라 실제로 소프트웨어 안에서 살아 숨쉬는 객체들의 책임에 초점을 맞추게 됩니다. 즉 객체의 인터페이스를 바라보게 됩니다. 명세 관점에서 프로그래머는 객체가 협력을 위해 무엇을 할 수 있는가에 초점을 맞춥니다.
구현 관점은 프로그래머에게 가장 익숙한 관점으로 실제 작업을 수행하는 코드와 연관 돼있습니다. 구현 관점의 초점은 객체들이 책임을 수행하는 데 필요한 동작하는 코드를 작성하는 것입니다. 따라서 프로그래머는 객체의 책임을 어떻게 수행할 것인가에 초점을 맞추며 인터페이스를 구현하는 데 필요한 속성과 메서드를 클래스에 추가합니다.
클래스를 세가지 관점으로 바라봐야합니다. 클래스가 은유하는 개념은 도메인 관점을 반영합니다. 클래스의 공통 인터페이스는 명세 관점을 반영합니다. 클래스의 속성과 메서드는 구현 관점을 반영합니다. 클래스는 세 가지 관점을 모두 수용할 수 있도록 개념 인터페이스 구현을 함께 드러내야 하며 코드 안에서 세가지 관점을 쉽게 식별할 수 있도록 깔끔하게 분리해야 합니다.
이제 각 관점을 자세히 살펴보고, 온라인 도서관 시스템을 예시로 하여 TypeScript 코드를 통해 실제로 어떻게 적용되는지 알아보겠습니다.
이 세 가지 관점을 친숙한 커피숍 시나리오를 통해 알아보겠습니다. 고객이 커피를 주문하고 바리스타가 커피를 만드는 과정을 TypeScript로 구현하면서, 각 관점이 어떻게 다르게 접근하는지 살펴보겠습니다.
1. 개념 관점: 도메인의 언어를 코드로 옮기기
개념 관점은 도메인 안에 존재하는 개념과 개념들 상호간의 관계를 표현합니다. 커피숍이라는 도메인에서 사용자(고객, 바리스타, 매니저)들이 사용하는 용어와 규칙을 그대로 코드에 반영하는 것이 핵심입니다.
커피숍 도메인에서 우리가 다루어야 할 핵심 개념들을 살펴보겠습니다:
// 개념 관점: 현실 세계의 '커피' 개념을 코드로 은유
class Coffee {
name: string; // 아메리카노, 라떼 등
size: 'Small' | 'Medium' | 'Large';
temperature: 'Hot' | 'Iced';
price: number;
constructor(name: string, size: 'Small' | 'Medium' | 'Large', temperature: 'Hot' | 'Iced', price: number) {
this.name = name;
this.size = size;
this.temperature = temperature;
this.price = price;
}
}
// 개념 관점: 현실 세계의 '고객' 개념을 표현
class Customer {
customerId: string;
name: string;
phoneNumber: string;
loyaltyPoints: number;
constructor(customerId: string, name: string, phoneNumber: string) {
this.customerId = customerId;
this.name = name;
this.phoneNumber = phoneNumber;
this.loyaltyPoints = 0;
}
}
// 개념 관점: '바리스타'라는 역할을 가진 사람을 표현
class Barista {
baristaId: string;
name: string;
skillLevel: 'Beginner' | 'Intermediate' | 'Expert';
isWorking: boolean;
constructor(baristaId: string, name: string, skillLevel: 'Beginner' | 'Intermediate' | 'Expert') {
this.baristaId = baristaId;
this.name = name;
this.skillLevel = skillLevel;
this.isWorking = false;
}
}
// 개념 관점: '주문'이라는 비즈니스 개념을 표현
class Order {
orderId: string;
customer: Customer;
coffee: Coffee;
orderTime: Date;
status: 'Pending' | 'InProgress' | 'Completed' | 'Cancelled';
constructor(orderId: string, customer: Customer, coffee: Coffee) {
this.orderId = orderId;
this.customer = customer;
this.coffee = coffee;
this.orderTime = new Date();
this.status = 'Pending';
}
}
개념 관점에서는 클래스명, 속성명, 관계가 모두 도메인 전문가나 사용자가 사용하는 언어와 일치합니다. 커피숍에서 일하는 사람이나 커피를 주문하는 고객이 봐도 직관적으로 이해할 수 있는 구조입니다.
2. 명세 관점: 객체의 책임과 협력 정의하기
명세 관점에서는 도메인을 벗어나 소프트웨어 안에서 살아 숨쉬는 객체들의 책임에 초점을 맞춥니다. 각 객체가 다른 객체와 협력하기 위해 "무엇을 할 수 있는가"를 인터페이스를 통해 명시합니다.
// 명세 관점: 커피가 시스템 내에서 수행해야 할 책임들을 정의
interface CoffeeSpecification {
getCoffeeInfo(): CoffeeInfo;
calculateTotalPrice(): number;
isAvailable(): boolean;
}
interface CoffeeInfo {
name: string;
size: string;
temperature: string;
basePrice: number;
}
class Coffee implements CoffeeSpecification {
private name: string;
private size: 'Small' | 'Medium' | 'Large';
private temperature: 'Hot' | 'Iced';
private basePrice: number;
private isInStock: boolean = true;
constructor(name: string, size: 'Small' | 'Medium' | 'Large', temperature: 'Hot' | 'Iced', basePrice: number) {
this.name = name;
this.size = size;
this.temperature = temperature;
this.basePrice = basePrice;
}
// 명세 관점: 커피 정보를 안전하게 제공하는 인터페이스
getCoffeeInfo(): CoffeeInfo {
return {
name: this.name,
size: this.size,
temperature: this.temperature,
basePrice: this.basePrice
};
}
// 명세 관점: 사이즈에 따른 가격 계산 책임
calculateTotalPrice(): number {
const sizeMultiplier = {
'Small': 1.0,
'Medium': 1.2,
'Large': 1.4
};
return this.basePrice * sizeMultiplier[this.size];
}
// 명세 관점: 재고 확인 책임
isAvailable(): boolean {
return this.isInStock;
}
}
// 명세 관점: 고객이 시스템에서 수행할 수 있는 행동들을 정의
interface CustomerSpecification {
placeOrder(coffee: Coffee): Order;
payForOrder(order: Order, paymentMethod: string): boolean;
earnLoyaltyPoints(amount: number): void;
getCustomerInfo(): CustomerInfo;
}
interface CustomerInfo {
customerId: string;
name: string;
loyaltyPoints: number;
}
class Customer implements CustomerSpecification {
private customerId: string;
private name: string;
private phoneNumber: string;
private loyaltyPoints: number;
private orderHistory: Order[] = [];
constructor(customerId: string, name: string, phoneNumber: string) {
this.customerId = customerId;
this.name = name;
this.phoneNumber = phoneNumber;
this.loyaltyPoints = 0;
}
// 명세 관점: 주문을 생성하는 책임
placeOrder(coffee: Coffee): Order {
if (!coffee.isAvailable()) {
throw new Error('Coffee is not available');
}
const order = new Order(
`ORDER_${Date.now()}`,
this,
coffee
);
this.orderHistory.push(order);
return order;
}
// 명세 관점: 결제를 처리하는 책임
payForOrder(order: Order, paymentMethod: string): boolean {
const amount = order.coffee.calculateTotalPrice();
if (this.processPayment(amount, paymentMethod)) {
this.earnLoyaltyPoints(Math.floor(amount));
return true;
}
return false;
}
// 명세 관점: 적립금을 쌓는 책임
earnLoyaltyPoints(amount: number): void {
this.loyaltyPoints += amount;
}
// 명세 관점: 고객 정보를 제공하는 책임
getCustomerInfo(): CustomerInfo {
return {
customerId: this.customerId,
name: this.name,
loyaltyPoints: this.loyaltyPoints
};
}
private processPayment(amount: number, paymentMethod: string): boolean {
// 결제 처리 로직은 구현 관점에서 다룰 예정
return true;
}
}
// 명세 관점: 바리스타가 시스템에서 수행해야 할 책임들을 정의
interface BaristaSpecification {
startWorking(): void;
stopWorking(): void;
makeOrder(order: Order): void;
canMakeOrder(order: Order): boolean;
}
class Barista implements BaristaSpecification {
private baristaId: string;
private name: string;
private skillLevel: 'Beginner' | 'Intermediate' | 'Expert';
private isWorking: boolean = false;
private currentOrders: Order[] = [];
constructor(baristaId: string, name: string, skillLevel: 'Beginner' | 'Intermediate' | 'Expert') {
this.baristaId = baristaId;
this.name = name;
this.skillLevel = skillLevel;
}
// 명세 관점: 근무 시작/종료 책임
startWorking(): void {
this.isWorking = true;
}
stopWorking(): void {
this.isWorking = false;
}
// 명세 관점: 주문을 제작하는 책임
makeOrder(order: Order): void {
if (!this.canMakeOrder(order)) {
throw new Error('Cannot make this order');
}
order.status = 'InProgress';
this.currentOrders.push(order);
// 실제 커피 제작 시간 시뮬레이션
setTimeout(() => {
order.status = 'Completed';
this.currentOrders = this.currentOrders.filter(o => o.orderId !== order.orderId);
}, this.getPreparationTime(order.coffee));
}
// 명세 관점: 주문 제작 가능 여부를 판단하는 책임
canMakeOrder(order: Order): boolean {
return this.isWorking && this.currentOrders.length < this.getMaxConcurrentOrders();
}
private getMaxConcurrentOrders(): number {
const limits = {
'Beginner': 2,
'Intermediate': 4,
'Expert': 6
};
return limits[this.skillLevel];
}
private getPreparationTime(coffee: Coffee): number {
// 구현 관점에서 자세히 다룰 예정
return 3000; // 3초
}
}
명세 관점에서는 public 인터페이스를 통해 객체의 책임을 명확히 정의하고, 캡슐화를 통해 내부 구현을 보호합니다. 각 객체가 외부와 어떻게 협력할지를 규정하여 시스템의 전체적인 구조를 만들어갑니다.
3. 구현 관점: 실제 동작하는 코드 작성하기
구현 관점은 정의된 책임을 실제로 어떻게 수행할 것인지에 초점을 맞춥니다. 알고리즘, 자료구조, 성능 최적화, 예외 처리 등 실제 동작하는 코드를 작성합니다.
// 구현 관점: 커피숍 시스템의 실제 운영 로직을 구현
class CoffeeShopSystem {
private menu: Map<string, Coffee> = new Map(); // 구현: 빠른 검색을 위한 Map 사용
private customers: Map<string, Customer> = new Map();
private baristas: Map<string, Barista> = new Map();
private orders: Map<string, Order> = new Map();
private orderQueue: Order[] = []; // 구현: 주문 대기열을 배열로 관리
private dailySales: number = 0;
constructor() {
// 구현: 시스템 초기화 시 기본 메뉴 등록
this.initializeMenu();
}
// 구현 관점: 메뉴 초기화 로직
private initializeMenu(): void {
const menuItems = [
{ name: 'Americano', basePrice: 4000 },
{ name: 'Latte', basePrice: 4500 },
{ name: 'Cappuccino', basePrice: 4500 },
{ name: 'Mocha', basePrice: 5000 }
];
menuItems.forEach(item => {
['Small', 'Medium', 'Large'].forEach(size => {
['Hot', 'Iced'].forEach(temp => {
const key = `${item.name}_${size}_${temp}`;
const coffee = new Coffee(
item.name,
size as 'Small' | 'Medium' | 'Large',
temp as 'Hot' | 'Iced',
item.basePrice
);
this.menu.set(key, coffee);
});
});
});
}
// 구현 관점: 고객 등록의 실제 구현
registerCustomer(name: string, phoneNumber: string): string {
// 구현: 고유 ID 생성 알고리즘
const customerId = this.generateCustomerId();
// 구현: 전화번호 중복 체크 로직
const existingCustomer = Array.from(this.customers.values())
.find(customer => customer['phoneNumber'] === phoneNumber);
if (existingCustomer) {
throw new Error('Customer with this phone number already exists');
}
// 구현: 전화번호 형식 검증
if (!this.isValidPhoneNumber(phoneNumber)) {
throw new Error('Invalid phone number format');
}
const customer = new Customer(customerId, name, phoneNumber);
this.customers.set(customerId, customer);
return customerId;
}
// 구현 관점: 주문 처리 시스템의 실제 구현
processOrder(customerId: string, coffeeName: string, size: string, temperature: string): string {
// 구현: 입력값 검증
const customer = this.customers.get(customerId);
if (!customer) {
throw new Error('Customer not found');
}
const coffeeKey = `${coffeeName}_${size}_${temperature}`;
const coffee = this.menu.get(coffeeKey);
if (!coffee) {
throw new Error('Coffee not available');
}
// 구현: 주문 생성 및 대기열 추가
const order = customer.placeOrder(coffee);
this.orders.set(order.orderId, order);
this.orderQueue.push(order);
// 구현: 자동으로 사용 가능한 바리스타에게 주문 할당
this.assignOrderToBarista(order);
return order.orderId;
}
// 구현 관점: 주문 할당 알고리즘
private assignOrderToBarista(order: Order): void {
// 구현: 가장 적합한 바리스타 찾기 로직
const availableBaristas = Array.from(this.baristas.values())
.filter(barista => barista.canMakeOrder(order))
.sort((a, b) => {
// 구현: 스킬 레벨 순으로 정렬 (Expert > Intermediate > Beginner)
const skillOrder = { 'Expert': 3, 'Intermediate': 2, 'Beginner': 1 };
return skillOrder[b['skillLevel']] - skillOrder[a['skillLevel']];
});
if (availableBaristas.length > 0) {
const assignedBarista = availableBaristas[0];
assignedBarista.makeOrder(order);
// 구현: 주문 대기열에서 제거
this.orderQueue = this.orderQueue.filter(o => o.orderId !== order.orderId);
}
}
// 구현 관점: 결제 처리 시스템
processPayment(orderId: string, paymentMethod: 'Card' | 'Cash' | 'Mobile'): boolean {
const order = this.orders.get(orderId);
if (!order) {
throw new Error('Order not found');
}
const amount = order.coffee.calculateTotalPrice();
// 구현: 결제 방법별 처리 로직
let paymentSuccess = false;
switch (paymentMethod) {
case 'Card':
paymentSuccess = this.processCardPayment(amount);
break;
case 'Cash':
paymentSuccess = this.processCashPayment(amount);
break;
case 'Mobile':
paymentSuccess = this.processMobilePayment(amount);
break;
}
if (paymentSuccess) {
// 구현: 매출 집계
this.dailySales += amount;
// 구현: 고객 적립금 지급
order.customer.earnLoyaltyPoints(Math.floor(amount / 100));
return true;
}
return false;
}
// 구현 관점: 실시간 주문 상태 모니터링
getOrderStatus(orderId: string): { status: string; estimatedTime?: number } {
const order = this.orders.get(orderId);
if (!order) {
throw new Error('Order not found');
}
// 구현: 주문 상태별 예상 시간 계산
switch (order.status) {
case 'Pending':
const queuePosition = this.orderQueue.findIndex(o => o.orderId === orderId) + 1;
return {
status: order.status,
estimatedTime: queuePosition * 5 // 구현: 주문당 평균 5분 예상
};
case 'InProgress':
return {
status: order.status,
estimatedTime: 3 // 구현: 제작 중일 때는 3분 예상
};
case 'Completed':
return { status: order.status };
default:
return { status: order.status };
}
}
// 구현 관점: 매출 분석 및 보고서 생성
generateDailySalesReport(): SalesReport {
const totalOrders = Array.from(this.orders.values())
.filter(order => {
const today = new Date().toDateString();
return order.orderTime.toDateString() === today;
});
// 구현: 커피별 판매량 집계 알고리즘
const salesByType = totalOrders.reduce((acc, order) => {
const coffeeName = order.coffee.getCoffeeInfo().name;
acc[coffeeName] = (acc[coffeeName] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// 구현: 시간대별 판매량 분석
const salesByHour = totalOrders.reduce((acc, order) => {
const hour = order.orderTime.getHours();
acc[hour] = (acc[hour] || 0) + 1;
return acc;
}, {} as Record<number, number>);
return {
date: new Date().toDateString(),
totalSales: this.dailySales,
totalOrders: totalOrders.length,
salesByType,
salesByHour,
averageOrderValue: totalOrders.length > 0 ? this.dailySales / totalOrders.length : 0
};
}
// 구현 관점: 각종 검증 및 유틸리티 메서드들
private generateCustomerId(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substr(2, 9);
return `CUST_${timestamp}_${random}`.toUpperCase();
}
private isValidPhoneNumber(phoneNumber: string): boolean {
// 구현: 한국 전화번호 형식 검증 정규식
const phoneRegex = /^01[0-9]-?[0-9]{4}-?[0-9]{4}$/;
return phoneRegex.test(phoneNumber);
}
private processCardPayment(amount: number): boolean {
// 구현: 실제 카드 결제 API 연동 로직이 들어갈 곳
console.log(`Processing card payment: ${amount}원`);
// 실제 구현에서는 PG사 API 연동
// return await paymentGateway.processCard({ amount, merchantId, ... });
return Math.random() > 0.1; // 90% 성공률로 시뮬레이션
}
private processCashPayment(amount: number): boolean {
// 구현: 현금 결제 처리 (항상 성공)
console.log(`Processing cash payment: ${amount}원`);
return true;
}
private processMobilePayment(amount: number): boolean {
// 구현: 모바일 결제 API 연동
console.log(`Processing mobile payment: ${amount}원`);
return Math.random() > 0.05; // 95% 성공률로 시뮬레이션
}
}
// 구현 관점: 매출 보고서 데이터 구조
interface SalesReport {
date: string;
totalSales: number;
totalOrders: number;
salesByType: Record<string, number>;
salesByHour: Record<number, number>;
averageOrderValue: number;
}
구현 관점에서는 실제 알고리즘과 자료구조 선택, 성능 최적화와 효율성 고려, 에러 처리와 예외 상황 관리, 외부 시스템과의 연동 로직 등을 다룹니다.
세 관점의 조화로운 통합
좋은 클래스 설계는 이 세 관점이 명확히 분리되면서도 조화롭게 통합되어야 합니다. 다음은 세 관점이 어떻게 하나의 클래스에서 조화를 이루는지 보여주는 예시입니다:
class Order {
// 개념 관점: 도메인 개념을 반영한 속성들
private orderId: string;
private customer: Customer;
private coffee: Coffee;
private orderTime: Date;
private status: 'Pending' | 'InProgress' | 'Completed' | 'Cancelled';
// 구현 관점: 내부 상태 관리를 위한 속성들
private paymentMethod?: string;
private discountApplied: number = 0;
private statusHistory: StatusChange[] = [];
constructor(orderId: string, customer: Customer, coffee: Coffee) {
this.orderId = orderId;
this.customer = customer;
this.coffee = coffee;
this.orderTime = new Date();
this.status = 'Pending';
this.addStatusChange('Pending', new Date());
}
// 명세 관점: 외부 협력을 위한 public 인터페이스
public getOrderInfo(): OrderInfo {
return {
orderId: this.orderId,
customerName: this.customer.getCustomerInfo().name,
coffeeInfo: this.coffee.getCoffeeInfo(),
status: this.status,
orderTime: this.orderTime
};
}
public updateStatus(newStatus: 'Pending' | 'InProgress' | 'Completed' | 'Cancelled'): void {
if (!this.isValidStatusTransition(this.status, newStatus)) {
throw new Error(`Invalid status transition from ${this.status} to ${newStatus}`);
}
this.status = newStatus;
this.addStatusChange(newStatus, new Date());
}
public calculateFinalPrice(): number {
const basePrice = this.coffee.calculateTotalPrice();
return basePrice - this.discountApplied;
}
// 구현 관점: private 메서드로 내부 구현 세부사항 캡슐화
private isValidStatusTransition(current: string, next: string): boolean {
const validTransitions: Record<string, string[]> = {
'Pending': ['InProgress', 'Cancelled'],
'InProgress': ['Completed', 'Cancelled'],
'Completed': [],
'Cancelled': []
};
return validTransitions[current]?.includes(next) || false;
}
private addStatusChange(status: string, timestamp: Date): void {
this.statusHistory.push({ status, timestamp });
}
}
interface StatusChange {
status: string;
timestamp: Date;
}
interface OrderInfo {
orderId: string;
customerName: string;
coffeeInfo: CoffeeInfo;
status: string;
orderTime: Date;
}
마무리 : 세 관점의 가치
객체지향 클래스 설계에서 세 가지 관점을 의식적으로 분리하여 설계하면 다음과 같은 이점을 얻을 수 있습니다:
개념 관점을 통해 도메인과 일치하는 직관적인 코드를 작성할 수 있습니다. 커피숍에서 일하는 사람이나 시스템을 사용하는 사람이 코드를 보더라도 쉽게 이해할 수 있습니다.
명세 관점을 통해 객체 간의 명확한 협력 관계를 정의할 수 있습니다. 고객이 주문하고, 바리스타가 커피를 만들고, 시스템이 이를 관리하는 책임이 명확히 분리됩니다.
구현 관점을 통해 효율적이고 안정적인 실제 동작 코드를 작성할 수 있습니다. 결제 처리, 주문 대기열 관리, 성능 최적화 등의 기술적 세부사항이 적절히 구현됩니다.
이러한 관점의 분리는 코드의 가독성, 유지보수성, 확장성을 크게 향상시키며, 개발팀 내에서의 의사소통도 원활하게 만들어줍니다. 각 관점이 서로 다른 목적과 관심사를 가지고 있음을 인식하고, 이를 적절히 분리하면서도 조화롭게 통합하는 것이 좋은 객체지향 설계의 핵심입니다.
'programming' 카테고리의 다른 글
| HTTP 캐싱 가이드 (0) | 2025.12.17 |
|---|---|
| 견고한 소프트웨어를 위한 오류 핸들링 (0) | 2024.07.01 |
| [설계] 오용하기 어려운 코드 설계하기 (0) | 2024.06.30 |
| [설계] 단일 책임 원칙에 근거한 모듈 설계 (0) | 2024.06.28 |