Call by Value: 인자로 전달된 변수의 값만 복사하여 사용. 원본 변수에는 영향을 주지 않는다.
Call by Reference: 인자로 전달된 변수의 메모리 주소(참조값, reference)를 전달. 함수 내에서 값이 변경되면 원본 변수에도 영향을 미친다.
자바는 Call by Value
자바의 메서드 호출 방식은 항상 Call by Value다. 즉, 메서드가 인자를 받을 때 원본 변수의 값을 복사하여 전달한다.
기본 타입 (Primitive Type)
기본 타입(예: int, double, char 등)은 Call by Value로 동작하여 원본 변수에 영향을 주지 않는다.
참조 타입 (Reference Type)
객체(Object)와 같은 참조 타입의 경우, 객체의 참조값(메모리 주소)이 값으로 전달된다. 따라서 참조하는 객체의 속성을 변경하면 원본 객체에도 영향을 준다. 하지만 참조 자체를 변경해도(새로운 객체를 할당하면) 원본에는 영향을 주지 않는다.
class Person {
String name;
}
public class Example {
public static void changePerson(Person p) {
p = new Person(); // 새로운 객체를 할당
p.name = "Charlie";
}
public static void main(String[] args) {
Person person = new Person();
person.name = "Bob";
changePerson(person);
System.out.println(person.name); // 여전히 Bob (새로운 객체는 원본에 영향을 주지 않음)
}
}
changePerson 함수 안에서 p가 새로운 객체를 가리키도록 변경되었지만, 이것은 메서드 내부의 p 변수가 가리키는 참조가 바뀐 것일 뿐, 원래 person 변수에는 영향을 주지 않는다. 만약 메서드 내부에서 새로운 객체를 만들어 원본에도 반영하고 싶다면, 리턴 값을 활용하여 원본 변수를 직접 변경해야 한다.
public static Person changePerson(Person p) {
return new Person("New Person");
}
public static void main(String[] args) {
Person person = new Person("Original");
person = changePerson(person); // 리턴 값을 원본 변수에 할당
System.out.println(person.name); // "New Person"
}
객체의 참조값(메모리 주소)을 복사하여 전달하기 때문에, 객체 내부 값은 변경할 수 있지만, 객체 자체를 변경할 수는 없다.
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);
}
}
private void acquireLock(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) {
throw new ApiLockException("이미 처리 중 입니다. key: " + redisKey);
}
}
private void releaseLock(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 {
throw new ApiLockException("이미 처리 중 입니다. key: " + redisKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new 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 {
throw new ApiLockException("이미 처리 중 입니다. key: " + redisKey);
}
}
}
private boolean acquireLock(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 결과 확인
}
private void releaseLock(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에서는 자동으로 리소스를 관리하는 기능이 내장되어 있지 않기 때문...?
ExecutorService는 작업을 스레드 풀에서 관리하고 실행하는 인터페이스로, 사용이 끝나면 반드시 종료(shutdown)해줘야 합니다. 그렇지 않으면 애플리케이션이 종료되지 않고 백그라운드에서 스레드가 계속 실행될 수 있습니다.
ExecutorService의 종료 필요성
ExecutorService는 기본적으로 백그라운드 스레드 풀을 관리합니다. 따라서 다음과 같은 이유로 사용이 끝난 후 반드시 종료해야 합니다:
리소스 해제:
스레드 풀에 의해 사용되는 스레드와 기타 리소스를 해제하여 메모리 누수를 방지합니다.
정상적인 애플리케이션 종료:
스레드 풀이 종료되지 않으면 JVM이 종료되지 않고 계속 대기 상태에 있을 수 있습니다.
명시적 종료 호출:
executor.shutdown()을 호출하여 스레드 풀을 정상적으로 종료합니다. 이 메서드는 더 이상 새로운 작업을 수락하지 않고, 기존에 제출된 작업이 완료될 때까지 기다립니다.
executor.shutdownNow()를 호출하면 모든 작업을 중지하고, 실행 중인 작업을 즉시 종료하려고 시도합니다.
ExecutorService는 AutoCloseable을 구현하지 않기 때문에 try-with-resources 구문을 직접 사용할 수 없습니다.
public class ExecutorServiceExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
executor.submit(() -> System.out.println("Task 1"));
executor.submit(() -> System.out.println("Task 2"));
} finally {
// ExecutorService 종료
executor.shutdown();
try {
if (!executor.awaitTermination(60, java.util.concurrent.TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
}
위 코드에서는 shutdown() 메서드를 호출하여 새로운 작업을 수락하지 않도록 하고, awaitTermination()을 사용하여 스레드 풀이 완전히 종료될 때까지 대기합니다. awaitTermination()은 주어진 시간 동안 스레드 풀이 종료될 때까지 기다리며, 그 시간이 지나도 종료되지 않으면 shutdownNow()를 호출하여 강제로 종료를 시도합니다.
"main" #1 prio=5 os_prio=0 tid=0x00000000023f6000 nid=0x2c runnable [0x0000000002a1e000]
java.lang.Thread.State: RUNNABLE
at java.lang.Thread.sleep(Native Method)
at Example.main(Example.java:5)
현재 돌고 있는 프로세스의 덤프 뜨는 법
jmap이나 jcmd 명령어 사용(권한 필요)
sudo jmap -dump:format=b,file=/경로/heapdump.hprof <PID>
-- or
jcmd <PID> GC.heap_dump <경로>
// /proc/3272/root폴더에 권한이 없을 경우
Exception in thread "main" com.sun.tools.attach.AttachNotSupportedException: Unable to open socket file /proc/3272/root/tmp/.java_pid3272: target process 3272 doesn't respond within 10500ms or HotSpot VM not loaded
at jdk.attach/sun.tools.attach.VirtualMachineImpl.<init>(VirtualMachineImpl.java:100)
at jdk.attach/sun.tools.attach.AttachProviderImpl.attachVirtualMachine(AttachProviderImpl.java:58)
at jdk.attach/com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:207)
at jdk.jcmd/sun.tools.jmap.JMap.executeCommandForPid(JMap.java:128)
at jdk.jcmd/sun.tools.jmap.JMap.dump(JMap.java:208)
at jdk.jcmd/sun.tools.jmap.JMap.main(JMap.java:114)
MAT(Eclipse Memory Analyzer Tool)
hprof파일을 얻었으면 분석 프로그램인 eclipse MAT 프로그램이 필요하다.
다운로드하고 hprof파일을 열어준다. hprof 파일의 용량이 클 수록 오래걸린다.
분석 보고서 이해하기
Overview (개요): 메모리 상태, 누수 가능성, 가장 큰 객체 등을 요약하여 보여줌
Dominator Tree: 힙 메모리의 최상위 점유자를 트리 구조로 보여주며 메모리 점유 비율이 큰 객체를 쉽게 파악 가능
Histogram: 클래스별로 객체 수와 메모리 점유량
Top Consumers: 메모리 사용량이 큰 객체 그룹
힙 분석 예시
메모리 누수 확인:
Leak Suspects Report를 사용하면 누수 가능성이 있는 객체를 분석하여 보여줌
"Path to GC Root" 기능을 사용해 메모리에서 해제되지 않은 객체의 참조 경로를 추적할 수 있음
Dominator Tree 분석:
Dominator Tree를 통해 메모리를 가장 많이 차지하는 객체 파악
with outgoing references를 사용하여 참조 중인 객체들을 확인
Histogram 분석:
클래스별로 객체 수와 메모리 점유율을 확인하여 특정 클래스가 메모리를 많이 사용하는지 파악
특정 클래스에서 메모리를 많이 사용하는 객체가 있다면, 이를 "List Objects -> with incoming references"로 추적 가능
클래스 안에서 클래스끼리 비교할 때 override해두면 Collections.sort, Arrays.sort 등에서 사용됨
하나뿐인 아래 함수를 상속받고 구현하면 됨, 구현해야지만 사용할 수 있음(not functional interface)
public int compareTo(T o);
해당 함수로 현재 객체(this)와 다른 객체(o)를 비교할 수 있으며 반환 값은 아래와 같음
this < o : -1 정방향
this == o : 0
this > o : 1 역방향
참고로 String 객체는 해당 interface의 함수를 이미 구현하고 있어서 별도의 설정을 하지 않아도 알파벳 순 정렬을 할 수 있다.
String.java
import java.util.*;
@Getter
@ToString
@AllArgsConstructor
public class Person implements Comparable<Person> { ///
private String name;
private int age;
@Override
public int compareTo(Person other) { ///
return Integer.compare(this.age, other.age); // Natural order by age
}
// Main method for testing
public static void main(String[] args) {
List<Person> people = Arrays.asList(
new Person("Alice", 30),
new Person("Bob", 25),
new Person("Charlie", 35)
);
// Sort using the natural order defined by Comparable
// 안줘도 자동으로 있는거 사용
Collections.sort(people);
System.out.println("Sorted by age (natural order): " + people);
}
}
Comparator Interface
Comparator is intended to be used for defining external comparison logic rather than internal natural ordering. However, you can create a Comparator for Person as a separate class, an inner class, or even as a static field in the Person class to provide custom sorting criteria.
객체 자체의 natural ordering 이 없거나, 좀 더 복잡한 정렬 방법이 있을 경우 사용(flexibility)
아래 함수를 상속받고 구현하면 됨(functional interface) ; 람다로 사용가능
int compare(T o1, T o2);
int를 반환하도록 되어있는데, 비교 값은 아래와 같다.
앞 < 뒤 : -1 정방향
앞 == 뒤 : 0
앞 > 뒤 : 1 역방햐
String 객체에 역시 이미 정의 되어 있다.
String.java
기본함수
Comparator<T> reversed():
Returns a comparator that reverses the order of this comparator.
Comparator<T> thenComparing(Comparator<? super T> other):
Returns a comparator that first compares using this comparator, and if the comparison is equal, uses the provided comparator. 앞에 비교한 결과가 같으면 추가 사용!
import java.util.*;
@Getter
@ToString
@AllArgsConstructor
public class Person { //여기서 comparator implement못하고 별도의 클래스로 빼야 함
private String name;
private int age;
// Main method for testing
public static void main(String[] args) {
List<Person> people = Arrays.asList(
new Person("Alice", 30),
new Person("Bob", 25),
new Person("Charlie", 35)
);
// Sort by name
// 별도로 만들어서 사용 , 람다 사용가능
Comparator<Person> nameComparator = Comparator.comparing(Person::getName);
Collections.sort(people, nameComparator);
System.out.println("Sorted by name: " + people);
// Sort by age in reverse order
Comparator<Person> ageComparator = Comparator.comparingInt(Person::getAge).reversed();
Collections.sort(people, ageComparator);
System.out.println("Sorted by age (reverse order): " + people);
}
}
//or 아래처럼 함수로 빼고 new AgeComparator() 삽입
// Comparator for sorting by age
public static class AgeComparator implements Comparator<Person> {
@Override
public int compare(Person p1, Person p2) {
return Integer.compare(p1.getAge(), p2.getAge());
}
}
어떤 것을 사용해야 하나
The Comparable interface is a good choice to use for defining the default ordering, or in other words, if it’s the main way of comparing objects.
Synchronization: Use the synchronized keyword to ensure that only one thread can access a critical section of code at a time. This helps prevent race conditions where multiple threads might try to modify shared data simultaneously.
Locks: Java provides various lock implementations like ReentrantLock, ReadWriteLock, and StampedLock which offer more flexibility and functionality compared to intrinsic locks (synchronized). They llow finer control over locking mechanisms and can help avoid deadlock situations.
Thread-safe data structures: Instead of using standard collections like ArrayList or HashMap, consider using their thread-safe counterparts from the java.util.concurrent package, such as ConcurrentHashMap, CopyOnWriteArrayList, and ConcurrentLinkedQueue. These data structures are designed to be safely accessed by multiple threads without external synchronization.
Atomic variables: Java's java.util.concurrent.atomic package provides atomic variables like AtomicInteger, AtomicLong, etc., which ensure that read-modify-write operations are performed atomically without the need for explicit locking.
Immutable objects: Immutable objects are inherently thread-safe because their state cannot be modified once created. If an object's state needs to be shared among multiple threads, consider making it immutable.
멀티 스레드 환경에서 안전하게 코딩하는 기본적인 다섯가지 방법에 대해 살펴본다. 자바 기준
1. synchronized 키워드 사용(직접적인 락은 아니지만 락과 같은 것; monitor lock)
함수 자체 그리고 함수 안에서도 블락으로 지정하여 사용 가능
allowing only one thread to execute at any given time.
The lock behind the synchronized methods and blocks is a reentrant. This means the current thread can acquire the same synchronized lock over and over again while holding it(https://www.baeldung.com/java-synchronized)
2. 명시적으로 lock interface를 구현한 구현체를 사용
lock()/tryLock() 함수로 락을 걸고 unlock()함수로 반드시 락을 해제해야한다.(아니면 데드락..)
Condition 클래스를 통해 락에 대한 상세한 조절이 가능
ReentrantLock implements Lock
synchronized와 같은 방법으로 동시성과 메모리를 핸들링하지만 더 섬세한 사용이 가능하다.
ReentrantReadWriteLock implements ReadWriteLock
Read Lock– 쓰기락이 없거나 쓰기락을 요청하는 스레드가 없다면, 멀티 스레드가 락 소유 가능
Write Lock– 쓰기/읽기 락 모두가 없는 경우 반드시 하나의 스레드만 락을 소유한다.
ConcurrentHashMap, ConcurentLinkedQueue, ConcurrentLinkedDeque 등
ConcurrentHashMap: This class is a thread-safe implementation of the Map interface. It allows multiple threads to read and modify the map concurrently without blocking each other. It achieves this by dividing the map into segments, each of which is independently locked.
CopyOnWriteArrayList: This class is a thread-safe variant of ArrayList. It creates a new copy of the underlying array every time it is modified, ensuring that iterators won't throw ConcurrentModificationException. This makes it suitable for scenarios where reads are far more frequent than writes.
ConcurrentLinkedQueue: This class is a thread-safe implementation of the Queue interface. It is designed for use in concurrent environments where multiple threads may concurrently add or remove elements from the queue. It uses non-blocking algorithms to ensure thread safety.
BlockingQueue: This is an interface that represents a thread-safe queue with blocking operations. Implementations like LinkedBlockingQueue and ArrayBlockingQueue provide blocking methods like put() and take() which wait until the queue is non-empty or non-full before proceeding.
ConcurrentSkipListMap and ConcurrentSkipListSet: These classes provide thread-safe implementations of sorted maps and sets, respectively. They are based on skip-list data structures and support concurrent access and updates.
4. thread safe 한 변수 사용
java.util.concurrent.atomic 패키지 참고(from java 5)
AtomicInteger, AtomocLong, AtomicBoolean 등
스레드 끼리 동기화 필요한 변수에 사용(상태 공유 등); 관련 성능 향상 시
락으로 인한 오버헤드나 컨테스트 스위칭 비용 감소
non blocking 상황에서(스레드 별 독립적인 작업 시)
5. immutable object 불변 객체 사용
String, Integer, Long, Double 등 과 같은 wrapper 클래스 사용
private final로 선언
생성자 이용하여 initialize
setter 함수나 수정가능 함수 제공하지 않기
6. volatile
변수를 Main Memory에 저장하겠다고 명시하는 것
각 스레드는 메인 메모리로 부터 값을 복사해 CPU 캐시에 저장하여 작업하는데 volatile은 CPU 캐시 사용 막고 메모리에 접근해서 실제 값을 읽어오도록 설정하여 데이터 불일치를 막음
자원의 가시성: 메인 메모리에 저장된 실제 자원의 값을 볼 수 있는 것
멀티쓰레드 환경에서 하나의 쓰레드만 read&write하고 나머지 쓰레드가 read하는 상황에 사용
Read : CPU cache에 저장된 값 X , 메인 메모리에서 읽음
Write : 메인 메모리에 작성
CPU Cache보다 메인 메모리가 비용이 더 큼(성능 주의)
가장 최신 값 보장
what to choose
synchronized
여러 쓰레드가 write하는 상황에 적합
가시성 문제해결 : synchronized블락 진입 전/후에 메인 메모리와 CPU 캐시 메모리의 값을 동기화 하여 문제 없도록 처리
volatile
하나의 쓰레드만 read&write하고 나머지 쓰레드가 read하는 상황에 적합
가시성 문제해결 : CPU 캐시 사용 막음 → 메모리에 접근해서 실제 값을 읽어오게 함
Read : CPU cache에 저장된 값 X , 메인 메모리에서 읽음
Write : 메인 메모리에 작성
AtomicIntger
여러 쓰레드가 read&write를 병행
가시성 문제해결: CAS알고리즘
현재 쓰레드에 저장된 값과 메인 메모리에 저장된 값을 비교하여
일치할 경우 새로운 값으로 교체
불일치할 경우 실패하고 재시도
로컬 변수도 스레드 경합?
함수 안에서도 싱글스레드가 자동으로 보장되지는 않습니다. 하지만 현재 메서드의 경우:
지역 변수: StringBuilder sb는 메서드 내부의 지역 변수로, 각 호출마다 새로 생성됩니다
스택 메모리: 지역 변수는 각 스레드의 스택에 저장되어 다른 스레드와 공유되지 않습니다
메서드 범위: 이 변수는 메서드 실행이 끝나면 소멸됩니다
따라서 여러 스레드가 동시에 serialize 메서드를 호출해도, 각각 독립적인 StringBuilder 인스턴스를 사용하므로 StringBuilder를 사용하는 것이 안전하고 성능상 유리합니다.