본문 바로가기

서버/Kotlin (Spring Boot)

동시성 문제

반응형

멀티 스레드와 같은 환경에서는 공유하고 있는 데이터를 변경하거나 조회할 때, 데이터가 최신임을 보장할 수 없는 상태가 필연적으로 발생한다.

이를 보장해주기 위해서 Lock이나 Queue 같은 방법을 사용한다.

일단 간단한 코드를 보면

package com.example.lock.kotlin

class KotlinCounter {
    var counter = 0

    fun doSomething() {
        if (counter == 0) {
            println("${Thread.currentThread().name} found 0!! counter should be 10")
            counter += 10
        } else {
            println("${Thread.currentThread().name} increase counter +1")
            ++counter
        }
    }
}

fun main() {
    val ks = KotlinCounter()
    Array(3) {
        ks.doSomething()
    }
    println(ks.counter)
}

결과는 다음과 같다.

main found 0!! counter should be 10
main increase counter +1
main increase counter +1
12

Process finished with exit code 0

코드 설명

counter 라는 속성의 초깃값은 0이고, 최초에 읽었을 때 해당 값이 0인 경우 10을 더한다.

counter 값을 읽었을 때 0이 아니라면 counter의 값을 1 증가시킨다.

일단 동시성의 개념이 없는 메인 스레드 하나만 존재하는 환경에서는 예상한대로 잘 동작한다.

메인 스레드가 루프를 돌면서 doSomething()을 3번 실행한다.

처음 실행 시 counter의 값을 0으로 읽으므로 10을 증가시킨다.

doSomething 함수를 종료하고 2번째 루프에서 다시 doSomething()을 실행한다.

counter는 10으로 읽고, 0이 아니므로 1을 증가시킨다.

마지막 3번째 루프도 동일.

최종적으로 12를 출력한다.

여기서 멀티 스레드 개념이 도입되면 문제가 생긴다.

package com.example.lock.kotlin

class KotlinThread {

    var counter = 0
    fun doSomething() {
        if (counter == 0) {
            println("${Thread.currentThread().name} found 0!! counter should be 10")
            counter += 10
        } else {
            println("${Thread.currentThread().name} increase counter +1")
            ++counter
        }
    }
}

fun main() {
    val ks = KotlinThread()
    Array(3) {
        Thread {
            ks.doSomething()
        }.start()
    }
    println(ks.counter)
}

결과가 좀 다르다.

0
Thread-1 found 0!! counter should be 10
Thread-2 found 0!! counter should be 10
Thread-0 found 0!! counter should be 10

Process finished with exit code 0

바뀐 코드는 기존에 루프를 돌던 로직에 doSomething()을 새로운 스레드에서 실행하도록 추가한 부분 뿐이다.

새로 생긴 스레드 3 개는 각자 거의 동시에 doSomething로직을 실행하고 각 스레드가 doSomething을 실행하는 시간은 1ms도 채 되지 않는다.

그런 와중에 KotlinThread 클래스의 counter 속성을 각 스레드가 조회하는 시점은 counter를 채 업데이트 하기도 전이기 때문에 모든 스레드는 counter를 0으로 조회하고 주어진 로직에 따라서 현재 counter 값에 10을 더한다.

0이 출력된 이유는 스레드 3개가 함수를 실행하는 것을 기다리지 않고 바로 counter를 조회했기 때문이고, 딜레이를 주고 출력해보면 30이 출력되는 것을 확인할 수 있다.

멀티 스레드 환경에서 counter 값이 최종적으로 12로 출력되게 하려면 어떻게 해야할까?

아주 아주 큰 흐름에서 보면 로직을 순서대로 실행시키면 된다.

package com.example.lock.kotlin

var switch = false

class KotlinThreadWithBlock {

    var counter = 0
    fun doSomething() {
        switch = true

        if (counter == 0) {
            println("${Thread.currentThread().name} found 0!! counter should be 10")
            counter += 10
        } else {
            println("${Thread.currentThread().name} increase counter +1")
            ++counter
        }
        switch = false
    }
}

fun main() {
    val ks = KotlinThreadWithBlock()
    Array(3) {
        Thread {
            if (switch) {
                Thread.sleep(10)
            }
            ks.doSomething()
        }.start()
    }
}

추가된 코드는 switch 변수와 Thread 생성 후 ks.doSomething() 실행 이전에 switch 값을 조회하는 부분이다.

doSomething()을 실행하기 전에 switch가 true라면 잠시 대기하고 false라면 바로 실행한다.

만약 doSomething()을 실행하면 가장 먼저 switch를 true로 바꾸고 로직을 실행한 뒤, 종료 직전 switch를 false 바꾼다.

요약하면 어떤 스레드에서 doSomething을 실행 중이라면, 다른 스레드에서 doSomething을 실행할 수 없도록 막고, 실행이 끝난 뒤 시도 하도록 하는 방법이다.

사실 이 코드는 다른 스레드를 대기 시키는 시간, 혹은 로직을 실행하는 시간에 따라서 문제가 발생할 수 있으나 간략하게 함수를 잠시 독점하고 다른 요청을 막는 개념만을 설명하기 위해 간략하게 구현했다.

실제로 자바와 코틀린에서는 이러한 기법을 쓰는 synchronized 키워드 혹은 어노테이션을 사용할 수 있다.

public synchronized void doSomething() {
    // do something
}

// or

@Synchronized
public void doSomething() {
    // do something
}
@Synchronized
fun doSomething() {
    // do something
}

// or

class OtherClass {
    fun doSomeSomething() {
        synchronized(this) {
            // do something
        }
    }
}

당연히 스레드와 같은 동시성 문제가 발생할 수 있는 환경이 JVM에만 존재하는 건 아니다.

여러 개의 CPU 코어 간의 메모리 Read & Write

여러 개의 스레드 간의 프로세스 데이터 Read & Write

여러 개의 서버 어플리케이션 간의 원격지의 DB Read & Write

앞에서 이야기한 것 처럼 어떤 속성을 독점하고 이 후에 풀어주면 되는데, 어떻게 보면 병목이 될 수 있기 때문에 이 기법은 적절하게 사용하지 못하면 오히려 성능 저하가 일어난다.

속도를 위해 병렬로 처리하는 환경에서 병렬 처리를 막고 순차적으로 처리하는 것을 강제하기 때문이다. 더군다나 독점 중에 생기는 대기 시간의 끝과 독점이 끝나는 시간을 정확하게 일치 시킬 수 없기 때문에 더욱 주의해야한다.

그런데 조금 특수한 경우도 있다.

여러 개의 서버 어플리케이션 간의 원격지의 DB Read & Write

전형적인 API 서버와 DB의 형태인데, 상황에 따라서 위에서 이야기한 문제들을 조금 천천히 해결해도 괜찮다.

  • 동시간에 많은 요청들이 API로 몰리고 DB에 어떤 값을 조작해야하는데, 서비스 상에서 명시적으로 대기를 요청한다.
    • 대표적으로 티켓팅, 드로우와 같은 서비스
    • 유저의 액션으로 쌓인 다량의 데이터를 자정마다 배치 형태로 처리하는 경우

다시 간단한 코드로 예를 들어보면

package com.example.lock.kotlin

val queue = mutableListOf<String>()

class KotlinThreadWithQueue {

    var counter = 0
    fun doSomething() {
        if (counter == 0) {
            println("${Thread.currentThread().name} found 0!! counter should be 10")
            counter += 10
        } else {
            println("${Thread.currentThread().name} increase counter +1")
            ++counter
        }
    }

    fun doSomethingWithQueue() {
        if (queue.isEmpty()) {
            println("Queue is still empty...")
        }
        queue.forEach {
            println("Thread $it is doing something")
            doSomething()
        }
    }

    fun registerQueue() {
        queue.add(Thread.currentThread().name)
    }
}

fun main() {
    val ks = KotlinThreadWithQueue()
    Array(3) {
        Thread {
            println("${Thread.currentThread().name} is registering queue...")
            ks.registerQueue()
        }.start()
        Thread.sleep(50)
    }
    while (queue.size < 3) {
        Thread.sleep(500)
    }
    if (queue.size == 3) {
        ks.doSomethingWithQueue()
    }
    println(ks.counter)
}

조금 길어지긴 했지만, 추가된 부분은 다음과 같다.

doSomething()을 각 스레드에서 직접 실행하지는 않는다. 각 스레드는 doSomething()을 하고 싶다면 registerQueue()를 통해서 전역적으로 유지되는 queue 리스트에 등록해야만 한다.

queue에는 각 스레드의 이름이 담긴다. 누가 doSomething()을 하려는 지에 대한 정보와 같다. queue는 총 스레드의 갯수인 3개 미만인 경우에는 계속 sleep 하며 아무것도 하지 않는다.

queue의 사이즈가 3 즉, 모든 스레드가 registerQueue()를 완료했을 때, doSomethingWithQueue()를 실행한다. doSomethingWithQueue()는 queue에 담긴 순서대로 순회하며 doSomething() 실행한다.

기존과 동일하게 12가 출력된다.

MySQL의 Lock도 다루고 싶은데 글이 너무 길어질 것 같다. 다음에!

반응형

'서버 > Kotlin (Spring Boot)' 카테고리의 다른 글

Spring WebFlux를 쓰려는데, Netty.. EventLoop?  (0) 2023.06.30