🚀 객체지향 프로그래밍의 의미
Java 생태계, 특히 Spring Boot 환경에서 개발을 하다 보면 숨 쉬듯이 객체를 생성하고 관계를 맺습니다. 그렇다면 왜 우리는 과거의 절차지향적 방식에서 벗어나 객체지향이라는 패러다임을 채택했을까요? 그 핵심은 바로 소프트웨어의 복잡성 통제와 변화에 대한 유연한 대응에 있습니다.
객체지향 프로그래밍은 시스템을 수많은 독립적인 객체들의 협력으로 바라봅니다. 각 객체는 자신만의 명확한 역할과 책임을 가지고 있으며, 서로 메시지를 주고받으며 거대한 애플리케이션을 구동시킵니다.
이를 통해 우리는 코드의 재사용성을 극대화하고, 유지보수를 쉽게 만들며, 무엇보다 새로운 기능이 추가되더라도 기존 시스템에 미치는 영향을 최소화할 수 있습니다.
🤦♂️ 시행착오: OOP 설계의 실수
객체지향 언어인 Java를 사용한다고 해서 무조건 객체지향적으로 코딩하고 있는 것은 아닙니다. 실무에서, 특히 도메인 주도 설계나 객체지향에 익숙하지 않을 때 가장 흔하게 저지르는 실수가 바로 빈약한 도메인 모델(Anemic Domain Model)을 만드는 것입니다.
빈약한 도메인 모델이란 상태(데이터)와 행위(비즈니스 로직)가 분리되어, 객체가 단순히 데이터 덩어리로 전락하고 모든 핵심 로직이 비즈니스 서비스 클래스에 집중되는 현상을 말합니다.
아래는 흔히 볼 수 있는 잘못된 방식의 예시입니다. 상태를 변경하는 모든 로직이 외부 서비스 클래스에 노출되어 있습니다.
// 잘못된 예시: 무분별한 Getter/Setter 남용과 수동적인 객체
public class Order {
private Long id;
private String status;
private int totalAmount;
// 무의미한 Getter/Setter가 모든 필드에 열려있음
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public int getTotalAmount() { return totalAmount; }
public void setTotalAmount(int totalAmount) { this.totalAmount = totalAmount; }
}
public class OrderService {
public void cancelOrder(Order order) {
// 객체의 상태를 외부에서 직접 판단하고 변경함 (절차지향적)
if ("SHIPPED".equals(order.getStatus())) {
throw new IllegalArgumentException("이미 배송된 주문은 취소할 수 없습니다.");
}
order.setStatus("CANCELLED");
}
}
이러한 코드는 객체지향의 핵심인 캡슐화를 완전히 위반합니다.
객체는 스스로의 상태를 책임져야 하지만, 위 코드에서 Order 객체는 그저 데이터를 담는 바구니일 뿐이며, OrderService가 모든 절차를 통제합니다. 이는 시간이 지날수록 서비스 클래스를 비대하게 만들고(God Class 문제), 응집도를 떨어뜨려 유지보수를 극도로 어렵게 만듭니다.
데이터를 꺼내서 외부에서 처리하는 방식이 아니라, 객체에게 직접 일을 시키는 방식으로 사고를 전환해야 합니다.
💡 핵심 : Java 객체지향의 4가지 핵심 기둥과 실전 활용
앞서 살펴본 문제점들을 해결하고 진정한 유연성을 확보하기 위해, 객체지향을 지탱하는 4가지 핵심 개념과 이를 실무에 어떻게 적용하는지 깊이 있게 파헤쳐 보겠습니다.
🔒 캡슐화 (Encapsulation): 데이터와 행위를 하나로 묶고 숨기기
캡슐화는 단순히 필드에 private 접근 제어자를 붙이고 Getter와 Setter를 만드는 것이 아닙니다.
진정한 캡슐화는 객체의 내부 구현을 외부로부터 감추고, 오직 외부에 노출된 명확한 인터페이스(메서드)를 통해서만 상호작용하도록 만드는 것입니다. 이를 통해 객체의 무결성을 보호하고, 내부 로직이 변경되더라도 외부 코드에 영향을 주지 않게 됩니다.
위에서 본 시행착오 코드를 캡슐화 원칙에 맞게 리팩토링해 보겠습니다.
// 올바른 예시: 스스로 상태를 책임지는 풍부한 도메인 모델
public class Order {
private Long id;
private OrderStatus status; // Enum 사용으로 타입 안정성 확보
private Money totalAmount;
public Order(Money totalAmount) {
this.status = OrderStatus.PENDING;
this.totalAmount = totalAmount;
}
// 외부에서 상태를 마음대로 바꾸지 못하도록 의도를 가진 메서드 제공
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException("이미 배송된 주문은 취소할 수 없습니다.");
}
this.status = OrderStatus.CANCELLED;
}
// 비즈니스 로직에 필요한 데이터만 제한적으로 제공
public boolean isCancellable() {
return this.status != OrderStatus.SHIPPED;
}
}
public class OrderService {
public void cancelOrder(Order order) {
// 서비스 계층은 그저 객체에게 '취소하라'고 명령만 내림
order.cancel();
}
}
이제 Order 객체는 자신의 상태(status)를 어떻게 변경해야 하는지 스스로 알고 있고 외부에서는 그저 cancel() 메서드를 호출할 뿐입니다. 캡슐화가 잘 된 객체는 변경에 닫혀 있으며, 비즈니스 규칙이 객체 내부에 응집되어 있어 코드의 가독성과 안전성이 비약적으로 상승합니다.
🧬 상속 (Inheritance): 재사용이 아닌 확장의 관점으로 바라보기
상속은 기존 클래스의 특성을 물려받아 새로운 클래스를 작성하는 기능입니다. Java에서는 extends 키워드를 사용합니다. 하지만 상속은 실무에서 가장 오해받고 남용되는 개념 중 하나입니다. 많은 개발자들이 단순히 코드를 재사용하기 위해 상속을 사용하지만, 이는 치명적인 설계 오류를 낳습니다.
상속은 반드시 부모 클래스와 자식 클래스 간에 완벽한 IS-A 관계가 성립할 때만 사용해야 합니다. 부모의 내부 구현에 자식 클래스가 강하게 결합되기 때문에, 부모 클래스가 변경되면 자식 클래스도 예기치 못한 버그를 겪을 확률이 매우 높습니다.
따라서 최신 트렌드에서는 상속보다 조합(Composition)을 권장합니다. 조합은 HAS-A 관계로, 다른 객체를 필드로 주입받아 그 기능을 사용하는 방식입니다.
// 1. 상속을 잘못 사용한 예 (단순 코드 재사용 목적)
public class ArrayListEngine {
public void saveToDatabase() { /* DB 저장 로직 */ }
}
public class CustomList extends ArrayListEngine {
// 부모의 메서드에 강하게 종속됨
public void addElementAndSave() {
// ... 요소 추가 ...
super.saveToDatabase();
}
}
// 2. 조합(Composition)을 사용한 유연한 설계
public class CustomList {
private final DatabaseEngine dbEngine; // 필드로 주입받음 (HAS-A)
// 생성자를 통한 의존성 주입
public CustomList(DatabaseEngine dbEngine) {
this.dbEngine = dbEngine;
}
public void addElementAndSave() {
// ... 요소 추가 ...
dbEngine.save(); // 필요할 때 위임(Delegation)
}
}
조합을 사용하면 런타임에 동적으로 동작을 변경할 수 있으며, 결합도가 낮아져 테스트와 유지보수가 훨씬 수월해집니다.
상속은 오직 다형성을 위한 타입 계층을 구축할 때 제한적으로 사용하는 것이 바람직합니다.
🌸 다형성 (Polymorphism): OOP의 핵심, 역할과 구현의 분리
다형성은 하나의 타입에 여러 가지 객체를 대입할 수 있는 성질을 의미합니다. 클라이언트(호출자)는 구체적인 구현체를 알 필요 없이, 인터페이스(역할)에만 의존하여 메시지를 보냅니다. 이는 Spring Boot의 핵심 근간인 의존성 주입(DI)이 가능하게 하는 마법입니다.
다형성은 주로 메서드 오버라이딩(Overriding)과 인터페이스를 통해 구현됩니다. 외부 결제 시스템을 연동하는 상황을 상상해 보겠습니다.
// 1. 역할(인터페이스) 정의
public interface PaymentClient {
void pay(int amount);
}
// 2. 구현체 1: 카카오페이
public class KakaoPayClient implements PaymentClient {
@Override
public void pay(int amount) {
System.out.println("카카오페이로 " + amount + "원을 결제합니다.");
}
}
// 3. 구현체 2: 네이버페이
public class NaverPayClient implements PaymentClient {
@Override
public void pay(int amount) {
System.out.println("네이버페이로 " + amount + "원을 결제합니다.");
}
}
// 4. 클라이언트 (Spring의 Service 계층)
public class OrderService {
// 구체적인 클래스(Kakao, Naver)가 아닌 인터페이스에 의존
private final PaymentClient paymentClient;
public OrderService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
public void processPayment(int amount) {
// 어떤 결제 수단이든 동일한 메서드로 호출 가능 (다형성)
paymentClient.pay(amount);
}
}
만약 새로운 토스페이가 추가되어야 한다면 어떻게 될까요? OrderService의 코드는 단 한 줄도 수정할 필요가 없습니다. 그저 PaymentClient 인터페이스를 구현하는 TossPayClient를 새로 만들고 주입해주기만 하면 됩니다.
이것이 바로 다형성이 가져다주는 무한한 확장성입니다. 클라이언트는 변경에 닫혀 있고, 기능은 확장에 열려 있는 이 상태가 객체지향 설계의 핵심입니다.
☁️ 추상화 (Abstraction): 복잡성을 숨기고 핵심만 남기기
추상화는 복잡한 시스템으로부터 핵심적인 개념이나 기능을 간추려내는 것을 말합니다. 지하철 노선도를 볼 때 우리가 실제 지형의 굴곡이나 실제 거리를 보는 것이 아니라, 역과 역 사이의 연결성이라는 핵심 정보만 보는 것과 같습니다.
주로 추상 클래스나 인터페이스를 통해 이루어집니다. 이를 통해 우리는 하위 시스템의 복잡한 구현 세부 사항(RDBMS 쿼리, 네트워크 소켓 통신 등)을 추상화 계층 뒤로 숨길 수 있습니다.
// 추상화된 메시지 전송 역할
public interface MessageSender {
void send(String topic, String message);
}
// 구체적인 복잡한 구현은 숨겨짐
public class KafkaMessageSender implements MessageSender {
@Override
public void send(String topic, String message) {
// 복잡한 Kafka 프로듀서 설정, 직렬화, 네트워크 전송 로직이 이 안에 감춰짐
// 클라이언트는 이 복잡성을 몰라도 됨
}
}
추상화를 통해 개발자는 "이 객체가 구체적으로 어떻게 동작하는가?"라는 질문에서 벗어나, "이 객체가 무엇을 할 수 있는가?"라는 추상적인 관점에서 시스템을 설계하고 조립할 수 있게 됩니다.
🎯 SOLID 원칙: 객체지향을 완성하는 5가지 설계 지침
이러한 4가지 기둥을 기반으로, 훌륭한 객체지향 코드를 작성하기 위해 로버트 C. 마틴이 정리한 SOLID 원칙을 반드시 숙지해야 합니다.
- 단일 책임 원칙 (SRP): 하나의 클래스는 단 하나의 변경 이유만을 가져야 합니다. 주문 클래스가 데이터베이스 연결까지 책임지면 안 됩니다.
- 개방-폐쇄 원칙 (OCP): 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 합니다. 앞서 본 다형성 결제 시스템 예제가 바로 OCP를 완벽히 지킨 사례입니다. 기존 코드 수정 없이 결제 수단 확장이 가능합니다.
- 리스코프 치환 원칙 (LSP): 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 합니다. 자식 클래스가 부모의 의도나 규약을 깨뜨리는 오버라이딩을 해서는 안 됩니다.
- 인터페이스 분리 원칙 (ISP): 자신이 사용하지 않는 인터페이스에 의존하지 않아야 합니다. 거대한 다목적 인터페이스보다는 특정 클라이언트를 위한 얇은 인터페이스 여러 개가 낫습니다.
- 의존성 역전 원칙 (DIP): 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다. Spring의 IoC 컨테이너가 이 원칙을 시스템 수준에서 지원해 주는 대표적인 기술입니다.
📝 오늘의 3줄 요약
- 객체는 데이터를 담는 단순한 바구니가 아니며, 상태와 행위를 스스로 책임지는 능동적인 존재로 설계하여 캡슐화를 이뤄야 합니다.
- 단순한 코드 재사용을 위한 상속은 독이 될 수 있으므로, 기능의 확장이 필요할 때는 유연한 조합(Composition)을 우선적으로 고려하세요.
- 변하는 것(구현)과 변하지 않는 것(역할/인터페이스)을 분리하는 다형성 구축이야말로, 유연하고 확장 가능한 애플리케이션의 핵심 비결입니다.
'Java' 카테고리의 다른 글
| JVM Heap과 Stack의 상호작용 및 생명주기 관리 (1) | 2026.03.11 |
|---|---|
| Java 뮤텍스와 세마포어 실전 활용 (0) | 2026.03.11 |
| Java 백엔드 생태계로 알아보는 동기와 비동기 (0) | 2026.03.10 |
| Netty 이벤트 루프와 Non-Blocking I/O 동작 원리 가이드 (0) | 2026.03.10 |
| Call by Value vs Call by Reference (0) | 2026.03.09 |
댓글