최근 조영호 저자의 책 '오브젝트'의 1장을 읽고 객체지향 프로그래밍에 대한 깊은 통찰을 얻었습니다. 특히 객체 간의 자율성, 협력 관계, 그리고 의존성 관리에 대한 개념들이 매우 인상적이었습니다. 이 글에서는 책의 내용을 바탕으로 객체지향의 핵심 개념들을 정리하고, 실무에 적용할 수 있는 방법에 대해 기록하고자 합니다.
'오브젝트' 책의 1장에서는 소극장 운영 이벤트라는 예제를 통해 객체지향 설계의 원칙을 설명합니다. 이 예제를 통해 잘못된 설계와 개선된 설계를 비교해 보겠습니다.
이 요구사항에 대한 도메인 모델은 다음과 같이 구성할 수 있습니다:
초기 설계에서는 다음과 같은 문제점이 있었습니다:
public class Theater {
private TicketSeller ticketSeller;
public void enter(Audience audience) {
if (audience.getBag().hasInvitation()) {
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().setTicket(ticket);
} else {
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().minusAmount(ticket.getFee());
ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
audience.getBag().setTicket(ticket);
}
}
}
이 설계의 문제점:

위 UML 다이어그램에서 볼 수 있듯이, Theater 클래스는 거의 모든 객체에 직접적으로 의존하고 있습니다. 특히 점선으로 표시된 의존관계(매개변수를 통한 의존)가 Theater에서 여러 객체로 뻗어나가고 있어, Theater가 많은 객체의 내부 구현을 알고 있어야 함을 보여줍니다.
생성자를 보면 떠오르는 점: 이 객체가 생겨날 때 무조건 필요로 하는 (의존하는) 개념
- 관객은 가방없이 생겨날 수 없음
- 극장은 판매원 없이 생겨날 수 없음
멤버변수를 보면 떠오르는 것:
- 가방은 초대장, 티켓, 현금을 지니고 있음
- 극장은 판매원을 알고 있음
- 판매원은 티켓 오피스를 알고 있음
의존관계 종류:
- Audience는 함수의 매개변수를 통한 의존: Theater의 enter 메서드가 실행될 때만 일시적으로 관계가 생김
- TicketSeller는 멤버변수를 통한 의존: 극장은 항상 판매원의 존재를 알고 있음
이러한 의존성 구조는 객체지향 설계의 핵심 원칙인 캡슐화와 자율성을 위반하고 있습니다. Theater가 너무 많은 객체의 내부 구현을 알고 있어야 하므로, 어떤 객체의 구현이 변경되면 Theater도 함께 변경되어야 하는 높은 결합도를 가지게 됩니다.
이러한 문제점을 해결하기 위해 각 객체에게 적절한 책임을 부여하고 자율성을 높인 설계로 변경합니다:
public class Theater {
private TicketSeller ticketSeller;
public void enter(Audience audience) {
ticketSeller.sellTo(audience);
}
}
public class TicketSeller {
private TicketOffice ticketOffice;
public void sellTo(Audience audience) {
ticketOffice.sellTicketTo(audience);
}
}
public class Audience {
private Bag bag;
public Long buy(Ticket ticket) {
return bag.hold(ticket);
}
}
public class Bag {
private Long amount;
private Invitation invitation;
private Ticket ticket;
public Long hold(Ticket ticket) {
if (hasInvitation()) {
setTicket(ticket);
return 0L;
} else {
setTicket(ticket);
minusAmount(ticket.getFee());
return ticket.getFee();
}
}
}
개선된 설계의 이점:


개선된 UML 다이어그램에서는 각 객체가 자신의 책임을 수행하며, 상위 객체가 하위 객체의 내부 구현에 직접 접근하지 않습니다. Theater는 오직 TicketSeller에만 의존하고, 나머지 객체들과의 직접적인 의존 관계가 제거되었습니다. 이는 실선(강한 연결)과 점선(약한 의존)을 적절히 사용하여 객체 간의 관계를 더 명확하게 표현합니다.
// 극장 클래스
public class Theater {
private TicketSeller ticketSeller; // 멤버변수를 통한 의존: 판매원은 극장의 필수적인 부분
// 생성자를 통한 의존관계 설정 - 극장은 판매원 없이 존재할 수 없음
public Theater(TicketSeller ticketSeller) {
this.ticketSeller = ticketSeller;
}
public void enter(Audience audience) { // 메서드 파라미터를 통한 의존: 일시적 관계
ticketSeller.sellTo(audience);
}
}
개선 후 의존성의 특징:
1. 생성자를 통한 의존: 객체가 생성될 때 반드시 필요한 협력 객체를 명시합니다. 이는 강한 결합을 의미하지만, 필수적인 관계를 명확히 합니다.
2. 메서드 파라미터를 통한 의존: 일시적이고 약한 결합을 의미합니다. 메서드가 실행되는 동안만 관계가 유지됩니다.
3. 책임의 분산: 각 객체가 자신의 상태를 스스로 관리하며, 다른 객체의 내부 구현에 간섭하지 않습니다.
이러한 의존성 관리 방식은 코드 변경에 더 유연하게 대응할 수 있게 하며, 객체지향 설계의 본질인 "자율적인 객체들의 협력"을 실현합니다.
소극장 예제를 통해 배운 객체지향 설계의 핵심 원칙은 다음과 같습니다:
개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 행위
캡슐화를 통해 객체의 내부 구현을 외부로부터 감추면 다음과 같은 이점이 있습니다:
다른 클래스가 나를 많이 알고 있을수록 의존성이 커진다
비록 현실에서는 수동적인 존재라고 하더라도 객체지향의 세계에서는 모든 것이 능동적이고 자율적인 존재로 바뀐다
좋은 모듈(클래스, 패키지, 라이브러리 등)이 갖춰야 할 필요충분조건은 다음과 같습니다:
'오브젝트' 책의 내용을 실무에 적용할 때 고려해야 할 점은 다음과 같습니다:
스프링은 의존성 주입(DI)을 통해 객체 간의 결합도를 낮추는 데 도움을 줍니다.
@Service
public class OrderService {
private final PaymentService paymentService;
@Autowired
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public void processOrder(Order order) {
// 주문 처리 로직
paymentService.processPayment(order);
}
}
이 코드에서 OrderService는 PaymentService의 구현체가 아닌 인터페이스에 의존하므로, 실제 구현체가 변경되어도 OrderService의 코드는 변경되지 않습니다.
실무에서 클래스 설계 시 다음과 같은 의존성 분석 접근법을 활용할 수 있습니다:
생성자 분석:
public OrderService(PaymentService paymentService)는 OrderService가 PaymentService 없이는 존재할 수 없음을 의미멤버변수 분석:
private final PaymentService paymentService는 OrderService가 PaymentService와 지속적인 관계를 유지함을 의미메서드 매개변수 분석:
public void process(Order order)는 OrderService가 Order와 일시적인 관계만 가짐을 의미이러한 분석을 통해 얻은 인사이트를 바탕으로 다음과 같은 결정을 내릴 수 있습니다:
'오브젝트' 책의 1장을 통해 객체지향 설계의 핵심 원칙인 의존성, 캡슐화, 자율성에 대해 깊이 있게 이해할 수 있었습니다. 이러한 원칙들은 단순히 이론적인 개념이 아니라, 실제 코드의 품질과 유지보수성에 직접적인 영향을 미치는 중요한 요소입니다.
객체지향 설계에서는 객체를 단순한 데이터 컨테이너가 아닌, 자율적이고 책임을 갖는 존재로 바라보는 관점의 전환이 필요합니다. 이러한 관점을 바탕으로 코드를 설계할 때, 더 유연하고 이해하기 쉬우며 변경에 강한 소프트웨어를 만들 수 있습니다.
의존성을 분석하는 관점에서 생성자, 멤버변수, 메서드 매개변수를 통해 객체 간의 관계를 명확히 이해하고, 이를 통해 더 나은 설계 결정을 내릴 수 있습니다. 이러한 분석 방법은 단순히 코드를 보는 것에서 나아가, 객체의 본질적인 특성과 책임을 파악하는 데 도움이 됩니다.
앞으로의 개발 과정에서도 이러한 객체지향의 원칙을 항상 염두에 두고, 더 나은 코드를 작성하기 위해 노력해야겠습니다.
객체지향 설계에서 가장 중요한 개념 중 하나는 의존성(dependency)입니다. 의존성이란 한 클래스가 다른 클래스를 사용하는 관계를 의미합니다. 쉽게 말해, A 클래스가 B 클래스를 알고 있다면 "A는 B에 의존한다"고 표현합니다.
💡 핵심: 의존성은 '사용하는 쪽 → 제공하는 쪽'으로 흐릅니다.
@Service
public class OrderService {
private final PaymentService paymentService;
// 의존성 주입
@Autowired
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
이 예시에서 OrderService는 PaymentService를 의존(사용)합니다. 즉, payment 기능을 order에 제공합니다.
객체지향 설계에서는 의존성을 더 세분화하여 다음과 같이 구분합니다:
한 클래스가 다른 클래스를 멤버 변수로 참조하는 관계입니다.
class Course {
private Professor professor; // 단순 참조
public void setProfessor(Professor professor) {
this.professor = professor;
}
}
"has-a" 관계로, 전체와 부분의 관계를 나타내지만 부분이 전체에 독립적으로 존재할 수 있습니다.
class Department {
private List<Employee> employees; // 부분(Employee)이 전체(Department) 없이도 존재 가능
public Department() {
this.employees = new ArrayList<>();
}
public void addEmployee(Employee employee) {
employees.add(employee);
}
// Employee는 Department가 사라져도 계속 존재할 수 있음
}
더 강한 "has-a" 관계로, 부분의 생명주기가 전체에 종속됩니다.
class Car {
private final Engine engine; // 부분(Engine)이 전체(Car)에 생명주기가 종속됨
public Car() {
this.engine = new Engine(); // Car가 Engine을 직접 생성
}
// Car가 소멸하면 Engine도 함께 소멸
}
일시적인 관계로, 메서드 파라미터나 지역 변수로 다른 클래스를 사용하는 경우입니다.
class OrderProcessor {
public void processOrder(Order order, PaymentGateway paymentGateway) {
// OrderProcessor는 메서드에서 Order와 PaymentGateway에 의존함
}
}
의존 관계의 특징:
UML 다이어그램에서 클래스 간의 관계를 표현할 때 실선과 점선은 다음과 같은 의미를 갖습니다:
이러한 표기법의 차이는 단순히 시각적인 구분이 아니라, 객체 간의 결합 강도와 생명주기 관리 방식을 명확히 하기 위한 것입니다.
의존성 관리는 객체지향 설계에서 가장 중요한 측면 중 하나입니다. 잘 관리된 의존성은 다음과 같은 이점을 제공합니다:
객체지향 설계의 목표 중 하나는 "높은 응집도와 낮은 결합도"를 달성하는 것입니다. 의존성을 잘 관리하는 것은 이 목표를 달성하는 데 핵심적인 역할을 합니다.
public class Order {
private final Long id; // 필수, 식별자
private final Customer customer; // 필수, 주문자 없는 주문은 불가능
private final LocalDateTime orderDate; // 필수, 주문 시간
private OrderStatus status; // 멤버변수만, 상태는 변경될 수 있음
private String deliveryAddress; // 멤버변수만, 나중에 설정 가능
private List<OrderItem> items; // 멤버변수만, 주문 후 아이템 추가 가능
public Order(Long id, Customer customer) {
this.id = id;
this.customer = customer;
this.orderDate = LocalDateTime.now(); // 자동 생성 가능
this.status = OrderStatus.CREATED; // 기본값 설정
this.items = new ArrayList<>(); // 빈 컬렉션으로 초기화
}
// 메서드...
}
의존성 방향 고려: 의존성은 항상 안정적인 방향(추상화 또는 고수준 모듈)으로 향해야 함
// 좋은 예: 구체적 구현체보다 인터페이스에 의존
public Report generateReport(ReportData data, ReportFormatter formatter) {
// formatter는 인터페이스로, 다양한 구현체가 주입될 수 있음
}
책임의 분리: 각 메서드는 단일 책임을 가져야 함
// 나쁜 예: 큰 인터페이스에 의존
public void saveExp(AccExp101 VO) {
// 입력값 검증
// 데이터 베이스에서 중복 체크
// 품의 저장
// 품의 거래처 저장
// 품의 재원 저장
// 가전표 생성
}
// 좋은 예: 작은 인터페이스에 의존
public class ExpenseService {
private final ExpenseValidator validator;
private final ExpenseRepository expRepository;
private final VendorRepository vendorRepository;
private final FundRepository fundRepository;
private final JournalEntryGenerator journalGenerator;
public ExpenseService(ExpenseValidator validator,
ExpenseRepository expRepository,
VendorRepository vendorRepository,
FundRepository fundRepository,
JournalEntryGenerator journalGenerator) {
this.validator = validator;
this.expRepository = expRepository;
this.vendorRepository = vendorRepository;
this.fundRepository = fundRepository;
this.journalGenerator = journalGenerator;
}
@Transactional
public void saveExp(AccExp101 expenseVO) {
// 1. 입력값 검증 - 검증 책임을 validator에 위임
validator.validate(expenseVO);
// 2. 품의 저장 - 데이터 저장 책임을 repository에 위임
Long expenseId = expRepository.save(expenseVO);
// 3. 품의 거래처 저장 - 거래처 관련 책임을 vendorRepository에 위임
vendorRepository.saveVendorForExpense(expenseId, expenseVO.getVendors());
// 4. 품의 재원 저장 - 재원 관련 책임을 fundRepository에 위임
fundRepository.saveFundsForExpense(expenseId, expenseVO.getFunds());
// 5. 가전표 생성 - 가전표 생성 책임을 journalGenerator에 위임
journalGenerator.generateJournalEntry(expenseVO);
}
}
의존성 주입 원칙: 객체는 자신의 의존성을 직접 생성하지 않고 외부에서 주입받아야 함
// 나쁜 예: 의존성을 직접 생성
public Report generateReport(ReportData data) {
PDFFormatter formatter = new PDFFormatter(); // 직접 생성
return formatter.format(data);
}
// 좋은 예: 의존성 주입
public Report generateReport(ReportData data, ReportFormatter formatter) {
return formatter.format(data);
}
인터페이스 분리 원칙: 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 함
// 나쁜 예: 큰 인터페이스에 의존
public void processCustomer(CustomerManager manager, Customer customer) {
// manager는 많은 메서드를 가진 큰 인터페이스지만 여기선 한 메서드만 사용
manager.updateProfile(customer);
}
// 좋은 예: 작은 인터페이스에 의존
public void processCustomer(ProfileUpdater updater, Customer customer) {
// ProfileUpdater는 updateProfile 메서드만 가진 작은 인터페이스
updater.updateProfile(customer);
}