yeonuel-tech
왜 인터페이스나 추상 클래스로 프로그래밍하는 것을 선호하는가? 본문

오늘 학원에서 자바스크립트 수업 듣다가 원장님께서 자바스크립트에서는 인터페이스가 없지만, 인터페이스와 유사한 프로토콜이 있다고 했다. 그 와중에 생각 났던 것이 인터페이스의 장점 중 하나는 클래스 간의 관계를 맺어 줄 수 있어서 유연한 설계를 할 수 있는 것이다. 나는 자바스크립트에서도 프로토콜이 자바의 인터페이스 처럼 클래스 간의 관계를 맺어 줄 수 있는지 궁금했고 질문을 했다. 결과는 프로토콜은 클래스 간의 관계를 맺어 줄 수 없다.
그 과정에서 인터페이스나 추상 클래스로 프로그래밍 하는 것의 장점을 생각해보게 되었다.
1. 변경에 유리한 코드 작성, 코드 재활용 촉진
인터페이스나 추상 클래스로 프로그래밍을 하면 변경에 유리한 코드를 짤 수 있다. 이 말은 변경 사항이 발생해도 코드의 변경 범위가 작다는 것을 의미한다.
추상적으로 프로그래밍을 했을 때 장점이 잘 드러나는 코드를 보자. 밑에는 Iterator 패턴을 적용한 코드이다.
자바에는 Collection 이 있고 크게 List와 Set로 분리할 수 있다. Collection에는 iterator() 메서드가 있고 반환 타입으로 Iterator를 반환한다. 이를 통해서 반복 처리를 하는 로직을 공통으로 다룰 수 있다. 즉 List나 Set를 Collection 으로 묶어서 반복 처리를 할 수 있는 공통 로직을 작성할 수 있다. 이는 List나 Set이라는 클래스 타입이 달라도 반복 처리하는 로직은 달라지지 않아 변경이 일어나지 않음을 알 수 있다. 또한 List나 Set이라는 여러 구현체를 쉽게 끼워 넣을 수 있어서 코드를 재활용해서 사용할 수 있다
즉 밑에 코드를 보면, applyIterator()라는 메서드에 반복 처리 공통 로직을 작성해서 List, Set 모두 사용할 수 있음을 확인할 수 있다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Iterator 패턴 예시")
class IteratorTest {
List<Integer> list = new ArrayList<>();
Set<Integer> set = new LinkedHashSet<>();
@BeforeEach
void init() {
// init
list.clear();
set.clear();
}
@DisplayName("Iterator 적용하지 않은 List 순회하는 코드")
@ParameterizedTest(name = "{index}. 현재 리스트의 사이즈는 {0}입니다.")
@ValueSource(ints = {0, 10, 100, 1000, 10000})
void testForNotIteratorPatternOfList(int size) {
// 기대 결과
String expected = makeResultString(size);
// 리스트 적용
fillElements(list, size);
StringBuilder sb = new StringBuilder();
for (int i=0; i<list.size(); i++) {
sb.append(list.get(i));
}
String result1 = sb.toString();
// 결과 비교
assertEquals(expected, result1);
}
@DisplayName("Iterator 적용하지 않은 Set 순회하는 코드")
@ParameterizedTest(name = "{index}. 현재 세트의 사이즈는 {0}입니다.")
@ValueSource(ints = {0, 10, 100, 1000, 10000})
void testForNotIteratorPatternOfSet(int size) {
// 기대 결과
String expected = makeResultString(size);
// 세트 적용, 세트는 순서가 없어서 순회하는 것 자체가 모순, 따라서 여기서는 스트림으로 처리
fillElements(set, size);
StringBuilder sb = new StringBuilder();
set.stream().forEach(e -> sb.append(e));
String result = sb.toString();
// 결과 비교
assertEquals(expected, result);
}
@DisplayName("Iterator 적용한 코드, 서로 다른 컬렉션이여도 공통 로직으로 처리 가능")
@ParameterizedTest(name = "{index}. 현재 컬렉션(리스트, 세트)의 사이즈는 {0}입니다.")
@ValueSource(ints = {0, 10, 100, 1000, 10000})
void testForIteratorPattern(int size) {
// 기대 결과
String expected = makeResultString(size);
// 리스트 적용
fillElements(list, size);
String result1 = applyIterator(list);
// 세트 적용
fillElements(set, size);
String result2 = applyIterator(set);
// 결과 비교
assertEquals(expected, result1);
assertEquals(expected, result2);
assertEquals(result1, result2);
}
// 원소 채우기
private void fillElements(Collection c, int size) {
c.clear();
for (int i=0; i<size; i++) {
c.add(i);
}
}
// 예상 결과 생성
private String makeResultString(int size) {
StringBuilder sb = new StringBuilder();
for (int i=0; i<size; i++) {
sb.append(i);
}
return sb.toString();
}
// 리스트, 세트 모두 적용 가능한 공통 로직 이것이 추상화의 장점
private String applyIterator(Collection c) {
StringBuilder sb = new StringBuilder();
Iterator<Integer> it = c.iterator();
while (it.hasNext()) {
sb.append(it.next());
}
return sb.toString();
}
}

2. 컴파일 적극 활용(타입 체크, 형변환 활용)
또한, 밑에 코드를 보면 컴파일에게 많은 역할을 분담하여 성능이 개선되는 것을 알 수 있다. 즉, 인터페이스를 적극 활용함으로써 런타임 시점에서 일일이 해당 인스턴스의 타입을 체크하는 if 문을 생략할 수 있다.
repair(Unit u)의 경우 if 문으로 Unit이 Repairable 타입인지 확인을 한다. 하지만 repair(Repairable r)의 경우 if 문이 제거 된 것을 확인할 수 있다. 프로그램이 작을 때는 if 문이 성능 저하에 큰 요인이 되진 않지만 프로그램이 커지면 성능 저하의 주범이 되며 가독성이 좋지 않은 코드를 작성하게 된다.
추가적으로 형변환도 생략할 수 있다. 내가 밑에 작성한 코드는 SCV, Tank의 타입이 모두 Unit 으로 선언되었기 때문에 형변환을 해주었지만, Unit이 아닌 Repairable 타입으로 선언하면 형변환 없이 repair(Repairable r) 메서드를 사용할 수 있다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class InterfaceTest {
Unit marine; // Marine
Unit scv; // SCV
Unit tank; // Tank
Mechanic mechanic;
@BeforeEach
void init() {
marine = new Marine();
scv = new SCV();
tank = new Tank();
mechanic = new Mechanic();
}
@DisplayName("정비공이 Marine을 수리하지못함, Repairable이 아니기 때문")
@Test
void mechanicFailToRepair() {
// 마린이 scv에 공격당함
int damage = scv.attack();
marine.attacked(damage);
// 정비공이 수리하려함
boolean result = mechanic.repair(marine);
// 실패
assertEquals(false, result);
}
@DisplayName("정비공이 SCV를 수리에 성공함")
@Test
void mechanicSuccessToRepairSCV() {
// scv가 마린과 tank에 공격당함
int damage1 = marine.attack();
int damage2 = tank.attack();
// 정비공이 수리함
// 첫 번째 repair(Unit u) 사용
scv.attacked(damage1+damage2);
boolean result1 = mechanic.repair(scv);
// 두 번째 repair(Repairable r) 사용
scv.attacked(damage1+damage2);
boolean result2 = mechanic.repair((Repairable) scv);
// 성공
assertEquals(true, result1);
assertEquals(true, result2);
assertEquals(result1, result2);
}
@DisplayName("정비공이 Tank 수리에 성공함")
@Test
void mechanicSuccessToRepairTank() {
// tank가 마린과 scv에게 공격당함
int damage1 = marine.attack();
int damage2 = scv.attack();
// 정비공이 수리함
// 첫 번째 repair(Unit u) 사용
tank.attacked(damage1+damage2);
boolean result1 = mechanic.repair(tank);
// 두 번째 repair(Repairable r) 사용
tank.attacked(damage1+damage2);
boolean result2 = mechanic.repair((Repairable) tank);
// 성공
assertEquals(true, result1);
assertEquals(true, result2);
assertEquals(result1, result2);
}
}
abstract class Unit {
public abstract void move(int x, int y);
public abstract int attack();
public abstract boolean attacked(int amount);
public abstract boolean isDead();
}
class Marine extends Unit {
private static final int DAMAGE = 10;
private static final int SPEED = 5;
private static final int FULL_HP = 100;
private int x;
private int y;
private int hp;
Marine() {
x = 0;
y = 0;
hp = FULL_HP;
}
@Override
public void move(int x, int y) {
if (isDead()) {
System.out.println("해당 마린은 죽었습니다.");
return;
}
x += SPEED;
y += SPEED;
System.out.printf("Marine이 이동했습니다. (현재 좌표 x : %d, y : %d)\n", x, y);
}
@Override
public int attack() {
if (isDead()) {
System.out.println("해당 마린은 죽었습니다.");
return -1;
}
System.out.printf("Marine이 공격했습니다. (데미지 : %d)\n", DAMAGE);
return DAMAGE;
}
@Override
public boolean attacked(int amount) {
if (isDead()) {
System.out.println("해당 마린은 죽었습니다.");
return false;
}
hp -= amount;
return true;
}
@Override
public boolean isDead() {
return hp <= 0;
}
}
class SCV extends Unit implements Repairable {
private static final int DAMAGE = 50;
private static final int SPEED = 85;
private static final int FULL_HP = 350;
private int x;
private int y;
private int hp;
SCV() {
x = 0;
y = 0;
hp = FULL_HP;
}
@Override
public void move(int x, int y) {
x += SPEED;
y += SPEED;
System.out.printf("SCV가 이동했습니다. (현재 좌표 x : %d, y : %d)\n", x, y);
}
@Override
public int attack() {
System.out.printf("SCV가 공격했습니다. (데미지 : %d)\n", DAMAGE);
return DAMAGE;
}
@Override
public boolean attacked(int amount) {
if (isDead()) {
System.out.println("해당 SCV는 파괴되었습니다.");
return false;
}
hp -= amount;
return true;
}
@Override
public boolean isDead() {
return hp <= 0;
}
@Override
public void repairing(int amount) {
if (isDead()) {
System.out.println("해당 SCV는 파괴되었습니다.");
return;
}
if (hp + amount <= FULL_HP) {
hp += amount;
} else {
hp = FULL_HP;
}
}
@Override
public boolean isRepaired() {
return hp == FULL_HP;
}
}
class Tank extends Unit implements Repairable {
private static final int DAMAGE = 100;
private static final int SPEED = 50;
private static final int FULL_HP = 800;
private int x;
private int y;
private int hp;
Tank() {
x = 0;
y = 0;
hp = FULL_HP;
}
@Override
public void move(int x, int y) {
x += SPEED;
y += SPEED;
System.out.printf("Tank가 이동했습니다. (현재 좌표 x : %d, y : %d)\n", x, y);
}
@Override
public int attack() {
System.out.printf("Tank가 공격했습니다. (데미지 : %d)\n", DAMAGE);
return DAMAGE;
}
@Override
public boolean attacked(int amount) {
if (isDead()) {
System.out.println("해당 Tank는 파괴되었습니다.");
return false;
}
hp -= amount;
return true;
}
@Override
public boolean isDead() {
return hp <= 0;
}
@Override
public void repairing(int amount) {
if (isDead()) {
System.out.println("해당 Tank는 파괴되었습니다.");
return;
}
if (hp + amount <= FULL_HP) {
hp += amount;
} else {
hp = FULL_HP;
}
}
@Override
public boolean isRepaired() {
return hp == FULL_HP;
}
}
interface Repairable {
void repairing(int amount);
boolean isRepaired();
};
class Mechanic {
// 수리량
private static final int AMOUNT = 10;
// 형변환 및 타입 체크 없이 바로 사용가능, 성능 개선 및 로직 깔끔하게 작성
public boolean repair(Repairable r) {
System.out.println("수리를 시작합니다.");
while (r.isRepaired()) {
r.repairing(AMOUNT);
}
System.out.println("수리를 완료했습니다.");
return true;
}
// 타입 체크, 형변환 동시에 진행
public boolean repair(Unit u) {
// 타입 체크
if (!(u instanceof Repairable)) {
System.out.println("수리할 수 없는 대상입니다.");
return false;
}
// 형변환
Repairable r = (Repairable) u;
System.out.println("수리를 시작합니다.");
while (r.isRepaired()) {
r.repairing(AMOUNT);
}
System.out.println("수리를 완료했습니다.");
return true;
}
}

이 과정에서 자바스크립트, Python, 과 같은 언어는 컴파일이 존재하지 않아서 코드를 유연하게 작성할 수 있지만, 자바, C, C++ 언어와 같이 컴파일러가 존재하는 언어의 장점을 알 수 있었다. 또한, 컴파일러를 최대한 활용하면 if문, 형변환 같은 여러 코드를 줄일 수 있다는 것이다. 이는 코드가 깔끔해지고 오류 발생 가능성도 낮아지며 성능도 개선된다는 것을 알 수 있다. 따라서, 인터페이스나 추상 클래스로 프로그래밍을 선호하는 이유는 변경에 유리한 코드를 작성하면서도 컴파일러를 최대한 활용하는 이점이 있기 때문이다. 따라서 인터페이스나 추상 클래스를 적극 활용한 코드를 작성하려고 노력하자
- 참고
자바의 정석(남궁성)
Java의 정석 | 남궁성 - 교보문고 (kyobobook.co.kr)
Java의 정석 | 남궁성 - 교보문고
Java의 정석 | 자바의 기초부터 실전활용까지 모두 담다!자바의 기초부터 객제지향개념을 넘어 실전활용까지 수록한『Java의 정석』. 저자의 오랜 실무경험과 강의한 내용으로 구성되어 자바를
product.kyobobook.co.kr
'programing-languages > Java' 카테고리의 다른 글
데코레이터 패턴은 어떻게 해서 등장하게 된 것일까? (3) | 2024.07.15 |
---|---|
쓰레드를 구현하는 과정에서 쓰이는 패턴은? (작성중) (0) | 2024.03.27 |
try-with-resources는 무엇이고 언제 써야할까? (0) | 2024.02.25 |
Integer 클래스에서 compare()메서드에 -를 안쓰고 < 부등호를 사용했을까? (0) | 2024.02.24 |
@SafeVarargs 애너테이션은 언제 사용하는 것일까?(이펙티브 자바 내용 넣기) (1) | 2024.02.24 |