[동시성] synchronized

SIK407·2025년 6월 23일

Backend

목록 보기
5/10
post-thumbnail

동시성 프로그래밍에서 경쟁 상태 (Race Over)를 해결하는 방안을 공부중이다.
(동시성이 뭐고, 경쟁상태가 뭔지는 요기로!)

동시성을 공부하던 도중, 흥미로운 사실을 하나 알아냈다.

◽ 내가 생각한 가설

1. K6 테스트 코드

import http from 'k6/http';

export let options = {
    vus: 500, // 동시에 50명의 가상 유저가 요청
    iterations: 500, // 각 유저 1회 요청
    // duration: '5s', // 5초 동안 실행
};

export default function () {
    const url = 'http://localhost:8080/api/post/like';

    const params = {
        headers: {
            'Content-Type': 'application/json',
        },
    };

    const res = http.post(url, params);
    // console.log(res.status);
}

2. 뭔가 수상한 Java Service 코드

// Service단에 synchronized를 적용한 상태 
public synchronized ResponseEntity<?> likePost() {
	// 그냥 테이블에 데이터 없으면 넣어주는 코드
    // 딱히 이 실험에 영향이 가지 않는다.
    if ( postRepository.count() == 0 ) initData();

    try {
        Post post = postRepository.findById(1L)
                .orElseThrow(() -> new NullPointerException("Post not found"));
        post.setLikes(post.getLikes() + 1);
        postRepository.save(post);

        return ResponseEntity.ok().body("좋아요: " + post.getLikes());
    } catch (Exception e) {
        log.error("Error liking post: {}", e.getMessage());
        return ResponseEntity.status(500).body("좋아요 실패." + e.getMessage());
    }
}

현재 코드는 서비스에 synchronized를 적용한 상태다.

유저는 간단하게 500명이 있다고 가정하고, k6로 500개의 요청을 보내면...
"경쟁상태"에 의해 500개보다 좋아요👍 의 수가 적어야 된다고 생각했다.

근데...

이거 왜 500개가 그대로 찍히지? 😱😱
ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅠㅠㅠㅠㅠㅠㅠ

자세하게 알아보자.



◽ 트랜잭션 (Transaction)과 synchronized

원인은 @Transactional 이 친구다!

트랜잭션 (Transaction)
데이터베이스의 상태를 변화시키기 해서 수행하는 작업의 단위

@Transactional
해당 메소드 내의 모든 데이터베이스 접근 작업이 하나의 트랜잭션으로 묶임

트랜잭션을 붙이게 되면,
그 메소드 내에서 실행되는 모든 쿼리가 성공해야만 커밋(저장)되고,
하나라도 실패하면 롤백(취소) 된다.

❌ @Transactional을 사용하지 않은 경우

public synchronized ResponseEntity<?> likePost() {
	// 그냥 테이블에 데이터 없으면 넣어주는 코드
    // 딱히 이 실험에 영향이 가지 않는다.
    if ( postRepository.count() == 0 ) initData();

    try {
        Post post = postRepository.findById(1L)
                .orElseThrow(() -> new NullPointerException("Post not found"));
        post.setLikes(post.getLikes() + 1);
        postRepository.save(post);

        return ResponseEntity.ok().body("좋아요: " + post.getLikes());
    } catch (Exception e) {
        log.error("Error liking post: {}", e.getMessage());
        return ResponseEntity.status(500).body("좋아요 실패." + e.getMessage());
    }
}

지금 이 메소드에 @Transactional 적용되어 있지 않다.
그렇기에 postRepository.save(post) 가 실행이 되면, 바로 DB에 커밋을 하게 된다.

커밋을 한 후, synchronized에 의해 락이 걸려 있던 likePost() 메소드가 풀리면서, 정상적으로 다음 스레드가 likePost()를 다시 실행한다.

즉, 커밋 시점synchronized락이 풀리는 시점보다 빨라서 "경쟁 상태"가 발생하지 않는다.

그럼 @Transactional을 적용하면...?

⭕ @Transactional을 사용한 경우

정상적으로(?) 경쟁상태가 됐다.

@Transactional을 적용시키기 되면,

	1.	Spring의 AOP 프록시가 메서드 호출을 가로챔
	2.	AOP가 트랜잭션을 미리 시작
		→ 여기서 DB 커넥션 + 트랜잭션 바인딩됨
	3.	진짜 메서드 진입 (synchronized 발동)
		→ 다른 스레드는 여기서 기다림 (Lock)
	4.	메서드 본문 실행
	5.	메서드 종료
    	→ Lock 해제 → 다른 스레드가 접근 가능
	6.	AOP가 트랜잭션을 커밋 or 롤백

5번에서 메서드가 종료되고, 그와 동시에 Lock도 풀려버린다.

이렇게 되면, AOP가 Commit이나 RollBack을 하기 전에 자원을 가져가게 되는 순간!
경쟁상태가 되어 버린다...



◽ 그럼 어떻게 경쟁상태를 해결해...?

간단하다. Controller에 synchronized를 적용하면 된다.

@PostMapping("/like")
public synchronized ResponseEntity<?> likePost() { 
	return postService.likePost(); 
}

AOP가 트랜잭션을 실행하는 시점은 Service 계층이다.

그 전에, Controller 계층에서 synchronizedLock을 하게 되면, 경쟁상태가 발생하지 않는다.

정상적으로 500개의 좋아요가 저장되어 있다.



◽ 문제점

많은 개발자들이 "DB 락을 사용해!" 라고 말한다.
왜 그럴까...?

synchronized의 가장 큰 단점은 "다중 환경"이다.

서버의 트래픽 분산을 위해,
위와 같은 구조처럼 MSA 혹은 분산서버로 서버를 여러대를 사용하는 경우가 많다.

A서버에서 likePost()의 메소드를 Lock을 했다고 가정하자.
그럼 B서버가 그걸 어케 아는가...?

출처: 요기

당연히 A(Server 1)는 B(Server 2)의 스레드에 대한 동시성을 제어할 수는 없다.
그렇기에 실무에서는 잘 사용하지 않는다고 한다.

그래서! DB 레벨에서 Lock을 걸어버린다고 한다.

다음엔 DB 락 공부해야지~

profile
감자 그 자체

0개의 댓글