객체지향 설계와 다형성
클래스·객체, 캡슐화·상속·다형성, 객체 관계와 동적 바인딩을 학습한다.
1. 객체, 클래스와 인스턴스
객체는 상태와 행동을 함께 가지는 소프트웨어 구성요소이다.
- 상태: 필드·속성으로 표현
- 행동: 메서드로 표현
- 클래스: 객체가 가질 상태와 행동을 정의한 설계
- 인스턴스: 클래스를 바탕으로 실제 생성한 객체
- 메시지: 객체에게 메서드 수행을 요청하는 상호작용
클래스 Account
├─ 상태: balance, owner
└─ 행동: deposit(), withdraw()
│
├──► accountA 인스턴스
└──► accountB 인스턴스
2. 객체지향의 네 핵심 개념
캡슐화
데이터와 관련 연산을 하나의 단위로 묶는다. 객체 내부 표현을 외부 코드가 직접 의존하지 않게 하면 변경 파급을 줄일 수 있다.
정보은닉
외부에 필요한 인터페이스만 공개하고 내부 구현을 감춘다. 접근 제한자는 이를 지원하는 수단이지만 정보은닉은 단순히 모든 필드를 private으로 만드는 것보다 넓은 설계 원칙이다.
상속
기존 타입의 특성과 행동을 확장하거나 재정의한다. 상속은 is-a 관계에 적합할 때 사용하고, 단순 코드 재사용만을 목적으로 남용하면 결합도가 높아질 수 있다.
다형성
같은 인터페이스의 호출이 실제 객체 타입에 따라 다른 동작을 수행하는 성질이다.
Payment 인터페이스
├─ CardPayment.pay()
├─ BankTransfer.pay()
└─ PointPayment.pay()
checkout(payment) ──► payment.pay()
실제 객체에 맞는 메서드 실행
3. Java 클래스 예시
interface Shape {
double area();
}
class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
class Rectangle implements Shape {
private final double width;
private final double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
호출자는 구체 클래스가 아니라 Shape에 의존할 수 있다.
4. 오버로딩과 오버라이딩
| 구분 | 오버로딩 | 오버라이딩 |
|---|---|---|
| 의미 | 같은 이름, 다른 매개변수 목록 | 상위 타입 메서드를 하위 타입이 재정의 |
| 결정 시점 | 일반적으로 컴파일 시 | 일반적으로 실행 시 동적 바인딩 |
| 관계 | 같은 클래스 안에서도 가능 | 상속·인터페이스 관계 필요 |
| 반환형만 변경 | 불가능 | 규칙 안에서 공변 반환형 가능 |
void print(int value) { }
void print(String value) { } // 오버로딩
Animal a = new Dog();
a.sound(); // 실제 객체 Dog의 재정의 메서드 실행
변수의 선언 타입이 Animal이어도 실행할 인스턴스 메서드는 실제 객체 타입에 따라 결정될 수 있다. 이를 동적 바인딩이라고 한다.
5. 추상 클래스와 인터페이스
- 추상 클래스: 공통 상태와 일부 구현을 공유하면서 하위 클래스가 나머지를 완성하게 할 수 있다.
- 인터페이스: 구현 객체가 지켜야 할 동작 계약을 표현한다.
언어 버전에 따라 인터페이스가 기본 구현을 가질 수 있으므로 “인터페이스에는 구현이 절대 없다”처럼 단정하면 안 된다. 인터페이스는 다중 타입 계약, 느슨한 결합, 교체 가능성을 중심으로 이해한다.
6. 객체 사이의 관계
| 관계 | 의미 | 예시 |
|---|---|---|
| 연관 | 객체들이 지속적으로 관련됨 | 학생-과목 |
| 집합 | 전체가 부분을 포함하나 부분이 독립 생존 가능 | 팀-선수 |
| 합성 | 전체가 부분의 생명주기를 강하게 소유 | 주문-주문항목 |
| 의존 | 일시적으로 다른 객체를 사용 | 서비스가 매개변수로 저장소 사용 |
| 일반화 | 상위·하위 타입 관계 | 동물-개 |
| 실체화 | 인터페이스 구현 | Shape-Circle |
Order ◆──── OrderItem 합성
Team ◇──── Player 집합
Dog ─────▷ Animal 일반화
Circle - - -▷ Shape 실체화
7. 캡슐화 예시
좋지 않은 예시는 외부에서 잔액을 직접 수정하도록 한다.
class Account {
public int balance;
}
개선 예시는 불변조건을 메서드 안에서 보호한다.
class Account {
private int balance;
public void deposit(int amount) {
if (amount <= 0) throw new IllegalArgumentException();
balance += amount;
}
public boolean withdraw(int amount) {
if (amount <= 0 || amount > balance) return false;
balance -= amount;
return true;
}
public int getBalance() {
return balance;
}
}
핵심은 단순 접근 제한이 아니라 객체가 유효한 상태를 스스로 유지하도록 만드는 것이다.
8. 상속과 합성 선택
상속은 강한 관계를 만든다. 변경 가능성이 크거나 단순 기능 재사용 목적이라면 객체를 필드로 포함하고 위임하는 합성이 더 유연할 수 있다.
상속: ElectricCar is-a Car
합성: Car has-a Engine
“상속보다 합성을 무조건 사용”하는 규칙이 아니라, 도메인 관계와 교체 가능성을 보고 선택한다.
9. 다형성 코드 판독
class Parent {
void show() { System.out.print("P"); }
}
class Child extends Parent {
@Override
void show() { System.out.print("C"); }
}
Parent x = new Child();
x.show(); // C
선언 타입은 접근 가능한 멤버를 제한하고, 재정의된 인스턴스 메서드의 실제 실행은 객체 타입이 결정한다. 정적 메서드·필드 숨김은 다른 규칙이 적용될 수 있으므로 인스턴스 메서드 다형성과 섞지 않는다.
10. 필드·정적 메서드와 동적 바인딩 구분
Java의 인스턴스 메서드 오버라이딩과 필드·정적 메서드 숨김은 다르게 판독한다.
class Parent {
int value = 1;
static String who() { return "P"; }
String show() { return "P"; }
}
class Child extends Parent {
int value = 2;
static String who() { return "C"; }
@Override String show() { return "C"; }
}
Parent p = new Child();
p.value → 1 : 선언 타입 기준 필드 선택
p.who() → P : 정적 메서드 숨김, 선언 타입 기준
p.show() → C : 인스턴스 메서드 오버라이딩, 실제 객체 기준
11. 다운캐스팅과 타입 안전성
상위 타입 참조가 실제로 해당 하위 객체를 가리킬 때만 안전하게 다운캐스팅할 수 있다.
if (animal instanceof Dog dog) {
dog.fetch();
}
컴파일이 허용되는 캐스트라도 실제 객체가 맞지 않으면 실행 중 ClassCastException이 발생할 수 있다.
12. LSP를 계약으로 판정하기
리스코프 치환 원칙은 단순히 “상속을 사용한다”가 아니라 하위 타입을 상위 타입 자리에 넣었을 때 호출자의 올바른 가정이 깨지지 않아야 한다는 뜻이다.
하위 타입은
- 요구하는 사전조건을 더 강하게 만들지 않고
- 보장하는 사후조건을 더 약하게 만들지 않으며
- 상위 타입의 불변조건을 지켜야 한다.
예를 들어 상위 타입이 모든 정수를 허용하는 메서드를 제공하는데 하위 타입이 양수만 허용하도록 제한하면 기존 호출자가 실패할 수 있다.
13. equals와 hashCode 계약
Java에서 논리적 동등성을 정의하려면 equals와 hashCode 계약을 함께 지켜야 한다.
equals계약은 반사성·대칭성·추이성·일관성을 만족해야 하며,x.equals(null)은false여야 한다.a.equals(b)가 참이면a.hashCode()==b.hashCode()여야 한다.- 해시값이 같다고 반드시
equals가 참인 것은 아니다.
이 계약이 깨지면 HashSet, HashMap에서 동등 객체 조회와 중복 제거가 실패할 수 있다.
14. 인터페이스 충돌과 구성 선택
두 인터페이스가 같은 시그니처의 default 메서드를 제공하면 구현 클래스가 충돌을 명시적으로 해결해야 한다. 또한 기능 조합이 자주 바뀌는 경우 상속 계층을 깊게 만드는 것보다 작은 인터페이스와 합성·위임을 이용하면 교체와 테스트가 쉬워질 수 있다.
확인 문제
Parent p=new Child();에서 필드와 재정의 인스턴스 메서드는 각각 어느 타입을 기준으로 선택되는가?- 실제 객체가 Dog가 아닌데
(Dog)animal을 수행하면 어떤 문제가 발생할 수 있는가? - LSP 관점에서 하위 타입이 상위 타입보다 더 강한 사전조건을 요구해도 되는가?
a.equals(b)가 참일 때 반드시 성립해야 하는 해시 코드 조건은 무엇인가?- 두 인터페이스의 동일한 default 메서드가 충돌하면 구현 클래스는 어떻게 해야 하는가?