캡슐화와 상속, 그리고 객체 간의 관계
오늘 수업에서 강사님이 Customer.java 주석에 언어 진화 과정을 통째로 담아뒀는데, 이게 그냥 역사 얘기가 아니라 왜 객체지향이 이기는지를 설명하는 핵심이다.
기계어 → 어셈블러 → 절차식 언어 → 구조적 언어 → 객체지향 언어
절차식은 코드가 실행 순서대로 다 써져 있어야 해서 구현량이 많고 메모리를 많이 잡아먹었다. 구조적 언어는 자주 쓰는 코드를 모듈화해서 재사용이 가능해졌지만, 데이터와 코드의 결합도가 너무 높아서 뭔가 하나 바꾸면 연쇄적으로 다 손봐야 했다.
객체지향이 해결한 핵심은 데이터와 코드의 결합도를 낮추는 것이다. 덕분에 재사용성과 수정성이 올라갔고, 대규모 협업 환경에서 버텨낼 수 있게 됐다. 자바가 엔터프라이즈 시장을 지배한 이유가 여기 있다.
처음 배울 때는 "그냥 public으로 다 열면 편하지 않나?" 싶은데, 실제로 팀 프로젝트 규모가 커지면 생각이 바뀐다.
MyDate.java를 보면 이게 명확하게 이해된다.
// 캡슐화 안 했을 때
today.year = 2026;
today.month = 13; // 13월이 들어가도 막을 방법이 없다
today.date = 32; // 32일도 마찬가지
// 캡슐화 했을 때
public void setMonth(int month) {
if(month > 0 && month < 13) {
this.month = month;
} else {
System.err.println("1월부터 12월 사이로 설정해주세요");
}
}
속성을 private으로 막고 setter를 통해서만 접근하게 하면, 유효성 검증 로직이 setter 하나에 집중된다. 나중에 검증 로직이 바뀌어도 setter만 수정하면 된다. 호출하는 쪽 코드는 건드릴 필요가 없다. 이게 Decoupling이고, 유지보수성을 높이는 방식이다. (결합도 낮추기 내공외제스자)
접근 제한자 정리:
| 접근 제한자 | 같은 클래스 | 같은 패키지 | 상속 관계 | 외부 |
|---|---|---|---|---|
| public | ✅ | ✅ | ✅ | ✅ |
| protected | ✅ | ✅ | ✅ | ❌ |
| default(생략) | ✅ | ✅ | ❌ | ❌ |
| private | ✅ | ❌ | ❌ | ❌ |
AccessModifierTest.java에서 chapter06.sub 패키지에서 Customer 속성(default)에 접근하면 컴파일 에러가 나는 걸 직접 확인
오늘 수업에서 짚어준 부분인데, 재사용 방식이 두 가지라는 게 핵심이다.
1. 상속 (Inheritance) - extends
MainCustomer is a Customer2. 객체 생성 (Association)
B has a A오늘 필기 사진에서 강사님이 그려준 객체 관계 다이어그램이 이걸 잘 보여준다.
Association : B 클래스가 A 타입 필드를 가짐 (B has a A)
Dependency : C 클래스의 메서드 인자로 A가 넘어옴 (C use a A), 더 느슨한 결합
Inheritance : SubA가 A를 extends (SubA is a A)
세 가지 중 결합도가 가장 낮은 건 Dependency다. 메서드 호출 시점에만 관계가 생기고 끝나니까. Association은 객체가 살아있는 동안 계속 관계가 유지된다.
public class MainCustomer extends Customer {
private String hobby;
public MainCustomer(String name, int age, String address, String hobby) {
super(name, age, address); // 반드시 첫 번째 줄
this.hobby = hobby;
}
}
자바 상속에서 헷갈리기 쉬운 포인트를 정리하면 이렇다.
super()로 직접 호출해야 한다.super()는 반드시 생성자의 첫 번째 명령에서만 호출 가능하다.둘 다 이름이 비슷해서 처음엔 헷갈리는데, 목적 자체가 다르다.
| 구분 | 오버로딩 (Overloading) | 오버라이딩 (Overriding) |
|---|---|---|
| 개념 | 같은 이름, 다른 인자 | 상속받은 메서드를 재정의 |
| 적용 범위 | 같은 클래스 내 | 부모-자식 간 |
| 목적 | 호출 편의성 | 동작 변경 |
| 조건 | 인자가 달라야 함 | 이름, 인자, 리턴타입 동일해야 함 |
오버라이딩의 핵심 효과는 두 가지다.
1. 기존 코드를 수정하지 않고 변경된 내용을 반영할 수 있다. (호출하는 쪽은 그대로)
2. 부모든 자식이든 동일한 이름으로 호출한다. 나중에 다형성이랑 연결되는 개념이다.
// MainCustomer가 toString()을 오버라이딩
public String toString() {
return super.toString() + " 취미:" + hobby;
// super.toString()으로 부모 메서드 재사용 + 자식 정보 추가
}
super.toString()을 쓰는 게 포인트다. 부모 코드를 그냥 갖다 쓰고, 자식에서 필요한 부분만 덧붙인다. 중복 없이 확장하는 방식이다.
// 나쁜 설계 - 중복 필드가 3개 클래스에 다 있음
Employee: empno, ename, salary
Manager: empno, ename, salary, position ← 중복!
Engineer: empno, ename, salary, skill ← 중복!
상속으로 리팩토링하면:
// Employee 부모 클래스
public class Employee {
private String empno;
private String name;
private int salary;
// getter, setter, toString
}
// Manager는 position만 추가
public class Manager extends Employee {
private String position;
public Manager(String empno, String name, int salary, String position) {
super(empno, name, salary); // 부모 생성자 재사용
this.position = position;
}
@Override
public String toString() {
return super.toString() + "position=" + position;
}
}
공통 속성은 Employee에만 두고, 각 직군은 자기 것만 관리한다. 나중에 급여 계산 로직이 바뀌면 Employee.setSalary() 하나만 수정하면 Manager, Engineer 전부 반영된다.
public void setSalary(int salary) {
if(salary < 2000000) {
this.salary = 2000000; // 최저임금 보정
} else {
this.salary = salary;
}
}
이게 캡슐화의 실질적인 가치다. salary를 public으로 열어두면 어디서든 0이나 음수를 넣을 수 있다. setter 하나로 비즈니스 규칙(최저임금)을 강제할 수 있다.
// this() : 같은 클래스의 다른 생성자 호출 (코드 재사용)
public Customer() {
this("UPlus", 3, "서울 강남구 선릉로"); // 아래 생성자 호출
}
// super() : 부모 생성자 호출
public MainCustomer(String name, int age, String address, String hobby) {
super(name, age, address); // 반드시 첫 줄
this.hobby = hobby;
}
this()와 super() 둘 다 생성자 첫 번째 줄에만 쓸 수 있다. 그래서 이 둘을 동시에 쓰는 건 불가능하다.
상속이 재사용성을 높이는 건 알겠는데, 무조건 상속을 쓰는 게 답인가?
오늘 수업에서 재사용 방식이 상속과 객체 생성 두 가지라고 배웠다. 근데 실무에서는 상속을 남발하면 오히려 유지보수가 힘들어진다는 얘기를 많이 들었다.
이유를 생각해봤다.
상속은 부모-자식 간 결합도가 매우 높다. 부모 클래스를 수정하면 모든 자식이 영향을 받는다. Employee.toString()을 바꾸면 Manager, Engineer 전부 바뀐다. 이건 장점이기도 하지만, 예상치 못한 사이드 이펙트가 될 수도 있다.
그래서 나온 원칙이 "상속보다 컴포지션을 선호하라" (Effective Java에서도 언급되는 내용이다). 단순히 코드 재사용이 목적이라면 has-a 관계(Association)로 설계하는 게 더 유연하다. 상속은 진짜 is-a 관계일 때, 즉 동작을 변경(오버라이딩)할 필요가 있을 때 쓰는 게 맞다.
오늘 사원관리 프로젝트에서 Manager is a Employee, Engineer is a Employee는 진짜 is-a 관계가 맞다. 상속이 적절한 케이스다. 근데 만약 Employee가 payTax() 같은 메서드를 가지고 있고, 계약직 직원은 세금 계산 방식이 다르다면? 그냥 상속하면 계약직 클래스에서 부모 메서드를 오버라이딩해야 하는데, 계속 이렇게 되면 클래스 계층이 복잡해진다.
결론적으로 상속은 강력하지만 신중하게 써야 한다. 다음 주에 인터페이스와 다형성을 배우면 이 고민이 더 구체화될 것 같다.