다시, 인터페이스 (feat. 객체지향 언어에 인터페이스가 존재하는 이유)

지난번에 "객체지향 언어에 인터페이스가 존재하는 이유" 라는 글을 쓴 적이 있었다. 하지만 다시 읽어보니 지나치게 난잡 하다는 느낌을 지울수가 없어서, 이 기회에 다시 글을 적어보게 되었다.

객체지향 언어에 인터페이스가 존재하는 이유
코드의 결합도가 높으면 생기는 문제 어떤 개발자가 고객으로부터 계정의 패스워드를 암호화 해서 저장을 해야하니, 패스워드를 암호화 하는 기능을 만들어야 한다는 요청을 받았다. 개발자는 금방 암호화 기능을 추가해 고객에게 보여주었다. @Testpublic void encryptMD5() throws NoSuchAlgorithmException { // 외부에서 입력받은 패스워드 String password = “this is password”; // MD5 알고리즘으로 문자열을 인코딩 한다 M…

인터페이스란 추상화 된 클래스다

컴퓨터 과학에서 추상화란 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것을 말한다. 다만 막상 이렇게 추상화의 개념에 대해 들어봐도 이해하기 힘들 것이다. 그렇다면 이해하기 쉽게 한 가지 예를 들어보자.

세계에는 정말 여러가지 종류의 자동차가 존재한다. 일단 자동차 회사만 해도 국내에는 현대와 기아가 존재하고, 세계로 뻗어 나가면 도요타, 혼다, BMW, 아우디, 폭스바겐, 포드 등등... 회사만 해도 이렇게 많은데, 각 회사마다 한 종류의 자동차만 만드는 것도 아니다. 이 수많은 종류의 자동차는 각각 외형도 구동 방식도 마력도 전부 다르다. 하지만 이렇게 수 많은 차이점이 존재하더라도, 자동차인 이상 가지는 공통적인 기능이 몇 가지 존재한다. 모든 자동차가 가지는 공통적인 기능을 세 가지만 추려내자면 다음과 같이 추려볼 수 있다.

  1. 액셀을 밟으면 차의 속력이 증가한다.
  2. 브레이크를 밟으면 차의 속력이 감소한다.
  3. 핸들을 특정 방향으로 돌리면 차량이 해당 방향으로 꺾인다.

이런 식으로 서로 다른 객체들 사이에서 핵심적인 개념과 기능을 추려내는 것을 추상화라고 한다. 그렇다면 '인터페이스란 추상화 된 클래스다'라는 말은 어떤 의미일까? 우선 클래스를 구성하는 요소들을 살펴보자. 클래스 내부는 필드와 메소드로 구성된다. 그리고 필드는 상수와 변수로 구분되고, 메소드는 매개변수, 반환 값, 메소드 명으로 구성된다. 인터페이스는 이런 클래스의 요소들의 공통적인 부분을 추려낸 것이다. 위에서 얘기한 자동차에 대한 얘기들 코드로 표현해 보면 쉽게 이해할 수 있을 것이다.

// 현대 차
public class Hyundai {
    public void has() { System.out.println("경사로 밀림 방지 기능 켜짐") }
    public void pushAccel() { System.out.println("1km/h씩 가속 됩니다"); }
    public void pushBreak() { System.out.println("1km/h씩 감속 됩니다"); }
    public void handling(String direction) { System.out.println(direction + "방향으로 꺾습니다."); }
}

// 도요타 차
public class Toyota {
    public void lfa() { System.out.println("차로 유지 보조 기능 켜짐"); }
    public void pushAccel() { System.out.println("2km/h씩 가속 됩니다"); }
    public void pushBreak() { System.out.println("2km/h씩 감속 됩니다"); }
    public void handling(String direction) { System.out.println(direction + "방향으로 꺾습니다."); }
}

// BMW 차
public class BMW {
    public void cruise() { System.out.println("크루즈 컨트롤 기능 켜짐"); }
    public void pushAccel() { System.out.println("3km/h씩 가속 됩니다"); }
    public void pushBreak() { System.out.println("3km/h씩 감속 됩니다"); }
    public void handling(String direction) { System.out.println(direction + "방향으로 꺾습니다."); }
}

위와 같이 세 대의 자동차가 있다고 가정하자. 인터페이스를 만들기 위해서는 우선 위의 구현 클래스 간의 공통점을 찾아내야 한다. 우선 세 종류의 차량에서 pushAccel, pushBreak, handling이라는 공통된 메소드를 찾아낼 수 있다. 이 때, 인터페이스로 추상화 하기 위해서는 내부 기능을 제외한 메소드의 구성요소 매개변수, 반환 값, 메소드 명은 모두 같아야만 한다. 우선, pushAccel, pushBreak를 먼저 추상화 해보면 다음과 같이 된다.

public interface Car {
    public void pushAccel();
    public void pushBreak();
}

handling같은 경우에는 아예 내부 기능까지 동일하니, 최종적으로는 다음과 같이 인터페이스를 작성할 수 있다.

public interface Car {
    public void pushAccel();
    public void pushBreak();
    
    // default 키워드는 Java8 이상에서 지원한다
    default public void handling(String direction) {
        System.out.println(direction + "방향으로 꺾습니다.");
    }
}

참고로 인터페이스에서는 필드도 추상화 가능하다. 다만 제약 조건이 있는데, 인터페이스 필드에 추상화 할 수 있는 것은 오직 static 키워드를 선언한 상수만 추상화할 수 있다. 이제 추상화된 인터페이스를 이용해, 기존의 코드를 변경해보자.

// 현대 차
public class Hyundai implements Car {
    public void has() { System.out.println("경사로 밀림 방지 기능 켜짐") }
    
    @Override
    public void pushAccel() { System.out.println("1km/h씩 가속 됩니다"); }
    
    @Override
    public void pushBreak() { System.out.println("1km/h씩 감속 됩니다"); }
}

// 도요타 차
public class Toyota implements Car {
    public void lfa() { System.out.println("차로 유지 보조 기능 켜짐"); }
    
    @Override
    public void pushAccel() { System.out.println("2km/h씩 가속 됩니다"); }
    
    @Override
    public void pushBreak() { System.out.println("2km/h씩 감속 됩니다"); }
}

// BMW 차
public class BMW implements Car {
    public void cruise() { System.out.println("크루즈 컨트롤 기능 켜짐"); }
    
    @Override
    public void pushAccel() { System.out.println("3km/h씩 가속 됩니다"); }
    
    @Override
    public void pushBreak() { System.out.println("3km/h씩 감속 됩니다"); }
}

그런데 이쯤 되면 한 가지 의문이 드는 사람도 있을 것이다.

"굳이 추상화를 할 필요가 있는 건가?"

인터페이스의 존재 의의를 모른다면 당연히 들 수 밖에 없는 의문이다. 왜냐하면 굳이 추상화를 하지 않더라도 동작에는 아무런 문제가 없기 때문이다. 하지만 인터페이스를 사용하지 않고 구현 클래스로만 코드를 작성하게 되면 나중에 코드가 복잡해질 때 유지보수에 영향을 미치게 된다. 이 다음 절의 내용을 보면서 인터페이스가 없을 때, 발생할 수 있는 문제에 대해 확인해 보자.


인터페이스가 없을 때, 생기는 문제

어떤 프로젝트에 필요한 암호화 라이브러리가 필요하다는 이유로, PM이 세 명의 사원 A, B, C씨에게 각각 SHA-1,  SHA-256,  SHA-512 알고리즘으로 데이터를 받아 암호화 하고, 암호화 된 데이터를 반환하는 기능을 구현해 달라고 지시했다 가정하자.

A씨는 다음과 같이 구현했다.

public class SHA1 {
    public byte[] encrypt(String text) {
        String ecryptedText = null;
        
        // SHA-1 알고리즘으로 데이터를 암호화 하는 로직
        
        return encryptedText;
    }
}

A 씨는 PM이 의도한 대로, 훌륭히 기능을 구현해 주었다.

B씨는 다음과 같이 구현했다.

public class SHA256 {
    public String encryption(byte[] data) {
        byte[] encrypedData = null;
    
        // SHA-256 알고리즘으로 데이터를 암호화 하는 로직
        
        return encryptedData;
    }
}

B씨는 기능 자체는 훌륭히 구현하긴 했지만, PM이 의도한대로 구현 되지는 않았다. PM은 encrypt라는 이름을 가진 메소드 명을 가지고 매개변수와 반환 값의 자료형이 byte[]가 아닌 String를 사용하기 바랐기 때문에, 이는 PM의 의도와 어긋난 구현이라고 볼 수 있다.

C씨는 다음과 같이 구현했다.

public class SHA512 {
    public byte[] amHoHwa(byte[] data) {
        byte[] encryptedData = null;
    
        // SHA-512 알고리즘으로 데이터를 암호화 하는 로직
        
        return encryptedData;
    }
}

PM은 슬슬 어지러워지기 시작했다. 일단, 매개변수 자료형과 반환 값 자료형도 뒤죽박죽에 메소드 명은 amHoHwa라는 요상한 이름을 붙여 놓았다.

PM의_심정.jpg

이런 일이 발생한 이유는, PM이 세 명의 사원에게 지시를 내리면서

  1. 매개변수는 어떤 자료형으로 받아야 하는가?
  2. 반환 값의 자료형은 무엇인가?
  3. 메소드 명은 어떻게 명명해야 하는가?

라는 구체적인 지시가 없었기 때문이다. 즉, 건물로 따지자면 설계도도 없이 인부들이 대충 공구리 친 것이나 다름 없는 상황인 것이다. 이런 통일되지 않은 구현 클래스의 존재는 추후 유지보수에 악영향을 줄 수도 있다. 다음 코드를 보자.

public class Password {

    // byte[] 자료형의 패스워드를 받아 암호화 하고 byte[] 자료형으로 반환한다
    public byte[] passwordEncrypt(String password) {
        SHA1 sha1 = new SHA1();
        return sha1.encrypt(password);
    }
}

이 코드는 어떤 프로젝트 개발자가 A씨가 구현한 SHA1 구현 클래스를 이용해 패스워드를 암호화 하는 로직이다. PM이 의도한대로 구현한 A씨의 구현 클래스는 실제 서비스 로직에서 사용될 때, 아주 짧은 코드만으로도 동작이 가능했다. 하지만 모종의 이유로 SHA256, SHA512 구현 클래스를 이용해 패스워드를 암호화 할 일이 생겼다고 가정해 보자. SHA256 구현 클래스를 이용해 패스워드를 암호화 하고자 한다면 다음과 같이 코드가 변경될 것이다.

// SHA256을 사용할 경우
public class Password {

    // byte[] 자료형의 패스워드를 받아 암호화 하고 byte[] 자료형으로 반환한다
    public byte[] passwordEncrypt(String password) {
        SHA256 sha256 = new SHA256();
        byte[] encryptedPassword = sha256.encrypt(password.getBytes(StandardCharsets.UTF_8));
        
        // 식별 가능한 문자열을 생성하려면 Hex 혹은 Base64로 인코딩 해야한다
        return new String(encryptedPassword);
    }
}

// SHA512을 사용할 경우
public class Password {

    // byte[] 자료형의 패스워드를 받아 암호화 하고 byte[] 자료형으로 반환한다
    public byte[] passwordEncrypt(String password) {
        SHA512 sha512 = new SHA512();
        byte[] encryptedPassword = sha512.amHoHwa(password.getBytes(StandardCharsets.UTF_8));
        
        // 식별 가능한 문자열을 생성하려면 Hex 혹은 Base64로 인코딩 해야한다
        return new String(encryptedPassword);
    }
}

SHA256 를 이용해 패스워드를 암호화 하려면, 해당 클래스의 encrypt 메소드에 byte[] 자료형인 데이터를 넘겨줘야 하므로 문자열을 변환해주는 과정을 거쳐야 한다. 거기에 반환 타입이 byte[] 였으므로 다시 String 자료형으로 변환한 뒤 반환해 줘야 한다. 단순 비교를 해봐도 SHA1 구현 클래스를 사용했을 때 보다 자료형 변환을 위해 두 단계를 더 거치도록 코드를 수정해야 한다. SHA512로 바꾼 경우에는 한술 더 떠서 메소드 이름까지 바꿔줘야 한다. 지금이야 단순히 암호화 하는 메소드 하나 뿐이라 그렇지, 메소드가 여러개 였다면 더 많은 코드 수정이 이루어 졌을지도 모른다.

비슷한 기능을 하는 구현 클래스가 서로 통일되지 않은 자료형, 메소드 명을 사용한다면 위의 예시와 같이 사소한 변경사항 만으로도 해당 구현 클래스에 의존하는 코드들을 모조리 바꿔야 하는 불상사가 발생할 수 있다. 이런 일을 방지하기 위해, 대부분의 객체지향 언어에는 인터페이스 혹은 추상 클래스라는 것을 지원하고 있다.(JAVA에서는 둘 다 지원하지만, 이 글에서는 인터페이스에 대해서만 다룬다.) JAVA에서는 인터페이스를 상속받아 클래스를 구현할 때, 인터페이스에서 지정한 자료형과 메소드 명을 강제로 따르도록 할 수 있다. 이런 특성 때문에 인터페이스를 '구현되지 않은 설계도'라고 부르기도 한다. 또한 인터페이스는 구현되지 않은 상태기 때문에 단독으로 인스턴스를 생성할 수 없으며, 반드시 인터페이스를 통해 구현된 클래스를 통해서만 인스턴스를 생성할 수 있다.


인터페이스를 통해 강제로 따르도록 만들기

PM은 String 형태의 매개변수를 받아 암호화 한 뒤, String 형태로 반환하는 encrypt라는 이름을 가진 메소드를 만들게 강제하려고 한다. 그렇다면 인터페이스는 다음과 같이 작성하면 된다.

public interface OneWayEncrypt {

    /*
     * 인터페이스에서 구현 할 메소드는 모두 public으로만 선언할 수 있다.
     * 참고로 public은 생략할 수 있다.
     */
    public String encrypt(String text);
}

PM은 A, B, C 씨에게 OneWayEncryption 인터페이스를 상속받아 기능을 구현해 달라고 요청했다. 인터페이스를 상속 받아 구현한 코드는 다음과 같다.

// A 씨의 코드
public class SHA1 implements OneWayEncrypt {

    @Override
    public String encrypt(String data) {
        String encryptedPassword = null;
    
        // SHA-1 알고리즘으로 데이터를 암호화 하는 로직
        
        return encryptedPassword;
    }
}

// B 씨의 코드
public class SHA256 implements OneWayEncrypt {

    @Override
    public String encrypt(String data) {
        String encryptedPassword = null;
    
        // SHA-256 알고리즘으로 데이터를 암호화 하는 로직
        
        return encryptedPassword;
    }
}

// C 씨의 코드
public class SHA512 implements OneWayEncrypt {

    @Override
    public String encrypt(String data) {
        String encryptedPassword = null;
    
        // SHA-512 알고리즘으로 데이터를 암호화 하는 로직
        
        return encryptedPassword;
    }
}

JAVA에서 인터페이스를 상속 받으면, 구현되지 않은 메소드를 항상 재정의 해야 할 의무가 생기게 된다. 객체지향 언어에서 메소드 재정의는 매개변수, 반환 값, 메소드 명을 동일하게 유지하되 내부의 기능을 재구성하는 것이기 때문에, 위와 같이 PM이 의도한대로 기능을 구현하도록 강제할 수 있다.


하나의 인터페이스로 여러 개의 구현 클래스 묶기(feat. 결합도 낮추기)

인터페이스의 장점은 이 뿐만이 아니다. 위에서 봤듯이 인터페이스에 정의한 메소드를 강제로 구현체에서 재정의 해야 한다는 제약이 걸리기 때문에, 개발자가 인터페이스 내부에서 선언된 메소드가 무엇 인지만 알면 구현 클래스의 내부가 어떻게 되어있는지 몰라도 해당 메소드를 사용할 수 있다.

예를 들어, 다음과 같은 코드가 있다고 가정해 보자.

public Password {
    public String passwordEncrypt(String password) {
    
        // SHA1 인스턴스를 OneWayEncrypt 인터페이스에 연결.
        OneWayEncrypt owe = new SHA1();
        return owe.encrypt(password);
    }
}

코드만 봐도 알 수 있겠지만, 위에서 OneWayEncrypt 인터페이스를 상속 받아 구현한 SHA1 구현 클래스로 패스워드를 암호화 한 뒤 반환하고 있다. 그런데 앞으로는 보안을 위해, 암호화 강도가 더 강한 SHA-256 알고리즘을 통해 패스워드를 암호화 해야 한다는 지침이 위로부터 내려왔다고 가정하자. 원래대로 라면 SHA-1 알고리즘으로 구현된 코드를 싹 다 뜯어 고쳐야 했겠지만, 우리는 이미 OneWayEncrypt 인터페이스를 상속 받아 구현한 SHA256 구현 클래스가 존재한다. 또한 이 구현 클래스 내부에 어떤 코드가 작성되었는지 모르지만, 확실한 것은 encrypt라는 메소드를 호출해 암호화를 하면 된다는 점이다.

즉, 위의 코드는 다음과 같이 변경할 수 있다.

public Password {
    public String passwordEncrypt(String password) {
    
        // SHA256 인스턴스를 OneWayEncrypt 인터페이스에 연결.
        OneWayEncrypt owe = new SHA256();
        return owe.encrypt(password);
    }
}

암호화 로직을 수정하지 않더라도, 인터페이스에 연결되는 인스턴스만 교체해 버리면 이전과 동일한 기능을 수행하되 알고리즘만 SHA-256으로 변경된 코드로 변경할 수 있다.

이러한 특성은 추후 새로운 구현 클래스를 만들 때도 도움이 된다. 예를 들어 현재 SHA2 계열의 알고리즘의 위험성이 지적되며 완전히 새로운 알고리즘으로 구현된 SHA3 알고리즘이 개발되었는데, 이를 인터페이스 규격에 맞춰 개발한다면 위와 마찬가지로 인스턴스만 교체해 버리면 된다.

public class SHA3512 implements OneWayEncrypt {

    @Override
    public String encrypt(data) {
        String encryptedPassword = null;
        
        // SHA3-512 알고리즘으로 암호화 하는 로직
        
        return encryptedPassword;
    }
}
public Password {
    public String passwordEncrypt(String password) {
    
        // SHA3512 인스턴스를 OneWayEncrypt 인터페이스에 연결.
        OneWayEncrypt owe = new SHA3512();
        retuen owe.encrypt(password);
    }
}

위와 같이 패스워드 암호화 서비스 로직에서 가장 중요한 암호화 로직을 구현 클래스에 감추어 놓고, 서비스 로직으로부터 분리해 알고리즘 변경으로 인한 서비스 로직의 변경을 최소화 할 수 있다. 또한, 구현 클래스에서 어떠한 문제가 발견되어 수정하더라도 서비스 로직에서 이미 핵심 기능이 분리되어 있는 상태이기 때문에 서비스 로직의 수정이 불필요하다는 장점이 있다. 이렇게 서비스 로직으로부터 핵심 기능을 분리해 서로가 서로의 로직에 영향을 미치는 정도가 적은 것을 객체 지향 프로그래밍(이하 OOP) 에서는 '결합도가 낮다' 라고 표현한다. 이는 OOP 5대 원칙인 SOLID 원칙에서 '개방-폐쇄 원칙'과 '의존성 역전 원칙'과 관련이 있다. 여기서 OOP 5대 원칙까지 얘기하기에는 주제에도 맞지 않을 것 같고, 글이 너무 길어질 것 같으므로 자세한 얘기는 생략하도록 하겠다.


요약해서 다시 보는 인터페이스

  1. 인터페이스란 클래스를 추상화 한 것이다. 이 때, 추상화가 되는 대상은 메소드 명, 매개변수, 반환 자료형 그리고 필드에 존재하는 상수다.
  2. 인터페이스는 구현 클래스가 가져야 할 특징에 대한 정보만 담고 있고 기능은 구현되어 있지 않다. 이러한 성질 때문에 인터페이스를 '구현되지 않은 설계도'라고 부르기도 한다. 또한 기능이 구현되어 있지 않기 때문에 인터페이스 단독으로 인스턴스를 생성하는 것은 불가능하다.
  3. 인터페이스를 상속 받아 구현하는 클래스는 인터페이스에서 선언한 메소드를 재정의 해야 할 의무가 생긴다. 이 때문에 인터페이스를 상속받아 구현된 클래스에는 항상 인터페이스에서 선언된 메소드가 존재하므로, 개발자는 인터페이스에서 선언된 메소드만 알면 구현 클래스의 메소드가 무엇이 있는지 몰라도 해당 메소드를 사용할 수 있다.
  4. 인터페이스는 서비스 로직과 핵심 기능 로직을 분리하는 역할을 한다. 이 때문에 서비스 로직과 핵심 기능 둘 중 하나에 코드 변경이 일어나도 서로 간에 영향을 적게 받으므로 유지보수성이 좋아진다. 이처럼 코드가 서로 간에 영향을 적게 받는것을 '코드 간의 결합도가 낮다'라고 표현하며, 이는 OOP 5대 원칙 중 '개방-폐쇄 원칙'과 '의존성 역전 원칙'과 관련이 있다.

정리하며

업무상의 이유로 세종에 내려와 있었는데, 시간이 남는 동안 과거 컴퓨터 과학을 공부하면서 배웠던 객체 지향 프로그래밍에 대한 개념과 JAVA를 공부하며 배웠던 인터페이스에 대해 정리할 기회가 있었다. 가능한한 내용을 최소화 하기 위해 노력했는데도 불구하고 무려 5000자나 되는 글을 적어버렸다. 이 글이 인터페이스에 대해 알고 싶은 사람들을 위해 도움이 되기를 바라며, 다음에는 OOP 5대 원칙을 실제 코드에 비유하고 설명하는 글을 적어보려고 한다.