[20241119 TIL] 제네릭 / Java의 함수형 프로그래밍 / 스레드와 동시성 / 예외처리

Haizel·2024년 1월 19일
1
post-thumbnail

01. 제네릭


제네릭은 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시 타입을 체크해주는 역할을 한다.
Java에서 다양한 데이터 타입을 사용하는 클래스나 메서드를 사용할 때, 코드의 재사용성과 안정성을 향성시키기 위해 도입되었다.

선언 방식은 다음과 같다.

public class ClassName <T> {...}
public class ClassName <T, V> {...} // map
  • Key, Value를 사용하는 Map은 두 개의 변수를 선언할 수 있다. 이때 제네릭 명은 임의로 지정 가능하다.
  • 단, T, V 등의 제네릭 타입음 클레스 안에서만 유효하다.

✷ 예제

① MyCustomList.class

package oop2.jenerics;

import java.util.ArrayList;

public class MyCustomList<T> {
	ArrayList<T> List = new ArrayList<>();

	public void addElement(T element) {
		list.add(element);
	}

	public void removeElement(T element) {
		list.remove(element);
	}
}

② Generics.class

package oop2.jenerics;

public class GenericsRunner {
	public static void main(Stirng[] args) {
		MyCustomList<String> list = new MyCustomList();
		list.addElement("Element 1");
		lst.addElement("Element 2");

		MyCustomList<Integer> list2 = new MyCustomList();
		list2.addElement(Integer.valueOf(5));
		list2.addElement(Integer.valueOf(7));
	}
}

👉 MyCustomList 클래스의 타입을 별도로 지정하지 않았으므로, String 타입과 Integer 타입 모두 사용해 리스트를 생성할 수 있다.


✷ 제네릭의 암묵적 규칙

제네릭 타입설명
TType
EElement
KKey
VValue
NName

✷ 제네릭의 장점

  1. 타입 안정성
    • 컴파일러가 코드에서 발생할 수 있는 타입 관련 오류를 미리 감지할 수 있다.
    • 런타임 시 발생할 수 있는 형변환 오류를 방지하고 안정적인 코드를 작성할 수 있다.
  2. 코드의 재사용성
    • 여러 타입에서 반복적으로 사용되는 코드를 한번만 작성할 수 있어 재사용에 용이하다.
    • 동일한 로직을 가지면서 다양한 타입에 대응하는 메서드나, 클래스를 구현할 수 있다.
  3. 유연성 및 확장성
    • 다양한 타입의 데이터를 처리하는 클래스나 메서드를 만들 수 있다.

✷ 제네릭의 단점

  1. 복잡성 증가 : 일반적인 코드에 비해 더 복잡하게 느껴질 수 있고, 가독성이 감소될 수 있다.
  2. 호환성 :JAVA5 이전의 버전에서는 호환되지 않는다.
  3. 타입 지정 제한 : 원시타입(int, short, boolean 등)은 제네릭 타입으로 지정할 수 없다.

✷ 와일드카드

제네릭 타입을 좀 더 유연하게 다룰 수 있는 도구로, 주로 ?를 통해 사용된다.

예시 ①

  • 어떤 타입이든 허용되는 와일드카드
List<?> list = new ArrayList<String>();

예시 ②

  • 와일드카드에 상한 또는 하한을 지정하여 → 특정 타입의 상한 또는 하한 타입만을 허용하는 경우
// 하위타입만 지정
List<? extends Number> numbers = new ArrayList<Integer>();

// 상위타입만 지정
List<? super Integer> integerss = new ArrayList<Number>();

⛔️ caution!

와일드카드는 유연성을 제공하지만 그만큼 코드를 읽고 유지보수하는데 어려움을 초래할 수 있다. 따라서 사용 시 주의하여 필요한 상황에서만 사용하는 것이 좋다.


02. Java의 함수형 프로그래밍


① 스트림(Stream)

Java에서 스트림(Stream)이란 데이터의 흐름을 의미하는데, 스트림을 이용해 배열 또는 컬렉션 인스턴스에 함수 여러 개를 조합해 원하는 결과를 필터링하고 가공된 결과를 얻을 수 있다.

스트림(Stream)은 자바8에서 추가된 기술로, 람다를 활용할 수 있다.

자바8 이전에는 배열 또는 컬렉션 인스턴스를 다루기 위해 for이나 forEach문을 사용해 요소 하나 하나를 꺼내어 다루어야 했다. 간단한 경우라면 상관 없지만 로직이 복잡할수록 코드의 양이 많아지고, 메소드를 나눌 경우 루프를 여러 번 돌며 반복되는 작업을 해야하는 등 문제가 많았다.

❗️ 이를 해결하기 위해 등장한 것이 바로 스트림(Stream) 이다.

스트림의 가장 큰 장점은 람다를 활용할 수 있다는 점이다. 람다를 통해 코드의 양을 줄이고 간결하게 표현할 수 있다. 즉 배열과 컬렉션을 함수형으로 처리할 수 있는 것이다.

스트림의 두 번째 장점은 간단하게 병렬처리(multi-threading) 를 할 수 있다는 점이다. 멀티 스레드 작업을 통해 많은 요소들을 빠르게 처리할 수 있다.

💡병렬 처리(parallel processing) : 하나의 작업을 둘 이상의 작업으로 잘게 나눠서 동시에 진행하는 것을 말한다.


❶ 스트림(Stream)의 중간 연산

스트림의 중간 연산 의 횟수에는 제한이 없다.

  • sorted(정렬) : 인자들을 오름차순/내림차순으로 정렬
numbers.stream().sorted().forEach(e -> System.out.println(e));
  • distinct(중복 제거) : 중복된 요소를 제외하고 입력된 순서대로 출력
numbers.stream().distinct().forEach(e -> System.out.println(e));
    • map : 스트리의 각 요소를 콜백함수에 매핑하여 새로운 배열 또는 컬렉션 인스턴스로 반환
numbers.stream().distinct().sorted().map(e -> e * e).forEach(e -> System.out.println(e));

✷ 스트림 중간 연산 예제

// 1~10까지의 숫자를 제곱해서 출력

IntStream.range(1,11).map(e -> e * e).forEach(p -> System.out.println(p));

// 1, 4, 9, 16, 25, 36, 49, 64, 81, 100

// 리스트를 모두 소문자로 바꿔서 출력

List.of("Apple", "Ant", "Bat").stream().map(s -> s.toLowerCase()).forEach(p -> System.out.println(p));

// apple, ant, bat

// 리스트를 글자 수 반환

List.of("Apple", "Ant", "Bat").stream().map(s -> s.length()).forEach(p -> System.out.println(p));

// 5, 3, 3

❷ 스트림(Stream)의 종단 연산

  • reduce() : 하나의 결과값을 반환하는데 사용되며, 주로 총합이나 최대, 최소값 계산 시 사용된다.
// reduce 문법
T reduce(T identity, BinaryOperator<T> accumulator)

// 1부터 10까지의 정수의 총합을 계산
int sum = IntStream.range(1, 11).reduce(0, (n1, n2) -> n1 + n2);
  • max() : 최대값을 반환하는 메서드로, 요소들이 Comparable 인터페이스를 구현하고 있어야 한다.
// 정수 리스트에서 최대값을 구하는 종단 연산

List<Integer> numbers = List.of(23, 12, 34, 53);

numbers.stream().max((n1,n2) -> Integer.compare(n1,n2)).get();
  • min() : 최소값을 구하는 메소드
// 최솟값 찾기
List<Integer> numbers = List.of(23, 12, 34, 53);

numbers.stream().min((n1,n2) -> Integer.compare(n1,n2)).get();
  • collect() : 스트림의 요소를 모아 새로운 컬렉션을 생성하거나 요약할 때 사용한다.
// 홀수만 찾아 리스트화 하기

numbers.stream().filter(e -> e % 2 == 1).collect(Collectors.toList());

numbers.stream().filter(e -> e % 2 == 0).collect(Collectors.toList()); // 짝수
  • boxed() : 기본자료형 스트림을 해당 기본 자료형의 래퍼 클래스 스트림으로 변환한다.

    💡 기본 자료형 스트림을 래퍼 클래스의 스트림으로 변환하는 이유

    • 기본 자료형 스트림 : 기본 자료형에 특화된 연산만 사용 가능
    • 래퍼 클래스 스트림 : 일반적인 스트림 연산도 모두 사용 가능
// 10개의 정수를 제곱한 값으로 리스트 생성

IntStream.range(1, 11).map(e -> e * e).boxed().collect(Collectors.toList());

❸ Optional

자바 8에서 도입된 클래스로, 값이 존재하지 않을 수 있는 상황에서 기본값을 명시적으로 다루기 위해 사용한다.

  • 값이 존재할 경우 → 해당 값을 감싸고, 존재하지 않은 경우 명시적으로 empty상태를 나타낸다.
  • null의 사용을 대체하고, 코드에서 명시적으로 값의 존재 여부를 다룰 수 있도록 도와준다.
  • NullPointerException을 방지하고, 코드의 안정성을 높일 수 있다.

✷ Optional의 주요 메서드

  • of(value) : 주어진 값으로 Optional을 생성하며, 값이 null이면 NullPointerException이 발생한다.
  • empty() : 빈 Optional을 반환한다.
  • isPresent() : 값이 존재하는지 확인하는 메서드로 값이 존재하면 true, 없으면 false를 반환한다.
  • get() : 값이 존재하면 해당 값을 반환하고 없으면 NoSuchElementException이 발생한다.
  • orElse(defaultValue) : 값이 존재하면 해당 값을 반환하고, 없으면 주어진 기본값을 반환한다.

② 메서드 참조(Method Reference)

Java의 함수형 프로그래밍 개념 중 하나로 메소드를 가리키는 방식을 의미하며, ::이라는 연산자를 사용해 표현한다.

메서드 참조는 람다 표현식을 간결하게 표현하고 코드의 가독성을 향상시킬 수 있다는 장점이 있다.


정적 메소드 참조(Static Method Reference)

  • 클래스이름::정적 메서드 형태로 표현
//람다 표현식
(a, b) -> MyClass.staticMethod(a, b)

// 메소드 참조
MyClass::staticMethod

인스턴스 메소드 참조(Instance Method Reference)

  • 특정 객체의 인스턴스 메소드를 가리키는 참조 형태를 말한다.
  • 객체참조::인스턴스 메서드 형태로 표현한다.
// 람다 표현식
(a, b) -> myObject.instanceMethod(a, b)

// 메소드 참조
myObject::instanceMethod

클래스의 임의 객체의 메소드 참조(Arbitrary Object's Method Reference)

  • 임의 객체의 인스턴스 메서드를 가리킨다.
  • 클래스이름:인스턴스 메서드 형태로 표현한다.
// 람다 표현식
() -> "example".length()

// 메소드 참조
String::length

생성자 참조(Constructor Reference)

  • 클래스의 생성자를 가리킨다.
  • 클래스이름:new 형태로 표현한다.
// 람다 표현식
() -> new MyClass()

// 메소드 참조
MyClass::new

03. 스레드(Thread)와 동시성


  • 스레드(Thread) : 동시에 여러 작업을 수행하기 위한 프로그래밍 구조로, 여러 코드 블록이 동시에 실행되어 병렬 작업을 처리할 수 있다.

✷ 스레드를 생성하는 2가지 방법

❶ Thread클래스 확장

// 1.Thread클래스 확장
class MyThread extends Thread {
	public void run() {
	// 스레드가 실행할 코드 작성
	}
}


// 1-1. 스레드 생성 및 시작
MyThread myThread = new MyThread();
myThread.start();

❷ Runnable 인터페이스 구현

// 2.Runnable 인터페이스 구현
class MyRunnable implements Runnable {
	public void run() {
	// 스레드가 실행할 코드 작성
	}
}

// 2-1. 스레드 생성 및 시작
MyRunnable myRunnable = new MyRunnable();
Thread myThread = new Thread(myRunnable);
myThread.start();

✷ 스레드의 상태

  1. NEW(새로 생성됨) : 스레드 객체가 생성되었지만, 아직 start() 메소드가 호출되지 않은 상태로, 즉 스레드가 아직 실행되기 전의 초기 상태이다.

  2. RUNNABLE(실행 가능) : start() 메소드가 호출되고, 스레드가 실행 대기 중인 상태이다.

  3. RUNNING(실행중) : 스레드가 실제로 실행 중인 상태

  4. BLOCKED(차단됨) : 스레드가 특정 자원을 얻지 못하고, 대기 상태에 있는 경우이다.

    • ex.외부 서비스의 응답을 기다리고 있거나 DB를 이용하고 있는데, DB가 느리다면 차단된다.   
  5. WAITING, TIMED_WAITING(대기 중, 제한된 기간 동안 대기 중) : - 스레드가 다른 스레드에 의해 깨워질 때까지 대기하고 있는 상태이다.

    • wait() 메소드sleep() 메소드 호출 시 대기 중 상태가 된다.
    • TIMED_WAITING은 제한된 시간 동안 대기하고 있는 상태로, sleep() 메소드에 인자를 전달하여 설정한다.
  6. TERMINATED / DEAD(종료됨) : 스레드의 run() 메소드가 종료되었거나, stop() 메소드에 의해 종료된 경우이다.

💡 Thread run과 start의 차이

  • start() 메서드 : 새로운 스레드를 생성해 run() 메서드를 별도의 실행 경로에서 실행시킨다.
  • run() 메서드 : 새로운 스레드의 생성 없이, run() 메서드는 현재 스레드에서 run() 메서드로 호출한 코드가 실행된다.

👉 따라서, 병렬 실행을 원한다면 start() 메서드를 사용하면 된다.


✷ 스레드의 우선 순위

스레드 스케줄러에 의해 결정되는 값으로, 스래드의 실행 우선 순위를 나타낸다.

새로운 스레드가 생성되면 기본적으로 기본적으로 Thread.NORM_PRIORITY 값(5) 이 설정되며, 1~10까지의 정수 범위 내에서 우선순위를 표현할 수 있다.

z-index처럼 값이 높을수록 높은 우선순의를 의미한다.

  • setPriority(int newPriority) : Tread 클래스에서 제공하는 메서드로, 스레드의 우선순위를 결정한다.(1~10)

⛔️Caution!

우선순위는 단순히 추천사항으로 이해해야 한다. Why? JAVA는 우선순위를 고려하긴 하지만, 반드시 지켜지지는 않기 때문이다.
👉 즉, 높은 우선순위를 가진 스레드일수록 더 많은 CPU 자원을 얻을 가능성이 있지만, 이 사실은 반드지 보장되는 것은 아니다.


✷ 주요 Thread 메서드

① join()

특정 스레드가 끝날 때까지 대기하도록 하는 역할로, 주로 스레드의 실행 순서를 조절하고 스레드 간 동기화 작업을 할 때 활용된다.
ex) task1.join() 은 Task1 스레드 실행이 종료될 때까지 대기 후 다음 줄의 코드를 실행하게 된다.

예외처리

join()의 예외 처리는 throws InterruptedException 을 추가하거나, try-catch 블록으로 처리한다.


sleep()

  • Tread.sleep(ms)은 현재 실행 중인 스레드를 일정 시간동안 정지시킨다.
Tread.sleep(3000);
// 해당 스레드가 3초동안 대기 상태에 들어가게 된다.

yield()

  • Thread.yield()는 현재 실행 중인 스레드가 다른 스레드에게 CPU를 양보(yield)하도록 요청한다.
  • 단, 스케줄러는 우선 순위 등의 이유로 이 요청을 무시할 수 있는데 → 따라서 양보 여부는 스케줄러에 의해 결정된다.

✷ Synchronized 키워드

  • 동기화된 블록을 설정하거나, 여러 스레드가 공유하는 데이터에 접근할 때 사용한다.
  • Synchronized로 묶인 메소드나 블록 내에서는 해당 코드를 실행하는 동안엔 다른 스레드가 접근하지 못하게 막는다.
  • 단점으로는 과도한 Overhead를 유발할 수 있으며, 대안으로 최근 버전의 Java에서는 다양한 동기화 대안이 제공되고 있다.
  • Overhead : 동기화된 코드는 하나의 스레드만이 실행하도록 보장하기 때문에 다른 스레드들이 해당 코드가 종료되길 기다려야 한다. 이로 인해 성능에 영향을 미칠 수 있다.
  • 동기화 대안 : Java의 최근 버전에서 Synchronized의 대안책으로 concurrent collection 등 다양한 기능이 도입되었다.
    - 이를 통해 스레드 간의 안전한 데이터 공유가 보다 효율적으로 이루어질 수 있다.

Executor Service

스레드는 현재 몇 개의 스레드가 실행 중인지 추적하기 어렵고, 특정 Task의 실행이 완료된 후에 다음 작업을 수행하는 것이 쉽지 않다는 한계가 존재한다.

이런 문제를 해결하기 위해 Executor Service가 도입되었는데, 기존의 Tread 문제를 극복하고 고수준의 스레드 관리 기능을 제공한다.

✔️ Executor Service의 주요기능

  • 다수의 Thread 실행 : Thread Pool을 사용하여 다수의 Thread를 효율적으로 관리하고 실행할 수 있다.
  • Task의 상태 추적 : 각 Task의 실행 상태를 추적하고, Task가 완료되면 알림을 받을 수 있다.
  • Task 실행 순서 관리 : 특정 Task가 완료된 후에 다음 작업을 실행하도록 순서를 조절할 수 있다.
  • Thread 종료 관리 : Executor Service를 종료하면 해당 Executor에 속한 모든 Thread도 종료된다.

profile
한입 크기로 베어먹는 개발지식 🍰

2개의 댓글

comment-user-thumbnail
2024년 1월 25일

최고👍

1개의 답글

관련 채용 정보