[Java] 함수형 인터페이스

chanbyeong·2025년 4월 17일

Spring

목록 보기
2/2

함수형 인터페이스란?

자바는 8 버전부터 함수형 프로그래밍 개념을 입하며, 람다 표현식과 함께 사용할 수 있는 함수형 인터페이스를 지원하게 되었다.
함수형 인터페이스를 한 문장으로 정의하면
"추상 메서드가 오직 하나만 선언된 인터페이스
즉, 특정 '행위'를 추상화한 하나의 동작을 표현하기 위해 만들어진 인터페이스이다.

@FunctionalInterface
public interface MyFunction {
    void execute();
}

@FunctionalInterface 어노테이션 자체는 선택사항이지만, 해당 인터페이스가 함수형 조건을 만족하지 않으면 컴파일 에러를 발생시켜줘서, 개발자의 실수를 방지해준다.

왜 필요할까?

예를 들어 기존에는 이런식으로 코드를 작성했다.

Runnable run = new Runnable() {
    @Override
    public void run() {
        System.out.println("기존 방식!");
    }
};

하지만 함수형 인터페이스를 람다와 함께 쓰면 불필요한 익명 클래스 선언을 없애고, 의도에 집중한 간결한 코드 작성이 가능해진다.

Runnable run = () -> System.out.println("람다로 더 간결하게!");

자바에서 제공하는 주요 함수형 인터페이스

자바는 java.util.function 패키지를 통해 여러 종류의 범용 함수형 인터페이스를 제공해준다.

1. Supplier<T> - "값을 공급한다"

  • 입력값 없음
  • 반환값 있음
Supplier<String> stringSupplier = () -> "Hello, world!";
System.out.println(stringSupplier.get()); // Hello, world!

------

Supplier<Double> random = () -> Math.random();
System.out.println(random.get()); // 0.584... (매번 달라짐)

언제 쓰일까?

  • 랜덤값 생성
  • 객체 자연 초기화
  • 테스트 데이터 생성

2. Counsumer<T> - "값을 소비한다"

  • 입력값 있음
  • 반환값 없음(void)
Consumer<String> printer = s -> System.out.println("출력: " + s);
printer.accept("람다식"); // 출력: 람다식

------

List<String> list = List.of("Java", "Spring", "Lambda");
list.forEach(s -> System.out.println(s.toUpperCase()));

언제 쓰일까?

  • 로깅
  • 사용자 알림
  • 출력 처리

3. Function<T, R> - "T를 받아 R을 반환한다"

  • 입력값 있음
  • 반환값 있음
Function<String, Integer> getLength = s -> s.length();
System.out.println(getLength.apply("Hello")); // 5

------

List<String> list = List.of("Java", "Spring", "Lambda");
list.forEach(s -> System.out.println(s.toUpperCase()));

언제 쓰일까?

  • DTO 변환
  • 값 파싱
  • 데이터 전처리

4. Predicate<T> -> "조건 판단을 한다."

  • 입력값 있음
  • 반환값은 boolean
Predicate<String> isEmpty = s -> s.isEmpty();
System.out.println(isEmpty.test(""));    // true
System.out.println(isEmpty.test("Hi"));  // false
_______

List<String> names = List.of("Tom", "Jerry", "Spike");

names.stream()
     .filter(name -> name.startsWith("J"))
     .forEach(System.out::println);  // 출력: Jerry

언제 쓰일까?

  • 유효성 검사
  • 조건 필터링
  • if 대신 Stream 조건 처리

5. Runnable – “파라미터도 없고 리턴도 없는 실행 단위”

  • 스레드나 단순 실행 블록에 자주 사용
Runnable task = () -> System.out.println("작업 실행 중...");
new Thread(task).start();

언제 쓰일까?

  • 백그라운드 작업
  • 비동기 실행
  • 주기적인 작업 처리

그 외 함수형 인터페이스

  • BiFunction<T, U, R> : 두 입력값(T, U)를 받아 R 반환
  • UnaryOperator<T> : Function<T,T>와 같음 (입력 = 출력)
  • BinaryOperator<T> : BiFunction<T, T, T> 와 같음

실제로 사용해보자

@Service
public class FileService {

    // 업로드된 파일이 제공될 URL의 공통 경로 (리소스 핸들러와 매핑)
    private static final String FILE_PATH = "/uploads/profile-images/";
    // 허용 확장자
    private static final List<String> ALLOWED_FILE_EXTENSIONS = List.of("jpg", "jpeg", "png", "gif");

    // 파일 저장소의 실제 경로
    private final Path fileStorageLocation;

    public FileService(@Value("${file.dir}") String fileDir) {
        fileStorageLocation = Paths.get(fileDir)
                .toAbsolutePath()
                .normalize();
        try {
            Files.createDirectories(fileStorageLocation);
        } catch (Exception exception) {
            // 파일 저장 디렉토리를 생성할 수 없음
            throw new BusinessException(fileDir, "fileStorageLocation", ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }

    public String saveProfileImage(MultipartFile file) {
        String originalFileName = StringUtils.cleanPath(file.getOriginalFilename());

        try {
            // 파일 이름 검증
            if (originalFileName.contains("..")) {
                throw new BusinessException(originalFileName, "fileName", ErrorCode.MEMBER_FILE_BAD_REQUEST);
            }

            // 파일 확장자 검증
            String fileExtension = StringUtils.getFilenameExtension(originalFileName).toLowerCase();
            if (!ALLOWED_FILE_EXTENSIONS.contains(fileExtension)) {
                throw new BusinessException(fileExtension, "fileExtension", ErrorCode.MEMBER_FILE_BAD_REQUEST);
            }

            // 고유한 파일 이름 생성
            String newFileName = UUID.randomUUID() + "." + fileExtension;

            // 파일 저장 경로
            Path targetLocation = fileStorageLocation.resolve(newFileName);

            // 파일 저장 (기존 파일이 있으면 덮어쓰기)
            Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);

            return FILE_PATH + newFileName;

        } catch (IOException ex) {
            // 파일 저장 실패 시 예외 발생
            throw new BusinessException(originalFileName, "fileName", ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * 기존 프로필 이미지를 삭제합니다.
     *
     * @param fileName 삭제할 파일의 이름
     */
    public void deleteProfileImage(String fileName) {
        try {
            Path filePath = fileStorageLocation.resolve(fileName).normalize();
            Files.deleteIfExists(filePath);
        } catch (IOException ex) {
            // 파일 삭제 실패 시 예외 발생
            throw new BusinessException(fileName, "fileName", ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }

기존 코드의 문제점

기존의 FileService 클래스는 파일 관련 작업(저장, 삭제)마다 매번 try-catch문을 사용하여 IOException 을 처리하고 있다.

try {
    Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
    throw new BusinessException(originalFileName, "fileName", ErrorCode.INTERNAL_SERVER_ERROR);
}

try {
	Path filePath = fileStorageLocation.resolve(fileName).normalize();
	Files.deleteIfExists(filePath);
} catch (IOException ex) {
	// 파일 삭제 실패 시 예외 발생
	throw new BusinessException(fileName, "fileName", ㅋErrorCode.INTERNAL_SERVER_ERROR);
}

try-catch 블록이 계속 반복되고 있고, 예외 메시지만 바뀌고 하는일은 같아 함수형 인터페이스를 선언하여 적용해 보면 좋을 거 같다는 생각이 들었고,

if (originalFileName.contains("..")) {
    throw new BusinessException(originalFileName, "fileName", ErrorCode.MEMBER_FILE_BAD_REQUEST);
}

if (!fileExtension.matches("jpg|jpeg|png|gif")) {
    throw new BusinessException(fileExtension, "fileExtension", ErrorCode.MEMBER_FILE_BAD_REQUEST);
}

유효성 체크 같은 경우도 마찬가지였다. 파일 이름이나 확장자 같은 건 결국 true/false로 조건 판단하는 거라 이 부분도 Predicate를 이용해서 검증 로직을 공통화하면 더 깔끔하고 재사용하기 좋겠다고 생각했다.

리팩토링

@Service
public class FileService {

    private static final String FILE_PATH = "/uploads/profile-images/";
    private static final List<String> ALLOWED_FILE_EXTENSIONS = List.of("jpg", "jpeg", "png",
            "gif");

    private final Path fileStorageLocation;

    public FileService(@Value("${file.dir}") String fileDir) {
        this.fileStorageLocation = Paths.get(fileDir).toAbsolutePath().normalize();
        withIOException(
                () -> Files.createDirectories(this.fileStorageLocation),
                fileDir, "fileStorageLocation"
        );
    }

    public String saveProfileImage(MultipartFile file) {
        String originalFileName = StringUtils.cleanPath(file.getOriginalFilename());
        // 파일 이름 검증
        validate(originalFileName, name -> name.contains(".."),
                originalFileName, "fileName");

        // 확장자 검증
        String fileExtension = StringUtils.getFilenameExtension(originalFileName)
                .toLowerCase();
        validate(fileExtension, ext -> !ALLOWED_FILE_EXTENSIONS.contains(ext),
                fileExtension, "fileExtension");

        // 고유 이름 생성
        String newFileName = UUID.randomUUID() + "." + fileExtension;
        Path targetLocation = fileStorageLocation.resolve(newFileName);

        // 파일 저장 (공통 예외 처리 추상화)
        withIOException(
                () -> Files.copy(file.getInputStream(), targetLocation,
                        StandardCopyOption.REPLACE_EXISTING),
                originalFileName, "fileName"
        );

        return FILE_PATH + newFileName;
    }

    public void deleteProfileImage(String fileName) {
        Path filePath = fileStorageLocation.resolve(fileName).normalize();
        withIOException(
                () -> Files.deleteIfExists(filePath),
                fileName, "fileName"
        );
    }

    @FunctionalInterface
    private interface ThrowingRunnable {

        void run() throws IOException;
    }

    /**
     * IOException 발생 시 BusinessException으로 전환하여 던져줍니다.
     *
     * @param action 파일 I/O 작업 (파일 생성/삭제/복사 등)
     */
    private void withIOException(ThrowingRunnable action,
            String input, String field) {
        try {
            action.run();
        } catch (IOException ex) {
            throw new BusinessException(input, field, ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * Predicate를 이용한 검증 후 실패 시 예외를 던집니다.
     */
    private <T> void validate(T value, Predicate<T> predicate,
            String input, String field) {
        if (predicate.test(value)) {
            throw new BusinessException(input, field, ErrorCode.MEMBER_FILE_BAD_REQUEST);
        }
    }
}

1. 커스텀 함수형 인터페이스 사용

  • 리팩토링 후, IOException을 처리를 위한 함수형 인터페이스를 활용한 ThrowingRunable을 정의했다.
@FunctionalInterface
private interface ThrowingRunnable {
    void run() throws IOException;
}
  • 파일 생성/복사/삭제와 같은 I/O 작업에서 발생할 수 있눈 예외를 처리하는 단 하나의 동작을 함수형 인터페이스를 사용하여 추상화 하였다.
private void withIOException(ThrowingRunnable action, String input, String field) {
    try {
        action.run();
    } catch (IOException ex) {
        throw new BusinessException(input, field, ErrorCode.INTERNAL_SERVER_ERROR);
    }
}
  • 이렇게 이전 코드에서 반복적으로 나타났던 IOException 처리 및 try-catch 블록을 하나의 공통 메서드로 추상화하였다.
  • 파일 생성/삭제/디렉토리 생성 작업에서 모두 사용할 수 있도록 하여 재사용성을 극대화 할 수 있었다.

2. 검증 로직 공통화: Predicate<T> 활용

  • 파일 이름 및 확장자 검증을 위한 중복된 if문 대신, 범용적인 검증 메서드를 추가하였다.
private <T> void validate(T value, Predicate<T> predicate, String input, String field) {
    if (predicate.test(value)) {
        throw new BusinessException(input, field, ErrorCode.MEMBER_FILE_BAD_REQUEST);
    }
}
  • 다양한 조건 검증 시 조건만 Predicate로 전달하여 공통 로직으로 처리했다.
validate(originalFileName, name -> name.contains(".."), originalFileName, "fileName");
validate(fileExtension, ext -> !ALLOWED_FILE_EXTENSIONS.contains(ext), fileExtension, "fileExtension");

리팩토링 후 얻은 장점

✅ 1. 중복 제거로 코드의 양 감소

  • 이전에는 파일 저장, 삭제, 디렉토리 생성마다 각각 try-catch 구문을 작성해야 했다.
  • 이제는 withIOException()이라는 하나의 메서드만으로 이 모든 작업에서 발생할 수 있는 예외를 처리할 수 있다.
  • 덕분에 코드 중복이 제거되고, 전체 클래스가 훨씬 짧아졌다.

✅ 2. 함수형 인터페이스 활용으로 코드의 의도 명확화

  • withIOException(() -> ...)만 봐도 “이 블록은 예외가 발생할 수 있는 I/O 작업이다”라는 걸 한눈에 알 수 있다.
  • 이처럼 비즈니스 로직과 예외처리 로직을 분리함으로써, 코드의 의도(= 무슨 작업을 하는가)가 더욱 명확해졌다.

✅ 3. 공통 로직 추상화로 유지보수 용이성 향상

  • 예를 들어 추후에 모든 예외를 로깅하거나 다른 방식으로 처리하고 싶다면, withIOException() 메서드 한 곳만 수정하면 된다.
  • 검증 로직 또한 validate() 메서드 한 곳만 바꾸면 전역에 영향을 줄 수 있어, 변경 비용이 낮아지고 유지보수성이 올라간다.

✅ 4. 가독성 향상

  • 핵심 비즈니스 로직인 파일 저장, 파일 삭제 등의 동작이 더 눈에 잘 들어온다.
  • 예외처리나 검증이 간결하게 추상화되어, 전체적인 코드 흐름을 방해하지 않고 읽히도록 도와준다.

회고

개인적으로 뭔가 더 복잡해진 느낌이라서 추가적으로 함수형 인터페이스 관련 메서드가 모여있는 유틸 클래스를 추가하여 리팩토링 했다.

  • FileService
@Service
public class FileService {

    private static final String FILE_PATH = "/uploads/profile-images/";
    private static final List<String> ALLOWED_FILE_EXTENSIONS = List.of("jpg", "jpeg", "png",
            "gif");

    private final Path fileStorageLocation;

    public FileService(@Value("${file.dir}") String fileDir) {
        this.fileStorageLocation = Paths.get(fileDir).toAbsolutePath().normalize();
        FileUtil.withIOException(
                () -> Files.createDirectories(this.fileStorageLocation),
                fileDir, "fileStorageLocation"
        );
    }

    public String saveProfileImage(MultipartFile file) {
        String originalFileName = StringUtils.cleanPath(file.getOriginalFilename());

        // 파일 이름 유효성 검사
        FileUtil.validate(
                originalFileName,
                name -> name.contains(".."),
                originalFileName, "fileName"
        );

        // 확장자 유효성 검사
        String fileExtension = StringUtils.getFilenameExtension(originalFileName).toLowerCase();
        FileUtil.validate(
                fileExtension,
                ext -> !ALLOWED_FILE_EXTENSIONS.contains(ext),
                fileExtension, "fileExtension"
        );

        // 새 파일 이름 생성
        String newFileName = UUID.randomUUID() + "." + fileExtension;
        Path targetLocation = fileStorageLocation.resolve(newFileName);

        // 파일 저장
        FileUtil.withIOException(
                () -> Files.copy(file.getInputStream(), targetLocation,
                        StandardCopyOption.REPLACE_EXISTING),
                originalFileName, "fileName"
        );

        return FILE_PATH + newFileName;
    }

    public void deleteProfileImage(String fileName) {
        Path filePath = fileStorageLocation.resolve(fileName).normalize();
        FileUtil.withIOException(
                () -> Files.deleteIfExists(filePath),
                fileName, "fileName"
        );
    }
}
  • FileUtil
/**
 * 파일 관련 공통 유틸 클래스
 */
public class FileUtil {

    @FunctionalInterface
    public interface ThrowingRunnable {
        void run() throws IOException;
    }

    /**
     * 파일 작업 중 IOException 발생 시 BusinessException으로 래핑하여 처리
     *
     * @param action 실행할 파일 작업
     * @param input 예외 메시지에 사용할 입력값
     * @param field 예외 필드 이름
     */
    public static void withIOException(ThrowingRunnable action, String input, String field) {
        try {
            action.run();
        } catch (IOException ex) {
            throw new BusinessException(input, field, ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * 주어진 조건이 true일 경우 예외를 던지는 검증 유틸
     *
     * @param value      검증할 값
     * @param predicate  실패 조건
     * @param input      예외 메시지에 사용할 입력값
     * @param field      예외 필드 이름
     * @param <T>        검증 타입
     */
    public static <T> void validate(T value, Predicate<T> predicate, String input, String field) {
        if (predicate.test(value)) {
            throw new BusinessException(input, field, ErrorCode.MEMBER_FILE_BAD_REQUEST);
        }
    }
}

아직 눈에 익지 않아서 조금 어색한 느낌이 있지만 가독성 면에서는 많이 좋아진거 같다. 앞으로도 함수형 인터페이스의 존재를 까먹지 말고 사용하면 더 좋은 코드를 짤 수 있을거 같다.

0개의 댓글