[강의] Java 8 주요 문법

Jerry·2025년 7월 14일

Topic

Lambda expression
Functional interface
Method reference
Stream
Optional

What I Learned

Java 8

개요

Java 8은 자바에 함수형 프로그래밍을 본격 도입하며, API 및 문법이 크게 확장된 버전입니다.

주요 변경점

  1. 람다식(Lambda Expressions)
    • 함수형 프로그래밍 스타일
    • 익명 함수 형태로 코드 간결화
    • 스트림 처리에 용이
  2. 메서드 참조(::)
    • 기존 메소드를 간결하게 참조
    • ClassName::methodName
  3. 함수형 인터페이스(Functional interface)
    • 단일 추상 메소드(SAM) 인터페이스
    • @FunctionalInterface 지원
    • 주요 인터페이스: Function, Predicate, Consumer, Supplier 등 (java.util.function 패키지)
  4. Stream API
    • 컬렉션을 함수형으로 처리하는 API
    • 주요 메소드: map, filter, collect
  5. Optional 클래스
    • 값의 존재/부재를 명시적으로 표현
    • Optional.of, Optional.ofNullable, orElse
  6. 기본형 스트림
    • 박싱/언박싱 오버헤드 없는 기본 타입 전용 스트림
    • IntStream, LongStream, DoubleStream
  7. 인터페이스 Default & Static 메소드
    • 인터페이스에 구현 메소드(default, static) 작성 가능
  8. java.time 패키지
    • 불변 객체 기반의 날짜/시간 API
    • 주요 클래스: LocalDate, LocalDateTime, Duration
  9. Repeatable Annotations
    • 동일 어노테이션을 여러 번 적용 가능

람다식(Lambda expression)

익명 클래스(Anonymous Class)

  • 클래스 선언과 객체 생성을 동시에 수행하는 일회성 클래스 표현 방식
  • 주로 인터페이스를 구현하거나 추상 클래스를 오버라이딩할 때 코드 간결화를 위해 사용
  • Java에서는 함수가 존재하지 않아 익명 클래스와 메소드 형태로 함수 형태를 구현해야함
// 인터페이스 구현 (Runnable)
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println("익명 클래스의 run 메소드 실행");
    }
};

Thread thread = new Thread(runnable);
thread.start();


// 추상 클래스 상속
abstract class Animal {
    abstract void sound();
}

Animal dog = new Animal() {
    @Override
    void sound() {
        System.out.println("멍멍!");
    }
};

dog.sound(); // "멍멍!" 출력


// 버튼 이벤트 처리(GUI)
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println("버튼 클릭!");
    }
});

람다식(lambda expression)

  • 익명 함수를 간결하게 표현하는 익명 객체
  • 함수형 인터페이스(추상 메소드가 1개인 인터페이스)의 구현체를 매우 간단히 표현할 수 있음

문법

// 메소드의 이름과 반환 타입을 제거하고 '->'를 블록{} 앞에 추가
(int a, int b) -> {
	return a > b ? a : b;
}

// 반환 값이 있는 경우 식이나 값만 적고 return문 생략 가능(세미콜론을 안 붙임)
(int a, int b) -> a > b ? a : b

// 매개변수 타입이 추론 가능하면 생략 가능
(a, b) -> a > b ? a : b

// 매개변수가 하나인 경우 괄호 생략 가능(타입 생략시에만)
a -> a * a

// 블록 안의 문장이 하나뿐일 때 괄호 생략 가능(세미콜론을 안 붙임)
(int a) -> System.out.println(a)

// 하나뿐인 문장이 return문이면 괄호 생략 불가(return도 생략하면 가능)
(int a, int b) -> { return a > b ? a : b; }

타입 추론

컴파일러가 컨텍스트에서 추론할 수 있는 경우 타입 생략 가능

특징

  • 함수형 인터페이스 전용 (예: Runnable, Comparator, Function, Predicate 등)
  • 이름 없음 (익명)
  • 불변(Stateless)이 기본 (상태 X)
  • 지역 변수 캡처: 람다 바깥의 지역 변수는 final이거나 사실상 final(effectively final)이어야 사용 가능
  • 내부적으로 익명 클래스와 다름 (스택/힙, this 처리 등 차이)

람다식 vs 익명 클래스

실체화(동작 원리)

  1. 람다식
    • invokedynamic 명령어와 런타임에 생성되는 팩토리 메소드 활용
    • 필요할 때만 람다 객체가 만들어짐 (바이트코드 최적화)
  2. 익명 클래스
    • 컴파일 시점에 .class 파일로 명확하게 생성됨

this

  1. 람다식
    • this는 람다가 선언된 바깥 클래스의 인스턴스를 가리킴
  2. 익명 클래스
    • this는 익명 클래스 자신을 가리킴
class Outer {
    void test() {
        Runnable r1 = () -> {
            System.out.println(this); // Outer의 this
        };

        Runnable r2 = new Runnable() {
            @Override
            public void run() {
                System.out.println(this); // 익명 클래스의 this
            }
        };

        r1.run(); // Outer@xxx 출력
        r2.run(); // Outer$1@yyy (익명클래스) 출력
    }
}

스택/힙 영역, 생성 방식

  • 람다는 불필요한 익명 객체 생성을 줄여주며, 더 가볍고 효율적임
  • 람다는 메소드 참조와 함께 최적화되어, 자주 재사용됨
  • 익명 클래스는 매번 새로운 인스턴스가 생성됨

@FunctionalInterface

  • 단 하나의 추상 메소드만 갖는, 함수형 인터페이스임을 명시적으로 선언하는 어노테이션
  • 람다식이나 메소드 참조(::)와 함께 사용할 수 있으며, 컴파일 타임에 함수형 인터페이스 규칙 위반을 검출
  • 만일 @FunctionalInterface가 없더라도 단 하나의 추상 메소드만 가지면 람다식으로 활용은 가능 (예: Comparator)

메소드 참조(::)

개념

  • Java 8에서 도입된, 람다식을 더 간결하게 표현할 수 있는 문법
  • 이미 존재하는 메소드(정적/인스턴스/생성자)를 람다식 대신 간단히 “참조”하여 사용할 수 있게 해주는 문법
  • 람다와 동일하게 함수형 인터페이스 구현에 사용

종류

  1. 정적 메소드 참조

    public class Utils {
        public static void printUpper(String s) {
            System.out.println(s.toUpperCase());
        }
    }
    
    List<String> list = List.of("a", "b", "c");
    
    // 람다
    list.forEach(s -> Utils.printUpper(s));
    
    // 메서드 참조
    list.forEach(Utils::printUpper);
  2. 특정 객체의 인스턴스 메서드 참조

    Printer printer = new Printer();
    
    list.forEach(printer::print);
    
    class Printer {
        public void print(String s) {
            System.out.println("출력: " + s);
        }
    }
  3. 임의 객체의 인스턴스 메소드 참조

    List<String> list = List.of("spring", "boot", "java");
    
    // 정렬 기준: 소문자 알파벳 순
    list.sort(String::compareToIgnoreCase);

    내부적으로는 이렇게 동작:

    (a, b) -> a.compareToIgnoreCase(b)
  4. 생성자 참조

    Supplier<List<String>> s = ArrayList::new;
    // () -> new ArrayList<>()와 동일
    
    Function<String, Integer> f = Integer::new;
    // (s) -> new Integer(s)와 동일 (Deprecated됨에 주의)

표준 함수형 인터페이스

개념

  • java.util.function 패키지의 기본 인터페이스 모음
  • 람다식 또는 메소드 참조를 사용할 수 있도록 입력과 출력 형태에 따라 정의되어 있음

종류

  • java.lang.Runnable: void run() 매개변수도 없고 반환 값도 없음
  • Supplier<T>: T get() 매개변수는 없고 반환 값만 있음
  • Consumer<T>: void accept(T t) 매개변수만 있고 반환 값이 없음
  • Function<T, R>: R apply(T t) 하나의 매개변수를 받아 반환 값을 반환
  • Predicate<T>: boolean test(T t) 조건식을 표현하는데 사용
  • UnaryOperator<T>: T apply(T t) Function의 자손으로 매개변수와 반환 값의 타입이 같음
  • BinaryOperator<T>: T apply(T t, T t) BiFunction의 자손으로 매개변수와 반환 값의 타입이 같음

Consumer 함수형 인터페이스

  • 소비자(Consumer) 함수형 인터페이스는 입력 값을 받아서 소비하지만 반환 값이 없는 함수형 인터페이스
  • 주로 출력, 로깅, 외부 상태 변경과 같은 side effect를 수행할 때 사용됨
default void foEach(Consumer<? super T> action)

List<String> names = List.of("Alice", "Bob");
names.forEach(name -> System.out.println(name));

Supplier 함수형 인터페이스

  • 생산자(Supplier) 함수형 인터페이스는 입력 매개값 없이 결과값만 반환하는 함수형 인터페이스
  • 주로 값 생성, 지연 초기화 등에 사용됨
Random random = new Random();
IntSupplier intSupplier = () -> random.nextInt(100) + 1;
IntStream.generate(intSupplier).limit(5).forEach(System.out::println);

Predicate 함수형 인터페이스

  • 하나의 입력 값을 받아 조건을 검사하고, 그 결과를 반환하는 함수형 인터페이스
  • 주로 필터링, 유효성 검사, 조건 분기에 활용됨
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> isGreaterThan10 = n -> n > 10;
Stream<Integer> stream = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).stream();
stream.filter(isEven).filter(isGreaterThan10).forEach(System.out::println);

Function 함수형 인터페이스

  • 입력값 T를 받아 반환 값 R을 반환하는 순수 함수형 인터페이스
  • 대표 메소드는 apply(T t)로, 주로 map()과 같은 변환 작업에 사용됨
Function<String, Integer> strLength = s -> s.length();
int len = strLength.apply("Hello");

Operator 함수형 인터페이스

  • 입력값과 반환 값의 타입이 동일한 함수형 인터페이스를 정의할 때 사용되는 인터페이스
  • UnaryOperator<T>는 하나의 입력값을, BinaryOperator<T>는 두 개의 동일 타입 입력값을 인수로 가짐
UnaryOperator<Integer> square = x -> x * x;
BinaryOperator<Integer> sum = (a, b) -> a + b;

Predicate 체인 (필터 체이닝)

  • Predicate 체인은 여러 조건을 and(), or(), negate() 메소드로 조합하여 복합 조건을 구성하는 방식
Predicate<String> startsWithA = s -> s.startsWith("a");
predicate<String> lengthIs3 = s -> s.length() == 3;
Predicate<?> combined = startsWithA.and(lengthIs3);

Function 체이닝

  • Function 체이닝은 andThen() 또는 compose()를 이용하여 여러 함수를 순차적으로 연결 실행하는 방식입니다.
  • 함수의 변환 값이 여러 개인 경우 활용할 수 있는 문법입니다.

스트림(Stream)

개념

  • 데이터 소스를 추상화하여, 데이터의 흐름(파이프라인)으로 처리하는 기능.
  • 컬렉션(리스트, 셋, 맵 등), 배열, 파일 등에서 데이터를 하나씩 꺼내 연속적으로 처리할 수 있도록 해줌
  • 함수형 프로그래밍 스타일을 자바에서 구현할 수 있게 해줌
  • 병렬 스트림(Parallel Stream)을 지원하며, 데이터를 멀티스레드로 병렬 처리하여 성능을 높이는 스트림을 제공 (Kafka 계열이나 간단한 데이터 처리에는 활용됨)

스트림 파이프라인(Stream Pipeline)

  • 스트림 파이프라인은 스트림 소스 -> 중간 연산 -> 최종 연산으로 구성된 데이터 처리 흐름입니다.
  • 각 단계는 연결된 연산 체인으로 구성되어, 지연 평가 방식으로 최종 연산 시 실행됩니다.

스트림 메소드

  • 중간 연산(Intermediate): 연산 결과가 스트림인 연산으로 반복적으로 적용 가능합니다.
  • 최종 연산(Terminal): 연산 결과가 스트림이 아닌 연산으로 스트림의 요소를 소모하므로 한 번만 적용 가능합니다.

문법적 특징

// 스트림은 데이터 소스로부터 데이터를 읽기만할 뿐 변경하지 않습니다.
List<Integer> list = Arrays.asList(3, 1, 5, 4, 2);
List<Integer> sortedList = list.stream().sorted() // list를 정렬해서
		.collect(Collectors.toList()); // 새로운 List에 저장
System.out.println(list);		// [3, 1, 5, 4, 2]
System.out.println(sortedList);	// [1, 2, 3, 4, 5]

// 스트림은 Iterator처럼 일회용입니다.
strStream.forEach(System.out::println);	// 모든 요소를 화면에 출력(최종 연산)
int numOfStr = strStream.count();		// 스트림이 이미 닫혀서 에러

// 최종 연산 전까지 중간 연산이 수행되지 않습니다.(지연된 연산 - lazy evaluation)
IntStream intSteram = new Random().ints(1,46);	// 1~45 범위의 무한 스트림
intStream.distinct().limit(6).sorted()			// 중간 연산
		.forEach(i->System.out.print(i+","));	// 최종 연산

// 스트림은 작업을 내부 반복으로 처리합니다.
for (String str : strList) {
	System.out.println(str); // -> stream.forEach(System.out::println);
}

void forEach(Consumer<? super T> action) {
	Objects.requireNonNull(action);	// 매개변수의 널 체크
    
    for (T t : src) { // 내부 반복(for 문을 메소드 안으로 넣음)
    	action.accept(T);
    }
}

스트림 메소드 표

스트림 통계 처리(Statistics)

  • Java Stream API에서는 기본형 특화 스트림 (IntStream, LongStream, DoubleStream)을 통해 통계 처리가 용이하게 제공됨.
  • 이를 통해 간단한 통계처리는 알고리즘 없이 처리 가능

병렬 스트림(Parallel Stream)

  • 병렬 스트림은 요소들을 멀티스레드로 분할 실행하여 성능을 높이는 처리 방식
  • 내부적으로 Fork/Join 프레임워크를 활용하여 데이터를 병렬로 처리하며, 사용자의 별도의 제어 없이 간단히 사용할 수 있음
Stream<String> strStream = Stream.of("dd", "aaa", "CC", "cc", "b");
int sum = strStream.parallelStream()
		.mapToInt(s -> s.length()).sum();

기본형 스트림

  1. 일반 Stream 사용 시
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
int sum = list.stream()
              .map(i -> i * 2) 
              .reduce(0, Integer::sum);
  • .map(i -> i * 2)
    1. 스트림 요소(Integer) map에 전달
    2. 람다 인자 i를 int로 언박싱
      • i(Integer)가 i * 2 연산을 위해 int로 자동 언박싱
      • 내부적으로: i.intValue() * 2
    3. 곱셈 연산 수행
      • 언박싱된 int 값에 대해 곱셈 수행
    4. 연산 결과(int)를 다시 Integer로 박싱
      - map의 리턴 타입이 Stream<Integer>이기 때문에
      연산 결과 int를 다시 Integer로 오토박싱
    5. 다음 스트림 요소에 대해 반복
      • 모든 요소에 대해 1~4단계 반복
  • .reduce(0, Integer::sum)
    1. identity (int 0) → 박싱되어 Integer 0
    2. 다음 스트림 요소 (Integer 2) → accumulator 호출
    3. accumulator에 (Integer 0, Integer 2) 전달
    4. 둘 다 int로 언박싱 (0 + 2)
    5. 합산 결과 int 2 → 다시 박싱되어 Integer 2
    6. 다음 스트림 요소에 대해 반복
      • 모든 요소에 대해 2~5단계 반복
  • GC(가비지 컬렉션) 부하 증가, 성능 저하
  1. 기본형 스트림 사용 시
int sum = IntStream.of(1, 2, 3, 4, 5)
                   .map(i -> i * 2) // int 연산
                   .sum();
  • 모든 연산이 int 타입으로 처리
  • 메모리 및 성능 모두 효율적

옵셔널(Optional)

개념

  • null 값을 안전하게 처리하기 위해 등장한 컨테이너 클래스
  • 값이 없을 수도 있는 상황에서 명시적으로 null 여부를 다룰 수 있도록 도와주는 기능으로, NullPointerException을 방지하기 위해 활용됨
    • NPE(NullPointerException) null 값을 참조하려 할 때 발생하는 예외
  • Optional<T>, OptionalInt, OptionalLong, OptionalDouble

메소드

생성 메소드

  • Optional.of(T value) 절대 null이 아님을 보장할 때 (null이면 예외 발생)
  • Optional.ofNullable(T value) value가 null일 수도 있을 때
  • Optional.empty() 비어있는 Optional, null이 저장됨

장점

  • NPE(NullPointerException) 예방: 값 부재를 명시적으로 다룸
  • 코드 가독성 향상: 값이 optional임을 한눈에 알 수 있음
  • Optional 체이닝(map/flatMap 등)으로 깔끔한 함수형 스타일 처리 가능

주의점 및 한계

  • Optional은 주로 반환 타입에만 사용 (필드, 파라미터엔 권장 X)
  • Optional 필드, Optional 파라미터, 컬렉션의 Optional 요소 등은 오히려 복잡성만 증가시킴
  • 성능 이슈: Optional은 객체이므로, 기본형 값을 감쌀 땐 박싱 오버헤드가 생김
  • get() 남용 금지: 값이 없을 때 예외. 항상 orElse/ifPresent 등으로 안전하게 처리해야 함
  • Java 8까지는 Serializable이 아님 (Java 9부터 Serializable)

Optional<T>과 OptionalInt

// 'T'타입 객체의 래퍼 클래스 - Optional<T>
String str = "abc";
Optional<String> optVal = Optional.of(str);
Optional<String> optVal = Optional.of("abc");
Optional<String> optVal = Optional.of(null);		// NullPointerException 발생
Optional<String> optVal = Optional.ofNullable(null)	// OK

// Optional 객체의 값 가져오기 - get(), orElse(), orElseGet(), orElseThrow()
Optional<String> optVal = Optional.of("abc");
// T get()
String str1 = optVal.get(); // optVal에 저장된 값을 반환, null이면 예외 발생
// T orElse(T other)
String str2 = optVal.orElse(""); // optVal에 저장된 값이 null일 때는 ""를 반환
// T orElseGet(Supplier<? extends T> other)
String str3 = optVal.orElseGet(String::new) // 람다식 사용 가능 () -> new String()
// T orElseThrow(Supplier<? extends X> exceptionSupplier)
String str4 = optVal.orElseThrow(NullPointerException::new); // null이면 예외 발생
profile
Backend engineer

0개의 댓글