yeonuel-tech

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

programing-languages/Java

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

여늘(yeonuel) 2024. 3. 12. 21:07

 
오늘 학원에서 자바스크립트 수업 듣다가 원장님께서 자바스크립트에서는 인터페이스가 없지만, 인터페이스와 유사한 프로토콜이 있다고 했다. 그 와중에 생각 났던 것이 인터페이스의 장점 중 하나는 클래스 간의 관계를 맺어 줄 수 있어서 유연한 설계를 할 수 있는 것이다. 나는 자바스크립트에서도 프로토콜이 자바의 인터페이스 처럼 클래스 간의 관계를 맺어 줄 수 있는지 궁금했고 질문을 했다. 결과는 프로토콜은 클래스 간의 관계를 맺어 줄 수 없다. 
 
그 과정에서 인터페이스나 추상 클래스로 프로그래밍 하는 것의 장점을 생각해보게 되었다.
 

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