자바의 동시성과 병렬 처리를 알아보자.
📌 서론
백엔드 개발에서 높은 성능과 효율성을 달성하기 위해 동시성 프로그래밍은 필수적인 요소다. 이번 포스팅에서는 Java를 활용하여 병렬 처리, 스레드 동기화, 뮤텍스, 세마포어 등 다양한 동시성 제어 메커니즘을 실용적인 예제와 함께 살펴보자.
1. 병렬 처리의 실용적인 활용: Java 코드 예제로 알아보기
병렬처리의 필요성
- 현대의 애플리케이션은 대용량 데이터를 신속하게 처리해야 하는 경우가 많다. 이를 위해 시스템은 작업을 병렬로 처리하여 성능을 극대화할 수 있어야 한다. 예를 들어, 대규모 데이터셋을 여러 부분으로 나누어 각 파트를 독립적으로 처리한 후 결과를 합치는 방식이 효과적이다.
Java에서의 병렬 처리 구현
- Java에서는 ForkJoinPool과 병렬 스트림(parallelStream())을 사용하여 손쉽게 병렬 처리를 구현할 수 있습니다. 아래는 대용량 데이터를 병렬로 처리하는 예제 코드입니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
public class DataProcessor {
public static void main(String[] args) {
// 대용량 데이터셋 생성
List<Integer> dataSet = new ArrayList<>();
for (int i = 0; i < 100; i++) {
dataSet.add(i);
}
// ForkJoinPool 객체 생성
ForkJoinPool forkJoinPool = new ForkJoinPool();
// ForkJoinPool에 병렬 작업 제출
forkJoinPool.submit(() -> {
// 병렬 스트림을 사용하여 각 데이터 요소를 처리
dataSet.parallelStream().forEach(DataProcessor::processData);
}).join(); // 작업이 완료될 때까지 대기
}
private static void processData(int data) {
// 시간이 오래 걸리는 작업을 시뮬레이션
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 처리된 데이터 출력
System.out.println("Processed data: " + data);
}
}
예제코드 설명
- ForkJoinPool 생성: ForkJoinPool 객체는 병렬 작업을 관리하고 스케줄링하는 역할을 한다.
- 병렬 작업 제출: forkJoinPool.submit() 메서드를 통해 병렬 작업을 제출하고, .join()을 호출하여 작업 완료를 기다린다.
- 병렬 스트림 사용: dataSet.parallelStream().forEach(DataProcessor::processData);는 데이터셋의 각 요소를 병렬로 처리한다.
- 동시 처리 방식: 여러 스레드가 동시에 데이터를 처리하므로 출력 순서는 실행할 때마다 달라질 수 있다.
ForkJoinPool과 병렬 스트림을 활용하면 대용량 데이터를 효율적으로 병렬 처리할 수 있다.
이를 통해 시스템의 성능을 크게 향상시킬 수 있다.
2. Java의 기초적인 동시성 처리 (synchronized)
스레드 동기화의 필요성
- 백엔드 시스템에서는 다수의 사용자 요청을 효율적으로 처리하기 위해 여러 스레드를 사용한다. 이때, 여러 스레드가 공유 자원에 동시에 접근하면 데이터 무결성이 깨질 수 있으므로 동기화가 필수적이다.
synchronized 키워드
- synchronized는 Java에서 가장 기본적인 동기화 메커니즘으로, 메서드나 블록을 동기화하여 한 번에 하나의 스레드만 접근할 수 있도록 제한한다.
예제 코드: synchronized 키워드 사용
public class SynchronizedExample {
private int count = 0; // 공유 자원
// synchronized 키워드를 사용하여 메서드 동기화
public synchronized void increment() {
count++; // 임계 영역
}
public static void main(String[] args) {
SynchronizedExample example = new SynchronizedExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final value of count: " + example.count);
}
}
- 공유 자원 초기화: count 변수를 0으로 초기화한다.
- 동기화된 메서드: increment() 메서드에 synchronized 키워드를 추가하여 한 번에 하나의 스레드만 접근할 수 있도록 한다.
- 스레드 생성 및 실행: 두 개의 스레드가 각각 increment() 메서드를 1000번 호출한다.
- 결과 확인: 최종적으로 count의 값은 2000이 된다. 이는 동기화를 통해 데이터 무결성이 유지되었음을 보여준다.
synchronized 키워드를 사용하면 여러 스레드가 동시에 공유 자원에 접근할 때 발생할 수 있는 문제를 방지할 수 있다. 이는 데이터 무결성을 유지하는 데 중요한 역할을 한다.
3. Java의 기초적인 동시성 처리 (Lock 인터페이스)
Lock 인터페이스
- synchronized 키워드 외에도 Java는 Lock 인터페이스를 제공한다. Lock 인터페이스는 보다 세밀한 동기화 제어를 가능하게 하며, 다양한 동기화 전략을 구현할 수 있다.
예제 코드: Lock 인터페이스 사용
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private final Lock lock = new ReentrantLock(); // Lock 인터페이스의 구현체
private int count = 0; // 공유 자원
public void increment() {
lock.lock(); // 임계 영역에 진입하기 전에 lock을 획득
try {
count++; // 임계 영역
} finally {
lock.unlock(); // 임계 영역을 빠져나올 때 lock을 해제
}
}
public static void main(String[] args) {
LockExample example = new LockExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final value of count: " + example.count);
}
}
- Lock 객체 생성: ReentrantLock을 사용하여 lock 객체를 생성한다.
- 동기화된 메서드: increment() 메서드에서 lock.lock()을 호출하여 락을 획득하고, 작업이 끝난 후 lock.unlock()을 호출하여 락을 해제한다.
- 스레드 생성 및 실행: 두 개의 스레드가 각각 increment() 메서드를 1000번 호출한다.
- 결과 확인: 최종적으로 count의 값은 2000이 된다.
Lock 인터페이스는 synchronized보다 더 유연한 동기화 제어를 제공한다. 예를 들어, 락 획득 시 시간 제한을 설정하거나, 중단 가능한 락을 사용할 수 있다. 이를 통해 보다 복잡한 동기화 요구 사항을 충족시킬 수 있다.
4. Java에서의 동시성 제어 메커니즘 - 뮤텍스
뮤텍스(Mutex)란?
- 뮤텍스(Mutex, Mutual Exclusion)는 동시성 프로그래밍에서 중요한 개념으로, 공유 자원에 대한 동시 접근을 제어하여 데이터의 일관성과 프로그램의 안정성을 보장한다. 한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 제한함으로써 데이터 경쟁 조건(Race Condition)이나 데드락(Deadlock)과 같은 문제를 예방한다.
왜 사용하는가?
- 데이터 무결성 유지: 공유 자원을 여러 스레드가 동시에 수정하는 것을 방지한다.
- 안정성 보장: 프로그램의 예측 가능성과 안정성을 높인다.
실제 상황 예시
- 데이터베이스 레코드 수정: 여러 스레드가 동시에 동일한 레코드를 수정할 때.
- 파일 시스템 접근: 여러 스레드가 동시에 하나의 파일을 수정하려고 할 때.
- 싱글턴 패턴 구현: 인스턴스 생성을 동기화할 때.
예제 코드: 뮤텍스 구현
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class MutexExample {
private int resource = 0; // 공유 리소스
private final Lock lock = new ReentrantLock(); // 락 객체
// 공유 리소스를 사용하는 메서드
public void useResource() {
lock.lock(); // 락 획득
try {
resource++; // 리소스 사용
System.out.println("스레드에 의해 리소스가 사용 중입니다. 카운트: " + resource);
} finally {
resource--; // 리소스 사용 완료
lock.unlock(); // 락 해제
}
}
public static void main(String[] args) {
MutexExample example = new MutexExample();
// 두 개의 스레드가 동시에 리소스를 사용하려고 시도
Thread t1 = new Thread(() -> example.useResource());
Thread t2 = new Thread(() -> example.useResource());
t1.start();
t2.start();
}
}
- 공유 리소스 초기화: resource 변수를 0으로 초기화한다.
- Lock 객체 생성: ReentrantLock을 사용하여 lock 객체를 생성한다.
- useResource() 메서드
- lock.lock()을 호출하여 락을 획득한다.
- 공유 리소스 resource를 증가시키고 현재 상태를 출력한다.
- resource를 다시 감소시키고 lock.unlock()을 호출하여 락을 해제한다.
- 스레드 생성 및 실행: 두 개의 스레드가 동시에 useResource() 메서드를 호출한다. 락을 통해 한 번에 하나의 스레드만 리소스를 사용할 수 있다.
뮤텍스는 여러 스레드가 동시에 공유 자원에 접근할 때 발생할 수 있는 문제를 방지합니다. Lock 인터페이스를 통해 뮤텍스를 구현하면, 보다 세밀한 동기화 제어가 가능해집니다.
5. Java에서의 동시성 제어 메커니즘 - 세마포어
세마포어(Semaphore)란?
- 세마포어(Semaphore)는 동시성 프로그래밍에서 스레드의 접근을 제어하는 도구로, 특정 자원에 동시에 접근할 수 있는 스레드의 수를 제한합니다. 뮤텍스와 유사하지만, 한 번에 여러 스레드가 자원에 접근할 수 있도록 허용하는 점이 다릅니다. 이를 통해 자원의 과도한 사용을 방지하고 동시 접근을 효율적으로 관리할 수 있습니다.
왜 사용하는가?
- 자원 관리: 제한된 자원(예: 데이터베이스 연결, 파일 핸들 등)에 대한 접근을 제어합니다.
- 과도한 접근 방지: 동시에 접근할 수 있는 스레드 수를 제한하여 시스템의 안정성을 유지합니다.
실제 상황 예시
- 웹 서버 요청 처리: 동시에 처리할 수 있는 요청 수를 제한하여 서버 과부하를 방지합니다.
- 주차 시스템: 제한된 주차 공간에 동시에 접근하는 차량 수를 제어합니다.
예제 코드: 세마포어 구현
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
private final Semaphore semaphore = new Semaphore(3); // 동시에 3개의 스레드만 접근 가능
// 주차하는 메서드
public void parkCar() {
try {
semaphore.acquire(); // 세마포어 획득
System.out.println("Car parked. Available permits: " + semaphore.availablePermits());
// 차를 주차하는 작업 수행
Thread.sleep(1000); // 주차 시간 시뮬레이션
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 세마포어 반환
System.out.println("Car left. Available permits: " + semaphore.availablePermits());
}
}
public static void main(String[] args) {
SemaphoreExample example = new SemaphoreExample();
// 네 개의 스레드가 동시에 주차 공간에 접근
Thread t1 = new Thread(() -> example.parkCar());
Thread t2 = new Thread(() -> example.parkCar());
Thread t3 = new Thread(() -> example.parkCar());
Thread t4 = new Thread(() -> example.parkCar());
t1.start();
t2.start();
t3.start();
t4.start();
}
}
- Semaphore 객체 생성
- Semaphore 객체를 생성할 때 3을 인자로 전달하여 동시에 최대 3개의 스레드만 접근할 수 있도록 설정한다.
- parkCar() 메서드
- semaphore.acquire()를 호출하여 세마포어를 획득한다. 세마포어의 허용량이 모두 사용 중일 경우, 스레드는 대기 상태가 된다.
- 주차 작업을 수행한 후, semaphore.release()를 호출하여 세마포어를 반환한다.
- 스레드 생성 및 실행
- 네 개의 스레드가 동시에 parkCar() 메서드를 호출한다. 세마포어의 제한으로 인해 한 번에 최대 3개의 스레드만 주차할 수 있다.
세마포어는 뮤텍스보다 더 유연한 동시성 제어를 제공하며, 동시에 접근할 수 있는 스레드의 수를 제한함으로써 자원의 효율적인 관리를 가능하게 한다. 이를 통해 시스템의 안정성과 성능을 동시에 향상시킬 수 있다.
다음 포스팅을 읽어보자!
'JAVA' 카테고리의 다른 글
Java I/O: BufferedReader, BufferedWriter, Buffer 사용법 (0) | 2023.11.01 |
---|---|
[Java] 동시성과 병렬 처리 part2: 함정, 고급 패턴, 성능 최적화 (1) | 2023.11.01 |
[Java] Stream: mapToInt 함수로 점수 합산하기 (0) | 2023.09.27 |
[Java] 예제로 이해하는 자바 스트림(stream) (0) | 2023.09.27 |
[Java] ObjectMapper란 무엇인가? (0) | 2023.08.16 |