본문 바로가기

개발 관련 지식

객체지향의 사실과 오해 정리 - 7장. 역할, 책임, 협력 관점에서 본 객체지향

📚 도서관 도메인 예제

도서관에는 다양한 분야의 책들이 정리되어 있다. 각 책은 고유한 제목과 대출 가능 여부 정보를 가진다.
이용자는 책장을 둘러보거나 도서 검색 시스템을 통해 원하는 책을 찾고, 대출 데스크에 있는 사서에게 대출을 요청한다.
사서는 책의 상태를 확인하고, 이용자의 정보를 기반으로 대출을 처리한다.
대출이 완료되면 대출일과 반납일이 기록된 대출증을 발급한다.

1. 도메인 모델을 활용한 객체 정의

1. 도메인 객체 구분

  • 책(Book)
    • 상태: 제목, 대출 여부
  • 이용자(User)
    • 대출 요청의 주체
  • BookFinder
    • 사용자가 책을 찾는 데 도움을 주는 도우미 객체
  • 사서(Librarian)
    • 책 상태 확인, 대출 처리, 대출증 발급
  • 대출증(LoanTicket)
    • 대출된 책과 사용자, 대출일·반납일을 기록

📌 객체로 분리할 기준: 다른 객체와 명확히 구별되는 경계가 있는가?

💡 대출 가능 상태 확인을 별도 시스템으로 분리할지 여부는 도메인 복잡도에 따라 판단.
현재는 사서에게 책임을 두고, 나중에 필요시 분리한다.

2. 객체 사이 관계 파악


2. 시스템 객체를 자율 객체로 간주하고 메시지 정의

시스템 전체를 하나의 자율 객체로 보고, 가장 핵심적인 메시지를 먼저 정의한다.

"책을 빌려라(책 이름)"

3. 책임을 수행할 적절한 객체 선택


4. 요청을 받은 객체가 스스로 해결하지 못하는 책임은 나누어 메세지 생성


5. 3-4 반복

 


6. 인터페이스 정리

export interface User {
  requestLoan(bookTitle: string)
}

export interface BookFinder {
  findByTitle(title: string): Book | null;
}

export interface Book {
  isAvailable(): boolean;
  markAsLoaned(): void;
}

export interface Librarian {
  loan(book: Book, user: User): {
    ticket: LoanTicket | null;
    book: Book | null;
  };
}

export interface LoanTicket {
    makeTicket(book: Book, user: User): LoanTicket;
}

7.  구현

export class BasicUser implements User {
  constructor(private readonly id: string) {}

  public requestLoan(
    bookTitle: string,
    finder: BookFinder,
    librarian: Librarian
  ): {
    ticket: LoanTicket | null;
    book: Book | null;
  } {
    const book = finder.findByTitle(bookTitle);
    if (!book) return { ticket: null, book: null };
    return librarian.loan(book, this.getId());
  }

  public getId(): string {
    return this.id;
  }
}

export class BookShelf implements BookFinder {
  constructor(private readonly books: Book[]) {}

  public findByTitle(title: string): Book | null {
    return this.books.find(
      book => book.getTitle() === title && book.isAvailable()
    ) ?? null;
  }
}

export class BasicBook implements Book {
  private loaned: boolean = false;

  constructor(private readonly title: string) {}

  public isAvailable(): boolean {
    return !this.loaned;
  }

  public markAsLoaned(): void {
    this.loaned = true;
  }

  public markAsReturned(): void {
    this.loaned = false;
  }

  public getTitle(): string {
    return this.title;
  }
}

export class BasicLoanTicket implements LoanTicket {
  constructor(
    private readonly book: Book,
    private readonly userId: string,
    private readonly loanDate: Date = new Date(),
    private readonly returnDate: Date = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
  ) {}

  public getLoanDate(): Date {
    return this.loanDate;
  }

  public getReturnDate(): Date {
    return this.returnDate;
  }

  public getBook(): Book {
    return this.book;
  }

  public getUserId(): string {
    return this.userId;
  }
}

export class BasicLibrarian implements Librarian {
  public loan(book: Book, userId: string): {
    ticket: LoanTicket | null;
    book: Book | null;
  } {
    if (!book.isAvailable()) return { ticket: null, book: null };

    book.markAsLoaned();
    const ticket = new BasicLoanTicket(book, userId);
    return { ticket, book };
  }
}

 

구현 도중 객체의 인터페이스가 변경될 수 있다.

따라서 협력을 구상하는 단계에 너무 오랜 시간을 쏟지 말고, 최대한 빨리 구현으로 넘어가 설계에 이상이 없는지 판단해야 한다.

 

변화된 인터페이스

export interface User {
  requestLoan(
    bookTitle: string,
    finder: BookFinder,
    librarian: Librarian
  ): {
    ticket: LoanTicket | null;
    book: Book | null;
  };
  getId(): string;
}

export interface BookFinder {
  findByTitle(title: string): Book | null;
}

export interface Book {
  isAvailable(): boolean;
  markAsLoaned(): void;
  markAsReturned(): void;
  getTitle(): string;
}

export interface Librarian {
  loan(book: Book, userId: string): {
    ticket: LoanTicket | null;
    book: Book | null;
  };
}

export interface LoanTicket {
  getLoanDate(): Date;
  getReturnDate(): Date;
  getBook(): Book;
  getUserId(): string;
}

 

사용 예시

const books: Book[] = [
  new BasicBook("어린 왕자"),
  new BasicBook("1984"),
];

const finder = new BookShelf(books);
const librarian = new BasicLibrarian();
const user = new BasicUser("user-001");

// 첫 번째 대출 요청
const result1 = user.requestLoan("1984", finder, librarian);
console.log("📚 첫 대출 요청:", result1.ticket ? "성공" : "실패");

// 두 번째 대출 요청 (같은 책)
const result2 = user.requestLoan("1984", finder, librarian);
console.log("📚 두 번째 대출 요청:", result2.ticket ? "성공" : "실패");

// 대출 가능 상태 확인용 출력
console.log(
  "남아 있는 책:",
  books.map(book => ({
    title: book.getTitle(),
    isAvailable: book.isAvailable(),
  }))
);
📚 첫 대출 요청: 성공
📚 두 번째 대출 요청: 실패
남아 있는 책: [
    { title: '어린 왕자', isAvailable: true },
    { title: '1984', isAvailable: false }
]

세가지 관점으로 판단하기 - 개념, 명세, 구현

1️⃣ 개념 관점 (Conceptual Perspective)

"도메인 안에 존재하는 개념과 개념들 사이의 관계를 잘 표현하고 있는가?"

개념 관점은 우리가 현실에서 다루는 문제, 즉 도메인 모델에 가까운 관점이다.

객체 역할
User 대출을 요청하는 주체
Book 대출 대상, 상태를 가진 엔티티
BookShelf 책을 탐색하는 도구
Librarian 대출 가능 여부를 판단하고 처리
LoanTicket 대출의 결과이자 기록

 

2. 명세 관점 (Specification Perspective)

실제 소프트웨어 안에서 객체들이 어떤 메시지를 주고받고, 어떤 책임을 수행하는가?

이제 소프트웨어 설계로 들어온다.
각 객체는 협력 과정에서 메시지를 주고받으며 책임을 수행한다.
이 책임을 표현하는 것이 인터페이스다.

 

3. 구현 관점 (Implementation Perspective)

실제 책임을 수행하기 위해 어떻게 동작해야 하는가?

마지막으로, 그 책임을 실제로 어떻게 수행할지 결정하는 것이 구현이다.

여기서는 인터페이스를 따르는 구체 클래스들이 등장한다.

실제 데이터, 상태 변화, 로직이 이 안에 담긴다.

 

훌륭한 객체지향 코드는 이 세 가지 관점을 동시에 갖고 있다.

  • 개념은 도메인을 명확하게 설명하고,
  • 명세는 객체 간의 협력을 드러내며,
  • 구현은 실제 동작을 담담하게 수행한다.

인터페이스는 구현 세부 사항을 드러내지 않아야 한다.

 

※ 본 글은 『객체지향의 사실과 오해』(조영호 저) 7장을 기반으로 학습 목적으로 요약한 글입니다.

※ 이 글은 책의 내용을 요약한 것으로, 원문 없이 읽을 경우 오해의 여지가 있을 수 있습니다. 정확한 이해를 위해 원서의 정독을 권장합니다.