자바는 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 패키지를 통해 여러 종류의 범용 함수형 인터페이스를 제공해준다.
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... (매번 달라짐)
언제 쓰일까?
Counsumer<T> - "값을 소비한다"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()));
언제 쓰일까?
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()));
언제 쓰일까?
Predicate<T> -> "조건 판단을 한다."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
언제 쓰일까?
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. 커스텀 함수형 인터페이스 사용
@FunctionalInterface
private interface ThrowingRunnable {
void run() throws IOException;
}
private void withIOException(ThrowingRunnable action, String input, String field) {
try {
action.run();
} catch (IOException ex) {
throw new BusinessException(input, field, ErrorCode.INTERNAL_SERVER_ERROR);
}
}
2. 검증 로직 공통화: Predicate<T> 활용
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);
}
}
validate(originalFileName, name -> name.contains(".."), originalFileName, "fileName");
validate(fileExtension, ext -> !ALLOWED_FILE_EXTENSIONS.contains(ext), fileExtension, "fileExtension");
✅ 1. 중복 제거로 코드의 양 감소
withIOException()이라는 하나의 메서드만으로 이 모든 작업에서 발생할 수 있는 예외를 처리할 수 있다.✅ 2. 함수형 인터페이스 활용으로 코드의 의도 명확화
withIOException(() -> ...)만 봐도 “이 블록은 예외가 발생할 수 있는 I/O 작업이다”라는 걸 한눈에 알 수 있다.✅ 3. 공통 로직 추상화로 유지보수 용이성 향상
withIOException() 메서드 한 곳만 수정하면 된다.validate() 메서드 한 곳만 바꾸면 전역에 영향을 줄 수 있어, 변경 비용이 낮아지고 유지보수성이 올라간다.✅ 4. 가독성 향상
개인적으로 뭔가 더 복잡해진 느낌이라서 추가적으로 함수형 인터페이스 관련 메서드가 모여있는 유틸 클래스를 추가하여 리팩토링 했다.
@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"
);
}
}
/**
* 파일 관련 공통 유틸 클래스
*/
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);
}
}
}
아직 눈에 익지 않아서 조금 어색한 느낌이 있지만 가독성 면에서는 많이 좋아진거 같다. 앞으로도 함수형 인터페이스의 존재를 까먹지 말고 사용하면 더 좋은 코드를 짤 수 있을거 같다.