객체지향 패러다임과 아키텍처 설계
객체지향 프로그래밍(Object-Oriented Programming, OOP) 은 컴퓨터 프로그램을 명령어의 목록으로 보는 전통적인 절차지향적 관점에서 벗어나, 여러 개의 독립된 단위인 ‘객체(Object)’ 들의 모임으로 파악하고자 하는 프로그래밍 패러다임입니다. 쉽게 말해, 현실 세계의 사물이나 개념을 상태(Data)와 행위(Method)를 가진 객체로 모델링하고, 이 객체들 간의 메시지 교환을 통해 시스템을 구성하는 방식입니다.
OOP의 4가지 핵심 원칙(The 4 Pillars of OOP)
추상화(Abstraction)
복잡한 현실 세계의 사물에서 불필요한 세부 사항을 제거하고, 애플리케이션의 목적에 맞는 핵심적인 속성과 기능만을 추출하여 모델링하는 과정입니다.
시스템의 복잡성을 제어하는 가장 기본적인 방법입니다. 인터페이스(Interface)나 추상 클래스(Abstract Class)를 통해 “무엇을(What)” 하는지는 정의하지만, “어떻게(How)” 하는지는 구현 객체에 위임하여 결합도를 낮춥니다.
캡슐화(Encapsulation)
객체의 상태(데이터)와 그 상태를 조작하는 행위(메서드)를 하나의 묶음으로 통합하고, 외부에서 객체의 내부 상태에 직접 접근하지 못하도록 은닉(Information Hiding)하는 것입니다.
외부의 잘못된 사용으로부터 객체의 무결성을 보호합니다. 또한, 내부 구현이 변경되더라도 외부 인터페이스가 유지되는 한 시스템의 다른 부분에 영향을 주지 않아 유지보수성이 극대화됩니다.
상속(Inheritance)
기존 클래스(부모)의 속성과 기능을 새로운 클래스(자식)가 물려받아 재사용하고 확장하는 기능입니다. ‘IS-A’ 관계를 표현합니다.
코드의 중복을 줄이고 계층적인 구조를 만듭니다. 하지만 과도한 상속은 부모 클래스와 자식 클래스 간의 강한 결합(Tight Coupling)을 유발하므로, 현대의 객체지향 설계에서는 주의해서 사용해야 하는 양날의 검입니다.
다형성(Polymorphism)
하나의 메시지(메서드 호출)가 객체의 실제 타입에 따라 다르게 응답할 수 있는 능력입니다. 오버라이딩(Overriding)과 오버로딩(Overloading)이 대표적입니다.
객체지향의 ‘꽃’이자 유연한 설계의 핵심입니다. 클라이언트 코드는 인터페이스만 알면 되며, 런타임에 주입되는 실제 객체의 구현에 따라 동작이 동적으로 결정(Dynamic Dispatch)됩니다. 이는 시스템을 수정하지 않고도 새로운 기능을 쉽게 확장(Open-Closed Principle)할 수 있게 해줍니다.
장점
- 코드 재사용성 및 유지보수성 : 캡슐화와 다형성 덕분에 코드의 수정이 국소화되고, 디버깅 및 기능 확장이 용이합니다.
- 자연스러운 모델링 : 비즈니스 로직을 현실 세계와 유사하게 객체로 매핑할 수 있어, 요구사항을 코드로 번역하기 수월합니다.
단점
- 초기 설계 비용 : 클래스 구조와 객체 간의 관계를 설계하는 데 많은 시간과 노력이 필요합니다.
- 성능 오버헤드 : 절차지향(C언어)에 비해, 런타임 시 다형성을 위한 동적 바인딩(Dynamic Binding) 과정이나 수많은 객체 생성/소멸에 따른 메모리 및 가비지 컬렉션(GC) 오버헤드가 발생할 수 있습니다.
객체지향 설계의 5원칙(SOLID)
SOLID는 로버트 C. 마틴(Uncle Bob)이 정립한 다섯 가지 설계 원칙의 앞글자를 딴 약어입니다. 이 원칙들의 공통적인 목표는 “변경에 유연하고, 이해하기 쉬우며, 재사용 가능한” 소프트웨어를 만드는 것입니다.
단일 책임 원칙(Single Responsibility Principle, SRP)
“한 클래스는 하나의 책임만 가져야 하며, 클래스가 변경되어야 하는 이유는 오직 하나뿐이어야 한다.”
응집도(Cohesion) 는 높이고 결합도(Coupling) 는 낮추는 것입니다.
‘책임’이라는 용어가 모호할 수 있습니다. 시니어의 관점에서 책임은 곧 ‘변경의 축’ 입니다. 만약 한 클래스를 수정해야 하는 이유가 여러 부서(회계팀 요구사항, 기획팀 요구사항 등)의 요청 때문이라면, 그 클래스는 너무 많은 책임을 지고 있는 것입니다.
너무 작게 쪼개면 클래스 숫자가 폭증하여 시스템의 전체적인 흐름을 파악하기 어려워질 수 있습니다.
개방-폐쇄 원칙(Open-Closed Principle, OCP)
“소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만, 수정에 대해서는 닫혀 있어야 한다.”
기능을 추가할 때 기존 코드를 변경하지 않고도 확장할 수 있어야 한다는 뜻입니다. 이를 가능하게 하는 것이 바로 추상화와 다형성입니다.
인터페이스를 설계하고 이를 상속받아 구현함으로써, 새로운 요구사항이 들어왔을 때 새로운 클래스를 만들기만 하면 됩니다. 기존 시스템의 안정성을 해치지 않는 가장 강력한 방법입니다.
스코프 치환 원칙(Liskov Substitution Principle, LSP)
“프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.”
상속 관계에서 자식 클래스는 부모 클래스의 역할을 완전히 대체할 수 있어야 한다는 계약(Contract) 에 관한 원칙입니다.
가장 흔한 위반 사례는 ‘직사각형-정사각형’ 문제입니다. 부모의 행위를 자식이 거부하거나 예상치 못한 방식으로 동작한다면, 다형성을 활용하는 클라이언트 코드는 망가지게 됩니다. 상속을 단순한 코드 재사용 수단으로 보지 말고, ‘행위적 호환성’ 관점에서 접근해야 합니다.
인터페이스 분리 원칙(Interface Segregation Principle, ISP)
“특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.”
자신이 사용하지 않는 메서드에 의존하도록 강제해서는 안 된다는 것입니다.
하나의 거대한 인터페이스(Fat Interface)를 만들면, 이를 구현하는 클래스들이 필요 없는 기능까지 억지로 구현해야 합니다. 인터페이스를 클라이언트의 목적에 맞게 세밀하게 분리하여 인터페이스 결합도를 낮추어야 합니다.
의존성 역전 원칙(Dependency Inversion Principle, DIP)
“상위 모듈은 하위 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다. 또한 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.”
변하기 쉬운 구체적인 것(Concrete Class)보다 변하지 않는 추상적인 것(Interface/Abstract Class)에 의존하라는 원칙입니다.
자바 개발에서 가장 중요한 원칙 중 하나입니다. 제어의 역전(IoC) 과 의존성 주입(DI) 의 기반이 됩니다. 하위 수준의 모듈(예: 특정 데이터베이스 라이브러리)이 변경되더라도 상위 수준의 로직(서비스 계층)이 영향을 받지 않게 보호합니다.
추상 클래스와 인터페이스
추상 클래스(Abstract Class)
추상 클래스는 클래스 앞에 abstract 키워드를 붙여 만들며, 하나 이상의 추상 메서드(구현부가 없는 메서드)를 가질 수 있는 미완성 설계도입니다.
설계 목적(IS-A 관계) : “A는 B이다”라는 명확한 소속 관계를 나타낼 때 사용합니다. 서로 밀접하게 연관된 클래스들의 공통적인 속성(상태)과 행위를 추출하여 부모 클래스로 묶고, 일부 세부 구현만 자식에게 강제할 때 씁니다.
- 일반적인 필드(멤버 변수)와 생성자를 가질 수 있습니다. 즉, 상태(State) 를 가질 수 있습니다.
- 일반 메서드(구현이 완료된 메서드)도 가질 수 있어, 자식 클래스들이 코드를 그대로 재사용할 수 있습니다.
- 자바의 단일 상속 원칙에 따라, 추상 클래스는 오직 하나만 상속(extends) 받을 수 있습니다.
사용하는 경우
- 상속 관계를 타고 내려오면서 클래스들 간에 공통된 필드(상태)나 중복되는 코드(구현된 메서드)가 많을 때 선택합니다.
- 템플릿 메서드 패턴(Template Method Pattern)처럼, 전체적인 알고리즘의 뼈대는 부모(추상 클래스)가 잡아두고, 특정 단계만 자식에게 위임하고 싶을 때 사용합니다.
상속 구조의 깊이가 깊어지면 부모의 변경이 자식에게 연쇄적으로 영향을 미치는 강한 결합(Tight Coupling) 이 발생합니다.
인터페이스(Interface)
인터페이스는 interface 키워드를 사용하며, 객체가 “무엇을 할 수 있는지(CAN-DO)”를 외부 세계에 약속하는 계약서(Contract) 입니다.
설계 목적(CAN-DO / HAS-A 관계) : 완전히 다른 족보를 가진 객체들이라도, 특정한 기능(능력)을 수행할 수 있음을 보장할 때 사용합니다. 예를 들어, ‘새(Bird)’와 ‘비행기(Airplane)’는 소속(IS-A)은 다르지만 둘 다 ‘날 수 있다(Flyable)’는 공통된 행위를 가집니다.
- 태생적으로 상태를 가질 수 없습니다. (모든 변수는 암묵적으로
public static final상수가 됩니다.) - 전통적으로 모든 메서드는 구현부가 없는 추상 메서드(
public abstract)여야 합니다. - 가장 강력한 장점은 다중 구현(implements)이 가능하다는 것입니다. 하나의 클래스가 여러 개의 인터페이스를 동시에 묶어서(조합) 구현할 수 있습니다.
Java 8의 default 메서드와 인터페이스의 진화
자바 8부터 인터페이스 안에 구현부가 있는 default 메서드를 넣을 수 있게 되었습니다. 다중 상속 시 충돌하는 default 메서드가 있으면 반드시 자식 클래스에서 오버라이딩하여 직접 충돌을 해결하도록 강제하는 방식을 사용합니다.
자바 생태계가 수십 년간 커지면서, 기존에 만들어둔 수많은 인터페이스에 새로운 메서드(List 인터페이스에 stream())를 추가해야 할 일이 생겼습니다. 만약 그냥 추상 메서드로 추가해 버리면, 전 세계의 List를 구현하던 수백만 개의 클래스들이 컴파일 에러를 뿜으며 터져버립니다. 이를 막기 위해 인터페이스에 기본 구현을 제공하는 default 메서드를 탄생시켜 하위 호환성을 지켜낸 것입니다.
사용하는 경우
- 객체의 구현 방식을 철저히 숨기고 외부와의 통신 규약(API)만 정의하고 싶을 때 선택합니다.
- 스프링(Spring) 프레임워크와 같은 현대 자바 생태계에서는 “구현체보다는 인터페이스에 의존하라(DIP 원칙)” 는 철학에 따라, 비즈니스 로직을 설계할 때 거의 무조건 인터페이스를 먼저 뽑아내는 것을 기본(Default)으로 삼습니다.