본문 바로가기

(09.29) Java 프로그래밍 - 스레드 통신, 제어, 데드락 해결방식, 함수형 프로그래밍

@starweb2025. 9. 29. 20:45

[ 8주차 - 0929 ] 

    금일 커리큘럼
        ├ 09:00 ~ 12:00 백엔드 프로그래밍 (스레드 간 통신, 스레드 제어, 데드락 해결방식, 유틸 concurrent)
        └ 13:00 ~ 18:00 백엔드 프로그래밍 (함수형 프로그래밍, 함수형 인터페이스와 람다식)

1. 스레드 간 통신

스레드 간 협력을 위한 통신 방법

  • 스레드 간 통신이란?
    • 멀티스레드 환경에서 스레드들이 서로 데이터를 주고받거나 상태를 공유하는 것을 의미
    • 스레드 간 통신을 통해 동기화 문제를 해결하고, 효율적인 자원 관리를 할 수 있음

관련 메서드

  • wait(): 현재 스레드를 대기 상태로 전환
    • 다른 스레드가 notify() 또는 notifyAll()을 호출할 때까지 기다림
  • notify(): 대기 중인 스레드 중 하나를 깨움
  • notifyAll(): 대기 중인 모든 스레드를 깨움

관련 예시

class SharedResource {
    private int data;
    private boolean available = false;

    public synchronized int consume() throws InterruptedException {
        while (!available) {
            System.out.println("[consume] 대기중..");
            wait(); // 데이터가 없으면 대기
        }
        available = false;
        notify(); // 생산자에게 알림
        return data;
    }

    public synchronized void produce(int value) throws InterruptedException {
        while (available) {
            System.out.println("[produce] 대기중..");
            wait(); // 데이터가 있으면 대기
        }
        data = value;
        available = true;
        notify(); // 소비자에게 알림
    }
}
public class ProducerConsumerExam {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    System.out.println("Producing: " + i);
                    resource.produce(i);
                    Thread.sleep(500); // 생산 속도 조절
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread consumer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    int value = resource.consume();
                    System.out.println("Consuming: " + value);
                    Thread.sleep(1000); // 소비 속도 조절
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();
    }
}
# 출력 결과
[consume] 대기중..
Producing: 1
Consuming: 1
Producing: 2
Consuming: 2
Producing: 3
Producing: 4
[produce] 대기중..
Consuming: 3
Producing: 5
[produce] 대기중..
Consuming: 4
Consuming: 5
...

2. 스레드제어 - 복습 (join, interrupt, setPriority)

스레드의 실행 흐름을 제어하는 방법

join()

  • join(): 특정 스레드가 종료될 때까지 현재 스레드를 대기 상태로 만듬
public class JoinExam {
    public static void main(String[] args) {
        System.out.println("메인 시작");
        Thread thread1 = new Thread(() -> {
            System.out.println("스레드1 start");
            try {
                Thread.sleep(2000); // 2초 대기
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("스레드1 end");
        });

        thread1.start();
        try {
            thread1.join(); // thread1이 종료될 때까지 대기
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("메인 종료");
    }
}
# 출력 결과
메인 시작
스레드1 start
스레드1 end
메인 종료

interrupt()

  • interrupt(): 스레드가 대기 상태에 있을 때 인터럽트를 발생시켜 대기 상태에서 벗어나게 함
public class InterruptExam {
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            try {
                for (int i = 1; i <= 10; i++) {
                    System.out.println("작업중 " + i + "/10");
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                System.out.println("작업 중단!");
                return;
            }
            System.out.println("10번 작업 완료");
        });

        thread1.start();

        Thread.sleep(3500); // 3.5초 대기
        thread1.interrupt(); // 작업 중단 요청
    }
}
# 출력 결과
작업중 1/10
작업중 2/10
작업중 3/10
작업중 4/10
작업 중단!

setPriority()

  • setPriority(): 스레드의 우선순위를 설정함 (1~10 사이의 값, 기본값은 5)
    • 우선순위가 높은 스레드가 CPU 시간을 더 많이 할당받을 가능성이 높음
    • 하지만, 우선순위는 절대적인 것이 아니며, 운영체제의 스케줄링 정책에 따라 다르게 동작할 수 있음

public class PriorityExam {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("[thread1] 작업중 " + i + "/3");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("[thread1] 작업 중단!");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("[thread2] 작업중 " + i + "/3");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("[thread2] 작업 중단!");
                }
            }
        });


        Thread thread3 = new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("[thread3] 작업중 " + i + "/3");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("[thread3] 작업 중단!");
                }
            }
        });

        // setPriority 우선순위 지정 (높은값 우선)
        thread1.setPriority(Thread.MIN_PRIORITY); // 우선순위 낮음 - 1
        thread2.setPriority(Thread.MAX_PRIORITY); // 우선순위 높음 - 10
        thread3.setPriority(5); // 정수로 지정가능

        thread1.start();
        thread2.start();
        thread3.start();
    }
}
# 출력 결과
[thread2] 작업중 1/3
[thread3] 작업중 1/3
[thread1] 작업중 1/3

...ing

3. 데드락 & 해결방식

데드락 ? 둘 이상의 스레드가 서로 상대방이 점유하고 있는 자원을 기다리느라 무한 대기 상태 빠지는 경우

graph TD;
    A[스레드 A] -->|자원 1 점유| R1[자원 1]
    B[스레드 B] -->|자원 2 점유| R2[자원 2]
    A -->|자원 2 요청| R2
    B -->|자원 1 요청| R1

데드락 발생 조건 ?

  1. 상호 배제: 자원은 한 번에 하나의 스레드만 사용할 수 있음
  2. 점유와 대기: 스레드가 최소한 하나의 자원을 점유하고 다른 자원을 기다림
  3. 비선점: 자원을 강제로 빼앗을 수 없음
  4. 순환 대기: 스레드들이 서로 순환적으로 자원을 기다림

데드락 강제 발생 예시


public class DeadlockExam {
    public static void main(String[] args) {
        final Object resource1 = new Object();
        final Object resource2 = new Object();

        // 스레드 A
        Thread threadA = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("스레드 A: 자원1 점유");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    System.out.println(e.getMessage());
                }
                synchronized (resource2) {
                    System.out.println("스레드 A: 자원2 점유");
                }
            }
        });

        // 스레드 B
        Thread threadB = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("스레드 B: 자원2 점유");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    System.out.println(e.getMessage());
                }
                synchronized (resource1) {
                    System.out.println("스레드 B: 자원1 점유");
                }
            }
        });

        // 100ms 동안 실행임
        threadA.start();
        threadB.start();

        // 1000ms 뒤에도 A,B가 아직도 실행중이면 ? 데드락임!
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
            }
            // isAlive : 스레드가 아직 실행중인지?
            if (threadA.isAlive() && threadB.isAlive()) {
                System.out.println("!!!! 데드락 발생 감지 !!!!");
            }
        }).start();
    }
}

 

# 출력 결과 (데드락 발생)

# 100ms
스레드 A: 자원1 점유
스레드 B: 자원2 점유

# 1000ms 
!!!! 데드락 발생 감지 !!!!

데드락 해결방식

데드락 진입점 통일

  • 진입점 lock 통일: 모든 스레드가 동일한 순서로 자원을 요청하도록 설계
    • 예시에서 method1method2 모두 lock1을 먼저 획득하도록 변경
    • 이렇게 하면 순환 대기 조건이 발생하지 않음
public class DeadlockExam1 {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();
    private final Object lock3 = new Object();

    // 진입점 락 통일 (lock1)
    public void method1() {
        synchronized(lock1) {
            synchronized(lock2) {
                // 작업 수행
                synchronized(lock3) {
                    // 작업 수행
                }
            }
        }
    }

    // 진입점 락 통일 (lock1)
    public void method2() {
        synchronized(lock1) {
            synchronized(lock3) {
                // 작업 수행
                synchronized(lock2) {
                    // 작업 수행
                }
            }
        }
    }
}

데드락 타임아웃 설정

  • 타임아웃 설정: tryLock() 메서드를 사용하여 일정 시간 동안만 자원을 요청
    • 일정 시간 내에 자원을 획득하지 못하면 요청을 포기하고 다시 시도
    • 이렇게 하면 데드락 상황에서 벗어날 수 있음
  • ReentrantLock
    • 자바에서 제공하는 락 클래스중 하나
    • synchronized 키워드보다 더 세밀한 락 제어 가능
    • 옵션으로 공정성(fairness) 설정 가능
      • false가 기본값 (비공정)
      • true로 설정 시, 대기 시간이 오래된 스레드가 우선적으로 락을 획득

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TimeoutExam {
    // 공정성 옵션: true면 대기 시간 오래된 스레드에 우선권 (기아 방지 도움)
    private final ReentrantLock lock1 = new ReentrantLock(true);
    private final ReentrantLock lock2 = new ReentrantLock(true);

    public void timeoutRun() throws InterruptedException {
        final long TIMEOUT_MS = 1000; // tryLock 대기시간

        while (true) {
            // currentThread() : 현재 실행중인 스레드 반환
            // isInterrupted() : 외부에서 인터럽트 요청이 왔는지 확인
            if (Thread.currentThread().isInterrupted()) {
                // 외부에서 인터럽트 요청 시 깔끔히 종료
                throw new InterruptedException("작업이 인터럽트되었습니다.");
            }

            boolean isRun1 = false;
            boolean isRun2 = false;
            boolean needBackoff = false; // 백오프실행 여부

            try {
                // lock1 시도 (timeout)
                isRun1 = lock1.tryLock(TIMEOUT_MS, TimeUnit.MILLISECONDS);
                
                // isRun1 실패: 락 점유 X -> 바로 재시도해도 OK
                if (!isRun1) continue; // 실패 시 재시도 (while 로 돌아감)
                
                System.out.println(Thread.currentThread().getName() + " : lock1 획득");

                // lock2 시도 (timeout)
                isRun2 = lock2.tryLock(TIMEOUT_MS, TimeUnit.MILLISECONDS);
                
                // isRun2 실패: 이미 lock1 점유중 상태 -> 먼저 해제하고 백오프 필요
                if (!isRun2) {
                    needBackoff = true; // 백오프 on
                } else {
                    System.out.println(Thread.currentThread().getName() + " : lock2 획득");
                    // ...
                    System.out.println(Thread.currentThread().getName() + " : 작업 수행 완료");
                    return; // 성공적으로 종료
                }
            } finally {
                // 락 해제 역순
                if (isRun2) lock2.unlock();
                if (isRun1) lock1.unlock();
            }
            if (needBackoff) {
                // 모든 락 해제 후에 백오프 수행
                Thread.yield();
                Thread.sleep(10);
            }
        }
    }

    // 메인 메서드
    public static void main(String[] args) {
        TimeoutExam exam = new TimeoutExam();

        // 스레드 여러 개 실행
        Runnable task = () -> {
            try {
                exam.timeoutRun();
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + " : 인터럽트 발생");
            }
        };

        Thread t1 = new Thread(task, "스레드 A");
        Thread t2 = new Thread(task, "스레드 B");
        Thread t3 = new Thread(task, "스레드 C");

        t1.start();
        t2.start();
        t3.start();
    }
}
# 출력 결과
스레드 A : lock1 획득
스레드 A : lock2 획득
스레드 A : 작업 수행 완료
스레드 B : lock1 획득
스레드 B : lock2 획득
스레드 B : 작업 수행 완료
스레드 C : lock1 획득
스레드 C : lock2 획득
스레드 C : 작업 수행 완료

4. java.util.concurrent 패키지

4-1 ExecutorService

  • 스레드 풀을 관리하고 작업을 효율적으로 처리할 수 있는 인터페이스
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

// 스레드 풀 예시
public class ThreadPoolExam {
    public static void main(String[] args) throws InterruptedException {
        // 고정 크기 스레드 풀 생성 (3개의 스레드)
        ExecutorService executor = Executors.newFixedThreadPool(3);

        // 3개의 작업 제출
        for (int i = 1; i <= 3; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("작업 " + taskId + " 시작");
                try {
                    Thread.sleep(2000); 
                } catch (InterruptedException e) {
                    System.out.println("작업 " + taskId + " 중단");
                }
                System.out.println("작업 " + taskId + " 완료");
            });
        }

        // 스레드 풀 종료 (새 작업 제출 불가)
        executor.shutdown();
        try {
            // 모든 작업이 끝나길 3초 기다림 (작업 2초라면 넉넉)
            if (!executor.awaitTermination(3, TimeUnit.SECONDS)) {
                // 제한 시간 안에 안 끝나면 스레드 풀 강제 종료
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
        System.out.println("-- end --");
    }
}
# 출력 결과
작업 3 시작
작업 2 시작
작업 1 시작
작업 2 완료
작업 3 완료
작업 1 완료
-- end --

4-2 Future와 Callable

  • Future: 비동기 작업의 결과를 나타내는 인터페이스
  • Callable: 작업을 수행하고 결과를 반환할 수 있는 인터페이스
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

// Future와 Callable 예시
public class FutureCallableExam {
    public static void main(String[] args) {
        // 고정 크기 스레드 풀 생성 (2개의 스레드)
        ExecutorService executor = Executors.newFixedThreadPool(2);

        // Callable 작업 정의
        Callable<Integer> task = () -> {
            System.out.println("작업 시작");
            Thread.sleep(2000); // 2초 대기
            System.out.println("작업 완료");
            return 100; // 결과 반환
        };

        // 작업 제출 및 Future 객체 반환
        Future<Integer> future = executor.submit(task);

        try {
            // 작업 결과 가져오기 (완료될 때까지 대기)
            Integer result = future.get();
            System.out.println("작업 결과: " + result);
        } catch (InterruptedException e) {
            System.out.println("작업 중단");
        } catch (ExecutionException e) {
            System.out.println("작업 중 예외 발생: " + e.getCause());
        } finally {
            // 스레드 풀 종료
            executor.shutdown();
        }
    }
}
# 출력 결과
작업 시작
작업 완료
작업 결과: 100

4-3 CountDownLatch

  • 여러 스레드가 특정 작업을 완료할 때까지 대기하는 데 사용되는 동기화 도구
  • CountDownLatch(int count) 생성자에서 초기 카운트를 설정
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

// CountDownLatch 예시
public class CountDownLatchExam {
    public static void main(String[] args) throws InterruptedException {
        final int NUM_TASKS = 3;
        CountDownLatch latch = new CountDownLatch(NUM_TASKS);
        // 고정 크기 스레드 풀 생성 (3개의 스레드)
        ExecutorService executor = Executors.newFixedThreadPool(NUM_TASKS);

        // 작업 정의
        Runnable task = () -> {
            try {
                System.out.println(Thread.currentThread().getName() + " 작업 시작");
                Thread.sleep((long) (Math.random() * 3000)); // 랜덤 대기
                System.out.println(Thread.currentThread().getName() + " 작업 완료");
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + " 작업 중단");
            } finally {
                latch.countDown(); // 작업 완료 시 카운트 다운
            }
        };

        // 작업 제출
        for (int i = 0; i < NUM_TASKS; i++) {
            executor.submit(task);
        }

        // 모든 작업이 완료될 때까지 대기
        latch.await();
        System.out.println("모든 작업 완료!");

        // 스레드 풀 종료
        executor.shutdown();
    }
}
# 출력 결과
pool-1-thread-1 작업 시작
pool-1-thread-2 작업 시작
pool-1-thread-3 작업 시작
pool-1-thread-3 작업 완료
pool-1-thread-2 작업 완료
pool-1-thread-1 작업 완료
모든 작업 완료!

4-4 ConcurrentHashMap

  • 멀티스레드 환경에서 안전하게 사용할 수 있는 해시맵 구현체
  • 내부적으로 세그먼트 락을 사용하여 동시성을 향상시킴
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

// ConcurrentHashMap 예시
public class ConcurrentHashMapExam {
    public static void main(String[] args) throws InterruptedException {
        Map<String, Integer> map = new ConcurrentHashMap<>();
        // 고정 크기 스레드 풀 생성 (5개의 스레드)
        ExecutorService executor = Executors.newFixedThreadPool(5);

        // 1000개의 작업 제출
        for (int i = 0; i < 1000; i++) {
            final int taskId = i;
            executor.submit(() -> {
                map.put("key" + taskId, taskId);
            });
        }

        // 스레드 풀 종료
        executor.shutdown();
        // 모든 작업이 끝나길 1초마다 확인
        while (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
            System.out.println("아직 작업 중...");
        }

        // 맵 크기 출력 (1000이어야 함)
        System.out.println("작업완료\n맵 크기: " + map.size());
    }
}
# 출력 결과
아직 작업 중...
작업완료
맵 크기: 1000

5. 성능 최적화 팁

5.1 스레드 풀 크기 조정

  • CPU 코어 수에 맞게 스레드 풀 크기 설정
  • I/O 작업이 많은 경우 더 큰 스레드 풀 사용 고려
  • CPU 작업이 많은 경우 코어 수와 동일하거나 약간 큰 크기 설정
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// 최적 스레드 풀 크기 예시
public class OptimalThreadPool {
    public static void main(String[] args) {
        // CPU 집약적 작업
        // Runtime 객체를 통해 CPU 코어 수 확인
        // getRuntime() : 현재 실행중인 자바 프로그램의 런타임 객체 반환
        // availableProcessors() : 사용 가능한 프로세서 수 반환
        int coreCount = Runtime.getRuntime().availableProcessors();


        // I/O 집약적 작업
        // 작업비율에 따라 스레드 수 조정 0% ~ 100%
        double blockingPercent = 0.4; // I/O 작업 비율 40%

        // 최적 스레드 수 계산
        // CPU 코어 수 / (1 - 블로킹 계수)
        int optimalThreadCount = (int) (coreCount / (1 - blockingPercent));

        // CPU 코어 수에 맞게 스레드 풀 생성
        ExecutorService executor = Executors.newFixedThreadPool(optimalThreadCount);
        System.out.println("CPU 코어 수: " + coreCount);
        System.out.println("I/O 작업 비율: " + (blockingPercent * 100) + "%");
        System.out.println("최적 스레드 수: " + optimalThreadCount);

        // 스레드 풀 종료
        executor.shutdown();
    }
}
# 출력 결과
CPU 코어 수: 6
I/O 작업 비율: 40.0%
최적 스레드 수: 10

5.2 락 경합 최소화

  • 동기화는 성능 저하의 원인이 될 수 있으므로, 꼭 필요한 부분에만 사용
public class LockGoodExam {
    private final Object lock = new Object();
    private int count = 0;

    // 안좋은 예
    public void badMethod() {
        synchronized(lock) {
            longRun(); // 긴작업..
            commonData(); // 실제 동기화가 필요한 부분
        }
    }

    // 좋은 예
    public void goodMethod() {
        longRun(); // 락 밖에서 수행
        synchronized(lock) {
            commonData(); // 필요한 부분만 동기화
        }
    }

    private void longRun() {
        try {
            Thread.sleep(2000); // 2초 대기
        } catch (InterruptedException e) {
            System.out.println(e.getMessage());
            Thread.currentThread().interrupt();
        }
    }

    private void commonData() {
        count++;
        System.out.println("[" + Thread.currentThread().getName() + "] count: " + count);
    }

    public static void main(String[] args) {
        LockGoodExam exam = new LockGoodExam();

        Runnable badTask = () -> exam.badMethod();
        Runnable goodTask = () -> exam.goodMethod();

        // 스레드 풀 예시
        Thread t1 = new Thread(badTask, "스레드 A");
        Thread t2 = new Thread(badTask, "스레드 B");

        Thread t3 = new Thread(goodTask, "스레드 C");
        Thread t4 = new Thread(goodTask, "스레드 D");

        System.out.println("─── badMethod 실행 ───");
        t1.start();
        t2.start();

        try { Thread.sleep(4100); } catch (InterruptedException e) {}

        System.out.println("\n─── goodMethod 실행 ───");
        t3.start();
        t4.start();
    }
}
# 출력 결과
─── badMethod 실행 ───
[스레드 A] count: 1 # 2초뒤..
[스레드 B] count: 2 # 2초뒤..
# 약 4초 소요

─── goodMethod 실행 ───
[스레드 C] count: 3
[스레드 D] count: 4
# 약 2초 소요

5.3 ThreadLocal 사용

ThreadLocal ? 스레드마다 독립적인 변수를 유지할 수 있도록 도와주는 클래스

  • 스레드 간 변수 공유로 인한 동기화 문제를 방지

import java.text.SimpleDateFormat;
import java.util.Date;

public class ThreadLocalExam {
    // 각 스레드마다 독립적인 SimpleDateFormat 객체를 생성
    private static final ThreadLocal<SimpleDateFormat> dateFormatter =
            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

    public String formatDate(Date date) {
        // 각 스레드가 자신만의 SimpleDateFormat 인스턴스 사용
        return dateFormatter.get().format(date);
    }

    public static void main(String[] args) {
        ThreadLocalExam exam = new ThreadLocalExam();

        // 여러 스레드에서 안전하게 사용
        for (int i = 0; i < 5; i++) {
            int cnt = i;
            new Thread(() -> {
                Date now = new Date(); // java.util.Date 객체
                String formatted = exam.formatDate(now); // ThreadLocal 포맷 사용
                System.out.println("[" + cnt + "] " + Thread.currentThread().getName() +
                        ": " + formatted);
            }).start();
        }
# 출력 결과
[2] Thread-2: 2025-09-29 11:58:51
[3] Thread-3: 2025-09-29 11:58:51
[4] Thread-4: 2025-09-29 11:58:51
[0] Thread-0: 2025-09-29 11:58:51
[1] Thread-1: 2025-09-29 11:58:51

6. 자바에서의 함수형 프로그래밍

자바에서 함수 ?

  • 자바는 함수형 프로그래밍 언어가 아니므로, 함수 자체를 독립적으로 다룰 수 없음
  • 대신, 함수형 인터페이스를 통해 함수처럼 동작하는 객체를 만들 수 있음
  • 람다식으로 별도 로직 간결하게 가능

프로그래밍 패러다임

  • 명령형 프로그래밍 : 절차지향, 객체지향, 상태 변화 중심 (어떻게 할건지)
    • ex) 자바, C, C++, Python, JavaScript(일부)
  • 선언형 프로그래밍 : 함수형, 논리형, 결과 중심 (무엇을 할건지)
    • ex) javaScript(일부), SQL

함수형 프로그래밍 특징

  • 1급 시민: 프로그래밍 언어에서 함수가 다른 데이터 타입과 동일하게 취급되는 것
    • 함수가 변수에 할당되거나, 매개변수로 전달되거나, 반환값으로 사용될 수 있음
  • 순수 함수: 동일한 입력에 대해 항상 동일한 출력을 반환하며, 부작용이 없음
    • 즉, 부수효과 제거된 것
    • 부수효과 : 함수 외부 상태 변경, I/O 작업 등
  • 불변성: 데이터가 변경되지 않고, 새로운 데이터를 생성하여 사용

7. 함수형 인터페이스와 람다식

함수형 인터페이스와 람다식을 통해 함수형 프로그래밍 구현

  • 함수형 인터페이스: 단 하나의 추상 메서드만을 가지는 인터페이스
    • @FunctionalInterface 애노테이션으로 명시 가능 (선택사항)
    • 익명 클래스, 람다식 등으로 구현 가능
  • 람다식: 익명 함수 표현식으로, 간결하게 함수형 인터페이스 구현 가능
    • (매개변수) -> { 실행문 } 형태
    • 매개변수가 하나일 때는 괄호 생략 가능
    • 매개변수는 타입 추론 가능
    • 실행문이 하나일 때는 중괄호와 return 생략 가능
// 함수형 인터페이스 애노테이션
@FunctionalInterface
interface RunFunc {
    void run();
}

@FunctionalInterface
interface PlusFunc {
    int plus(int a, int b);
}

class FuncImpl implements RunFunc {
    @Override
    public void run() {
        System.out.println("임플리 run");
    }
}

public class FuncImplTest {
    public static void main(String[] args) {
        // 1. 익명 객체 사용
        RunFunc funcTest1 = new RunFunc() {
            @Override
            public void run() {
                System.out.println("재정의 run");
            }
        };

        // 2. 클래스 구현 사용
        RunFunc funcTest2 = new FuncImpl();

        // 3. 람다식 사용
        RunFunc funcTest3 = () -> System.out.println("람다 run");

        // 4. 매개변수, 반환값 있는 람다식
        PlusFunc funcTest4 = (a, b) -> {
            // 매개변수 int 생략 가능 - 타입 추론
            int result = 0;
            for (int i = a; i <= b; i++) {
                result += i;
            }
            return result;
        };

        funcTest1.run();
        funcTest2.run();
        funcTest3.run();
        int result = funcTest4.plus(1, 10);

        System.out.println("람다 run 2 결과: " + result);
    }
}
# 출력 결과
재정의 run
임플리 run
람다 run
람다 run 2 결과: 55

자바 표준 함수형 인터페이스

java.util.function 패키지에 다양한 표준 함수형 인터페이스 제공

  • Runnable : 매개변수 없고, 반환 X
    • void run()
  • Consumer<T> : 매개변수 하나, 반환 X
    • void accept(T t)
  • Function<T, R> : 매개변수 하나, R타입 반환 O
    • R apply(T t)
  • BiFunction<T, U, R> : 매개변수 두개, R타입 반환 O
    • R apply(T t, U u)
  • Predicate<T> : 매개변수 하나, boolean 반환
    • boolean test(T t)
  • Supplier<R> : 매개변수 없고, R타입 반환
    • R get()

T: 입력 타입 / U: 두번째 입력 타입 / R: 반환 타입

유틸 펀션 예시

import java.util.function.*;

public class StandardFuncExam {
    public static void main(String[] args) {
        // 매개변수 없고, 반환 X
        Runnable r = () -> System.out.println("Runnable 실행");
        r.run();

        // 매개변수 하나, R 반환 X
        Consumer<String> consumer = (s) -> System.out.println("Consumer: " + s);
        consumer.accept("Hello");

        // 매개변수 하나, R 반환 O
        Function<Integer, String> function = (i) -> "Function: " + (i * 2);
        String result = function.apply(5);
        System.out.println(result);

        // 매개변수 두개, R 반환 O
        BiFunction<Integer, Integer, Integer> biFunction = (a, b) -> a + b;
        int sum = biFunction.apply(3, 7);
        System.out.println("BiFunction: " + sum);

        // 매개변수 하나, boolean 반환
        Predicate<String> predicate = (s) -> s.length() > 5;
        boolean testResult = predicate.test("스트링 길이 넘었나요?");
        System.out.println("Predicate: " + testResult);

        // 매개변수 없고, R 반환 O
        Supplier<Integer> supplier = () -> (int)(Math.random()*10) + 1;
        int randomValue = supplier.get();
        System.out.println("Supplier: " + randomValue);
    }
}
# 출력 결과
Runnable 실행
Consumer: Hello
Function: 10
BiFunction: 10
Predicate: true
Supplier: 7 # (랜덤값)

8. 람다식과 컬렉션

람다식을 활용하여 컬렉션 데이터를 간결하게 처리

  • Stream API 활용
    • Stream ? 컬렉션 요소를 함수형으로 처리할 수 있는 기능
    • forEach, map, filter, reduce 등 다양한 메서드 사용

리스트 처리 예시

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class LambdaCollectionExam {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("신짱구", "김철수", "유리", "훈이", "맹구");

        // 1. forEach : 각 요소 출력
        System.out.println("─── forEach ───");
        names.forEach(item -> {
            System.out.print(item + " ");
        });
        System.out.println();

        // 1-1. forEach 내 idx 활용
        System.out.println("\n─── forEach + IntStream (index추가) ───");
        IntStream.range(0, names.size())
                .forEach(idx -> {
                    if(idx != names.size() - 1) System.out.printf("[%d] %s | ", idx, names.get(idx));
                    else System.out.printf("[%d] %s%n", idx, names.get(idx));
                });

        // 2. map : name + 님
        System.out.println("\n─── map (+ 님) ───");
        List<String> upperNames = names.stream()
                .map(item -> item + "님")
                .collect(Collectors.toList());

        upperNames.forEach(item -> System.out.print(item + " "));
        System.out.println();

        // 3. filter : 이름 길이가 3 이상인 요소만 필터링
        System.out.println("\n─── filter (길이 >= 3) ───");
        List<String> longNames = names.stream()
                .filter(name -> name.length() >= 3)
                .collect(Collectors.toList());

        longNames.forEach(name -> System.out.print(name + " "));
        System.out.println();

        // 4. reduce : 모든 이름을 하나의 문자열로 결합
        System.out.println("\n─── reduce (모든 이름 결합) ───");
        String allNames = names.stream()
                .reduce((item1, item2) -> item1 + ", " + item2)
                .orElse("없는데요?");
        System.out.println("모든 이름: " + allNames);

        // 5. sorted : 이름을 가나다순으로 정렬
        System.out.println("\n─── sorted (가나다순) ───");
        List<String> sortedNames = names.stream()
                .sorted()
                .collect(Collectors.toList());
        sortedNames.forEach(name -> System.out.print(name + " "));
        System.out.println();
        // sorted : 디폴트 오름차순
        // sorted(Comparator.reverseOrder()) : 내림차순
    }
}
# 출력 결과
─── forEach ───
신짱구 김철수 유리 훈이 맹구 

─── forEach + IntStream ───
[0] 신짱구 | [1] 김철수 | [2] 유리 | [3] 훈이 | [4] 맹구

─── map (+ 님) ───
신짱구님 김철수님 유리님 훈이님 맹구님 

─── filter (길이 >= 3) ───
신짱구 김철수

─── reduce (모든 이름 결합) ───
모든 이름: 신짱구, 김철수, 유리, 훈이, 맹구

─── sorted (가나다순) ───
김철수 맹구 신짱구 유리 훈이

예제 관련 sort() 와 sorted() 차이

  • sort() : 리스트 자체를 정렬 (원본 변경 o)
    • sort 스트림 메서드가 아님
  • sorted() : 스트림 요소를 정렬 (새로운 스트림 반환, 원본 변경 x)
    • 디폴트 오름차순
    • () 내 Comparator 지정 가능
    • sorted(Comparator.reverseOrder()) : 내림차순 정렬
    • sorted(Comparator.comparing(String::length)) : 길이순 정렬
    • sorted(Comparator.comparing(String::toUpperCase)) : 대문자순 정렬
    • sorted(Comparator.comparing(<대상클래스>::<대상메서드>) : 대상 메서드 지정 가능

메서드 참조 예시

메서드 참조 ? 람다식에서 특정 메서드를 직접 참조

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.Comparator;

public class MethodRefExam {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("신짱구", "김철수", "유리", "훈이", "맹구");

        // 1. 정적 메서드 참조
        System.out.println("─── 정적 메서드 참조 ───");
        List<String> upperNames = names.stream()
                .map(String::toUpperCase) // 람다식 (item -> item.toUpperCase()) 와 동일
                .collect(Collectors.toList());

        // 2. 인스턴스 메서드 참조
        System.out.println("\n─── 인스턴스 메서드 참조 ───");
        List<String> sortedNames = names.stream()
                .sorted(Comparator.comparing(String::length)) // 길이순 정렬
                .collect(Collectors.toList());

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

        // 3. 생성자 참조
        System.out.println("\n─── 생성자 참조 ───");
        List<String> copiedNames = names.stream()
                .map(String::new) // 새로운 String 객체 생성
                .collect(Collectors.toList());

        copiedNames.forEach(System.out::println);
    }
}
starweb
@starweb :: starweb 님의 블로그

starweb 님의 블로그 입니다.

공감하셨다면 구독도 환영합니다!

목차