싱글스레드인 경우에는 무엇과 언제가 서로 밀접하기 때문에, 호출 스택을 보면 프로그램 상태가 곧바로 드러나게 된다. 무엇과 언제를 분리하게 되면 곧 동시성을 구현할 수 있음을 의미하게 되는데, 어플리케이션의 구조와 효율이 좋아지고 시스템을 이해하기 수월하다는 장점을 가지고 있다.
한 번에 한 사용자를 처리하는 시스템이 있다고 가정해보자. 한 사용자를 처리하는데 1초가 걸린다면, 1000번째 사용자는 처리하는데 1000초가 걸리게 된다. 사용자의 수가 늘어나게되면 응답하는 속도가 느려질 수 밖에 없는데, 처리 과정을 병렬로 진행한다면 속도문제를 개선할 수 있다. 반드시 동시성을 사용해야하는 때가 존재하는데 굉장히 조심해서 사용해야 한다.
아래는 동시성에 대해 알아야하는 사실이다.
count
를 증가시키는 Counter 클래스가 있다고 가정하자.
class Counter {
var count = 0
fun getIncreasedCount(): Int {
return ++count
}
}
fun main() {
val executor = Executors.newFixedThreadPool(10)
val counter = Counter()
for (i in 0 until 10) {
executor.execute {
for (j in 0 until 10) {
counter.getIncreasedCount()
}
}
}
executor.shutdown()
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)
println("count: ${counter.count}")
}
다양한 스레드가 Counter 클래스를 공유하고 있는 상태에서, getIncreasedCount()
를 호출한다고 가정했을 때 다음과 같은 결과를 받을 수 있다.
스레드가 코드 한 줄을 거쳐가는 경로는 수없이 많은데, 그 중 일부의 경로가 잘못된 결과를 내놓기 때문이다. 대다수는 올바른 결과를 내놓지만, 문제는 잘못된 결과를 내놓는 일부 경로들이다. 잘못된 결과를 낳을 수 있는 가능성 위에 작업이 지속적으로 추가된다면, 나중에 어딘가에서 발생될 에러의 원인을 알지 못한 채 넘어갈 수 있다. 그래서 우리는 동시성으로 발생할 수 있는 문제를 방어해 잘못된 경로에 대한 문제를 방어해야 한다.
1. 단일 책임 원칙
SRP는 메소드, 클래스, 컴포넌트가 변경할 이유가 하나여야한다는 원칙이다. 그리고 동시성 코드는 그 자체로도 복잡하기 때문에 분리할 이유가 충분하다. 잘못 구현한 동시성 코드는 어떠한 방식으로든 실패할 수 있는데, 주변에 있는 다른 코드가 발목을 잡지 않더라도 동시성 하나만으로도 충분히 어렵기 때문에 동시성 코드는 따로 분리하는 것을 추천한다.
2. 자료 범위를 제한해라
위 예시처럼 하나의 객체를 공유하는 멀티 스레드는 예상치 못한 결과를 발생할 수 있다. 이 때 문제를 해결하기 위해 객체를 사용하는 코드 내에 synchronized 키워드로 임계영역을 설정하는 방법을 권장한다. 하지만 제일 좋은 것은 임계영역의 수를 줄이는 것이다. 공유 객체를 수정하는 위치가 많아질수록 코드가 망가지기 쉬우며 찾아내기 어려운 버그를 더욱 찾아낼 수 없기 때문이다. 그러므로, 자료는 최대한 캡슐화 하는 것이 중요하다.
3. 자료 사본을 사용해라
공유자료를 줄이려면 처음부터 공유하지 않는 방법이 좋다. 객체를 복사해서 읽기전용으로 사용한다면 코드가 문제를 일으킬 가능성이 줄어들게 된다.
4. 스레드는 가능한 독립적으로 구현하라
자신만의 세상에 존재하는 스레드를 구현한다. 다른 스레드 간 자료를 공유하지 않으며, 각각의 스레드는 클라이언트 요청을 수행해 독자적인 시스템 위에서 동작하는 것처럼 구성해 다른 스레드와 동기화할 일을 만들지 않는 것이다.
5. 라이브러리를 이해하라
java.util.concurrent
패키지는 내부에 synchronized 키워드를 제공해, 멀티 스레드 환경에서 사용하기에 안전하고 성능도 좋다. 대표적으로 ConcurrentHashMap이 java.util.concurrent 패키지 안에 존재하는데 멀티 스레드에서 안전하며, 거의 모든 상황에서 HashMap 보다 빠르다. 이렇게 스레드 갯수에 따라 사용하기에 적합한 패키지를 조사하는 것이 좋다.
6. 실행 모델을 이해하라
아래는 멀티 스레드 어플리케이션을 분류하는 방식이며, 발생할 수 있는 문제점에 대해 정의한다.
Producer - Consumer
하나 이상의 생산자 스레드가 정보를 생성해 버퍼나 대기열에 넣는다. 하나 이상의 소비자 스레드가 대기열에서 정보를 가져와 사용한다. 대기열은 한정된 자원이다. 생산자는 대기열에 빈 공간이 있어야 정보를 채우고, 그렇지 않은 경우에는 빈 공간이 생길 때까지 기다린다. 생산자는 소비자에게 대기열에 정보가 있다는 신호를 보내고, 반대로 소비자는 대기열에서 정보를 읽어들인 후 생산자에게 대기열에 빈 공간이 있다는 정보를 보낸다. 이 행동은 잘못하면 생산자와 소비자가 둘다 진행 가능함에도 불구하고 동시에 서로에게 신호를 기다리고 있을 가능성을 가지고 있다.
Readers - Writers
처리율(Throughput)을 강조하면 기아현상이 발생하거나 오래된 정보가 쌓인다. 반대로 갱신을 허용하면 처리율에 영향을 미치게 된다. 읽기 쓰레드가 우선순위를 가지고 있다고 가정했을 때, 읽기 쓰레드가 작업이 없을 때까지 쓰기 쓰레드가 기다리는 방법은 쓰기 스레드를 기아상태에 빠지게 만든다. 반대로 쓰기 쓰레드가 우선순위를 가지고 있어 계속 작업을 진행한다면 처리율이 낮아질 것이다. 따라서 이 문제를 적절히 해결할만한 방법이 필요하다.
Dining Philophers
둥근 식탁에 철학자들이 앉아있고, 각 철학자들의 왼쪽에는 포크가 놓여져있다. 식탁 가운데는 음식이 놓여져 있고, 철학자들이 배가고플 때 왼쪽 포크를 집어 먹을 수 있다. 왼쪽 철학자나 오른쪽 철학자가 포크를 사용하고 있다면, 포크를 내려놓을 때까지 계속 기다려야한다.
7. 동기화 하는 부분을 작게 만들어라
synchronized 키워드를 사용하면 lock을 사용해 해당 영역에 대해 한 번에 한 스레드만 실행이 가능해진다. 하지만 lock은 쓰레드를 지연시키고 부하를 가중시키므로 키워드를 남발해서 사용하면 안된다. 하지만 임계영역은 반드시 보호해야하는데, 최대한 임계영역 수를 줄이는 방향으로 코드를 구성해라.
8. 올바른 종료 코드는 구현하기 어렵다.
깔끔하게 종료하는 코드를 구현하는 것은 어렵다. 가장 흔하게 발생하는 문제가 데드락이다. 그러므로 종료하는 멀티스레드 코드를 짜야한다면 시간을 많이 투자해서 올바르게 구현해라.
9. 스레드 테스트 코드 구성
말이 안되는 실패는 잠정적인 쓰레드 문제로 취급해라
멀티 스레드 코드는 말이 안되는 오류를 일으킬 때가 많다. 스레드 코드에 버그가 발생하게되면 재현하기가 매우 어려워진다. 그래서 많은 개발자가 일회성 오류라고 판단할 때가 많다. 이런 일회성 오류를 계속 방치하게되면 잘못된 코드 위에 코드가 계속 쌓이게 될 것이다.
멀티 쓰레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
스레드 환경 밖에서 코드가 잘 돌아가는지부터 확인하는 것이 좋다.
멀티 쓰레드를 사용하는 코드 부분을 다양한 환경에 쉽게 끼워넣을 수 있도록 구성해라
싱글 스레드 혹은 멀티 스레드로 돌려도 정상적으로 동작해야 하며, 테스트 환경이 변화하거나 테스트 코드 실행 속도가 변화해도 문제가 없어야 한다. 또한 플랫폼에 따라 코드가 다르게 동작할 수 있기 때문에 해당 코드가 돌아갈 가능성이 있는 다른 플랫폼에서도 모두 테스트해봐야 한다.
코드에 보조 코드를 넣어 돌려라
스레드 환경에서는 간단한 테스트로는 버그를 찾기 힘들다. 이 때 코드의 실행 순서를 변경하는 행위 등 보조코드를 추가해보는 것이 좋다.