객체지향 프로그래밍(OOP) 완벽 가이드: 네 가지 핵심 개념과 실무 적용

객체지향 프로그래밍(OOP) 완벽 가이드: 네 가지 핵심 개념과 실무 적용

객체지향 프로그래밍(Object-Oriented Programming, OOP)은 프로그램을 “객체(object)”라는 단위로 구성하는 프로그래밍 패러다임이다. 객체는 데이터(상태)와 그 데이터를 다루는 행위(메서드)를 하나로 묶은 것이다.

OOP 이전의 절차적 프로그래밍은 데이터와 함수가 분리되어 있었다. 함수가 데이터를 입력받아 처리하고 결과를 반환하는 구조다. 프로그램이 작을 때는 문제가 없지만, 규모가 커지면 “어떤 함수가 어떤 데이터를 다루는가”가 불명확해지고, 데이터 구조가 바뀌면 관련 함수를 모두 찾아서 수정해야 한다.

OOP는 이 문제를 “관련 있는 데이터와 행위를 하나의 객체로 묶어라”라는 원칙으로 해결한다. 객체가 자신의 데이터를 스스로 관리하고, 외부에는 정해진 인터페이스만 노출한다. 이것이 OOP의 출발점이다.

이 글은 OOP의 네 가지 핵심 개념(추상화, 캡슐화, 상속, 다형성)이 각각 무엇을 말하는지, 왜 필요한지, Java에서 어떻게 구현되는지, JVM 내부에서 어떤 메커니즘으로 동작하는지, 그리고 실무에서 어떻게 활용하고 어떤 함정을 피해야 하는지를 단계적으로 짚는다.


1. 추상화 (Abstraction)

“복잡한 시스템에서 핵심적인 개념만 추출하고, 불필요한 세부 사항은 숨긴다.”

추상화는 OOP의 가장 근본적인 개념이다. 현실 세계의 복잡한 사물을 프로그램으로 모델링할 때, 모든 세부 사항을 코드로 옮기는 것은 불가능하다. 문제 해결에 필요한 핵심 속성과 행위만 뽑아내는 과정이 추상화다.

추상화는 “무엇을 드러낼 것인가”와 “무엇을 숨길 것인가”를 결정하는 설계 행위다.

추상화의 두 가지 형태

데이터 추상화: 객체의 핵심 속성만 정의한다.

1
2
3
4
5
6
7
8
9
// 실제 직원의 속성은 수백 가지다 (키, 몸무게, 취미, 혈액형...)
// 인사 시스템에서 필요한 속성만 추출한다
public class Employee {
    private String employeeId;
    private String name;
    private String department;
    private BigDecimal salary;
    private LocalDate hireDate;
}

실제 직원에 대한 정보는 무한히 많지만, 인사 시스템이라는 맥락에서 필요한 속성만 추출했다. 같은 “직원”이라도 급여 시스템에서는 계좌번호와 세율이 중요하고, 출입 관리 시스템에서는 사진과 카드 번호가 중요하다. 추상화는 항상 맥락에 따라 달라진다.

행위 추상화: 객체가 “무엇을 하는가”만 정의하고, “어떻게 하는가”는 숨긴다.

1
2
3
4
5
// 행위 추상화: "결제한다"는 행위만 정의
public interface PaymentGateway {
    PaymentResult charge(BigDecimal amount, PaymentMethod method);
    PaymentResult refund(String transactionId, BigDecimal amount);
}

PaymentGateway를 사용하는 코드는 결제가 내부적으로 카드사 API를 호출하는지, 가상계좌를 발급하는지 알 필요가 없다. “결제를 요청하면 결과가 돌아온다”는 추상적인 행위만 알면 된다.

Java에서의 추상화 도구: 인터페이스 vs 추상 클래스

Java는 추상화를 위한 두 가지 핵심 도구를 제공한다.

인터페이스 (Interface): 순수한 행위 계약을 정의한다.

1
2
3
4
5
6
7
public interface Searchable {
    List<Result> search(String keyword);
}

public interface Sortable<T> {
    List<T> sort(Comparator<T> comparator);
}

인터페이스는 “이 객체가 할 수 있는 것”을 선언한다. 구현 방법은 전혀 포함하지 않으므로 가장 높은 수준의 추상화를 제공한다. Effective Java 아이템 20이 “추상 클래스보다는 인터페이스를 우선하라”고 권고하는 이유가 여기에 있다.

추상 클래스 (Abstract Class): 공통 구현을 포함하면서 일부를 추상으로 남긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public abstract class AbstractNotificationSender {
    // 템플릿 메서드: 골격은 고정, 세부는 확장 가능
    public final void send(Notification notification) {
        validate(notification);
        log(notification);
        doSend(notification);
    }

    private void validate(Notification notification) {
        if (notification.getRecipient() == null) {
            throw new IllegalArgumentException("수신자가 없습니다.");
        }
    }

    private void log(Notification notification) {
        System.out.println("[알림] " + notification.getType()
            + " → " + notification.getRecipient());
    }

    // 추상 메서드: 하위 클래스가 반드시 구현해야 한다
    protected abstract void doSend(Notification notification);
}

public class EmailNotificationSender extends AbstractNotificationSender {
    @Override
    protected void doSend(Notification notification) {
        // 이메일 발송 구현
    }
}

public class SmsNotificationSender extends AbstractNotificationSender {
    @Override
    protected void doSend(Notification notification) {
        // SMS 발송 구현
    }
}

인터페이스와 추상 클래스의 선택 기준

기준 인터페이스 추상 클래스
다중 상속 가능 (여러 인터페이스 구현) 불가 (단일 상속만)
상태(필드) 상수만 가능 (static final) 인스턴스 필드 가능
생성자 없음 있음
접근 제어자 public만 (Java 9부터 private 가능) 모든 접근 제어자 가능
적합한 상황 “할 수 있다(can-do)” 관계 표현 “~이다(is-a)” 관계 + 공통 구현 공유

실무 규칙은 단순하다.

  • 기본적으로 인터페이스를 사용한다. 다중 구현이 가능하고 결합도가 가장 낮다.
  • 공통 구현을 공유해야 할 때만 추상 클래스를 사용한다. 템플릿 메서드 패턴이 대표적이다.
  • 둘을 조합할 수 있다. 인터페이스로 계약을 정의하고, 추상 클래스로 기본 구현을 제공하는 “골격 구현(skeletal implementation)” 패턴이 효과적이다.

골격 구현 패턴 (Skeletal Implementation) — Effective Java 아이템 20

이 패턴은 인터페이스와 추상 클래스의 장점을 동시에 얻는 방법이다. Java 표준 라이브러리의 AbstractList, AbstractSet, AbstractMap이 대표적인 예다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 1단계: 인터페이스로 계약을 정의한다
public interface Vending {
    void start();
    void chooseProduct();
    void stop();
    void process();
}

// 2단계: 골격 구현 추상 클래스를 제공한다
public abstract class AbstractVending implements Vending {
    @Override
    public void start() {
        System.out.println("시작합니다.");
    }

    @Override
    public void stop() {
        System.out.println("종료합니다.");
    }

    // 템플릿 메서드
    @Override
    public final void process() {
        start();
        chooseProduct();  // 하위 클래스가 구현
        stop();
    }
}

// 3단계: 구현체는 골격 구현을 확장한다
public class CandyVending extends AbstractVending {
    @Override
    public void chooseProduct() {
        System.out.println("사탕을 선택했습니다.");
    }
}

public class DrinkVending extends AbstractVending {
    @Override
    public void chooseProduct() {
        System.out.println("음료를 선택했습니다.");
    }
}

Vending 인터페이스 타입으로 다형적 사용이 가능하고, AbstractVending이 공통 로직을 제공하며, 각 구현체는 chooseProduct()만 구현하면 된다. 인터페이스의 유연성과 추상 클래스의 편의성을 동시에 얻는다.

Java 17 Sealed 클래스와 추상화

Java 17에서 도입된 sealed 클래스는 추상화의 새로운 차원을 열었다. 상속 가능한 하위 타입을 명시적으로 제한할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// sealed 클래스: 허용된 하위 타입만 존재할 수 있다
public sealed interface Shape permits Circle, Rectangle, Triangle {
    double area();
    double perimeter();
}

public record Circle(double radius) implements Shape {
    @Override
    public double area() { return Math.PI * radius * radius; }
    @Override
    public double perimeter() { return 2 * Math.PI * radius; }
}

public record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() { return width * height; }
    @Override
    public double perimeter() { return 2 * (width + height); }
}

public record Triangle(double a, double b, double c) implements Shape {
    @Override
    public double area() {
        double s = (a + b + c) / 2;
        return Math.sqrt(s * (s - a) * (s - b) * (s - c));
    }
    @Override
    public double perimeter() { return a + b + c; }
}

sealed는 “추상화의 범위를 제한”한다. 일반 인터페이스는 누구든 구현할 수 있지만, sealed 인터페이스는 허용된 타입만 구현할 수 있다. 이를 통해 컴파일러가 switch 표현식에서 모든 경우를 검사할 수 있게 된다.

1
2
3
4
5
6
7
8
9
// Java 21: sealed 타입과 switch 패턴 매칭
public String describe(Shape shape) {
    return switch (shape) {
        case Circle c    -> "반지름 " + c.radius() + "인 원";
        case Rectangle r -> r.width() + " x " + r.height() + " 직사각형";
        case Triangle t  -> "삼각형 (둘레: " + t.perimeter() + ")";
        // default 불필요 — 컴파일러가 모든 케이스를 확인
    };
}

추상화의 적절한 수준

추상화는 “너무 많아도, 너무 적어도” 문제다.

과도한 추상화: 모든 클래스에 인터페이스를 만들고, 구현체가 하나뿐인 인터페이스가 넘쳐난다. 코드를 읽으려면 인터페이스와 구현체를 번갈아 봐야 해서 가독성이 떨어진다. 이것을 “인터페이스 만능주의(Interface-itis)”라고 부른다.

부족한 추상화: 구체 클래스에 직접 의존하고 있어서, 구현을 교체하거나 테스트에서 목(mock)을 주입하기 어렵다.

적절한 수준의 기준은 다음과 같다.

  • 교체 가능성이 있으면 추상화한다: DB, 외부 API, 알림 시스템 등
  • 테스트에서 격리가 필요하면 추상화한다: 느린 I/O, 외부 의존성
  • 여러 구현이 존재하면 추상화한다: 결제 수단, 인증 방식 등
  • 위 조건에 해당하지 않으면 구체 클래스를 직접 사용해도 된다

2. 캡슐화 (Encapsulation)

“객체의 내부 상태를 외부에서 직접 접근하지 못하게 숨기고, 정해진 메서드를 통해서만 상호작용하게 한다.”

캡슐화는 데이터 보호와 정보 은닉(Information Hiding)의 핵심이다. 객체가 자신의 데이터를 스스로 관리하고, 외부에는 의미 있는 행위만 공개한다.

Effective Java 아이템 15(“클래스와 멤버의 접근 권한을 최소화하라”)가 캡슐화의 구체적인 지침이다.

캡슐화가 없는 코드의 문제

1
2
3
4
5
6
7
8
9
10
11
12
// 캡슐화 없음: 모든 필드가 public
public class BankAccount {
    public String accountNumber;
    public BigDecimal balance;
    public String ownerName;
    public boolean frozen;
}

// 어디서든 직접 접근할 수 있다
BankAccount account = new BankAccount();
account.balance = new BigDecimal("-1000000");  // 잔액이 음수?
account.frozen = false;                         // 동결 계좌를 직접 해제?

필드가 public이면 누구든 아무 값이나 넣을 수 있다. 잔액을 음수로 만들거나, 동결된 계좌를 해제하는 것을 막을 방법이 없다. 비즈니스 규칙이 객체 바깥에 흩어지고, 데이터의 일관성을 보장할 수 없다.

캡슐화 적용: 행위 중심 설계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class BankAccount {
    private final String accountNumber;
    private BigDecimal balance;
    private final String ownerName;
    private boolean frozen;

    public BankAccount(String accountNumber, String ownerName,
                       BigDecimal initialBalance) {
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        this.balance = initialBalance;
        this.frozen = false;
    }

    // 의미 있는 행위를 통해서만 상태 변경
    public void deposit(BigDecimal amount) {
        validateNotFrozen();
        validatePositiveAmount(amount);
        this.balance = this.balance.add(amount);
    }

    public void withdraw(BigDecimal amount) {
        validateNotFrozen();
        validatePositiveAmount(amount);
        if (this.balance.compareTo(amount) < 0) {
            throw new InsufficientBalanceException(
                "잔액 부족: 현재 " + this.balance + "원, 요청 " + amount + "원"
            );
        }
        this.balance = this.balance.subtract(amount);
    }

    public void freeze() {
        this.frozen = true;
    }

    public void unfreeze(String adminCode) {
        // 관리자 코드 검증 후에만 해제
        if (!"ADMIN-SECRET".equals(adminCode)) {
            throw new UnauthorizedException("권한이 없습니다.");
        }
        this.frozen = false;
    }

    public BigDecimal getBalance() {
        return this.balance;
    }

    public String getAccountNumber() {
        return this.accountNumber;
    }

    private void validateNotFrozen() {
        if (frozen) {
            throw new AccountFrozenException("동결된 계좌입니다.");
        }
    }

    private void validatePositiveAmount(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("금액은 양수여야 합니다.");
        }
    }
}

이제 잔액은 deposit()withdraw()를 통해서만 변경된다. 입금액이 음수인 것을 막고, 잔액 부족 시 출금을 거부하고, 동결된 계좌에서의 거래를 차단하는 비즈니스 규칙이 객체 안에 모여 있다. 계좌 해제는 관리자 코드가 있어야만 가능하다. 외부에서는 이 규칙을 우회할 방법이 없다.

Java의 접근 제어자와 캡슐화 전략

접근 제어자 같은 클래스 같은 패키지 하위 클래스 외부
private O X X X
(default, package-private) O O X X
protected O O O X
public O O O O

Effective Java의 캡슐화 원칙: 가장 제한적인 접근 제어자부터 시작하라.

  1. 필드는 항상 private으로 선언한다. public 필드는 캡슐화를 완전히 포기하는 것이다. Effective Java 아이템 16은 “public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라”고 명시한다.
  2. 메서드는 private으로 시작한다. 외부에 공개할 필요가 있을 때만 범위를 넓힌다.
  3. 패키지 내부에서만 사용되면 package-private(default)으로 충분하다. 패키지를 모듈 경계로 활용하면 접근 범위를 효과적으로 제한할 수 있다.
  4. 상속을 위한 것이면 protected를 사용한다. 그러나 protected는 같은 패키지에서도 접근 가능하므로 남용하면 캡슐화가 약해진다.
  5. 외부 API로 공개할 것만 public으로 선언한다. 한 번 public으로 공개된 API는 호환성 때문에 변경하기 어렵다.

getter/setter의 함정: 잘못된 캡슐화

캡슐화를 이야기할 때 자주 나오는 오해가 있다. “필드를 private으로 만들고 getter/setter를 만들면 캡슐화다”라는 생각이다. 이것은 형식적 캡슐화에 불과하다.

1
2
3
4
5
6
7
8
9
10
11
12
// 잘못된 캡슐화: getter/setter가 모든 필드에 있으면
// public 필드와 실질적으로 다를 바 없다
public class Order {
    private String status;

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}

// 외부에서 상태를 아무렇게나 바꿀 수 있다
order.setStatus("아무거나");  // 유효하지 않은 상태값
order.setStatus("SHIPPED");  // 결제 전에 배송 완료?

올바른 캡슐화: 상태 변경을 의미 있는 행위로 표현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class Order {
    private OrderStatus status;
    private LocalDateTime paidAt;
    private LocalDateTime shippedAt;
    private LocalDateTime deliveredAt;

    public Order() {
        this.status = OrderStatus.CREATED;
    }

    public void pay() {
        if (this.status != OrderStatus.CREATED) {
            throw new IllegalStateException(
                "결제 가능한 상태가 아닙니다. 현재 상태: " + this.status);
        }
        this.status = OrderStatus.PAID;
        this.paidAt = LocalDateTime.now();
    }

    public void ship() {
        if (this.status != OrderStatus.PAID) {
            throw new IllegalStateException(
                "결제된 주문만 배송할 수 있습니다. 현재 상태: " + this.status);
        }
        this.status = OrderStatus.SHIPPED;
        this.shippedAt = LocalDateTime.now();
    }

    public void deliver() {
        if (this.status != OrderStatus.SHIPPED) {
            throw new IllegalStateException(
                "배송 중인 주문만 배송 완료할 수 있습니다. 현재 상태: " + this.status);
        }
        this.status = OrderStatus.DELIVERED;
        this.deliveredAt = LocalDateTime.now();
    }

    public void cancel() {
        if (this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException("배송 완료된 주문은 취소할 수 없습니다.");
        }
        this.status = OrderStatus.CANCELLED;
    }

    public OrderStatus getStatus() { return this.status; }
    public boolean isPaid() { return this.paidAt != null; }
}

public enum OrderStatus {
    CREATED, PAID, SHIPPED, DELIVERED, CANCELLED
}

주문 상태는 CREATED → PAID → SHIPPED → DELIVERED라는 정해진 흐름을 따를 수밖에 없다. setStatus() 대신 pay(), ship(), deliver()라는 의미 있는 행위로 상태를 변경한다. 취소는 배송 완료 전에만 가능하다. 이것이 행위 중심의 캡슐화다.

“묻지 말고 시켜라” 원칙 (Tell, Don’t Ask)

캡슐화와 밀접한 원칙이 “Tell, Don’t Ask”다. 객체의 상태를 꺼내서(ask) 판단하지 말고, 객체에게 행위를 시키라(tell).

1
2
3
4
5
6
7
8
// 안티패턴: 묻고 판단하기 (Ask)
if (account.getBalance().compareTo(amount) >= 0
    && !account.isFrozen()) {
    account.setBalance(account.getBalance().subtract(amount));
}

// 올바른 방식: 시키기 (Tell)
account.withdraw(amount);

첫 번째 코드는 계좌의 잔액과 동결 여부를 꺼내서 직접 판단하고, 직접 계산해서 다시 넣는다. 비즈니스 규칙이 객체 바깥에 흩어져 있다. 다른 곳에서 같은 출금 로직을 사용할 때 잔액 검사를 빠뜨리면 버그가 된다.

두 번째 코드는 계좌에게 “출금하라”고 명령한다. 잔액 확인, 금액 차감, 동결 여부 검사 등의 비즈니스 규칙은 모두 BankAccount 안에 있다. 어디서 호출하든 같은 규칙이 적용된다.

방어적 복사 (Defensive Copying) — Effective Java 아이템 50

캡슐화가 깨지는 미묘한 경로가 있다. 가변 객체를 그대로 반환하면, 외부에서 그 객체를 수정하여 내부 상태를 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 캡슐화 깨짐: 내부 가변 객체를 그대로 반환
public class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        this.start = start;
        this.end = end;
    }

    public Date getStart() { return start; }  // 위험!
    public Date getEnd() { return end; }      // 위험!
}

// 공격 코드
Period p = new Period(new Date(), new Date());
p.getEnd().setYear(78);  // Period의 내부 상태가 변경됨!

Date 객체는 가변(mutable)이므로, 반환받은 참조를 통해 Period의 내부 상태를 직접 변경할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 방어적 복사로 캡슐화 보호
public class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end) {
        // 생성자에서 방어적 복사
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());

        if (this.start.compareTo(this.end) > 0) {
            throw new IllegalArgumentException(
                "시작일이 종료일보다 늦을 수 없습니다.");
        }
    }

    public Date getStart() {
        return new Date(start.getTime());  // 복사본 반환
    }

    public Date getEnd() {
        return new Date(end.getTime());    // 복사본 반환
    }
}

생성자에서 매개변수를 복사하고, getter에서도 복사본을 반환한다. 외부에서 반환된 Date를 수정해도 Period의 내부 상태에는 영향을 주지 않는다.

가장 좋은 해결책은 불변 객체를 사용하는 것이다. Date 대신 LocalDate, LocalDateTime 같은 불변 클래스를 사용하면 방어적 복사가 필요 없다.

불변 객체와 캡슐화 — Effective Java 아이템 17

캡슐화의 가장 강력한 형태는 불변 객체(Immutable Object)다. 상태를 아예 변경할 수 없게 만들면, 잘못된 변경 자체가 불가능해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public final class Money {
    private final BigDecimal amount;
    private final Currency currency;

    public Money(BigDecimal amount, Currency currency) {
        this.amount = Objects.requireNonNull(amount);
        this.currency = Objects.requireNonNull(currency);
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("금액은 음수일 수 없습니다.");
        }
    }

    // 상태를 변경하지 않고, 새로운 객체를 반환한다
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("통화가 다릅니다.");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int multiplier) {
        return new Money(
            this.amount.multiply(BigDecimal.valueOf(multiplier)),
            this.currency
        );
    }

    public BigDecimal getAmount() { return amount; }
    public Currency getCurrency() { return currency; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money money)) return false;
        return amount.compareTo(money.amount) == 0
            && currency.equals(money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}

불변 객체의 장점은 다음과 같다.

  1. 스레드 안전: 상태가 변하지 않으므로 동기화 없이 공유할 수 있다.
  2. 방어적 복사 불필요: 내부 상태가 변하지 않으므로 참조를 그대로 반환해도 안전하다.
  3. Map의 키나 Set의 원소로 사용 가능: hashCode()가 변하지 않으므로 컬렉션에서 안전하다.
  4. 실패 원자성: 연산 중 예외가 발생해도 기존 객체의 상태는 변하지 않는다.

Java의 String, Integer, LocalDate, BigDecimal 등이 모두 불변 객체다. Java 16의 record도 불변 객체를 간결하게 만드는 도구다.

1
2
3
4
5
6
7
8
9
10
11
// Java record: 불변 데이터 클래스를 간결하게
public record Point(int x, int y) {
    // 자동으로 final 필드, 생성자, getter, equals, hashCode, toString 생성
}

public record OrderSummary(
    String orderId,
    BigDecimal totalAmount,
    OrderStatus status,
    LocalDateTime createdAt
) {}

3. 상속 (Inheritance)

“기존 클래스의 속성과 행위를 새로운 클래스가 물려받아 재사용하고 확장한다.”

상속은 코드 재사용과 계층 구조 표현을 위한 OOP의 핵심 메커니즘이다. 그러나 Effective Java와 SOLID 원칙의 관점에서 보면, 상속은 네 가지 개념 중 가장 신중하게 사용해야 하는 도구이기도 하다.

상속의 기본 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Animal {
    private String name;
    private int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void eat() {
        System.out.println(name + "이(가) 먹이를 먹습니다.");
    }

    public void sleep() {
        System.out.println(name + "이(가) 잠을 잡니다.");
    }

    public String getName() { return name; }
}

public class Dog extends Animal {
    private String breed;

    public Dog(String name, int age, String breed) {
        super(name, age);
        this.breed = breed;
    }

    public void fetch() {
        System.out.println(getName() + "이(가) 공을 물어옵니다.");
    }

    @Override
    public void eat() {
        System.out.println(getName() + "이(가) 사료를 먹습니다.");
    }
}

JVM에서의 메서드 디스패치: 상속이 동작하는 내부 메커니즘

상속과 오버라이딩이 런타임에 어떻게 동작하는지 이해하면, 다형성의 비용과 한계를 파악할 수 있다.

JVM은 메서드 호출을 두 가지 바이트코드 명령으로 처리한다.

invokevirtual: 일반 인스턴스 메서드 호출. 객체의 실제 타입에 따라 호출할 메서드를 결정한다.

invokeinterface: 인터페이스 메서드 호출. invokevirtual과 비슷하지만, 인터페이스 메서드 테이블에서 검색한다.

1
2
Animal animal = new Dog("바둑이", 3, "진돗개");
animal.eat();  // invokevirtual — Dog.eat()이 호출됨

JVM은 각 클래스에 대해 가상 메서드 테이블(vtable)을 생성한다. vtable은 클래스의 각 메서드가 실제로 어떤 구현을 가리키는지를 저장하는 배열이다.

1
2
3
4
5
6
7
8
Animal의 vtable:
  eat()   → Animal.eat()
  sleep() → Animal.sleep()

Dog의 vtable:
  eat()   → Dog.eat()        ← 오버라이딩됨
  sleep() → Animal.sleep()   ← 상속받은 그대로
  fetch() → Dog.fetch()      ← 새로 추가

animal.eat()을 호출하면 JVM은 animal이 가리키는 실제 객체(Dog)의 vtable에서 eat()의 구현을 찾아 Dog.eat()을 실행한다. 이것이 동적 디스패치(dynamic dispatch)이고, 다형성의 기반이다.

상속의 위험성: “깨지기 쉬운 기반 클래스 문제” — Effective Java 아이템 18

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 원소 추가 횟수를 세는 HashSet — 상속의 함정
public class CountingHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}
1
2
3
CountingHashSet<String> set = new CountingHashSet<>();
set.addAll(List.of("A", "B", "C"));
System.out.println(set.getAddCount());  // 6! (기대값 3)

addCount가 3이 아니라 6이다. HashSet.addAll()이 내부적으로 add()를 호출하기 때문이다. addAll()에서 3을 더하고, super.addAll()이 각 원소에 대해 add()를 호출하면서 다시 3이 더해진다.

이것이 “깨지기 쉬운 기반 클래스 문제(Fragile Base Class Problem)”다. 하위 클래스가 상위 클래스의 구현 세부 사항에 의존하게 되고, 상위 클래스의 내부 구현이 바뀌면 하위 클래스가 예기치 않게 깨진다.

더 큰 문제는 이 상황을 HashSet의 문서만으로는 알 수 없다는 것이다. addAll()add()를 호출한다는 것은 구현 세부 사항이지, 공식 계약의 일부가 아니다. Java의 다음 버전에서 addAll()의 내부 구현이 바뀌면, CountingHashSet은 다시 깨질 수 있다.

조합(Composition)으로 해결하기 — Effective Java 아이템 18

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 전달 클래스(Forwarding Class): 래퍼 패턴
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;

    public ForwardingSet(Set<E> s) { this.s = s; }

    @Override public boolean add(E e) { return s.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    @Override public int size() { return s.size(); }
    @Override public boolean contains(Object o) { return s.contains(o); }
    @Override public boolean remove(Object o) { return s.remove(o); }
    @Override public Iterator<E> iterator() { return s.iterator(); }
    @Override public boolean isEmpty() { return s.isEmpty(); }
    @Override public void clear() { s.clear(); }
    // ... 나머지 메서드도 전달
    @Override public Object[] toArray() { return s.toArray(); }
    @Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
    @Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
    @Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
    @Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
}

// 래퍼 클래스: 전달 클래스를 확장하여 기능 추가
public class CountingSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public CountingSet(Set<E> s) { super(s); }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}
1
2
3
CountingSet<String> set = new CountingSet<>(new HashSet<>());
set.addAll(List.of("A", "B", "C"));
System.out.println(set.getAddCount());  // 3 — 올바른 결과

super.addAll()이 내부적으로 s.addAll()을 호출하고, s.addAll()s.add()를 호출하더라도 CountingSet.add()가 아니라 HashSet.add()가 호출된다. addCount에 영향을 주지 않는다.

이 패턴은 데코레이터(Decorator) 패턴이기도 하다. ForwardingSet은 어떤 Set 구현체든 감쌀 수 있고, CountingSet은 카운팅 기능을 동적으로 추가한다.

상속을 사용해야 하는 경우 vs 조합을 사용해야 하는 경우

기준 상속 조합
관계 진정한 “is-a” 관계 “has-a” 또는 “uses-a” 관계
상위 클래스 통제 상위 클래스를 직접 설계하거나 확장용으로 문서화된 경우 제3자가 만든 클래스를 활용할 때
재정의 필요성 상위 클래스의 행위를 변경해야 할 때 기존 클래스의 행위를 활용만 할 때
결합도 높음 (상위 클래스 변경에 영향 받음) 낮음 (내부 구현에 독립적)

상속이 적합한 경우:

  1. 프레임워크가 확장을 위해 설계한 클래스: HttpServlet, JsonSerializer<T>
  2. 같은 패키지 내에서 설계자가 통제하는 클래스: 상위 클래스의 구현 세부 사항을 알고 있고, 변경 시 하위 클래스도 함께 수정할 수 있다
  3. Effective Java 아이템 19를 따르는 클래스: “상속을 고려해 설계하고 문서화하라”를 따른 클래스
1
2
3
4
5
6
7
8
9
10
11
// 프레임워크가 확장을 위해 설계한 클래스
public class CustomJsonSerializer extends JsonSerializer<Money> {
    @Override
    public void serialize(Money value, JsonGenerator gen,
                           SerializerProvider provider) throws IOException {
        gen.writeStartObject();
        gen.writeNumberField("amount", value.getAmount());
        gen.writeStringField("currency", value.getCurrency().getCurrencyCode());
        gen.writeEndObject();
    }
}

실무 원칙 정리:

  • 상속보다 조합을 기본으로 선택하라 (Effective Java 아이템 18)
  • 상속은 확장을 위해 설계되고 문서화된 클래스에만 사용하라 (Effective Java 아이템 19)
  • 상속을 사용한다면 @Override를 반드시 붙여라 (Effective Java 아이템 40)
  • 상속 깊이는 2~3단계를 넘지 않도록 하라

4. 다형성 (Polymorphism)

“같은 인터페이스를 통해 서로 다른 객체가 각자의 방식으로 동작한다.”

다형성은 OOP의 가장 강력한 특성이다. 같은 타입의 참조를 통해 다양한 구현체를 투명하게 사용할 수 있다. OCP, LSP, DIP가 코드에서 실현되는 것은 다형성 덕분이다.

다형성의 종류

컴파일 타임 다형성 (정적 다형성, Ad-hoc Polymorphism): 메서드 오버로딩

1
2
3
4
5
6
public class Calculator {
    // 같은 이름, 다른 매개변수 — 컴파일 시점에 어떤 메서드를 호출할지 결정
    public int add(int a, int b) { return a + b; }
    public double add(double a, double b) { return a + b; }
    public String add(String a, String b) { return a + b; }
}

컴파일러가 매개변수의 타입과 개수를 보고 어떤 add 메서드를 호출할지 결정한다. 바이트코드에 호출 대상이 확정되므로 정적 바인딩(static binding)이라고 한다.

런타임 다형성 (동적 다형성, Subtype Polymorphism): 메서드 오버라이딩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public interface Shape {
    double area();
    String describe();
}

public class Circle implements Shape {
    private final double radius;

    public Circle(double radius) { this.radius = radius; }

    @Override
    public double area() { return Math.PI * radius * radius; }

    @Override
    public String describe() { return "반지름 " + radius + "인 원"; }
}

public class Rectangle implements Shape {
    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() { return width * height; }

    @Override
    public String describe() { return width + " x " + height + " 직사각형"; }
}
1
2
3
4
5
6
7
8
9
List<Shape> shapes = List.of(
    new Circle(5),
    new Rectangle(3, 4),
    new Circle(10)
);

for (Shape shape : shapes) {
    System.out.println(shape.describe() + " → 넓이: " + shape.area());
}

shape 변수는 Shape 타입이지만, 실제로는 Circle이나 Rectangle 객체를 가리키고 있다. shape.area()를 호출하면 JVM이 실제 객체의 vtable을 참조하여 올바른 area() 메서드를 실행한다.

매개변수 다형성 (Parametric Polymorphism): 제네릭

1
2
3
4
5
6
7
8
9
// 타입에 관계없이 동작하는 코드
public <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) >= 0 ? a : b;
}

// 어떤 Comparable 타입이든 사용 가능
Integer maxInt = max(3, 5);          // Integer
String maxStr = max("abc", "xyz");   // String
LocalDate maxDate = max(date1, date2); // LocalDate

제네릭은 타입을 매개변수화하여, 동일한 코드가 다양한 타입에서 동작하게 한다. 타입 안전성을 유지하면서 코드를 재사용할 수 있다. Effective Java 아이템 26~33이 제네릭의 올바른 사용법을 다룬다.

다형성이 만드는 확장성: 실전 예시

다형성이 없다면, 새로운 타입을 추가할 때마다 기존 코드를 수정해야 한다.

1
2
3
4
5
6
7
8
9
// 다형성 없이: instanceof 체인
public double calculateArea(Object shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.getRadius() * c.getRadius();
    } else if (shape instanceof Rectangle r) {
        return r.getWidth() * r.getHeight();
    }
    throw new IllegalArgumentException("알 수 없는 도형");
}
1
2
3
4
5
6
// 다형성 사용: 새로운 도형을 추가해도 이 코드는 수정 불필요
public double calculateTotalArea(List<Shape> shapes) {
    return shapes.stream()
        .mapToDouble(Shape::area)
        .sum();
}

삼각형을 추가하려면? Triangle 클래스만 만들면 된다. calculateTotalArea()는 한 줄도 수정하지 않는다.

업캐스팅과 다운캐스팅

1
2
3
4
5
6
7
8
// 업캐스팅: 하위 타입 → 상위 타입 (자동, 항상 안전)
Shape shape = new Circle(5);

// 다운캐스팅: 상위 타입 → 하위 타입 (명시적, 위험)
Circle circle = (Circle) shape;  // 실제로 Circle이면 성공

// 잘못된 다운캐스팅 → ClassCastException
Rectangle rect = (Rectangle) shape;  // 실제로는 Circle이므로 실패!

다운캐스팅은 다형성의 이점을 포기하는 행위다. instanceof 검사와 다운캐스팅이 빈번하다면, 추상화가 불충분하다는 신호다.

Java 16의 패턴 매칭은 다운캐스팅이 불가피한 경우를 더 안전하게 처리한다.

1
2
3
4
5
6
7
8
9
10
11
// Java 16+ 패턴 매칭 — instanceof + 캐스팅을 하나로
if (shape instanceof Circle circle) {
    System.out.println("반지름: " + circle.getRadius());
}

// Java 21+ switch 패턴 매칭
String desc = switch (shape) {
    case Circle c    -> "원(r=" + c.getRadius() + ")";
    case Rectangle r -> "직사각형(" + r.getWidth() + "x" + r.getHeight() + ")";
    default          -> "알 수 없는 도형";
};

Spring에서의 다형성 활용: 전략 패턴 + 자동 등록

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public interface MessageSender {
    void send(String to, String message);
    boolean supports(String channel);
}

@Component
public class EmailSender implements MessageSender {
    @Override
    public void send(String to, String message) {
        System.out.println("[이메일] " + to + " : " + message);
    }
    @Override
    public boolean supports(String channel) {
        return "EMAIL".equalsIgnoreCase(channel);
    }
}

@Component
public class SmsSender implements MessageSender {
    @Override
    public void send(String to, String message) {
        System.out.println("[SMS] " + to + " : " + message);
    }
    @Override
    public boolean supports(String channel) {
        return "SMS".equalsIgnoreCase(channel);
    }
}

@Component
public class SlackSender implements MessageSender {
    @Override
    public void send(String to, String message) {
        System.out.println("[Slack] " + to + " : " + message);
    }
    @Override
    public boolean supports(String channel) {
        return "SLACK".equalsIgnoreCase(channel);
    }
}

@Service
public class NotificationService {
    private final List<MessageSender> senders;

    public NotificationService(List<MessageSender> senders) {
        this.senders = senders;
    }

    public void notify(String channel, String to, String message) {
        senders.stream()
            .filter(sender -> sender.supports(channel))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException(
                "지원하지 않는 채널: " + channel))
            .send(to, message);
    }

    public void broadcast(String to, String message) {
        senders.forEach(sender -> sender.send(to, message));
    }
}

새로운 채널(카카오톡, 텔레그램 등)을 추가하려면 MessageSender 구현체만 만들고 @Component를 붙이면 된다. NotificationService는 수정하지 않아도 된다. 다형성 + Spring DI + OCP가 결합된 설계다.

다형성의 한계: 이중 디스패치 문제와 Visitor 패턴

다형성은 하나의 타입에 대해서만 동적 디스패치를 제공한다. 두 객체의 타입에 따라 행위가 달라져야 할 때는 문제가 생긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 문제: 충돌 처리가 두 객체의 타입에 의존한다
public interface GameObject {
    void collideWith(GameObject other);
}

public class Spaceship implements GameObject {
    @Override
    public void collideWith(GameObject other) {
        // other가 Asteroid인지 Spaceship인지에 따라 다른 처리가 필요
        // 하지만 Java의 다형성은 this에 대해서만 동적 디스패치
        if (other instanceof Asteroid) {
            // 우주선-소행성 충돌
        } else if (other instanceof Spaceship) {
            // 우주선-우주선 충돌
        }
    }
}

이 문제를 해결하는 것이 Visitor 패턴 또는 이중 디스패치(Double Dispatch)다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public interface GameObject {
    void collideWith(GameObject other);
    void collideWithSpaceship(Spaceship spaceship);
    void collideWithAsteroid(Asteroid asteroid);
}

public class Spaceship implements GameObject {
    @Override
    public void collideWith(GameObject other) {
        other.collideWithSpaceship(this);  // 첫 번째 디스패치
    }

    @Override
    public void collideWithSpaceship(Spaceship spaceship) {
        System.out.println("우주선-우주선 충돌!");
    }

    @Override
    public void collideWithAsteroid(Asteroid asteroid) {
        System.out.println("소행성이 우주선에 부딪혔다!");
    }
}

public class Asteroid implements GameObject {
    @Override
    public void collideWith(GameObject other) {
        other.collideWithAsteroid(this);  // 첫 번째 디스패치
    }

    @Override
    public void collideWithSpaceship(Spaceship spaceship) {
        System.out.println("우주선이 소행성에 부딪혔다!");  // 두 번째 디스패치
    }

    @Override
    public void collideWithAsteroid(Asteroid asteroid) {
        System.out.println("소행성-소행성 충돌!");
    }
}

a.collideWith(b)를 호출하면 첫 번째 디스패치로 a의 실제 타입이 결정되고, 내부에서 b.collideWithX(this)를 호출하여 두 번째 디스패치로 b의 실제 타입이 결정된다. 두 객체의 타입 조합에 따라 올바른 메서드가 호출된다.


5. 네 가지 개념의 관계

추상화, 캡슐화, 상속, 다형성은 독립적인 개념이 아니라, 서로를 필요로 하고 강화한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
추상화 ─── 핵심 개념만 추출한다
  │
  ├── 인터페이스/추상 클래스로 표현된다
  │
  ├── 캡슐화가 보완한다: 추상화된 행위 뒤에
  │                    구현 세부 사항을 숨긴다
  │
  ├── 상속이 구현한다: 추상 클래스나 인터페이스를
  │                  구체 클래스가 확장/구현한다
  │
  └── 다형성이 활용한다: 추상 타입의 참조로
                       다양한 구현체를 투명하게 사용한다

캡슐화 ─── 내부를 숨기고 행위만 공개한다
  │
  ├── 추상화의 기반이 된다: 내부 구현이 감춰져야
  │                     클라이언트가 추상적 인터페이스에만 의존할 수 있다
  │
  └── 불변 객체가 가장 강력한 형태다: 상태 변경 자체를 차단한다

상속 ─── 기존 타입을 확장한다
  │
  ├── 다형성의 전제 조건이다: 상위-하위 타입 관계가 있어야
  │                        다형적 동작이 가능하다
  │
  └── 조합(Composition)이 대안이다: Effective Java는
                                  상속보다 조합을 권장한다

다형성 ─── 같은 타입으로 다른 동작을 수행한다
  │
  └── 네 가지 개념을 관통하는 최종 목표:
      유연하고 확장 가능한 코드

6. 절차적 프로그래밍과 OOP의 비교

같은 문제를 절차적 방식과 OOP 방식으로 풀어보면 차이가 명확해진다.

절차적 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ShapeData {
    public String type;
    public double param1;
    public double param2;
}

public class ShapeUtils {
    public static double calculateArea(ShapeData shape) {
        if ("CIRCLE".equals(shape.type)) {
            return Math.PI * shape.param1 * shape.param1;
        } else if ("RECTANGLE".equals(shape.type)) {
            return shape.param1 * shape.param2;
        } else if ("TRIANGLE".equals(shape.type)) {
            return 0.5 * shape.param1 * shape.param2;
        }
        throw new IllegalArgumentException("알 수 없는 도형");
    }

    public static double calculatePerimeter(ShapeData shape) {
        if ("CIRCLE".equals(shape.type)) {
            return 2 * Math.PI * shape.param1;
        } else if ("RECTANGLE".equals(shape.type)) {
            return 2 * (shape.param1 + shape.param2);
        }
        // 삼각형 둘레를 구하려면 세 변이 필요한데 param이 두 개뿐이다...
        throw new UnsupportedOperationException("삼각형 둘레 계산 불가");
    }
}

문제점:

  • 새로운 도형을 추가하면 모든 함수의 if-else를 수정해야 한다 (OCP 위반)
  • param1, param2가 무엇을 의미하는지 type에 따라 다르다 (가독성 문제)
  • 데이터에 잘못된 값이 들어가는 것을 막을 수 없다 (캡슐화 부재)
  • 도형 관련 코드가 여러 유틸리티 클래스에 흩어진다 (낮은 응집도)

OOP 방식

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public interface Shape {
    double area();
    double perimeter();
}

public class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        if (radius <= 0) throw new IllegalArgumentException("반지름은 양수여야 합니다.");
        this.radius = radius;
    }

    @Override
    public double area() { return Math.PI * radius * radius; }
    @Override
    public double perimeter() { return 2 * Math.PI * radius; }
}

public class Rectangle implements Shape {
    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("너비와 높이는 양수여야 합니다.");
        }
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() { return width * height; }
    @Override
    public double perimeter() { return 2 * (width + height); }
}

public class Triangle implements Shape {
    private final double a, b, c;

    public Triangle(double a, double b, double c) {
        if (a + b <= c || b + c <= a || a + c <= b) {
            throw new IllegalArgumentException("유효하지 않은 삼각형입니다.");
        }
        this.a = a; this.b = b; this.c = c;
    }

    @Override
    public double area() {
        double s = (a + b + c) / 2;
        return Math.sqrt(s * (s - a) * (s - b) * (s - c));
    }
    @Override
    public double perimeter() { return a + b + c; }
}

장점:

  • 새로운 도형을 추가해도 기존 코드를 수정하지 않는다 (OCP)
  • 각 도형의 데이터와 행위가 한 곳에 있다 (높은 응집도)
  • 잘못된 값은 생성자에서 즉시 거부된다 (캡슐화)
  • Shape 타입으로 모든 도형을 동일하게 다룬다 (다형성)

두 패러다임의 변경 비용 비교

흥미로운 점이 있다. 절차적 방식과 OOP 방식은 변경의 방향에 따라 유불리가 다르다.

변경 종류 절차적 방식 OOP 방식
새로운 타입 추가 (예: 오각형) 모든 함수의 if-else 수정 (비쌈) 새 클래스 추가만 (저렴)
새로운 연산 추가 (예: 둘레 계산) 함수 하나 추가 (저렴) 모든 클래스에 메서드 추가 (비쌈)

OOP는 새로운 타입 추가에 강하고, 절차적 방식은 새로운 연산 추가에 강하다. 이것을 “표현 문제(Expression Problem)”라고 부르며, 도메인의 특성에 따라 적절한 패러다임을 선택해야 한다.


7. OOP 안티패턴과 실무 주의사항

안티패턴 1: 빈약한 도메인 모델 (Anemic Domain Model)

Martin Fowler가 명명한 이 안티패턴은 실무에서 가장 흔하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 안티패턴: 데이터만 있고 행위가 없다
public class Order {
    private Long id;
    private OrderStatus status;
    private BigDecimal totalAmount;
    private List<OrderItem> items;

    // getter/setter만 존재
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
}

// 비즈니스 로직이 서비스에 흩어져 있다
public class OrderService {
    public void cancelOrder(Order order) {
        if (order.getStatus() == OrderStatus.SHIPPED) {
            throw new IllegalStateException("배송된 주문은 취소할 수 없습니다.");
        }
        order.setStatus(OrderStatus.CANCELLED);
        BigDecimal refund = order.getTotalAmount();
        // 환불 처리...
    }
}

Order 객체는 데이터 덩어리일 뿐, 자신의 상태에 대한 규칙을 모른다. “배송된 주문은 취소할 수 없다”는 규칙이 OrderService에 있지, Order에 없다. 다른 서비스에서 order.setStatus(OrderStatus.CANCELLED)를 직접 호출하면 이 규칙을 우회할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 개선: 풍부한 도메인 모델 (Rich Domain Model)
public class Order {
    private Long id;
    private OrderStatus status;
    private final List<OrderItem> items;

    public Order(List<OrderItem> items) {
        this.items = new ArrayList<>(items);
        this.status = OrderStatus.CREATED;
    }

    public void cancel() {
        if (this.status == OrderStatus.SHIPPED
            || this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException(
                "배송 중이거나 완료된 주문은 취소할 수 없습니다.");
        }
        this.status = OrderStatus.CANCELLED;
    }

    public BigDecimal getTotalAmount() {
        return items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public OrderStatus getStatus() { return this.status; }
    // setStatus() 없음! 상태 변경은 행위 메서드를 통해서만 가능
}

안티패턴 2: God Object (신 객체)

1
2
3
4
5
6
7
8
9
10
// 안티패턴: 모든 것을 하는 클래스
public class ApplicationManager {
    public void handleUserRegistration() { ... }
    public void processPayment() { ... }
    public void sendNotification() { ... }
    public void generateReport() { ... }
    public void manageInventory() { ... }
    public void handleCustomerSupport() { ... }
    // 2000줄 이상...
}

하나의 클래스가 모든 것을 관리한다. SRP를 완전히 위반하고, 변경 시 파급 효과가 예측 불가능하다. 모든 기능이 얽혀 있어서 하나의 기능만 테스트하기도 어렵다.

안티패턴 3: 과도한 상속 계층

1
2
3
4
5
6
7
// 안티패턴: 6단계 상속
public class Animal { }
public class Mammal extends Animal { }
public class DomesticAnimal extends Mammal { }
public class Pet extends DomesticAnimal { }
public class Dog extends Pet { }
public class GoldenRetriever extends Dog { }

GoldenRetriever의 동작을 이해하려면 6개 클래스를 모두 살펴봐야 한다. 상속 깊이는 2~3단계를 넘지 않도록 한다.

안티패턴 4: 타입 검사에 의존하는 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
// 안티패턴: instanceof로 분기하는 코드
public void process(Shape shape) {
    if (shape instanceof Circle c) {
        processCircle(c);
    } else if (shape instanceof Rectangle r) {
        processRectangle(r);
    }
}

// 개선: 다형성 활용
public void process(Shape shape) {
    shape.process();  // 각 도형이 자신의 처리 로직을 갖는다
}

안티패턴 5: 과도한 인터페이스 추출

1
2
3
4
5
6
7
8
9
10
// 안티패턴: 구현체가 하나뿐인 인터페이스
public interface UserService {
    void register(User user);
}

public class UserServiceImpl implements UserService {
    @Override
    public void register(User user) { ... }
}
// UserServiceImpl 외에 다른 구현체가 존재하지 않는다

교체 가능성도 없고 테스트에서 목(mock)으로 대체할 필요도 없다면, 인터페이스 없이 구체 클래스를 직접 사용해도 된다. 필요해지는 시점에 인터페이스를 추출하면 충분하다.


8. 실무 체크리스트

OOP 설계 체크리스트

1
2
3
4
5
6
7
8
9
10
✅ 클래스가 데이터와 그 데이터를 다루는 행위를 함께 가지고 있는가? (캡슐화)
✅ 클래스의 필드가 private이고, 의미 있는 메서드를 통해서만 상태가 변경되는가? (캡슐화)
✅ getter/setter만 있는 클래스는 없는가? (빈약한 도메인 모델 방지)
✅ 가변 객체를 반환할 때 방어적 복사를 하고 있는가? (캡슐화 보호)
✅ 변경 가능성이 있는 부분이 인터페이스로 추상화되어 있는가? (추상화)
✅ 구현체가 하나뿐인 불필요한 인터페이스는 없는가? (과도한 추상화 방지)
✅ 상속을 사용한 곳에서 조합으로 대체할 수 있는지 검토했는가? (상속 vs 조합)
✅ 상위 타입의 참조로 하위 타입을 투명하게 사용할 수 있는가? (다형성)
✅ instanceof 검사나 타입 캐스팅이 최소화되어 있는가? (다형성 활용)
✅ @Override 어노테이션을 일관되게 사용하고 있는가? (안전한 재정의)

설계 결정 가이드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
새로운 타입을 만들어야 하는가?
│
├── 데이터만 담는다 (행위 없음)
│    └── record 또는 DTO 사용
│
├── 상태와 행위를 모두 가진다
│    ├── 교체 가능성이 있다 → 인터페이스 정의 + 구현 클래스
│    ├── 테스트 격리가 필요하다 → 인터페이스 정의 + 구현 클래스
│    └── 위 둘 다 아니다 → 구체 클래스
│
└── 기존 타입을 확장해야 한다
     ├── 프레임워크가 확장을 위해 설계한 클래스다 → 상속
     ├── 기존 타입의 행위를 재사용하면서 기능을 추가한다 → 조합 (Wrapper)
     └── 잘 모르겠다 → 조합 (기본값)

9. OOP와 함수형 프로그래밍의 조화

현대 Java(8+)는 OOP와 함수형 프로그래밍(FP)을 함께 사용하는 멀티 패러다임 언어다. 두 패러다임은 경쟁이 아니라 보완 관계다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// OOP: 상태와 행위를 캡슐화
public class OrderProcessor {
    private final OrderRepository repository;
    private final List<DiscountPolicy> policies;

    public OrderProcessor(OrderRepository repository,
                           List<DiscountPolicy> policies) {
        this.repository = repository;
        this.policies = policies;
    }

    // FP: 컬렉션 처리에 Stream API 활용
    public BigDecimal calculateBestDiscount(Order order) {
        return policies.stream()                            // 스트림 변환
            .map(policy -> policy.calculateDiscount(order)) // 각 정책의 할인 계산
            .max(Comparator.naturalOrder())                 // 최대 할인 선택
            .orElse(BigDecimal.ZERO);                       // 없으면 0
    }

    // FP: Predicate를 활용한 필터링
    public List<Order> findOrders(Predicate<Order> condition) {
        return repository.findAll().stream()
            .filter(condition)
            .toList();
    }
}

// OOP 다형성 + FP 람다의 결합
List<Order> expensiveOrders = processor.findOrders(
    order -> order.getTotalAmount().compareTo(BigDecimal.valueOf(100000)) > 0
);

OOP가 강한 영역: 도메인 모델링, 상태 관리, 아키텍처 설계, 프레임워크 구조

FP가 강한 영역: 데이터 변환, 컬렉션 처리, 비동기/병렬 처리, 일회성 행위 표현

두 패러다임을 적절히 조합하는 것이 현대 Java 프로그래밍의 방향이다.


결론

OOP의 네 가지 핵심 개념은 각각 독립적인 것이 아니라, 하나의 목표를 향해 협력한다.

추상화: 복잡한 현실을 핵심만 남기고 모델링한다. 인터페이스, 추상 클래스, sealed 클래스로 표현된다. 맥락에 맞는 적절한 수준을 찾는 것이 핵심이다.

캡슐화: 객체의 내부를 보호하고, 외부에는 의미 있는 행위만 공개한다. 접근 제어자, 방어적 복사, 불변 설계로 구현된다. getter/setter만 있는 것은 캡슐화가 아니다.

상속: 기존 타입을 확장하고 재사용한다. 단, Effective Java의 조언대로 조합으로 대체할 수 있는지 항상 먼저 검토해야 한다. 깨지기 쉬운 기반 클래스 문제를 경계하라.

다형성: 같은 인터페이스를 통해 다양한 구현을 투명하게 사용한다. OCP, LSP, DIP가 코드에서 실현되는 메커니즘이다. JVM의 vtable과 동적 디스패치가 이를 지탱한다.

이 네 가지가 추구하는 공통 목표는 높은 응집도와 낮은 결합도다.

  • 응집도(Cohesion): 관련 있는 데이터와 행위가 하나의 단위(클래스)에 모여 있는 정도
  • 결합도(Coupling): 하나의 모듈이 다른 모듈에 얼마나 의존하는지의 정도

OOP를 잘한다는 것은 네 가지 문법을 아는 것이 아니라, “변경에 유연하고, 확장에 열려 있으며, 이해하기 쉬운 코드”를 만드는 사고 방식을 갖추는 것이다. 클래스를 만들 때마다 “이 객체의 책임은 무엇인가?”, “이 객체의 경계는 어디인가?”, “이 객체를 사용하는 클라이언트는 무엇을 알아야 하는가?”를 자연스럽게 질문하게 될 때, OOP를 체득했다고 할 수 있다.