CodeStatesBootCamp/Review

Section 1 - Unit 8 : [Java] 심화 Review

JKROH 2023. 3. 8. 11:56
반응형
Review 에서는 학습한 내용을 다시금 기록합니다.
Unit Review는 학습한 내용 중 기존에 알고 있었지만 정확하게 이해하지 못하던 정보와 새롭게 알게된 정보를 기록합니다. 추가적인 설명을 요하는 부분은 댓글로 남겨주세요.
Section Review는 전반적인 Section을 되돌아보고 학습했던 시간과 과정, 내용을 총괄하여 기록합니다.

<<JAVA 심화>>


애너테이션

* 애너테이션이란?

  • 애너테이션은 컴파일러나 다른 프로그램에 필요한 정보를 전달하기 위해 사용한다.
  • 다른 사람에게 정보를 전달하기 위해 주석을 사용하는 것과 비슷한 맥락이라고 이해할 수 있다.
  • 예를 들어, @Override 애너테이션은 해당 메서드가 오버라이딩 된 메서드임을 컴파일러에게 알려주는 역할을 한다.

- 애너테이션의 종류

  • JDK에서 기본적으로 제공하는 애너테이션
    • 표준 애너테이션 : JDK에 내장된 일반적 애너테이션.
    • 메타 애너테이션 : 다른 애너테이션을 정의할 때 사용하는 애너테이션, 애너테이션을 위한 애너테이션이다.
  • 사용자가 직접 정의해서 사용하는 애너테이션 : 사용자 정의 애너테이션

* 표준 애너테이션

- @Override

  • 메서드 앞에만 붙일 수 있다. 해당 메서드가 오버라이딩 된 것임을 컴파일러에게 알려준다.
  • 컴파일러는 컴파일 과정에서 @Override가 붙은 메서드를 발견하면 상위 타입에 동명의 메서드가 있는지 검사한다. 만약 없다면 컴파일 에러를 발생시킨다.
  • 이러한 과정을 통해 오버라이딩 단계에서 발생할 수 있는 문제(오타 등)를 미리 발견하고 줄일 수 있다.

- @Deprecated

  • 타입, 필드, 메서드 구분 없이 사용할 수 있다.
  • 기존에 사용하던 기능을 더 이상 사용하지 않도록 유도 할 때 사용한다.
  • 실제로 @Deprecated 애너테이션이 붙었다고 해당 기능이 작동하지 않는 것은 아니다. 다만 IDE와 컴파일러 차에서 사용하지 않는 방향 권장하는 메시지를 보낸다.
  • 수정을 요하거나, 리팩토링이 필요한 기능에 활용할 수 있다.

- @SuppressWarnings

  • 컴파일 경고 메시지가 나타나지 않도록 한다.
  • 애너테이션 옆에 괄호와 문자열을 통해 어떤 경고 메시지를 표시하지 않을지를 정할 수 있다. 
    • ex) @SuppressWarnings("null")
  • 중괄호를 이용해 여러 유형을 한 번에 묶어 처리할 수도 있다.
    • ex) @SuppressWarnings({"deprecation", "unused", "null"})

- @FucntionalInterface

  • 컴파일러가 함수형 인터페이스가 제대로 작성되었는지 검사하게 한다.
  • 함수형 인터페이스는 하나의 추상 메서드만을 가져야한다.
  • 만일 추상 메서드가 없거나, 두 개 이상일 경우 컴파일 단계에서 에러를 발생시킨다.

* 메타 애너테이션

  • 애너테이션을 정의하는 단계에서, 해당 애너테이션의 범위나 유지 기간을 지정하는데 사용한다.
  • 애너테이션은 @interface 키워드를 사용해 정의할 수 있다. 

- @Target

  • 애너테이션을 적용할 대상을 지정하는 데 사용한다.
  • @Target을 통해 지정할 수 있는 대상 타입과 대상 타입에 따른 적용범위, 사용 예는 다음과 같다.
대상 타입 적용 범위
ANNOTATION_TYPE 애너테이션
CONSTRUCTOR 생성자
FIELD 필드(멤버변수, Enum)
LOCAL_VARIABLE 지역변수
METHOD 메서드
PACKAGE 패키지
TYPE 타입(클래스, 인터페이스, ENUM)
TYPE_PARAMETER 타입 매개변수
TYPE_USE 타입이 사용되는 모든 대상
import static java.lang.annotation.ElementType.*; 

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

@MyAnnotation	// 적용대상이 TYPE인 경우
class Main {
    
	@MyAnnotation	// 적용대상이 FIELD인 경우
    int i;
    
    @MyAnnotiation // 적용대상이 TYPE_USE인 경우
    MyClass myClass;
}

 

- @Documented

  • 애너테이션에 대한 정보가 javadoc으로 작성한 문서에 포함되도록 한다.
  • 별로 사용할 일은 없다.

- @Inherited

  • 애너테이션을 하위 클래스에 상속시킨다.
  • @Inherited 가 붙은 애너테이션을 붙인 클래스를 상속한 하위 클래스는 해당 애너테이션을 따로 작성하지 않아도 애너테이션이 붙은 것으로 인식한다.

- @Retention

  • 애너테이션의 지속 시간을 결정하는데 사용한다.
  • 애너테이션이 유지되는 기간(유지 정책)은 아래의 세 가지가 있다.
    • SOURCE : 소스 파일에 존재한다. 컴파일하고 난 이후인 클래스 파일에는 존재하지 않는다.
    • CLASS : 클래스 파일에 존재한다. 그러나 실행 시에는 사용할 수 없다.
    • RUNTIME : 클래스 파일에 존재하며, 실행시에도 사용할 수 있다.

- @Repeatable

  • 애너테이션을 여러 번 붙일 수 있도록 허용할 때 사용한다.
  •  이 때, 한 번에 같은 애너테이션이 여러 번 붙은 것을 담을 별도의 패키지 애너테이션도 작성해야 한다. 이 때, 담는 형태는 배열이며, 배열의 이름은 반드시 value()여야 한다.
    • 예를 들어 @Todo 애너테이션을 만들고 @Repeatable을 붙였다고 하자.
    • 그러면 @Todos 애너테이션을 만들고 필드에 Todo[] value();를 반드시 정의하여야 한다.

* 사용자 정의 애너테이션

  • 위에서 신나게 만들었다. @MyAnnotation, @Todo 등이 사용자 정의 애너테이션이다.
  • @interface로 만든다는 것만 알고 있자.

람다

* 람다식의 기본 문법

  • 람다식은 기본적으로 (argument) -> {메서드 바디} 의 형태로 작성된다. 
  • 이 때, 메서드 바디의 실행문이 한 줄이라면, return문과 중괄호를 생략할 수 있다.
  • 함수형 인터페이스로부터 매개변수 타입의 추론이 가능하면 매개변수의 타입을 생략할 수 있다.
  • 아래의 예시를 통해 살펴보자.
// 일반적인 형태의 메서드
public int sum (int num1, int num2) {
    return num1 + num2;
}

// 람다식
(int num1, int num2) -> {return num1 + num2;}

// 중괄호와 return문을 제거한 람다식
(int num1, int num2) -> num1 + num2;

// 함수형 인터페이스로부터 매개변수 타입의 추론이 가능한 람다식
(num1, num2) -> num1 + num2;

* 함수형 인터페이스

  • 자바에서 함수는 독립적으로 사용할 수 없다. 반드시 객체를 생성하고 그 안에서 사용되어야 한다.
  • 따라서 람다식 역시 메서드 자체가 아니라 하나의 객체이다.
  • 기본적으로는 람다식을 사용하기 위해 익명 클래스를 사용한다.
  • 익명 클래스는 객체의 선언과 생성을 동시에 진행하여, 일회성으로 사용되고 재사용하지 않는 클래스를 의미한다.
  • 그러나 익명 클래스는 재사용이 불가능하므로, 선언만 가능하고 사용이 불가능하다. 굉장히 모순적이다.
public class Main {

    public static void main(String[] args) {
        Object calculator = new Object(){
            int sum(int num1, int num2){
                return num1+num2;
            }
        };

        System.out.println(calculator.sum(1,2));
    }
}
더보기

 해당 코드는 작동하지 않는다. calculator가 익명 클래스로 선언되었기 때문에 sum을 사용할 수 없기 때문이다.

 

  • 이러한 문제를 해결하기 위해 함수형 인터페이스를 사용한다.
  • 앞서 언급한대로 함수형 인터페이스는 단 하나의 추상 메서드만 선언될 수 있다. 이는 람다식과 함수형 인터페이스의 메서드가 1대1 매핑 되어야 하기 때문이다.
  • 만약 함수형 인터페이스의 메서드가 여러 개라면, 람다식에 매핑할 메서드가 무엇인지 알 수 없다.
  • 아래 예시를 통해 함수형 인터페이스를 사용하여 람다식을 활용하는 예시를 알아보자.
@FunctionalInterface
interface Calculator{
    int calculate(int num1, int num2);
}

public class Main {

    public static void main(String[] args) {
    
        /**
         * 각각의 변수는 interface를 구현한 구현 클래스가 된다.
         * 예를 들어 add는 
         * Class add implements Calculator{
         *       @Override
         *       int calculate(int num1, int num2) {
         *           return num1 + num2;
         *       }
         *}
         *가 되는 것이다.
         */
         
        Calculator add = (num1, num2) -> num1 + num2;
        Calculator sub = (num1, num2) -> num1 - num2;
        Calculator mul = (num1, num2) -> num1 * num2;
        Calculator div = (num1, num2) -> num1 / num2;

        System.out.println(add.calculate(4,2));
        System.out.println(sub.calculate(4,2));
        System.out.println(mul.calculate(4,2));
        System.out.println(div.calculate(4,2));
    }
}


========================================================================

6
2
8
2

 

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

  • 함수형 인터페이스의 메서드를 void method(); 의 형태로 구현하면 람다식 역시 매개변수도, 반환 값도 없다.
  • 매개변수가 없기 때문에 소괄호가 비어있다. () -> { 메서드 바디 ;} 의 형태로 사용된다.
  • 아래 예시를 통해 살펴보자.
@FunctionalInterface
interface Calculator {
    void calculate();
}

public class Main {

    public static void main(String[] args) {
        Calculator add = () -> System.out.println("저는 덧셈을 못해요.");
        Calculator sub = () -> System.out.println("저는 뺄셈도 못해요.");
        Calculator mul = () -> System.out.println("제가 곱셈은 할 줄 알겠어요?");
        Calculator div = () -> System.out.println("나눗셈은 물어보지도 마세요.");
        Calculator idiot = () -> {
            System.out.println("저는 바보에요.");
            System.out.println("저는 그냥 말하는 감자에요.");
        };

        add.calculate();
        sub.calculate();
        mul.calculate();
        div.calculate();
        idiot.calculate();
    }
}

====================================================================================

저는 덧셈을 못해요.
저는 뺄셈도 못해요.
제가 곱셈은 할 줄 알겠어요?
나눗셈은 물어보지도 마세요.
저는 바보에요.
저는 그냥 말하는 감자에요.
더보기

 아주 멍청한 계산기가 됐다. 매개변수가 없기 때문에 소괄호 내에는 어떤 매개변수도 적지 않으며, 반환값이 없기 때문에 별도의 연산 처리도 하지 않는다. 그저 중괄호 내의 메서드 바디 부분만 수행한다. 

 

- 매개변수가 있고 반환 값이 없는 람다식

  • 소괄호에 매개변수를 넣어주는 것 외에는 차이가 없다.
@FunctionalInterface
interface Calculator {
    void calculate(int x);
}

public class Main {

    public static void main(String[] args) {
        Calculator add = (num) -> {
            System.out.println("2를 더해볼까요.");
            System.out.println(num+2);
        };
        Calculator sub = (num) -> {
            System.out.println("2를 빼볼까요.");
            System.out.println(num-2);
        };
        add.calculate(5);
        sub.calculate(5);
    }
}

======================================================================

2를 더해볼까요.
7
2를 빼볼까요.
3

 

- 리턴값이 있는 람다식

@FunctionalInterface
interface Calculator {
    int calculate(int num1, int num2);
}

public class Main {

    public static void main(String[] args) {
        Calculator add = (num1, num2) -> {
            System.out.println("귀찮은데 떠넘겨야지.");
            return sum(num1, num2);
        };

        Calculator add2 = (num1, num2) -> sum(num1, num2); // 당연히 return만 하는 람다식이면 중괄호의 생략이 가능하다.
        
        System.out.println(add.calculate(2,3));
        System.out.println(add2.calculate(3,4));
    }

    private static int sum(int num1, int num2) {
        return num1 + num2;
    }
}

=============================================================================

귀찮은데 떠넘겨야지
5
7

* 메서드 레퍼런스

  • 메서드 레퍼런스는 람다식에서 불필요한 매개변수를 제거하고 더욱 단순화할 때 사용한다.
  • 기본적인 사용 방식은 '클래스이름::메서드이름' 이다.
  • 람다식과 마찬가지로 함수형 인터페이스의 추상 메서드가 어떤 매개변수를 갖는지, 어떤 반환 타입을 갖는지에 따라 사용할 수 있는 메서드 레퍼런스가 달라진다.

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

  • 정적 메서드를 사용할 때는 '클래스이름::메서드이름'을 사용하면 된다.
  • 인스턴스 메서드를 사용할 때는 '사용할 메서드의 객체 인스턴스명 :: 메서드이름'을 사용하면 된다.
import java.util.function.IntBinaryOperator;

class Calculator{
    int sum(int num1, int num2){
        return num1 + num2;
    }
}

public class Main {

    public static void main(String[] args) {

        IntBinaryOperator operator; // left, right 두 숫자 사이의 연산을 처리하는 java의 기본 fuctional interface

        operator = Math::addExact; // 정적 메서드 사용
        System.out.println(operator.applyAsInt(1,2));

        Calculator calculator = new Calculator();
        operator = calculator::sum; // 인스턴스 메서드 사용

        System.out.println(operator.applyAsInt(2,3));
    }
}

=====================================================================================

3
5

스트림

* 스트림의 핵심 개념과 특징

  • 스트림은 배열과 컬렉션의 저장 요소를 하나씩 참조해서 람다식으로 처리할 수 있도록 하는 반복자다.
  • 그 과정이 마치 물이 흐르듯 특정한 흐름에 따라 진행되기 때문에 stream이라는 이름이 붙었다.

- 스트림의 도입 배경

  • for문이나 iterator는 코드가 길고 복잡해진다. 스트림을 이용하면 한 줄로 줄일 수 있다.
  • 스트림을 사용하면 선언형 프로그래밍 방식으로 데이터를 처리할 수 있다.
  • 프로그램의 동작을 일일이 구현해야하는 명령형 프로그래밍과 달리 선언형 프로그래밍을 사용하면 내부적으로는 어떻게 돌아가는지는 관심 없이 메서드의 이름만으로 메서드의 역할과 기능을 알 수 있다. 어디서 많이 들어본 얘기다.
import java.util.*;

public class Main {
    public static void main(String[] args) {

        List<Integer> list = List.of(1, 3, 5, 7, 8, 9, 10);

        /**
         * 명령형 프로그래밍 방식
         */
        int sum = 0;
        for (int number : list) {
            if (number % 2 == 0) {
                sum += number;
            }
        }

        /**
         * 선언형 프로그래밍 방식
         */
        int sum2 = list.stream()
                .filter(number -> number % 2 == 0)
                .mapToInt(number -> number)
                .sum();

        System.out.println(sum);
        System.out.println(sum2);
    }
}
더보기

 잘은 몰라도 list를 돌면서(stream) number를 조건에 맞게 거르고(filter) 그 값들을 정수로 매핑해(mapToInt) 더한다. 라고 짐작해볼 수 있다.

 

- 스트림의 특징

  • 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다.
    • stream(); 메서드를 통해 스트림을 생성한다.
    • 필터링, 매핑, 정렬 등의 중간 연산을 진행한다. 이 때, 중간 연산의 결과 역시 스트림이기 때문에 중간 연산을 연속해서 수행할 수 있다. 이렇게 연결된 모양이 파이프라인과 같아서 이러한 구조를 스트림 파이프라인이라 한다.
    • 합, 평균, 카운트 등의 최종 연산을 수행한다. 최종 연산을 모두 수행하면 스트림이 닫히고 모든 데이터 처리가 완료된다. 최종 연산 과정에서 스트림의 요소들을 소모하면서 연산을 진행하기 때문에 최종 연산은 한 번만 가능하다.
    • 만약, 최종 연산 이후 새로운 연산을 하고 싶다면, 다시 스트림의 생성부터 시작하면 된다.
    • 위의 sum 을 구하는 연산을 통해 해당 과정을 도식화 해보자.

  • 스트림은 원본 데이터 소스를 변경하지 않는다
    • 스트림에 의해 원본 데이터가 변경되는 것을 방지하기 위함이다.
    • 스트림은 데이터를 읽어와서 생성된 스트림 내부에서만 데이터의 변경과 처리를 진행한다.
  • 스트림은 일회용이다.
  • 스트림은 내부 반복자이다.
    • 외부 반복자는 코드로 직접 요소를 가져오는 코드 패턴을 의미한다. 예를 들어, index를 이용하는 for문이나 while문 등이 있다.
    • 내부 반복자는 컬렉션 내부에 데이터 요소 처리 방법을 주입시켜 요소를 반복처리하는 방식이다.


* 스트림의 생성

- 배열 스트림 생성

  • Arrays.stream()
    • Stream<T> stream = Arrays.stream(배열 이름) 으로 stream을 생성할 수 있다.
  • Stream.of()
    • Stream<T> stream = Stream.of(배열 이름) 으로 stream을 생성할 수 있다.
  • IntStream, LongStream, DoubleStream
    • 숫자 값으로 된 스트림들은 위의 기본형 스트림으로 생성할 수 있다.
    • 기본형 스트림에는 원시 타입으로 값들이 저장되기 때문에 메모리를 덜 잡아먹는다.
    • 위의 이유로 기본형 스트림은 Integer <-> int 와 같은 박싱과 언박싱 과정을 생략하기 때문에 컴퓨터의 자원을 덜 사용한다.
    • 또한 기본형 스트림들은 다양한 기능을 제공하기 때문에 숫자와 관련된 경우에는 기본형 스트림을 사용하자.

- 컬렉션 스트림 생성

  • stream()
    • Stream<T> stream = 컬렉션이름.stream() 으로 stream을 생성할 수 있다.

- 임의의 수 스트림 생성

  • Random클래스와 함께 스트림을 사용하면 난수 스트림을 생성할 수 있다.
  • 예를 들어 IntStream intStream = new Random().ints(); 를 사용하면 난수의 무한 스트림을 생성할 수 있다.
  • 무한 스트림은 주로 limit(int limit) 과 같은 메서드와 함께 사용하거나 ints();에 매개변수로 스트림의 사이즈를 전달해서 크기를 제한해서 사용한다.
    • IntStream intStream = new Random().ints(5);
    • IntStream intStream = new Random().ints().limit(5);
    • 둘 모두 크기가 5인 난수 스트림을 생성한다.
  • range(int start, int end)rangeClosed(int start, int end) 메서드를 사용해 특정 범위 내의 정수 값을 스트림으로 생성할 수 있다. 둘의 차이는 end를 포함하느냐 아니냐에 있다.
    • IntStream intStream = IntStream.rangeClosed(1, 10); 은 1~10이 담긴 IntStream을 생성한다.

* 스트림의 중간 연산

- 필터링 (filter(), distinct())

  • 필터링은 내가 원하는 조건에 맞는 데이터만을 찾는 역할을 한다.
  • distinct() : 중복을 제거한다.
  • filter() : 조건을 걸고 그에 맞는 더 작은 컬렉션을 만든다. 이 때, 조건은 람다식을 사용해 정의할 수 있다.
import java.util.*;

public class Main {
    public static void main(String[] args) {

        List<Integer> numbers = List.of(1, 1, 3, 3, 5, 7, 8, 8, 9, 9, 10);

        // 중복 제거
        numbers.stream().distinct()
                .forEach(number -> System.out.print(number+" "));
        System.out.println();

        // 조건
        numbers.stream().filter(number -> number>4)
                .forEach(number -> System.out.print(number+" "));
        System.out.println();

        // 중복 제거 및 조건
        numbers.stream().distinct()
                .filter(number-> number%2==1 && number<9)
                .forEach(number -> System.out.print(number+" "));
    }
}

========================================================================

1 3 5 7 8 9 10 
5 7 8 8 9 9 10 
1 3 5 7

 

- 매핑 (map())

  • 매핑은 스트림 내에서 원하는 필드만 추출하거나 요소들을 특정한 형태로 변환할 때 사용하는 중간 연산자다.
  • 값을 변환하기 위한 조건을 람다식으로 정의한다.
  • 이차원 배열과 같은 중첩 구조는 flatMap()을 통해 단일 컬렉션으로 만들어 줄 수 있다.
import java.util.*;

public class Main {
    public static void main(String[] args) {

        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
        List<List<Integer>> doubleNumbers = List.of(List.of(1, 2, 3), List.of(4, 5, 6));

        // map을 통한 요소 처리
        numbers.stream()
                .map(number -> number * 2)
                .forEach(number -> System.out.print(number + " "));
        System.out.println();

        // 이중 List를 그냥 쓰면?
        doubleNumbers.stream()
                .map(Collection::stream)
                .forEach(number -> System.out.print(number + " "));
        System.out.println();

        // flatMap을 사용
        doubleNumbers.stream()
                .flatMap(Collection::stream)
                .forEach(number -> System.out.print(number + " "));
    }
}

 

- 정렬 (sorted())

  • 정렬을 하고 싶을 때 사용한다.
  • sorted()에 인자를 넣지 않으면 오름차순으로 정렬한다.
  • 다른 방식으로 정렬하고 싶으면 Compartor의 static 메서드들을 이용하자.
import java.util.*;

public class Main {
    public static void main(String[] args) {

        List<Integer> numbers = List.of(3,1,5,7,2,10);

        // 기본적인 오름차순 정렬
        numbers.stream()
                .sorted()
                .forEach(number -> System.out.print(number + " "));
        System.out.println();

        // 내림차순 정렬
        numbers.stream()
                .sorted(Comparator.reverseOrder())
                .forEach(number -> System.out.print(number + " "));
    }
}

================================================================================

1 2 3 5 7 10 
10 7 5 3 2 1

 

- 기타

  • skip() : 스트림의 일부 요소들을 건너뛴다.
  • limit() : 스트림의 일부를 자른다.
  • peek() : forEach()처럼 요소들을 순회하며 특정 작업을 수행한다. 중간 연산자이기 때문에 여러번 사용할 수 있다.

* 스트림의 최종 연산

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

  • 이름만 봐도 무슨 일을 하는지 알 수 있다.
import java.util.*;

public class Main {
    public static void main(String[] args) {

        List<Integer> numbers = List.of(3, 1, 5, 7, 2, 10);

        // 갯수 새기
        long count = numbers.stream()
                .count();

        // 합계 : Integer로 싸여있기 때문에 mapToInt를 해줘야 한다. int [] 형태였다면 필요 없다.
        long sum = numbers.stream()
                .mapToInt(number -> number)
                .sum();

        // 평균
        double average = numbers.stream()
                .mapToDouble(number -> number)
                .average()
                .getAsDouble();

        //최대값
        int max = numbers.stream()
                .mapToInt(number -> number)
                .max()
                .getAsInt();

        //최소값
        int min = numbers.stream()
                .mapToInt(number -> number)
                .min()
                .getAsInt();

        // 첫 번째 요소
        int first = numbers.stream()
                .findFirst()
                .get();

        System.out.println(count);
        System.out.println(sum);
        System.out.println(average);
        System.out.println(max);
        System.out.println(min);
        System.out.println(first);
    }
}
  • 이 때 최종 연산자 다음 get 메서드는 최종 연산자를 통해 반환되는 값이 Optional객체이기 때문이다.
  • Optional 객체에서 원하는 원시 타입을 뽑아오기 위해 get 메서드들을 사용한다.

- 매칭 (allMatch(), anyMatch(), nonMatch())

  • match() 메서드에 인자로 넣은 람다식 통해 각 데이터 요소들이 원하는 조건을 만족하는지를 검사할 수 있다.
    • allMatch() : 모든 요소들이 만족하면 true
    • nonMatch() : 모든 요소들이 만족하지 않으면 true
    • anyMatch() : 하나의 요소라도 만족하면 true
import java.util.*;

public class Main {
    public static void main(String[] args) {

        List<Integer> numbers = List.of(3, 1, 5, 7, 2, 10);

        // 모든 숫자가 짝수인지
        System.out.println(numbers.stream()
                .allMatch(number->number%2==0));
        // 짝수가 하나라도 있는지
        System.out.println(numbers.stream()
                .anyMatch(number->number%2==0));

        //모두 음수가 아닌지
        System.out.println(numbers.stream()
                .noneMatch(number->number<0));
    }
}

 

- 요소 소모(reduce())

  • reduce()는 스트림의 요소를 줄여나가면서 연산을 수행한다.
  • 일반적인 최종 연산과 다른 점은, reduce()는 인자로 BinaryOperator 객체를 받으며, 하나씩 연산을 수행한다.
    • 예를 들어서 1~10을 더하는 연산을 하기 위해 reduce((num1, num2) -> num1 + num2)를 수행한다고 해보자.
    • 이 때, 전달된 BinaryOperator는 (total, num) -> total + num 과 같이 전달된다.
    • 이 때, 초기값을 설정하지 않으면 처음에는 0이 전달된다. 1~10을 더하는 순서는 다음과 같다.
    • (0, 1) / (1,2) / (3,3) / (6,4) .... / (45/10)
    • 기존에 있었던 값과 새롭게 주어진 값이 '차례대로' 연산되는 것이 reduce의 특징이다.
  • 이 때, 연산의 초기값을 부여할 수 있다.
import java.util.*;

public class Main {
    public static void main(String[] args) {

        List<Integer> numbers = List.of(3, 1, 5, 7, 2, 10);

        // sum()
        long sum = numbers.stream()
                .mapToInt(n -> n)
                .sum();
        System.out.println("List 전체 요소 합: " + sum);

        // 초기값이 없는 reduce()
        int sum1 = numbers.stream()
                .mapToInt(element -> element * 2)
                .reduce((a, b) -> a + b)
                .getAsInt();
        System.out.println("초기값이 없는 reduce(): " + sum1);

        // 초기값이 있는 reduce()
        int sum2 = numbers.stream()
                .map(element -> element * 2)
                .reduce(5, (a, b) -> a + b);
        System.out.println("초기값이 있는 reduce(): " + sum2);
    }
}

========================================================================

List 전체 요소 합: 28
초기값이 없는 reduce(): 56
초기값이 있는 reduce(): 61

 

- 요소 수집 (collect())

  • 가공한 데이터들을 수집한다.
  • 연산 결과를 통해 새로운 형태의 컬렉션을 얻고 싶을 때 사용할 수 있다.(List -> Map) 
import java.util.*;
import java.util.stream.Collectors;

class Student {
    public enum Gender {Male, Female}

    ;
    private String name;
    private int score;
    private Gender gender;

    public Student(String name, int score, Gender gender) {
        this.name = name;
        this.score = score;
        this.gender = gender;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public Gender getGender() {
        return gender;
    }
}

public class Main {
    public static void main(String[] args) {
        // Student 객체로 구성된 배열 리스트 생성
        List<Student> totalList = List.of(
                new Student("김코딩", 100, Student.Gender.Male),
                new Student("박해커", 80, Student.Gender.Male),
                new Student("이자바", 90, Student.Gender.Female),
                new Student("나미녀", 60, Student.Gender.Female)
        );

        // 스트림 연산 결과를 Map으로 반환
        Map<String, Integer> maleMap = totalList.stream()
                .filter(s -> s.getGender() == Student.Gender.Male)
                .collect(Collectors.toMap(
                        student -> student.getName(), // Key
                        student -> student.getScore() // Value
                ));

        // 출력
        System.out.println(maleMap);
    }
}

======================================================================

{김코딩=100, 박해커=80}

파일 입출력 (I/O)

* InputStream, OutputStream

- FileInputStream

  • 기본적인 FileInputStream의 사용법은 다음과 같다.
  • 읽어오고자 하는 파일은 프로젝트 디렉토리 내부에 있어야 한다.
public class FileInputStreamExample {
    public static void main(String args[])
    {
        try {
            FileInputStream fileInput = new FileInputStream("myTextFile.txt");
            int i = 0;
            while ((i = fileInput.read()) != -1) { //fileInput.read()의 리턴값을 i에 저장한 후, 값이 -1인지 확인합니다.
                System.out.print((char)i); // 한 글자씩 읽어서 없을때까지 출력
            }
            fileInput.close();
        }
        catch (Exception e) {
            System.out.println(e);
        }
    }
}
  • 보조 스트림인 BufferedInputStream을 사용하면 버퍼에 여러 바이트를 저장하고 한 번에 데이터의 입출력을 처리하기 때문에 성능이 향상된다.
public class FileInputStreamExample {
    public static void main(String args[])
    {
        try {
            FileInputStream fileInput = new FileInputStream("myTextFile.txt");
	    BufferedInputStream bufferedInput = new BufferedInputStream(fileInput);
            int i = 0;
            while ((i = bufferedInput.read()) != -1) {
                System.out.print((char)i);
            }
            fileInput.close();
        }
        catch (Exception e) {
            System.out.println(e);
        }
    }
}

 

- FileOutputStream

  • 새로운 파일을 만들고 저장한다.
  • 같은 이름의 파일이 존재한다면 덮어쓴다.
public class FileOutputStreamExample {
    public static void main(String args[]) {
        try {
            FileOutputStream fileOutput = new FileOutputStream("newTextFile.txt");
            String word = "code";

            byte b[] = word.getBytes(); // 문자열을 byte단위로 받아서 byte 배열에 저장한다.
            fileOutput.write(b);
            fileOutput.close();
        }
        catch (Exception e) {
            System.out.println(e);
        }
    }
}

* FileReader / FileWriter

  • InputStream, OutputStream은 입출력을 바이트 단위로 진행한다.그러나 자바에서의 char타입은 2바이트다.
  • 이러한 문제를 해소하기 위해 제공되는 문자 기반 스트림이 FileReader와 FileWriter다.
  • 그러나 문자 기반 스트림은 문자밖에 쓰지 못하기 때문에 사진, 동영상 등의 파일을 다루기 위해서는 바이너리 스트림을 써야 한다.
public class FileReaderExample {
    public static void main(String args[]) {
        try {
            String fileName = "myTextFile.txt";
            FileReader file = new FileReader(fileName);

            int data ;

            while((data=file.read()) != -1) {
                System.out.print((char)data);
            }
            file.close();
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • FileReader 역시 버퍼를 사용할 수 있다.
public class BufferedReaderExample {
    public static void main(String args[]) {
        try {
            String fileName = "myTextFile.txt";
            FileReader file = new FileReader(fileName);
            BufferedReader buffered = new BufferedReader(file);

            int data = 0;

            while((data=buffered.read()) != -1) {
                System.out.print((char)data);
            }
            file.close();
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

- FileWriter

  • FileOutputStream과 비슷하게 사용할 수 있다.
public class FileWriterExample {
    public static void main(String args[]) {
        try {
            String fileName = "myTextFile.txt";
            FileWriter writer = new FileWriter(fileName);

            String str = "written!"; // 문자열을 그대로 받아서 넣는다.
            writer.write(str);
            writer.close();
        }
        catch (IOException e) {
            e.printStackTrace();
        }
    }
}

* File

  • File 클래스를 통해 파일과 디렉토리에 접근할 수 있다.
  • 파일 인스턴스를 생성한다고 파일이 생성되는 것은 아니다. 파일 인스턴스를 생성한 뒤, 인스턴스명.createNewFile();을 통해 파일을 생성할 수 있다.

쓰레드

* 쓰레드란?

- 프로세스와 쓰레드

  • 실행 중인 프로그램을 프로세스라고 부른다.
  • 프로세스는 데이터, 컴퓨터 자원, 쓰레드로 구성된다. 이 때, 쓰레드는 데이터와 자원을 활용하여 소스 코드를 실행한다.
  • 즉, 프로세스는 하나의 코드 실행 흐름으로 프로세스의 프로세스라고 이해할 수 있다.
  • OS에서 여러 프로세스를 올리는 것처럼, 하나의 프로세스에서 여러 쓰레드가 올라갈 수 있다.

- 메인 쓰레드

  • 자바 어플리케이션을 실행하면 가장 먼저 main메서드가 실행 된다.
  • 메인 쓰레드는 main 메서드를 실행시켜주는 역할을 한다.
  • 자바 어플리케이션의 소스 코드에 별도의 쓰레드를 생성하는 코드가 없다면, 해당 어플리케이션은 메인 쓰레드만 갖는 싱글 쓰레드 프로세스가 된다.
  • 그러나, 또 다른 쓰레드를 생성하는 코드가 있고, 이를 실행한다면 어플리케이션은 멀티 쓰레드로 동작한다.

- 멀티 쓰레드

  • 하나의 프로세스가 여러 동작(멀티 태스킹)을 하기 위해서는 멀티 쓰레드 기능을 구현해야 한다.

* 쓰레드의 생성과 실행

- 작업 쓰레드 생성과 실행

  • 멀티 쓰레드 환경은 여러 개의 작업 쓰레드를 생성하고 각 쓰레드가 수행할 동작을 구현해 실행시켜 만든다.
  • 멀티 쓰레드 환경을 만들기 위해서는 두 가지 방법이 있다.
    • Runnable 인터페이스를 구현한 객체에서 run()을 구현해 작업 쓰레드의 동작을 구현하고 실행한다.
    • Thread 클래스를 상속 받은 하위 클래스에서 run()을 구현해 작업 쓰레드의 동작을 구현하고 실행한다. 

- Runnable 인터페이스 구현

class TaskThread implements Runnable {
    @Override
    public void run() {  // run() 메서드 구현
        for(int i=0;i<30;i++) {
            System.out.print("#");
        }
    }
}

public class Main {
    public static void main(String[] args){
        Runnable task = new TaskThread();
        Thread thread = new Thread(task);

        thread.start();

        for(int i=0;i<30;i++){
            System.out.print("@");
        }
    }
}

======================================================

@@@@@######################@@@@@@@@@@@@@@@@@@@@@@@@@######## // 실행 결과는 실행마다 다르다
  • 해당 코드의 실행 결과가 실행마다 다른 이유는, 두 쓰레드가 병렬적으로 처리되기 때문이다.
  • 이러한 문제를 동시성 문제라고 하며, 동시성 문제는 데이터를 다루는 상황에서 치명적인 오류를 발생시킬 수 있기 때문에 반드시 해결해야 한다.

- Thread 클래스 상속

class TaskThread extends Thread {

    @Override
    public void run() {
        for(int i=0;i<30;i++) {
            System.out.print("#");
        }
    }
}

public class Main {
    public static void main(String[] args){
        Thread thread = new TaskThread();

        thread.start();

        for(int i=0;i<30;i++){
            System.out.print("@");
        }
    }
}

====================================================

@@##############################@@@@@@@@@@@@@@@@@@@@@@@@@@@@ // 실행 결과는 실행마다 다르다
  • Runnable 인터페이스를 구현한 것과 마찬가지로 run() 메서드에 작성된 코드를 처리한다.

- Runnable 익명 구현 객체의 활용

public class Main {
    public static void main(String[] args){
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 30; i++) {
                System.out.print("#");
            }
        });

        thread.start();

        for (int i = 0; i < 30; i++) {
            System.out.print("@");
        }
    }
}

 

- Thread 익명 하위 객체의 활용

public class Main {
    public static void main(String[] args){
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 30; i++) {
                System.out.print("#");
            }
        });

        thread.start();

        for (int i = 0; i < 30; i++) {
            System.out.print("@");
        }
    }
}

* 쓰레드의 이름

  • 기본적으로 메인 쓰레드의 이름은 'main' , 추가적으로 생성한 쓰레드는 'Thread-n'의 이름을 갖는다.

- 쓰레드의 이름 조회

  • 쓰레드 이름은 '쓰레드 참조변수 이름.getName()'으로 조회할 수 있다.
public class Main {
    public static void main(String[] args){
        Thread specialThread = new Thread(() -> {
            for (int i = 0; i < 30; i++) {
                System.out.print("#");
            }
        });

        System.out.println("specialThread의 이름은 " + specialThread.getName());
    }
}

====================================================

specialThread의 이름은 Thread-0

 

- 쓰레드의 이름 설정

  • 쓰레드 이름은 '쓰레드 참조변수 이름.setName()'으로 설할 수 있다.
public class Main {
    public static void main(String[] args){
        Thread specialThread = new Thread(() -> {
            for (int i = 0; i < 30; i++) {
                System.out.print("#");
            }
        });

        specialThread.setName("특별한 쓰레드.");

        System.out.println("specialThread의 이름은 " + specialThread.getName());
    }
}

========================================================================

specialThread의 이름은 특별한 쓰레드.

 

- 쓰레드 인스턴스의 주소값 얻기

  • 쓰레드 이름의 조회와 설정은 인스턴스 메서드이기 때문에 객체 인스턴스의 참조가 필요했다.
  • 실행 중인 쓰레드의 주소값을 사용해야 한다면, 정적 메서드인 currentThread()를 사용하면 된다.
public class Main {
    public static void main(String[] args){
        Thread specialThread = new Thread(() -> {
            System.out.println(Thread.currentThread().getName()); // 현재 쓰레드인 specialThread의 주소에 저장된 이름을 출력한다.
        });

        specialThread.setName("특별한 쓰레드.");

        specialThread.start();
        System.out.println(Thread.currentThread().getName()); // 현재 쓰레드인 메인 쓰레드의 이름을 출력한다.
    }
}

============================================================

main
특별한 쓰레드.

* 쓰레드의 동기화

  • 공유되는 객체에 여러 쓰레드가 동시에 접근하여 데이터를 다룬다면 오류가 발생한다.
  • 문서를 작성하는 상황 중 한 페이지에 여러 명이 동시에 붙는다고 생각하면 이해가 쉽다. 문장 하나를 완성하는데도 수많은 오류가 발생할 것이다.
  • 따라서 이러한 동기화 문제는 멀티 쓰레드 환경을 구현하기 위해서는 반드시 해결해야 한다.

- 임계 영역과 락

  • 임계 영역은 하나의 스레드만 코드를 실행할 수 있는 코드 영역이며, 락은 임예 영역을 포함하고 있는 객체에 접근할 수 있는 권한이다.
    • 임계 영역으로 설정된 객체 Object가 있다.
    • Object에 어떤 쓰레드도 접근하지 않은 상태일 때, 임의의 쓰레드 A가 락을 얻어 해당 객체에 접근한다.
    • 이 때, 다른 쓰레드들은 락이 없으므로 Object에 접근할 수 없다.
    • A가 모든 작업을 마친 뒤, 락을 반납한다.
    • 다시 임의의 쓰레드가 락을 얻어 Object에 접근할 수 있다.
  • 특정 코드 영역을 임계 영역으로 실행하기 위해서는 syncronized 키워드를 사용해야 한다.
    • 메서드 전체를 임계 영역으로 지정하기 위해서는 반환 타입 앞에 syncronized를 적어준다.
    • 특정 영역만 임계 영역으로 실행하기 위해서는 syncronized(this) { } 영역 안에 내용을 적는다.
public synchronized boolean withdraw(int money) {
	    if (balance >= money) {
	        try { Thread.sleep(1000); } catch (Exception error) {}
	        balance -= money;
	        return true;
	    }
	    return false;
}
    
======================================================

public boolean withdraw(int money) {
	    synchronized (this) {
		    if (balance >= money) {
			    try { Thread.sleep(1000); } catch (Exception error) {}
		        balance -= money;
		        return true;
		    }
		    return false;
		}
}

* 쓰레드의 상태와 실행 제어

  • start()는 쓰레드를 '실행 대기 상태 (Runnable)'로 만드는 메서드이다.

- 쓰레드의 상태와 실행 제어 메서드

- 쓰레드 실행 제어 메서드

  • sleep(long milliSecond) 
    • milliSecond 동안 쓰레드를 멈춘다.
    • sleep은 클래스 메서드이기 때문에, Thread.sleep(1000); 과 같이 클래스를 통해 호출하자.
    • 실행 중이던 쓰레드에 sleep() 처리를 하면 일시정지 상태에 진한다.
    • sleep()에 의해 일시 정지된 쓰레드는 argument로 넣어준 시간이 경과하거나 interrupt()를 통해 깨우면 다시 실행 대기 상태로 복귀한다.
    • sleep은 try-catch문 내에 작성해야 하는데, 그 이유는 깨우는 방식에 있다.
  • interrupt()
    • 일시 정지 상태에 있는 쓰레드들을 실행 대기 상태로 복귀시킨다.
    • 일시 정지 상태의 쓰레드가 아닌 다른 쓰레드에서 '일시정지쓰레드.interrupt()'를 호출하면 일시 정지한 쓰레드에서 예외가 발생하고 일시 정지가 풀리게 된다.
    • 예외를 발생시켜 깨우는 방식이기 때문에 sleep()은 try - catch 문 내에 작성해야 한다.
  • yield()
    • 다른 쓰레드에게 자신의 실행 시간을 양보한다.
    • 반복적인 작업을 실행하는 쓰레드가, 사실은 불필요한 일을 하고 있다면, 이런 경우에 yield를 통해 자신은 실행 대기 상태에 진입 다른 쓰레드에게 실행 시간을 넘겨줄 수 있다.
  • join()
    • 다른 쓰레드가 작업하는 동안 자신을 일시 중지 상태에 진입시킨다.
    • 시간을 argument로 넣어 특정 시간 동안만 일시 중지 상태에 진입시킬 수 있다.
    • interrupt()를 통해 깨울 수도 있다. 따라서 join()은 try - catch 문 내에 작성해야 한다.
  • wait(), notigy()
    • 두 쓰레드가 교대로 하나의 작업을 처리해야 할 때, 둘의 협업을 돕는 메서드다.
      • A는 notify를 통해 B를 실행 대기 상태에 진입시키고 본인은 wait을 통해 일시 정지 상태에 진입한다.
      • B는 작업 후 notify로 A를 부르고 본인은 wait 한다.
      • 이 과정을 반복한다.

JAVA VIRTUAL MACHINE

* JVM이란?

  • 기존의 C / C++는 컴파일링 단계부터 OS dependency했다. 이들은 이러한 문제를 크로스 컴파일을 통해 해결했다.
  • 그러나 플랫폼이 다양해지면서, 모든 플랫폼에 맞춰서 컴파일을 하기는 불가능하기 때문에, JAVA가 개발되었다.
  • 자바 소스코드는 javac에서 컴파일이 되면 JAVA ByteCode로 전환된다.
  • 이 JAVA ByteCode는 JVM을 통해 OS에 맞게 변형되어 OS dependency하게 실행된다.
  •  당연하게도 JVM은 OS dependency하다. 따라서 JAVA를 이용하고 싶다면, 각 OS에 맞는 JVM이 설치되어 있어야 한다.

* JVM 구조

  • 작성된 자바 소스코드는 javac를 통해 컴파일되어 바이트 코드로 전환된다.
  • JVM은 OS로부터 소스코드를 실행하는데 필요한 메모리 공간을 할당받는다. 
  • 이 때 할당받는 메모리 공간이 Runtime Data Area이다.
  • 이후 Class Loader에서 바이트 코드 파일을 JVM으로 가져와 Runtime Data Area에 적재시킨다. 즉, 자바 소스 코드를 메모리에 로드한다.
  • 로드가 완료되면, Execition Engine이 적재된 바이트 코드를 실행한다. 이 때 두 가지 방식이 존재한다.
    • Interpreter를 통해 코드를 한 줄씩 기계어로 번역하고 실행한다.
    • JIT Compiler를 통해 바이트 코드 전체를 기계어로 번역하고 실행한다.
  • 기본적으로는 Interpreter를 통해 실행되나, 중복되는 바이트 코드는 JIT Compiler를 통해 한 번에 실행한다.

* Stack과 Heap

- Runtime Data Area

  • Method Area와 Heap은 모든 쓰레드가 공유하는 Data Area이다.
    • Method Area
      • 클래스 로더가 파일을 읽어오면 클래스의 정보를 파싱해서 저장하는 장소다.
      • 클래스 멤버나 정적 멤버들을 저장한다.
    • Heap
      • 프로그램을 실행하면서 생성한 모든 객체 인스턴스를 저장하는 장소다.
  • Java Stack, PC (Program Counter) Register, Native Method Stack은 쓰레드마다 존재한다.
    • PC Register
      • 각 쓰레드는 어떤 메서드를 항상 실행하고 있다.
      • PC는 해당 메서드 내의 몇 번째 줄의 바이트코드를 실행하고 있는지를 나타낸다.
    • Java Stack
      • Java Stack은 쓰레드 별로 하나씩 존재한다.
      • 각 스택에는 Stack Frame이 쌓이게 되는데, 이 때 최상단에 놓인 Stack Frame에는 main method가 자리한다.
      •  이후로는 frame이 stack에 쌓이게 되는데, 그 순서는 main method가 호출한 메소드 -> 그 메소드가 호출한 메소드가 순차적으로 쌓이게 된다.
      • 즉, 메소드를 호출할 때마다 Stack Frame이 하나씩 생성되어서 쌓이게 된다.
      • 메서드의 실행이 끝나면 해당 Stack Frame은 pop되서 스택에서 제거된다.
      • 각 Stack Frame 은 Local variables array, Operand stack, Frame Data를 갖는다.
      • 이 중 Frame Data에는 이전 스택 프레임에 대한 정보와 현재 메서드가 속한 클래스 / 객체에 대한 잠조 등이 담겨 있다.
      • 생각해보면, 바이트 코드를 실행하고 이전 Stack Frame으로 넘어가야 하는데, 당연히 필요한 정보들이다. 메서드가 어떤 객체에 있는지 알아야 수행하고, 이전 단계가 어딘지 알아야 넘어가지.
    • Native Method Stack
      • 자바 바이트코드가 아닌 다른 언어로 작성된 코드를 컴파일해서 사용하기 위해 사용한다

* Garbage Collection

- Garbage Collection 이란?

  • 가비지 컬렉션은 프로그램에서 더 이상 사용하지 않는 객체를 삭제하여 메모리를 확보하는 것이다.
  • 즉, 동적으로 할당된 메모리 영역 중 사용하지 않는 영역을 탐지하여 해제하는 기능이다.
  • 이 때, 동적으로 할당된 메모리 영역은 Heap이다. Heap 영역에는 우리가 동적으로 생성한 객체 인스턴스의 정보가 저장되기 문이다.

- 동작 방식

  • JVM의 Heap 영역은 내부의 데이터가 짧게 저장된다는 것을 전제로 설계한다.
  • 따라서 Heap 영역은 객체의 생성 시점에 따라 내부적으로 Young 영역과 Old 영역으로 나뉜다.
  • Young 영역은 새롭게 생성된 객체가 할당되는 곳으로, 이 영역에서 활동하는 가비지 컬렉터를 Minor GC라고 부른다.
  • Old 영역은 Young에서 삭제되지 않은 객체들이 복사되는 곳으로 , 이 영역에서 활동하는 가비지 컬렉터를 Major GC라고 부른다.
  • 이렇게 영역을 분할해 놓으면 GC가 추적해야할 데이터의 양이 줄어들어 효율적인 가비지 컬렉팅이 가능하다.

  • Root set과 참조가 존재하는 객체를 Reachable한 상태의 객체라고 하며, 그렇지 않은 객체를 Unreachable하다고 한다. GC는 Unreachable한 객체를 수거한다.
  • 이 때, Root set은 JVM의 Stack 영역, JNI에 의해 생성된 객체, 정적 변수를 의미한다. 위 그림에서 Heap 영역을 제외한 모든 영역이다.
  • GC는 두 단계에 거쳐 실행된다. 
    • Stop The World
      • 가비지 컬렉팅을 위해 JVM이 어플리케이션의 실행을 멈춘다. 
    • Mark and Sweep
      • Root set이 참조하는 Heap 영역의 객체를 찾아서 Mark 한다.
      • 그 객체가 참조한 객체를 찾아서 Mark한다.
      • 이렇게 Mark 과정이 끝나고 남은 Mark되지 않은 객체, 다시 말해 Unreachable한 객체를 제거(Sweep)한다.
반응형