병렬 프로그래밍(Parallel Programming)은 여러 작업을 동시에 수행하는 프로그래밍 기법입니다. 이는 여러 개의 프로세스를 생성하는 멀티 프로세스(병렬성)와 여러 개의 스레드를 생성하는 멀티 스레드(동시성)로 나뉩니다.
멀티 프로세스는 여러 개의 프로세스를 생성하여 실행하는 방식으로 복잡한 수학 연산, 데이터 분석, AI 모델 학습과 같이 CPU 사용률이 높은 CPU-Bound 작업에 적합합니다. 각 프로세스는 독립적인 메모리 공간을 가지므로 메모리 오버헤드가 크지만 실제 병렬 실행이 가능합니다.
멀티 스레드는 하나의 프로세스 내부에서 여러 개의 스레드를 생성하여 실행하는 방식으로, 네트워크 요청, 파일 입출력, 데이터베이스 쿼리 등 I/O-Bound 작업에 적합합니다. 여러 스레드가 같은 메모리를 공유하여 메모리 사용량이 적지만, 실제 병렬 실행이 아니라 컨텍스트 스위칭을 통해 빠르게 번갈아가며 실행되는 방식입니다.
API 서버는 멀티 스레드 방식으로 동작하는 것이 일반적이지만 하나의 프로세스가 감당할 수 있는 스레드의 수에는 한계가 있습니다. 또한 단일 프로세스에서 장애가 발생하면 전체 서비스가 중단될 위험이 있습니다.
이러한 문제를 해결하기 위해 멀티 스레드와 멀티 프로세스 방식을 함께 사용합니다. 멀티 스레드는 한 프로세스 내에서 여러 요청을 동시에 처리할 수 있도록 하고 멀티 프로세스는 여러 개의 서버를 운영하면서 부하를 분산시키고 장애 발생 시에도 서비스가 지속될 수 있도록 합니다.
동시성 이슈(Concurrency Issue)는 여러 프로세스 또는 스레드가 공유 자원에 동시에 접근할 때 발생하는 문제를 의미합니다. 프로세스 관점에서는 데이터베이스, 파일 시스템, 네트워크 리소스 등이 공유 자원이 될 수 있고 스레드 관점에서는 JVM Heap 영역에 있는 객체, 배열, String Pool 등이 공유 자원이 될 수 있습니다.
동시성 이슈의 대표적인 예로 Race Condition, Deadlock, Starvation 이 있습니다.
Race Condition은 여러 스레드 또는 프로세스가 예상치 못한 실행 순서로 인해 잘못된 결과를 초래하는 현상을 의미합니다. 예를 들어 쇼핑몰에서 여러 사용자가 동시에 같은 상품을 구매하려고 할 때, 재고가 부족함에도 불구하고 구매가 완료되는 문제가 발생할 수 있습니다.
이는 공유 자원에 하나의 스레드만 접근할 수 있도록 락을 걸어 해결할 수 있습니다.
Starvation은 특정 스레드나 프로세스가 계속해서 자원을 할당받지 못하고 무기한 대기하는 현상을 의미합니다.
예를 들어 멀티 스레드 환경에서 실행 우선순위가 낮은 스레드가 높은 우선순위의 스레드에 밀려 영구적으로 실행되지 않는 문제가 발생할 수 있습니다.
이는 스레드 우선순위를 조정함으로써 방지할 수 있습니다.
Deadlock은 두 개 이상의 스레드가 서로가 점유한 자원의 락을 해제하지 않은 채 상대방의 락을 기다리면서 무한 대기 (시스템 중단) 하는 현상을 의미합니다. 이는 다음 네 가지의 조건이 모두 충족될 때 발생합니다.
상호 배제 (Mutual Exclusion): 한 번에 하나의 스레드만 자원을 사용할 수 있음
점유 대기 (Hold & Wait): 스레드가 이미 가진 자원을 놓지 않고 다른 자원을 기다림
비선점 (No Preemption): 강제로 자원을 빼앗을 수 없음
순환 대기 (Circular Wait): 스레드들이 원형으로 자원을 기다림
예를 들어 여러 대의 기차가 교차로에서 서로 길을 양보하지 않고 멈춰 서 있어 아무도 움직이지 못하는 상황이 발생할 수 있습니다. 이는 락의 실행 순서를 일정하게 유지하고 타임아웃을 설정하여 예방할 수 있습니다.
자바에서 Race Condition을 해결하는 대표적인 방법은 메소드에 하나의 스레드만 접근하도록 제한하는 방법과 공유 자원 자체를 하나의 스레드만 접근 가능하도록 하는 방법이 있습니다.
메소드에 하나의 스레드만 접근하도록 하기 위해서는 synchronized 기법을 사용하여 메소드 자체에 Lock을 걸거나, Lock 객체(ReentrantLock)를 직접 생성하여 관리하는 방법이 있습니다.
공유 자원 자체를 하나의 스레드만 접근 가능하도록 하는 방법으로는 CAS(compare-and-swap) 기법이 있습니다. CAS는 현재 값과 예상 값을 비교하여 동일하면 변경하고 다르면 실패하는 방식으로 동작하며 락을 사용하지 않기 때문에 비교적 적은 비용으로 동시성을 보장할 수 있습니다. 이를 활용하는 대표적인 클래스에는 Atomic 객체와 Concurrent 객체가 있습니다.
@Getter
public class InventoryCounter {
private int items = 0;
public synchronized void increment(){
items ++;
}
public synchronized void decrement(){
items --;
}
}
@Getter
public class InventoryCounter2 {
private Object lock1 = new Object();
private Object lock2 = new Object();
private int items = 0;
public void increment(){
// 매소드 내의 특정 블록을 임계지역으로 설정
synchronized (lock1){
items ++ ;
}
}
public void decrement(){
synchronized (lock1){
items -- ;
}
}
}
@Getter
public class InventoryCounter3 {
private Lock lock = new ReentrantLock();
private int items = 0;
public void increment() {
lock.lock();
try {
items++;
} finally {
lock.unlock();
}
}
}
public class Main {
// 동시성을 보장하는 Integer
static AtomicInteger atomicInteger;
// 동시성을 보장하는 HashMap
static ConcurrentHashMap<String, AtomicInteger> map =
new ConcurrentHashMap<>();
// 동시성을 보장하는 Queue (List)
static ConcurrentLinkedQueue<MobileErrorLog> errLogs =
new ConcurrentLinkedQueue<>()
}
자바에서 Starvation 을 해결하기 위해서는 스레드 우선순위를 순차적으로 부여하는 방법과 sleep() 사용하여 실행 간격을 조정하는 방법 등이 있습니다.
ReentrantLock 인스턴스를 생성할 때 true 를 입력하면 공정 락(Fair Lock)이 활성화되어 FIFO(First-In-First-Out) 방식으로 작동합니다.
public class FairLockExample {
// FIFO
private Lock lock = new ReentrantLock(true);
public void accessResource() {
lock.lock();
try {
System.out.println("비즈니스 로직 실행");
Thread.sleep(1000); // worker thread sleep
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
자바에서 Deadlock 을 해결하기 위해서는 락의 순서를 일정하게 유지하고
스레드가 처리하는 비즈니스 로직의 타임아웃을 설정하는 방법이 있습니다.
public class Intersection_2 {
private Lock lockObject1 = new ReentrantLock();
private Lock lockObject2 = new ReentrantLock();
public void takeRoadA() {
lockObject1.lock();
try{
String thraedName = Thread.currentThread().getName();
System.out.println("Road A is locked by thread : " + threadName);
lockObject2.lock();
try{
System.out.println(" Train is passing through road A");
} finally {
lockObject2.unlock();
}
} finally {
lockObject1.unlock();
}
}
public void takeRoadB() {
lockObject1.lock();
try{
String thraedName = Thread.currentThread().getName();
System.out.println("Road B is locked by thread : " + threadName);
lockObject2.lock();
try{
System.out.println(" Train is passing through road B");
} finally {
lockObject2.unlock();
}
} finally {
lockObject1.unlock();
}
}
}
수정이 필요한 데이터베이스에 많은 요청이 동시에 몰릴 경우 Race Condition이 발생할 수 있습니다.
다음은 Race Condition 발생으로 인해 기대되는 value 의 값이 8이 아닌 9의 값이 되는 예제 입니다.
- A 사용자가 데이터를 조회 (value = 10)
- B 사용자가 데이터를 조회 (value = 10)
- A 사용자가 데이터를 수정 (value -= 1)
- B 사용자가 데이터를 수정 (value -= 1)
- A 사용자가 데이터를 저장 (value = 9)
- B 사용자가 데이터를 저장 (value = 9)
Race Condition 을 해결하기 위해서는 데이터베이스에 락을 적용하거나 정보를 수정하는 비즈니스 로직에 분산락과 같은 동시성 제어 방식을 도입해야 합니다.
데이터베이스는 다음의 메카니즘을 이용하여 락을 적용합니다. 락을 적용하기 위해서는 최소 READ_COMMIT 이상의 격리수준을 적용해야 합니다.
인덱스를 대상으로 설정되는 락입니다.
트랜잭션이 한 행에 대해 SELECT FOR UPDATE나 UPDATE, DELETE 같은 명령을 실행할 때 해당 행을 잠가 다른 트랜잭션이 동시에 해당 행에 쓰기 작업을 수행하지 못하게 막습니다.
-- first_name 에만 index 가 걸려있다면 first_name 의 조회 결과가 락이 걸립니다.
-- last_name 에만 index 가 걸려있다면 last_name 의 조회 결과가 락이 걸립니다.
-- first_name, last_name 가 복합 인덱스로 설정될 떄 정확한 조회값만 락이 걸립니다.
UPDATE member
SET register_date = NOW()
WHERE last_name LIKE 'J%'
AND first_name = 'MangKyu';
갭 락은 레코드 사이의 간격(gap)에 대해 설정되는 락입니다.
REPEATABLE READ 이상의 격리수준에서 사용 가능하며 설정된 범위에 새로운 레코드를 삽입하지 못하게 막아 팬텀 리드를 차단합니다.
-- team_id 1 ~ 5 간걱에 Gap Lock 작동
SELECT * FROM employees WHERE team_id BETWEEN 1 AND 5 FOR UPDATE;
레코드 락과 갭 락을 결합한 형태 입니다. Gap Lock 의 조건에서 인덱스 기반 조회를 하는 경우 실행됩니다.
Gap Lock 의 경우 해당 범위의 인덱스에 락을 걸지 않으므로 범위 내의 데이터 수정이 가능한데 Next-Key Lock 은 인덱스에 락을 걸기 때문에 범위 내의 데이터 수정이 불가 합니다.
-- team_id 1 ~ 5 간걱에 Gap Lock 작동
-- 해당 구간의 데이터 변경 가능
SELECT * FROM employees WHERE team_id BETWEEN 1 AND 5 FOR UPDATE;
비관적 락(Pessimistic lock) 은 데이터베이스 락 메카니즘을 적용하는 방법 입니다.
충돌이 자주 발생하는 로직에서 안정적으로 동시성 이슈를 제어할 수 있지만 락이 오래 유지되는 경우 데드락이 발생할 수 있는 위험성이 있습니다.
비관적락의 종류
공유락 (SHARED LOCK) : 읽기는 허용하지만 쓰기(수정)는 차단 (SELECT ... LOCK IN SHARE MODE)
배타락 (EXCLUSIVE LOCK) : 읽기/쓰기 모두 차단 (SELECT ... FOR UPDATE)
일반적으로 비관적락은 배타락을 통해 강하게 컨트롤하고자 할 때 사용하며 공유락을 사용해야하는 경우에는 다른 락을 선택합니다.
JPA 는 @Lock 을 통해서 비관적락을 쉽게 사용할 수 있도록 추상화합니다.
// id 가 인덱스라면 Record Lock 이 적용됩니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
User findById(@Param("id") Long id);
// 트랜젝션 격리수준이 REPEATABLE READ 이상인 경우에만 Lock 이 적용 됩니다.
// RegisterDate 가 인덱스인 경우 : Next-Key Lock
// RegisterDate 가 인덱스가 아닌 경우 : Gap Lock
@Lock(LockModeType.PESSIMISTIC_READ) // PESSIMISTIC_READ : 공유락
List<User> findByRegisterDateBetween(
LocalDateTime start, LocalDateTime end);
낙관적 락(Optimistic lock)은 데이터베이스 락 메카니즘을 사용하지 않고 어플리케이션 레벨에서 엔티티에 동시성 이슈 처리를 위한 별도의 컬럼을 추가하여 충돌을 차단하는 방법 입니다.
추가되는 컬럼은 엔티티의 버전을 관리하며 커밋 시점에 버전을 확인한 후 동일하다면 쿼리 실행, 다르다면 OptimisticLockException 예외를 던집니다. 이는 충돌이 거의 없을 것으로 예상되는 로직에 적용 합니다.
JPA 는 낙관적락을 위한 컬럼을 @Version 을 통해 추상화합니다.
버전 관리를 위한 컬럼에 @Version 을 붙이면 이를 자동으로 관리해줍니다.
CREATE TABLE user (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
version BIGINT NOT NULL
);
@Getter
@Entity
public class User {
@Id
private Long id;
private String name;
@Version
private Long version;
}
분산락은 다중 서버 환경에서 외부 저장소에 락을 두고 공유 자원을 처리하는 비즈니스 로직에 하나의 요청만 접근하도록 하는 방법 입니다. 주로 Redis 기반의 Redisson 라이브러리가 사용됩니다.
Lettuce vs Redisson
레디스는 기본적으로 Lettuce 라이브러리를 사용하지만 분산락을 사용하는 경우에는 Redisson 라이브러리를 사용하는게 좋습니다. Lettuce 는 스핀락(락을 획득하지 못하면 획득할 때 까지 계속 시도) 형태로 락을 구성하기 때문에 Redis 부하가 심하며 요청 만료시간 컨트롤을 지원하지 않기 때문에 데드락 발생 가능성이 있습니다.
Redisson 은 pub/sub 구조로 락을 획득하며 요청 만료시간을 지원하기 때문에 데드락 발생 가능성을 줄이고 안정적으로 분산락을 관리할 수 있습니다.
@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(
REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
boolean isUniqueKey() default false;
long waitTime() default 5L;
long leaseTime() default 3L;
}
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint)
throws Throwable {
return joinPoint.proceed();
}
}
@Component
@RequiredArgsConstructor
public class DatabaseLock {
private final LockStorage lockStorage;
private final AopForTransaction aopForTransaction;
@Transactional
public Object lockWithDatabaseFallback(ProceedingJoinPoint joinPoint, String key)
throws Throwable {
Optional<Lock> optional = lockStorage.findByKeyForUpdate(key);
try {
if (optional.isEmpty()) {
lockStorage.save(Lock.builder()
.key(key)
.regDateTime(LocalDateTime.now())
.build());
}
optional = lockStorage.findByKeyForUpdate(key);
return aopForTransaction.proceed(joinPoint);
} finally {
lockStorage.deleteByKey(key);
}
}
}
@Slf4j
@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@RequiredArgsConstructor
public class DistributedLockAspect {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final DatabaseLock databaseLock;
private final AopForTransaction aopForTransaction;
@Around("@annotation(distributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint,
DistributedLock distributedLock) throws Throwable {
String key = distributedLock.isUniqueKey() ?
makeUniqueKey(distributedLock, joinPoint) :
REDISSON_LOCK_PREFIX + distributedLock.key();
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(
distributedLock.waitTime(), // 락 획득까지 대기시간
distributedLock.leaseTime(), // TTL
TimeUnit.SECONDS);
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint);
} catch (RedisConnectionException | RedisTimeoutException e) {
return databaseLock.lockWithDatabaseFallback(joinPoint, key);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (Exception e) {
log.error("rLock unlock error - " + e.getMessage());
}
}
}
private String makeUniqueKey(DistributedLock distributedLock,
JoinPoint joinPoint) {
StringBuilder sb = new StringBuilder();
sb.append(REDISSON_LOCK_PREFIX)
.append(distributedLock.key())
.append(((MethodSignature) joinPoint.getSignature())
.getMethod().getName());
for (Object arg : joinPoint.getArgs()) {
sb.append(arg);
}
return sb.toString();
}
}
A Service가 B Service를 호출하고 B Service에 분산락이 적용되어 있는 경우 B Service가 A Service의 트랜잭션에 포함된다면 데이터 정합성 문제가 발생할 수 있습니다.
이는 락이 먼저 해제된 이후에 트랜잭션 커밋 또는 롤백이 발생할 수 있기 때문입니다.
따라서 분산락을 사용하는 서비스에서는 항상 새로운 트랜잭션을 시작하여 외부 트랜잭션과 격리하는 것이 안전합니다.
또한 Redis는 인메모리 기반이므로 데이터 삭제 가능성이나 서버 장애를 고려해야 합니다.
이러한 상황에 대비해 Redis 락 획득에 실패하거나 오류가 발생할 경우를 대비하여 데이터베이스 기반의 락을 대안으로 사용할 수 있도록 설계하는 것이 바람직합니다.
백엔드 관점에서 발생하는 기아 상태는 메시지 큐와 같이 우선순위 기반의 작업 처리 시스템에서 낮은 우선순위를 가진 프로세스가 지속적으로 리소스를 할당받지 못해 작업이 처리되지 않는 상황에서 발생할 수 있습니다.
다음과 같이 세 개의 토픽이 하나의 컨슈머에서 처리되고 하나의 토픽에 트래픽이 몰리거나 처리 속도가 느린 경우 기아상태가 발생할 수 있습니다.
@KafkaListener(
topics = {"topic1", "topic2", "topic3"},
concurrency = "3",
groupId = "my-consumer-group"
)
public void listenAll(String message, String topic) {
// 토픽별 분기처리
}
이 경우 컨슈머를 분리해서 비즈니스로직을 처리하는 것이 좋습니다.
데이터베이스 수정 API 에 네트워크 이슈로 동시다발적인 요청이 들어오는 경우 데이터베이스 데드락이 발생할 수 있으며 이는 매우 흔한 케이스 입니다.
- 네트워크 오류로 테이블 수정 API 를 동시에 두 개 발송됩니다.
- A 트랜젝션이 수정을 위해 락을 획득합니다.
- B 트랜젝션이 동시에 수정을 위해 락을 획득합니다.
- A, B 트랜젝션이 각각 데이터 수정 쿼리를 요청합니다.
- 해당 Row 는 락이 걸려있으므로 무한히 기다립니다.
이를 해결하기 위해 DB 락 타임아웃을 설정하거나 레이스 컨디션(Race Condition) 문제 해결에 사용되는 락킹(Locking) 시스템을 적용할 수 있습니다.
DB 락 타임아웃을 설정하면 일정 시간 안에 락을 획득하지 못할 경우 경우 해당 요청은 실패로 처리되며 다른 요청이 리소스를 확보해 정상적으로 처리될 수 있게 됩니다.
SET innodb_lock_wait_timeout = 3; -- default 30s