Java에서 클래스는 객체의 특성과 행위를 정의하는데 사용한다. 그리고 객체를 정의한 클래스 사이 공통점이 많이 존재하는 경우, 관리를 위해 유사한 클래스를 묶어 관리할 수 있도록 상위 클래스를 정의하기도 한다. 바로 직전의 포스팅에서 구매 목록인 "문구"와 "과일"을 "품목" 클래스로부터 상속받아 사용하게 하는 것이 그 예이다.
즉, 지금까지 필자가 포스팅을하며 작성해왔던 클래스들은 각 객체의 특성과 행위를 매우 구체적으로 정의한 것이다. 하지만, 모든 객체를 이런식으로 구체화하기에는 현실적인 어려움이 있다. 따라서 Java에서는 구체화 할 수 없는 객체에 대해 정의할 수 있도록 추상클래스와 인터페이스라는 것을 제공한다. 일반 클래스 파일이 완성된 계획서라면, 추상클래스와 인터페이스는 미완성 계획서와 청사진으로 볼 수 있다.
상위 클래스를 정의하고 그것을 상속하더라도 모든 객체를 구현하는데 큰 문제가 없을 듯 한데, 왜 추상클래스와 인터페이스라는 것이 등장하게 되었을까? 지금부터 이들의 기능에 대해 알아보자.
1. 추상클래스 개요
필자가 며칠 전 작성한 final 제어자와 관련된 포스팅에서 abstract라는 제어자에 대해 잠깐 언급하고 넘어간 적이 있다. 이 abstract 제어자가 오늘 알아볼 추상 클래스와 관련이 있다.
서두에서 추상 클래스는 "미완성 계획서"라고 필자가 언급했다. 추상적이라는 것은 구체화되지 않았다는 것과 거의유사한데, 이 때문에 추상클래스는 반드시 몸통이 구현되지 않은 클래스가 하나 이상 포함되어야 한다.
abstract 클래스명()
{
...
abstract 매서드명1();
abstract 매서드명2();
...
}
추상 클래스는 일반 클래스와 마찬가지로 멤버변수, 일반 매서드도 가질 수 있으나 반드시 하나 이상의 추상 매서드, 즉 abstract 제어자가 적용된 매서드가 반드시 하나 이상 존재해야 한다. 또한 이 추상 매서드는 추상 클래스 내에서 중괄호 및 매서드 코드가 작성되지 않은 상태로 마무리되어야 한다. 도대체 왜 이런 코드를 쓰는 걸까?
2. 추상 클래스의 사용 이유
이번 포스팅에서 필자는 은행과 관련된 내용으로 코드를 예시를 위해 조금 복잡하게 작성해보려 한다. 우선 은행의 공통 부분을 정의한 BankAbstract라는 클래스를 만들것이며, 전체 보유액과 화폐코드를 일반 변수로 생성하고 인출과 예금에 대한 내용을 구현되지 않은 일반 매서드로 만들려고 한다.
다음으로 만들어진 BankAbstract 매서드를 상속하여 한국, 미국, 독일의 중앙은행에 대한 클래스를 생성할 것이다. 이 세 국가의 은행 클래스를 정의할 때, withdraw와 deposit 매서드를 오버라이딩을 진행해주지 않더라도 에러는 발생하지 않게 된다.
하지만 오버라이딩 없이 각 은행에 대한 클래스를 정의하기에는 어려운 점이 있다. 우선 각 국가의 은행이 사용하는 화폐단위가 다르기 때문에 각 국가의 은행 클래스에서는 서로 다른 인출/예금 매서드가 반드시 정의되어야 한다. 하지만 지금과 같이 상위 클래스 내에 일반 매서드를 사용하는 경우 하위 클래스에서는 해당 매서드들을 반드시 오버라이딩 해야할 의무가 없기 때문에 코드 작성 과정에서 매서드 작성 내용을 생략하고 넘어가게 될 수도 있다.
이제 상위 클래스 코드의 클래스와 매서드에 abstract 제어자를 적용해보자. 방금 전의 예시와 달리, 하위 클래스에서 에러가 발생하는 것이 확인될 것이다.
하위 클래스의 각 클래스 선언부 옆에 다음과 같은 에러가 확인된다.
이 말은 추상클래스를 상속한 클래스는 반드시 추상 클래스 내 매서드를 구체화하여 오버라이딩 해야한다는 것이다. 즉, 추상클래스는 하위 클래스에서 반드시 포함되어야하는 공통 매서드를 가지나, 매서드의 행위가 조금씩 다른 경우 오버라이딩을 의무적으로 행하도록 만드는 역할을 한다.
만약 한국은행에서 예금 시 예금액이 일정 금액을 넘어가면 로또 상품권을 주는 이벤트를 진행하고, 미국 은행은 세금 감면을 해 주는 등의, 예금으로 인한 결과가 다르게 나타나야한다면 해당 내용을 각 클래스의 deposit 매서드에 작성해주면 된다.
Main 함수에서 추상 클래스를 상속받은 각 국 은행의 인스턴스를 생성하여 deposit() 매서드를 호출해보면 아래와 같이 서로 다른 결과가 나타난다.
서로 유사한 클래스 내에 동일한 목적에 대한 매서드지만 구체적인 행위는 다르게 정의되는 경우 추상 클래스를 정의하여 사용한다. 위의 예시 외에도 교통수단으로의 이동을 예로 들 수 있다. 자동차의 경우 목적지 이동 시 반드시 차도로만 이동할 수 있고 험지를 통과하는 것은 불가능하지만, 비행기는 목적지까지 직선으로 이동할 수 있다. 따라서 목적지로 이동한다는 행위는 move()라는 이름의 매서도르 선언하되 구체적 행위(날아가냐 차도를 따라가냐)의 차이만 있기 때문에 abstract move()라는 추상 매서드가 정의된 추상 클래스를 선언받음으로써 교통 수단이라는 객체를 더 철저하게 구현할 수 있게 된다.
2. 인터페이스(Interface)
인터페이스는 추상클래스의 일종이다. 하지만, 클래스라는 이름 대신 인터페이스라는 명칭이 사용된 것은, 추상 클래스와 달리 일반 멤버변수나 일반 매서드를 사용할 수 없고 오로지 상수와 추상 매서드만을 정의할 수 있어 인스턴스 생성이 불가하기 때문이다.
인터페이스는 클래스와 유사한 방식으로 작성하나, 선언부에 class 대신 interface라고 작성한다.
interface 인터페이스명
{
public final 변수타입 변수명 = 리터럴;
public abstract 반환타입 매서드명();
}
인터페이스 내부에 작성되는 상수 선언부와 매서드 선언부의 제어자는 생략이 가능하다. 하지만 생략하더라도 인터페이스 내부에 존재하는 경우, JVM에 의해 자동으로 public final과 public abstract 제어자가 변수와 매서드 앞에 적용된다.
멤버변수와 일반 매서드 등의 정의가 가능한 추상 매서드와 달리, 인터페이스는 이들의 정의를 허용하지 않기 때문에 추상클래스에 비해 추상도가 높다. 따라서 추상 클래스에서 추가로 정의하기 어려운 부분 중 공통적인 부분에 대한 코드를 작성하기 어려운 상황에서 인터페이스를 사용한다.
"추상클래스가 존재함에도 불구하고 더 추상화 정도가 높은 인터페이스를 굳이 사용해야하는가?"라는 의문이 들 수 있다(필자도 몇 날 며칠을 이 문제때문에...). 지금까지의 포스팅을 정독하신 분들이라면 하나의 클래스는 두 개 이상의 클래스를 상속할 수 없다는 것을 알고 있으실 것이다. 따라서 추가적으로 공통 부문을 가지는 별도의 클래스를 작성하더라도 하위 클래스들이 새로 작성된 별도 클래스를 상속하지 못하기 때문에 일부 매서드를 적용하지 못하는 사태가 발생한다.
인터페이스의 내부 상수와 추상매서드는 일반 클래스에서도 '구현(implements)'을 통해 사용하는 것이 가능하다. 이 구현은 클래스 상속과는 별개의 프로세스로 동작하며, 하나의 클래스에서 다수의 인터페이스를 구현할 수 있기 때문이다. 필자의 학창 시절에 매우 큰 인기를 끌었던 "스타크래프트" 게임의 캐릭터를 통해 예시를 들어보려 한다.
(1) 클래스에서 인터페이스 구현하기
필자는 스타크래프트 캐릭터 5가지에 대해 클래스를 작성하려 한다. 이 캐릭터는 Unit 클래스를 상속하며, 각 캐릭터 클래스 파일은 자신의 종족에 맞는 패키지 폴더에 저장한다.
참고로 위 사진의 상위 2개 캐릭터는 SCV와 Drone으로 Terran과 Zerg라는 종족에서 자원을 채취하는 일꾼 역할을 한다. 물론, 기본적인 캐릭터이기 때문에 자원채취 외에도 지상 물체에 대한 공격도 가능하다.
아래의 세 캐릭터는 각각 Marine, Overlord 그리고 공성전차(Siegetank)다. Overlord는 Zerg 종족에 속한 유닛으로 공격 능력이 없는, 하늘에 떠다니는 캐릭터다. Marine과 탱크야 지구인에게 워낙 친숙한 물체니 별 다른 설명이 필요없을듯 하고...
Unit 클래스는 모든 유닛이 기본적으로 가지는 특성과 기능에 대해 추상 클래스로 정의해두었다. 추상 매서드는 이동과 정지 단 두 가지만 우선 정의해두었다. 이제 이 Unit 클래스를 상속하는 모든 캐릭터 클래스는 이동과 정지 매서드를 사용하는 것이 가능해진다.
이제 여기에 각 캐릭터의 기능을 추가한다고 가정해보자. 우선 Overlord를 제외한 나머지는 다른 객체를 공격할 수 있는 공격 매서드를 작성해야한다. 그럼, 하위 클래스 중 공격 능력을 가지는 캐릭터 파일에만 공격 매서드를 추가해야하는데, 단 하나의 공격 불가 캐릭터로 인해 수많은 파일에 공격 매서드를 작성하는 것은 비효율적이다. 게다가 공격 매서드의 내용 중 수정이 필요한 부분이 발생하는 순간, 공격 능력을 가지는 캐릭터 파일의 공격 매서드 내부 내용을 수동으로 일괄 변경해야하는 점도 문제가 된다.
그런다고 상위 클래스인 Unit에 공격 매서드를 넣자니, Overlord 파일에서 공격 매서드를 상속받는 것도 문제가 된다. 아무 동작을 하지 않는 매서드로 오버라이딩 시킬수도 있지만 엄밀히 따지자면 Overlord는 공격이 불가능한 캐릭터이기 때문에 잘못된 설계가 된다.
그럼, 공격이 가능한 캐릭터를 정의하는 클래스를 생성하여 Unit 클래스를 상속받게 하면 어떨까? 나쁜 방법은 아니지만, 기능이 많아진다면 클래스의 계층 구조가 복잡하게 꼬이면서 추후 관리가 어려워진다.
이제, 위에서 작성한 모든 코드를 삭제하고 인터페이스를 적용하여 위의 내용을 다시 구현해보려 한다. 먼저 Unit 클래스부터 작성해보자.
Unit 클래스에는 멤버변수를 제외한 어떠한 매서드도 정의되어 있지 않은데, 이 매서드를 인터페이스에서 받아 구현하기 위함이다. Unit 클래스는 기존에 move(), stop() 매서드 두 가지가 정의되어 있었으니, 이 두 매서드를 추상적으로 정의한 인터페이스를 먼저 생성해야한다.
생성한 인터페이스를 클래스에서 구현, 즉 인터페이스에 정의된 변수와 매서드를 사용할 수 있도록 정의하는 방법은 아래와 같다.
class 클래스명 implements 구현할_인터페이스명1, 구현할_인터페이명2...
{
public제어자 인터페이스_추상매서드_오버라이딩
}
위의 구조대로 Unit 클래스를 정의하면 아래와 같다.
이제 앞서 생성한 5가지 캐릭터를 클래스 파일로 형성하고 Unit 클래스를 상속받도록 해보자. 5가지 캐릭터 클래스는 모두 move, stop 행위가 가능한 상태로 정의되는 것을 확인할 수 있다.
각 캐릭터의 기본 기능은 구현되었으니, 이제 Overlord를 제외한 나머지 캐릭터의 공격에 대해 정의해보자. 이를 위해서는 공격과 관련된 인터페이스를 정의한 뒤, Overlord를 제외한 나머지 캐릭터 클래스에 구현해주면 된다. 그럼, 모든 캐릭터는 Movable 인터페이스에 정의된대로 이동과 관련한 매서드는 사용가능하며, Overlord를 제외한 나머지는 공격과 관련된 매서드도 추가로 사용할 수 있게 된다.
먼저 Marine 캐릭터에 Attackable 인터페이스를 적용하고, 추상 매서드를 구체화해보자.
attack() 매서드를 보면 공격받는 대상이 매게변수로 지정되어 있으며, 이 매개변수 인스턴스의 underAttack 매서드가 동작하도록 정의되어있다. 따라서 Unit 클래스도 이 매서드들을 사용할 수 있도록 추가 코드를 작성한다.
위의 코드에서 초록색 박스에 표시된 부분에 주목하자. Unit 클래스의 underAttack 매서드는 매개변수로 해당 인스턴스를 공격하는 Attackable 유닛의 인스턴스를 사용하도록 정의되어 있다. Attackable의 경우 클래스가 아닌 인터페이스인데다, 인터페이스 자체로는 인스턴스 생성이 불가능함에도 불구하고, Attackable 인터페이스를 구현한 클래스의 인스턴스는 마치 Attackable 인터페이스의 인스턴스로도 인식되도록 한다.
Marine 1기와 Drone1기를 생성해서 Marine이 Drone을 공격하도록 만들어보자.
계속 공격해보자.
이제 공격이 가능한 클래스 파일은 implements를 통해 Attackable 인터페이스를 상속받기만 하면 다른 유닛을 공격하는 것이 가능해짐을 확인했다. 모두 추가해보자.
그런데, 공격이 가능한 유닛에 모두 Attackable 인터페이스를 구현하고나니 한 가지 문제가 생겼다. 지상 공격만 가능한 일꾼과 탱크가 하늘위에 떠있는 Overlord를 공격하는 상황이 발생하는 것이다.
이러한 문제점 역시 인터페이스를 사용하면 해결이 가능하다. 특정 클래스가 인터페이스를 구현하면 클래스 인스턴스가 마치 인터페이스의 인스턴스처럼 인식되는 것을 이용하면 된다.
Overlord 클래스는 Flightable 인터페이스를 구현했으나, 인터페이스 내에 어떠한 추상매서드도 존재하지 않기 때문에 클래스 내에서 매서드 오버라이딩이 강제되지 않으면서 인터페이스의 인스턴스가 될 수 있다.
이제 SiegeTank가 Overlord를 공격할 수 없게 만들어보자. 여러 방법이 있겠지만 정교한 방식은 귀찮음을 수반하기 때문에 필자는 단순히 SiegeTank 클래스의 attack() 매서드를 수정하려 한다. 아래와 같이.
Main 함수에 SiegeTank, Drone, Overlord 인스턴스를 하나씩 생성하고, SiegeTank가 각 캐릭터를 공격하게 한 결과는 아래와 같이 나타난다.
혹시나, 필자가 그랬던 것과 동일한 생각을 하시는 분들이 있을지 모르겠다.
"그럼, 어떤 유닛이 공중 공격만을 하기 원한다면, attack() 매서드의 인자에 사용하는 타입을 Flightable로 변경하면 되겠네요?"
불가능하다. 당연한 이야기지만 인터페이스의 경우 인스턴스로의 직접 생성이 가능한 생성자가 없기 때문에 매개변수로 지정할 수 없다. 하지만 다른 클래스에 의해 인터페이스가 구현되어 있는 경우, 구현한 인터페이스의 매서드를 모두 사용할 수 있기 때문에 마치 해당 인터페이스의 인스턴스처럼 보이는 효과를 주게되는 것이다. 따라서, 클래스로부터 단일 상속만을 허용하는 Java에서 다중 상속 효과를 내야하는 경우, 위와 같이 인터페이스를 활용하면 된다.
(2) 인터페이스의 상속
인터페이스도 일종의 클래스이기 때문에 상속이 가능하다. 하지만 인터페이스의 상속은 인터페이스만이 진행할 수 있다는 것을 주의해야 한다. 인터페이스 상속 시 사용하는 키워드는 extends로 동일하며, 아래와 같이 사용한다.
interface 인터페이스명 extends 상속인터페이스1, 상속인터페이스2...
{
추상매서드 선언;
}
* 참고: 인터페이스는 하나 이상의 인터페이스 상속이 가능하다.
지금까지 진행한 모든 코드는 Main과 패키지를 제외한 나머지 파일을 삭제하고, 새 인터페이스와 클래스 파일로 인터페이스 상속 예시를 진행해보려 한다. 우선 인터페이스는 공격과 관련된 내용으로 작성할 것인데, InterfaceAttackable과 InterfaceGroundAttackable 인터페이스를 먼저 정의할 것이다. 게임 캐릭터는 지상과 공중 모두 공격이 가능한 캐릭터가 있는 반면, 일꾼과 같이 지상공격만 가능한 캐릭터도 존재한다.
지상, 공중공격이 모두 가능한 캐릭터의 공격 행위는 InterfaceAttackable 인터페이스의 attack(ClassUnit u) 추상매서드를, 지상 공격만 가능한 캐릭터의 공격행위는 InterfaceGroundAttackable 인터페이스의 groundAttack(ClassGroundUnit u) 추상매서드를 정의했다(매개 변수에 대한 내용은 뒤에서 확인하자). 그리고 InterfaceGroundAttackable 인터페이스는 InterfaceAttackable 인터페이스를 상속하도록 정의했다.
이제 필자는 Unit 클래스를 기존과 유사하게 정의한 뒤, 하위 클래스로 ClassGroundUnit을, 그 하위 클래스로 ClassGroundUnitGroundAttackOnly 클래스를 정의하려한다. ClassGroundUnitGroundAttackOnly 클래스는 지상 유닛이면서 지상 공격만 가능한 캐릭터를 정의하기 위한 클래스다.
각 인터페이스와 클래스 사이 상관관계 모식도는 아래와 같이 나타낼 수 있다. 파란색 선은 상속관계, 빨간 선은 인터페이스 구현을 나타낸다.
필자가 정의한 클래스인 Unit, GroundUnit, GroundUnitGroundAttackOnly는 각각 인터페이스 Movable, Attackable, GroundAttackable을 구현하고 있다. 그리고 최하위 클래스인 GroundUnitGroundAttackOnly 클래스는 GroundAttackable의 추상 매서드인 groundAttack() 외에도 구현한 인터페이스가 상속한 상위 인터페이스의 추상 매서드도 반드시 정의해주어야 한다.
이렇게 인터페이스도 상위 인터페이스로부터 상속을 받는다는 개념이 적용되며, 하위 인터페이스를 구현한 클래스는 반드시 상위 인터페이스의 추상 매서드도 함께 정의해주어야 한다.
지금까지 추상클래스, 그리고 추상 클래스에서 추상화 정도가 더 높은 인터페이스에 대해 알아보았으며, 인터페이스의 구현과 상속 방법까지 알아본 상태다. 그런데, 필자가 그러했듯이, 아직까지는 추상클래스와 인터페이스의 유용성에 대해 크게 체감하지 못하는 분들이 많을 것이다. 이제, 필자는 위에 작성한 인터페이스를 바탕으로, 각 캐릭터 클래스를 정의할 것이다.
(3) 인터페이스를 활용한 다중 상속 구현
Java 클래스는 다중상속을 허용하지 않는다. 따라서 필자가 지상 유닛을 지상/공중공격이 가능한 유닛과 지상 공격만 가능한 유닛을 인터페이스 사용 없이 클래스로만 작성하는 경우, 각 클래스마다 중복되는 코드가 증가하게된다. 중복되는 코드가 많아진다는 것은, 수정 사항이 생기면 각 클래스 파일에서 중복되는 내용을 수동으로 일일이 고쳐야한다는 것이다.
본 포스팅의 서두와 중간부분의 예시와 달리, 뒤에 작성하는 캐릭터들의 클래스에는 어떠한 매서드도 사용되지 않는다. 이미 인터페이스에 정의된, 또는 인터페이스를 구현한 클래스를 상속받아 캐릭터 클래스 파일을 정의할 것이기 때문이다.
필자가 작성한 다섯 캐릭터의 코드 어디에도 매서드 정의부분은 보이지 않는다. 캐릭터의 행위를 정의하는 매서드는 모두 인터페이스로 구현된 상위 클래스인 ClassUnit, ClassAirUnit, ClassGroundUnit, ClassGroundUnitGroundAttackOnly로부터 상속받기 때문이다.
이제 Main 함수에서 캐릭터 인스턴스를 생성해서 공격을 진행해보자.
말단 클래스의 동작을 상위 공통 클래스의 매서드를 통해 처리하기 때문에 추후 수정사항이 생기더라도 하나의 매서드 코드만 수정해주면 된다. 즉, 인터페이스 사용을 통해 코드 관리 및 유지보수를 효과적으로 진행하게 될 수 있는 것이다.
참고로, 위의 코드들을 모식도로 나타내면 아래와 같다.
위와 동일한 방식으로 공중 유닛, 그리고 공중 유닛 중에서도 지상 공격만 가능한 혹은 공중 공격만 가능한 캐릭터를 생성하는 것도 가능하다.
다음 포스팅에서는 Java의 예외처리에 대해 알아보려 한다.
Fin.
'Java > Java Basic' 카테고리의 다른 글
[Java Basic] 24. 예외 발생시키기와 사용자 지정 예외 생성 (0) | 2022.08.08 |
---|---|
[Java Basic] 23. 예외처리(Exception Handling) (0) | 2022.08.03 |
[Java Basic] 21. 클래스 다형성 (0) | 2022.07.27 |
[Java Basic] 20. final 제어자 (0) | 2022.07.23 |
[Java Basic] 19. 접근 제어자(Access Modifier) (0) | 2022.07.21 |
댓글