실무에서 마주한 함수형 프로그래밍 - 명령형에서 선언적으로
서론
코드 리뷰 시간, 팀장님이 제 코드를 보시더니 물으셨습니다.
“이 메서드, 뭐 하는 거예요?”
저는 자신 있게 설명했습니다. “고객 목록에서 활성 상태인 VIP 고객들의 이번 달 총 구매액을 계산하는 로직입니다.”
“아… 그렇군요. 근데 코드를 읽어도 그게 잘 안 보이네요.”
순간 당황했습니다. 제가 작성한 코드였는데도, 다시 보니 정말 한눈에 이해가 안 됐습니다.
public BigDecimal calculateMonthlyVipPurchase(List<Customer> customers) {
BigDecimal total = BigDecimal.ZERO;
for (Customer customer : customers) {
if (customer.getStatus().equals("ACTIVE")) {
if (customer.getGrade().equals("VIP")) {
List<Order> orders = customer.getOrders();
for (Order order : orders) {
LocalDate orderDate = order.getOrderDate();
LocalDate now = LocalDate.now();
if (orderDate.getYear() == now.getYear()
&& orderDate.getMonth() == now.getMonth()) {
total = total.add(order.getTotalAmount());
}
}
}
}
}
return total;
}3단계 중첩 루프에 if문이 여러 개… 로직은 맞지만 가독성은 최악이었습니다.
그날 오후, 저는 함수형 프로그래밍을 제대로 공부하기로 결심했습니다.
이번 포스팅에서는 제가 실무에서 명령형 코드를 함수형으로 전환하면서 배운 것들을 공유합니다.
함수형 프로그래밍이란?
교과서적 정의는 건너뛰고
“함수형 프로그래밍은 불변성과 순수 함수를 기반으로…”
이런 설명을 들으면 머리가 아파집니다. 실무 관점에서 다시 정리해보겠습니다.
실무 관점에서의 함수형 프로그래밍
“어떻게(How)“가 아니라 “무엇을(What)” 중심으로 코드를 작성하는 것
명령형 코드는 컴퓨터에게 어떻게 해야 하는지를 단계별로 지시합니다:
- “이 리스트를 순회해”
- “조건을 확인해”
- “값을 변경해”
- “다음으로 넘어가”
함수형 코드는 무엇을 얻고 싶은지를 선언합니다:
- “활성 고객들을 필터링하고”
- “VIP만 선택하고”
- “이번 달 주문 금액을 합산해줘”
왜 함수형인가?
제가 함수형 프로그래밍을 도입하게 된 실무적 이유는 3가지였습니다.
1. 가독성 향상
서론의 코드를 함수형으로 바꾸면:
public BigDecimal calculateMonthlyVipPurchase(List<Customer> customers) {
LocalDate now = LocalDate.now();
return customers.stream()
.filter(Customer::isActive)
.filter(customer -> customer.isVip())
.flatMap(customer -> customer.getOrders().stream())
.filter(order -> isCurrentMonth(order.getOrderDate(), now))
.map(Order::getTotalAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
private boolean isCurrentMonth(LocalDate date, LocalDate now) {
return date.getYear() == now.getYear()
&& date.getMonth() == now.getMonth();
}코드가 요구사항을 그대로 읽히게 됩니다.
“활성 고객 중 VIP 고객의 주문을 가져와서, 이번 달 주문만 필터링하고, 금액을 합산한다”
2. 버그 감소
명령형 코드의 가장 큰 문제는 가변 상태(Mutable State)였습니다.
// 명령형: 상태를 계속 변경
BigDecimal total = BigDecimal.ZERO;
for (...) {
total = total.add(...); // 상태 변경
}변수를 계속 변경하다 보면:
- 어디선가 의도치 않게 값이 바뀌고
- 디버깅할 때 어느 시점에 문제가 생겼는지 찾기 어렵고
- 멀티스레드 환경에서는 더 위험합니다
함수형 코드는 불변성(Immutability)을 유지합니다:
// 함수형: 새로운 값을 생성
return orders.stream()
.map(Order::getTotalAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);원본 데이터는 절대 변경되지 않습니다. 항상 새로운 결과를 반환합니다.
3. 병렬 처리 용이
서비스가 커지면서 성능 이슈가 생겼습니다. 약 50만 건의 데이터를 처리해야 했는데, 순차 처리로는 20초가 걸렸습니다.
함수형 코드는 병렬 처리로 전환이 간단합니다:
// 순차 처리
return orders.stream()
.filter(...)
.map(...)
.reduce(...);
// 병렬 처리 (한 줄만 수정)
return orders.parallelStream() // stream() → parallelStream()
.filter(...)
.map(...)
.reduce(...);병렬 처리로 전환했더니 처리 시간이 20초 → 5초로 단축됐습니다.
명령형 코드를 병렬화하려면? 코드 전체를 다시 작성해야 합니다.
실무에서 자주 쓰는 함수형 패턴
1. 컬렉션 처리 - filter, map, reduce
실무에서 가장 많이 하는 작업이 컬렉션 처리입니다.
Before: 명령형
public List<CustomerDto> getActiveCustomerEmails(List<Customer> customers) {
List<CustomerDto> result = new ArrayList<>();
for (Customer customer : customers) {
if (customer.getStatus().equals("ACTIVE")) {
if (customer.getEmail() != null && !customer.getEmail().isEmpty()) {
CustomerDto dto = new CustomerDto();
dto.setId(customer.getId());
dto.setName(customer.getName());
dto.setEmail(customer.getEmail());
result.add(dto);
}
}
}
return result;
}문제점:
- 임시 변수
result선언 필요 - 중첩 if문
- DTO 변환 로직이 섞여 있음
- 한눈에 뭘 하는지 파악 어려움
After: 함수형
public List<CustomerDto> getActiveCustomerEmails(List<Customer> customers) {
return customers.stream()
.filter(Customer::isActive)
.filter(customer -> customer.hasEmail())
.map(this::toCustomerDto)
.collect(Collectors.toList());
}
private CustomerDto toCustomerDto(Customer customer) {
return CustomerDto.builder()
.id(customer.getId())
.name(customer.getName())
.email(customer.getEmail())
.build();
}개선 효과:
- 각 단계가 명확히 분리됨
- 메서드 참조로 간결함
- DTO 변환 로직 분리로 재사용 가능
- 읽으면 바로 이해됨
2. null 처리 - Optional
null 처리는 개발자의 영원한 숙제입니다. 실무에서 NullPointerException은 대부분 null 체크를 빠뜨려서 발생합니다.
Before: null 체크 지옥
public String getCustomerGrade(Long customerId) {
Customer customer = customerRepository.findById(customerId);
if (customer != null) {
Grade grade = customer.getGrade();
if (grade != null) {
String gradeCode = grade.getCode();
if (gradeCode != null) {
return gradeCode;
}
}
}
return "NORMAL"; // 기본값
}문제점:
- 3단계 중첩 null 체크
- 실수로 한 단계라도 빠뜨리면 NPE 발생
- 읽기 힘든 구조
After: Optional 활용
public String getCustomerGrade(Long customerId) {
return customerRepository.findById(customerId)
.map(Customer::getGrade)
.map(Grade::getCode)
.orElse("NORMAL");
}개선 효과:
- null 체크가 자동으로 처리됨
- 한눈에 흐름 파악 가능
- NPE 발생 가능성 원천 차단
3. 조건 분기 - Predicate와 Function
실무에서는 복잡한 조건 분기가 많습니다. 특히 비즈니스 로직이 복잡할수록 if-else가 늘어납니다.
Before: if-else 폭탄
public BigDecimal calculateDiscount(Customer customer, BigDecimal amount) {
if (customer.getGrade().equals("VIP")) {
if (amount.compareTo(new BigDecimal("100000")) >= 0) {
return amount.multiply(new BigDecimal("0.15")); // 15% 할인
} else if (amount.compareTo(new BigDecimal("50000")) >= 0) {
return amount.multiply(new BigDecimal("0.10")); // 10% 할인
} else {
return amount.multiply(new BigDecimal("0.05")); // 5% 할인
}
} else if (customer.getGrade().equals("GOLD")) {
if (amount.compareTo(new BigDecimal("100000")) >= 0) {
return amount.multiply(new BigDecimal("0.10"));
} else {
return amount.multiply(new BigDecimal("0.05"));
}
} else {
return amount.multiply(new BigDecimal("0.03")); // 일반 회원 3% 할인
}
}문제점:
- 중첩 if-else로 가독성 최악
- 할인율 로직이 하드코딩
- 새로운 등급이나 조건 추가 시 메서드 전체 수정
After: 함수형 전략 패턴
public class DiscountCalculator {
private static final Map<String, Function<BigDecimal, BigDecimal>> DISCOUNT_POLICIES = Map.of(
"VIP", DiscountCalculator::calculateVipDiscount,
"GOLD", DiscountCalculator::calculateGoldDiscount,
"SILVER", DiscountCalculator::calculateSilverDiscount
);
public BigDecimal calculateDiscount(Customer customer, BigDecimal amount) {
return DISCOUNT_POLICIES
.getOrDefault(customer.getGrade(), DiscountCalculator::calculateNormalDiscount)
.apply(amount);
}
private static BigDecimal calculateVipDiscount(BigDecimal amount) {
return Stream.of(
new DiscountRule(amount -> amount.compareTo(new BigDecimal("100000")) >= 0, 0.15),
new DiscountRule(amount -> amount.compareTo(new BigDecimal("50000")) >= 0, 0.10),
new DiscountRule(amount -> true, 0.05)
)
.filter(rule -> rule.getPredicate().test(amount))
.findFirst()
.map(rule -> amount.multiply(BigDecimal.valueOf(rule.getDiscountRate())))
.orElse(BigDecimal.ZERO);
}
private static BigDecimal calculateGoldDiscount(BigDecimal amount) {
return amount.compareTo(new BigDecimal("100000")) >= 0
? amount.multiply(new BigDecimal("0.10"))
: amount.multiply(new BigDecimal("0.05"));
}
private static BigDecimal calculateSilverDiscount(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.03"));
}
private static BigDecimal calculateNormalDiscount(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.03"));
}
}
@Getter
@AllArgsConstructor
class DiscountRule {
private final Predicate<BigDecimal> predicate;
private final double discountRate;
}개선 효과:
- 등급별 할인 정책이 독립적인 함수로 분리
- 새로운 등급 추가 시 Map에만 추가하면 됨
- 각 등급별 할인 규칙이 명확히 보임
- 테스트 코드 작성이 쉬워짐
실무에서 마주한 함수형의 Trade-off
함수형 프로그래밍이 만능은 아닙니다. 실무에서 겪은 Trade-off를 공유합니다.
1. 성능 이슈
문제 상황
초기에 모든 컬렉션 처리를 Stream으로 전환했습니다. 그런데 성능 테스트를 해보니…
// Stream 방식
List<Integer> result = IntStream.range(0, 100)
.boxed()
.collect(Collectors.toList());
// 일반 for 방식
List<Integer> result = new ArrayList<>(100);
for (int i = 0; i < 100; i++) {
result.add(i);
}벤치마크 결과:
- for문: 약 50ns
- Stream: 약 200ns
Stream이 4배 느렸습니다.
내린 결론
데이터 크기에 따라 선택:
- 소량 데이터 (100개 이하): for문 사용
- 성능 차이가 미미하지만, 간단한 로직은 for문이 더 직관적
- 중간 데이터 (100~10,000개): Stream 사용
- 가독성과 유지보수성이 더 중요
- 성능 차이가 체감되지 않음
- 대량 데이터 (10,000개 이상): parallelStream 고려
- 병렬 처리로 성능 향상 가능
- 단, CPU 코어 수와 작업 특성을 고려해야 함
2. 디버깅의 어려움
문제 상황
운영 중에 버그가 발생했습니다. 고객 데이터 처리 중 특정 케이스에서 예외가 발생한다는 리포트였습니다.
public List<OrderSummary> getOrderSummaries(List<Customer> customers) {
return customers.stream()
.filter(Customer::isActive)
.flatMap(customer -> customer.getOrders().stream())
.filter(order -> order.isCompleted())
.map(this::toOrderSummary)
.collect(Collectors.toList());
}예외 로그:
NullPointerException at toOrderSummary문제는 어느 단계에서 null이 발생했는지 알기 어렵다는 것이었습니다.
해결 방법
디버깅용 peek() 메서드 활용:
public List<OrderSummary> getOrderSummaries(List<Customer> customers) {
return customers.stream()
.peek(customer -> log.debug("Processing customer: {}", customer.getId()))
.filter(Customer::isActive)
.peek(customer -> log.debug("Active customer: {}", customer.getId()))
.flatMap(customer -> customer.getOrders().stream())
.peek(order -> log.debug("Processing order: {}", order.getId()))
.filter(order -> order.isCompleted())
.peek(order -> log.debug("Completed order: {}", order.getId()))
.map(this::toOrderSummary)
.collect(Collectors.toList());
}운영 환경에서는 peek()을 제거하고, 개발 환경에서만 활성화하도록 프로파일 설정을 활용했습니다.
3. 팀원들의 학습 곡선
문제 상황
제가 작성한 함수형 코드를 주니어 개발자가 수정해야 하는 상황이 생겼습니다.
return orders.stream()
.collect(Collectors.groupingBy(
Order::getCustomerId,
Collectors.mapping(
Order::getTotalAmount,
Collectors.reducing(BigDecimal.ZERO, BigDecimal::add)
)
));“이게 무슨 의미인지 모르겠어요…”
팀 전체가 함수형 프로그래밍에 익숙하지 않으면 오히려 생산성이 떨어집니다.
해결 방법
점진적 도입 전략:
-
1단계: 간단한 filter, map만 사용
customers.stream() .filter(Customer::isActive) .map(Customer::getName) .collect(Collectors.toList()); -
2단계: flatMap, reduce 도입
customers.stream() .flatMap(customer -> customer.getOrders().stream()) .map(Order::getTotalAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); -
3단계: 복잡한 Collectors 활용
orders.stream() .collect(Collectors.groupingBy( Order::getStatus, Collectors.counting() ));
팀 스터디를 주 1회씩 진행하면서 단계별로 도입했고, 약 3개월 후에는 팀 전체가 함수형 코드를 자연스럽게 작성하게 됐습니다.
실무 적용 가이드
언제 함수형을 쓰면 좋을까?
실무에서 정리한 기준입니다.
함수형이 적합한 경우
-
컬렉션 데이터 변환/필터링
return customers.stream() .filter(Customer::isActive) .map(Customer::getName) .collect(Collectors.toList()); -
복잡한 비즈니스 로직의 파이프라인
return orders.stream() .filter(this::isEligibleForDiscount) .map(this::applyDiscount) .map(this::calculateTax) .collect(Collectors.toList()); -
null 안전성이 중요한 경우
return Optional.ofNullable(customer) .map(Customer::getAddress) .map(Address::getZipCode) .orElse("00000");
함수형이 과하거나 부적합한 경우
-
단순 루프 (100개 이하 데이터)
// 과하다 IntStream.range(0, 10) .forEach(i -> System.out.println(i)); // 이게 낫다 for (int i = 0; i < 10; i++) { System.out.println(i); } -
상태를 변경해야 하는 경우
// 부수 효과 발생 customers.stream() .forEach(customer -> customer.setStatus("INACTIVE")); // 명시적 for문이 낫다 for (Customer customer : customers) { customer.setStatus("INACTIVE"); } -
조기 종료(break)가 필요한 경우
// 복잡해진다 customers.stream() .filter(customer -> { if (customer.isVip()) { return true; } return false; }) .findFirst(); // 명시적 break가 낫다 for (Customer customer : customers) { if (customer.isVip()) { return customer; } }
함수형 코드 작성 팁
실무에서 정리한 Best Practice입니다.
1. 메서드 참조를 적극 활용
// 람다 표현식
customers.stream()
.filter(c -> c.isActive())
.map(c -> c.getName())
.collect(Collectors.toList());
// 메서드 참조 (더 간결)
customers.stream()
.filter(Customer::isActive)
.map(Customer::getName)
.collect(Collectors.toList());메서드 참조가 더 간결하고 읽기 쉽습니다.
2. 복잡한 람다는 별도 메서드로 추출
// 복잡한 람다
customers.stream()
.filter(customer -> {
LocalDate now = LocalDate.now();
return customer.getCreatedDate().isAfter(now.minusMonths(1))
&& customer.getPurchaseCount() >= 3
&& customer.getTotalAmount().compareTo(new BigDecimal("100000")) >= 0;
})
.collect(Collectors.toList());
// 메서드로 추출하면 의도가 명확해진다
customers.stream()
.filter(this::isActiveRecentCustomer)
.collect(Collectors.toList());
private boolean isActiveRecentCustomer(Customer customer) {
LocalDate oneMonthAgo = LocalDate.now().minusMonths(1);
return customer.getCreatedDate().isAfter(oneMonthAgo)
&& customer.getPurchaseCount() >= 3
&& customer.getTotalAmount().compareTo(new BigDecimal("100000")) >= 0;
}복잡한 조건은 이름 있는 메서드로 추출하면 의도가 명확해집니다.
3. Stream 체이닝은 적당히
// 너무 긴 체이닝 - 한눈에 파악이 안 된다
return customers.stream()
.filter(Customer::isActive)
.filter(this::isVip)
.flatMap(customer -> customer.getOrders().stream())
.filter(Order::isCompleted)
.filter(order -> isCurrentMonth(order.getOrderDate()))
.map(Order::getItems)
.flatMap(List::stream)
.filter(item -> item.getPrice().compareTo(new BigDecimal("10000")) >= 0)
.map(OrderItem::getProductId)
.distinct()
.collect(Collectors.toList());
// 단계별로 분리하면 가독성이 좋아진다
List<Customer> activeVipCustomers = customers.stream()
.filter(Customer::isActive)
.filter(this::isVip)
.collect(Collectors.toList());
List<Order> currentMonthOrders = getCompletedOrdersInCurrentMonth(activeVipCustomers);
return getHighValueProductIds(currentMonthOrders);한 번에 너무 많은 일을 하지 말고, 단계별로 분리하면 가독성이 향상됩니다.
4. Optional은 반환 타입으로만
// Optional을 파라미터로 받지 마라
public void updateCustomer(Optional<Customer> customer) {
customer.ifPresent(c -> ...);
}
// null을 허용하거나, @Nullable 사용
public void updateCustomer(@Nullable Customer customer) {
if (customer != null) {
...
}
}
// Optional은 반환 타입으로만 사용
public Optional<Customer> findCustomer(Long id) {
return Optional.ofNullable(customerRepository.findById(id));
}Optional은 반환 타입으로만 사용하는 것이 Best Practice입니다.
결론
함수형 프로그래밍을 도입한 지 1년이 지났습니다.
코드 리뷰 시간이 30% 정도 줄었습니다. 함수형 코드는 의도가 명확해서 리뷰어가 빠르게 이해할 수 있었거든요. 버그 발생률도 눈에 띄게 감소했습니다. 불변성을 유지하니 side effect로 인한 버그가 줄었고, 특히 NPE는 거의 사라졌습니다.
가장 체감되는 건 병렬 처리 도입이 쉬워진 점입니다. 성능 이슈가 생겼을 때 stream()을 parallelStream()으로만 바꿔도 개선되는 경우가 많았습니다.
함수형 프로그래밍은 처음엔 어색합니다. 저도 그랬습니다. 하지만 한 가지씩 익혀가다 보면, 어느 순간 명령형 코드가 더 어색하게 느껴지는 순간이 옵니다.
작은 것부터 시작하세요. 단순 for문을 Stream으로 바꿔보고, null 체크를 Optional로 바꿔보고, if-else를 Map과 Function으로 바꿔보는 겁니다.
그리고 무엇보다 Trade-off를 고려하세요. 함수형이 항상 정답은 아닙니다. 상황에 맞게 명령형과 함수형을 적절히 섞어 쓰는 것이 진짜 실력입니 다.
참고 :
Java Stream API 공식 문서
Effective Java 3/E - Item 42~48 (람다와 스트림)
함수형 자바 프로그래밍 (Functional Programming in Java)
