프로그래밍 언어는 일괄적으로 기본문법이 존재한다. 이 위에 각 언어는 자신들의 정체성을 쌓아올리는데, 지난 시간엔 자바의 정체성인 ‘객체지향’의 기초문법까지 배웠다.
오늘은 좀 더 객체지향적인 개발을 가능하게 하는 중급 문법에 대해서 다뤄보자.
그동안 내게 좋은 프로그램이란, 내 의도대로 ‘돌아가는지, 안 돌아가는지’였다.
근데 시간이 지나면서 기능은 얼마든지 구현할 수 있게 되었다. 반면 기능은 구현하지만 작성한 코드가 정말 지저분했다. 또 뭔가 하나를 바꾸면 전반적으로 수정해야하니 귀찮은 것도 마찬가지였고
마치 선정리가 안된 컴퓨터 뒷면을 보는 느낌이다.
이런 면에서 잘 만들어진 프로그램의 기준은 다음 3가지로 추릴 수 있다.
사실 신입 개발자, 입문하는 사람들이 위의 세 가지를 포함하는 프로그램을 만드는 것은 힘들다. 왜냐면 이건 경험의 비율이 굉장히 크기 때문이다.
그래서 경험자, 곧 선배 개발자들이 만들어 낸 틀이 있는데 그것을 바로 “디자인 패턴”이라고 부른다.
흔히 디자인을 할 때, 표준 템플릿이 있듯이 프로그램에도 형식이 존재한다.
디자인 패턴은 16개 정도가 있고 본 과정에선 4~5개 정도를 학습할 것이지만
그 중 제일 중요한 건 MVC (Model / View / Control) 이다.
이런 디자인 패턴을 다루기 위해선 프로그램의 기능을 넘어선 기능의 분류가 필요하다.
즉, 각 기능의 역할에 따라 ‘체계’를 만들어서 효율적으로 다뤄야 한다는 것이다.
따라서 객체지향의 중급문법을 기반으로 ‘체계’를 만들어내는 법을 학습하고자 한다.
앞서 좋은 프로그램의 기준들은 좋지 않은 프로그램의 문제로부터 출발한다.
그럼 좋지 않은 프로그램의 문제는 뭘까?
기본적으로 프로그램을 ‘지저분하게’ 작성하게 된다면 다음 두 가지 문제가 발생한다.
1) 코드 중복도 문제
프로그램 내에서 같은 기능의 공통 코드들이 존재해 생기는 문제로 기본적으로 가독성을 저하한다.
또한, 기능 수정을 할 때 공통된 내용을 전부 수정해야 한다. 즉, 코드들이 전부 따로 놀고 있기에 유지-보수하기 힘들다.
2) 코드 결합도 문제
객체지향에서 코드 결합의 문제는 쉽게 말하면, ‘클래스 간의 의존도가 높음을 의미한다.’
이에 대해 앞선 중복도 문제와 반대되기 때문에 모순이라 생각할 수 있다.
여기서 중복도는 프로그램 내의 공통 코드들의 문제이고, 결합도는 각 클래스 간의 연결이 밀착되어 한쪽을 수정하면 그와 연결된 클래스들도 전부 수정해야 하는 것을 말한다. 이 역시 유지-보수를 힘들게 한다.
중복도의 해결하기 위한 객체지향 문법은 ‘상속, 추상화’가 있다.
먼저 상속부터 다뤄보자.
상속이란 단어는 부모나 자신보다 위로부터 ‘물려받는 것’을 의미한다. 이런 의미는 객체지향에서도 같은 의미로 적용한다.
먼저 상속의 관계는 두 가지가 있다.
일반적인 참조를 의미한다. 클래스에서 다른 클래스를 끌어와서 사용하는 단순 ‘연결’의 관계이다.
Silver HAS A String
: Silver 클래스는 String 클래스를 내부에 가지고 있다.
HAS-A
가 연결이라면, IS-A
는 포함으로 다음과 같은 예시로 설명할 수 있다.
A IS A B
Tiger is a [ Animal ]
Bear is a [ Animal ]
위 예시를 보면, 호랑이와 곰은 ‘동물’이라는 범주 안에 포함된다.
따라서 우리가 Animal
이란 클래스를 사용하려면 그 안에 포함되어야만 가능하다.
만약 우리가 machine
이라는 클래스에서 Animal
클래스를 사용하려면 논리 문제가 발생한다.
그래서 올바른 관계에 한해 상속은 다음과 같이 쓰인다.
public class Tiger extends Animal
extends
키워드를 통해서 연결해주면 앞의 클래스는 뒤의 클래스의 내용을 그대로 물려받는다. 따라서 추가적인 코드를 작성 없이, 수정할 땐 Animal
클래스의 내용만 수정해주면 되는 것이다.
상속 관계를 이용하려면 추가적인 규칙을 지켜야 한다.
이를 위해서 다음의 코드를 통해서 사용할 것이다.
package types;
public class Grade {
private int id;
private String name;
protected int point;
public Grade() {}
public Grade(int id, String name, int point) {
this.id = id;
this.name = name;
this.point = point;
}
public double getBonus() {
return this.point*0.03;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getPoint() {
return point;
}
public void setPoint(int point) {
this.point = point;
}
}
package types;
public class Silver extends Grade {
public Silver(int id, String name, int point) {
super(id, name, point);
}
public double getBonus() {
return this.getPoint()*0.02;
}
}
부모 클래스는 공통된 부분으로 만들어진다. 이를 통해 코드의 중복을 피한다.
따라서 자식 클래스의 추가적인 부분들은 겹치지 않는다.
접근 범위의 기본 설정 default
는 같은 패키지에 포함된 메서드, 변수들만 공통적으로 사용할 수 있었다.
하지만 protected
를 사용하면 패키지가 달라도 상속 관계에 한해 사용이 가능하다.
extends
는 여러 가지를 할 수 없다.java는 다중 상속을 없앴다. 그보다 생성자 문제가 주로 발생했었다. 따라서 단, 자식 클래스는 또 상속을 할 수 있다. 즉, 여러 부모를 가질 순 없지만, 자식의 자식이 부모의 부모를 물려받는 것이 가능하다.
단, 주로 명시 생성자를 만들면서 기본 생성자를 안 만들어줘서 오류가 발생한다. 그래서 명시 생성자를 사용할 때는 기본 생성자도 꼭 작성해줘야 한다. 왜냐면 명시 생성자가 작성하면 기본 생성자가 사라지기 때문이다.
super()
는 부모 클래스의 생성자를 불러온다.기본적으로 변수는 private
로 설정되어 직접 접근할 수 없다.
그래서 setter & getter
를 응용하지만 이보단 생성자를 사용해서 접근하는 게 효율적이다.
구조적으로 자식 인스턴스 안에 부모 인스턴스가 만들어진다. 그래서 자식은 부모의 부분들을 당연히 이용할 수 있고, 같은 주소를 공유하고 있다.
따라서 setter & getter
를 사용하면 this
를 이용해 내부의 부모 메서드, 변수를 사용할 수 있다. 하지만 굳이 그럴 필요는 없다.
초기값을 입력받을 땐 생성자가 훨씬 유리한 기능이다. 그래서 본인의 명시 생성자를 만들어서 사용하면 될 것이다.
public class Silver extends Grade {
public Silver(int id, String name, int point) {
super(id, name, point);
위 코드는 Silver 클래스를 생성하면서 매개변수를 입력받으면 super()
를 통해 자기 안에 있는 부모 인스턴스 부분에 그대로 넣어준다 (매개변수의 특징)
그러나 막상 그리하면 에러가 발생할 수도 있다. 이는 부모 클래스의 생성자를 설정할 때, 명시 생성자를 설정하고, 기본 생성자를 설정하지 않으면 발생하는 것이다.
public Grade() {} // 이 부분이 없으면 에러가 발생한다.
public Grade(int id, String name, int point) {
this.id = id;
this.name = name;
this.point = point;
}
다시 한번 정리를 하자면 중복을 줄이려면 중복된 것을 한 곳으로 묶어주면 된다.
이를 위해서 가장 기본적으로 사용하는 게 ‘상속’이다.
하지만 한 가지 문제점은 “만약 공통적이지만 내용이 살짝 차이가 존재할 때는 어떻게 처리해야 할까?”
이럴 때, 사용하는 것이 ‘추상화’이다.
우리는 위에서 부모 클래스인 Grade
와 상속받은 자식 클래스 Silver
를 작성했다.
그리고 이를 main()
에서 다음과 같이 사용하고자 한다.
Members[i].getBonus();
위의 메서드는 각 등급별로 입력된 포인트에서 추가 포인트가 얼마인지 알아보는 코드이다.
따라서 Silver
클래스뿐만이 아니라, Grade
클래스를 물려받은 다른 클래스별로 다르게 적용해야 한다.
이를 적용하는 데는 두 가지 방법이 있다.
‘override’는 단어는 “직권을 이용하여 결정, 명령을 무시하다”라는 뜻이다.
이처럼 ‘method overriding’은 부모 클래스에서 정의된 메서드를 무시하고 새로 재정의하는 것이다.
public double getBonus() {
return this.point*0.03;
}
public double getBonus() {
return this.getPoint()*0.02;
이렇게 할 경우, Silver
의 getBonus()
는 Grade
클래스를 그대로 물려받지만 단, 기존의 기능을 무시하고 새롭게 정의된 것이다. 그런데도 주의할 것은 이는 Silver
의 비공통적인 것이 아니란 것이다.
즉, 인스턴스 안에서는 여전히 Grade
에 영역에 존재하고 있다.
이런 method overriding
을 사용하려면 다음의 규칙이 있다.
① 매개변수, 메서드명, 리턴값이 일치해야 한다.
② 접근제한자의 경우 더 작은 범위에서만 가능하다. public
→ protected
| private
추가로 오버라이딩을 하면 이클립스 내부에서 초록색 삼각형이 나타난다. 이를 보고 해당 메서드가 오버라이딩 되었음을 알 수 있다.
여기서 우리가 생각해야 할 것은 getBonus()
란 메서드의 의미이다. 오버라이드 할 메서드는 존재 자체가 중요하지. 그 내용은 중요하지 않다.
즉, 부모 클래스는 독자적으로 움직이지 않고 자식 클래스 내부에서만 존재할 때 의미를 지닌다. 그래서 메서드의 실행부를 삭제하면 에러가 발생한다.
따라서 컴파일러에 해당 메서드가 설계 목적임을 알려줘야 하는데, 이때 사용하는 기법을 추상화라고 한다.
추상화는 다음과 같이 사용한다.
Grade 클래스
abstract public double getBonus();
Silver 클래스
public double getBonus() {
return this.getPoint()*0.02;
① 명시 목적 메서드에는 {} 실행부를 입력하지 않는다.
② 메서드 앞에 abstract
라는 키워드를 붙여준다.
이를 통해서 컴파일러는 해당 메서드가 설계 목적이란 걸 알 수 있다.
하지만 바로 에러가 날 것이다. 이는 자바의 안정성 때문이다.
이는 해당 클래스의 사용자가 추상화된 메서드를 호출시킬 수도 있다는 경우의 수가 존재하기 때문이다. 따라서 세 번째 규칙이 등장한다.
③ 자바 컴파일러는 메서드를 추상화를 시키면 해당 클래스는 추상화 처리를 해줘야 한다.
abstract public class Grade {}
따라서 공통 메서드들은 부모 클래스에 다 쳐들어가야 하고 다음과 같이 응용한다.
- 프레임워크에서 사용자에게 Call-back 패턴을 위해서 사용
: 원리를 모르고 사용하기 때문에 기능을 구현하려면 필수적인 부분들을 입력하도록 강요하는 것이다.
- 자식 클래스에서 강제 오버라이딩 하기 위해 사용
: 설계 목적의 메서드이기에 이를 상속받는 자식 클래스는 생략된 {}
실행부의 내용을 입력해줘야만 한다. 이때는 정확히 오버라이딩보단 ‘implements’라고 해주고 하얀색 삼각형이 이클립스에 표시된다.
결과적으로 추상화는 메서드, 클래스를 설계 목적으로 만드는 것이다.
따라서 설계 용도로 존재하니까 상속받은 코드들에서 정의과정이 필요로 하다.
물론 추상화를 하더라도 부모 인스턴스의 ‘생성자’까지는 생성된다. 하지만 비어있는 메서드는 자식 클래스를 통해 정의된다. 따라서 기본 상속 개념이 독립성의 한계가 있었다면, 추상화를 통해 자식 클래스의 독립성을 채워줄 수 있다.
다형성은 상속의 원리를 이용하여 코드 결합도를 해결하는 데 쓰인다.
앞서 getBonus()
메서드를 Silver
클래스에서 오버라이딩 하여 사용하였다.
그러면 그 자체는 Silver
안에 소속되어 있어야 하는데 Grade
에 속해있다고 서술하였다.
이 역시 상속하고 관계되어 있다.
먼저 위의 두 클래스를 제외하고 main
에서 데이터를 처리하는 Manager
라는 클래스가 존재한다.
import types.Gold;
import types.Silver;
public class Manager {
private Silver[] silverMembers = new Silver[10];
private Gold[] goldMembers = new Gold[10];
private int silverIndex = 0;
private int goldIndex = 0;
public void insert(Silver s) {
this.silverMembers[silverIndex++] = s;
}
public void insert(Gold g) {
this.goldMembers[goldIndex++] = g;
}
public Silver[] getSilverMembers() {
return silverMembers;
}
public Gold[] getGoldMembers() {
return goldMembers;
}
public int getSilverIndex() {
return silverIndex;
}
public int getGoldIndex() {
return goldIndex;
}
원래 Grade
를 상속받는 클래스는 Gold
와 Silver
가 있다. 그리고 main
에서 값을 입력받을 때는 객체배열을 이용하여 입력을 받았었다.
그러다 보니 실질적으로 main()
에서 사용하는 객체배열을 두 가지이고, 이는 서로 독립적으로 운용된다.
즉, Silver
배열 1개, Gold
배열 1개로 총 20개를 입력받는 것이다. 이렇게 되면 표면적으로 “20개나 받을 수 있네”라고 생각하겠지만, 반대로 데이터를 받을 때도 배열 별로 따로 관리되는 것이다.
그런 면에서 코드중복은 기본이고, 각 클래스와 Manager
클래스 간의 결합도가 높아 한쪽이 변경되면 다른 한쪽도 변경되어야 한다.
따라서 Manager에서 운용되는 데이터를 하나로 통합시키는 것이 좋다.
이를 위해 방법은 Silver
Gold
에 포함되는 Grade
로 모든 데이터를 통합시키는 방향으로 전개되는데, 이때 사용되는 것이 ‘다형성’이다.
다형성이란? “형태가 많은 성질”을 의미한다.
구체적으로 본질은 하나인데, 형태가 여러 가지이다. 즉, 변수 하나가 여러 타입을 저장할 수 있는 성질이 다형성이라 한다.
① 다형성은 상속 관계에서 이뤄진다.
상속 관계에서 부모 인스턴스는 자식 인스턴스 내부에 만들어진다. 이를 이용하여서 다형성을 구현할 수 있다.
② 상위클래스 참조변수는 자신을 상속받는 하위클래스 인스턴스의 주소를 저장할 수 있다.
public class A {
public void funA() {
System.out.println("A입니다.");
}
}
public class B {
public void funB() {
System.out.println("B입니다.");
}
}
먼저 A a
라는 코드는 “나는 자료형 A를 가리키는 변수다. 나를 따라가면 A가 있다”라는 것이 a
라는 참조변수의 의미이다.
만약 a를 따라간 곳에 A 형태의 자료가 없다면? 여기서 에러가 발생하는 것이다.
따라서 A a = new B();
일 때, a.
을 찍으면 B
의 인스턴스는 기본적으로 나오지 않는다.
하지만 상속 관계라면?
public class B extends A{
public void funB() {
System.out.println("B입니다.");
}
}
위에서 말한 것처럼 부모 인스턴스는 자식 인스턴스 안에 반드시 존재한다.
따라서 B
가 A
의 자식 클래스라면 a
를 따라가면 그곳엔 A
자료형이 존재하게 된다.
public class Main {
public static void main(String[] args) {
A a = new B();
}
}
이를 자동으로 일어나는 참조변수의 형변환이라고 하여 ‘Up Casting’이라고 한다.
물론 크기는 B
가 크지만 A
가 더 상위 존재이기에 인스턴스의 자료형이 B일지라도 A
의 메서드와 변수들을 사용할 수 있다.
단, B
만 가지고 있는 메서드들을 사용할 수 없다.
하지만 마냥 불가능한 것은 아니다. 우리가 기본형을 다룰 때처럼 강제로 형변환을 일으켜주면 된다.
사용은 a
앞에 (B)
를 붙여 ((B)a)
의 형태를 만들어주면 되고 이를 참조변수의 수동 형변환 “Down Casting”이라고 해준다.
public static void main(String[] args) {
A a = new B();
((B)a).funB();
}
}
결과적으로 a
를 통해 A
와 B
클래스의 메서드를 사용할 수 있다.
예외적으로 다운 캐스팅 없이 사용할 수 있는 상황은 두 가지다.
① B
가 A의 메서드를 오버라이딩을 하면 가능하다.
이는 오버라이딩 자체가 A
의 메서드를 무효화하고 재정의한 것이기 때문에 A
의 메서드는 사용되지 않는다.
// A 클래스
public void Over() {
System.out.println("A의 것입니다.");
}
// B 클래스의 오버라이딩
public void Over() {
System.out.println("B의 것입니다.");
}
public static void main(String[] args) {
A a = new B();
a.Over();
}
② A 클래스를 추상화처리 한다.
오버라이딩과 같은 원리로 대신, 정의되지 않은 A의 메서드들을 B에서 처리해주는 것이다.
// A 클래스
abstract public void Over()'
// B 클래스의 implements
public void Over() {
System.out.println("B의 것입니다.");
}
public static void main(String[] args) {
A a = new B();
a.Over();
}
실행하면 두 코드 모두 “B의 것입니다”라고 출력된다.