멀티스레드 환경에서 안전하게 살아남기: Thread-safe한 코드 설계 전략

현대 소프트웨어 아키텍처에서 '성능'은 곧 '병렬 처리 능력'과 직결됩니다. 서버의 CPU 코어 수는 늘어났고, 우리는 더 짧은 시간에 더 많은 요청을 처리해야 하죠. 하지만 여러 스레드가 동시에 같은 데이터에 접근하는 멀티스레드 환경은 개발자에게 축복이자 재앙이기도 합니다.
제대로 제어되지 않은 공유 자원은 데이터 오염(Race Condition)을 일으키고, 이는 서비스의 신뢰도를 순식간에 무너뜨립니다. 오늘은 단순히 "Lock을 거세요"라는 뻔한 답변을 넘어, 어떻게 하면 우아하고 견고하게 Thread-safe한 코드를 작성할 수 있는지 그 깊은 속내를 파헤쳐 보겠습니다.
1. Thread-safe란 무엇인가: 맛집 대기열 비유
Thread-safe하다는 것은 여러 스레드가 해당 코드나 객체에 동시에 접근해도, 실행 결과가 항상 의도한 대로 정확하게 나오는 상태를 의미합니다.
이를 이해하기 쉽게 맛집의 **'공용 대기 명단'**에 비유해 볼까요?
- Thread-unsafe: 여러 손님이 동시에 펜을 잡아 명단에 이름을 쓰려고 합니다. 서로 밀치다가 글씨가 겹치거나, 같은 순번에 두 팀의 이름이 적히는 대혼란이 발생합니다.
- Thread-safe: 직원이 한 명씩만 펜을 잡도록 통제하거나, 아예 각자 모바일 앱으로 본인만의 대기 번호를 부여받아 나중에 합치는 방식입니다. 충돌이 없으니 명단은 항상 정확합니다.
현업에서는 이 '충돌'을 방지하기 위해 자원을 격리하거나, 접근 순서를 제어하는 전략을 사용합니다.
2. 핵심 전략 1: 불변성(Immutability)의 활용
가장 완벽한 방어는 공격할 빌미를 주지 않는 것입니다. 객체의 상태가 생성 후 절대 변하지 않는다면, 여러 스레드가 동시에 읽어도 아무런 문제가 없습니다.
실전 예제: 주문 시스템의 상태 관리
이커머스 시스템에서 주문 정보가 수정될 때마다 기존 객체를 변경하는 것이 아니라, 변경된 내용을 담은 새 객체를 반환하는 방식입니다.
// Thread-safe한 불변 객체 설계
public final class OrderInfo {
private final long orderId;
private final String status;
public OrderInfo(long orderId, String status) {
this.orderId = orderId;
this.status = status;
}
// 상태를 변경하는 대신, 새로운 객체를 생성하여 반환 (Copy-on-Write 전략)
public OrderInfo changeStatus(String newStatus) {
return new OrderInfo(this.orderId, newStatus);
}
public String getStatus() { return status; }
}
- 핵심 로직: final 키워드로 재할당을 막고, Setter를 아예 만들지 않습니다. 상태 변화가 필요하면 기존 데이터를 복사해 새 인스턴스를 만듭니다. 이로써 '수정 중인 데이터를 다른 스레드가 읽는' 시나리오 자체가 차단됩니다.
3. 핵심 전략 2: 원자적 연산(Atomic Operations)
단순한 카운터나 플래그를 관리할 때 무거운 Lock을 거는 것은 성능 낭비입니다. 이때는 CPU 레벨에서 지원하는 CAS(Compare-And-Swap) 연산을 활용한 원자적 클래스를 사용합니다.
실전 예제: 실시간 이벤트 조회수 카운터
import java.util.concurrent.atomic.AtomicLong;
public class EventStatistics {
// 내부적으로 CAS 알고리즘을 사용하여 Lock 없이도 안전하게 증가
private final AtomicLong viewCount = new AtomicLong(0);
public void incrementView() {
// 별도의 synchronized 없이도 스레드 안전 보장
viewCount.incrementAndGet();
}
public long getViewCount() {
return viewCount.get();
}
}
- 작동 원리: incrementAndGet()은 "현재 값이 내가 알고 있는 값과 같다면 1을 더해라"라는 명령을 원자적으로 수행합니다. 만약 그사이 다른 스레드가 값을 바꿨다면 실패하고 다시 시도합니다. 비어있는 대기열을 확인하고 순식간에 이름을 적고 나오는 민첩함과 같습니다.
4. 핵심 전략 3: 동기화 블록(Synchronized)과 명시적 Lock
복잡한 비즈니스 로직(예: 재고 확인 -> 결제 -> 재고 차감)이 하나의 묶음으로 실행되어야 할 때는 동기화가 필수입니다.
실전 예제: 창고 재고 시스템
import java.util.concurrent.locks.ReentrantLock;
public class InventoryManager {
private int stock = 100;
private final ReentrantLock lock = new ReentrantLock();
public void reduceStock(int amount) {
// 1. 락 획득: 다른 스레드는 여기서 대기
lock.lock();
try {
if (stock >= amount) {
// 비즈니스 로직 사이의 일관성 유지
Thread.sleep(10); // 네트워크 지연 가정
stock -= amount;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 2. 반드시 finally에서 언락: 예외 발생 시에도 데드락 방지
lock.unlock();
}
}
}
💡 트러블슈팅 팁: 데드락(Deadlock) 피하기
두 개 이상의 스레드가 서로가 가진 락이 풀리기를 영원히 기다리는 상황을 주의해야 합니다.
- 락 순서 고정: 항상 A 락을 잡은 뒤 B 락을 잡도록 순서를 일치시킵니다.
- 타임아웃 설정: lock.tryLock(5, TimeUnit.SECONDS) 처럼 일정 시간만 기다리게 설계하세요.
5. 장단점 및 고려사항 (Trade-offs)
멀티스레드 안전성을 확보하는 데는 반드시 비용이 따릅니다.
| 전략 | 장점 | 단점 |
| 불변 객체 | 설계가 단순하고 디버깅이 쉬움 | 객체 생성 비용이 커질 수 있음 (GC 부하) |
| Atomic 변수 | 성능이 매우 빠르고 데드락 위험 없음 | 복잡한 로직에는 적용 불가 |
| Synchronized/Lock | 가장 확실한 제어 가능 | 컨텍스트 스위칭 비용으로 인한 성능 저하, 데드락 위험 |
가장 좋은 설계는 공유 자원을 최소화하는 것입니다. 스레드마다 독립적인 데이터를 갖게 하는 ThreadLocal을 사용하거나, 메시지 큐를 통해 상태를 전달하는 구조를 고민해 보세요.
결론: 안전한 동시성을 향한 여정
Thread-safe한 코드는 단순히 문법적인 테크닉이 아닙니다. 시스템의 흐름을 얼마나 깊이 이해하고 있느냐의 척도입니다. 처음부터 모든 곳에 Lock을 바르기보다는, 데이터의 가변성을 먼저 제거하고, 그다음으로 범위를 좁힌 원자적 연산을 고려하며, 마지막 수단으로 동기화 블록을 사용하는 단계적인 접근이 필요합니다.