Chapter 5. 추상화 계층 설계
난이도: 🟡 중급
개요
**추상화(Abstraction)**는 소프트웨어 설계의 가장 강력한 도구 중 하나입니다. 복잡한 세부사항을 숨기고, 핵심 개념만 드러내며, 시스템을 이해하고 관리하기 쉽게 만듭니다. 지난 챕터까지 우리는 시스템을 분해하고 구조화하는 방법을 배웠습니다. 이번 주에는 그 구조를 더욱 명확하고 유지보수 가능하게 만드는 추상화 계층 설계를 학습합니다.
좋은 추상화는 "무엇을 하는가"는 명확히 드러내고, "어떻게 하는가"는 숨깁니다. 사용자는 자동차를 운전할 때 엔진의 내부 연소 과정을 알 필요가 없습니다. 핸들, 액셀, 브레이크라는 간단한 인터페이스만 이해하면 됩니다. 소프트웨어도 마찬가지입니다. 잘 설계된 API는 복잡한 구현을 숨기고, 사용하기 쉬운 인터페이스를 제공합니다.
이번 주 학습 목표:
-
추상화의 본질 이해: 왜 추상화가 필요하고, 어떻게 복잡성을 관리하는지 깊이 이해합니다.
-
API 설계 원칙 습득: 사용하기 쉽고, 이해하기 명확하며, 변경에 강한 API를 설계하는 방법을 배웁니다.
-
계층 아키텍처 실천: 레이어드 아키텍처, 의존성 역전 원칙, 깨끗한 아키텍처 같은 검증된 패턴을 실전에 적용합니다.
-
의료 시스템 설계: 실제 도메인의 복잡성을 가진 의료 데이터 관리 시스템을 GitHub Copilot과 협업하여 설계하고, 추상화 계층이 어떻게 복잡도를 낮추는지 경험합니다.
이번 주는 이론과 실습의 균형을 맞춥니다. 추상화의 원리를 이해하고, 구체적인 설계 패턴을 학습하며, 실제 시스템에 적용하는 전 과정을 거칩니다. 전문 개발자로서 복잡한 시스템을 우아하게 설계하는 능력을 키울 것입니다.
1. API, 모듈, 데이터 구조 단순화 전략
복잡성을 숨기는 추상화의 힘
복잡성은 소프트웨어의 숙명입니다. 비즈니스 요구사항은 복잡하고, 기술 스택은 다양하며, 통합해야 할 시스템은 많습니다. 이 복잡성을 그대로 노출하면 시스템은 이해하기 어렵고, 변경하기 위험하며, 유지보수가 불가능해집니다.
추상화는 복잡성을 관리하는 핵심 전략입니다. 세부사항을 캡슐화하고, 단순한 인터페이스를 제공하며, 사용자가 알아야 할 것과 알 필요 없는 것을 분리합니다.
추상화의 수준
추상화는 여러 수준에서 일어납니다:
하드웨어 추상화: 프로그래머는 CPU 레지스터나 메모리 주소를 직접 다루지 않습니다. 운영체제가 이를 추상화하여 파일 시스템, 프로세스, 네트워크 소켓 같은 고수준 개념을 제공합니다.
언어 추상화: TypeScript나 C#은 메모리 관리, 포인터 산술, 시스템 호출을 추상화합니다. 여러분은 객체, 메서드, 비동기 작업이라는 고수준 개념으로 생각합니다.
라이브러리 추상화: HTTP 통신을 위해 TCP 소켓을 직접 다루지 않습니다. axios나 HttpClient 같은 라이브러리가 복잡한 프로토콜을 추상화하여 간단한 API를 제공합니다.
도메인 추상화: 비즈니스 로직을 기술 세부사항과 분리합니다. "주문 생성"은 데이터베이스 트랜잭션, HTTP 요청, 메시지 큐 발행 같은 기술적 세부사항과 독립적으로 표현됩니다.
좋은 추상화의 특징
- 단순성: 사용자가 기억하고 이해해야 할 개념이 적습니다.
- 일관성: 비슷한 작업은 비슷한 방식으로 수행됩니다.
- 완전성: 필요한 기능을 모두 제공하지만, 불필요한 것은 포함하지 않습니다.
- 안정성: 구현이 바뀌어도 인터페이스는 변하지 않습니다.
나쁜 추상화의 징후
-
누수된 추상화(Leaky Abstraction): 구현 세부사항이 인터페이스를 통해 드러납니다. 예: "데이터베이스 연결이 끊어졌으므로 재시도하세요"라는 에러 메시지는 내부 구현(데이터베이스 사용)을 노출합니다.
-
과도한 추상화(Over-Abstraction): 너무 일반화되어 사용하기 어렵습니다. 예:
processData(data: any): any같은 지나치게 일반적인 함수는 의미가 불명확합니다. -
미흡한 추상화(Under-Abstraction): 사용자가 너무 많은 세부사항을 알아야 합니다. 예: 파일을 읽기 위해 버퍼 크기, 인코딩, 에러 핸들링을 모두 직접 관리해야 한다면 추상화가 부족한 것입니다.
API 설계 원칙
API(Application Programming Interface)는 소프트웨어 컴포넌트 간 계약입니다. 좋은 API는 사용하기 쉽고, 오용하기 어우며, 변경에 강합니다.
원칙 1: 최소 놀라움의 법칙 (Principle of Least Astonishment)
사용자가 직관적으로 예상하는 대로 동작해야 합니다. 이름, 매개변수, 반환값이 명확하고 일관적이어야 합니다.
// ✅ 좋은 예: 일관된 명명
interface UserRepository {
findById(id: string): Promise<User | null>;
findAll(): Promise<User[]>;
save(user: User): Promise<void>;
}
// ❌ 나쁜 예: 일관성 없음
interface UserRepository {
get(id: string): Promise<User | undefined>; // find vs get 혼용
list(): Promise<User[]>; // findAll vs list 혼용
persist(user: User): Promise<User>; // 불필요한 반환값
}
📁 전체 API 설계 예시: code/api-design-principles.ts
원칙 2: 표현력 있는 이름 사용
이름만 봐도 무엇을 하는지 알 수 있어야 합니다. 약어나 모호한 이름을 피하세요.
// ✅ 좋은 예: 명확한 이름
class OrderService {
createOrder(customerId: string, items: OrderItem[]): Promise<Order>
cancelOrder(orderId: string, reason: string): Promise<void>
}
// ❌ 나쁜 예: 약어와 모호한 이름
class OrderService {
create(cid: string, itm: any[]): Promise<any> // 약어, any
cancel(id: string): Promise<void> // 이유 누락
}
원칙 3: 강타입 활용
TypeScript의 타입 시스템을 최대한 활용하여 컴파일 시간에 오류를 잡으세요.
// Brand Types로 타입 안전성 확보
type UserId = string & { __brand: 'UserId' };
type OrderId = string & { __brand: 'OrderId' };
function sendOrderConfirmation(orderId: OrderId, email: Email): void { /* */ }
// ✅ 타입 불일치를 컴파일 시간에 검출
// sendOrderConfirmation(userId, email); // 컴파일 에러!
📁 전체 예시: code/api-design-principles.ts
원칙 4: 불변성 우선
가능한 한 불변 객체를 사용하세요. 불변 객체는 예측 가능하고, 스레드 안전하며, 디버깅이 쉽습니다.
// 핵심: 불변 객체, 새 인스턴스 반환
class Money {
constructor(
public readonly amount: number,
public readonly currency: string
) {}
add(other: Money): Money {
if (this.currency !== other.currency) throw new Error('Currency mismatch');
return new Money(this.amount + other.amount, this.currency); // 새 객체 반환
}
}
원칙 5: 에러 처리 명시
어떤 에러가 발생할 수 있는지 명확히 하세요. TypeScript에서는 Result 타입이나 명확한 throw 문서화를 사용합니다.
// Result 타입으로 타입 안전한 에러 처리
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
async function processPayment(
amount: Money,
method: PaymentMethod
): Promise<Result<PaymentConfirmation, PaymentError>> {
try {
return { ok: true, value: confirmation };
} catch (error) {
return { ok: false, error: new PaymentError(error.message) };
}
}
// 사용
const result = await processPayment(amount, method);
if (result.ok) {
console.log(result.value); // PaymentConfirmation
} else {
console.error(result.error); // PaymentError
}
원칙 6: 버전 관리 고려
API는 시간이 지나면서 진화합니다. 하위 호환성을 유지하면서 변경하는 전략이 필요합니다.
// ❌ 나쁜 예: 매개변수 추가로 하위 호환성 깨짐
// createUser(name: string) → createUser(name: string, email: string)
// ✅ 좋은 예: 확장 가능한 옵션 객체
interface CreateUserOptions {
name: string;
email?: string; // 선택적 필드로 나중에 추가 가능
phone?: string;
}
function createUser(options: CreateUserOptions): Promise<User> { /* */ }
// 새 필드 추가 시 기존 코드는 영향 없음
createUser({ name: 'John' }); // 여전히 동작
createUser({ name: 'John', email: 'john@example.com' }); // 새 기능
모듈 인터페이스 설계
모듈은 관련된 기능의 묶음입니다. 모듈의 인터페이스는 외부에 무엇을 노출하고, 무엇을 숨길지 결정합니다.
공개 인터페이스와 내부 구현 분리
TypeScript에서는 export로 공개 인터페이스를 명시합니다.
// payment-processor.ts
// 공개 인터페이스
export interface PaymentProcessor {
process(payment: Payment): Promise<PaymentResult>;
}
export interface Payment {
amount: Money;
method: PaymentMethod;
customerId: string;
}
export interface PaymentResult {
success: boolean;
transactionId?: string;
error?: string;
}
// 내부 구현 (export하지 않음)
class StripePaymentProcessor implements PaymentProcessor {
private apiKey: string;
private httpClient: HttpClient;
async process(payment: Payment): Promise<PaymentResult> {
// Stripe 특화 로직
}
}
// 팩토리 함수로 구현 숨김
export function createPaymentProcessor(config: PaymentConfig): PaymentProcessor {
if (config.provider === 'stripe') {
return new StripePaymentProcessor(config.apiKey);
} else if (config.provider === 'paypal') {
return new PayPalPaymentProcessor(config.credentials);
}
throw new Error(`Unknown provider: ${config.provider}`);
}
사용자는 PaymentProcessor 인터페이스만 알면 되고, 구체적인 구현(StripePaymentProcessor)은 알 필요가 없습니다.
facade 패턴으로 복잡한 하위 시스템 단순화
여러 복잡한 클래스를 하나의 단순한 인터페이스로 감쌉니다.
// 복잡한 하위 시스템
class EmailValidator { /* ... */ }
class PhoneValidator { /* ... */ }
class AddressValidator { /* ... */ }
class DatabaseChecker { /* ... */ }
// Facade: 단순한 인터페이스 제공
export class UserRegistrationFacade {
private emailValidator = new EmailValidator();
private phoneValidator = new PhoneValidator();
private addressValidator = new AddressValidator();
private databaseChecker = new DatabaseChecker();
async registerUser(userData: UserData): Promise<RegistrationResult> {
// 모든 복잡한 검증을 내부에서 처리
if (!this.emailValidator.isValid(userData.email)) {
return { success: false, error: 'Invalid email' };
}
if (!this.phoneValidator.isValid(userData.phone)) {
return { success: false, error: 'Invalid phone' };
}
if (await this.databaseChecker.emailExists(userData.email)) {
return { success: false, error: 'Email already registered' };
}
// 사용자 생성
return { success: true, userId: newUserId };
}
}
사용자는 하나의 registerUser 메서드만 호출하면 되고, 내부의 복잡한 검증 과정은 알 필요가 없습니다.
데이터 구조 추상화
데이터 구조도 추상화의 대상입니다. 내부 표현과 외부 표현을 분리하면, 구현을 자유롭게 변경할 수 있습니다.
DTO (Data Transfer Object) 패턴
API 경계에서 내부 도메인 객체를 직접 노출하지 말고, DTO를 사용하세요.
// 내부 도메인 모델 (복잡함, 민감 정보 포함)
class User {
private passwordHash: string; // 민감 정보
private roles: Role[];
hasPermission(permission: Permission): boolean { /* */ }
}
// 외부 API용 DTO (단순함, 필요한 정보만)
interface UserDTO {
id: string;
email: string;
displayName: string;
createdAt: string;
}
// 변환: 민감 정보 제외
function toUserDTO(user: User): UserDTO {
return {
id: user.getId(),
email: user.getEmail(),
displayName: user.getDisplayName(),
createdAt: user.getCreatedAt().toISOString()
// passwordHash 등은 노출하지 않음
};
}
Repository 패턴으로 데이터 접근 추상화
데이터 저장 방식(SQL, NoSQL, 메모리, 파일)을 추상화합니다.
// 추상 인터페이스
export interface UserRepository {
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: UserId): Promise<void>;
}
// PostgreSQL 구현
class PostgreSQLUserRepository implements UserRepository {
async findById(id: UserId): Promise<User | null> {
const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return row ? this.mapRowToUser(row) : null;
}
// ...
}
// MongoDB 구현
class MongoDBUserRepository implements UserRepository {
async findById(id: UserId): Promise<User | null> {
const doc = await this.collection.findOne({ _id: id });
return doc ? this.mapDocToUser(doc) : null;
}
// ...
}
비즈니스 로직은 UserRepository 인터페이스에만 의존하므로, 데이터베이스를 PostgreSQL에서 MongoDB로 바꿔도 영향을 받지 않습니다.
// 이미지로 교체되어야 함 : 추상화 계층을 보여주는 다이어그램 (사용자 → 인터페이스 → 구현 세부사항) 프롬프트: A layered abstraction diagram showing user/client at top interacting only with simple interface layer, which hides complex implementation details below, arrows showing information flow, clear separation between public API and internal implementation, clean technical illustration style
2. 계층 간 책임 분리와 인터페이스 정의
소프트웨어를 계층으로 나누면 각 계층이 명확한 책임을 가지고, 계층 간 의존성을 제어할 수 있습니다. 이는 시스템을 이해하기 쉽고, 테스트하기 쉬우며, 변경하기 안전하게 만듭니다.
레이어드 아키텍처
**레이어드 아키텍처(Layered Architecture)**는 가장 널리 사용되는 아키텍처 패턴 중 하나입니다. 시스템을 수평 계층으로 나누고, 각 계층은 자신의 바로 아래 계층에만 의존합니다.
전형적인 4계층 구조
┌─────────────────────────────────┐
│ Presentation Layer (API) │ ← HTTP 요청/응답, 컨트롤러
├─────────────────────────────────┤
│ Application Layer (Use Cases) │ ← 비즈니스 플로우, 조정
├─────────────────────────────────┤
│ Domain Layer (Business Logic) │ ← 도메인 모델, 비즈니스 규칙
├─────────────────────────────────┤
│ Infrastructure Layer (Data) │ ← 데이터베이스, 외부 API
└─────────────────────────────────┘
각 계층의 책임
Presentation Layer (표현 계층)
- 사용자 또는 외부 시스템과의 인터페이스
- HTTP 요청을 받아 Application Layer로 위임
- 응답을 적절한 형식(JSON, HTML)으로 변환
- 인증, 입력 검증 (기본적인 것만)
TypeScript 예제:
// 핵심: HTTP 요청 → DTO 변환 → Use Case 실행
export class UserController {
constructor(private createUserUseCase: CreateUserUseCase) {}
async createUser(req: Request, res: Response): Promise<void> {
const command: CreateUserCommand = {
name: req.body.name,
email: req.body.email,
password: req.body.password
};
const result = await this.createUserUseCase.execute(command);
res.status(201).json({ id: result.userId });
}
}
📁 4계층 전체 예시: code/layered-architecture-full-example.ts
Application Layer (애플리케이션 계층)
- 비즈니스 플로우 조정
- 여러 도메인 객체를 조율하여 유스케이스 구현
- 트랜잭션 경계 관리
- 도메인 이벤트 발행
TypeScript 예제:
// 핵심: 비즈니스 플로우 조정 (유스케이스 오케스트레이션)
export class CreateUserUseCase {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private eventPublisher: EventPublisher
) {}
async execute(command: CreateUserCommand): Promise<CreateUserResult> {
// 1. 중복 확인 → 2. 도메인 생성 → 3. 저장 → 4. 알림 → 5. 이벤트
const existingUser = await this.userRepository.findByEmail(command.email);
if (existingUser) throw new EmailAlreadyExistsError(command.email);
const user = User.create(command);
await this.userRepository.save(user);
await this.emailService.sendWelcomeEmail(user.email);
await this.eventPublisher.publish(new UserCreatedEvent(user.id));
return { userId: user.id };
}
}
Domain Layer (도메인 계층)
- 핵심 비즈니스 로직과 규칙
- 도메인 모델 (엔티티, 값 객체, Aggregate)
- 기술적 세부사항에 의존하지 않음
- 순수한 비즈니스 개념만 포함
TypeScript 예제:
// 핵심: 순수한 비즈니스 로직, 기술 세부사항 독립적
export class User {
private constructor(
public readonly id: UserId,
private name: string,
private email: Email,
private passwordHash: string,
private status: UserStatus
) {}
static create(props: CreateUserProps): User {
// 비즈니스 규칙 검증
if (props.name.length < 2) throw new InvalidNameError('...');
if (!this.isValidEmail(props.email)) throw new InvalidEmailError('...');
const passwordHash = this.hashPassword(props.password);
return new User(UserId.generate(), props.name, props.email, passwordHash, UserStatus.ACTIVE);
}
changeEmail(newEmail: Email): void {
// 비즈니스 규칙: 활성 사용자만 변경 가능
if (this.status !== UserStatus.ACTIVE) throw new InactiveUserError('...');
this.email = newEmail;
}
}
Infrastructure Layer (인프라 계층)
- 기술적 세부사항 구현
- 데이터베이스 접근, 파일 시스템, 외부 API 호출
- 프레임워크 특화 코드
- 도메인 계층에서 정의한 인터페이스 구현
TypeScript 예제:
// 핵심: 도메인 인터페이스 구현, ORM/프레임워크 특화 코드
export class TypeORMUserRepository implements UserRepository {
constructor(@InjectRepository(UserEntity) private repository: Repository<UserEntity>) {}
async findById(id: UserId): Promise<User | null> {
const entity = await this.repository.findOne({ where: { id: id.value } });
return entity ? this.toDomain(entity) : null;
}
async save(user: User): Promise<void> {
const entity = this.toEntity(user);
await this.repository.save(entity);
}
private toDomain(entity: UserEntity): User {
// ORM 엔티티 → 도메인 모델 변환
return User.reconstitute({ ...entity });
}
private toEntity(user: User): UserEntity {
// 도메인 모델 → ORM 엔티티 변환
return { ...user };
}
private toEntity(user: User): UserEntity {
// 도메인 모델을 ORM 엔티티로 변환
const entity = new UserEntity();
entity.id = user.id.value;
entity.name = user.getName();
entity.email = user.getEmail();
// ...
return entity;
}
}
계층 간 의존성 규칙
- 의존성은 아래로만 흐름: 상위 계층은 하위 계층에 의존할 수 있지만, 그 반대는 불가
- 도메인 계층은 독립적: 어떤 계층에도 의존하지 않음
- Infrastructure는 Domain 인터페이스 구현: 의존성 역전 원칙 적용
의존성 역전 원칙 (DIP)
**의존성 역전 원칙(Dependency Inversion Principle)**은 SOLID 원칙 중 하나로, 고수준 모듈이 저수준 모듈에 의존하지 않고, 둘 다 추상화에 의존해야 한다는 원칙입니다.
전통적 의존성 (나쁜 예)
// 고수준 모듈
class OrderService {
private mysqlRepository: MySQLOrderRepository; // 구체적 구현에 의존
constructor() {
this.mysqlRepository = new MySQLOrderRepository(); // 직접 생성
}
createOrder(order: Order): void {
this.mysqlRepository.save(order);
}
}
// 저수준 모듈
class MySQLOrderRepository {
save(order: Order): void {
// MySQL 특화 저장 로직
}
}
문제점:
- OrderService가 MySQL에 강하게 결합됨
- 데이터베이스를 바꾸려면 OrderService도 수정해야 함
- 테스트 시 실제 MySQL이 필요함
의존성 역전 적용 (좋은 예)
// 추상화 (인터페이스)
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
}
// 고수준 모듈 (추상화에 의존)
class OrderService {
constructor(private orderRepository: OrderRepository) {} // 인터페이스에 의존
async createOrder(order: Order): Promise<void> {
await this.orderRepository.save(order);
}
}
// 저수준 모듈 (추상화 구현)
class MySQLOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
// MySQL 특화 저장 로직
}
async findById(id: OrderId): Promise<Order | null> {
// MySQL 특화 조회 로직
}
}
// 또 다른 구현
class MongoDBOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
// MongoDB 특화 저장 로직
}
async findById(id: OrderId): Promise<Order | null> {
// MongoDB 특화 조회 로직
}
}
// 의존성 주입으로 구현 결정
const orderService = new OrderService(new MySQLOrderRepository());
// 또는
const orderService = new OrderService(new MongoDBOrderRepository());
이점:
- OrderService는 데이터베이스 세부사항을 몰라도 됨
- 데이터베이스 변경 시 OrderService 수정 불필요
- 테스트 시 Mock Repository 사용 가능
C# 예제로 DIP 이해하기
C#은 인터페이스와 의존성 주입을 강력하게 지원합니다.
// 인터페이스 정의
public interface IOrderRepository
{
Task SaveAsync(Order order);
Task<Order?> FindByIdAsync(OrderId id);
}
// 고수준 모듈
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IEmailService _emailService;
// 생성자 주입
public OrderService(
IOrderRepository orderRepository,
IEmailService emailService)
{
_orderRepository = orderRepository;
_emailService = emailService;
}
public async Task CreateOrderAsync(Order order)
{
await _orderRepository.SaveAsync(order);
await _emailService.SendOrderConfirmationAsync(order);
}
}
// 저수준 구현
public class SqlServerOrderRepository : IOrderRepository
{
private readonly DbContext _dbContext;
public SqlServerOrderRepository(DbContext dbContext)
{
_dbContext = dbContext;
}
public async Task SaveAsync(Order order)
{
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
}
public async Task<Order?> FindByIdAsync(OrderId id)
{
return await _dbContext.Orders
.FirstOrDefaultAsync(o => o.Id == id.Value);
}
}
// ASP.NET Core 의존성 주입 설정
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IOrderRepository, SqlServerOrderRepository>();
services.AddScoped<IEmailService, SendGridEmailService>();
services.AddScoped<OrderService>();
}
깨끗한 아키텍처 패턴
**깨끗한 아키텍처(Clean Architecture)**는 Robert C. Martin(Uncle Bob)이 제안한 아키텍처 패턴으로, 의존성의 방향을 안쪽(도메인)으로만 향하게 하여 핵심 비즈니스 로직을 기술 세부사항과 완전히 분리합니다.
동심원 구조
┌─────────────────────────────────────┐
│ Frameworks & Drivers (UI, DB) │ ← 가장 바깥: 기술 세부사항
├─────────────────────────────────────┤
│ Interface Adapters (Controllers) │ ← 변환 계층
├─────────────────────────────────────┤
│ Application Business Rules │ ← Use Cases
├─────────────────────────────────────┤
│ Enterprise Business Rules │ ← 핵심 도메인
└─────────────────────────────────────┘
↑ 의존성 방향 (안쪽으로만)
핵심 규칙
- 의존성은 안쪽으로만: 외부 계층은 내부 계층을 알지만, 내부 계층은 외부를 모름
- 도메인이 중심: 비즈니스 규칙이 가장 안정적이고 변하지 않음
- 경계에서 변환: 계층 경계를 넘을 때 데이터 형식 변환
프로젝트 구조 예
src/
├── domain/ # 가장 안쪽: 순수 비즈니스 로직
│ ├── entities/
│ │ ├── User.ts
│ │ └── Order.ts
│ ├── value-objects/
│ │ ├── Email.ts
│ │ └── Money.ts
│ └── repositories/ # 인터페이스만
│ ├── IUserRepository.ts
│ └── IOrderRepository.ts
│
├── application/ # Use Cases
│ ├── use-cases/
│ │ ├── CreateUserUseCase.ts
│ │ └── PlaceOrderUseCase.ts
│ └── ports/ # 외부 서비스 인터페이스
│ ├── IEmailService.ts
│ └── IPaymentGateway.ts
│
├── adapters/ # 변환 계층
│ ├── controllers/
│ │ ├── UserController.ts
│ │ └── OrderController.ts
│ ├── presenters/
│ │ └── OrderPresenter.ts
│ └── gateways/
│ └── StripePaymentGateway.ts
│
└── infrastructure/ # 가장 바깥: 기술 세부사항
├── database/
│ ├── TypeORMUserRepository.ts
│ └── TypeORMOrderRepository.ts
├── email/
│ └── SendGridEmailService.ts
└── web/
└── ExpressApp.ts
경계 넘기 예제
컨트롤러(외부)에서 Use Case(내부)를 호출할 때:
// adapters/controllers/UserController.ts
export class UserController {
constructor(private createUserUseCase: CreateUserUseCase) {}
async register(req: Request, res: Response): Promise<void> {
// HTTP 요청(외부 형식)을 Use Case 입력(내부 형식)으로 변환
const input: CreateUserInput = {
name: req.body.name,
email: req.body.email,
password: req.body.password
};
const output = await this.createUserUseCase.execute(input);
// Use Case 출력(내부 형식)을 HTTP 응답(외부 형식)으로 변환
res.status(201).json({
id: output.userId,
message: 'User created successfully'
});
}
}
Use Case는 HTTP를 전혀 모릅니다. Controller가 변환을 담당합니다.
테스트 용이성
깨끗한 아키텍처의 큰 이점은 테스트입니다. 도메인과 Use Case를 UI, 데이터베이스 없이 독립적으로 테스트할 수 있습니다.
describe('CreateUserUseCase', () => {
it('should create user with valid data', async () => {
// Mock Repository (실제 DB 불필요)
const mockUserRepository: IUserRepository = {
save: jest.fn(),
findByEmail: jest.fn().mockResolvedValue(null)
};
// Mock Email Service (실제 이메일 발송 불필요)
const mockEmailService: IEmailService = {
sendWelcomeEmail: jest.fn()
};
const useCase = new CreateUserUseCase(
mockUserRepository,
mockEmailService
);
const input = {
name: 'John Doe',
email: 'john@example.com',
password: 'SecurePass123'
};
const output = await useCase.execute(input);
expect(output.userId).toBeDefined();
expect(mockUserRepository.save).toHaveBeenCalled();
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalled();
});
});
// 이미지로 교체되어야 함 : 깨끗한 아키텍처의 동심원 구조와 의존성 방향을 보여주는 다이어그램 프롬프트: A concentric circles diagram of Clean Architecture showing layers from outside to inside: Frameworks & Drivers, Interface Adapters, Application Business Rules, Enterprise Business Rules at center, arrows pointing inward showing dependency direction, modern technical diagram style with clear labels
3. 실습: GitHub Copilot으로 의료 데이터 관리 시스템 설계
이제 배운 추상화 계층 설계를 실제로 적용해봅시다. 의료 데이터 관리 시스템은 복잡한 도메인 규칙, 높은 보안 요구사항, 다양한 외부 시스템 연동이 필요한 현실적인 예제입니다.
요구사항 분석
비즈니스 요구사항
중소 병원을 위한 환자 데이터 관리 시스템을 구축합니다:
- 환자 관리: 환자 등록, 기본 정보 관리, 의료 기록 조회
- 진료 기록: 진료 내역 기록, 처방전 발행, 검사 결과 기록
- 예약 관리: 진료 예약 생성, 변경, 취소
- 보안: HIPAA 준수, 환자 정보 암호화, 접근 로깅
비기능 요구사항
- 보안: 환자 정보는 최고 수준의 보안 필요
- 감사: 모든 데이터 접근 로깅
- 성능: 환자 조회 < 100ms
- 확장성: 향후 다른 병원으로 확장 가능
GitHub Copilot에게 도메인 모델링 요청
"의료 데이터 관리 시스템의 핵심 도메인 모델을 설계해줘.
환자(Patient), 진료(MedicalRecord), 의사(Doctor), 예약(Appointment)을 포함하고,
각각의 주요 속성과 관계를 TypeScript 인터페이스로 정의해줘.
HIPAA 규정을 고려한 민감 정보 처리도 포함해줘."
Copilot이 생성할 도메인 모델:
// domain/entities/Patient.ts
type PatientId = string & { __brand: 'PatientId' };
type SSN = string & { __brand: 'SSN' }; // Social Security Number (민감 정보)
export class Patient {
private constructor(
public readonly id: PatientId,
private name: string,
private dateOfBirth: Date,
private ssn: SSN, // 암호화 필요
private contactInfo: ContactInfo,
private insuranceInfo: InsuranceInfo
) {}
static create(props: CreatePatientProps): Patient {
// 검증 로직
if (props.name.length < 2) {
throw new InvalidPatientNameError();
}
if (!this.isValidSSN(props.ssn)) {
throw new InvalidSSNError();
}
return new Patient(
PatientId.generate(),
props.name,
props.dateOfBirth,
props.ssn,
props.contactInfo,
props.insuranceInfo
);
}
// SSN은 암호화된 형태로만 반환
getEncryptedSSN(): string {
return encrypt(this.ssn);
}
// 비즈니스 규칙: 18세 미만은 보호자 동의 필요
isMinor(): boolean {
const age = this.calculateAge();
return age < 18;
}
private calculateAge(): number {
const today = new Date();
const birthDate = new Date(this.dateOfBirth);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
private static isValidSSN(ssn: string): boolean {
return /^\d{3}-\d{2}-\d{4}$/.test(ssn);
}
}
// domain/entities/MedicalRecord.ts
export class MedicalRecord {
private constructor(
public readonly id: MedicalRecordId,
public readonly patientId: PatientId,
public readonly doctorId: DoctorId,
private diagnosis: string,
private prescription: Prescription[],
private notes: string, // 의사 소견 (민감 정보)
private recordedAt: Date
) {}
static create(props: CreateMedicalRecordProps): MedicalRecord {
return new MedicalRecord(
MedicalRecordId.generate(),
props.patientId,
props.doctorId,
props.diagnosis,
props.prescription,
props.notes,
new Date()
);
}
addPrescription(medication: Medication, dosage: string, duration: number): void {
this.prescription.push(new Prescription(medication, dosage, duration));
}
// 비즈니스 규칙: 기록 작성 후 24시간 이내만 수정 가능
canEdit(): boolean {
const now = new Date();
const hoursSinceRecorded = (now.getTime() - this.recordedAt.getTime()) / (1000 * 60 * 60);
return hoursSinceRecorded < 24;
}
}
계층 구조 설계
"위 도메인 모델을 기반으로 깨끗한 아키텍처 패턴을 적용한 계층 구조를 설계해줘.
각 계층의 책임과 주요 컴포넌트를 설명해줘."
Copilot이 제안할 구조:
src/
├── domain/ # 핵심 비즈니스 로직 (가장 안쪽)
│ ├── entities/
│ │ ├── Patient.ts
│ │ ├── MedicalRecord.ts
│ │ ├── Doctor.ts
│ │ └── Appointment.ts
│ ├── value-objects/
│ │ ├── PatientId.ts
│ │ ├── ContactInfo.ts
│ │ └── Prescription.ts
│ └── repositories/ # 인터페이스만
│ ├── IPatientRepository.ts
│ └── IMedicalRecordRepository.ts
│
├── application/ # Use Cases
│ ├── use-cases/
│ │ ├── RegisterPatientUseCase.ts
│ │ ├── CreateMedicalRecordUseCase.ts
│ │ └── ScheduleAppointmentUseCase.ts
│ └── ports/ # 외부 서비스 인터페이스
│ ├── IEncryptionService.ts
│ └── IAuditLogger.ts
│
├── adapters/ # 변환 계층
│ ├── controllers/
│ │ ├── PatientController.ts
│ │ └── MedicalRecordController.ts
│ └── presenters/
│ └── PatientPresenter.ts
│
└── infrastructure/ # 기술 세부사항 (가장 바깥)
├── database/
│ ├── PostgresPatientRepository.ts
│ └── PostgresMedicalRecordRepository.ts
├── security/
│ ├── AESEncryptionService.ts
│ └── HIPAACompliantAuditLogger.ts
└── web/
└── ExpressApp.ts
인터페이스 정의
Repository 인터페이스 정의
"PatientRepository 인터페이스를 정의해줘.
기본 CRUD 외에 HIPAA 규정을 고려한 감사 로깅과 암호화된 조회 기능을 포함해줘."
// domain/repositories/IPatientRepository.ts
export interface IPatientRepository {
// 기본 CRUD
save(patient: Patient): Promise<void>;
findById(id: PatientId): Promise<Patient | null>;
findAll(): Promise<Patient[]>;
delete(id: PatientId): Promise<void>;
// HIPAA 특화 기능
findBySSN(ssn: SSN, accessContext: AccessContext): Promise<Patient | null>;
searchByName(name: string, accessContext: AccessContext): Promise<Patient[]>;
// 감사 추적
logAccess(patientId: PatientId, accessedBy: DoctorId, reason: string): Promise<void>;
}
// AccessContext: 누가, 왜 접근하는지 명시
export interface AccessContext {
doctorId: DoctorId;
reason: string; // "진료", "검사 결과 확인" 등
timestamp: Date;
}
Use Case 인터페이스 정의
"RegisterPatientUseCase를 정의해줘.
입력 검증, 중복 확인, 암호화, 감사 로깅을 포함하는 완전한 구현을 작성해줘."
// application/use-cases/RegisterPatientUseCase.ts
export class RegisterPatientUseCase {
constructor(
private patientRepository: IPatientRepository,
private encryptionService: IEncryptionService,
private auditLogger: IAuditLogger
) {}
async execute(input: RegisterPatientInput, context: AccessContext): Promise<RegisterPatientOutput> {
// 1. 입력 검증
this.validateInput(input);
// 2. 중복 확인 (SSN 기준)
const existingPatient = await this.patientRepository.findBySSN(
input.ssn,
context
);
if (existingPatient) {
throw new PatientAlreadyExistsError(input.ssn);
}
// 3. 환자 생성 (도메인 로직)
const patient = Patient.create({
name: input.name,
dateOfBirth: new Date(input.dateOfBirth),
ssn: input.ssn,
contactInfo: input.contactInfo,
insuranceInfo: input.insuranceInfo
});
// 4. 민감 정보 암호화
await this.encryptionService.encryptPatientData(patient);
// 5. 저장
await this.patientRepository.save(patient);
// 6. 감사 로깅 (HIPAA 요구사항)
await this.auditLogger.log({
action: 'PATIENT_REGISTERED',
patientId: patient.id,
performedBy: context.doctorId,
timestamp: new Date(),
details: { reason: context.reason }
});
return {
patientId: patient.id,
message: 'Patient registered successfully'
};
}
private validateInput(input: RegisterPatientInput): void {
if (!input.name || input.name.length < 2) {
throw new ValidationError('Name must be at least 2 characters');
}
if (!this.isValidSSN(input.ssn)) {
throw new ValidationError('Invalid SSN format');
}
if (!input.dateOfBirth || new Date(input.dateOfBirth) > new Date()) {
throw new ValidationError('Invalid date of birth');
}
}
private isValidSSN(ssn: string): boolean {
return /^\d{3}-\d{2}-\d{4}$/.test(ssn);
}
}
구현 및 검증
Infrastructure 계층 구현
"IPatientRepository를 PostgreSQL로 구현해줘.
데이터 암호화, 감사 로깅, 트랜잭션 관리를 포함해줘."
// infrastructure/database/PostgresPatientRepository.ts
export class PostgresPatientRepository implements IPatientRepository {
constructor(
private dbClient: Pool,
private encryptionService: IEncryptionService,
private auditLogger: IAuditLogger
) {}
async save(patient: Patient): Promise<void> {
const client = await this.dbClient.connect();
try {
await client.query('BEGIN');
// SSN 암호화
const encryptedSSN = await this.encryptionService.encrypt(patient.getSSN());
await client.query(
`INSERT INTO patients (id, name, date_of_birth, ssn_encrypted, contact_info, insurance_info)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
patient.id,
patient.getName(),
patient.getDateOfBirth(),
encryptedSSN,
JSON.stringify(patient.getContactInfo()),
JSON.stringify(patient.getInsuranceInfo())
]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw new DatabaseError('Failed to save patient', error);
} finally {
client.release();
}
}
async findById(id: PatientId): Promise<Patient | null> {
const result = await this.dbClient.query(
'SELECT * FROM patients WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return null;
}
return this.mapRowToPatient(result.rows[0]);
}
async findBySSN(ssn: SSN, context: AccessContext): Promise<Patient | null> {
// SSN 암호화하여 검색
const encryptedSSN = await this.encryptionService.encrypt(ssn);
const result = await this.dbClient.query(
'SELECT * FROM patients WHERE ssn_encrypted = $1',
[encryptedSSN]
);
if (result.rows.length === 0) {
return null;
}
const patient = this.mapRowToPatient(result.rows[0]);
// 접근 로깅 (HIPAA 요구사항)
await this.logAccess(patient.id, context.doctorId, context.reason);
return patient;
}
async logAccess(patientId: PatientId, accessedBy: DoctorId, reason: string): Promise<void> {
await this.auditLogger.log({
action: 'PATIENT_ACCESSED',
patientId,
performedBy: accessedBy,
timestamp: new Date(),
details: { reason }
});
}
private async mapRowToPatient(row: any): Promise<Patient> {
// 암호화된 SSN 복호화
const decryptedSSN = await this.encryptionService.decrypt(row.ssn_encrypted);
return Patient.reconstitute({
id: row.id,
name: row.name,
dateOfBirth: row.date_of_birth,
ssn: decryptedSSN,
contactInfo: JSON.parse(row.contact_info),
insuranceInfo: JSON.parse(row.insurance_info)
});
}
}
테스트 코드 생성
"RegisterPatientUseCase의 단위 테스트를 작성해줘.
정상 케이스, 중복 환자, 유효하지 않은 입력 케이스를 포함해줘."
describe('RegisterPatientUseCase', () => {
let useCase: RegisterPatientUseCase;
let mockPatientRepository: jest.Mocked<IPatientRepository>;
let mockEncryptionService: jest.Mocked<IEncryptionService>;
let mockAuditLogger: jest.Mocked<IAuditLogger>;
beforeEach(() => {
mockPatientRepository = {
save: jest.fn(),
findBySSN: jest.fn(),
// ... 다른 메서드 mock
};
mockEncryptionService = {
encrypt: jest.fn(),
decrypt: jest.fn(),
encryptPatientData: jest.fn()
};
mockAuditLogger = {
log: jest.fn()
};
useCase = new RegisterPatientUseCase(
mockPatientRepository,
mockEncryptionService,
mockAuditLogger
);
});
it('should register patient with valid data', async () => {
// Given
mockPatientRepository.findBySSN.mockResolvedValue(null);
const input: RegisterPatientInput = {
name: 'John Doe',
dateOfBirth: '1980-01-01',
ssn: '123-45-6789',
contactInfo: { phone: '555-1234', email: 'john@example.com' },
insuranceInfo: { provider: 'Blue Cross', policyNumber: 'BC123456' }
};
const context: AccessContext = {
doctorId: 'doctor-123',
reason: '신규 환자 등록',
timestamp: new Date()
};
// When
const result = await useCase.execute(input, context);
// Then
expect(result.patientId).toBeDefined();
expect(mockPatientRepository.save).toHaveBeenCalled();
expect(mockEncryptionService.encryptPatientData).toHaveBeenCalled();
expect(mockAuditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
action: 'PATIENT_REGISTERED'
})
);
});
it('should throw error when patient already exists', async () => {
// Given
const existingPatient = Patient.create(/* ... */);
mockPatientRepository.findBySSN.mockResolvedValue(existingPatient);
const input = { /* ... */ };
const context = { /* ... */ };
// When & Then
await expect(useCase.execute(input, context))
.rejects.toThrow(PatientAlreadyExistsError);
});
it('should throw error with invalid SSN format', async () => {
// Given
const input: RegisterPatientInput = {
name: 'John Doe',
dateOfBirth: '1980-01-01',
ssn: 'invalid-ssn', // 잘못된 형식
contactInfo: { phone: '555-1234', email: 'john@example.com' },
insuranceInfo: { provider: 'Blue Cross', policyNumber: 'BC123456' }
};
const context = { /* ... */ };
// When & Then
await expect(useCase.execute(input, context))
.rejects.toThrow(ValidationError);
});
});
이 실습을 통해 여러분은 복잡한 도메인에서도 깨끗한 추상화 계층을 설계하고, GitHub Copilot과 협업하여 보안, 규정 준수, 테스트 가능성을 모두 만족하는 시스템을 구축하는 방법을 익혔습니다.
// 이미지로 교체되어야 함 : 의료 시스템의 계층 구조와 의존성 방향을 보여주는 패키지 다이어그램 프롬프트: A package diagram showing medical system layers: domain layer at center with Patient and MedicalRecord entities, application layer with use cases, adapters layer with controllers, infrastructure layer with database and security services, arrows showing dependency direction inward, HIPAA compliance annotations, clean technical diagram style
4. 실습 결과 요약
이번 챕터에서 우리는 추상화 계층 설계의 이론과 실천을 학습했습니다. 복잡한 시스템을 명확한 계층으로 분리하고, 각 계층 간 책임과 의존성을 관리하는 방법을 익혔습니다.
핵심 학습 내용
추상화의 본질
추상화는 복잡성을 관리하는 가장 강력한 도구입니다. 세부사항을 숨기고, 핵심 개념만 드러내며, "무엇을"과 "어떻게"를 분리합니다. 좋은 추상화는 단순하고, 일관적이며, 완전하고, 안정적입니다.
추상화는 여러 수준에서 일어납니다. 하드웨어 추상화, 언어 추상화, 라이브러리 추상화, 그리고 가장 중요한 도메인 추상화까지, 각 수준이 복잡성의 한 계층을 제거하고 더 높은 수준의 사고를 가능하게 합니다.
API 설계의 원칙
API는 소프트웨어 컴포넌트 간 계약입니다. 최소 놀라움의 법칙을 따르고, 표현력 있는 이름을 사용하며, TypeScript/C#의 강타입 시스템을 활용하고, 불변성을 우선하며, 에러 처리를 명시하고, 버전 관리를 고려해야 합니다.
좋은 API는 사용하기 쉽고, 오용하기 어우며, 변경에 강합니다. 이는 설계 단계에서의 세심한 고려를 통해 달성됩니다.
계층 아키텍처의 실천
레이어드 아키텍처는 시스템을 수평 계층으로 나눕니다. Presentation, Application, Domain, Infrastructure 각 계층은 명확한 책임을 가지며, 의존성은 아래로만 흐릅니다.
의존성 역전 원칙(DIP)은 고수준 모듈이 저수준 모듈에 의존하지 않고, 둘 다 추상화에 의존하게 만듭니다. 이는 시스템을 유연하고 테스트 가능하게 만드는 핵심 기법입니다.
깨끗한 아키텍처는 의존성을 안쪽(도메인)으로만 향하게 하여, 비즈니스 로직을 기술 세부사항과 완전히 분리합니다. 도메인이 중심이고, 프레임워크와 데이터베이스는 교체 가능한 플러그인입니다.
의료 시스템 설계 경험
실습을 통해 복잡한 도메인 규칙(HIPAA 규정), 높은 보안 요구사항(데이터 암호화, 접근 로깅), 다양한 계층 간 협력을 경험했습니다. GitHub Copilot과 협업하여 도메인 모델, Use Case, Repository 구현, 테스트 코드까지 전 과정을 완성했습니다.
추상화 계층이 어떻게 복잡도를 낮추고, 변경을 안전하게 만들며, 테스트를 쉽게 하는지 구체적으로 경험했습니다.
실무 적용 가이드
추상화 수준 결정
너무 낮은 추상화는 사용자에게 너무 많은 세부사항을 노출합니다. 너무 높은 추상화는 지나치게 일반적이어서 실용성이 떨어집니다. 적절한 수준은 도메인 전문가와 개발자가 모두 이해할 수 있고, 대부분의 사용 사례를 자연스럽게 표현할 수 있는 수준입니다.
GitHub Copilot에게 "이 API가 너무 복잡한가? 더 단순하게 만들 수 있는 방법을 제안해줘"라고 물어보세요. AI는 사용자 관점에서 인터페이스를 평가하고 개선 아이디어를 제공할 수 있습니다.
계층 간 경계 유지
계층 경계를 넘나들 때는 명시적인 변환을 수행하세요. HTTP 요청을 도메인 객체로, 도메인 객체를 데이터베이스 엔티티로, 각 경계에서 적절한 형식으로 변환합니다.
경계를 지키면 각 계층을 독립적으로 변경할 수 있습니다. 데이터베이스를 바꿔도 도메인은 영향받지 않고, 도메인 규칙을 바꿔도 API 컨트롤러는 최소한만 수정됩니다.
인터페이스 우선 설계
구현보다 인터페이스를 먼저 설계하세요. "이 서비스는 무엇을 제공해야 하는가?"를 먼저 정의하고, "어떻게 구현할 것인가"는 나중에 결정합니다.
"OrderService의 공개 인터페이스를 설계해줘.
주문 생성, 취소, 조회 기능을 포함하고, 각 메서드의 입력, 출력, 에러를 명확히 정의해줘."
인터페이스가 명확하면, GitHub Copilot이 구현을 생성하기 쉽고, 여러 구현(Mock, 실제 구현)을 쉽게 만들 수 있습니다.
점진적 추상화
처음부터 완벽한 추상화를 만들 필요는 없습니다. 구체적인 구현으로 시작하고, 패턴이 보이면 추상화하세요. "3번 반복되면 추상화하라"는 규칙이 유용합니다.
GitHub Copilot에게 "이 코드에서 반복되는 패턴을 찾아 추상화해줘"라고 요청하면, 리팩토링 제안을 받을 수 있습니다.
테스트로 검증
추상화가 올바른지 검증하는 가장 좋은 방법은 테스트입니다. Mock 객체를 쉽게 만들 수 있고, 비즈니스 로직을 데이터베이스 없이 테스트할 수 있다면, 추상화가 잘 되어 있는 것입니다.
"이 Use Case의 단위 테스트를 작성해줘.
모든 외부 의존성을 Mock으로 대체하고, 비즈니스 로직만 테스트해줘."
다음 주 예고
Chapter 6에는 **"GitHub Copilot과의 협업 모델"**을 깊이 있게 다룹니다. Agent 기능 심화, 고급 프롬프트 엔지니어링 기법, 반복적 개선 전략을 배우며, GitHub Copilot을 단순한 코드 생성 도구가 아닌 진정한 협업 파트너로 활용하는 방법을 익힙니다.
여러분은 이제 복잡한 시스템을 명확한 추상화 계층으로 설계하고, 각 계층의 책임을 분리하며, 변경에 강하고 테스트 가능한 구조를 만드는 전문가의 능력을 갖추었습니다. 다음 주에는 이러한 설계를 더욱 효과적으로 실현하는 AI 협업 기법을 완성할 것입니다.