
프로젝트의 요구 사항에 그룹 인원 제한이 추가되었다. 그룹을 처음 만들면 기본적으로 10명의 제한이 걸리게되며, 추후 과금을 통해 인원을 늘리는 비즈니스 모델을 생각해보았다.
하지만 예상과 다르게 인원수 제한을 넘어 가입되는 문제가 발생했는데...

문제 해결의 시작은 문제를 만드는 것 부터라는 내용을 어디선가 본 것 같다. 혼자만의 생각일지도..?
동시성 문제가 항상 재현될 수 있도록 코드를 수정해보자.
@Transactional
public void joinGroup(GroupJoinRequestDto dto, Long groupId) {
...
// 그룹 인원 확인
profileService.checkGroupSize(group);
// 가입 진행
profileService
.createNewProfile(dto.getNickname(), GroupRole.MEMBER, group);
...
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
// 동시성 이슈 발생용
}
}
메소드가 끝나기 전 5초의 sleep을 주었다. 이제 5초 안에 두번의 요청을 날릴 시 반드시 동시성 문제가 발생한다!
동시성 문제를 한줄로 표현하자면 다음과 같다.
하나의 공유자원에 여러 스레드가 동시에 접근함으로써 발생하는 문제
현재 로직 상 문제가 발생하는 부분을 찾아보자.
- 현재 1번 그룹에 9명이 존재한다.
- 사용자 A가 가입을 요청한다.
- 현재 그룹의 인원을 체크한다. 9명이다.
- 사용자 B도 가입을 요청한다.
- 현재 그룹의 인원을 체크한다. 사용자 A의 가입이 처리되지 않았기에 역시 9명이다.
- 사용자 A의 가입이 처리된다. 그룹이 가득 찬다.
- 사용자 B의 가입이 처리된다. 그룹은 이미 가득 찼지만, 사용자 A의 가입이 처리되기 전 인원 체크를 통과했기에 정상적으로 처리된다.
- 결과적으로, 사용자 A, B 모두 가입되며 그룹은 11명이 존재하게 된다.
결국, 사용자 A의 가입 요청이 끝난 후에 사용자 B의 가입 요청이 시작되어야 한다. Java에선 이런 문제를 synchronized 키워드를 통해 간단히 해결할 수 있다.
이제 synchronized 키워드로 문제를 해결해보자.
@Transactional
public synchronized void joinGroup(
GroupJoinRequestDto dto, Long groupId) {
...
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
// 동시성 이슈 발생용
}
}
위와 같이 메소드 자체에 임계 영역을 설정하는 방법을 synchronized method라고 한다. 동일 인스턴스의 메소드에 하나의 스레드만의 접근을 허용함으로써 동시성 문제를 해결한다.
굉장히 간단히 동시성 문제를 해결하였다! 자, 다시 문제가 재현되는지 확인해보자.
예상과 다르게 여전히 문제가 발생한다.

먼저 로그를 찍어 확인해봤다.
@Transactional
public synchronized void joinGroup(
GroupJoinRequestDto dto, Long groupId) {
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
log.info("method called. time : {}", now.format(formatter));
...
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
// 동시성 이슈 발생용
}
}
...GroupService : method called. time : 2024-06-08 01:39:56
...GroupService : method called. time : 2024-06-08 01:40:02
메소드는 정상적으로 임계 영역이 지정되었으며, 5초 대기 후 다음 요청이 해당 메소드에 진입한다.
이를 확인하기 위해 JPA와 스프링 트랜잭션의 로깅 레벨을 DEBUG로 설정하였다.
logging:
level:
org:
hibernate:
SQL: DEBUG
transaction: DEBUG
springframework:
orm:
jpa: DEBUG
transaction: DEBUG
이후 발생한 로그를 확인해보니
2024-06-08T01:48:58.498+09:00 DEBUG 16620 --- [group-service] [nio-8082-exec-1] o.j.s.OpenEntityManagerInViewInterceptor : Opening JPA EntityManager in OpenEntityManagerInViewInterceptor
...
// 커밋 완료
2024-06-08T01:49:03.991+09:00 DEBUG 16620 --- [group-service] [nio-8082-exec-1] o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(112250976<open>)]
// 다음 요청 처리 시작
2024-06-08T01:49:03.991+09:00 DEBUG 16620 --- [group-service] [nio-8082-exec-4] org.hibernate.SQL : select g1_0.id,g1_0.created_date,g1_0.description,g1_0.group_scope,g1_0.group_size,g1_0.join_condition,g1_0.name,g1_0.owner_user_id,g1_0.state,g1_0.updated_date from music_group g1_0 where g1_0.id=?
역시 정상적으로 트랜잭션이 커밋된 후 다음 요청이 진행되는 것을 볼 수 있었다.
다만, 예상과는 다르게 진행된 부분이 한가지 있었는데 첫번째 요청의 트랜잭션이 커밋되기 한참 전부터 두번째 요청의 트랜잭션이 시작되었다는 것이다.
// 요청 1 트랜잭션 시작
2024-06-08T01:48:58.630+09:00 DEBUG 16620 --- [group-service] [nio-8082-exec-1] o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [me.kong.groupservice.service.GroupService.joinGroup]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...
// 요청 1이 synchronized method에 진입
2024-06-08T01:48:58.656+09:00 INFO 16620 --- [group-service] [nio-8082-exec-1] m.k.groupservice.service.GroupService : method called. time : 2024-06-08 01:48:58
...
// 요청 1 처리중...
...
// 요청 2 트랜잭션 시작
2024-06-08T01:49:00.934+09:00 DEBUG 16620 --- [group-service] [nio-8082-exec-4] o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [me.kong.groupservice.service.GroupService.joinGroup]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
...
// 요청 2가 synchronized method에 진입
2024-06-08T01:49:03.990+09:00 INFO 16620 --- [group-service] [nio-8082-exec-4] m.k.groupservice.service.GroupService : method called. time : 2024-06-08 01:49:03
...
// 요청 1 트랜잭션 커밋
2024-06-08T01:49:03.991+09:00 DEBUG 16620 --- [group-service] [nio-8082-exec-1] o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(112250976<open>)]
...
// 요청 2 처리중...
...
// 요청 2 커밋
2024-06-08T01:49:09.069+09:00 DEBUG 16620 --- [group-service] [nio-8082-exec-4] o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(147937689<open>)]
데이터베이스의 트랜잭션 격리수준을 확인해보자.

REPEATABLE-READ로 설정되어 있다. 이를 간단히 말해보자면 트랜잭션이 시작한 시점의 데이터를 반환한다는 것이다.
JPA와 트랜잭션 로그를 봤을 때, 첫번째 요청이 커밋되기 전 두번째 요청의 트랜잭션은 이미 시작된 것을 볼 수 있었다. 이는 즉, 두번째 요청의 트랜잭션이 첫번째 요청 커밋 전에 시작되었기에 두번째 요청은 첫번째 요청의 변경 사항을 읽을 수 없다는 것이고, 메소드 호출은 정상적으로 이루어졌어도 두번째 요청은 첫번째 요청으로 인해 변경된 데이터를 읽지 못한다는 것이다.
그렇다면 임계 영역이 끝나기도 전에 두번째 요청의 트랜잭션이 시작됐을까? 의심가는건 단 하나밖에 없다!
스프링은 @Transactional 어노테이션을 통해 간편하게 트랜잭션을 관리할 수 있다. 위 어노테이션을 사용할 시 Spring AOP에 의해 Proxy 객체가 생성되고, 해당 메소드에서 트랜잭션을 시작, 원래의 로직을 실행한다.
생성된 프록시 객체를 확인해보자.
class GroupServiceProxy extends GroupService{
private GroupService groupService;
@Override
public void joinGroup(GroupJoinRequestDto dto, Long groupId) {
try{
tx.start();
groupService.joinGroup(dto, groupId);
} catch (Exception e) {
// ...
} finally {
tx.commit();
}
}
}
public class GroupService {
...
@Transactional
public synchronized void joinGroup(
GroupJoinRequestDto dto, Long groupId) {
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
log.info("method called. time : {}", now.format(formatter));
...
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
// 동시성 이슈 발생용
}
}
...
}
트랜잭션을 시작하고 종료하는 부분엔 임계 영역이 지정되지 않았다. 이 때문에 작성한 메소드 자체에는 임계영역이 잘 지정되었어도 트랜잭션은 미리 시작하는 문제가 발생한 것이다.
스프링의 선언적 트랜잭션이 문제라면 사용하지 않으면 그만이다.
public synchronized void joinGroup(
GroupJoinRequestDto dto, Long groupId) {
// 트랜잭션 시작
TransactionStatus status = transactionManager
.getTransaction(new DefaultTransactionDefinition());
try {
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
log.info("method called. time : {}", now.format(formatter));
...
try {
Thread.sleep(5000);
} catch (InterruptedException ex) {
// 동시성 이슈 발생용
}
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status);
}
}
이제 synchronized method를 통해 정상적으로 동시성 문제를 해결할 수 있게 되었다!
synchronized 키워드를 통해 굉장히 간단하게 동시성 문제를 해결해보았다. 다만, 이 방법에는 치명적인 단점이 존재하는데 그건 바로
scale-out 시 무용지물이 된다.
하나의 서버에서 임계 영역을 지정하기에 여러 서버에서 동시에 실행되는 문제는 막을 수 없다는 것이다. 그렇다면 어떻게 해야 할까?
다음 포스트엔 데이터베이스에 락을 걸어 해결하는 낙관적 락, 비관적 락을 적용해보겠다.
