02. 동작 파라미터화 (Behavior Parameterization)
같은 기능을 여러 번 구현하는 건 비효율적이다.
특정 조건을 만족하는 요소만 필터링하는 메서드가 필요하다고 가정하자.
사과 리스트에서 "녹색 사과"만 추출하는 코드가 필요하다면 어떻게 구현할까?
단순한 if-else 로직을 반복하는 대신, 더 유연하고 재사용 가능한 코드를 만들 수는 없을까?
이를 가능하게 해주는 개념이 바로 동작 파라미터화(Parameterization)다
동작 파라미터화로 이처럼 다양한 기능을 수행할 수 있다.
- 모든 리스트 요소에 대해 특정 동작을 적용할 수 있음
- 리스트 처리 후 추가적인 작업을 유연하게 실행할 수 있음
- 에러 발생 시 미리 정의된 대체 동작을 수행할 수 있음
1. 변화에 대응하기
1.1 첫 번째 시도 : 녹색 사과 필터링
enum Color { RED, GREEN }
public static List<Apple> filterGreenApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (GREEN.equals(apple.getColor())) { // 녹색 사과를 선택하는데 필요한 조건
result.add(apple);
}
}
return result;
}
녹색 사과를 필터링하는 메서드를 만들었지만, 빨간 사과도 필터링해야 하는 상황이 발생했다.
이를 위해 새로운 메서드를 추가하면 비슷한 코드가 반복되므로, 중복을 줄이기 위해 반복되는 코드를 추상화한다.
1.2 두 번째 시도 : 색을 파라미터화
문제점: 빨간 사과도 필터링해야하는 상황이 발생했다.
이때, 각각의 색상에 대해 별도의 메서드를 만들면 중복 코드가 증가하고 확장성이 떨어지는 코드가 된다.
혜결 방법
색상을 메서드의 파라미터로 받아, 원하는 사과의 색상을 필터링 할 수 있도록 개선한다.
public static List<Apple> filterApplesByColor(List<Apple> inventory, Color color) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (apple.getColor().equals(color)) { // 색을 파라미터로 받아 유연한 대응을 가진다
result.add(apple);
}
}
return result;
}
이제 특정 색상의 사과를 필터링할 때, 색상을 직접 지정할 수 있다.
List<Apple> greenApples = filterApplesByColor(inventory, GREEN);
List<Apple> redApples = filterApplesByColor(inventory, RED);
새로운 요구사항: 무게로 필터링하기
사과를 색상뿐만 아니라 무게(150g 이상 여부)로도 필터링해야 한다.
이를 위해 필터링 조건을 확장할 필요가 있다.
색과 마찬가지로 바뀔 수 있는 다양한 무게에 대응할 수 있도록 무게 정보 파라미터도 추가했다.
public static List<Apple> filterApplesByWeight(List<Apple> inventory, int weight) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (apple.getWeight() > weight) { // 색을 파라미터로 받아 유연한 대응을 가진다
result.add(apple);
}
}
return result;
}
문제점
하지만 색상과 무게를 필터링하는 코드가 매우 유사하므로, 중복을 줄이기 위해 이를 통합해야 한다.
중복 코드는 소프트웨어 공학의 DRY(Don’t Repeat Yourself, 반복하지 말 것) 원칙을 위반하며, 유지보수가 어려워진다.
색상과 무게를 하나의 filter 메서드로 통합할 수도 있지만, 필터링 기준을 명확히 구분해야 한다. 이를 위해 플래그를 추가할 수 있으나, 권장되지 않는 방식이다.
1.3 세 번째 시도 : 가능한 모든 속성으로 필터링
public static List<Apple> filterApples(List<Apple> inventory, Color color, int weight, boolean flag) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if ((flag && apple.getColor().equals(color)) || (!flag && apple.getWeight() > weight)) {
result.add(apple);
}
}
return result;
}
다음과 같이 메서드를 사용할 수 있지만, 코드가 점점 비효율적이고 복잡해졌다.
List<Apple> greenApples = filterApples(inventory, GREEN, 0, true);
List<Apple> heavyApples = filterApples(inventory, null, 150, false);
문제점
1. 색과 무게를 함께 필터링하는 filter 메서드를 구현할 수도 있지만,
이 경우 색과 무게를 구분하는 기준이 필요하며, 위 코드에서는 이를 위해 boolean 타입의 flag를 사용했다.
2. 어떤 값이 true이고 false인지 직관적이지 않으며,
사과의 속성(크기, 모양, 출하지 등)이 추가될 경우 확장성이 떨어진다.
3. 또한, 빨간색이면서 무거운 사과와 같은 조건을 처리하려면 중복된 메서드를 계속 추가해야 하므로 유지보수가 어렵다
2. 동작 파라미터화
우리는 변화하는 요구사항에 유연하게 대응해야 한다.
사과의 특정 속성을 기준으로 선택 조건을 결정하고, 참 또는 거짓을 반환하는 함수를 프레디케이트(Predicate)라고 한다.
이제 선택 조건을 정의하는 인터페이스를 설계해보자.
인터페이스이므로 직접 로직을 구현하지 않음 → 대신 구체적인 전략(필터링 방식)은 구현체에서 제공
interface ApplePredicate {
boolean test(Apple a);
}
사과를 다양한 기준으로 필터링할 수 있도록 여러 가지 구현 클래스를 만들 수 있다.
static class AppleWeightPredicate implements ApplePredicate {
@Override
public boolean test(Apple apple) {
return apple.getWeight() > 150; // 무거운 사과만 선택
}
}
static class AppleColorPredicate implements ApplePredicate {
@Override
public boolean test(Apple apple) {
return apple.getColor() == Color.GREEN; // 녹색 사과만 선택
}
}
위 조건에 따라 filter 메서드는 서로 다른 방식으로 동작할 수 있다.
이처럼 실행 시점에 전략(알고리즘)을 선택하는 방식을 전략 디자인 패턴(Strategy Pattern)이라고 한다.
전략 패턴은 여러 알고리즘(전략)을 하나의 패밀리로 캡슐화하고, 필요에 따라 적절한 전략을 실행하는 기법이다.
이 예제에서 ApplePredicate는 공통 인터페이스(알고리즘 패밀리)이며
AppleHeavyWeightPredicate와 AppleGreenColorPredicate는 구체적인 전략에 해당한다.
ApplePredicate가 다양한 동작을 수행하려면, filterApples 메서드에서 ApplePredicate을 받아 사과의 조건을 검사하도록 수정해야 한다.
이처럼 동작을 파라미터화하여 다양한 전략을 메서드에 전달하면, 유연한 조건 적용과 동적 실행이 가능해진다.
이제 filterApples 메서드가 ApplePredicate 객체를 인수로 받도록 수정하자.
이렇게 하면 filterApples 내부에서 컬렉션을 반복하는 로직과 각 요소에 적용할 동작(프레디케이트)을 분리할 수 있어 코드의 가독성과 유지보수성이 향상된다.
2.1 네 번째 시도: 추상적 조건으로 필터링
public static List<Apple> filter(List<Apple> inventory, ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
// [Apple{color=GREEN, weight=80}, Apple{color=GREEN, weight=155}]
List<Apple> greenApples2 = filter(inventory, new AppleColorPredicate());
// [Apple{color=GREEN, weight=155}]
List<Apple> heavyApples = filter(inventory, new AppleWeightPredicate());
이처럼 필터 조건을 매개변수로 전달하면, 특정 값에 의존하지 않고 다양한 조건을 적용할 수 있어 코드의 유연성과 가독성이 향상된다.
이제 중량이 150g 이상인 빨간 사과를 검색해야 할 경우,
ApplePredicate 인터페이스를 구현하는 클래스만 추가하면 된다.
즉, Apple의 속성이 변경되더라도 손쉽게 대응할 수 있는 확장 가능한 코드가 완성된 것이다.
static class AppleRedAndHeavyPredicate implements ApplePredicate {
@Override
public boolean test(Apple apple) {
return apple.getColor() == Color.RED && apple.getWeight() > 150;
}
}
List<Apple> redAndHeavyApples = filter(inventory, new AppleRedAndHeavyPredicate());
전달된 ApplePredicate 객체에 따라 filterApples 메서드의 동작이 결정된다.
즉, filterApples 메서드는 동작을 파라미터화하여, 다양한 조건을 유연하게 적용할 수 있다.
예제에서 중요한 부분은 filterApples 메서드가 새로운 동작을 정의하는 test 메서드를 활용한다는 점이다.
하지만 메서드는 객체만 인수로 받을 수 있으므로, test 메서드는 ApplePredicate 객체로 감싸서 전달해야 한다.
이를 통해 구현 객체를 이용해 동작을 전달할 수 있으며, 람다 표현식을 활용하면 이를 더욱 간결하게 작성할 수 있다.
한 개의 파라미터, 다양한 동작
컬렉션 탐색 로직과 항목에 적용할 동작을 분리할 수 있다는 것이 동작 파라미터화의 강점이다.
한 메서드가 다른 동작을 수행하도록 재활용할 수 있다. 따라서 유연한 API를 만들 때 동작 파라미터화가 중요한 역할을 한다.
유연한 prettyPrintApple 메서드 구현하기
사과 리스트를 인수로 받아, 다양한 방식으로 문자열을 생성할 수 있도록
(커스터마이징된 toString() 메서드처럼 동작하도록) 파라미터화된 prettyPrintApple 메서드를 구현해보자.
Apple 객체를 인수로 받아, 정해진 형식의 문자열로 변환할 수 있도록 AppleFormatter 인터페이스를 구현한다.
interface AppleFormatter {
String accept(Apple a);
}
AppleFormatter 인터페이스를 구현하여 다양한 포맷 동작을 정의할 수 있다.
AppleFancyFormatter와 AppleSimpleFormatter는 AppleFormatter를 구현하므로,
이를 AppleFormatter 타입의 변수에 저장하거나, 메서드의 인자로 전달할 수 있다.
static class AppleFancyFormatter implements AppleFormatter {
public String accept(Apple apple) {
String characteristic = apple.getWeight() > 150 ? "heavy" : "light";
// 최종 문자열을 소문자로 변환하여 일관된 형식으로 출력
return ("A " + characteristic + " " + apple.getColor() + " apple").toLowerCase();
}
}
static class AppleSimpleFormatter implements AppleFormatter {
public String accept(Apple apple) {
return "An apple of " + apple.getWeight() + "g";
}
}
3. prettyPrintApple 메서드가 AppleFormatter 객체를 인수로 받을 수 있게 파라미터를 추가한다.
public static void prettyPrintApple(List<Apple> inventory,
AppleFormatter formatter) {
for (Apple apple: inventory) {
String output = formatter.accept(apple);
System.out.println(output);
}
}
이제 다양한 AppleFormatter 객체를 인수로 전달받아 여러 동작을 수행할 수 있다.
prettyPrintApple(inventory, new AppleFancyFormatter());
/**
a light green apple
a heavy green apple
a light red apple
**/
prettyPrintApple(inventory, new AppleSimpleFormatter());
/**
An apple of 80 g
An apple of 155 g
An apple of 120 g
**/
지금까지 동작을 추상화하여 변화에 유연하게 대응하는 코드를 살펴보았다.
그러나 여러 클래스를 구현하고 인스턴스화(객체 생성)하는 과정이 다소 번거롭게 느껴질 수 있다.
이를 더 간결하고 효율적으로 개선하는 방법을 알아보자.
3. 복잡한 과정 간소화
/// 무거운 사과 선택
public static class ApplyHeavyWeightPredicate implements ApplePredicate {
public boolean test(Apple apple) { // @Override 묵시적 생략
return apple.getWeight() > 150;
}
}
/// 녹색 사과 선택
public static class AppleGreenColorPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return GREEN.equals(apple.getColor());
}
}
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for(Apple apple : inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
public class FilteringApples {
public static void main(String... args) {
List<Apple> inventory = Arrays.asList(
new Apple(80, GREEN),
new Apple(155, GREEN),
new Apple(120, Color.RED));
List<Apple> greenApples = filter(inventory, new AppleColorPredicate());
System.out.println(greenApples);
List<Apple> heavyApples = filter(inventory, new AppleWeightPredicate());
System.out.println(heavyApples);
}
}
로직과 상관 없는 코드가 많이 추가되었다. 현재 filterApples 메서드로 새로운 동작을 전달하려면
ApplePredicate 인터페이스를 구현하는 여러 클래스를 정의한 다음에 인스턴스화해야한다. 이는 상당히 번거로운 작업이며 시간 낭비다.
3.1 익명 클래스
Java는 클래스를 선언하면서 동시에 인스턴스화할 수 있도록 익명 클래스(Anonymous Class) 기능을 제공한다.
익명 클래스를 사용하면 코드의 양을 줄일 수 있지만, 모든 경우에 적합한 것은 아니다.
Java 8에서 추가된 람다(lambda) 표현식을 활용하면 더 간결하고 가독성 높은 코드를 작성할 수 있다
익명 클래스(Anonymous Class)는 자바의 지역 클래스(Local Class)와 유사한 개념이다.
이는 이름이 없는 클래스로, 클래스 선언과 동시에 인스턴스화할 수 있는 특징을 가진다.
3.2 다섯 번째 시도 : 익명 클래스 사용
익명 클래스를 이용해 Applepredicate를 구현하는 객체를 만드는 방법으로 필터링 예제를 다시 구현한 코드다.
List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
// filterApples 메서드의 동작을 직접 파라미터화 했다.
@Override
public boolean test(Apple apple) {
return RED.equals(apple.getColor());
}
});
익명 클래스는 여전히 많은 공간을 차지한다.
또한 많은 프로그래머가 익명 함수의 사용에 익숙하지 않다. 아래 예제 문제는 많은 프로그래머를 곤경에 빠뜨리는 고전 자바 문제다.
퀴즈 2-2 익명 클래스 문제
다음 코드를 실행한 결과는 4, 5, 6, 42 중 어느 것일까?
public class MeaningOfThis {
public final int value = 4;
public void doIt() {
int value = 6;
Runnable r = new Runnable() {
public final int value = 5;
@Override
public void run() {
int value = 10;
System.out.println(this.value);
}
};
r.run();
}
public static void main(String... args) {
MeaningOfThis m = new MeaningOfThis();
m.doIt(); // 이 행의 출력 결과는?
}
}
문제의 답을 알아내기 위해서 익명 클래스 내부에서 this가 무엇을 참조하는지부터 알아야 한다.
MeaningOfThis 클래스 | 4 | this.value 사용 불가 (외부 클래스) |
doIt() 메서드 | 6 | this.value 사용 불가 (지역 변수) |
Runnable 익명 클래스 | 5 | ✅ this.value가 참조하는 값(인스턴스 변수) |
run() 메서드 내부 | 10 | 지역 변수 (가장 가까운 스코프, this가 없을 시 가장 가까운 지역 변수가 출력) |
this는 항상 현재 실행 중인 객체(인스턴스)를 가리키는 참조 변수로, 현재 메서드를 실행하는 객체 자신을 의미한다.
익명 클래스는 내부적으로 new 키워드를 통해 인스턴스를 생성하는 방식으로 동작한다.
따라서 익명 클래스 내부에서 선언된 변수(value = 5;)는 해당 익명 클래스의 인스턴스 변수(필드)가 된다.
선언 위치 | 클래스 내부 (메서드 밖) | 메서드 또는 블록 내부 |
저장 위치 | 히프(Heap) 메모리 | 스택(Stack) 메모리 |
생명 주기 | 객체가 존재하는 동안 유지 | 메서드 실행 중에만 유지 |
접근 범위 | 클래스 내 모든 메서드에서 접근 가능 | 선언된 블록 내에서만 사용 가능 |
this 사용 여부 | this.변수명으로 접근 가능 | this로 접근 불가능 |
코드의 장황함(verbosity)는 나쁜 특성이다.익명 클래스로 인터페이스를 구현하는 클래스 선언 과정을 줄일 순 있지만, 객체를 만들고 명시적으로 새 동작을 구현함은 변하지 않는다.
이는 Java 8에 추가된 람다 표현식을 사용하여, 간결하게 정리할 수 있다.
3.3 여섯 번째 시도 : 람다 표현식 사용
자바 8의 람다 표현식을 이용해서 코드를 간단하게 재구현 할 수 있다.
List<Apple> result =
filterApples(inventory, (Apple apple) -> RED.equals(apple.getColor()));
3.4 일곱 번째 시도 : 리스트 형식으로 추상화
public static <T> List<T> filter(List<T> list, Predicate<T> p) { // 형식 파라미터 T 등장
List<T> result = new ArrayList<>();
for(T e: list){
if(p.test(e)){
result.add(e);
}
}
return result;
}
형식 파라미터(제네릭)
T가 메서드를 호출할 때 전달되는 타입(Integer, String, Apple 등)으로 자동 결정됨.
입력값(List<T> list)과 반환값(List<T> result)이 같은 타입을 유지.
이제 정수, 문자열 등의 리스트에 필터 메소드를 사용할 수 있다. 다음은 람다 표현식을 사용한 예이다.
List<Apple> redAppleFind =
filter(inventory, (Apple apple) -> RED.equals(apple.getColor()));
List<Integer> evenNums =
filter(numbers, (Integer i) -> i % 2 == 0);
4. 실전 예제
코드 전달 개념을 확실히 익힐 수 있도록 Comparator로 정렬하기, Runnable로 코드 블록 실행하기, Callable 결과 반환하기 예제를 다룬다.
4.1 Comparator로 정렬하기
변화에 쉽게 대응 가능한 다양한 정렬 동작을 수행하는 코드를 다룬다.
Java 8에 List는 sort 메서드가 포함되어있다.(Collection.sort도 존재한다)
인터페이스를 갖는 java.util.Comparator 객체를 이용해 sort의 동작을 파라미터화 할 수 있다.
public interface Comparator<T> {
int compare(T o1, T o2);
}
Comparator를 구현해서 sort 메서드의 동작을 다양화할 수 있다. 익명 클래스를 이용해 무게가 적은 순서로 목록에서 사과를 정렬할 수 있다.
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
});
요구사항이 바뀌면 새로운 Comparator를 만들어 sort 메서드에 전달할 수 있다. 람다 표현식을 이용하면 더 간단하게 구현이 가능하다.
inventory.sort((a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));
4.2 Runnable로 코드 블록 실행하기
자바 스레드를 사용하면 코드 블록을 병렬로 실행할 수 있으며, 여러 스레드가 각기 다른 코드를 실행할 수 있다.
Java 8 이전에는 Thread 생성자에 객체만 전달할 수 있었기 때문에, 반환값이 없는 void run() 메서드를 포함하는 익명 클래스가 Runnable 인터페이스를 구현하는 방식이 일반적이었다.
Thread t = new Thread(new Runnable() {
public void run() {
System.out.println("Hello world");
}
});
Java 8부터 지원하는 람다 표현식을 이용하면 다음과 같이 Thread 코드를 구현할 수 있다.
Thread thread = new Thread(() -> System.out.println("Hello world"));
4.3 Callable를 결과로 반환하기
ExecutorService 인터페이스는 Task 제출과 실행 과정의 연관성을 끊어준다.
Task를 Thread Pool로 보내고 결과를 Future로 저장할 수 있다는 점이 Thread와 Runnable를 이용하는 방식과 다르다.
Runnable의 업그레이드 버전이라 볼 수 있다.
public interface Callable<V> {
V call();
}
ExecutorService executorService = Executors.newCachedThreadPool();
Future<String> threadName = executorService.submit(new Callable<String>() {
@Override
public String call() throws Exception {
return Thread.currentThread().getName();
}
});
}
실행 서비스에 Task를 제출하여 위 코드를 활용할 수 있다. 다음 예제는 Task를 실행하는 Thread의 이름을 반환한다.
Future<String> threadName = executorService.submit(
() -> Thread.currentThread().getName());
람다를 사용하면 다음처럼 코드를 줄일 수 있다.
import java.util.concurrent.*;
public class Bakery {
public static void main(String[] args) throws Exception {
ExecutorService factory = Executors.newCachedThreadPool(); // 로봇 공장장
Future<String> bread = factory.submit(new Callable<String>() { // 로봇에게 "빵 구워!" 시킴
@Override
public String call() throws Exception {
Thread.sleep(2000); // 빵 굽는 시간 (2초)
return "빵 완성!";
}
});
System.out.println("빵을 주문했어요! 기다리는 중...");
System.out.println(bread.get()); // 빵이 다 구워질 때까지 기다림
factory.shutdown(); // 로봇 공장 종료
}
}
✅ ExecutorService = "로봇 공장장" (스레드 관리)
✅ Callable = "결과를 돌려주는 로봇" (작업 후 결과 반환 가능)
✅ Future = "결과가 나올 때까지 기다리는 약속" (비동기 실행 후 결과 받기)
아직은 이 개념이 낯설 수 있다.
뒷부분에서 병렬 실행을 더 자세히 살펴보니 Callable 인터페이스를 이용해 결과를 반환하는 Task를 만든다는 사실만 알아두자(당장 무슨 말인지 하나도 모르겠지만?)
🔵 핵심 개념 요약
⚫ 동작 파라미터화 (Behavior Parameterization)
특정 행동(로직)을 매개변수로 전달하여 코드의 유연성을 높이는 기법
중복 코드 감소, 유지보수성 증가
filter() 메서드에서 필터링 기준을 동적으로 적용 가능
⚫ 전략 패턴 (Strategy Pattern)
여러 개의 알고리즘을 하나의 인터페이스로 추상화하여 필요할 때 선택 가능
ApplePredicate 인터페이스를 활용하여 필터 조건을 분리
실행 시점에 전략(알고리즘)을 결정하는 방식
⚫ Predicate 인터페이스 활용
boolean test(T t) 메서드를 구현하여 다양한 조건을 적용 가능
filter() 메서드에 원하는 필터링 전략을 전달하여 재사용성 증가
ApplePredicate을 구현한 클래스를 통해 가독성 & 확장성 향상
⚫ 익명 클래스 & 람다 표현식 적용
익명 클래스를 사용하면 불필요한 클래스 선언 없이 필터링 가능
람다 표현식을 사용하면 코드가 더욱 간결해짐
List<Apple> redApples = filter(inventory, apple -> RED.equals(apple.getColor()));
Predicate<T>를 활용하여 사과뿐만 아니라 다른 타입도 필터링 가능
⚫ 제네릭을 활용한 범용 필터링
특정 객체(Apple)뿐만 아니라 모든 타입(T)에 대해 필터링 가능
List<T> 타입을 받아 원하는 조건(Predicate<T>)으로 필터링 가능
public static <T> List<T> filter(List<T> list, Predicate<T> p) { ... }