본문 바로가기
Spring

[ Spring ] 트랜잭션과 synchronized

by 희디비 2025. 12. 20.

[ 🌱 synchronized ]

자바의 synchronized는 동시성 상황에서 정합성을 지키기 위해 사용됩니다.

하지만 트랜잭션 환경에서 같이 쓰인다면 정합성을 보장할 수 없습니다.

먼저 동시성 문제의 간단한 예시를 알아보겠습니다.


[ 🌱 Count  도메인 ]

수를 저장하는 Count 도메인 입니다.

현재 자신의 수에서 1을 더하는 도메인 로직이 있습니다.

@Getter
@Entity
public class Count {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    private int count;

    protected Count() {
    }

    public Count(int count) {
        this.count = count;
    }

    public void countUp() {
        this.count += 1;
    }
}

[ 🌱 Count  비지니스 로직 ]

Count 도메인을 찾아와 변경 감지를 통해 자신의 수를 + 1 하는 로직입니다.

변경 감지를 사용하기 위해 트랜잭션이 걸려 있습니다.

@Transactional
@RequiredArgsConstructor
@Service
public class CountService {

    private final CountRepository countRepository;

    public void countUp(long countId) {
        Count count = countRepository.findById(countId).orElseThrow();
        count.countUp();
    }
}

[ 🌱 테스트 ]

하나의 스레드로 100번 카운트를 증가 시키면 총 카운트 수는 100이 됩니다.

@SpringBootTest
class CountServiceTest {

    @Autowired
    private CountService countService;

    @Autowired
    private CountRepository countRepository;

    @DisplayName("카운트를 100번 증가 시키면 총 카운트 수는 100이다.")
    @Test
    void countUp() throws InterruptedException {
        int taskCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(1);
        CountDownLatch countDownLatch = new CountDownLatch(taskCount);

        Count initCount = countRepository.save(new Count(0));
        for (int i = 0; i < taskCount; i++) {
            executorService.submit(() -> {
                try {
                    countService.countUp(initCount.getId());
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
        executorService.shutdown();

        Count findCount = countRepository.findById(initCount.getId()).orElseThrow();
        assertThat(findCount.getCount()).isEqualTo(taskCount);
    }
}

 

현재 상황에서 스레드 개수를 10개로 늘리게 된다면 어떻게 될까요?

 

동시성 문제로 인해 카운트가 재대로 증가 하지 않습니다.

자바는 이런 동시성 문제에 대한 해결책으로 synchronized를 사용할 수 있습니다.


[ 🌱 서비스 Synchronized ]

서비스 로직에 synchronized 적용 하였습니다.

하지만 테스트 시 동시성 문제는 해결 되지 않습니다.

이유는 트랜잭션과 synchronized 정합성 보장 구간이 다르기 때문입니다. 

@Transactional
@RequiredArgsConstructor
@Service
public class CountService {

    private final CountRepository countRepository;

    public synchronized void countUp(long countId) {
        Count count = countRepository.findById(countId).orElseThrow();
        count.countUp();
    }
}


[ 🌱 원인 ]

트랜잭션의 순서를 그림으로 나타낸 것입니다.

자바 Synchronized는 객체 내부의 락을 사용 합니다.

비니지스 로직 시작할 때 획득 후 끝나고 락을 반납 합니다. 여기서 문제는 락을 해제하는 타이밍 입니다.

Count 업데이트는 JPA 변경감지를 통해서 일어나는데 커밋 시점에는 이미 락이 해제 되어있습니다.

그레서 업데이트를 하기 전에 이미 다른 트랜잭션이 실행 될 수 있기 때문에 문제가 발생합니다.

 


[ 🌱 컨트롤러 Synchronized ]

그러면 Synchronized를 Controller에 사용하면 어떻게 될까요?

트랜잭션이 시작하기 전 락 획득 후 끝나고 반납하기 때문에 정합성이 보장 됩니다.

하지만 멀티 스레드 환경에서 단일 스레드로 사용 하는것이기 때문에 성능이 좋지 않습니다.

@RequiredArgsConstructor
@Controller
public class CountController {

    private final CountService countService;

    public synchronized void countUp(@RequestParam("countId") long countId){
        countService.countUp(countId);
    }
}