Java

[Java]함수형 프로그래밍: 익명 클래스보다는 람다를, 람다보다는 메소드 참조를 사용해라?(Feat. Open source contribute

Woonys 2023. 7. 28. 19:17
반응형

TL;DR

  • 회사에서 쓰는 오픈소스 공부하다 리팩토링 욕구가 샘솟았다.
  • 리팩토링해서 PR 올렸는데 머지됐다.
  • 오늘부터 오픈소스 컨트리뷰터 ㅋ

Background

2주 전을 기점으로 Payment 파트에서 Loan 파트로 팀이 옮겨졌다. 지난 6개월 동안은 유저에게 비즈니스 가치를 전달하는 피쳐 위주 개발을 경험했다. 잘 이끌어주신 덕분에 좋은 성과 역시 낼 수 있었다. 그래서 이번에는 비즈니스의 코어 쪽으로 더 파고들 수 있는 Loan platform 팀으로 가보는 게 어떻겠냐는 제안을 받았다. 나 역시 예전부터 코어 쪽을 들여다보고 싶은 마음이 컸던 지라 기쁜 마음으로 흔쾌히 수락했다.
 
현재 우리 회사의 전체 아키텍쳐를 개략적으로 살펴보면 유저들이 사용하는 앱을 관장하는 Balance-API 서버와 그 뒷단에서 코어 뱅킹(특히 대출을 메인으로 하는) 로직을 관장하는 Loan-platform 서버가 존재한다. 이전에 있었던 결제 팀에서는 Balance-API 서버를 주로 유지보수 및 개발했다면, 지금 Loan platform 팀에서는 말 그대로 모든 대출을 관장하고 있는 Loan platform을 유지보수하는 일이 메인이 되겠다.
 
문제는 이 Loan platform으로 오픈소스인 Apache Fineract를 사용하고 있다는 것인데, 내가 이놈에 대해 아는 게 하나도 없다는 것.. 그래서 인사변경에 대한 제안을 듣자마자 집에 가서 코드베이스를 까보기 시작했다.

SQLInjectionValidator: 중복 코드를 어떻게 리팩토링하지?

리포지토리를 요리조리 까보다가 흥미로운 코드를 발견했다. input으로 들어오는 값에 대해 SQLInjection 공격을 검사하는 validator 클래스이다. 보다시피 중복 코드가 꽤나 많았다. 본능적으로 리팩토링에 대한 욕구가 치솟았다.

public final class SQLInjectionValidator {

    private SQLInjectionValidator() {

    }

    private static final String[] DDL_COMMANDS = { "create", "drop", "alter", "truncate", "comment", "sleep" };

    private static final String[] DML_COMMANDS = { "select", "insert", "update", "delete", "merge", "upsert", "call" };

    private static final String[] COMMENTS = { "--", "({", "/*", "#" };

    private static final String SQL_PATTERN = "[a-zA-Z_=,\\-:'!><.?\"`% ()0-9*\n\r]*";

    public static void validateSQLInput(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase();
        for (String ddl : DDL_COMMANDS) {
            if (lowerCaseSQL.contains(ddl)) {
                throw new SQLInjectionException();
            }
        }

        for (String dml : DML_COMMANDS) {
            if (lowerCaseSQL.contains(dml)) {
                throw new SQLInjectionException();
            }
        }

        for (String comments : COMMENTS) {
            if (lowerCaseSQL.contains(comments)) {
                throw new SQLInjectionException();
            }
        }

        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
    }
    public static void validateAdhocQuery(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase().trim();
        for (String ddl : DDL_COMMANDS) {
            if (lowerCaseSQL.startsWith(ddl)) {
                throw new SQLInjectionException();
            }
        }

        for (String comments : COMMENTS) {
            if (lowerCaseSQL.contains(comments)) {
                throw new SQLInjectionException();
            }
        }

        // Removing the space before and after '=' operator
        // String s = " \" OR 1 = 1"; For the cases like this
        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
    }
    public static void validateDynamicQuery(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }

        String lowerCaseSQL = sqlSearch.toLowerCase();
        for (String ddl : DDL_COMMANDS) {
            if (ddl.equals(lowerCaseSQL)) {
                throw new SQLInjectionException();
            }
        }

        for (String dml : DML_COMMANDS) {
            if (dml.equals(lowerCaseSQL)) {
                throw new SQLInjectionException();
            }
        }

        for (String comment : COMMENTS) {
            if (comment.equals(lowerCaseSQL)) {
                throw new SQLInjectionException();
            }
        }

        // Removing the space before and after '=' operator
        // String s = " \" OR 1 = 1"; For the cases like this
        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
    }

코드를 보면 반복되는 로직이 보인다.

  • String array에 대해 For문을 돌아 String을 하나씩 꺼내서
  • 만약 특정 조건을 만족하면(if)
  • 예외 처리를 던진다(throw new SQLInjectionException();)

이를 재사용할 수 있게 특정 메소드로 모듈화를 할텐데, 이 과정에서 함수형 프로그래밍을 적용해보자. 그전에 함수형 프로그래밍에 대해 간략히 살펴본다.

함수형 프로그래밍: 그게 왜 좋은데?

요즘에는 워낙 람다식, 스트림, 메소드 참조 등을 너무 자연스럽게 쓰다보니 굳이 설명하지 않아도 될 것 같지만 간략하게 정리해보자. 자바는 객체 지향 언어인데, 왜 굳이 함수형 프로그래밍을 쓰려고 할까? 지난 번에 들었던 클린코드, TDD with JAVA에서 배운 내용을 참조하면 다음과 같다.
 
동시성 이슈
2023년 지금을 기준으로 어떤 클라이언트 기기에서도 멀티 CPU가 너무 당연하게 탑재된 시대다. 애초에 자바스크립트처럼 싱글 스레드를 사용하는 게 아닌 이상, 멀티스레드 프로그래밍에서 동시성 이슈는 늘 달고 사는 고질적인 문제다. 문제는 데이터의 상태를 변경하는 식으로 프로그래밍하는 객체 지향 프로그래밍 방식으로는 동시성 문제를 해결하는데 한계가 있다는 점이다.
 
데이터 관리에 따른 부담
대용량 데이터를 다루는 작업은 갈수록 점점 더 많아지고 있다. 이때, 데이터를 매번 객체로 변환하는데 따르는 부담은 클 수밖에 없다. 따라서 대용량 데이터를 처리할 수 있는 효율적인 데이터 구조 및 연산이 필요하다.
 
더 작은 단위의 모듈에 대한 니즈
객체 단위의 모듈화는 가장 작은 단위의 모듈화가 아니다. 객체 역시 그 안에 상태(필드)와 행위(메소드)를 들고 있지 않나. 반면 함수형 프로그래밍 관점에서의 함수는 객체와 비교했을 때 훨씬 더 작은 모듈로 간주할 수 있다. 그만큼 재사용의 범위가 커지는 것. 함수형 프로그래밍은 더 유용하고, 재사용이 편리하며, 구성이 용이하고 테스트하기 더 간편한 추상화를 제공한다.
 
정리하면, 함수형 프로그래밍을 이용하면 문제에 접근하는 방법, 문제를 작은 단위로 쪼개는 방법, 설계하는 과정, 프로그래밍하는 순서에서 새로운 시각을 배울 수 있다. 즉, 함수형 프로그래밍 방식을 학습하면 현재 프로그래밍 스타일을 개선해 더 깔끔한 코드를 구현할 수 있으며, 이를 통해 복잡한 문제를 작은 단위로 분리해 해결하는 능력을 기를 수 있게 된다.

함수형 프로그래밍으로 리팩토링: 그래서 어떻게 하는 건데?

함수형 프로그래밍에 대한 이해도 없이 위 클래스를 리팩토링한다고 해보자. 보다시피 for → if → throw Exception과 같은 구조로 되어 있으니, 이러한 형태를 지닌 메소드를 새로 생성해 분리하고 이걸 계속 재사용하면 될 것 같다.

 
어라, 근데 문제가 생겼다. 다시 내부 로직을 찬찬히 살펴보자.

// 1. equals
for (String ddl : DDL_COMMANDS) {
    if (ddl.equals(lowerCaseSQL)) {
        throw new SQLInjectionException();
    }
}
// 2. contains
for (String comments : COMMENTS) {
    if (lowerCaseSQL.contains(comments)) {
        throw new SQLInjectionException();
    }
}

// 3. startsWith
for (String ddl : DDL_COMMANDS) {
    if (lowerCaseSQL.startsWith(ddl)) {
        throw new SQLInjectionException();
    }
}

for → if → throw exception이라는 큰 틀은 같은데, if에서 호출하는 메소드가 저마다 다르다. 다른 애들이야 인자로 받으면 그만인데, 함수를 인자로 받는다고..? 객체 지향 프로그래밍에서는 생각할 수 없는 관점이다.

 
그런데 함수형 프로그래밍에서는 이것이 가능해진다. 왜냐? 함수형 프로그래밍에서는 가장 작은 단위의 모듈이 함수이기 때문. 함수 역시 인자로 받는 것이 가능해진다. 이쯤에서 함수형 프로그래밍의 특징 역시 간략하게 정리하고 넘어가자.(역시 클린코드, TDD With JAVA 강의에서 들은 내용을 정리해본다.)


변경 불가능한 값(Immutable value)을 활용한다.
값이 변경되는 것을 허용하면 멀티스레드 프로그래밍이 힘들어진다.
 
1등 시민으로서의 함수
함수형 프로그래밍에서는 함수가 1등 시민 역할을 수행한다. 함수를 1등 시민으로 활용할 경우, 함수를 함수의 인자로 받거나 함수의 반환 값으로 활용하는 것이 가능하다. (이것 덕분에 우리는 위의 코드를 아주 간결하게 리팩토링할 수 있게 된다.)
 
고계함수(Higher-order functions)
고계함수란 다른 함수를 인수로 받아들이거나 혹은 함수를 리턴하는 함수를 말한다. 자바 메소드는 기본적으로 인수나 반환값으로 원시 타입과 객체만 사용할 수 있다. 하지만 함수형 프로그래밍에서는 함수가 1급 시민이 될 수 있기 때문에 고계함수를 구현할 수 있게 된다.
 
Side effect가 없는 함수
함수는 불변인 특성을 갖는다. 기본적으로 x → y 인 구조이니까. 따라서 함수를 사용하는 입장에서 특별한 부수효과가 발생하지 않는다. 이 덕분에 멀티 스레드 환경에서 안정적인 프로그래밍을 구현할 수 있다. 이 side effect를 만들지 않는다는 특징이 함수형 프로그래밍의 가장 큰 특징이라고 볼 수 있겠다.


얘기가 길었다. 그래서 어떻게 바꿀 수 있을까? 함수형 프로그래밍을 이용하더라도 총 3가지 방식이 존재한다. 가장 옛날 방식부터 하나씩 해보자. 모든 메소드를 다 하긴 너무 기니 validateSQLInput()validateAdhocQuery() 두 메소드만 리팩토링한다. 어차피 똑같은 방식인지라 이것만 봐도 충분할듯.

1. 익명 클래스로 구현

지금은 잘 쓰지 않지만, 예전에는 함수 객체를 만드는 주요 수단으로 익명 클래스를 활용했다. 말 그대로 이름이 없는 클래스를 구현하는 것인데, 긴말 할 것 없이 예시를 보도록 하자.
익명 클래스 방식으로 리팩토링을 수행한다.
1. 위 코드에서 변경되는 부분과 변경되지 않는 부분의 코드를 분리한다.

  • 변경되지 않는 부분: for → if → throw exception 구조
  • 변경되는 부분: if 내 조건

2. 변경되는 부분(if 조건)을 인터페이스로 추출한다.

//SQLCommandCondition.interface

public interface SQLCommandCondition {

    boolean checkCondition(String command, String sql);
}

3. 변경되지 않는 부분을 메소드 분리한다.

//SQLInjectionValidator.class

...

private static void validateSQLCommand(String lowerCaseSQL, String[] commands, SQLCommandCondition condition) {
    for (String command : commands) {
        if (condition.checkCondition(command, lowerCaseSQL)) {
            throw new SQLInjectionException();
        }
    }
}

private static void validateSQLCommands(String lowerCaseSQL, List<String[]> commandsList, SQLCommandCondition condition) {
    for (String[] commands : commandsList) {
        validateSQLCommand(lowerCaseSQL, commands, condition);
    }
}

4. 인터페이스에 대한 구현체를 익명 클래스로 구현한다. 아래 코드를 보면 validateSQLCommands, validateSQLCommand 각각 끝 인자에 new SQLCommandCondition(){}으로 인터페이스 구현체가 익명 클래스로 구현된 것을 확인할 수 있다.

//SQLInjectionValidator.class
public static void validateSQLInput(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase();
        List<String[]> commandsList = List.of(DDL_COMMANDS, DML_COMMANDS, COMMENTS);
        validateSQLCommands(lowerCaseSQL, commandsList, new SQLCommandCondition() {
            @Override
            public boolean checkCondition(String command, String sql) {
                return sql.contains(command);
            }
        });

        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
}

public static void validateAdhocQuery(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase().trim();
        validateSQLCommand(lowerCaseSQL, DDL_COMMANDS, new SQLCommandCondition() {
            @Override
            public void checkCondition(String command, String sql) {
                return sql.startsWith(command);
            }
});
        validateSQLCommand(lowerCaseSQL, COMMENTS, new SQLCommandCondition() {
            @Override
            public void checkCondition(String command, String sql) {
                return sql.contains(command);
            }
});

        // Removing the space before and after '=' operator
        // String s = " \\" OR 1 = 1"; For the cases like this
        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
    }

 
이렇게 함수형 프로그래밍의 특징은 인자로 함수 자체를 넣을 수도 있다는 것. 하지만 보다시피 익명 클래스 방식은 보다시피 코드가 너무 길기 때문에 리팩토링에 적합하지 않다. 이럴 거면 이전 코드가 더 나은 것 같기도 하고..그래서 이펙티브 자바에서는 이렇게 말한다.

익명 클래스보다는 람다를 사용해라!

오키..얼마나 간결해지는지 실제로 해보도록 하자.

2. 람다(lambda expression, lambda)로 구현

람다식(lambda expression, 혹은 람다)은 함수나 익명 클래스와 개념이 비슷한데 코드는 훨씬 간결하다. 리팩토링 코드를 먼저 살펴본 뒤, 왜 더 간결할 수 있는지 알아보도록 하자. 위의 1, 2, 3번 과정은 동일하므로 4번만 변경한다.

//SQLInjectionValidator.class
public static void validateSQLInput(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase();
        List<String[]> commandsList = List.of(DDL_COMMANDS, DML_COMMANDS, COMMENTS);
        validateSQLCommands(lowerCaseSQL, commandsList, (command, sql) -> sql.contains(command));
        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
}

public static void validateAdhocQuery(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase().trim();
        validateSQLCommand(lowerCaseSQL, DDL_COMMANDS, (command, sql) -> sql.startsWith(command));
        validateSQLCommand(lowerCaseSQL, COMMENTS, (command, sql) -> sql.contains(command));

        // Removing the space before and after '=' operator
        // String s = " \" OR 1 = 1"; For the cases like this
        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
}

와..말도 안되게 코드량이 줄었다. new 연산자 및 @Override 부분도 사라지고 심지어 타입 역시 사라졌다. 이렇게 할 수 있는 이유는 자바 컴파일러가 람다에서 문맥을 살펴 타입을 추론해주는 덕분이다. 그러니 람다의 모든 매개변수 타입은 타입을 꼭 명시해야 할 경우를 제외하고는 생략하도록 하자.

 
람다가 익명 클래스보다 무조건 더 나은 것 같지만 꼭 그렇지만은 않다. 주의할 점이 있는데

  1. 람다는 이름이 없는 함수이므로 문서화 맥락에서 좋지 않다. 그러니 코드 자체로 동작이 명확히 설명되지 않거나 오히려 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.
  2. 람다는 함수형 인터페이스의 구현체로만 사용이 가능하다. 추상 클래스의 인스턴스를 만들 때는 람다를 사용할 수 없고 익명 클래스로만 구현이 가능하다.
  3. 람다에서 this 키워드는 익명 클래스의 인스턴스 자신을 가리킨다. 그래서 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 써야 한다.

정리하면 람다가 익명 클래스보다 나은 점은 간결함이다. 이 장점을 취할 수 있는 경우 람다를 쓰는 것인데, 문제는 자바에서 제공하는 기능 중에 함수 객체를 람다보다도 더 간결하게 만드는 방법이 있으니, 그것이 바로 세 번째로 소개할 메소드 참조다.

3. 메소드 참조로 구현

역시 긴말 할 것 없이 구현부터 가보자. 보면 깜짝 놀랄지도 모른다.

//SQLInjectionValidator.class
public static void validateSQLInput(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase();
        List<String[]> commandsList = List.of(DDL_COMMANDS, DML_COMMANDS, COMMENTS);
        validateSQLCommands(lowerCaseSQL, commandsList, String::contains);
        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
}

public static void validateAdhocQuery(final String sqlSearch) {
        if (StringUtils.isBlank(sqlSearch)) {
            return;
        }
        String lowerCaseSQL = sqlSearch.toLowerCase().trim();
        validateSQLCommand(lowerCaseSQL, DDL_COMMANDS, String::startsWith);
        validateSQLCommand(lowerCaseSQL, COMMENTS, String::contains);

        // Removing the space before and after '=' operator
        // String s = " \" OR 1 = 1"; For the cases like this
        patternMatchSqlInjection(sqlSearch, lowerCaseSQL);
    }

오…이게 뭐지? 메소드 참조는 람다에서 필요했던 매개변수 역시도 제거하게 해준다. 물론, 때로는 매개변수의 이름 자체가 읽는 사람에게 이해를 도울 수 있으니 때로는 오히려 람다가 나을 수 있다. 하지만 일반적으로는, 특히 매개변수의 개수가 많을수록 람다보다 메소드 참조가 코드를 훨씬 간결하게 만들어준다. 그리고 람다로 할 수 없다면 메소드 참조도 쓸 수 없다. 메소드 참조를 사용하는 편이 더 짧고 간결하니 람다로 구현했을 때 너무 길거나 복잡하다면 메소드 참조가 좋은 대안이 될 것이다.
 
메소드 참조는 총 다섯 가지의 유형이 있는데,
 
1. static method reference(정적)
<클래스::정적 메서드> 형태로 참조한다
2. instance method reference (bound receiver) (한정적 인스턴스)
<인스턴스::인스턴스 메서드> 형태로 참조한다
3. instance method reference (unbound receiver)(비한정적 인스턴스)
<클래스::인스턴스 메서드> 메서드 형태로 참조한다
4. class constructor reference (클래스 생성자)
<클래스::new> 연산자 형태로 참조한다
5. array constructor reference (배열 생성자)
<배열::new> 연산자 형태로 참조한다
 

위의 리팩토링에서 본 유형은 비한정적 인스턴스 메소드 참조에 해당한다. 한정적/비한정적을 나누는 기준은 메소드 참조가 특정 인스턴스에 묶여있냐 아니냐이다.

 
한정적이라는 것은 특정 인스턴스가 이미 존재하고 있어야 한다는 뜻이다. 즉, 어떤 클래스로부터 생성되어 이미 존재하는 인스턴스의 메소드를 참조하는 것이다. 메서드 참조를 생성할 때 알려진 기존 객체의 인스턴스의 메소드에 묶여(bound)있다고 보면 되겠다.
 

반대로 비한정적이라는 것은 메서드 참조를 생성할 당시에는 알 수 없는 특정 유형의 임의 객체의 인스턴스 메서드에 대한 참조이다. 메서드 참조는 해당 클래스에서 만들어진 여러 인스턴스 중 특정 인스턴스를 참조하지 않으며, 메서드는 호출 시점에 제공된 인스턴스에서 호출된다. 수신자(메서드가 호출되는 객체)는 바인딩되지 않는다. 그렇기 때문에 특정 인스턴스 이름:메소드명 이 아니라 클래스명:메소드명 이 되는 것.

 
무튼 정리하면 메소드 참조는 람다의 간단명료한 대안이 될 수 있다. 메소드 참조 쪽이 짧고 명확하다면 메소드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하자.

Conclusion: 오픈소스 컨트리뷰터 등극!

위의 내용을 바탕으로 Apache Fineract 리포지토리에 PR을 올렸다. 아파치 재단에 기여하려면 생각보다 여러 과정을 거쳐야 해서(메일링 등록, Jira account 발급을 포함해 코드 리뷰와 테스트 통과 등등) 은근 시간을 많이 잡아먹었지만 결과는…
 

Merge!!!

이렇게 오픈소스 컨트리뷰터가 됐다는 감동적인 이야기..

깨알같은 업데이트

 

반응형