객체 지향은 프로그램에 현실 세계를 조금 더 많이 반영한 프로그래밍 패러다임이다.
현실 세계에서 특정 종류의 물건들을 분류하고, 그에 맞는 속성과 행위들을 규정하고 인지하듯 프로그래밍 세계에서도 일정한 class를 만들고 그에 맞는 property와 method들을 설정하고 이용하는 것이다. 특정 클래스에 맞는 하나의 instance, 즉 object 단위로 프로그래밍이 가능하여 더욱 직관적인 프로덕트 설계를 할 수 있도록 한다.
객체 지향엔 크게 4가지 특성이 있다. 개체들을 분류하여 객체를 설정하고, 그 인스턴스를 만들어 제품을 설계할 때 프로그램이 갖게되는 특징들이다. 설계 과정에서 사용되는 특징부터 bottom-up 방식으로 작성해보도록 하겠다!
1. 추상화 (Abstraction) : 애플리케이션 경계에 맞는 모델링
은행 어플리케이션에서 고객 class를 설계한다고 생각해보자. 고객은 은행 어플리케이션을 이용하는 사람이다. class는 하나의 분류이고, 특정 class로 분류된 object에는 그에 맞는 특성과 행위를 가져야 할 것이다. 방금 고객은 사람이라고 했다. 그럼 사람이 가지고 있는 특성과 행위들은 어떤 것이 있을까?
내가 정말 좋아하는 애니메이션 강철의 연금술사에서 주인공 에드워드 엘릭이 말하길, 인간은 다음과 같이 구성되어있다고 말한다.
물 35리터, 탄소 20킬로그램, 암모니아 4리터, 석회 1.5킬로그램, 인 800그램, 염분 250그램, 질산칼륨 100그램, 유황 80그램, 플루오린 7.5그램, 철 5그램, 규소 3그램, 기타 미량 원소 15가지.
고객 class가 사람을 대상으로 한다 해서 위 property를 다 넣을 것인가? 초등학생한테 물어봐도 고개를 저을 것이다.
우리는 Class를 설계, 즉 객체를 모델링 하기 위해 애플리케이션 경계에 맞는 특성들을 뽑아와야 한다. 우리가 만들려는 애플리케이션에 관계 있는 것들만, 관심 있는 것들만 쇽쇽 뽑아와 property와 method로 설정한다는 것이다. 그 결과로 모델링된 class는 실제 객체의 모든 특성을 가지고 있진 않을 것이다. 여기서 '우리가 관심 있는 것들'이 바로 애플리케이션 경계이고, 이 과정이 바로 추상화이다.
다시 정리하자면, 객체 지향에서 추상화란 목적에 맞게 관심 있는 특성들만을 추출하여 클래스를 모델링하는 과정이라는 것이다. 그래서 추상화는 모델링이다.
예시에 맞게 사람 class를 설계해보겠다. 간단하게 이름, 잔액, 입/출금 기능만 가지고 있는 class이다. clientOf는 어떤 회사의 고객인지를 나타내는 특성인데, 좀이따 static 관련 설명을 하기 위해 억지로 끼워넣었다.
package oopStudy01;
public class Client {
public String name;
public int property;
public String clientOf;
public void deposit(int money) {
property += money;
System.out.println(money + "원 입금");
System.out.println(this.name + "잔액: " + this.property);
}
public void withdraw(int money) {
property -= money;
System.out.println(money + "원 출금");
System.out.println(this.name + "잔액: " + this.property);
}
}
사람이라 하면 이름, 출생년도, 나이, 각종 신체 지표, 직업, 주소, 먹-자-싸(...) 등등등등등 수백만 개의 특성 및 행위가 나오겠지만 은행 고객으로서 우리가 관심있는 특성과 행위만을 뽑아 모델링했다고 말할 수 있다. 방금 우리는 추상화를 한 것이다.
package oopStudy01;
public class BankDriver {
// static 영역에 java.lang 패키지, BankDriver 클래스 적재
public static void main(String[] args) {
// stack 영역에 main method를 위한 스택 프레임 생성
Client c0 = new Client();
Client c1 = new Client();
// static 영역에 Client 클래스 적재
// main() 스택 프레임에 c0, c1 참조 변수 공간 생성
// heap 영역에 c0, c1 인스턴스 생성 및 스택 프레임 내 참조 변수 공간에 각각의 인스턴스 주소 할당
c0.name = "고객0";
c0.property = 1000;
c0.clientOf = "부추 은행";
// heap 영역, c0 메모리 공간에 각각의 값 할당
c1.name = "고객1";
c1.property = 5000;
c1.clientOf = "부추 은행";
// heap 영역, c1 메모리 공간에 각각의 값 할당
c0.deposit(3000);
c1.withdraw(2500);
// 인스턴스 각각의 method가 코드 실행 영역에서 실행
c1 = null;
// main() 스택 프레임의 c1 주소공간이 null화됨 -> garbage collector가 추후 소거해감
}
// main method 스택 프레임 삭제, 프로그램 종료
}
BankDriver라는 클래스를 같은 패키지에 만들어 다음과 같이 main method를 작성했다. 코드 각 줄을 실행했을 때 메모리 주소공간에 어떤 일이 일어나고 있는지 주석을 달았으니 살펴보자. 어떤 결과가 출력될지는.. 자바를 1시간 공부한 사람도 답을 낼 수 있을 것이라 생각한다.
🌱 static?
class를 설계하다 보면, class 내에 속한 모든 인스턴스들이 같은 값을 가지고 있는 속성이나 메쏘드가 존재한다. 예를 들어 Korean 클래스의 모든 사람들의 국적은 한국일 것이며, 일반적인 경우에 한해 사람의 눈은 두 개일 것이다. 이와 같은 경우 nation, numberOfEyes 등의 속성을 인스턴스 멤버로 두는 것은 메모리상 비효율적일 수 있다.
그 때 스태틱 변수를 사용하는 것이다. 선언부 앞에 static 키워드를 붙여, 클래스의 모든 객체들이 같은 값을 가지는 변수(혹은 메쏘드)를 미리 설정해놓는 것이다. 힙 영역에 변수 저장공간이 생기는 인스턴스 변수와는 달리, 스태틱 변수는 말 그대로 클래스 선언부의 스태틱 공간에 변수 저장공간이 생긴다.
package oopStudy01;
public class Client {
public String name;
public int property;
// 모든 Object들이 같은 값을 갖는 경우, static 영역에 둔다
public static String clientOf = "부추은행";
public void deposit(int money) {
property += money;
System.out.println(money + "원 입금");
System.out.println(this.name + "잔액: " + this.property);
}
public void withdraw(int money) {
property -= money;
System.out.println(money + "원 출금");
System.out.println(this.name + "잔액: " + this.property);
}
}
은행 고객 클래스 예시의 경우 어느 은행의 고객인지를 나타내는 clientOf는, 100% 우리 부추 은행을 사용하는 고객을 위해 "부추 은행"이라는 값을 넣어줬다.
참고로 static 변수(정적 변수)는 선언되어야만 초기값이 정해지는 인스턴스 변수(객체 변수)와는 달리 초기화되지 않아도 자바 프로그램(정확한 책임자는 잘 모르겠다)이 알아서 초기화를 해준다. string은 "", 객체 변수는 null, int는 0, float 등은 0.0, boolean은 false 등으로. static 변수는 객체의 생성 여부와 관계 없이 클래스 선언만으로 사용할 수 있어야 하기 때문이다.
2. 상속 (Inheritance < extending) : 재사용 및 확장
상속은 추상적인 것/일반적인 것이 조금 더 구체적이고 특수한 것으로, 상위 개념이 확장되며 하위 개념으로 내려가는 객체 지향의 특성이다.
'하위 클래스는 상위 클래스이다'
위 명제가 만족되었을 때 객체 지향에서 말하는 올바른 상속이 일어났다고 볼 수 있다. 따라서 객체 지향에 대해 설명하는 글이나 책들은 inheriance보단 extends라는 단어를 더 선호하는 것 같다. 단순히 상위 클래스의 속성을 받아 자식이 되는게 아니라 더 구체적인 것으로 확장해나가는 개념이 더 근본적이기 때문에..
내가 좋아하는 [메이플스토리]라는 게임의 캐릭터 class로 만들어보겠다. 캐릭터엔 직업이라는 것이 있는데, 전사 / 궁수 / 도적 / 마법사 / 해적 등이 있고 전사에는 히어로/팔라딘/다크나이트, 마법사에는 비숍/썬콜/불독 등이 있다. 이를 클래스 상속관계 코드로 간단하게 구현했다.
package oopStudy02;
public class Character {
String job;
public Character() {
job = "초보자";
}
void showCharacter() {
System.out.println("나는 " + job);
}
}
package oopStudy02;
public class Warrier extends Character {
public Warrier() {
job = "전사";
}
}
package oopStudy02;
public class Wizard extends Character{
public Wizard() {
job = "마법사";
}
}
package oopStudy02;
public class Hero extends Character{
public Hero() {
job = "히어로";
}
}
package oopStudy02;
public class Paladin extends Character{
public Paladin() {
job = "팔라딘";
}
}
package oopStudy02;
public class ThunCold extends Character{
public ThunCold() {
job = "썬콜";
}
}
package oopStudy02;
public class Bishop extends Wizard{
public Bishop() {
job = "비숍";
}
}
캐릭터 밑에 전사와 마법사, 전사 밑에 히어로와 팔라딘, 마법사 밑에 썬콜과 비숍으로 각각 '확장'된 클래스 상속 구조를 볼 수 있다.
package oopStudy02;
public class CharacterDriver1 {
public static void main(String[] args) {
Character[] characters = new Character[7];
characters[0] = new Character();
characters[1] = new Warrier();
characters[2] = new Hero();
characters[3] = new Paladin();
characters[4] = new Wizard();
characters[5] = new Bishop();
characters[6] = new ThunCold();
for (int i = 0 ; i < 7 ; i++) {
characters[i].showCharacter();
}
}
}
그리고 방금 설정한 상위 클래스, 즉 Character class 배열을 선언한 뒤 object 선언을 각각의 하위 클래스로 설정했다.어떤 결과가 나올 것인지는 예상이 갈 것이다.
객체 지향에서 상속은 상속 그 자체보단 '확장'의 개념으로 접근해야 한다. 보통 전공서적에서는 'is a' 관계를 만족해야 한다고들 설명하는데, 확장의 개념을 조금 더 강조한 책들에서는 'is a kind of'
예컨데, 첫 번째보다 두 번째 명제가 참이어야 한다는 말이다.
1. is a : 비숍은 하나의 마법사이다.
2. is a kind of : 비숍은 마법사의 한 종류이다.
이해가 잘 된다!
🌱 다중 상속과 인터페이스
java 그 자체에 대한 얘기로 조금 넘어가보자면, 자바는 다중상속 대신 인터페이스를 지원한다. '하위 클래스는 상위 클래스이다'로 설명되는 상속 대신, 인터페이스는 다음과 같이 설명된다.
구현 클래스는 인터페이스 할 수 있다.
앞선 메이플 직업 예시로 돌아가보자. 팔라딘과 비숍은 퓨어딜러보단 서포트 캐릭터에 가깝고, 히어로와 불독은 퓨어딜러에 가깝다. 재사용성을 높이기 위해 상속을 이용하는 지금 상황에서, 서포터와 딜러라는 상위 클래스를 따로 만들어야 할까? 그렇지 않다. '서폿할 수 있는', '딜할 수 있는' 인터페이스를 구현하고 이를 각각의 직업 클래스에서 implement하면 된다.
package oopStudy02;
public interface Supportable {
public void support();
}
package oopStudy02;
public class Bishop extends Wizard implements Supportable {
public Bishop() {
job = "비숍";
}
@Override
public void support() {
System.out.println(job+"의 비숍 서폿!");
}
}
아주 간단하게 비숍에만 서폿 문구를 추가해봤다. 인터페이스가 어떻게 이용되는지는 이 예시로 충분하다 생각한다.
🌱 상속 관계에서의 힙 메모리
다시 설명하면 힙 메모리는 생성된 객체의 공간이다. 하위 클래스의 인스턴스가 생성될 때 상위 클래스의 인스턴스도 함께 생성된다. 인스턴스 생성을 결정하는 것은 역시 생성자 'new'에 기반하는데, 에시에서의 경우 new Bishop()으로 객체를 생성할 경우 Character, Wizard, Bishop 객체가 힙 영역에 한번에 생성된다는 것이다.
그리고 객체 참조 변수의 형에 기반하여 만들어진 객체 중 어떤 객체의 주소를 참조할 것인지가 결정된다. 하위 클래스의 객체가 생성되었지만 객체 참조 변수 타입이 상위 클래스일 때, 참조 변수는 힙에 존재하는 상위 클래스 객체의 주소를 가리킨다는 뜻이다. 그래서 따로 overriding 된 것 없이 하위 클래스에서 추가로 정의한 property나 method는 상위 클래스 객체 변수가 이용 불가하다.
아래의 코드와 주석에 단 설명을 보고 천천히 이해해보도록 하자..
package oopStudy02;
public class CharacterDriver2 {
public static void main(String[] args) {
// 객체 참조 변수 타입 : 캐릭터(상) => 생성된 객체 타입 : 비숍(하위)
// 이 경우 myBishop은 힙에 비숍 객체와 함꼐 생성된 Character 객체를 가리킨다.
// Bishop 클래스에서 heal() method를 추가 정의했다면 myBishop.heal() 호출 X
Character myBishop = new Bishop();
// heal() 호출을 위해선 type casting 필요
((Bishop) myBishop).heal();
// 객체 참조 변수 타입 : 히어로(하위) => 생성된 객체 타입 : 히어로(하위)
// 이 경우 myHero는 힙에 캐릭터 객체와 함께 생성된 Hero 객체를 가리킨다.
Hero myHero = new Hero();
// 객체 참조 변수 타입 : 팔라딘(하위) => 생성된 객체 타입 : 캐릭터(상위)
// 이 경우 힙에는 상위 클래스의 객체만 생성되어, 하위 클래스 타입을 가진 참조 변수가 참조할 수 없다.
// Error!
Paladin myPaladin = new Character();
}
}
3. 다형성 (Polymorphism) : 오버라이딩, 사용 편의
간단하게 말하면 다형성은 곧 오버라이딩을 지칭한다 할 수 있다. 오버라이딩은 '재정의'이다. 상위 클래스에서 설정한 method와 같은 이름의 method를 하위 클래스에서 새롭게 정의하면 그것을 오버라이딩 했다고 말할 수 있는 것이다.
힙 메모리 구조에서 하위 클래스 인스턴스를 생성했을 때 상위 클래스 인스턴스도 함께 생성된다고 상속을 설명할 때 언급했다. 따라서 하위 클래스에서 새로 정의한 method는 상위 클래스 타입의 객체 참조 변수를 사용했을 때 접근할 수 없다 했는데, 그렇다면 이 때 overriding 된 method는 어떻게 적용될까?
결론부터 말하자면 '생성된 인스턴스에서 오버라이딩된 Method'가 호출된다. 힙 메모리에서, 설령 객체 참조 변수가 상위 클래스 타입의 인스턴스를 가리키더라도 내부 method는 하위 클래스에서 오버라이딩된 함수가 '가린'다고 이해하면 편하다. 하위 클래스에서 재정의 되었다면 재정의된 method가, 그렇지 않다면 상위 클래스에서 정의한 method가 호출되는 것이다.
다시 한 번 강조. 상위 클래스 타입의 객체 참조 변수를 사용하더라도 하위 클래스에서 오버라이딩한 method가 호출된다!
비슷하지만 다른 개념으로 오버로딩(중복 정의)이 있는데, 오버로딩은 특정 method가 호출 될 때 같은 이름을 가진 method에서 인자의 type이나 갯수에 맞는 것이 호출되는 것이다. 간단하게 재정의, 중복 정의의 차이라고 할 수 있다. 면접에 자주 나온다는데, 전혀 헷갈리거나 어려운 개념이 아닌듯 한데..
4. 캡슐화 : 정보 은닉
객체 지향에서 캡슐화는 인스턴스 자체를 하나의 독립된 [캡슐]로 보고 정해진 창구로만 내부의 정보를 읽거나 쓸 수 있도록 하는 특성이다. 자바에서 캡슐화와 관련된 키워드는 public, private, protected, 그리고 [default] 4가지의 접근 제어자 키워드가 있겠다. 접근 제어자는 클래스 내에 선언한 변수와 메쏘드의 접근을 어느 범위까지 허용할 것인지 결정하는 제어자이다.
대부분의 자바 개발서에서 접근 제어자에 따른 변수 접근 가능 여부는 다음과 같이 설명한다.
private | 해당 class 내부에서만 접근 가능 |
default | 같은 패키지 내의 class에서 접근 가능 |
protected | 같은 패키지, 혹은 상속된 class에서 접근 가능 |
public | 모든 클래스에서 접근 가능 |
그럼 다음과 같이 선언된 클래스, ClassA가 있다고 가정해보자.
package oopStudy03_1;
public class ClassA {
private int pri;
int def;
protected int pro;
public int pub;
void runSomething() {}
static void runStaticSomething() {}
}
각각의 변수와 method들에 대해
1. 현재 클래스인 ClassA
2. 같은 패키지의 클래스인 ClassB
3. 같은 패키지에서 ClassA를 상속받은 ClassAA
4. 다른 패키지에서 ClassA를 상속받은 ClassAB
5. 다른 패키지의 클래스인 ClassC
위에 설명한 5개의 클래스에서 접근이 가능한지, 가능하다면 어떻게 접근해야하고 불가능하다면 왜 그런지 생각해보도록 하자!
추가로, 클래스의 정적변수에 접근할 때는 "클래스명.정적변수명"으로 접근하는 것이 이해와 자바 내 포인터 관리 측면에서 유용하다고 한다. 스태틱 변수는 스태틱 영역에 생성되는데, 클래스명.정적변수명 으로 스태틱 변수에 접근하면 바로 코드실행영역 -> 스태틱 영역으로 가지만, 인스턴스명.정적변수명 으로 접근했을 경우 코드실행영역 -> 스택 프레임의 객체참조변수 -> 힙 영역의 인스턴스 -> 스태틱 영역 .. 으로 참조 과정이 늘어나기 때문이다.
🌱 CallByValue VS CallByReference
학부 1학년 시절.. C++을 처음 공부하면서 이해가지 않았던 레전드 주제다. 사실 포인터 개념 자체를 아예 몰랐으니까 그냥
간단하게 변수에 값(value) 자체를 넘겨줄 것이냐, 아니면 그 값이 보관된 주소(reference)를 넘겨줄 것이냐의 문제이다. 다음 두 코드의 예시를 비교해보면 알 수 있다.
package oopStudy04;
public class CallByValue {
public static void main(String[] args) {
int a = 1;
int b = a;
a = 2;
System.out.println(a);
System.out.println(b);
}
}
위 코드의 경우, 첫째줄엔 2, 둘째줄엔 1이 출력된다. 변수 b에 a를 할당했더라도 a 자체가 아닌 a의 '값'이 할당되었으므로 a의 값이 바뀌어도 b의 값은 그대로 남아있는 것이다.
package oopStudy04;
public class CallByReference {
public static void main(String[] args) {
Person a = new Person("사람 a");
Person b = a;
a.name = "바뀐 사람 a";
System.out.println(a.name);
System.out.println(b.name);
}
}
class Person {
public String name;
public Person(String name) {
this.name = name;
}
}
그러나 위 코드의 경우 "바뀐 사람 a"가 2번 출력되는 결과가 나온다. 객체 참조변수 b에 a가 참조하는 인스턴스의 주소를 할당한 상태에서 인스턴스의 값을 바꾸면 같은 인스턴스를 참조하는 b의 인스턴스 값도 바뀔 것이다!
한가지 알아둬야 할 것은, a와 b가 같은 주소를 가리키고 있다고 해서 같은 변수는 아니라는 것이다. a = null; 로 바꾸어도, b가 참조하는 객체는 여전히 존재한다. 이 상황에서 b까지 null로 만들면 아마 가비지컬렉터가 인스턴스를 힙 영역에서 수거해 갈 것이다.
두 코드의 차이는 값이 기본 자료형 변수인지, 참조 자료형 변수인지의 여부다. 예상했듯 전자는 call by value로 값을 복사해서 넘겨주고, 후자는 call by reference로 참조 인스턴스의 주소 자체를 넘겨준다.
이상 객체 지향의 4대 특성, 캡! 상추다 를 공부해봤다.