Java 프로그래밍 패러다임
프로그래밍 패러다임의 질문 차이를 이해하고 Study Log 통계를 절차, 객체, 함수형 파이프라인으로 구현해 선택 기준을 세웁니다.
프로그래밍 패러다임은 문법 목록이 아니라 문제를 설명하는 관점입니다. 명령형은 실행 순서와 상태 변경이라는 어떻게를, 선언형은 원하는 관계와 결과라는 무엇을을 앞에 둡니다. 절차지향은 명령을 함수 단위로 나누고 객체지향은 데이터와 불변식을 객체에 묶습니다. 함수형 스타일은 값 변환과 합성을 강조합니다. Java는 한 가지만 강제하지 않으므로 경계마다 가장 읽기 좋은 관점을 조합할 수 있습니다.
재사용한 가변 누산기에 이전 실행이 남는 정보
아래 보고서 객체는 호출마다 합계를 새로 계산하는 것처럼 보이지만 필드를 초기화하지 않습니다.
같은 입력을 두 번 처리하면 출력은 wrong-second-total=24가 됩니다.
계산 결과가 호출 이력에 의존해 독립적인 함수로 읽을 수 없습니다.
import java.util.List;
public final class ReusedMutableAccumulatorBug {
private static final class StudyReport {
private int totalMinutes;
int total(List<Integer> minutes) {
for (int value : minutes) {
totalMinutes += value;
}
return totalMinutes;
}
}
public static void main(String[] args) {
StudyReport report = new StudyReport();
List<Integer> minutes = List.of(3, 4, 5);
System.out.println("first-total=" + report.total(minutes));
System.out.println("wrong-second-total=" + report.total(minutes));
}
}원칙은 계산 중간 상태를 가능한 한 메서드 지역으로 두고 결과를 반환하는 것입니다. 객체 필드는 장기 식별 정보와 불변식을 표현할 때 사용하고, 매 실행의 임시 누산기를 숨기는 곳으로 쓰지 않습니다. 같은 입력이 같은 결과를 만드는 성질은 호출 순서에 따른 버그를 줄입니다.
명령형 구현과 제어 흐름
학습 기록에서 완료된 세션의 총 학습 시간을 구할 때 반복문은 필터링과 누산 순서를 명확히 제어합니다. 조기 종료, 여러 가변 산출물, 세밀한 예외 복구가 핵심이라면 명령형 코드가 더 읽기 쉬울 수 있습니다.
import java.util.List;
public final class ImperativeStudyMinutes {
private record Session(String topic, int minutes, boolean completed) {}
static int completedMinutes(List<Session> sessions) {
int total = 0;
for (Session session : sessions) {
if (!session.completed()) {
continue;
}
total += session.minutes();
}
return total;
}
public static void main(String[] args) {
List<Session> sessions =
List.of(
new Session("stream", 35, true),
new Session("fork-join", 45, false),
new Session("optional", 25, true));
System.out.println("completed-minutes=" + completedMinutes(sessions));
}
}이 구현은 나쁜 함수형 코드의 반대말이 아닙니다.
completedMinutes는 외부 상태를 바꾸지 않고 지역 변수만 변경하므로 호출자 관점에서는 순수한 계산입니다.
함수 안에 대입이 있다고 자동으로 비순수가 되는 것은 아닙니다.
객체지향의 상태·규칙 소유
Session의 학습 시간은 양수여야 하고 완료한 세션만 total에 포함된다는 규칙을 값 객체와 컬렉션 객체에 둘 수 있습니다.
객체지향의 장점은 계산을 메서드로 옮기는 데 그치지 않고 잘못된 상태를 생성 시점에 막는 것입니다.
import java.util.List;
public final class ObjectOrientedStudyLog {
private record Session(String topic, int minutes, boolean completed) {
private Session {
if (topic.isBlank()) {
throw new IllegalArgumentException("topic");
}
if (minutes <= 0) {
throw new IllegalArgumentException("minutes");
}
}
int completedMinutes() {
return completed ? minutes : 0;
}
}
private record StudyLog(List<Session> sessions) {
private StudyLog {
sessions = List.copyOf(sessions);
}
int completedMinutes() {
int total = 0;
for (Session session : sessions) {
total += session.completedMinutes();
}
return total;
}
}
public static void main(String[] args) {
StudyLog log =
new StudyLog(
List.of(
new Session("lambda", 30, true),
new Session("collector", 40, false),
new Session("record", 20, true)));
System.out.println("completed-minutes=" + log.completedMinutes());
}
}객체를 지나치게 쪼개 모든 한 줄 계산을 클래스로 감싸면 코드를 따라가는 비용이 늘어납니다. 장기 상태, 생명 주기, 다형적 정책이 있는지 확인하고 단순한 데이터 변환은 함수로 남길 수 있습니다.
선언형 파이프라인은 변환 관계를 전면에 배치
Stream 버전은 순회 방법보다 완료만 선택 → 학습 시간으로 변환 → 합계라는 데이터 흐름을 보여 줍니다.
파이프라인 단계가 도메인 문장과 맞을 때 효과적입니다.
import java.util.List;
public final class DeclarativeStudyMinutes {
private record Session(String topic, int minutes, boolean completed) {}
static int completedMinutes(List<Session> sessions) {
return sessions.stream().filter(Session::completed).mapToInt(Session::minutes).sum();
}
public static void main(String[] args) {
List<Session> sessions =
List.of(
new Session("pure-function", 50, true),
new Session("side-effect", 20, true),
new Session("composition", 30, false));
System.out.println("completed-minutes=" + completedMinutes(sessions));
}
}Java의 다중 패러다임
클래스와 인터페이스로 도메인 경계를 만들고, record로 불변 값을 표현하고, Stream과 람다로 내부 컬렉션 계산을 작성할 수 있습니다.
자원 close와 재시도 반복문은 명령형으로 제어합니다.
한 메서드 안에서 방식을 무작위로 섞지 말고 경계별 책임에 맞춥니다.
- 엔터티와 집계는 불변식과 소유권을 책임집니다.
- 순수 함수는 결정 규칙과 값 변환을 책임집니다.
Stream은 유한 컬렉션 파이프라인을 표현합니다.- 명령형 블록은 자원, 트랜잭션, 조기
return, 복잡한 실패 복구를 표현합니다. - 부수 효과는 애플리케이션 경계에 모아 순서와 재시도를 드러냅니다.
선택 기준
단순히 반복문을 Stream으로 바꾸는 것이 함수형 설계는 아닙니다.
Stream 콜백에서 외부 목록에 add하면 제어 흐름만 숨긴 명령형 코드가 됩니다.
반대로 지역 변수를 쓰는 반복문도 외부에서 관찰 가능한 변경이 없다면 예측 가능한 계산입니다.
- 요구가 값의 선택·변환·집계로 읽히면 선언형 파이프라인을 검토합니다.
- 단계별 중단·복구와 여러 부수 효과가 핵심이면 명령형 흐름을 유지합니다.
- 상태에 생성·변경 규칙과 식별 정보가 있으면 객체가 소유자가 됩니다.
- 재사용할 결정은 입력과 출력을 명시한 함수로 추출합니다.
함수형 스타일을 쓰려면 모든 객체와 반복문을 없애야 하나요?
아닙니다. Java 애플리케이션은 객체로 도메인과 의존성을 구성하고 함수형 방식으로 내부 계산을 단순화할 수 있습니다. 불변 값 객체와 순수 메서드는 객체지향과 함수형 접근이 만나는 지점입니다. 억지 변환보다 효과·상태·자원 구분이 독자에게 보이는지가 중요합니다.
연습 문제
완료된 세션만 선택해 트랙별 학습 시간 합계를 반환하세요.
메서드 밖의 목록이나 Map을 변경하지 말고 결과 Map을 새로 만듭니다.
해설 보기
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class CompletedMinutesByTrackSolution {
private record Session(String track, int minutes, boolean completed) {}
static Map<String, Integer> summarize(List<Session> sessions) {
return sessions.stream()
.filter(Session::completed)
.collect(
Collectors.groupingBy(
Session::track, Collectors.summingInt(Session::minutes)));
}
public static void main(String[] args) {
List<Session> sessions =
List.of(
new Session("java", 30, true),
new Session("spring", 40, false),
new Session("java", 25, true),
new Session("database", 35, true));
System.out.println(summarize(sessions));
}
}종료 기준은 java=55, database=35이며 spring 키가 없는 것입니다.
모든 트랙 키가 필요하다면 하위 수집기 filtering이나 별도 시드 정책으로 스키마를 먼저 정합니다.