0. 서버 동시 실행
트래픽이 적은 서비스라고 해도 초당 100개의 트래픽이 발생할 수 있다. 이때 모든 요청을 순서대로 처리할 경우 처리량과 응답시간이 느려지는 문제가 발생할 수 있어 동시에 여러 요청을 처리하는 방식을 선택해야 한다
서버가 동시에 여러 클라이언트의 요청을 처리하는 방식은 크게 다음 2가지가 있다.
- 클라이언트 요청마다 스레드를 할당해서 처리
- 클라이언트 요청수에 맞게 여러 스레드가 동시에 코드를 실행하는 것을 의미한다. 클라이언트 요청이 동시에 10개 들어오면 10개가 실행되고, 50개가 들어오면 50개가 실행되는 방식이다.
- 비동기 IO(또는 논블로킹 IO)를 사용해서 처리
- 이 경우에도 보통 단일 스레드만 사용하는 경우는 드문데, IO 요청을 처리하기 위해 여러 스레드를 사용하는 경우가 많다.
위 2가지 방식 중 어떤 방식을 사용하든 서버는 동시 실행이 기본이다. 서로 다른 두 스레드가 동시에 같은 데이터를 조회하고 수정하는 일이 발생할 수 있는데, 이때 동시성 문제가 발생할 수 있다.
public class Increaser {
private int count = 0;
public void inc() {
count = count + 1;
}
public int getCount() {
return count;
}
}
Increaser increaser = new Increaser();
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increaser.inc();
}
});
threads[i] = t;
t.start();
}
for (Thread t : threads) {
t.join();
}
위 코드를 실행 후 10000이 출력되는 걸 예상할 수 있다. 하지만 실제로 여러 번 실행해 보면 9982, 9973 같은 값이 출력된다. 그 원인은 여러 스레드가 동시에 count = count + 1 코드를 실행하기 때문이다. 이 코드는 실제로 다음 두 단계로 실행된다.
1) count 값을 읽는다.
2) 읽은 값에 1을 더한 결과를 count에 저장한다.
위 과정에서 count = 5일 때 2개의 스레드가 위 코드 처럼 실행될 경우 모두 count 값은 7이 아닌 6(5+1)이 된다.
이처럼 동시성 문제를 고려하지 않고 코드를 작성할 경우 정상적인 데이터가 계산되지 않는 문제가 발생할 수 있다. 또 이 문제는 바로 드러나는 것이 아닌 예상치 못한 순간에 나타나기도 해 문제 파악이 어려울 수도 있다. 또 찾아낸다 해도 해당 문제를 재현하는 것도 쉽지 않을 때가 있다. 따라서 처음부터 동시성 문제를 염두에 두고 개발하는 것이 중요하다.
경쟁상태
여러 스레드가 동시에 공유 자원에 접근할 때, 접근 순서에 따라 결과가 달라지는 상황을 의미한다. 경쟁 상태가 발생하면 예상하지 못한 결과가 나오거나 오류가 발생할 수 있다.
잘못된 데이터 공유로 인한 문제 예시
public class payService {
private Long payId;
public PayResp pay(PayRequest req) {
....
this.payId = genPayId(); // 1단계
saveTemp(this.payId, req); // 2단계
PayResp resp = sendPayData(this.payId, ...) // 3단계
applyResponse(resp); // 4단계
return resp;
}
private void applyResponse(PayResp resp) {
PayData payData = createPayDataFromResp(resp); // 4-1 단계
updatePayData(this.payId, payData); // 4-2 단계
...
}
}
위 코드에서 PayService 인스턴스가 하나뿐인 싱글톤 객체(예 : 스프링 컨테이너의 싱글톤 빈)이라고 가정하자. 이때 다중 스레드 환경에서 PayService 객체가 동시에 사용되면, 1단계에서 생성한 payId 값과 4-2 단계에서 사용하는 payId 값이 다를 수 있다. 아래 그림처럼 말이다.

위 코드와 그림으로 기반한 프로세스 및 문제 발생은 다음과 같다.
- 스레드 1 -> 고객 A 정보처리 -> payId = 2 -> payId = 2의 데이터를 덮어썼기 때믄에 고객A(payId = 1)의 정보가 사라진다.
- 스레드 2 -> 고객 B 정보처리 -> payId = 2-> 고객 B 정보는 처음엔 정상 반영 되었지만, 이후 스레드 1의 4-2 프로세스가 반영되어 고객 B 정보에 고객A 정보가 덮어 씌워진다.
=> 고객 A의 결제 결과는 사라지고, 고객 B의 결제에는 잘못된 값(고객A의 결제)이 반영되는 매우 심각한 문제가 발생한다.
서버 말고 DB에도 동시성 이슈가 발생할 수 있어 서버와 DB 모두 동시성 이슈를 고려한 개발이 필요하다.
1. 프로세스 수준에서의 동시 접근 제어
잠금(lock)을 이용한 접근 제어
프로세스 수준에서 데이터를 동시에 수정하는 것을 막기 위한 일반적인 방법은 잠금을 사용하는 것이다. 잠금을 사용하면 공유 자원에 접근하는 스레드를 한 번에 하나로 제한할 수 있다.
- 잠금을 사용하는 일반적인 흐름은 다음과 같다.
- 잠금을 획득함
- 잠금은 한 번에 한 스레드만 획득할 수 있다.
- 다른 스레드가 잠금 획득 시도시, 잠금이 해제될 때까지 대기한다.
- 공유 자원에 접근(임계영역)
- 임계영역 : 동시에 둘 이상의 스레드나 프로세스가 접근하면 안 되는 경우 공유 자원(메모리, 파일)에 접근하는 코드 영역을 의미한다.
- 잠금을 해제함
ReentrantLock을 사용해 동시에 HashMap 수정을 막는 코드
아래 코드는 ReentrantLock을 사용해서 sessions 필드에 대한 동시 접근을 제한한다. sessions는 HashMap인데, HashMap은 다중 스레드 환경에서 안전하지 않다. 동시에 여러 스레드가 HashMap의 put() 메서드를 호출하면 데이터가 유실되거나 값이 잘못 저장되는 문제가 발생할 수 있다. 이를 방지하기 위해 잠금(ReentrantLock)을 사용해 sessions 필드에 한 번에 한 스레드만 접근할 수 있도록 제한한 코드이다.
public class UserSessions {
private Lock lock = new ReentrantLock();
private Map<String, UserSession> sessions = new HashMap<>();
public void addUserSession(UserSession session) {
lock.lock(); // 잠금 획득할 때까지 대기
try {
sessions.put(session.getSessionId(), session); // 공유 자원 접근
} finally {
lock.unlock(); // 잠금해제
}
}
public UserSession geUserSession(String sessionId) {
lock.lock();
try {
return sessions.get(sessionId);
} finally {
lock.unlock();
}
}
}
Synchronized vs. ReentrantLock
| Synchronized | ReentrantLock |
| 코드 블록이 끝나면 자동으로 잠금을 풀어주기 때문에 unlock() 같은 메서드 호출 안해도 된다. | 잠금 획득 대기 시간을 지정하는 등 synchronized에 없는 기능을 제공한다. 잠금 해제 시 unlock() 호출 한 번에 한 스레드만 공유 자원에 접근 가능하다. 자바 21 버전 : 가상스레드는 ReentrantLock만 지원 자바 24 버전 : 가상스레드는 ReentrantLock + synchronized 지원 |
뮤텍스(Mutex)
Mutual exclusion의 줄임말로, 다른 말로 잠금(lock)이라고도 한다.
세마포어(Semaphore)
세마포어는 동시에 실행할 수 있는 스레드 수를 제한한다. 외부 서비스에 대한 동시 요청을 최대 5개로 제한하고 싶을 때와 같이 자원에 대한 접근을 일정 수준으로 제한하고 싶을 때 세마포어를 사용할 수 있다.
세마포어는 허용 가능한 숫자를 이용해서 생성하는데, 프로그래밍 언어별로 명칭이 다르다. 자바 세마포어 구현체는 permit이라고 표현하며, Go 언어의 세마포어 구현체는 weight라고 표현한다.
세마포어 종류로는 이진(binary) 세마포어와 계수(counting) 세마포어가 있다.
- 이진 세마포어 : 동시접근가능 스레드가 1개인 세마포어
- 계수 세마포어 : 지정한 수만큼 스레드의 동시 접근이 가능한 세마포어
세마포어 사용의 전형적인 순서는 다음과 같다.
- 세마포어에서 퍼밋 획득(허용 가능 숫자 - 1)
- 각 스레드는 세마포어에서 퍼밋을 획득한 뒤에 코드를 실행할 수 있다.
- 남아있는 퍼밋 개수가 0인 상태에서 스레드가 퍼밋 획득 시도시 퍼밋이 반환될 때까지 대기해야 한다.
- 코드 실행
- 세마포어 퍼밋 반환(허용 가능 숫자 + 1)
세마포어 퍼밋 연산
- 퍼밋 구하기 : P 연산 또는 wait 연산
- 퍼밋 반환 : V 연산 또는 signal 연산
예시) 세마포어를 사용한 동시 실행 스레드 제한 코드
import java.util.concurrent.Semaphore;
public class MyClient {
private Semaphore semaphore = new Semaphore(5); // 세마포어 퍼밋 개수
public String getData() {
// 스레드 퍼밋 획득
try {
semaphore.acquire(); // 퍼밋 획득 시도
} catch(InterruptedException e) {
throw new RuntimeException(e);
}
// 스레드 퍼밋으로 작업 후 퍼밋 반환
// 최대 5개의 스레드만 실행 가능
try {
String data = ... // 외부 연동코드
return data;
} finally {
semaphore.release(); // 퍼밋 반환
2. 읽기 쓰기 잠금
잠금을 사용하면(공유자원에 1개의 스레드만 접근가능한 경우) get() 또는 set()을 한번에 한 번 밖에 실행할 수 없기 때문에 읽기와 쓰기를 동시에 할 수 없고, 데이터를 변경하지 않는 읽기도 동시에 할 수가 없다. 쓰기 빈도 대비 읽기 빈도가 높을 경우에 읽기 성능이 떨어지는 문제가 발생할 수 있다. 이때 읽기 쓰기 잠금을 사용할 수 있다.
읽기 쓰기 잠금 특징
- 쓰기 잠금은 한 번에 한 스레드만 구할 수 있다.
- 읽기 잠금은 한 번에 여러 스레드가 구할 수 있다.
- 한 스레드가 쓰기 잠금을 획득했다면 쓰기 잠금이 해제될 때까지 잠금을 구할 수 없다.
- 읽기 잠금을 획득한 모든 스레드가 읽기 잠금을 해제할 때까지 쓰기 잠금을 구할 수 없다.
읽기 쓰기 잠금을 이용한 동시 접근 제어
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class UserSessionRW {
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Lock writeLock = lock.writeLock();
private Lock readLock = lock.writeLock();
private Map<String, UserSession> sessions = new HashMap<>();
public void addUserSession(UserSesion session) {
writeLock.lock();
try {
sessions.put(session.getSessionId(), session);
} finally {
writeLock.unlock();
}
}
ppublic UserSession getUserSession(String sessionId) {
readLock.lock();
try {
return sessions.get(sessionId);
} finally {
readLock.unlock();
}
}
}
addUserSession() : 데이터를 변경하므로 쓰기 잠금을 사용해 동시 접근 제어
getUserSession() : 데이터를 조회하므로 읽기 잠금을 사용해 여러 스레드가 동시에 읽기 수행
3. 원자적 타입
아래처럼 동작할 경우, 동시성 이슈가 발생한다.
public class Increaser {
private int count = 0;
public void inc() P
count = count + 1;
}
}
위 코드의 동시성 문제를 해결한 코드는 다음과 같다.
public class Increaser {
private Lock lock = new ReentrantLock();
private int count = 0;
public void inc() {
lock.lock();
try {
count = count + 1;
} finally {
lock.unlock();
}
}
}
위 코드처럼 잠금을 사용하면 count 필드값 증가에 대한 동시성 문제를 간단히 해결할 수 있지만
잠금을 획득하지 못하나 스레드는 대기해야하므로 CPU 효율이 저하된다는 잠점이 있다.
잠금을 사용하지 않으면서 동시성 문제 없어 count 증가를 구현하는 다른 방법으론 원자적 타입을 사용할 수 있다. 자바 언어를 예로 들면 AtomicInteger, AtomicLong, AtomicBoolean과 같은 타입이다. 이 타입을 사용하면 다중 스레드 환경에서 동시성 문제없이 여러 스레드가 공유하는 데이터를 변경할 수 있다.
AtomicInteger는 내부적으로 CAS(Compare And Swap) 연산을 사용한다. 이를 통해 스레드를 멈추지 않고도 다중 스레드 환경에서 안전하게 값을 변경할 수 있다. 값 증가시 모든 스레드가 멈추지 않고 실행되므로 CPU 효율을 높일 수 있다. AtomicInteger 클래스의 내부 구현은 잠금을 사용하는 것보다 복잡하지만, 사용하는 입장에서는 Lock을 사용하는 것보다 간단하게 동시성 문제를 해결할 수 있다.
4. 동시성 지원 컬렉션
4.1 동기화 컬렉션
자바에서 HashMap이나 HashSet처럼 스레드에 안전하지 않은 컬렉션을 여러 스레드가 공유하면 동시성 문제가 발생할 수 있다. 이 문제를 해결하는 방법 중 하나가 동기화된 컬렉션을 사용하는 것이다. 즉, 데이터를 변경하는 모든 연산에 잠금을 적용해서 한 번에 한 스레드만 접근할 수 있도록 제한하는 것이다. 자바에서 Collections 클래스가 그 예인데, 동기화된 컬렉션을 제공하는 메서드를 제공한다. 이 메서드를 사용하면 기존 컬렉션 객체를 쉽게 동기화된 컬렉션 개체로 변환할 수 있다.
동기화된 컬렉션 객체는 변경이나 조회와 관련된 메서드가 모두 동기화된 블록에서 실행되어 동시성 문제를 해결한다.
4.2 동기화 컬렉션 사용 예시
Map<String, String> map = new HashMap<>();
// 동기화된 컬렉션 객체 생성
Map<String, String> syncMap = Collections.synchronizedMap(map);
syncMap.put("key1", "value1"); // put()은 내부적으로 synchronized로 처리됨
주의할 점이 자바23 이하 버전 기준으로 가상 스레드를 사용한다면 Collections.synchronizedMap()을 포함한 동기화 컬렉션 객체로 변환해주는 메서드를 사용하면 안된다. 그 이유는 synchronizedMap()은 내부적으로 synchronized를 사용해서 동시 접근을 동기화하는데, 자바 23 이하 버전의 가상 스레드는 아직 synchronized를 지원하지 않기에 가상 스레드 환경에서 Collections.synchronized() 메서드로 생성한 동기화된 컬렉션을 사용하면 성능에 문제가 생길 수 있기 때문이다.
4.3 동시성 지원 컬렉션
동시성 문제를 해결하는 또 다른 방법은 동시성 자체를 지원하는 컬렉션 타입을 사용하는 것이다. 예시로 HashMap 대신 ConcurrentHashMap을 사용하는 것이 있다.
ConcurrentMap<String, String> map = new ConcurrentHashMap<>();
map.put("key1", "value1"); // 동시성 지원 컬렉션은 잠금 범위를 최소화한다.
ConcurrentHashMap 타입은 데이터를 변경할 때 잠금 범위를 최소화하기에 키의 해시 분포가 고르고 동시 수정이 많을 때 사용하는 것이 적합하다.
5. DB 수준 잠금
대부분의 DB는 명시적인 잠금 기법을 제공하는데, 이를 선점 잠금 또는 비관적 잠금이라고 한다. 비관적 잠금과 낙관적 잠금을 비교하면 다음과 같다.
| 선점 잠금, 비관적잠금, 배타적 잠금, 명시적 잠금기법 | 비선점 잠금, 낙관적 잠금 |
| 동일한 레코드에 대해 한 번에 하나의 트랜잭션만 접근하게 제어 | 값을 비교해서 수정하는 방식(실제로 잠금하는 것은 아님) |
| 데이터 변경이 성공할 가능성이 낮다(한 번에 1개의 클라이언트만 접근가능하므로) | 쿼리 실행 자체를 막지 않고 데이터 잘못 변경을 방지한다 |
| 잠금을 구하기 위한 대기과정이 없어 응답이 빠르다 | |
| 일단 데이터 변경을 시도한다 | |
| 데이터 변경이 성공할 가능성이 높다(대기가를 안 하므로) |
선점 잠금
선점 잠금은 데이터에 먼저 접근한 트랜잭션이 잠금을 획득하는 방식이다. 오라클과 MySQL에서 선점 잠금을 획득하기 위한 쿼리는 다음과 같다.
select *
from 테이블
where 조건
for update
위 쿼리는 조건에 해당하는 레코드를 조회하면서 동시에 잠금을 획득한다. 한 트랜잭션이 특정 레코드에 대한 잠금을 획득한 경우, 잠금을 해제할 때까지 다른 트랜잭션은 동일 레코드에 대한 잠금을 획득하지 못하고 대기해야 한다. 레코드에 대한 잠금은 트랜잭션이 종료될 때 (커밋이나 롤백) 반환된다. 이를 통해 동일 레코드에 대한 동시 수정을 막을 수 있다.
분산 잠금(Distributed Lock)
여러 프로세스가 동시에 동일한 자원에 접근하지 못하도록 막는 방법. 여러 프로세스 간 잠금 처리를 한다는 점에서 잠금과 차이가 있음
간단한 분산 잠금이 필요할 때는 DB 제공 선점 잠금 추천-> 간단한 코드 구현, 대부분의 시스템이 DB를 필수로 사용하므로 별도 도구 추가가 필요 없다
트래픽이 많은 경우 Redis 분산 잠금 구현 추천
비선점 잠금
비선점 잠금은 명시적으로 잠금을 사용하지 않는다. 대신 데이터를 조회한 시점의 값과 수정하려는 시점의 값이 같은지 비교하는 방식으로 동시성 문제를 처리한다. 보통 비선점 잠금을 구현할 때는 정수 타입의 버전 컬럼을 사용한다.
버전 컬럼 이용해서 비선점 잠금 구현하기
1) SELECT 쿼리 실행 시 version 컬럼 함께 조회
SELECT ...
,version
FROM 테이블
WHERE id = 아이디
2) 로직 수행
3) UPDATE 쿼리 실행 시 version 컬럼 + 1. 이 때 version 컬럼 값이 1에서 조회한 값과 같은지 비교하는 조건을 where 절에 추가한다.
UPDATE 테이블
SET ...
,version = version + 1
WHERE id = 아이디
AND version = [1에서 조회한 version 값]
4) UPDATE 결과로 변경된 행 개수가 0이면, 이미 다른 트랜잭션이 version 값을 증가시킨 것이므로(해당 트랜잭션이 조회하는 동안 다른 트랜잭션이 데이터 변경) 데이터 변경에 실패한 것이다. 이 경우 트랜잭션을 롤백한다.
5) UPDATE 결과로 변경된 행 갯수가 0보다 크면, 다른 트랜잭션보다 먼저 데이터 변경에 성공한 것이므로 트랜잭션을 커밋한다.
비선점 잠금은 낙관적인 방식이기 때문에 일단 데이터 변경을 시도한다.
조회한 버전 값을 비교해 데이터가 변경되지 않았는지 확인한다. 만약 다른 트랜잭션이 먼저 데이터를 변경했다면 버전 값이 달라지기 때문에 UPDATE 결과는 0건이 된다. 이 경우에는 데이터 변경에 실패한 것이므로 적절한 오류 처리 후 롤백한다.
반면 UPDTE 결과 > 0인 경우, 조회한 이후로 버전 값이 바뀌지 않았다는 뜻이므로 데이터 변경을 할 수 있고 커밋하면 된다.
비선점 잠금은 잠금을 구하기 위한 대기 과정이 없기 때문에 실패할 경우 사용자에게 더 빠르게 결과를 응답할 수 있다는 장점이 있다.
6. 외부 연동과 잠금
트랜잭션 범위 내에서 외부 시스템과 연동해야 한다면, 비선점 잠금보다는 선점 잠금을 고려하는 것이 좋다. 만약 비선점 잠금을 사용하고 싶다면 트랜잭션 아웃박스 패턴을 적용해서 외부 연동을 처리하는 방법도 있다. 비선점 잠금 + 트랜잭션 아웃박스 패턴
7. 증분 쿼리
SUBJECT 테이블의 joinData의 값을 증가시키는 로직이 있다고 하자.
// 1. 주제 조회
Subject subject = jdbcTemplate.queryForObject(
"SELECT id
,joinCount
FROM SUBJECT
WHERE id = ?"
);
// 2. 참여 데이터 추가
joinToSubject(joinData, subject); // SUBJECT_JOIN 테이블에 추가
// 3. 주제 데이터의 참여자 수 증가
jdbcTemplate.update(
"UPDATE SUBJECT
SET joinCount = ?
WHERE id = ?"
, subject.getJoinCount() + 1, subject.getId()
);
위처럼 참여자 수를 증가시키는 쿼리가 있을 때 그 수가 의도한 바대로 세팅되지 않을 수 있다. 그 이유는 위 과정이 1) 조회, 2) 값 증가 3) 값 update 와 같이 3단계이고 만약 동시에 서로 다른 스레드가 동시에 1)조회 단계를 실행한 후 값을 증가시켰다면 값은 +2가 아닌 +1만 될 수 있기 때문이다.
이 문제를 선점 잠금이나 비선점 잠금으로 해결할 수 있지만
선점 잠금의 경우 잠금 대기시간만큼 응답 시간이 길어지는 문제가 발생할 수 있고,
비선점 잠금을 사용하면 대기시간은 없지만 변경 실패 에러가 자주 발생할 수 있다.
이때 잠금을 사용하지 않으면서도 참여자 수를 증가 시키는 방법은 증분 쿼리를 사용하는 것이다. 참여자 수 + 1을 위한 쿼리는 다음과 같다. 위 코드처럼 조회 및 값 증가 후 update를 하는 것이 아닌 update 시에 값 증가도 같이 하는 것이다.
update SUBJECT
set joinCount = joinCount + 1
where id = #{id}
증분 쿼리를 원자적 연산으로 처리하는 DB인지 확인한 후, 위 쿼리를 실행하면 jointCount = jointCount + 1을 원자적 연산으로 처리한다. 동일 데이터에 대한 원자적 연산이 동시에 실행될 경우 이를 순차적으로 실행해 데이터가 누락되는 문제가 발생하지 않는다.
'기타 > 책' 카테고리의 다른 글
| [주니어 백엔드 개발자가 반드시 알아야 할 실무지식] 7. IO 병목 (0) | 2026.04.14 |
|---|---|
| [주니어 백엔드 개발자가 반드시 알아야 할 실무지식] 6.2 동시성 (0) | 2026.04.13 |
| [주니어 백엔드 개발자가 반드시 알아야 할 실무 지식] 5. 비동기 연동 (0) | 2026.03.29 |
| [주니어 백엔드 개발자가 반드시 알아야 할 실무 지식] 4. 외부연동 (0) | 2026.03.26 |
| [주니어 백엔드 개발자가 반드시 알아야 할 실무 지식] 3.2 DB 지식 (0) | 2026.03.24 |