
모 회사의 PT 면접을 보게 되었다.
PT 주제는 "객체지향 프로그래밍과 SOLID 원칙"이였다. 쉬운 주제라고 생각했지만 면접을 준비하면서, 객체지향에 대해서 이해가 부족하다고 생각이 들었다.
이번 글에서는 객체지향 프로그래밍(OOP) 관련 내용을 정리해보려고 한다.
객체지향 프로그래밍에 대해서 알아보기 전에 객체부터 알아보자. 익숙하게 들었던 객체라는 단어는 어떤 의미를 가지는가? 두 가지 측면에서 살펴볼 수 있다.
우리는 흔히 객체라는 말을 일상에서도 사용한다. 그렇다면 객체의 정확한 사전적 정의는 무엇일까? 네이버 사전을 통해 검색해보았다.

그렇다면 프로그래밍에서의 객체는 어떤가?
많은 의미가 있겠지만 Java의 아버지라고 불리는 James Arthur Gosling 은 아래와 같이 말했다.
An object is a class instance or an array. (객체는 클래스의 인스턴스이거나 배열이다.)
놀랍게도, 프로그래밍에서의 객체는 사전적 의미도 따르지 않는 것을 알 수 있다. 즉, 어떤 것이든 (무생물, 생물, 행위, 상태)가 될 수 있다는 것이다.
객체에 대해서 알았으니, 이런 객체를 지향하는 객체지향 프로그래밍이란 무엇일까?
사전적 정의로는 아래와 같다.
프로그래밍에서 필요한 데이터를 추상화시켜 상태와 행위를 가진 객체로 만들고, 객체들 간의 상호작용을 통해 로직을 구성하는 프로그래밍 방법
여기서 주목할 점은 데이터를 추상화시켜 상태와 행위를 가진 객체로 만든다는 점이다. 생물이 아닌 객체도 능동적이고 자율적인 존재라고 생각하는 기법을 의인화라고 하고, OOP에서는 의인화를 많이 사용한다.
OOP의 최종 목적이 코드의 유지보수성을 높이기 위함이라는 것만은 반드시 기억하자.
이제 OOP의 4가지 특징에 대해서 살펴본다. 어떤 것이 있고, 흔히 어떤 특징을 가지는지는 이 글에서 다루지 않고, 개인적으로 생각할 것들을 작성한다. 현재 "오브젝트(조영호)"를 읽고 있기 때문에 그 책의 개념이 종종 나온다.
불필요한 세부 사항을 숨기고 중요한 속성이나 동작만을 표현하는 것
처음에는 Java의 interface나 abstract class를 사용하면 그것이 곧 추상화라고 생각할 수 있다. 하지만 추상화의 본질은 그것이 아니다. 아래 추상화의 예시를 보자.
1. 현실 사람에게서 서비스에 필요한 정보만 뽑아 모델링하기
현실 사람은 수많은 행동들을 할 수 있다. 먹고, 자고, 걷고, 숨쉬고, 뛸 수 있다.
우리가 흔히 Member 클래스를 만들 때 이런 정보들이 필요할까? 답은 필요할 수도 있고, 필요없을 수도 있다. 답을 정하기 위해서는 추가 정보가 필요하다.
어떤 정보가 필요할까? 코드 예시를 보자.
public class Member {
// 상태 (State): 서비스 운영에 필요한 최소한의 정보
private String nickname;
private String email;
private int point;
// 행위 (Behavior): 서비스 내에서의 상호작용
public void writePost(String content) {
System.out.println(nickname + "님이 글을 작성했습니다: " + content);
this.point += 10; // 글 작성 시 포인트 증가
}
}
클래스는 상태와 행위를 가지고, 서비스를 운영하는데 nickname, email, point을 필요로 한다.
public class Patient {
// 상태 (State): 진료에 필수적인 정보
private String name; // 실명
private double bloodPressure; // 혈압
private String bloodType; // 혈액형
// 행위 (Behavior): 의료 목적의 행위
public void updateBloodPressure(double pressure) {
this.bloodPressure = pressure;
if (pressure > 140) {
System.out.println("주의: 고혈압 경고 상태입니다.");
}
}
public void prescribe(String medicine) {
System.out.println(name + " 환자에게 " + medicine + "을 처방합니다.");
}
}
병원을 운영하는데 필요한 정보인name, bloodPressure, bloodType을 가진다.
위에서 필요했던 nickname, email, point 정보는 필요 없다.
이처럼 추상화는 어떤 시스템에서 어떤 역할을 하는지에 따라 달라지는 정보를 추출하는 것을 말한다.
2. 복잡한 로직을 단일 API 내부에 숨김
public static void main(String[] args) {
System.out.println("hello world!");
}
처음 Java를 배운다면 한 번쯤 작성했을법한 코드이다. 단순히 "hello world!"를 출력하는 코드이다. 그런데System.out.println 메서드 내부는 그렇게 간단하지 않다.
public void println(String x) {
if (getClass() == PrintStream.class) {
writeln(String.valueOf(x));
} else {
synchronized (this) {
print(x);
newLine();
}
}
}
처음 Java를 배울 때 우리가 이 코드를 이해해야 할까? 알 필요 없다. 단순히 System.out.println을 사용하면 문자열을 출력한다고만 생각하면 된다. 이처럼 복잡한 로직을 단일 API에 숨기는 것도 추상화라고 할 수 있다.
3. 인터페이스나 추상 클래스를 통해 구체적인 구현이 아닌 '역할'과 '규격'을 정의
결제 시스템을 생각하자. 결제 방식에는 KakaoPay, NaverPay.. 등등 많이 존재할텐데, 이것들을 interface 하나로 추상화할 수 있다.
public interface Payment {
void pay(int amount); // 결제 수단이라면 무조건 이 규칙을 따라야 함
}
// 2. 구체적인 구현체들
public class KakaoPay implements Payment {
@Override
public void pay(int amount) {
System.out.println("카카오페이로 " + amount + "원 결제합니다.");
}
}
public class NaverPay implements Payment {
@Override
public void pay(int amount) {
System.out.println("네이버페이로 " + amount + "원 결제합니다.");
}
}
이러면 OrderService 은 interface에 의존할 수 있다. (추상화에 의존)
public class OrderService {
// 구체적인 구현이 아닌 '추상적인 역할(인터페이스)'에 의존
private Payment payment;
public OrderService(Payment payment) {
this.payment = payment;
}
public void processOrder(int amount) {
// 어떤 페이든 상관없이 규격(pay)에 맞춰 호출하면 됨
payment.pay(amount);
}
}
흔히 생각할 수 있는 interface, abstract class 추상화가 여기에 해당한다.
여기까지 3가지가 추상화의 내용이었다. 파편적인 정보라고 생각할 수 있지만, 공통된 목표를 가리킨다는 것을 명심하자
추상화는 불필요한 세부 사항을 숨기고 중요한 속성이나 동작만을 표현하는 것이다.
상태와 행위를 하나로 묶어 객체가 스스로를 책임지게 만드는 것
처음에는 캡슐화를 클래스 내부 필드를 private 로 선언하고 Getter/Setter를 통해 접근하는 것만을 떠올릴 수 있다. 하지만 그것이 본질은 아니다.
본질은 데이터와 기능을 한 덩어리로 묶는 것이다. 코드 예시를 보자.
나쁜 코드 예시
public class BankAccount {
private long balance;
public long getBalance() { return balance; }
public void setBalance(long balance) { this.balance = balance; }
}
public class TransferService {
public void transfer(BankAccount from, BankAccount to, long amount) {
// 규칙(음수 금지, 잔액 부족 금지)이 여기저기 흩어지기 쉬움
from.setBalance(from.getBalance() - amount); // 잔액 부족이면 음수 가능
to.setBalance(to.getBalance() + amount);
}
}
위 코드는 캡슐화 관점에서 바람직하지 않은 예시다.
BankAccount는 내부 필드를 private하게 가지고 있지만 (balance), Getter/Setter를 통해 그대로 노출하고 있는 것이나 마찬가지다.
(여기서 그대로 노출이라고 함은 Getter를 통한 필드 노출이기도 하고, getBalance라는 메서드 이름을 통한 필드 노출이기도 하다.)
balance값을 마음대로 수정할 수 있다. 잔액이 음수가 되어도 막을 수 없다. 검증 로직을 추가할 수는 있지만, 서비스 곳곳에 검증 로직이 중복될 것이다.
그렇기 때문에BankAccount는 객체라기 보다는 "데이터 덩어리"에 가깝다.
좋은 코드 예시
public class BankAccount {
private long balance; // 원 단위
public BankAccount(long initialBalance) {
if (initialBalance < 0) throw new IllegalArgumentException("초기 잔액은 0 이상");
this.balance = initialBalance;
}
public long balance() { // 읽기는 허용(읽기 전용)
return balance;
}
public void deposit(long amount) {
validatePositive(amount);
balance += amount;
}
public void withdraw(long amount) {
validatePositive(amount);
if (balance < amount) throw new IllegalStateException("잔액 부족");
balance -= amount;
}
private void validatePositive(long amount) {
if (amount <= 0) throw new IllegalArgumentException("금액은 1원 이상");
}
}
public class TransferService {
public void transfer(BankAccount from, BankAccount to, long amount) {
from.withdraw(amount);
to.deposit(amount);
}
}
balance(상태)를 BankAccount 스스로 지키도록 행위 중심 API를 제공하고 있다. 제대로 된 캡슐화이다.상속은 기존 클래스의 동작과 타입을 물려받아, 새로운 클래스를 만드는 방식이다.
상속의 장점?
다양한 장점이 있지만, 단점도 존재한다.
상속의 단점?
상속의 단점 때문에 항상 "합성" 방식과 비교하게 된다. 그렇다면 합성은 무엇일까?
합성(Composition)이란?
합성은 객체가 다른 객체를 “가지고(has-a)”, 그 객체에게 일을 맡기거나(위임/delegation) 조합해서 기능을 만드는 방식이다.
class Engine {
void start() {
System.out.println("엔진 시동");
}
}
class Car {
private final Engine engine; // has-a 관계
Car(Engine engine) {
this.engine = engine;
}
void start() {
engine.start(); // 위임
System.out.println("자동차 출발");
}
}
Car는 Engine을 상속하지 않는다. 대신 Engine을 “가지고” 있고, 필요할 때 일을 맡긴다.
합성 방식을 사용하면 상속의 단점을 상당 부분 완화할 수 있다.
하지만 지금도 많은 Java 코드에서 상속을 사용하고 있고, 계층적인 구조를 나타내기 위해서는 상속이 필요하다고 생각한다. 따라서 계층적 모델링이 필요한 경우에는 상속을 사용하고, 그 외에는 합성 방식을 사용해 유연성을 확보하는 것이 바람직하다.
같은 자료형에 여러 가지 타입의 데이터를 대입하여 다양한 결과를 얻을 수 있는 성질.
즉, 같은 타입으로 다루지만, 실제 동작은 다르게 수행되는 것이다. 코드 예시를 보자.
public interface Payment {
void pay(int amount);
}
public class CardPayment implements Payment {
@Override
public void pay(int amount) {
// 카드 결제 로직 ~~
System.out.println("카드로 " + amount + "원 결제");
}
}
public class KakaoPayment implements Payment {
@Override
public void pay(int amount) {
// 카카오페이 결제 ~~
System.out.println("카카오페이로 " + amount + "원 결제");
}
}
결제 도메인을 생각하자. Payment 인터페이스를 통해 "결제"라는 행위를 정의하고, 구현체(CardPayment, KakaoPayment)에서 결제 방법을 다르게 정의한다.
public class OrderService {
private final Payment payment;
public OrderService(Payment payment) {
this.payment = payment; // 다형성 활용
}
public void checkout(int amount) {
payment.pay(amount);
}
}
그러면 OrderService 에서는 Payment 인터페이스만 알고 pay() 메서드만 호출하면 된다. 결제 종류가 어떤 것이 있는지 알 필요가 없다.
다형성을 활용하면 기존 코드 수정 없이 새로운 구현체만 추가하면 된다. (개방폐쇄원칙(OCP) 달성)
만약 다형성을 활용하지 않았다면, OrderService가 결제 종류를 모두 알아야 하고, if-else 구문을 활용했을 것이다. (단일 책임 원칙에도 맞지 않는다)
public class OrderService {
private final CardPayment cardPayment;
private final KakaoPayment kakaoPayment;
...
public void checkout(int amount, Type type) {
if (type.equals("CARD")) {
// 카드 결제
cardPayment.pay(amount);
} else if (type.equals("KAKAO")) {
// 카카오페이 결제
kakaoPayment.pay(amount);
}
}
}
객체지향의 4가지 특징(추상화, 캡슐화, 상속, 다형성)은 단순히 시험 문제로 외우는 개념이 아니다. 이 개념들은 결국 하나의 목표를 가지고 있다.
변화에 유연한 코드를 만들기 위함이다.
왜 변화에 유연한 코드를 만들어야 할까? 현실의 서비스는 반드시 변하기 때문이다.
요구사항은 계속 추가되고, 기능은 수정되고, 정책은 바뀐다.
개발자는 부지런하지만 게으르다. 변화에 유연하지 않은 코드라면 수정하기 위해 많은 시간을 들여야 하지만, 변화에 유연한 코드는 적은 시간으로도 충분하다.
실제로 프로젝트를 하면서 기능 수정을 해야될 상황이 많았는데, 여기저기 코드가 수정되었다면 객체지향적으로 맞는 설계를 했는지 다시 생각해볼 필요가 있겠다.
지금까지 객체지향 프로그래밍의 4가지 특성에 대해서 살펴보았다. 그런데 이것만으로는 충분하지 않다는 생각이 든다.
그래서 뭐 어떻게 설계하면 객체지향적인 코드를 작성할 수 있나요? 이게 좋은건 알겠는데, 설계 방식 같은 건 안 알려주나요?
이 질문에 대한 해답은 SOLID 원칙에 있다. 다음 글에서는 SOLID에 대해서, 그리고 각각 객체지향 프로그래밍의 어떤 특성과 연관이 있는지 살펴보도록 한다.