[코드스테이츠 백엔드 44기 SEB BE] 17일차

오태호·2023년 3월 8일
0

코드스테이츠

목록 보기
15/22
post-thumbnail

애너테이션(annotation)

  • 정보 전달을 목적으로 만들어진 문법 요소
  • 주석 vs 애너테이션
    • 정보를 전달하는 대상에서 차이점을 가진다
      • 주석 : 개발자, 즉 사람에게 정보를 전달
      • 에너테이션 : 다른 프로그램에게 정보를 전달

애너테이션이란?

  • 소스 코드가 컴파일되거나 실행될 때에 컴파일러 및 다른 프로그램에게 정보를 전달해주는 문법 요소
class Person implements Comparable<Person> {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return anme;
    }

    public int getAge() {
        return age;
    }

    @Override
    public int compareTo(Person o) {
        return 0;
    }
}
  • 위 코드에서 @Override가 애너테이션
    • 애너테이션은 @으로 시작하고, 클래스 / 인터페이스 / 필드 / 메서드 등에 붙여서 사용할 수 있다
    • @Override는 compareTo()가 추상 메서드를 구현하거나, 상위 클래스의 메서드를 오버라이딩한 메서드라는 것을 컴파일러에게 알려주는 역할

애너테이션의 종류

  • 애너테이션은 JDK가 기본적으로 제공하는 애너테이션도 있지만, 다른 프로그램에서 제공하는 애너테이션도 있다
  • JDK에서 기본적으로 제공하는 애너테이션은 2가지로 구분된다
    1. 표준 애너테이션
      • JDK에 내장되어 있는 일반적인 애너테이션
    2. 메타 애너테이션
      • 다른 애너테이션을 정의할 때 사용하는 애너테이션
  • 표준 애너테이션
    • @Override와 같이 다른 문법 요소에 붙여서 사용하는 일반적인 애너테이션
  • 메타 애너테이션
    • 애너테이션을 직접 정의해서 사용할 때에 사용하는 애너테이션
  • 사용자 정의 애너테이션
    • 사용자가 직접 정의해서 사용하는 애너테이션

표준 애너테이션

@Override

  • 메서드 앞에만 붙일 수 있는 애너테이션
  • 선언한 메서드가 상위 클래스의 메서드를 오버라이딩하거나 추상 메서드를 구현하는 메서드라는 것을 컴파일러에게 알려주는 역할을 수행한다
  • 컴파일러가 컴파일 과정에서 @Override를 발견하면, @Override가 붙은 메서드와 동일한 이름을 가진 메서드가 상위 클래스(인터페이스)에 존재하는지 검사한다
  • 만약 상위 클래스(인터페이스)에서 @Override가 붙어있는 메서드명과 동일한 이름의 메서드를 찾을 수 없다면 컴파일러가 에러를 발생시킨다
  • 오버라이딩 메서드와 동일한 이름의 메서드가 상위 클래스(인터페이스)에 존재하는지 확인하는 이유
    • 메서드를 오버라이딩하거나 구현할 때, 개발자의 실수로 인해 메서드 이름이 잘못 작성되는 경우가 발생한다
    • 이러한 경우, 컴파일 에러 없이 코드가 그대로 실행될 수 있어 실행 시에 런타임 에러가 발생할 것이며, 런타임 에러 발생 시에 어디에서 에러가 발생했는지 에러의 원일을 찾기 어렵다
    • @Override를 사용하여 오버라이딩 메서드라는 것을 컴파일러가 인지하고 상위 클래스(인터페이스)에 해당 메서드가 존재하는지 확인하면 위와 같은 상황을 방지할 수 있다

@Deprecated

  • 기존에 사용하던 기술이 다른 기술로 대체되어 기존 기술을 적용한 코드를 더이상 사용하지 않도록 유도하는 경우에 사용한다
    • 기존의 코드를 다른 코드와의 호환성 문제로 삭제하기는 곤한해 남겨두어야 하지만 더이상 사용하는 것을 권장하지 않을 때에 @Deprecated를 사용한다
class Old {
    @Deprecated
    private String oldField;

    @Deprecated
    String getOldField() {
        return oldField;
    }
}
  • 만약 @Deprecated 애너테이션이 붙은 것을 사용한다면 IDE에서 취소선이 뜨면서, 경고 메시지를 출력한다

@SuppressWarnings

  • 컴파일 경고 메시지가 나타나지 않도록 하는 것
  • 경고가 발생할 것이 충분히 예상됨에도 묵인해야 할 때 주로 사용한다
  • @SuppressWarnings 뒤에 괄호를 붙이고 그 안에 억제하고자 하는 경고메시지를 지정해줄 수 있다
애너테이션설명
@SuppressWarnings("all")모든 경로를 억제
@SuppressWarnings("deprecation")Deprecated 메서드를 사용한 경우에 발생하는 경로를 억제
@SuppressWarnings("fallthrough")switch문에서 break문이 없을 때 발생하는 경고를 억제
@SuppressWarnings("finally")finally와 관련된 경고를 억제
@SuppressWarnings("null")null과 관련된 경고를 억제
@SuppressWarnings("unchecked")검증되지 않은 연산자와 관련된 경고를 억제
@SuppressWarnings("unused")사용하지 않는 코드와 관련된 경고를 억제

@FunctionalInterface

  • 인터페이스를 선언할 때, 컴파일러가 함수형 인터페이스의 선언이 바르게 선언되었는지를 확인하도록 한다
    • 함수형 인터페이스
      • 단 하나의 추상 메서드만을 가져야 한다
  • 바르게 선언되지 않았다면 에러를 발생한다
@FunctionalInterface
public interface FuntionalInterfaceExample {
    public abstract void abstractMethod();
}

메타 애너테이션(Meta-annotation)

  • 애너테이션을 정의하는 데에 사용되는 애너테이션
  • 애너테이션의 적용 대상 및 유지 기간을 지정하는 데에 사용된다
    • 즉, 메타 애너테이션을 사용하여 애너테이션의 다양한 특성을 지정할 수 있다!

@Target

  • 애너테이션을 적용할 대상을 지정하는 데에 사용한다
  • @Target 애너테이션을 사용하여 지정할 수 있는 대상의 타입
    • 모두 java.lang.annotation.ElementType이라는 열거형에 정의되어 있다
대상 타입적용범위
ANNOTATION_TYPE애너테이션
CONSTRUCTOR생성자
FIELD필드(멤버변수, 열거형 상수)
LOCAL_VARIABLE지역변수
METHOD메서드
PACKAGE패키지
PARAMETER매개변수
TYPE타입(클래스, 인터페이스, 열거형)
TYPE_PARAMETER타입 매개변수
TYPE_USE타입이 사용되는 모든 대상
import static java.lang.annotation.ElementType.*;

@Target({FIELD, TYPE, TYPE_USE}) // 적용 대상이 FIELD, TYPE, TYPE_USE
public @interface MyAnnotation {}

@MyAnnotation // 적용 대상이 TYPE인 경우
class Test {
    @MyAnnotation // 적용 대상이 FIELD인 경우
    int num;
}
  • 애너테이션을 사용자가 정의할 때에는 @interface를 사용하여 애너테이션을 정의할 수 있다
  • @Target 애너테이션을 통해 사용자가 정의한 애너테이션이 어디에 적용될 수 있는지 설정할 수 있다!

@Documented

  • 애너테이션에 대한 정보가 javadoc으로 작성된 문서에 포함되도록 하는 애너테이션 설정
  • 자바에서 제공하는 표준 애너테이션과 메타 애너테이션 중 @Override와 @SuppressWarnings를 제외하고는 모두 @Documented가 적용되어 있다

@Inherited

  • 하위 클래스가 애너테이션을 상속받도록 한다
    • @Inherited 애너테이션을 상위 클래스에 붙이면, 하위 클래스도 상위 클래스에 붙은 애너테이션이 동일하게 적용된다
@Inherited
@interface MyAnnotation {}

@MyAnnotation
class SuperClass {}

class SubClass extends SuperClass {} // SubClass에 @MyAnnotation이 붙은 것으로 인식한다
  • SuperClass 상위 클래스로부터 확장된 SubClass 하위 클래스는 상위 클래스와 동일하게 @MyAnnotation에 정의된 내용들을 적용받게 된다

@Retention

  • 애너테이션의 지속 시간을 결정하는 데에 사용된다
  • 애너테이션과 관련한 유지 정책의 종류에는 다음과 같은 것들이 있다
    • 유지 정책(retention policy) : 애너테이션이 유지되는 기간을 지정하는 속성
유지 정책설명
SOURCE소스 파일에 존재, 클래스 파일에는 존재하지 않는다
CLASS클래스 파일에 존재, 실행 시에 사용 불가, 이 값이 기본값
RUNTIME클래스 파일에 존재, 실행 시에 사용 가능하다
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE) // 클래스 파일에 남길 필요 없이 컴파일 시에만 확인하고 사라진다
public @interface Override() {}
  • @Override 애너테이션은 컴파일러가 사용하면 끝나기 때문에, 실행 시에는 더이상 사용되지 않음을 의미한다

@Repeatable

  • 애너테이션을 여러 번 붙일 수 있도록 허용한다는 의미
  • @Repeatable 애너테이션은 일반적인 애너테이션과 달리 같은 이름의 애너테이션이 여러 번 적용될 수 있으므로 이 애너테이션들을 하나로 묶어주는 애너테이션도 별도로 작성해야 한다
@interface Eats { // 여러 개의 Eat 애너테이션을 담을 컨테이너 애너테이션
    Eat[] value();
}

@Repeatable(Eats.class) // 컨테이너 애너테이션 지정
@interface Eat {
    String value();
}

@Repeatable("아침")
@Repeatable("점심")
@Repeatable("저녁")
class Test {

}

사용자 정의 애너테이션

  • 사용자가 직접 애너테이션을 정의해서 사용하는 것
  • 애너테이션 정의 방법은 인터페이스 정의 방법과 비슷하다
@interface 애너테이션명 {
    타입 요소명();
}
  • 애너테이션은 java.lang.annotation 인터페이스를 상속받기 때문에 다른 클래스나 인터페이스를 상속받을 수 없다

람다(Lambda)

람다식(Lambda Expression)

  • 함수형 프로그래밍을 지원하는 자바의 문법 요소
  • 함수(메서드)를 좀 더 간단하고 편리하게 표현하기 위해 고안된 문법 요소
    • 메서드를 하나의 식으로 표현한 것이라고 볼 수 있다
  • JDK 1.8 이후 람다식과 같은 함수형 프로그래밍 문법 요소를 도입해 객체지향 프로그래밍과 함수형 프로그래밍을 혼합하는 방식을 이용하여 보다 효율적인 프로그래밍을 할 수 있다!
  • 코드가 간결하면서 명확하게 표현할 수 있다는 장점이 있다
  • 람다식은 익명의 객체이다!
    • 자바의 문법 요소를 해치지 않으면서 함수형 프로그래밍 기법을 사용할 수 있는 장치가 필요해졌다
    • 이에 따라 함수형 인터페이스(functional interface)가 만들어지게 된다

람다식의 기본 문법

// 기존의 메서드 표현 방식
void exercise() {
    System.out.println("운동 중...");
}

// 위의 코드를 람다식으로 변경
() -> System.out.println("운동 중...");
  • 람다식에서는 기본적으로 반환 타입 및 이름을 생략할 수 있다!
    • 그래서 람다함수를 이름이 없는 함수, 즉 익명 함수(anonymous function)라 부르기도 한다
  • 람다식을 만드는 방법
int multiply(int n1, int n2) {
    return n1 * n2;
}
  • multiply 메서드가 위처럼 구현되어 있는데, 이 메서드를 람다식으로 변경하고자 한다
  • 람다식에서는 반환 타입 및 이름을 생략할 수 있으니 아래와 같이 작성이 가능하다
(int n1, int n2) -> { // 반환 타입 및 메서드명 제거 + 화살표 추가
    return n1 * n2;
}
  • 특정 조건이 충족된다면 람다식을 더욱 축약할 수 있다
    1. 만약 메서드 바디에 문장이 실행문 하나만 존재한다면, 중괄호와 return문을 생략할 수 있다
      • 이 때, 세미콜론도 같이 생략한다
    2. 매개변수 타입을 함수형 인터페이스를 통해 유추할 수 있는 경우에 매개변수 타입을 생략할 수 있다
(n1, n2) -> n1 * n2

함수형 인터페이스

  • 자바에서의 함수는 클래스 안에 정의되어야 하므로 메서드가 독립적으로 있을 수 없고 객체를 먼저 생성한 후에 해당 객체를 통해 메서드를 호출해야 한다
    • 이러한 맥락에서 메서드와 동일시 여겼던 람다식 역시 객체이다
      • 이름이 없기 때문에 익명 객체라 할 수 있다
// multiply 메서드 람다식
(n1, n2) -> n1 * n2

// 람다식을 객체로 표현
new Object() {
    int multiply(int n1, int n2) {
        return n1 * n2;
    }
}
  • 위에서 람다식으로 표현한 multiply 메서드는 아래 코드와 같이 이름이 없는 익명 객체이다
  • 익명 객체
    • 익명 객체는 익명 클래스를 통해 만들 수 있다
  • 익명 클래스
    • 객체의 선언 및 생성을 동시에 하여 오직 하나의 객체를 생성하고, 단 한 번만 사용되는 일회용 클래스
    • 아래와 같이 생성 및 선언을 한 번에 할 수 있다
new Object() {
    int multiply(int n1, int n2) {
        return n1 * n2;
    }
}
  • 람다식이 객체라면 이 객체에 접근하고 사용하기 위해 참조 변수가 필요하다
    • 기존에 객체를 생성할 때 만들었던 Object 클래스에는 multiply라는 메서드가 없으므로, Object 참조변수에 담는다고 하더라도 multiply 메서드를 사용할 수 없다
  • 위와 같은 문제를 해결하기 위해 사용되는 자바의 문법 요소가 자바의 함수형 인터페이스(Functional Interface)
    • 함수형 프로그래밍을 하기 위한 새로운 문법 요소를 도입하지 않고, 기존 인터페이스 문법을 활용하여 람다식을 다룬다
  • 함수형 인터페이스(Functional Interface)
    • 단 하나의 추상 메서드만을 선언할 수 있다
    • 람다식과 인터페이스의 메서드가 1:1로 매칭되어야 하기 때문에 하나의 추상 메서드만을 선언한다
public class Test {
    public static void main(String[] args) {
        FunctionalInterfaceExample functionalInterfaceExample = (n1, n2) -> n1 * n2;
        System.out.println(functionalInterfaceExample.multiply(2, 5));
    }
}

@FunctionalInterface
interface FunctionalInterfaceExample {
    int multiply(int n1, int n2);
}
  • 위 코드를 보면 함수형 인터페이스인 FunctionalInterfaceExample에 추상 메서드 multiply()이 정의되어져 있다
  • 이 함수형 인터페이스는 람다식을 참조할 참조 변수를 선언할 때, 타입으로 사용된다
    • 참조 변수에 람다식을 할당하고 해당 참조 변수를 통해 함수형 인터페이스에 선언되어 있는 메서드를 호출할 수 있다
  • 함수형 인터페이스를 사용하면 참조 변수의 타입으로 함수형 인터페이스를 사용하여 우리가 원하는 메서드에 접근이 가능하다!

매개변수와 리턴값이 없는 람다식

@FunctionalInterface
public interface FunctionalInterfaceExample {
    void sleep();
}
  • 매개변수 및 리턴값이 없는 추상 메서드를 가진 함수형 인터페이스를 타입으로 갖는 람다식은 다음과 같이 작성한다
@FunctionalInterface
interface FunctionalInterfaceExample {
    void sleep();
}

public class Test {
    public static void main(String[] args) {
        FunctionalInterfaceExample example = () -> System.out.println("숙면 중..");
        example.sleep();
    }
}
  • 람다식에 대입된 인터페이스의 참조 변수는 위와 같이 호출할 수 있다

매개변수가 있는 람다식

@FunctionalInterface
public interface FunctionalInterfaceExample {
    void eat(String food);
}
  • 매개변수가 있고 리턴값이 없는 추상 메서드를 가진 함수형 인터페이스를 타입으로 갖는 람다식은 다음과 같이 작성한다
@FunctionalInterface
interface FunctionalInterfaceExample {
    void eat(String food);
}

public class Test {
    public static void main(String[] args) {
        FunctionalInterfaceExample example = (food) -> System.out.println(food + "먹는 중...");
        example.eat("아침");
    }
}
  • 람다식에 대입된 인터페이스의 참조 변수는 위와 같이 호출할 수 있다
    • 매개값으로 추상 메서드 타입의 데이터를 준다

리턴값이 있는 람다식

@FunctionalInterface
public interface FunctionalInterfaceExample {
    int multipliy(int n1, int n2);
}
  • 매개변수와 리턴값을 가지는 추상 메서드를 가진 함수형 인터페이스를 타입으로 갖는 람다식은 다음과 같이 작성한다
@FunctionalInterface
public interface FunctionalInterfaceExample {
    int multipliy(int n1, int n2);
}

public class Test {
    public static void main(String[] args) {
        // FunctionalInterfaceExample example = (n1, n2) -> {
        //     int result = n1 * n2;
        //     return result;
        // };
        FunctionalInterfaceExample example = (n1, n2) -> n1 * n2;
        
        int result1 = example.multiply(2, 10);
        System.out.println(result1);
    }
}

메서드 레퍼런스

  • 메서드 참조는 람다식에서 불필요한 매개변수를 제거할 때 주로 사용한다
    • 람다식으로 더욱 간단해진 익명 객체를 더욱 간단하게 사용할 수 있다
  • 람다식은 기본 메서드를 단순히 호출만 하는 경우가 많다
  • Ex. 두 개의 값을 받아 더 큰 수를 반환하는 Math 클래스의 max() 정적 메서드를 호출하는 람다식을 보면 아래와 같다
(n1, n2) -> Math.max(n1, n2)
  • 위 람다식은 단순히 두 개의 값을 Math.max() 메서드의 매개값으로 전달하는 역할만 하고, 입력값과 출력값의 반환타입을 쉽게 유추할 수 있어 입력값 및 출력값을 일일이 적어주는 것이 중요하지 않아 다소 불편해 보인다
  • 이럴 경우 아래와 같이 처리할 수 있다
// 클래스이름 :: 메서드이름
Math :: max
  • 메서드 참조 역시 인터페이스의 익명 구현 객체로 생성되므로 인터페이스의 추상 메서드가 어떤 매개 변수를 가지고, 리턴 타입이 무엇인가에 따라 달라진다
IntBinaryOperator operator = Math :: max;
  • IntBinaryOperator 인터페이스는 두 개의 int 매개값을 받아 int 값을 반환하므로, Math :: max 메서드 참조를 대입할 수 있다
  • 메서드 참조는 정적 혹은 인스턴스 메서드를 참조할 수 있고, 생성자도 참조할 수 있다

정적 메서드와 인스턴스 메서드 참조

  • 정적 메서드를 참조할 경우에는 클래스 이름 뒤에 :: 기호를 붙이고 정적 메서드 이름을 기술한다
클래스 :: 메서드
  • 인스턴스 메서드를 참조할 경우에는 먼저 객체를 생성한 후, 참조 변수 뒤에 :: 기호를 붙이고 인스턴스 메서드 이름을 기술한다
참조 변수 :: 메서드
public class Calculator {
    public static int multiply(int x, int y) {
        return x * y;
    }

    public int add(int x, int y) {
        return x + y;
    }
}

public class MethodReferences {
    public static void main(String[] args) {
        IntBinaryOperator operator;

        // 정적 메서드
        operator = Calculator :: multiply;
        System.out.println("곱하기 결과 : " + operator.applyAsInt(2, 5));

        // 인스턴스 메서드
        Calculator calculator = new Calculator();
        operator = calculator :: add;
        System.out.println("더하기 결과 : " + operator.applyAsInt(2, 5));
    }
}

생성자 참조

  • 생성자를 참조한다는 것은 객체를 생성한다는 것을 의미한다
  • 단순히 객체를 생성하고 반환하도록 구성된 람다식은 생성자 참조로 대체할 수 있다
// 단순히 객체를 생성하고 반환하는 람다식
(n1, n2) -> new 클래스(n1, n2)

// 생성자 참조
클래스 :: new
  • 생성자 참조는 클래스 이름 뒤에 :: 기호를 붙이고 new 연산자를 기술하면 된다
  • 생성자가 여러 개 오버로딩 되어있을 경우에는, 컴파일러가 함수형 인터페이스의 추상 메서드와 동일한 매개 변수 타입과 개수를 가지고 있는 생성자를 찾아 실행한다
  • 만약 해당 생성자가 존재하지 않는다면 컴파일 오류가 발생한다

스트림(Stream)

  • 배열, 컬렉션의 요소들을 하나씩 참조하여 람다식으로 처리할 수 있도록 해주는 반복자
  • 스트림 ==> 데이터의 흐름
    • 각 데이터를 흐름에 따라 우리가 원하는 결과로 가공하고 처리하는 일련의 과정이다!
  • 스트림을 사용하면 List, Set, Map, 배열 등 다양한 데이터 소스로부터 스트림을 만들 수 있고, 이들을 표준화된 방법으로 다룰 수 있다
    • 데이터 소스를 다루는 메서드들을 제공한다
      • 이를 활용하면 다량의 데이터에 복잡한 연산을 수행하더라도 가독성과 재사용성이 높은 코드를 작성할 수 있다

스트림의 도입 배경

  • 배열과 컬렉션 등에 저장된 데이터들에 반복적으로 접근하여 가공하기 위해 for문과 Iterator 등을 활용할 수 있다
    • 이러한 방식들은 데이터를 처리하는 데에 있어 2가지 한계가 존재한다
      1. 코드가 길고 복잡해질 수 있다
      2. 데이터 소스를 각기 다른 방식으로 다뤄야 한다는 불편함이 존재한다

1. 코드가 길고 복잡해질 수 있다

public class Example {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 3, 10, 12, 15);

        Iterator<Integer> iter = list.iterator();

        while(iter.hasNext()) {
            int num = iter.next();
            System.out.println(num);
        }
    }
}
  • Iterator를 통해 List에 있는 요소들을 출력하는 코드를 작성하면 위와 같다
  • 이를 Stream으로 변경하면 아래와 같이 작성할 수 있다
public class Example {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 3, 10, 12, 15);

        Stream<Integer> stream = list.stream();
        stream.forEach(System.out :: println);
    }
}
  • 이렇게 스트림을 이용하면 코드의 길이가 짧아진다
    • 복잡한 로직이 있는 경우에는 스트림에서 제공하는 메서드를 이용하여 단순하게 작성할 수 있어 더욱 스트림의 효과를 볼 수 있다
  • 스트림을 사용하면 선언형 프로그래밍(Declarative Programming) 방식을 통해 데이터를 처리할 수 있어, 더욱 인간친화적이고 직관적인 코드를 작성할 수 있다
    • 명령형 프로그래밍(Imperative Programming)
      • 목표를 달성하기 위해 코드 한 줄 한 줄의 동작 원리를 이해하고 순차적이고 세세하게 규정하는 방식
      • 즉, "어떻게" 코드를 작성할 것인지에 초점을 맞춘다
    • 선언형 프로그래밍(Declarative Programming)
      • "무엇"에 집중하여 코드를 작성하는 코드 작성 방법론
      • 내부 동작 원리를 모르더라도 어떤 코드가 어떤 역할을 하는지 직관적으로 이해할 수 있다
      • "어떻게"에 대한 부분은 추상화되어 있다
  • 명령형 프로그래밍
public class Example {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 3, 10, 12, 15);
        int sum = 0;

        for(int num : list) {
            if(num % 2 == 0 && num >= 4) sum += num;
        }

        System.out.println("명령형 프로그래밍의 합계 : " + sum);
    }
}
  • 선언형 프로그래밍
public class Example {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 3, 10, 12, 15);
        
        int sum = list.stream()
                .filter(num -> num % 2 == 0 && num >= 4)
                .maptToInt(Integer :: valueOf)
                .sum();

        System.out.println("선언형 프로그래밍의 합계 : " + sum);
    }
}
  • "어떻게"에 해당하는 각 함수의 내부 동작은 알 수 없지만 어떤 흐름으로 어떤 일이 일어나는지 파악하기 어렵지 않다
  • 스트림을 이용하면 위와 같이 선언형 프로그래밍 방식을 통해 데이터를 처리할 수 있어 보다 직관적이고 간결한 코드 작성에 유리하다

2. 데이터 소스를 각기 다른 방식으로 다뤄야 한다는 불편함이 존재한다

  • 스트림을 사용하지 않을 때에는 표준화된 하나의 방식이 아닌 데이터 소스마다 그에 맞는 방식의 메서드를 각각 다르게 적용하여 데이터 처리를 해야 한다
    • Ex. 어떤 데이터 집합을 정렬할 때, 같은 정렬 기능을 수행하는데 있어서 배열과 List는 다른 메서드를 사용한다
      • 배열 : Arrays.sort()
      • List : Collections.sort()
  • 이러한 문제를 해결해주는 것이 스트림!
  • 스트림을 사용하면 데이터 소스가 무엇이던 같은 방식으로 데이터를 가공 / 처리할 수 있다
public class Example {
    public static void main(String[] args) {
        // ArrayList
        List<String> list = new ArrayList<>();
        list.add("아침");
        list.add("점심");
        list.add("저녁");

        // 배열
        String[] arr = {"아침", "점심", "저녁"};

        // 스트림 생성
        Stream<String> listStream = list.stream();
        Stream<String> arrStream = Arrays.stream(arr);

        // 출력
        listStream.forEach(System.out :: println);
        arrStream.forEach(System.out :: println);
    }
}
  • 스트림은 for문 / Iterator 등과 같은 기존 방식의 한계를 효과적으로 보완하면서, 더 간결하고 직관적인 코드를 작성할 수 있도록 도와준다

스트림의 특징

  1. 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다
  2. 스트림은 원본 데이터 소스를 변경하지 않는다(read-only)
  3. 스트림은 일회용이다(onetime-only)
  4. 스트림은 내부 반복자이다

1. 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다

  • 스트림 파이프라인(Stream Pipeline)은 1) 스트림의 생성, 2) 중간 연산, 3) 최종 연산 총 세 가지 단계로 구성된다
  • 간략한 흐름
    1. 컬렉션, 배열, 임의의 수 등 다양한 데이터 소스를 일원화하여 스트림으로 작업하기 위해서는 스트림을 생성해야 한다
    2. 스트림이 생성되면 최종 처리를 위한 중간 연산을 수행한다
      • 필터링, 매핑, 정렬 등의 작업이 포함될 수 있다
      • 중간 연산의 결과는 또 다른 스트림이므로 중간 연산을 계속 연결해서 연산을 수행할 수 있다
    3. 중간 연산이 완료된 스트림을 최종적으로 처리하는 최종 연산을 끝으로 스트림은 닫히고 모든 데이터 처리가 완료된다
      • 총합, 평균, 카운팅 등의 작업이 포함될 수 있다
      • 최종 연산은 스트림의 요소들을 소모하면서 연산을 수행하므로 단 한 번의 연산만 가능하다
      • 최종 연산 이후 결과를 다시 처리하고 싶다면, 스트림을 다시 생성해주어야 한다

2. 스트림은 원본 데이터 소스를 변경하지 않는다(read-only)

  • 오직 데이터를 읽어올 수 있고, 데이터에 대한 변경 및 처리는 생성된 스트림 내에서만 수행된다
    • 원본 데이터가 스트림에 의해 임의로 변경되거나 데이터가 손상될 일을 방지하기 위함이다

3. 스트림은 일회용이다(onetime-only)

  • 스트림이 생성되고 여러 중간 연산들을 거쳐 최종 연산이 수행되고 난 후에는 스트림이 닫히고 다시 사용할 수 없다
  • 추가적인 작업이 필요하다면, 스트림을 다시 생성해야 한다

4. 스트림은 내부 반복자이다

내부 반복자(Internal Iterator), 외부 반복자(External Iterator)

  • 외부 반복자(External Iterator)
    • 개발자가 코드로 직접 컬렉션의 요소를 반복해서 가져오는 코드 패턴
    • 인덱스를 사용하는 for문, Iterator를 사용하는 while문이 대표적이다
  • 스트림은 컬렉션 내부에 데이터 요소 처리 방법(람다식)을 주입시켜 요소를 반복처리하는 방식
    • 외부 반복자는 요소가 필요할 때마다 순차적으로 컬렉션에서 필요한 요소를 불러온다
    • 내부 반복자의 경우, 데이터 처리 코드만 컬렉션 내부로 주입시켜 그 안에서 모든 데이터 처리가 이뤄지도록 한다
      • 효율적인 데이터 처리가 가능하다!

스트림의 생성

  • 스트림으로 데이터를 처리하기 위해서는 우선 스트림을 생성해야 한다
  • 스트림을 생성할 수 있는 데이터 소스는 컬렉션, 배열, 임의의 수, 특정 범위의 정수 등 다양하다
    • 이에 따라 스트림 생성 방법에는 조금씩 차이가 존재한다

배열 스트림 생성

  • 배열을 데이터 소스로 하는 스트림 생성은 Arrays 클래스의 stream() 메서도 또는 Stream 클래스의 of() 메서드를 사용한다
public class StreamTest {
    public static void main(String[] args) {
        String[] arr = {"아침", "점심", "저녁"};

        // Arrays.stream() 이용
        Stream<String> arrysStream = Arrays.stream(arr);

        arrysStream.forEach(System.out :: println);

        // Stream.of() 이용
        Stream<String> stream = Stream.of(arr);

        stream.forEach(System.out :: println);
    }
}

컬렉션 스트림 생성

  • 컬렉션 타입의 경우, 컬렉션의 최상위 클래스인 Collection에 정의된 stream() 메서드를 사용하여 스트림을 생성할 수 있다
    • Collection으로부터 확장된 하위 클래스 List와 Set을 구현한 컬렉션 클래스들은 모두 stream() 메서드를 사용하여 스트림을 생성할 수 있다
public class StreamTest {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3, 5, 19, 25, 111);
        Stream<Integer> stream = list.stream();

        stream.forEach(System.out :: println);
    }
}

임의의 수 스트림 생성

  • 난수를 생성하는 Random 클래스 안에는 해당 타입의 난수들을 반환하는 스트림을 생성하는 메서드들이 정의되어 있다
    • Ex. ints() 메서드의 경우, int형 범위 안에 있는 난수들을 무한대로 생성하여 IntStream 타입의 스트림으로 반환한다
public class StreamExample {
    public static void main(String[] args) {
        IntStream stream = new Random().ints();
        ints.forEach(System.out :: println);
    }
}
  • 무한 스트림(Infinite Stream)
    • 스트림의 크기가 정해지지 않은 스트림
    • 무한 스트림은 주로 limit() 메서드와 함께 사용하거나 매개변수로 스트림의 사이즈를 전달해서 그 범위를 제한할 수 있다
public class StreamExample {
    public static void main(String[] args) {
        IntStream ints = new Random().ints(5);
        IntStream ints2 = new Random().ints().limit(5);

        ints.forEach(System.out :: println);
        ints2.forEach(System.out :: println);
    }
}
  • IntStream과 LongStream에 정의된 range()나 rangeClosed() 메서드를 사용하여 특정 범위의 정수값을 스트림으로 생성해서 반환할 수 있다
public class StreamExample {
    public static void main(String[] args) {
        // 1 ~ 10
        IntStream rangeClosedStream = IntStream.rangeClosed(1, 10);
        rangeClosedStream.forEach(System.out :: println);

        // 1 ~ 9
        IntStream rangeStream = IntStream.range(1, 10);
        rangeStream.forEach(System.out :: println);
    }
}
  • rangeClosed() vs range()
    • 두 번째로 전달되는 매개 변수가 범위에 포함되는지 여부에 따라 구분된다
    • rangeClosed() : 끝번호가 범위에 포함된다
    • range() : 끝번호가 범위에 포함되지 않는다

스트림의 중간 연산

중간 연산자(Intermediate Operation)

  • 스트림의 중간 연산자의 결과는 스트림을 반환하므로 여러 개의 연산자를 연결하여 원하는 데이터 처리를 수행할 수 있다
  • 대표적인 중간 연산자
    • 필터링(filtering), 매핑(mapping), 정렬(sorting)
  • 전체적인 코드 구조

  • 최초에 데이터 소스를 가지고 스트림을 생성한 후에, 중간 연산자로 데이터를 가공하고, 최종 연산자를 통해 스트림 작업을 종료한다

필터링(fileter(), distinct())

  • 조건에 맞는 데이터들만 정제하는 역할을 하는 중간 연산자
    • distinct()
      • 스트림의 요소들에 중복된 데이터가 존재한다면 중복을 제거하기 위해 사용한다
    • filter()
      • 스트림에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어낸다
      • 매개값으로 조건(Predicate)이 주어지고, 조건이 참이 되는 요소만 필터링한다
      • 조건은 람다식을 사용하여 정의할 수 있다
public class FilterExample {
    public static void main(String[] args) throws Exception {
        List<String> list = Arrays.asList("Java", "Spring", "Spring boot", "C", "C++", "Python", "Java", "Spring", "C");

        // 중복 제거
        list.stream()
            .distinct()
            .forEach(System.out :: println);
        System.out.println();

        // C로 시작하는 요소들만 필터링
        list.stream()
            .filter(lang -> lang.startsWith("C"))
            .forEach(System.out :: println);
        System.out.println();
        
        // 중복제거와 필터링을 모두 수행
        list.stream()
            .distinct()
            .filter(lang -> lang.startsWith("C"))
            .forEach(System.out :: println);
    }
}

매핑(map())

  • 스트림 내의 요소들에서 원하는 필드만 추출하거나 특정 형태로 변환할 때 사용하는 중간 연산자
    • 값을 변환하기 위한 조건을 람다식으로 정의한다
public class MappingExample {
    public static void main(String[] args) {
        List<String> list = Arrays.asList("Java", "Spring", "Spring boot", "C", "C++", "Python", "Java", "Spring", "C");
        list.stream()
                .map(lang -> lang.toUpperCase()) // 요소들을 대문자로 변환
                .forEach(System.out :: println);
    }
}
  • flatMap() 중간 연산자
    • 만약 이중 배열이 있고, 그 안의 배열들을 map() 메서드를 사용하여 하나씩 출력한다고 가정해보자
String[][] langArr = new String[][] {{"Java", "Spring"}, {"Spring boot", "C"}, {"C++", "Python"}};

Arrays.stream(langArr)
    .map(inner -> Arrays.stream(inner))
    .forEach(System.out :: println);
  • 위와 같이 작성한다면 map() 메서드는 Stream<Stream>, 즉 중복 스트림을 반환한다
  • 이로 인해 우리가 원하는 결과값을 얻지 못한다
    • 우리는 map()을 통해 Stream을 얻어야 한다
    • 이러한 경우에 아래와 같이 수정할 수 있다
Arrays.stream(langArr)
    .map(inner -> Arrays.stream(inner))
    .forEach(lang -> lang.forEach(System.out :: println));
  • forEach() 메서드 안의 람다식 정의에서 각 요소들에 대하여 다시 forEach() 메서드를 출력함으로써 뎁스가 있는 요소들에 접근하여 이를 출력할 수 있다
  • 그러나, 뎁스가 깊어지면 깊어질수록 작성도 번거롭고 작성된 코드 또한 가독성이 떨어진다
  • 이럴 때, flatMap()을 사용한다!
Arrays.stream(langArr).flatMap(Arrays :: stream).forEach(System.out :: println);
  • flatMap()은 중첩 구조를 제거하고 단일 컬렉션(Stream)으로 만들어주는 역할을 한다!
    • 플래트닝(flattening)
      • 요소들을 평평하게 한다
  • 배열 요소들의 뎁스가 있는 작업들을 수행할 때, flatMap() 메서드를 활용하면 간편하고 효과적으로 같은 작업을 수행할 수 있다!

정렬(sorted())

  • 정렬을 할 때 사용하는 중간 연산자
  • sorted() 메서드를 사용하여 정렬할 때에는, 괄호 안에 Comparator 인터페이스에 정의된 static 메서드와 default 메서드를 사용하여 정렬 작업을 수행할 수 있다
    • 괄호에 아무 값을 넣지 않는다면 기본 정렬(오름차순)로 정렬된다
  • 기본 정렬
public class SortedClass {
    public static void main(String[] args) {
        List<String> langs = Arrays.asList("Java", "Spring", "Spring boot", "C", "C++", "Python", "Java", "Spring", "C");

        langs.stream().sorted().forEach(System.out :: println);
    }
}
  • 역순으로 정렬
public class SortedClass {
    public static void main(String[] args) {
        List<String> langs = Arrays.asList("Java", "Spring", "Spring boot", "C", "C++", "Python", "Java", "Spring", "C");

        langs.stream()
            .sorted(Comparator.reverseOrder())
            .forEach(System.out :: println);
    }
}

skip() - 스트림의 일부 요소들을 건너뛴다

public class SkipClass {
    public static void main(String[] args) {
        IntStream stream = IntStream.rangeClosed(1, 15);

        stream.skip(3).forEach(System.out :: println);
    }
}

// 출력값
4
5
6
7
8
9
10
11
12
13
14
15

limit() - 스트림의 일부를 자른다

public class LimitClass {
    public static void main(String[] args) {
        IntStream stream = IntStream.rangeClosed(1, 15);

        stream.limit(3).forEach(System.out :: println);
    }
}

// 출력값
1
2
3

peek() - 요소들을 순회하면 특정 작업을 수행한다

  • forEach()와 같이 요소들을 순회하며 특정 작업을 수행한다
  • forEach()와의 차이는?
    • 중간 연산자인지의 여부
      • peek()은 중간 연산자이므로 이후에도 여러 연산들을 연결하여 사용할 수 있다
      • forEach()는 최종 연산자이기 때문에 마지막에 한 번만 사용될 수 있다
  • peek()의 특성 때문에 에러를 찾기 위한 디버깅(Debugging) 용도로 종종 활용된다
public class LimitClass {
    public static void main(String[] args) {
        IntStream stream = IntStream.of(1, 2, 3, 4, 5, 5, 6, 7, 7, 8, 8, 8, 9);

        int sum = stream.filter(num -> num % 2 == 0)
                .peek(System.out :: println)
                .sum();

        System.out.println("합계 = " + sum);
    }
}

// 출력값
2
4
6
8
8
8
합계 = 36

스트림의 최종 연산

최종 연산(Terminal Operation)

  • 최종 연산 메서드가 스트림 파이프라인에서 최종적으로 사용되고 나면 스트림은 닫히고 모든 연산이 종료된다
  • 지연된 연산(lazy evaluation)
    • 중간 연산은 최종 연산자가 수행될 때 비로소 스트림의 요소들이 중간 연산을 거쳐 가공된 후에 최종 연산에서 소모되는데 이를 지연된 연산이라고 부른다

기본 집계(sum(), count(), average(), max(), min())

  • 숫자와 관련된 기본적인 집계의 경우에는 대부분 최종 연산자이다
public class Test {
    public static void main(String[] args) {
        int[] arr = {1, 2, 5, 10, 15};

        // 카운팅
        long count = Arrays.stream(arr).count();
        System.out.println("arr의 전체 요소 개수 : " + count);

        // 합계
        long sum = Arrays.stream(arr).sum();
        System.out.println("arr의 전체 요소 합 : " + sum);

        // 평균
        double average = Arrays.stream(arr).average().getAsDouble();
        System.out.println("arr의 전체 요소의 평균 : " + average);

        // 최댓값
        int max = Arrays.stream(arr).max().getAsInt();
        System.out.println("arr의 최댓값 : " + max);

        // 최솟값
        int min = Arrays.stream(arr).min().getAsInt();
        System.out.println("arr의 최솟값 : " + min);

        // 배열의 첫 번째 요소
        int first = Arrays.stream(arr).findFirst().getAsInt();
        System.out.println("arr의 첫 번째 요소 : " + first);
    }
}
  • 스트림의 최종 연산자로 스트림이 닫히는데, average나 max, min 등을 보면 getAsDouble()이나 getAsInt() 메서드가 뒤에 더 붙고 있다
double average = Arrays.stream(arr).average().getAsDouble();
  • average() 연산자가 반환하는 값을 확인해보면 OptionalDouble을 반환한다
    • OptionalDouble 클래스
      • 일종의 래퍼 클래스
      • null 값으로 인해 NullPointerException 예외가 발생하는 현상을 객체 차원에서 효율적으로 방지하기 위한 목적으로 도입되었다
      • 즉, 연산 결과를 Optional 객체 안에 담아 반환하면, if문을 사용한 조건문으로 반환된 결과가 null인지 여부를 체크하지 않아도 에러가 발생하지 않도록 코드를 작성할 수 있다
  • average() 연산자가 일종의 래퍼 클래스인 OptionalDouble을 반환하고 있기 때문에 이를 우리가 원하는 기본형으로 변환하는 과정이 필요하다
    • getAsDouble(), getAsInt()와 같은 메서드는 객체로 반환되는 값을 다시 기본형으로 변환하기 위해 사용되는 메서드이다

매칭(allMatch(), anyMatch(), noneMatch())

  • match() 메서드를 사용하면 조건식 람다 Predicate를 매개변수로 넘겨 스트림의 각 데이터 요소들이 특정한 조건을 충족하는지 검사하고, 그 결과를 boolean으로 반환한다
  • match() 메서드에는 3가지 종류가 존재한다
    • allMatch()
      • 모든 요소들이 조건을 만족하는지 여부를 판단한다
    • noneMatch()
      • 모든 요소들이 조건을 만족하지 않는지 여부를 판단한다
    • anyMatch()
      • 하나로 조건을 만족하는 요소가 있는지 여부를 판단한다
public class Example {
    public static void main(String[] args) {
        int[] arr = {2, 4, 5, 6, 10, 15};

        // allMatch
        boolean result1 = Arrays.stream(arr).allMatch(element -> element % 2 != 0);
        System.out.println("요소 모두가 홀수인가요? " + result1);

        // anyMatch
        boolean result2 = Arrays.stream(arr).anyMatch(element -> element % 2 != 0);
        System.out.println("요소에 홀수가 존재하나요? " + result2);

        // noneMatch
        boolean result3 = Arrays.stream(arr).noneMatch(element -> element % 2 != 0);
        System.out.println("요소 모두가 홀수가 아닌가요? " + result3);
    }
}

요소 소모(reduce())

  • 스트림의 요소를 줄여나가며 연산을 수행하고 최종적인 결과를 반환한다
  • 스트림의 최종 연산은 모두 요소를 소모하며 연산을 수행한다
  • reduce() 메서드
    • 먼저 첫 번째와 두 번째 요소를 이용하여 연산을 진행하고
    • 그 결과와 세 번째 요소를 가지고 또 연산을 수행
    • 이러한 방식으로 연산이 끝날 때까지 반복한다
  • reduce() 메서드의 매개변수 타입은 BinaryOperator로 정의되어 있다
    • Optional reduce(BinaryOperator accumulator)
  • 매개변수를 2개 받는 reduce() 메서드
    • T reduce(T identity, BinaryOperator accumulator)
      • 첫 번째 매개변수 identity : 특정 연산을 시작할 때 설정되는 초기값
      • 두 번째 매개변수 accumulator : 각 요소들을 연산하여 나온 누적된 결과물을 생성하는 데에 사용하는 조건식
public class Example {
    public static void main(String[] args) throws Exception {
        int[] arr = {1, 2, 5, 10, 13, 15};

        // sum
        long sum = Arrays.stream(arr).sum();
        System.out.println("arr 전체 요소의 합 : " + sum);

        // 초기값이 없는 reduce()
        int sum1 = Arrays.stream(arr)
                .reduce((n1, n2) -> n1 + n2)
                .getAsInt();
        System.out.println("초기값 없는 reduce()를 통한 arr 전체 요소의 합 : " + sum);

        // 초기값이 있는 reduce()
        int sum2 = Arrays.stream(arr)
                .reduce(5, (n1, n2) -> n1 + n2);
        System.out.println("초기값 있는 reduce()를 통한 arr 전체 요소의 합 : " + sum);
    }
}
  • 두 번째, 세 번째 메서드
    • reduce() 메서드를 사용하고 있다
    • 세 번째 메서드의 경우에는 초기값으로 5가 설정되어있기 때문에 최종 연산의 결과가 두 번째 메서드보다 5가 많은 값이 출력된다
  • 두 번쨰 메서드의 구체적인 흐름을 살펴보면
    1. accumulator : (a, b) -> a + b (a: 누적값, b: 새롭게 더해질 값)
    2. 최초 연산 시 : 1 + 2 -> a : 3, b : 3
    3. 3 + 3 -> a : 6, b : 4
    4. 6 + 4 -> a : 10, b : 5
    5. 10 + 5 -> 최종 결과: 15
  • count()와 sum() 같은 집계 메서드도 내부적으로 reduce()를 사용하여 연산을 수행한다

요소 수집(collect())

  • 스트림에서 중간 연산들을 통해 요소들의 데이터를 가공한 후에 요소들을 수집하는 최종 처리 메서드
    • 스트림의 요소들을 List, Set, Map 등 다른 타입으로 수집하고 싶은 경우에 collect() 메서드를 유용하게 사용할 수 있다
  • collect() 메서드는 Collector 인터페이스 타입의 인자를 받아서 처리할 수 있다
    • 자주 사용되는 기능들을 Collectors 클래스에서 제공하고 있다
  • collect() 메서드는 요소 수집 이외에도 요소 그룹핑 및 분할 등의 여러 기능들을 제공한다
public class Example {
    public static void main(String[] args) {
        List<Car> cars = Arrays.asList(
            new Car("그랜져", "현대"),
            new Car("K9", "기아"),
            new Car("소나타", "현대"),
            new Car("쏘렌토", "기아")
        );

        Map<String, String> kiaCar = cars.stream()
                .filter(car -> car.getBrand().equals("기아"))
                .collect(Collectors.toMap(
                    car -> car.getBrand(),
                    car -> car.getModel()
                ));

        System.out.println(kiaCar);
    }
}

class Car {
    private String model;
    private String brand;

    public Car(String model, String brand) {
        this.model = model;
        this.brand = brand;
    }

    public String getModel() {
        return model;
    }

    public String getBrand() {
        return brand;
    }
}
  • 흐름
    • 리스트 배열에 스트림을 생성한다
    • 중간 연산자 filter() 메서드를 통해 브랜드가 기아인 차들만 필터링
    • 마지막으로 최종 연산자 collect()에 Collectors 클래스 안에 정의된 정적 메서드 toMap()을 사용하여 Map 타입의 결과물을 받는다
profile
자바, 웹 개발을 열심히 공부하고 있습니다!

0개의 댓글