들어가기 전에
회사를 그만두고 있었던 공백기간에 주로 코드 리팩터링, 내가 지향하는 클린코드의 기준을 만들기 위해 노력했다. 사실 사용자 입장에서는 똑같은 기능이겠지만 뭐랄까... 리팩터링(혹은 최적화가)이 성공적으로 완료되어 내가 원하는 대로 애플리케이션이 실행될 때의 그 쾌감은 동종 업계 사람들이라면 다 공감하지 않을까?
이번 주제는 리팩터링하면서 오래간만에 기초부터 다시 다듬게 된 abstract과 interface다.
추상 클래스(abstract class)
클래스를 일반 설계도라고 한다면, 추상 클래스는 미완성 설계도에 비유할 수 있다. 미완성 설계도로 완성된 제품을 만들 수 없듯이 추상클래스로 인스턴스는 생성할 수 없다. 추상클래스는 상속을 통해서 자손클래스에 의해서만 완성될 수 있다.
추상 클래스는 일반 클래스에 키워드 abstract을 추가하여 선언할 수 있다.
abstract class 클래스이름 {
...
}
추상 클래스는 추상 메서드를 포함하고 있다는 것을 제외하고는 일반 클래스와 동일하다.
- 생성자, 멤버변수, 메서드를 가질 수 있음
- 추상 메서드를 가짐
추상 메서드를 사용하는 이유는 메서드의 내용이 상속받는 클래스에 따라 달라질 수 있기 때문에 조상 클래스에서는 선언부만을 작성하고, 주석을 덧붙여 어떤 기능을 수행할 목적으로 작성되었는지 알려 주고, 실제 내용은 상속받는 클래스에서 구현하도록 비워두는 것이다.
/* 주석을 통해 어떤 기능을 수행할 목적으로 작성하였는지 설명한다 */
abstract 리턴타입 메서드이름();
추상 클래스로부터 상속받는 자손 클래스는 오버라이딩을 통해 조상인 추상 클래스의 추상 메서드를 모두 구현해주어야 한다. 만약 조상으로부터 상속받은 추상메서드 중 하나라도 구현하지 않는다면, 자손클래스 역시 추상클래스로 지정해 주어야 한다.
상속과 추상
상속이 자손 클래스를 만드는데 조상 클래스를 사용하는 것이라면, 이와 반대로 추상화는 기존의 클래스의 공통부분을 뽑아내서 조상 클래스를 만드는 것이라고 할 수 있다.
상속 계층도를 따라 내려갈수록 클래스는 점점 기능이 추가되어 구체화의 정도가 심해지며, 상속 계층도를 따라 올라갈수록 클래스는 추상화의 정도가 심해진다.
이렇게 추상 메서드를 사용하여 구현하면 다형성에 의해 조상 클래스의 참조변수로 자손 클래스의 인스턴스를 참조하는 것이 가능하게 된다.
// Marine, Tank, Dropship 은 각각 Unit의 자손이다
Unit [] group = new Unit[4];
group[0] = new Marine();
group[1] = new Tank();
group[2] = new Dropship();
for(int i=0; i < group.length;i++)
group[i].move(100, 200);
인터페이스(interface)
인터페이스는 일종의 추상 클래스이다. 추상 클래스처럼 추상메서드를 갖지만 추상 클래스보다 추상화 정도가 높아서 추상클래스와 달리 몸통을 갖춘 일반 메서드 또는 멤버변수를 구성원으로 가질 수 없다.
- 상수
- 추상메서드
앞서 추상 클래스를 미완성 설계도라고 표현했는데, 인터페이스는 구현된 것은 아무 것도 없고 밑그림만 그려져 있는 기본 설계도라고 할 수 있다.
인터페이스는 선언시 class 대신 Interface를 사용한다.
interface 인터페이스이름 {
public static final 타입 상수이름 = 값;
public abstract 메서드이름 (매개변수목록);
}
또한 일반적인 클래스의 멤버들과 달리 다음과 같은 제약사항이 있다.
- 모든 멤버 변수는 public static final 이어야 하며, 이를 생략할 수 있다
- 모든 메서드는 public abstract 이어야 하며, 이를 생략할 수 있다
그런데 jdk 1.8부터 인터페이스에 static메서드와 디폴트 메서드(default method)를 허용하는 방향으로 변경되었다.
인터페이스(interface)의 상속과 구현
인터페이스는 인터페이스로부터만 상속받을 수 있으며, 클래스와는 달리 다중상속을 지원한다. 또한 추상클래스처럼 그 자체로는 인스턴스를 생성할 수 없으며, 추상클래스가 상속을 통해 추상메서드를 완성하는 것처럼 인터페이스도 자신에 정의된 추상메서드의 몸통을 만들어주는 클래스를 작성해야 한다. 이 구현 대상 클래스를 작성할 때 키워드 implements(구현)를 사용한다.
만일 구현하는 인터페이스의 메서드 중 일부만 구현한다면 abstract을 붙여서 추상 클래스로 선언해야 한다.
// Fightable에서 제공하는 메서드의 일부만 구현할 경우
abstract class Fighter implements Fightable {
...
}
또한 다음과 같이 상속과 구현을 동시에 할 수도 있다.
class Fighter extends Unit implements Fightable {
// Unit 과 Fightable에 있는 메서드들을 Override 한다
// Override 작성시 조상의 메서드보다 넓은 범위의 접근 제어자를 사용해야 한다는 것을 기억하자
}
다중 상속
기본적으로 자바에서는 다중 상속을 허용하지 않는다. 동일한 객체지향언어인 C++에서는 다중상속을 허용하므로 자바에서도 이에 대응하기 위해 인터페이스를 통한 다중 상속이 가능하다라고 표현하는 것일 뿐 자바에서 인터페이스를 통해 다중 상속을 구현하는 경우는 거의 없다.
만일 두 개의 클래스로부터 상속을 받아야 할 상황이라면, 두 조상 클래스 중에서 비중이 높은 쪽을 선택하고 다른 한쪽은 클래스 내부에 멤버로 포함시키는 방식으로 처리하거나 어느 한쪽의 필요한 부분을 뽑아서 인터페이스로 만든 다음 구현하면 된다.
인터페이스를 이용한 다형성
인터페이스 역시 이를 구현한 클래스의 조상이라 할 수 있으므로 해당 인터페이스 타입의 참조변수로 이를 구현한 클래스의 인스턴스를 참조할 수 있으며, 인터페이스 타입으로의 형변환도 가능하다.
// Fightable 는 인터페이스, Fighter은 Fightable 인터페이스를 구현한 클래스에 해당한다
Fightable f = (Fightable)new Fighter();
Fightable f = new Fighter();
따라서 다음과 같이 메서드의 매개변수 타입으로 사용될 수 있다.
void attack(Fightable f) {
//...
}
인터페이스 타입의 매개변수가 갖는 의미는 메서드 호출 시 해당 인터페이스를 구현한 클래스의 인스턴스를 매개변수로 제공해야 한다는 것이다. 또한 같은 맥락에서 메서드의 리턴타입으로 인터페이스의 타입을 지정하는 것 역시 가능하다.
Fightable method() {
...
Fighter f = new Fighter();
return f; // 여기서 Fighter 은 인터페이스 Fightable을 구체화한 클래스에 해당한다
}
리턴타입이 인터페이스라는 것은 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다는 것을 의미한다.
리턴타입을 인터페이스로 정의하면, 인터페이스를 구현하는 새로운 종류의 클래스가 추가되더라도 굳이 서비스로직에서 호출되는 부분을 수정할 필요가 없다.
아래 코드는 실무에서도 자주 사용되는 인터페이스 사용 패턴이다.
interface Parseable {
//구문 분석작업을 수행한다
public abstract void parse(String fileName);
}
class ParserManager {
//리턴 타입이 Parseable인 인터페이스이다.
public static Parseable getParser(String type) {
// 일반적으로 if-else 혹은 switch 문이 이와 같은 패턴으로 작성 된다
if(type.eqauls("XML")) {
return new XMLParser(); // Parseable을 implements 하는 객체에 해당한다
} else {
return new HTMLParser(); // Parseable을 implements 하는 객체에 해당한다
}
}
}
class ParserTest {
public static void main(String args[]) {
Parseable parser = ParserManager.getParser("XML");
parser.parse("document.xml");
parser = ParserManager.getParser("HTML");
parser.parse("document2.html");
}
}
위와 같이 Parseable 참조변수 parse를 통해 parse()를 호출하면 추후 또 다른 NewXMLParser 등이 추가되어도 ParserTest 내의 기존 소스는 수정하지 않아도 된다. 즉 서비스 도메인 소스의 수정이 불필요해지는 것이다.
여기서 알 수 있는 것은 ParseTest는 인터페이스 Parseable과의 관계만을 가지기 때문에 getParser가 반환하는 구체화된 클래스들의 변경에 영향을 받지 않는다.
이 특성은 인터페이스에 대한 본질적 이해의 핵심적인 부분과 연결된다.
클래스를 사용하는 쪽과 클래스를 제공하는 쪽이 존재한다
메서드를 사용하는 쪽에서는 사용하려는 메서드의 선언부만 알면 된다
인터페이스의 장점
인터페이스의 장점은 다음과 같다.
- 개발시간을 단축시킬 수 있다
- 표준화가 가능하다 → 일종의 틀을 제공하는 것이므로 보다 일관되고 정형화된 프로그램의 개발을 가능하게 해 준다
- 서로 관계없는 클래스들에게 관계를 맺어 줄 수 있다 → 서로 상속관계에 있지 않고 같은 조상클래스를 가지고 있지 않은 서로 아무런 관계도 없는 클래스들에게 하나의 인터페이스를 공통적으로 구현하도록 함으로써 관계를 맺어 줄 수 있다
- 독립적인 프로그래밍이 가능하다 → 클래스의 선언과 구현을 분리시킬 수 있기 때문에 클래스들의 직접적인 관계를 간접적으로 변경할 수 있다
디폴트 메서드와 static 메서드
- static 메서드
static메서드는 인스턴스와 관계가 없는 독립적인 메서드이기 때문에 예전부터 인터페이스에 추가하지 못할 이유가 없었다. 그러나 자바를 보다 쉽게 배울 수 있도록 규칙을 단순히 할 필요가 있어서 인터페이스의 모든 메서드는 추상 메서드이어야 한다는 규칙에 예외를 두지 않은 것뿐이다.
그러나 JDK1.8부터는 이를 허용한다.
가장 대표적인 것으로 java.util.Collection인터페이스가 있는데 이 인터페이스와 관련된 static 메서드 선언을 위해 Collections 클래스가 생성되었다. 만약 인터페이스에 static메서드를 추가할 수 있었다면 Collections는 존재하지 않았을 것이다.
static메서드 역시 접근 제어자가 항상 public이며, 생략할 수 있다.
- default 메서드
조상 클래스에 새로운 메서드를 추가하는 것은 별 일이 아니지만, 인터페이스의 경우 추가하게 된다면 다음과 같은 처리가 따라온다.
추상 메서드 추가 → 인터페이스를 구현한 기존의 모든 클래스에 새로 추가된 메서드를 구현
따라서 인터페이스는 기본적으로 변경되지 않는 것이 가장 좋다. 그러나 아무리 설계를 잘해도 언젠가 변경은 반드시 일어난다.
디폴트 메서드는 이럴 때 사용 된다.
디폴트 메서드는 추상 메서드의 기본적인 구현을 제공하는 메서드로, 추상 메서드가 아니기 때문에 디폴트 메서드가 추가되어도 해당 인터페이스를 구현한 클래스를 변경하지 않아도 된다.
키워드 default를 붙여 선언할 수 있으며 추상 메서드와 달리 일반 메서드처럼 몸통이 있어야 한다. 디폴트 메서드 역시 접근 제어자가 public이며, 이를 생략할 수 있다.
interface MyInterface {
default void newMethod(){}
}
단, 새로 추가된 디폴트 메서드가 기존의 메서드와 이름이 중복되어 충돌하는 경우가 발생할 수 있다. 이 충돌을 해결하는 규칙은 다음과 같다.
- 여러 인터페이스의 디폴트 메서드 간의 충돌 → 인터페이스를 구현한 클래스에서 디폴트 메서드를 오버라이딩
- 디폴트 메서드와 조상 클래스의 메서드 간의 충돌 → 조상 클래스의 메서드가 상속되고, 디폴트 메서드는 무시됨
인터페이스와 추상 클래스의 비교
지금까지 인터페이스와 추상 클래스에 대해 간략히 정리했다. 여기서부터는 공식적인 서적에 기재된 내용이 아니라 내가 리팩터링 하게 되면서 고민했던 기준점을 정리하는 글이니 누군가에게 참고용으로만 읽히길 바란다.
JDK1.8 이전에는 추상 클래스와 인터페이스의 문법적인 사용방법에서의 차이점이 명확했다. 그러나 default, static이 허용되게 되면서 둘 사이의 문법적 기능 구현에는 큰 차이가 없어졌다고 생각한다.
내가 강조하고 싶은 부분은 코드 작성 시 컴파일 에러를 뱉어내는 '문법적인' 것이다. 인터페이스는 기능의 수평적 확장, 추상 클래스는 자신의 기능을 하위 클래스로의 확장에 쓰이지만, 사실 이런 것들은 이론적인 부분이고 개발자가 실제 실무에서 이것들을 무시하고 코드를 작성했을 때 프로그램이 빌드되는 것엔 문제가 없다. ( 물론 잘못된 설계로 시작된 코드는 추후 유지보수 시 아주 힘든 과정을 겪어야겠지만... )
그래서 두 개의 사용처를 어떻게 분리해야 할지 모호했다. 인터페이스든 추상 클래스든 implements, extends 하는 자식 객체는 override를 해서 메서드를 재정의하는 것엔 차이가 없기 때문이다.
이를 명확하게 정의하기 위해 다시 기초로 돌아갔다. 객체지향은 보다 현실세계에 가까운 방식으로 프로그래밍하는 것이고, 이것을 생각해 보면 인터페이스와 추상 클래스의 쓰임을 보다 명확하게 구분할 수 있었다.
한 가지 예시를 들어보자.
현실세계에서 동물에는 고양이, 개들이 등이 속한다. 이들은 달릴 수 있는 행위를 한다. 또한 눈, 코, 입이 있고 호흡을 한다.
탈 것에는 자동차, 버스등이 있다. 이것들은 엔진을 가지고, 경적을 울릴 수 있다. 또한 달린다.
이때 동물과 고양이, 개의 상위 클래스를 동물이라는 추상 클래스로 선언하고, '호흡한다'라는 행위를 메서드로 묶을 수 있다. 고양이와 개는 '호흡한다'의 구체적인 내용을 상속받아 재작성(Overriding) 한다.
탈 것 또한 하나의 추상 클래스로 선언하고, 이를 자동차와 버스가 각각 상속받을 수 있다. 자동차와 버스는 '경적을 울린다'의 구체적인 내용을 재작성한다.
그러나 '달리다'의 내용을 공유하기 위해 [동물-자동차, 버스] 혹은 [탈 것-고양이, 개]를 부모-자식 간의 상속관계로 엮는 것은 의미상으로 어색하다. 이럴 경우 인터페이스를 사용할 수 있다.
Action이라는 인터페이스를 선언하고, '달리다'라는 행위를 메서드로 선언한다. 그리고 고양이, 개, 자동차, 버스는 이것을 implements 한다.
이것이 추상 클래스와 인터페이스가 가지는 의미적 차이다. 앞으로의 리팩터링 과정에서 이를 혼돈하지 않도록 정리하는 시간을 가졌다.
누군가에게 이 글이 도움이 되었으면 좋겠다고 생각하며 오늘 포스팅도 마친다.
'Dev > Java' 카테고리의 다른 글
Exception - 에러와 예외, Unchecked 와 Checked (2) | 2024.06.15 |
---|---|
Record 로 DTO 생성하기 (0) | 2024.06.03 |
Garbage Collector 과JVM 메모리 구조 ( OOP를 제대로 알기 ) (1) | 2024.05.01 |
직렬화(Serialization) - 객체를 주고 받는 방법 (0) | 2024.04.15 |
Thread 를 알아보자 - Synchronized, Lock과 Condition, volatile (3/3) (0) | 2024.04.15 |