본문 바로가기

(09.25 ②) Java 프로그래밍 - 프로세스와 스레드, 스레드 생성, 동기화, 데몬스레드, 스레드 제어 메서드

@starweb2025. 9. 25. 20:49

[ 7주차 - 0925 ] - ②

    금일 커리큘럼
        ├ 09:00 ~ 12:00 FrontEnd (...other post)
        └ 13:00 ~ 18:00 자바 프로그래밍 (프로세스와 스레드, 스레드 생성, 동기화, 데몬스레드, 스레드 제어 메서드)

1. 프로세스와 스레드 구조

프로세스 (Process)

  • 프로세스는 실행 중인 프로그램의 인스턴스
  • 각 프로세스는 독립적인 메모리 공간을 가짐
  • 프로세스끼리는 메모리를 공유하지 않음
  • 각 프로세스 안에 여러 스레드가 존재할 수 있음

스레드 (Thread)

  • 스레드는 프로세스 내에서 실제 작업을 수행하는 최소 단위
  • 같은 프로세스 내의 스레드들은 메모리를 공유
  • 각 스레드는 자신만의 스택(Stack)을 가짐
  • 코드, 데이터, 힙 영역은 공유
# 프로세스와 스레드 구조
# Th : thread (스레드)
┌──────────────────────┐    ┌──────────────────────┐
│     Process A        │    │     Process B        │
│                      │    │                      │
│ ┌─────┐ ┌─────┐      │    │ ┌─────┐ ┌─────┐      │
│ │Th-1 │ │Th-2 │      │    │ │Th-1 │ │Th-2 │      │
│ │     │ │     │      │    │ │     │ │     │      │
│ │스택 │ │스택 │      │    │ │스택 │ │스택 │      │
│ └─────┘ └─────┘      │    │ └─────┘ └─────┘      │
│                      │    │                      │
│ 공유 메모리:         │    │ 공유 메모리:         │
│ - Code (코드)        │    │ - Code (코드)        │
│ - Data (전역변수)    │    │ - Data (전역변수)    │
│ - Heap (동적메모리)  │    │ - Heap (동적메모리)  │
└──────────────────────┘    └──────────────────────┘
        독립적                    독립적
       (공유불가)               (공유불가)

# 메모리 공유 관계
Process A: Thread 1,2가 Code/Data/Heap 공유 (Stack만 개별)
Process B: Thread 1,2가 Code/Data/Heap 공유 (Stack만 개별)  
Process A ↔ B: 완전 독립 (메모리 공유 없음)

멀티스레드

멀티스레드란 ? 여러 스레드가 동시에 실행되는 프로그래밍 기법

  • 장점
    • 성능향상 : 여러 작업을 동시에 처리하여 전체 처리 시간 단축
    • 자원효율성 : 프로세스보다 적은 리소스 사용
    • 응답성 향상 : UI가 더 빠르게 반응
  • 단점 및 주의사항
    • 동시성 문제 : 공유 자원 접근 시 충돌 가능
    • 복잡성 증가 : 설계 및 디버깅 어려움
    • 데드락 위험 : 잘못된 동기화로 인한 교착 상태 발생 가능

프로세스와 멀티스레드 비교

구분 멀티 프로세스 멀티 스레드
메모리 공유 독립적인 메모리 공간 메모리 공간 공유
통신 방식 IPC 공유 메모리를 통한 직접 통신
생성 비용 높음 (프로세스 생성 오버헤드) 낮음 (스레드 생성 가벼움)
컨텍스트 스위칭 비용 높음 비용 낮음
안정성 한 프로세스 죽어도 다른 프로세스 영향 없음 한 스레드 오류 시 전체 프로세스 영향
디버깅 상대적으로 쉬움 어려움 (동기화 문제)
메모리 사용량 많음 (각각 독립적 메모리) 적음 (메모리 공유)
확장성 제한적 좋음

스레드는 어떨때 사용 할까?

  • I/O 작업이 많은 경우 (예: 파일 읽기/쓰기, 네트워크 통신)
  • 사용자 인터페이스가 있는 애플리케이션 (UI 스레드와 작업 스레드 분리)
  • 병렬 처리 작업 (예: 대규모 데이터 처리, 이미지 처리)
  • 서버 애플리케이션 (예: 웹 서버에서 각 요청을 별도의 스레드로 처리)

2. 스레드 생성과 실행

스레드 Class 상속하여 별도 스레드 정의 가능

  • Thread 클래스 상속
    • Thread 클래스를 상속받아 run() 메서드를 오버라이딩
    • start() 메서드를 호출하여 스레드 실행
public class MyThread extends Thread {
    private String name;

    MyThread(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        // 스레드가 실행할 코드
        for (int i = 1; i <= 5; i++) {
            System.out.println(this.name + " 실행 중: " + i);
            try {
                Thread.sleep(1000); // 1초 슬립
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
            }
        }
    }

    public static void main(String[] args) {
        System.out.println("메인 스레드 시작");

        MyThread thread1 = new MyThread("Thread-1");
        MyThread thread2 = new MyThread("Thread-2");


        thread1.start(); // 스레드 시작
        thread2.start();

        for (int i = 1; i <= 5; i++) {
            System.out.println("main" + i);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
            }
        }

        System.out.println("메인 스레드 종료");
    }
}
# 실행 결과
메인 스레드 시작
main1
Thread-2 실행 중: 1
Thread-1 실행 중: 1
main2
Thread-1 실행 중: 2
Thread-2 실행 중: 2
main3
Thread-2 실행 중: 3
Thread-1 실행 중: 3
Thread-2 실행 중: 4
Thread-1 실행 중: 4
main4
main5
Thread-2 실행 중: 5
Thread-1 실행 중: 5
메인 스레드 종료

3. Runnable 인터페이스

Runnable 이란 ? 스레드로 실행할 작업을 정의하는 인터페이스

  • Runnable 인터페이스 구현
    • Runnable 인터페이스를 구현하여 run() 메서드를 정의
    • Thread 객체에 Runnable 객체를 전달하여 스레드 실행
  • Runnable = 스레드 ? 아님
    • Runnable 인터페이스는 스레드로 실행할 작업을 정의하는 역할
    • Thread 클래스는 실제 스레드를 생성하고 관리하는 역할
    • Runnable은 작업 단위, Thread는 실행 단위임

Runnable 상속 예시

class MyRunnable implements Runnable {
    private String taskName;

    MyRunnable(String name) {
        this.taskName = name;
    }

    @Override
    public void run() {
        // 스레드가 실행할 코드
        for (int i = 1; i <= 3; i++) {
            System.out.println(this.taskName + " 실행 중: " + i);
            try {
                Thread.sleep(1000); // 1초 슬립
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
            }
        }
    }
}
class RunnableTest {
    public static void main(String[] args) {

        MyRunnable task1 = new MyRunnable("task1");
        MyRunnable task2 = new MyRunnable("task2");

        Thread thread1 = new Thread(task1); // Runnable을 Thread에 전달
        Thread thread2 = new Thread(task2);

        thread1.start(); // 스레드 시작
        thread2.start();
    }
}
# 실행 결과
task1 실행 중: 1
task2 실행 중: 1
task1 실행 중: 2
task2 실행 중: 2
task2 실행 중: 3
task1 실행 중: 3

Runnable 람다 예시

public class LambdaThreadTest {

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("람다스레드확인: " + i);
                try {
                    Thread.sleep(1000);

                } catch (InterruptedException e) {
                    System.out.println(e.getMessage());
                }
            }
        });

        thread1.start();
    }
}
# 실행 결과
람다스레드확인: 1
람다스레드확인: 2
람다스레드확인: 3

스레드 상속 아닌 러너블 구현할때 장점

  • 1. 상속의 제약 : 스레드는 단일상속, 러너블은 다중상속 가능
// ❌ Thread 상속 - 다른 클래스 상속 불가
class MyThread extends Thread {
    // 이미 Thread를 상속받아서 다른 클래스 상속 불가능
}

// ✅ Runnable 구현 - 다른 클래스 상속 가능
class MyTask extends ParentClass implements Runnable {
    public void run() { /* 작업 내용 */ }
}
  • 2. 작업과 스레드 분리 : 같은 작업을 여러 스레드에서 재사용 가능
// Thread 상속 - 작업과 스레드가 결합
class PrintThread extends Thread {
    public void run() {
        System.out.println("Hello");
    }
}

// Runnable 구현 - 작업과 스레드 분리
Runnable task = () -> System.out.println("Hello");
Thread t1 = new Thread(task);  // 같은 작업을
Thread t2 = new Thread(task);  // 여러 스레드에서 재사용
  • 3. 람다식 활용 : Runnable은 함수형 인터페이스로 람다 표현식 사용 가능
// Thread 상속 - 람다 불가
class MyThread extends Thread {
    public void run() { System.out.println("실행"); }
}

// Runnable - 람다 가능 (함수형 인터페이스)
Thread t = new Thread(() -> System.out.println("실행"));
  • 4. 스레드 풀 호환성 : ExecutorService 등 스레드 풀에서 Runnable 사용
ExecutorService executor = Executors.newFixedThreadPool(3);

// Runnable은 스레드 풀에서 바로 사용 가능
executor.execute(() -> System.out.println("스레드 풀 실행"));
executor.submit(() -> System.out.println("Future 반환"));

결론 = 스레드상속 대신 러너블 사용 권장

구분 Thread 상속 Runnable 구현
다중 상속 불가능 가능
유연성 낮음 높음
코드 재사용성 낮음 높음
람다식 사용 불가능 가능
스레드 풀 호환 어려움 쉬움
권장 사항 특별한 경우만 일반적으로 권장 ✅
  • ✅ 더 나은 객체지향 설계 (IS-A vs HAS-A)
  • ✅ 코드의 유연성과 재사용성 향상
  • ✅ 최신 자바의 함수형 프로그래밍 패러다임과 일치
  • ✅ 스레드 풀, CompletableFuture 등과의 호환성

4. 스레드 동기화

동기화란 ? 여러 스레드가 공유 자원에 동시에 접근하는 것을 제어하는 기법

동기화 필요한 이유

  • 여러 스레드가 공유 자원에 동시에 접근하면 데이터 불일치 문제가 발생
    • 예: 은행 계좌 잔액 업데이트, 카운터 증가 등
  • 동기화를 통해 한 번에 하나의 스레드만 공유 자원에 접근하도록 제어

동기화가 없는 경우

class CounterA {
    private int count = 0;

    // 동기화가 없는 메서드
    public void increment() {
        count++; // 비원자적 연산 (읽기 + 쓰기 + 저장 -> 3번 연산)
    }

    public int getCount() {
        return count;
    }
}

public class NoSyncTest {

    public static void main(String[] args) {
        CounterA counter = new CounterA();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        try {
            // join 쓰는 이유 ? 자식스레드 끝날 때까지 메인스레드가 대기함
            thread1.join(); // thread1이 끝날 때까지 대기
            thread2.join(); // thread2가 끝날 때까지 대기
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("최종 카운트: " + counter.getCount());
    }
}
# 실행 결과 (동기화 없을 때)
# 스레드A 1000 + 스레드B 1000 = 2000이 되어야 하지만
# 동기화가 없어서 중간에 count++의 읽기/쓰기/저장 중첩충돌 인해 값이 달라짐
최종 카운트: 1873

synchronized 키워드 (동기화)

  • 메서드나 블록에 synchronized 키워드를 사용하여 동기화 가능
class CounterB {
    private int count = 0;

    // 동기화된 메서드
    public synchronized void increment() {
        count++; // 이제 이 메서드는 한 번에 하나의 스레드만 접근 가능
    }

    public int getCount() {
        return count;
    }
}

public class SyncTest {
    public static void main(String[] args) {
        CounterB counter = new CounterB();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

            try {
            // join 쓰는 이유 ? 자식스레드 끝날 때까지 메인스레드가 대기함
            thread1.join(); // thread1이 끝날 때까지 대기
            thread2.join(); // thread2가 끝날 때까지 대기
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("최종 카운트: " + counter.getCount());
    }
}
# 실행 결과 (동기화 있을 때)
# 동기화되서 2000 정상 출력
최종 카운트: 2000

synchronized 블록

  • 특정 코드 부분을 synchronized() 블록으로 동기화 가능
  • 특정 객체 잠금 ? lock 객체를 사용하여 동기화 범위 지정
    • lock : 특정 객체 잠금 (추천)
    • this : 현재 객체 잠금
class CounterC {
    private final Object lock = new Object();
    private int count = 0;

    public void increment() {
        // 동기화 블록
        synchronized (lock) { // lock 객체를 잠금
            count++;
        }
        // or
        // synchronized (this) { // 현재 객체를 잠금
        //     count++;
        // }
    }

    public int getCount() {
        return count;
    }
}

5. 데몬스레드

데몬스레드란 ? 백그라운드에서 실행되는 스레드로, 주 스레드가 종료되면 자동으로 종료됨

  • 데몬스레드 특징
    • 주 스레드가 종료되면 데몬스레드도 자동 종료
    • 주로 백그라운드 작업에 사용 (예: 가비지 컬렉터, 로그 기록 등)
    • setDaemon(true) 메서드로 데몬스레드 설정 가능
class TaskThread extends Thread {
    public void run() {
        System.out.println("작업시작");
        try{
            sleep(2000); // 2초 작업
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("TaskThread 작업 완료"); // 추가하면 더 명확
    }
}

class DaemonThread extends Thread {
    public void run() {
        while(true){
            System.out.println("일하는중...");
            try{
                sleep(1000); // 1초마다 출력
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

public class DaemonExam {
    public static void main(String[] args) {
        TaskThread task1 = new TaskThread();
        task1.start();

        DaemonThread deTh = new DaemonThread();
        deTh.setDaemon(true); // 데몬스레드로 설정
        deTh.start();

        try{
            task1.join(); // TaskThread가 끝날 때까지 대기
        }catch(InterruptedException e){
            System.out.println(e.getMessage());
        }

        System.out.println("작업완료");
        // 여기서 main 스레드 종료 후 데몬스레드도 자동 종료
    }
}
# 실행 결과
작업시작
일하는중...
일하는중...
TaskThread 작업 완료  # TaskThread 2초 작업 완료
일하는중...           # 데몬스레드가 한 번 더 실행
작업완료              # main 스레드 & 데몬스레드 종료
  • 데몬스레드 쓰는 이유
    • 백그라운드에서 지속적으로 실행되어야 하는 작업에 적합
    • 주 스레드가 종료되면 자동으로 종료되어 자원 해제 용이
    • 예: 로그 기록, 모니터링, 가비지 컬렉션 등
  • 데몬스레드 주의사항
    • 데몬스레드는 중요한 작업에 사용하지 말 것 (예: 파일 저장, 네트워크 통신)
    • 주 스레드가 종료되면 데몬스레드도 즉시 종료되어 작업이 중단될 수 있음
    • 데몬스레드 내에서 자원을 해제하거나 정리하는 코드를 작성하지 말 것

etc.

스레드 제어 메서드 - sleep(), join(), interrupt()

sleep() : 지정된 시간동안 스레드 일시 정지

  • Thread.sleep(밀리초) 형태로 사용
  • InterruptedException 익셉션 처리 필요
// 앞선 예제에서 사용된 sleep() 
try {
    Thread.sleep(1000); // 1초간 일시 정지
} catch (InterruptedException e) {
    System.out.println(e.getMessage());
}
  • sleep() 사용 이유 ?
    • 스레드 간 실행 시간 간격 조절
    • CPU 사용률 조절 (무한루프 방지)
    • 시뮬레이션에서 시간 지연 효과 구현

join() - 다른 스레드 스레드 완료 대기

  • 순차적 실행이 필요할 때 사용
  • InterruptedException 익셉션 처리 필요
// 동기화 예제에서 사용된 join()
try {
    thread1.join(); // thread1이 끝날 때까지 대기
    thread2.join(); // thread2가 끝날 때까지 대기
} catch (InterruptedException e) {
    e.printStackTrace();
}

System.out.println("최종 카운트: " + counter.getCount());
  • join() 사용 이유 ?
    • 자식 스레드 완료 후 결과 확인
    • 모든 작업 완료 후 다음 단계 진행
    • 메인 스레드가 너무 빨리 종료되는 것 방지

interrupt() - 실행 중인 스레드에게 중단 요청

  • sleep(), wait(), join() 상태의 스레드를 깨움
  • InterruptedException 익셉션 발생 시킴
public class InterruptExample {
    public static void main(String[] args) {
        Thread worker = new Thread(() -> {
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    System.out.println("작업 중...");
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                System.out.println("작업이 중단됨");
            }
        });

        worker.start();

        // 3초 후 중단 신호
        try {
            Thread.sleep(3000);
            worker.interrupt(); // 중단 요청
        } catch (InterruptedException e) {
            System.out.println(e.getMessage());
        }
    }
}

스레드 제어 메서드 정리

메서드 기능 사용된 예제
sleep(ms) 지정 시간 일시 정지 Thread 상속, Runnable, 람다, 동기화 예제
join() 다른 스레드 완료 대기 동기화 예제, 데몬스레드 예제
interrupt() 스레드 중단 신호 별도 예제

 

InterruptedException 이란?

  • 스레드가 대기 상태에 있을 때 interrupt() 메서드로 중단 신호를 받으면 발생
  • sleep(), wait(), join() 메서드에서 발생 가능
  • 예외 처리를 통해 스레드를 정상적으로 종료하거나 다른 작업 수행

멀티 스레드 에러 영향

  • 스레드 A에서 예외 발생 시 해당 스레드만 종료되고, 다른 스레드는 계속 실행됨
  • 단, OutOfMemoryError와 같은 치명적인 오류는 JVM 전체에 영향을 미쳐 모든 스레드가 종료될 수 있음

스레드 A Error & 스레드 B 러닝 예시

public class ThreadErrorTest {
    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            System.out.println("Thread A 시작");
            int result = 10 / 0; // 오류 발생
            System.out.println("Thread A 끝"); // 실행 안됨
        });

        Thread threadB = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                System.out.println("Thread B 실행: " + i);
                try { Thread.sleep(1000); } 
                catch (InterruptedException e) {}
            }
        });

        threadA.start();
        threadB.start(); // Thread A 오류와 상관없이 정상 실행
    }
}

스레드 A OutOfMemoryError 예시

public class ThreadErrorTest {
    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            System.out.println("Thread A 시작");
            throw new OutOfMemoryError(); // 치명적인 오류 발생
        });

        Thread threadB = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                System.out.println("Thread B 실행: " + i);
                try { Thread.sleep(1000); } 
                catch (InterruptedException e) {}
            }
        });

        threadA.start();
        threadB.start(); // 스레드A에서 JVM 전체 영향 줘서 다른 스레드 종료
    }
}
starweb
@starweb :: starweb 님의 블로그

starweb 님의 블로그 입니다.

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

목차