Call by Value: 인자로 전달된 변수의 값만 복사하여 사용. 원본 변수에는 영향을 주지 않는다.
Call by Reference: 인자로 전달된 변수의 메모리 주소(참조값, reference)를 전달. 함수 내에서 값이 변경되면 원본 변수에도 영향을 미친다.
자바는 Call by Value
자바의 메서드 호출 방식은 항상 Call by Value다. 즉, 메서드가 인자를 받을 때 원본 변수의 값을 복사하여 전달한다.
기본 타입 (Primitive Type)
기본 타입(예: int, double, char 등)은 Call by Value로 동작하여 원본 변수에 영향을 주지 않는다.
참조 타입 (Reference Type)
객체(Object)와 같은 참조 타입의 경우, 객체의 참조값(메모리 주소)이 값으로 전달된다. 따라서 참조하는 객체의 속성을 변경하면 원본 객체에도 영향을 준다. 하지만 참조 자체를 변경해도(새로운 객체를 할당하면) 원본에는 영향을 주지 않는다.
classPerson{
String name;
}
publicclassExample{
publicstaticvoidchangePerson(Person p){
p = new Person(); // 새로운 객체를 할당
p.name = "Charlie";
}
publicstaticvoidmain(String[] args){
Person person = new Person();
person.name = "Bob";
changePerson(person);
System.out.println(person.name); // 여전히 Bob (새로운 객체는 원본에 영향을 주지 않음)
}
}
changePerson 함수 안에서 p가 새로운 객체를 가리키도록 변경되었지만, 이것은 메서드 내부의 p 변수가 가리키는 참조가 바뀐 것일 뿐, 원래 person 변수에는 영향을 주지 않는다. 만약 메서드 내부에서 새로운 객체를 만들어 원본에도 반영하고 싶다면, 리턴 값을 활용하여 원본 변수를 직접 변경해야 한다.
publicstatic Person changePerson(Person p){
returnnew Person("New Person");
}
publicstaticvoidmain(String[] args){
Person person = new Person("Original");
person = changePerson(person); // 리턴 값을 원본 변수에 할당
System.out.println(person.name); // "New Person"
}
객체의 참조값(메모리 주소)을 복사하여 전달하기 때문에, 객체 내부 값은 변경할 수 있지만, 객체 자체를 변경할 수는 없다.
SimpleDateFormat은 내부적으로 Calendar 인스턴스를 공유하는데, 이 과정에서 공유 자원 변경이 발생하여 멀티스레드 환경에서 예상치 못한 결과를 초래할 수 있다. SimpleDateFormat은 멀티스레드 환경에서 사용할 경우 각 스레드마다 별도 인스턴스를 생성하거나 ThreadLocal을 이용해야 한다.
Java 8부터는 DateTimeFormatter가 제공되며, 이는 불변(immutable) 객체이므로 여러 스레드에서 동시에 안전하게 사용할 수 있다. 따라서 매번 새로 생성할 필요 없이, 재사용하는 것이 성능적으로도 더 유리하다.
Jedis는 Redis 클라이언트를 위한 핵심적인 기능만 포함하며, 추가적인 추상화나 고급 기능(분산 락, 세마포어 등)은 제공하지 않음
다른 클라이언트(예: Redisson)에 비해 의존성이 적고, 실행 크기가 작음, 가벼움
장점
사용법이 단순하고 직관적이어서 빠르게 배울 수 있음.
Redis와의 네이티브 한 연동 및 모든 명령어를 제공.
간단한 애플리케이션에서 적합.
단점
Thread-Safe 하지 않음: 멀티스레드 환경에서 사용하려면 JedisPool을 사용해야 함.
싱글스레드 기반이라 멀티스레드 환경에서는 성능이 저하될 수 있음.
추천 사용 시나리오
단일 스레드 기반 애플리케이션.
Redis를 단순 캐싱 또는 데이터 저장소로 사용하는 경우.
2. Lettuce
특징
비동기 및 동기 API를 모두 제공하며, Reactive Streams(Flux/Mono)도 지원.
기본적으로 비동기적으로 실행되고, 결과는 CompletionStage 또는 Future를 통해 반환됨
Netty를 기반으로 한 non blocking I/O 모델 사용.
Thread-Safe: 단일 커넥션을 여러 스레드에서 공유 가능.
장점
멀티스레드 환경에서 효율적이며 Thread-Safe.
비동기 작업에 유리하며 고성능 제공.
Reactive 프로그래밍 환경과의 호환성이 뛰어남.
클러스터와 Sentinel 환경 지원.
단점
API가 Jedis보다 다소 복잡할 수 있음.
초심자에게는 학습 곡선이 약간 높음.
추천 사용 시나리오
비동기 작업이 많은 고성능 애플리케이션.
멀티스레드 환경.
클러스터 또는 Sentinel 기반 Redis 설정.
Reactive 프로그래밍(Spring WebFlux 등)을 사용하는 경우.
3. Redisson
특징
Redis를 기반으로 한 고급 분산 기능 제공(분산 락, 분산 캐시 등).
Redis를 Java의 분산 데이터 구조와 유사하게 다룰 수 있는 API 제공.
Thread-Safe 하며, 다양한 고급 기능이 포함됨.
장점
분산 락, 분산 세마포어, RMap, RList 등 고급 데이터 구조 지원.
클러스터, Sentinel, 레플리카 환경에서 유연하게 동작.
Redis 클라이언트를 단순한 캐싱 도구 이상으로 활용 가능.
단점
다른 클라이언트보다 무겁고 약간의 추가 오버헤드 발생.
Jedis나 Lettuce에 비해 더 많은 메모리 사용 가능.
추천 사용 시나리오
분산 락, 세마포어, 큐와 같은 고급 분산 기능이 필요한 환경.
Redis를 데이터베이스 이상의 목적으로 사용하는 경우.
복잡한 클러스터 환경.
jedis 사용
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>1.8.4.RELEASE</version>
</dependency>
<!-- Jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
public <T> T lockProcess(String redisKey, long lockSecond, Supplier<T> logic){
log.debug("redisKey: {}, ttl: {}", redisKey, lockSecond);
try {
acquireLock(redisKey, lockSecond);
return logic.get();
} finally {
releaseLock(redisKey);
}
}
privatevoidacquireLock(String redisKey, long lockSecond){
final RedisCallback<Boolean> redisCallback = connection -> {
byte[] key = redisTemplate.getStringSerializer().serialize(redisKey);
byte[] value = redisTemplate.getStringSerializer().serialize("locked");
return connection.setNX(key, value) && connection.expire(key, lockSecond);
};
boolean success = redisTemplate.execute(redisCallback);
if (!success) {
thrownew ApiLockException("이미 처리 중 입니다. key: " + redisKey);
}
}
privatevoidreleaseLock(String redisKey){
redisTemplate.delete(redisKey);
}
위처럼 분산락을 jedis를 사용하여 개발하면 락을 얻고 ttl을 주고 해제하는 과정을 수동으로 개발해야 한다.
redisson으로 수정
redisson을 사용하면 아래와 같이 라이브러리에서 제공하는 기능을 사용하여 간소화하게 짤 수 있다.
public <T> T lockProcess(String redisKey, long lockSecond, Supplier<T> logic){
RLock lock = redissonClient.getLock(redisKey); // Redis 분산 락 생성
log.debug("Acquiring lock for redisKey: {}, ttl: {} seconds", redisKey, lockSecond);
try {
// 락을 시도하고 성공할 경우 논리 실행if (lock.tryLock(0, lockSecond, TimeUnit.SECONDS)) {
return logic.get();
} else {
thrownew ApiLockException("이미 처리 중 입니다. key: " + redisKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
thrownew ApiLockException("락을 획득하는 동안 인터럽트가 발생했습니다. key: " + redisKey, e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // 락 해제
log.debug("Lock released for redisKey: {}", redisKey);
}
}
}
lettuce...?
호옥시 lettuce로 바꾸면 어떻게 될까 궁금해서 chat gpt에게 물어봤다.
public <T> T lockProcess(String redisKey, long lockSecond, Supplier<T> logic){
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisCommands<String, String> commands = connection.sync();
log.debug("Acquiring lock for redisKey: {}, ttl: {} seconds", redisKey, lockSecond);
if (acquireLock(commands, redisKey, lockSecond)) {
try {
return logic.get();
} finally {
releaseLock(commands, redisKey);
}
} else {
thrownew ApiLockException("이미 처리 중 입니다. key: " + redisKey);
}
}
}
privatebooleanacquireLock(RedisCommands<String, String> commands, String redisKey, long lockSecond){
String result = commands.set(redisKey, "locked", io.lettuce.core.SetArgs.Builder.nx().ex(lockSecond));
return"OK".equals(result); // Redis SET NX PX 결과 확인
}
privatevoidreleaseLock(RedisCommands<String, String> commands, String redisKey){
commands.del(redisKey); // 락 해제
log.debug("Lock released for redisKey: {}", redisKey);
}
lettuce는 동기 동작을 위해 .sync()를 꼭 넣어야 하고(비동기는 .async()) 나머지는 jedis와 비슷하게 직접(setnx) 락을 설정하고 ttl도 설정해야 한다.
그리고 lettuce의 StatefulRedisConnection를 사용하는 경우 close 해줘야 한다. AutoCloseable를 implement 했기 때문에 try-with-resources 사용 가능하다.
연결 풀을 사용할 경우에는, 연결이 자동으로 풀로 반환되며 그 자체로 명시적인 종료가 필요하지 않지만, StatefulRedisConnection은 연결 풀을 사용하지 않고, 각 연결을 명시적으로 관리하는 방식.
연결 풀을 사용하면 연결을 가져오고 반환하는 방식으로 관리되기 때문에, 풀에서 자동으로 연결을 회수하고 재사용할 수. 하지만 StatefulRedisConnection은 풀을 사용하지 않기 때문에 사용자가 직접 연결을 닫고 관리해야 합니다.
shutdown을 수동으로 해야하나..?
참고로 레디스 연결 객체인 RedisClient와 RedisConnectionFactory는 프로그램 종료 시 shutdown이 되어야 하는데, 빈으로 등록되어 있는 경우 Spring Boot에서는 자동으로 연결 종료가 처리된다. 이렇게 되면 명시적인 shutdown() 호출은 필요하지 않는다. Spring Boot는 빈 관리 및 라이프사이클을 자동으로 처리하기 때문에, 애플리케이션 종료 시 리소스를 자동으로 정리한다.
하지만 Spring Framework에서는 명시적인 shutdown() 호출이 필요할 수 있다. 왜냐하면 Spring Framework에서는 자동으로 리소스를 관리하는 기능이 내장되어 있지 않기 때문...?
ForkJoinPool은 Java 7에 도입된 병렬 처리 프레임워크로, 작업을 작은 단위로 분할(fork)하고 병렬로 처리한 후 다시 합치는(join) 방식으로 동작한다. 병렬 프로그래밍과 작업 스케줄링을 위한 강력한 도구로, 특히 대규모 데이터 처리나 계산 집약적인 작업에 유용하다.
이게 프레임워크?
고수준의 작업 관리: ForkJoinPool은 작업 스케줄링, 워크 스틸링, 병렬 처리 등을 관리하는 메커니즘을 제공한다. 개발자가 세부적인 스레드 관리나 큐 처리 등을 직접 코딩하지 않아도 된다.
제어 역전 (IoC, Inversion of Control): 작업 실행과 스레드 관리는 ForkJoinPool이 수행하며, 개발자는 작업의 논리만 작성
작업 분할 및 병합 전략:RecursiveTask와 RecursiveAction이라는 추상 클래스를 기반으로 작업을 설계하며, 내부적으로는 효율적인 작업 분할 및 병합을 자동으로 처리한다.
워크 스틸링 (Work Stealing): 스레드 풀에서 작업 큐를 관리하며, 비활성 스레드가 다른 활성 스레드의 큐에서 작업을 가져와 실행하는 동적 작업 분배를 한다.(처리량을 최적화); 개발자가 구현할 필요 없이 forkjoinpool이 자동으로 처리
표준화된 인터페이스: 개발자가 사용할 수 있는 명확한 API (invoke, submit, fork, join 등)를 제공. 이로 인해 복잡한 병렬 프로그래밍을 간단히 구현할 수 있음.
장점
멀티코어를 활용하여 작업을 병렬로 처리하므로 CPU 사용률이 최적화
워크 스틸링을 통해 비효율적인 작업 분배를 방지
단점
작업 분할 및 병합에 대한 오버헤드가 존재
I/O 중심 작업에서는 비효율적이며, CPU 집약적인 작업에 적합
적합한 상황
데이터가 많고, 병렬로 처리할 수 있는 작업
재귀작업/반복적으로 작업을 나눌 수 있을 때 (예: 합계, 정렬)
CPU 집약적인 작업에서 최적의 성능을 얻고자 할 때
개발 시 전체적인 흐름
큰 작업이면 Fork하여 병렬 처리.
작은 작업이면 직접 계산으로 효율적 처리.
모든 계산이 끝나면 병렬 결과를 Join하여 최종 결과를 얻음.
작은 작업은 직접 계산하는 이유
작업 분할의 비용 문제:
Fork/Join Framework는 큰 작업을 작은 작업으로 나누고 각 작업을 병렬적으로 실행
하지만 작업을 너무 많이 나누면작업 분할과 작업 병합(merge)에 드는 오버헤드(비용)가 커질 수 있음
작은 작업에 대해서는 작업 분할을 하지 않고 직접 계산하여 오버헤드를 줄임
효율성 최적화:
일정 크기 이하의 작업은 더 이상 병렬로 처리할 필요가 없으므로 직접 계산이 더 효율적
예를 들어, 배열의 일부를 합산하거나 특정 범위의 숫자를 더하는 간단한 작업이라면, 병렬처리 대신 반복문을 통해 순차적으로 계산하는 것이 빠름
ForkJoinPool의 주요 메서드
invoke(ForkJoinTask<?> task): 기다리고 결과를 받음
execute(ForkJoinTask<?> task): 작업을 비동기로 실행
submit(ForkJoinTask<?> task): 작업을 실행하고 Future를 반환
ForkJoinPool 개발 시 RecursiveTask와 RecursiveAction의 역할
RecursiveTask<V>:
반환값이 있는 병렬 작업을 정의할 때 사용
작업을 분할하고 결과를 합산하여 반환(compute() 메서드)
RecursiveAction:
반환값이 없는 병렬 작업을 정의할 때 사용.
단순히 작업을 수행하고 결과를 반환하지 않는 경우 적합(compute() 메서드)
꼭 써야해?
RecursiveTask를 상속하지 않고도 직접ForkJoinTask또는Runnable과 같은 인터페이스를 사용할 수 있다. 하지만 이는 더 복잡하고 비효율적이며 코드 복잡성을 증가시킨다.
ForkJoinPool
스레드 갯수를 생략하면, 기본적으로가용한 CPU 코어 수에 따라 동작
스레드 수 = Runtime.getRuntime().availableProcessors() 즉, 현재 시스템의 CPU 코어 수(논리적 코어 포함)가 기본 스레드 수로 사용됨
ForkJoinPool pool = newForkJoinPool(); //내부적으로 가용한 프로세서 수를 기반으로 스레드 풀 크기를 결정
ForkJoinPool pool = newForkJoinPool(4); // 스레드 4개 사용
예시
import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;
publicclassForkJoinExample{
staticclassSumTaskextendsRecursiveTask<Integer> {
privatefinalint[] arr;
privatefinalint start, end;
privatestaticfinalint THRESHOLD = 10;
publicSumTask(int[] arr, int start, int end){
this.arr = arr;
this.start = start;
this.end = end;
}
@Overrideprotected Integer compute(){
if (end - start <= THRESHOLD) {
// 작은 작업은 직접 계산int sum = 0;
for (int i = start; i < end; i++) {
sum += arr[i];
}
return sum;
} else {
// 작업 분할int mid = (start + end) / 2;
SumTask leftTask = new SumTask(arr, start, mid);
SumTask rightTask = new SumTask(arr, mid, end);
leftTask.fork(); // 병렬 처리int rightResult = rightTask.compute(); // 동기 처리int leftResult = leftTask.join(); // 병합return leftResult + rightResult;
}
}
}
publicstaticvoidmain(String[] args){
int[] arr = newint[100];
for (int i = 0; i < arr.length; i++) arr[i] = i + 1;
ForkJoinPool pool = new ForkJoinPool();
SumTask task = new SumTask(arr, 0, arr.length);
int result = pool.invoke(task);
System.out.println("Sum: " + result); // 출력: Sum: 5050
}
}
1. leftTask.fork();
작업 분할:
leftTask를 병렬로 처리할 수 있도록 Fork-Join Pool에 작업 큐로 제출
fork() 메서드는 현재 작업을 Fork-Join Pool의 스레드가 처리하도록 요청하며, 비동기적으로 실행
이 시점에서 leftTask는 아직 결과를 계산하지 않음
2. int rightResult = rightTask.compute();
동기 실행:
rightTask는 직접 현재 스레드에서 동기적으로 계산
compute() 메서드는 RecursiveTask에서 작업을 처리하는 메인 로직
이렇게 함으로써 하나의 스레드가 rightTask를 바로 계산하여, 자원을 최대한 활용
3. int leftResult = leftTask.join();
결과 병합:
join() 메서드는 leftTask가 완료될 때까지 대기하고 결과를 반환
만약 leftTask가 이미 완료되었으면, 바로 결과를 반환
이를 통해 leftTask와 rightTask의 결과를 병합
왜 이런 방식으로 처리?
자원의 효율적 활용:
leftTask는 병렬로 실행하도록 요청 (fork())
한편, rightTask는 현재 스레드에서 처리 (compute())
이렇게 하면 다른 작업 스레드가 leftTask를 처리하는 동안, 현재 스레드가 놀지 않고 rightTask를 계산하여 자원을 최대한 활용
병렬성과 동기화의 조합:
fork()로 비동기 작업을 시작하고 join()으로 결과를 기다리며 동기화.
병렬성과 동기화의 균형을 유지하면서 성능을 최적화
ForkJoinPool
특징:
Java 7에서 도입.
작업 분할(divide-and-conquer)을 기반으로 병렬 처리를 수행.
Work-Stealing 알고리즘을 사용해 작업이 끝난 스레드가 다른 스레드의 작업을 훔쳐 효율성을 높임.
주로 재귀적인 작업 처리나 작업 분할에 사용.
RecursiveTask(결과 반환)와 RecursiveAction(결과 없음)을 통해 작업 정의.