객체지향 언어에 인터페이스가 존재하는 이유

코드의 결합도가 높으면 생기는 문제

어떤 개발자가 고객으로부터 계정의 패스워드를 암호화 해서 저장을 해야하니, 패스워드를 암호화 하는 기능을 만들어야 한다는 요청을 받았다.

"네, 알겠습니다 ㅎㅎ"

개발자는 금방 암호화 기능을 추가해 고객에게 보여주었다.

@Test
public void encryptMD5() throws NoSuchAlgorithmException {

    // 외부에서 입력받은 패스워드
    String password = "this is password";

    // MD5 알고리즘으로 문자열을 인코딩 한다
    MessageDigest messageDigest = MessageDigest.getInstance("MD5");
    messageDigest.update(password.getBytes());
    byte[] bytes = messageDigest.digest();

    // 인코딩 한 데이터를 다시 문자열로 변경한다
    StringBuilder builder = new StringBuilder();
    for(byte b : bytes) {
        builder.append(String.format("%02x", b));
    }
    
    String encryptedPassword = builder.toString();
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: 8910e62fae2505e21f568632df8410a9

그러나 "MD5 암호화 알고리즘은 오래되서 보안성 문제가 있다고 하니 SHA-256 암호화 알고리즘을 적용해 주세요" 라며, 암호화 알고리즘을 변경해 달라는 새로운 요청을 해왔다.

"예, 뭐... 보안 문제가 있다면 쓸 수 없겠죠. 다시 적용해서 보여드리겠습니다."

그나마 다행인 점이라면, JAVA에서 SHA 알고리즘은 MD5랑 동일한 java.security 패키지를 사용하면 되기 때문에 getInstance("MD5")getInstance("SHA-256")으로 바꿔주기만 하면 해결될 문제다.

@Test
void encryptSHA256() throws NoSuchAlgorithmException {

    // 외부에서 입력받은 패스워드
    String password = "this is password";

    // SHA-256 알고리즘으로 문자열을 인코딩 한다
    MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
    messageDigest.update(password.getBytes());
    byte[] bytes = messageDigest.digest();

    // 인코딩 한 데이터를 다시 문자열로 변경한다
    StringBuilder builder = new StringBuilder();
    for(byte b : bytes) {
        builder.append(String.format("%02x", b));
    }

    String encryptedPassword = builder.toString();
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: 15a1b278f7e9cd1525e75fd9182d7bc0c6c00e4fa9e678c2184655491f98f1a2

그런데 며칠 후, 이번에는 "보안팀으로부터 패스워드 암호화 시 SALT값을 필수적으로 부여해야 한다는 통보가 내려왔으니 기존 로직에 SALT값이 적용되도록 수정해 주시고, 기왕 이렇게 된 김에 SHA-256 알고리즘보다 문제가 적은 SHA-512 알고리즘으로 변경하죠"라는 요청이 고객으로부터 들어왔다.

"거 참, 한번에 부탁하지 좀..."

슬슬 짜증이 나려고 하지만, 돈 주는 고객님의 부탁이라는 데 뭐 어쩌겠는가. 말 같지도 않은 소리가 아닌 이상 까라면 까야하는 게 개발자다. 신규 유저가입이 이루어지면  해당 유저에게 SALT 값을 발급하도록 하고 패스워드를 암호화 할 때, 해당 유저의 SALT 값을 조회해 와서 패스워드와 함께 암호화 되도록 로직을 수정했다.

@Test
void encryptSHA512WithSalt() throws NoSuchAlgorithmException {

    // 외부에서 입력받은 패스워드
    String password = "this is password";
    
    // 데이터베이스로부터 가져온 해당 유저의 SALT 값
    String salt = "this is salt";
    
    // 패스워드 + SALT 값
    String passwordWithSalt = password + salt;

    // SHA-512 알고리즘으로 문자열을 인코딩 한다
    MessageDigest messageDigest = MessageDigest.getInstance("SHA-512");
    messageDigest.update(passwordWithSalt.getBytes());
    byte[] bytes = messageDigest.digest();
    
    // 인코딩 한 데이터를 다시 문자열로 변경한다
    StringBuilder builder = new StringBuilder();
    for(byte b : bytes) {
        builder.append(String.format("%02x", b));
    }

    String encryptedPassword = builder.toString();
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: 023c2e7bf017ac33995be2cad33f99f4c71985ce5af9b9214a218f7fb816153978a86f9b657a0cb07e1f2c08124a3964e1123810986a32032b417a31957ce0ed

딱 봐도 알겠지만, 이번에는 단순히 getInstance("SHA-256")을 바꾸는 것으로 끝나지 않는다. 중간에 SALT 값을 조회하는 로직을 추가해야 하고, 패스워드 값에 SALT 값을 추가하는 로직도 작성해야 한다. 테스트 코드니까 이정도지, 실제로는 더 광범위한 서비스 로직의 수정이 이루어지게 될 것이다.

이렇게 모든게 다 순탄하게 끝날줄 알았는데, 이번에는 뜬금없이 테스트 기간 전에 "암호화 알고리즘을 BCrypt로 바꾸는게 좋을 것 같네요, SALT가 필요없는 암호화 알고리즘 이라고 하니 간단히 바꿀 수 있겠죠? ㅎㅎ"라는 요청이 들어왔다.

"장난쳐?! 패키지 까지 바뀌어서 이전에 짠 암호화 로직을 전부 바꿔야 하잖아!!!"

이쯤 되면 고객이고 뭐고 간에 한 대 치고 싶어진다. 하지만 사람을 때리는 것은 불법이니 개발자는 이내 화를 삭히고 로직을 수정하러 간다.

@Test
void encryptBCrypt() {

    // 외부에서 입력받은 패스워드
    String password = "this is password";

    // BCrypt 알고리즘으로 인코딩 한다
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    String encryptedPassword = encoder.encode(password);
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: $2a$10$1e7IPggMd6BBl9OIfaIjW.97kt0vRATLXRL5KiiPGBsKNmdxcvAca
참고로 BCrypt 알고리즘은 매번 결과 값이 바뀐다

org.springframework.security.crypto.bcrypt 패키지에 포함된 BCrypt 암호화 인코더는  MessageDigest의 인코더와 달리 byte[]가 아닌 String으로 변환해 주므로 MessageDigest를 이용한 MDSHA 계열의 알고리즘 암호화 보다는 짜야하는 코드가 적다. 하지만 대규모로 코드를 변경했기 때문에, 코드를 변경 함으로써 발생할 수 있는 사이드 이펙트를 고려해야만 한다.

사이드 이펙트(Side Effect)란, 의도하지 않은 효과를 의미한다. 물론 좋은 사이드 이펙트도 있을 수 있으나 일반적으로 개발에서 사이드 이펙트란 부정적인 의미로 사용된다.

일반적으로 사이드 이펙트는 결합도가 높은 코드에서 발생하기 쉽다. 결합도가 높을수록 해당 객체에서 제공하는 기능에 직접 의존하고 호출하기 때문에, 해당 객체가 바뀌면 코드도 바뀌어야만 한다. MessageDigest 객체를 BCryptPasswordEncoder로 바꿨기 때문에 MessageDigest에서 제공하는 메소드들은 모두 삭제하고 새로 짜야한다. 상당히 많은 코드를 바꿔야 하기 때문에 귀찮을 뿐더러, 패스워드를 암호화 하는 로직을 쓰는 곳은 한 군데 뿐만은 아닐 것이다. 따라서 패스워드를 암호화 하는 로직 중, 변경할 곳을 놓친 곳이 있다면 그대로 버그를 일으키는 존재가 될 것이다.


인터페이스는 규약이다

뜬금 없겠지만 하드웨어에 관련된 얘기를 해보려고 한다. 혹시 다음 로고가 무엇인지 알고 있는가?

이 로고는 USB를 상징하는 로고인데 USB는 규격도 제각각에 전원도 공급이 안되는 등, 여러가지 이유로 불편했던 컴퓨터 인터페이스들을 대체하려고 나온 인터페이스다. 그렇다 USB는 인터페이스다. 물론 프로그래밍 언어에서 말하는 인터페이스와는 달리 하드웨어 인터페이스이긴 하지만 말이다. 다만 IT 계열에서 말하는 인터페이스란 접속 및 연결이라는 의미로 사용된다는 것을 볼 때, 어느정도 공통점은 존재한다. 예를 들어 USB는 사전에 미리 정의된 규약(프로토콜) 대로만 통신 한다면 컴퓨터에 연결되는 장치가 어떤 장치든 상관 없이 연결 될 수 있다. 이것이 키보드나 마우스 같은 서로 다른 장치가 메인보드에 납땜하지 않고도 동일한 USB 인터페이스로 컴퓨터에 연결 될 수 있는 이유다.

객체지향 언어의 인터페이스도 이와 비슷한 역할을 한다. 각각의 기능들을 추상화 시켜 공통된 메소드로만 기능을 사용할 수 있도록 규정한다. 즉 규약을 만드는 셈이다. 또한 로직으로 부터 기능을 분리시켜 마치 USB 인터페이스처럼 필요에 따라 갈아끼울 수 있게 만들어 준다. 그렇다면 실제로 객체지향 언어의 인터페이스는 어떻게 사용되는지 다음 예제를 통해 확인해 보자.


인터페이스를 구현하는 방법

그렇다면 프로그래밍 언어에서 인터페이스는 어떻게 구현될까? 프로그래밍 언어별로 조금씩 차이가 있기는 하지만, 위에서 JAVA로 코드의 결합도가 높을 때 생기는 문제에 대해 설명을 했으므로 JAVA를 기준으로 설명하겠다. JAVA 같은 경우에는 특정 클래스가 공통적으로 사용하는 기능을 메소드 단위로 추상화 하여 사용한다. 이게 무슨 의미인지 위에서 나왔던 암호화 기능을 다시 보면서 얘기해 보겠다.

/*
 * MD5 암호화
 */
@Test
public void encryptMD5() throws NoSuchAlgorithmException {

    // 외부에서 입력받은 패스워드
    String password = "this is password";

    // MD5 알고리즘으로 문자열을 인코딩 한다
    MessageDigest messageDigest = MessageDigest.getInstance("MD5");
    messageDigest.update(password.getBytes());
    byte[] bytes = messageDigest.digest();

    // 인코딩 한 데이터를 다시 문자열로 변경한다
    StringBuilder builder = new StringBuilder();
    for(byte b : bytes) {
        builder.append(String.format("%02x", b));
    }
    
    String encryptedPassword = builder.toString();
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}

/*
 * SHA-256 암호화
 */
@Test
void encryptSHA256() throws NoSuchAlgorithmException {

    // 외부에서 입력받은 패스워드
    String password = "this is password";

    // SHA-256 알고리즘으로 문자열을 인코딩 한다
    MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
    messageDigest.update(password.getBytes());
    byte[] bytes = messageDigest.digest();

    // 인코딩 한 데이터를 다시 문자열로 변경한다
    StringBuilder builder = new StringBuilder();
    for(byte b : bytes) {
        builder.append(String.format("%02x", b));
    }

    String encryptedPassword = builder.toString();
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}

/*
 * SHA-512 + SALT 암호화
 */
@Test
void encryptSHA512WithSalt() throws NoSuchAlgorithmException {

    // 외부에서 입력받은 패스워드
    String password = "this is password";
    
    // 데이터베이스로부터 가져온 해당 유저의 SALT 값
    String salt = "this is salt";
    
    // 패스워드 + SALT 값
    String passwordWithSalt = password + salt;

    // SHA-512 알고리즘으로 문자열을 인코딩 한다
    MessageDigest messageDigest = MessageDigest.getInstance("SHA-512");
    messageDigest.update(passwordWithSalt.getBytes());
    byte[] bytes = messageDigest.digest();
    
    // 인코딩 한 데이터를 다시 문자열로 변경한다
    StringBuilder builder = new StringBuilder();
    for(byte b : bytes) {
        builder.append(String.format("%02x", b));
    }

    String encryptedPassword = builder.toString();
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}

/*
 * BCrypt 암호화
 */
@Test
void encryptBCrypt() {

    // 외부에서 입력받은 패스워드
    String password = "this is password";

    // BCrypt 알고리즘으로 인코딩 한다
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    String encryptedPassword = encoder.encode(password);
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}

위의 코드는 네 가지 각기 다른 암호화 알고리즘으로 암호화 하는 서로 다른 메소드의 집합 이지만, 각각의 메소드를 개념적으로 추상화 시킨다면 '외부로부터 패스워드 값을 입력 받고, 인코딩 한 값을 돌려주는 메소드'라는 개념으로 공통화 시킬 수 있을 것이다. 이것을 JAVA의 인터페이스로 구현 한다면 다음과 같이 된다.

public interface PasswordEncoder {

    /*
     * encode 메소드를 호출하고 패스워드 값을 넘겨주면
     * 암호화 알고리즘으로 인코딩 된 String 값이 반환된다
     */
    String encode(String password) throws NoSuchAlgorithmException;
}
throws NoSuchAlgorithmException는 예외 처리도 추상화해야 하기 때문에 넣은 것이니 신경쓰지 말자

물론 인터페이스는 단순히 추상화 된 객체일 뿐이다. 따라서 인터페이스를 상속받아 클래스를 구현해 줘야 한다. 위에서 얘기 했던 네 가지 암호화 로직을 인터페이스를 상속 받아 구체화 된 클래스로 변경해 보자.

public class Md5 implements PasswordEncoder {

    @Override
    public String encode(String password) throws NoSuchAlgorithmException {
        MessageDigest messageDigest = MessageDigest.getInstance("MD5");
        messageDigest.update(password.getBytes());
        byte[] bytes = messageDigest.digest();

        StringBuilder builder = new StringBuilder();
        for(byte b : bytes) {
            builder.append(String.format("%02x", b));
        }

        // 인코딩 된 문자열을 반환한다
        return builder.toString();
    }
}
Md5.java
public class Sha256 implements PasswordEncoder {

    @Override
    public String encode(String password) throws NoSuchAlgorithmException {
        MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
        messageDigest.update(password.getBytes());
        byte[] bytes = messageDigest.digest();

        StringBuilder builder = new StringBuilder();
        for(byte b : bytes) {
            builder.append(String.format("%02x", b));
        }

        // 인코딩 된 문자열을 반환한다
        return builder.toString();
    }
}
Sha256.java
public class Sha512WithSalt implements PasswordEncoder {

    @Override
    public String encode(String password) throws NoSuchAlgorithmException {
        String salt = "this is salt";

        MessageDigest messageDigest = MessageDigest.getInstance("SHA-512");
        messageDigest.update((password + salt).getBytes());
        byte[] bytes = messageDigest.digest();

        StringBuilder builder = new StringBuilder();
        for(byte b : bytes) {
            builder.append(String.format("%02x", b));
        }

        // 인코딩 된 문자열을 반환한다
        return builder.toString();
    }
}
Sha512WithSalt.java
public class BCrypt implements PasswordEncoder {
    
    @Override
    public String encode(String password) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        
        // 인코딩 된 문자열을 반환한다
        return encoder.encode(password);
    }
}
BCrypt.java

PasswordEncoder라는 인터페이스를 상속 받아 Md5, Sha256, Sha512WithSalt, BCrypt 네 개의 클래스 구현체를 생성했다. 얼핏 보기엔 인터페이스를 하나 더 구현해야 하기 때문에 일만 늘어난 것처럼 보인다. 그러나 인터페이스의 진가는 이 다음음 부터다.


인터페이스로 코드의 결합도를 느슨하게 만들기

맨 처음에 나왔던 이 코드를 기억하는가?

@Test
public void encryptMD5() throws NoSuchAlgorithmException {

    // 외부에서 입력받은 패스워드
    String password = "this is password";

    // MD5 알고리즘으로 문자열을 인코딩 한다
    MessageDigest messageDigest = MessageDigest.getInstance("MD5");
    messageDigest.update(password.getBytes());
    byte[] bytes = messageDigest.digest();

    // 인코딩 한 데이터를 다시 문자열로 변경한다
    StringBuilder builder = new StringBuilder();
    for(byte b : bytes) {
        builder.append(String.format("%02x", b));
    }
    
    String encryptedPassword = builder.toString();
    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: 8910e62fae2505e21f568632df8410a9

이 코드를 이제부터 인터페이스를 활용한 코드로 바꾸어 보겠다. 변경된 코드는 다음과 같다.

@Test
@DisplayName("MD5 Interface 암호화")
void encryptMD5Interface() throws NoSuchAlgorithmException {
    PasswordEncoder encoder = new Md5();

    String password = "this is password";
    String encryptedPassword = encoder.encode(password);

    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: 8910e62fae2505e21f568632df8410a9

그런데 이 코드를 보면서 "이 코드, 결국엔 암호화 하는 로직을 클래스로 만들어서 코드 바깥으로 뺀 것 뿐 아닌가?" 라는 의문을 가지는 사람도 있을 것이다. 그렇다면 여기서 고객이 요구했던 두 번째 요청을 떠올려 보자. 고객이 요구했던 두 번째 요청은 "MD5 알고리즘의 암호화 수준이 약하니, SHA-256 알고리즘으로 교체해 주세요" 였다. 그렇다면 이 코드에서 SHA-256 알고리즘으로 교체해 보자. 코드는 다음과 같이 변경된다.

@Test
@DisplayName("SHA256 Interface 암호화")
void encryptSHA256Interface() throws NoSuchAlgorithmException {
    PasswordEncoder encoder = new Sha256();

    String password = "this is password";
    String encryptedPassword = encoder.encode(password);

    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: 15a1b278f7e9cd1525e75fd9182d7bc0c6c00e4fa9e678c2184655491f98f1a2

이번에는 각각 SHA-512에 SALT를 추가한 알고리즘, BCrypt 알고리즘으로 교체해 보자.

@Test
@DisplayName("SHA512 + Salt Interface 암호화")
void encryptSHA512WithSaltInterface() throws NoSuchAlgorithmException {
    PasswordEncoder encoder = new Sha512WithSalt();

    String password = "this is password";
    String encryptedPassword = encoder.encode(password);

    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: 023c2e7bf017ac33995be2cad33f99f4c71985ce5af9b9214a218f7fb816153978a86f9b657a0cb07e1f2c08124a3964e1123810986a32032b417a31957ce0ed
@Test
@DisplayName("BCrypt Interface 암호화")
void encryptBCryptInterface() throws NoSuchAlgorithmException {
    PasswordEncoder encoder = new BCrypt();

    String password = "this is password";
    String encryptedPassword = encoder.encode(password);

    logger.info(String.format("암호화 된 값: %s", encryptedPassword));
}
암호화 된 값: $2a$10$WrFPcZWzZn1YK5B/N.07l.ZcPMlZe7NPfNLHDt7KgD.FxuAyLmT82
참고로 BCrypt 알고리즘은 매번 결과 값이 바뀐다

놀랍지 않은가? 이것이 인터페이스의 진가다. 기존에 암호화 로직에서 알고리즘을 바꾸기 위해 많고 많은 코드를 뜯어 고쳐야 했던 것에서, 인터페이스 하나 구현하고 해당 인터페이스를 상속 받아 만든 클래스 구현체 몇 개를 만들었더니 코드에서 암호화 알고리즘을 자유자재로 바꿀 수 있게 되었다. 이제부터는 고객의 추가적인 암호화 알고리즘 변경 요청이 들어오더라도 PasswordEncoder를 상속 받은 새로운 암호화 클래스 구현체를 만들기만 하면 간단하게 갈아 끼워 버릴 수 있을 것이다!