애너테이션(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가지로 구분된다
- 표준 애너테이션
- 메타 애너테이션
- 다른 애너테이션을 정의할 때 사용하는 애너테이션
- 표준 애너테이션
- @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();
}
- 애너테이션을 정의하는 데에 사용되는 애너테이션
- 애너테이션의 적용 대상 및 유지 기간을 지정하는 데에 사용된다
- 즉, 메타 애너테이션을 사용하여 애너테이션의 다양한 특성을 지정할 수 있다!
@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})
public @interface MyAnnotation {}
@MyAnnotation
class Test {
@MyAnnotation
int num;
}
- 애너테이션을 사용자가 정의할 때에는 @interface를 사용하여 애너테이션을 정의할 수 있다
- @Target 애너테이션을 통해 사용자가 정의한 애너테이션이 어디에 적용될 수 있는지 설정할 수 있다!
@Documented
- 애너테이션에 대한 정보가 javadoc으로 작성된 문서에 포함되도록 하는 애너테이션 설정
- 자바에서 제공하는 표준 애너테이션과 메타 애너테이션 중 @Override와 @SuppressWarnings를 제외하고는 모두 @Documented가 적용되어 있다
@Inherited
- 하위 클래스가 애너테이션을 상속받도록 한다
- @Inherited 애너테이션을 상위 클래스에 붙이면, 하위 클래스도 상위 클래스에 붙은 애너테이션이 동일하게 적용된다
@Inherited
@interface MyAnnotation {}
@MyAnnotation
class SuperClass {}
class SubClass extends SuperClass {}
- SuperClass 상위 클래스로부터 확장된 SubClass 하위 클래스는 상위 클래스와 동일하게 @MyAnnotation에 정의된 내용들을 적용받게 된다
@Retention
- 애너테이션의 지속 시간을 결정하는 데에 사용된다
- 애너테이션과 관련한 유지 정책의 종류에는 다음과 같은 것들이 있다
- 유지 정책(retention policy) : 애너테이션이 유지되는 기간을 지정하는 속성
유지 정책 | 설명 |
---|
SOURCE | 소스 파일에 존재, 클래스 파일에는 존재하지 않는다 |
CLASS | 클래스 파일에 존재, 실행 시에 사용 불가, 이 값이 기본값 |
RUNTIME | 클래스 파일에 존재, 실행 시에 사용 가능하다 |
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override() {}
- @Override 애너테이션은 컴파일러가 사용하면 끝나기 때문에, 실행 시에는 더이상 사용되지 않음을 의미한다
@Repeatable
- 애너테이션을 여러 번 붙일 수 있도록 허용한다는 의미
- @Repeatable 애너테이션은 일반적인 애너테이션과 달리 같은 이름의 애너테이션이 여러 번 적용될 수 있으므로 이 애너테이션들을 하나로 묶어주는 애너테이션도 별도로 작성해야 한다
@interface Eats {
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;
}
- 특정 조건이 충족된다면 람다식을 더욱 축약할 수 있다
- 만약 메서드 바디에 문장이 실행문 하나만 존재한다면, 중괄호와 return문을 생략할 수 있다
- 매개변수 타입을 함수형 인터페이스를 통해 유추할 수 있는 경우에 매개변수 타입을 생략할 수 있다
(n1, n2) -> n1 * n2
함수형 인터페이스
- 자바에서의 함수는 클래스 안에 정의되어야 하므로 메서드가 독립적으로 있을 수 없고 객체를 먼저 생성한 후에 해당 객체를 통해 메서드를 호출해야 한다
- 이러한 맥락에서 메서드와 동일시 여겼던 람다식 역시 객체이다
(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) -> 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. 코드가 길고 복잡해질 수 있다
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) {
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 등과 같은 기존 방식의 한계를 효과적으로 보완하면서, 더 간결하고 직관적인 코드를 작성할 수 있도록 도와준다
스트림의 특징
- 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다
- 스트림은 원본 데이터 소스를 변경하지 않는다(read-only)
- 스트림은 일회용이다(onetime-only)
- 스트림은 내부 반복자이다
1. 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다
- 스트림 파이프라인(Stream Pipeline)은 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 = {"아침", "점심", "저녁"};
Stream<String> arrysStream = Arrays.stream(arr);
arrysStream.forEach(System.out :: println);
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) {
IntStream rangeClosedStream = IntStream.rangeClosed(1, 10);
rangeClosedStream.forEach(System.out :: println);
IntStream rangeStream = IntStream.range(1, 10);
rangeStream.forEach(System.out :: println);
}
}
- rangeClosed() vs range()
- 두 번째로 전달되는 매개 변수가 범위에 포함되는지 여부에 따라 구분된다
- rangeClosed() : 끝번호가 범위에 포함된다
- range() : 끝번호가 범위에 포함되지 않는다
스트림의 중간 연산
- 스트림의 중간 연산자의 결과는 스트림을 반환하므로 여러 개의 연산자를 연결하여 원하는 데이터 처리를 수행할 수 있다
- 대표적인 중간 연산자
- 필터링(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();
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)으로 만들어주는 역할을 한다!
- 배열 요소들의 뎁스가 있는 작업들을 수행할 때, 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};
boolean result1 = Arrays.stream(arr).allMatch(element -> element % 2 != 0);
System.out.println("요소 모두가 홀수인가요? " + result1);
boolean result2 = Arrays.stream(arr).anyMatch(element -> element % 2 != 0);
System.out.println("요소에 홀수가 존재하나요? " + result2);
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};
long sum = Arrays.stream(arr).sum();
System.out.println("arr 전체 요소의 합 : " + sum);
int sum1 = Arrays.stream(arr)
.reduce((n1, n2) -> n1 + n2)
.getAsInt();
System.out.println("초기값 없는 reduce()를 통한 arr 전체 요소의 합 : " + sum);
int sum2 = Arrays.stream(arr)
.reduce(5, (n1, n2) -> n1 + n2);
System.out.println("초기값 있는 reduce()를 통한 arr 전체 요소의 합 : " + sum);
}
}
- 두 번째, 세 번째 메서드
- reduce() 메서드를 사용하고 있다
- 세 번째 메서드의 경우에는 초기값으로 5가 설정되어있기 때문에 최종 연산의 결과가 두 번째 메서드보다 5가 많은 값이 출력된다
- 두 번쨰 메서드의 구체적인 흐름을 살펴보면
- accumulator : (a, b) -> a + b (a: 누적값, b: 새롭게 더해질 값)
- 최초 연산 시 : 1 + 2 -> a : 3, b : 3
- 3 + 3 -> a : 6, b : 4
- 6 + 4 -> a : 10, b : 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 타입의 결과물을 받는다