[ JAVA ] 함수형 인터페이스의 변형

Wooju Kang ·2025년 5월 22일
post-thumbnail

GIF 출처 : https://www.amigoscode.com/courses/java

🖥 Contents


1 ) 함수형 인터페이스 박싱

2 ) 함수 합성

3 ) 정적 헬퍼

4 ) 함수형 예외 처리 (수정중)




1 ) 함수형 인터페이스 박싱


제네릭 타입의 경우 원시 타입 ( Primative type ) 을 사용할 수 없기 때문에 객체 래퍼 타입 ( Wrapper type ) 를 통해 박싱하여 제네릭 타입으로 사용한다.

그러나 자동으로 타입을 래퍼 타입으로 변경해주는 오토 박싱 ( Auto Boxing ) 의 경우 성능에 영향을 주기 때문에 주의해야한다. 그렇기 때문에 JDK에서 제공하는 함수형 인터페이스들은 오토 박싱을 피하기 위해 원시 타입을 사용한다.

함수형 인터페이스 뿐만 아니라 Stream , Optional 또한 원시 타입을 처리하기 위한 특수화된 타입을 제공한다.




2 ) 함수 합성


  • 함수 합성 ( Functional Comparison ) 란?

    : 함수 합성이란 작은 함수들을 결합하여 더 크고 복잡한 작업을 처리하는 함수형 프로그래밍의 접근 방식을 의미한다.

    자바는 이전 버전과의 하위 호환성을 보장하기 위해 새로운 키워드를 도입하거나 자바의 글루 메서드 ( Glue Method ) 를 사용한다.

    자바에서 제공하는 글루 메소드는 2가지가 존재한다.

Compose 메소드

: compose 메소드의 경우 before 인수를 입력받고 결과를 해당 함수에 적용하여 합성함수를 만든다.


< Compose 기본 형식 >

<V> Function<V,R> compose (Function<? super V,? extends T> before )

: compose 메소드의 실행 순서는 다음과 같다. 먼저 compose 내부에 있는 Function 인터페이스를 실행한 뒤 , 외부에서 compose를 호출한 함수를 실행시킨다.


ex ) 문자열에서 특정 문자를 삭제하고 대문자로 변경하는 예시

Function<String, String> removerLowerCaseA = str -> str.replace("a", "");
Function<String, String> upperCase = String::toUpperCase;

var input = "abcd";

String result = upperCase.compose(removerLowerCaseA)
                         .apply(input);

System.out.println(result);


andThen 메소드

: andThen메소드의 경우 compose와 반대로 호출한 함수를 실행한 뒤 , after인수로 받은 함수를 실행한다.


< andThen 기본 형식 >

<V> Function<T,V> andThen (Function<? super R, ? extends V> after 

ex ) 문자열에서 특정 문자를 삭제하고 대문자로 변경하는 예시

Function<String, String> removerLowerCaseA = str -> str.replace("a", "");
Function<String, String> upperCase = String::toUpperCase;

var input = "abcd";

String result = removerLowerCaseA.andThen(upperCase)
                                 .apply(input);

System.out.println(result);


  • 함수 합성 메서드 정리
메소드 시그니처타입 체인
Function<V,R> compose(Function<V,T> before)V -> T -> R
Function<T,V> andThen(Function<R,V> after)T -> R -> V



3 ) 정적 헬퍼


  • 정적 헬퍼 ( Static Helper ) 란?

    : 정적 헬퍼란 객체를 생성하지 않고 클래스의 정적 메소드를 통해 기능을 제공하는 메소드를 의미한다. 클래스 내에 여러개의 유틸리티 메소드를 모으는 헬퍼 타입을 만들 수 있다.

    ex ) 정적 헬퍼 메소드 예시

public class StringHelper {
    public static boolean isEmpty(String str) {
        return str == null || str.trim().isEmpty();
    }
}

public class Main{
   public static void main(String args[]){
   
     boolean result = StringHelper.isEmpty(" ");
   
    }
}  

  • 정적 헬퍼메소드의 특징

    • 장점

    ① 간편한 사용
    : 객체를 생성할 필요가 없어 코드가 간결해진다.

    ②재사용성 높음
    : 여러 곳에서 공통적으로 쓰이는 기능(예: 날짜 변환, 문자열 처리, 수학 계산 등)을 모아두기 좋음.

    ③상태 없음(Stateless)
    : 보통 내부에 상태를 두지 않기 때문에 멀티스레드 환경에서도 안전(Thread-Safe)하다.

    • 단점

    ① 객체지향 원칙 위배 가능
    : 정적 메소드는 다형성을 지원하지 않기 때문에 OOP(객체지향 프로그래밍) 의 장점을 잃을 수 있다. 때문에 의존성 주입이 불가하여 테스트 및 확장에 어려움이 있다.

    ② 확장성이 낮음
    : 상속, 오버라이딩 불가하기 때문에 변경이 필요한 경우 유연성이 떨어진다.




4 ) 함수형 예외 처리


스트림 파이프라인 예외처리 전략

: 함수형 인터페이스 중에서는 체크 예외를 발생시키지 않는 요소도 존재하기 때문에 체크 예외를 발생시키는 메서드와 호환되지 않는다. 예시로 map 연산은 체크예외를 발생시키는 메소드를 사용할 경우 컴파일 에러가 발생한다.

public static String readString(Path path) throws IOException {
  // 메소드 내용 
}


Stream.of(path1,path2,path3)
      .map(Files::readString)
      .forEach(System.out::println);
      // 컴파일 에러 발생 : 함수형 표현식에서는 java.io.IOException이 호환되지 않습니다.

이를 해결하기 위해서 람다를 블록으로 변환한 뒤 , try-catch 블록을 사용하면 된다.

Stream.of(path1,path2,path3)
      .map(path->{ // try-catch블록으로 예외 감싸기 
             try{
                 return Files.readString(path);             
             } catch (IOException e) {                
                 return null;             
             }
            })
      .forEach(System.out::println);

그러나 해당 방식은 예외 처리를 위한 보일러플레이트가 추가되면서 연산의 간결함과 직관성을 약화시킨다. 또한 람다에서 예외를 다루는 것은 마치 안티패턴 ( Anti Pattern ) 처럼 느껴질 수 있다.

따라서 파이프라인이 제공하는 간결함과 명확함을 잃지 않으면서도 예외를 처리할 수 있는 방법 3가지를 도입해볼 수 있다.

(1) 안전한 메소드 추출

: 로컬 예외 처리로직을 구성하여 클래스 내부에서 map을 통해 예외처리를 하도록 하는 방식이다.

String safeReadString(Path path){ //로컬 예외처리 메소드 생성 
  try {
      return Files.readString(path);  
  } catch ( IOException e ) {  
     return null;
  }
}


Stream.of(path1,path2,path3)
      .map(this::safeReadString) // map을 통해 예외 체크하기 
      .filter(Objects::nonNull)
      .forEach(System.out::println);

해당 방식은 façade 패턴을 지역 메소드에 도입한 방식과 유사하다. 예외 처리르 개선하기 위해 새로운 파사드를 얻어 사용함으로써 코드의 복잡성을 줄인다. 이는 인라인 람다와 메서드 참조의 명확성을 유지하면서도 , 모든 예외를 처리할 기회를 제공한다.


(2) 함수형 인터페이스 예외처리

: 언체크 예외 (RuntimeException 계열)는 컴파일러가 try-catch를 강제하지 않는다. 주로 예상하기 어렵거나 복구하기 힘든 경우에 발생한다.이때 함수형 인터페이스에서는 체크 예외를 직접 던질 수 없으므로, 보통 체크 예외를 언체크 예외로 변환(래핑) 하여 처리한다.

함수형 인터페이스에서는 이를 언체크 예외로 래핑하여 처리할 수 있다.

@FunctionalInterface
public interface ThrowingFunction<T,R> extends Function<T,R> {
    
    R applyThrows(T t) throws Exception;
    
    @Override
    default R apply(T t){
        try{
            return applyThrows(t);
        }catch (Exception e){
            throw new RuntimeException(e);
        }
    }
    
    public static <T,R> Function<T,R> uncheck(ThrowingFunction<T,R> fn){
        return fn::apply;
    }
    
}

다음과 같이 applyThrows 메소드는 Exception 이라는 최상위 체크예외를 던지고 있다. 이를 래핑하여 apply라는 메소드 내부에서 try-catchRuntimeException을 던지도록 하였다.

이 방식은 ThrowingFunction 에서 체크 예외를 던질 수 있게 한 뒤, wrap 메서드에서 RuntimeException으로 변환하기 때문에 컴파일 오류 없이 표준 Function 인터페이스 처럼 사용할 수 있다.

ThrowingFunction<Path,String> throwingFn = Files::readString;

Stream.of(path1,path2,path3)
      .map(ThrowingFunction.uncheck(Files::readString)) 
      .filter(Objects::nonNull)
      .forEach(System.out::println);

만약 Files.readString(path)에서 예외가 터지면 IOExceptionRuntimeException으로 래핑돼서 밖으로 던져진다.

(3) 몰래 던지기 ( Sneaky Throw )

: sneakyThrowthrows 키워드를 명시적으로 선언하지 않아도 체크 예외를 발생시킬 수 있는 방법이다. 이는 java8 이후부터 제네릭의 추론 강화로 인해 생긴 방식이다. E타입 파라미터로 구체적을 정해지지 않은 경우 컴파일러는 throws를 체크하지 않는다.

인터페이스의 제네릭 타입 EThrowable로 잡아두고, 실제로는 Checked Exception을 던지지만 컴파일러가 그 타입을 체크하지 못하게 한다.

public class SneakyThrow {
    @SuppressWarnings("unchecked")
    public static <T extends Throwable> void sneakyThrow(Throwable t) throws T {
        throw (T) t;  // 컴파일러는 이걸 체크하지 못함
    }
}
 
// Main함수 
Supplier<String> supplier = () -> {
            try {
                throw new java.io.IOException("Checked exception!"); // 컴파일러 오류 생성 -> catch문으로 이동 
            } catch (java.io.IOException e) {
                SneakyThrow.sneakyThrow(e); // sneakyThrow로 체크 예외 던지기 
            }
            return "Hello";
        };
    
   

sneakyThrow의 경우 해당 메소드가 어떤 예외를 던지는지 외부에서 알기 어렵다는 단점이 있다.

또한 해당 방식은 체크 예외를 처리하도록 강제하지 못하기 때문에 체크 예외가 남아있지만 이를 처리하지 못하고 그대로 남아있는 상황이 발생할 수 있다. 따라서 일반 함수사용에서는 이를 지양할 필요가 있다.


profile
배겐드 📡

0개의 댓글