Develop/Design

동시성 프로그래밍에 대하여

코딩의성지 2022. 1. 19. 21:38

백엔드 개발자라면 동시성을 고려한 프로그래밍을 할 줄 알아야한다.

 

다만 아직 학생이거나 주니어 레벨에서는 이러한 동시성을 이해하기가 쉽지는 않다.

 

프론트 단의 개발과는 다르게 백엔드 쪽은 명확하게 구조를 이해하기가 쉽지 않다. 

서버라는 것 자체가 내용이 방대하고, 구조가 복잡하기 때문이다.

 

분명 동시성을 이해하는 것은 실무에서의 경험이 어느정도 해결해주기는 하나 그래도 어느정도의 공부가 수반되어야 동시성을 잘 이해할 수 있다.

 

여러분들의 동시성에 대한 이해를 돕기 위해, 동시성에 대한 내용을 좀 정리해 보았다.

동시성 프로그래밍에 대한 오해

1. 동시성은 항상 성능을 높여준다?

동시성은 항상 성능을 높여주진 않는다. 때로 성능을 높여줄 뿐이다. 

구체적으로 말하면 대기시간이 아주 길어 여러스레드가 프로세서를 공유할 수 있거나, 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많다면 성능이 높아진다. 

 

예를 들어 웹브라우저에서 여러가지 이미지 리소스들을 불러와 다운로드할 때 동시성이 구현된 프로그래밍은 큰 효율을 발휘한다.

 

2. 동시성을 구현해도 설계는 변하지 않는다?

그렇지 않다. 단일 스레드인 시스템과 다중 스레드인 시스템은 설계가 완전히 달라진다.

무엇과 언제를 분리한다는 것만으로도 시스템 구조는 크게 바뀐다.

 

3. 스프링 프레임워크, 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다?

어플리케이션이 컨테이너를 통해 멀티 스레드를 사용하는 것이기 때문에 컨테이너를 정확히 이해하는 것이 좋다.

이러한 환경을 이해해야 동시 수정이나 데드락 같은 문제를 잘 해결할 수 있다.

 

 

여러분들이 잘 아시는 서블릿은 동시성의 좋은 예이다.

서블릿으로 요청이 들어오면 Thread Pool 에 있는 Thread가 서블릿의 Service() 메서드를 호출한다.

그러면 service() 의 doGet(), doPost()에서 각 요청에 대한 처리를 하도록 구현되어 있는데, 이러한 Servlet 구조가 동시성을 잘 구현한 예라 할 수 있다.

구체적으로 코드를 간단하게 보면 아래와 같다.

public class MyHttpServlet extends HttpServlet {
    // Thread Safe 하지 못한 static 변수
    protected static List list = new ArrayList();
    
    // Therad Safe 하지 못한 멤버 변수
    protected Map map = new HashMap();
    
    // 동시성 구현으로 접근에 대해서는 Thread Safe, 재할당에 대해서는 Thread Safe 하지 않음
    protected Map map = new ConcurrentHashMap();
    
    // immutable한 속성으로 접근에 대해서는 Thread Safe, 재할당에 대해서는 Thread Safe 하지 않음
    protected String tempString = "temp String";
    
    protected void doGet(HttpServletRequest request, HttpServlet Response response) throws ServletException, IOException {
    	// Thread safe
        List localList = new ArrayList();
        
        // 싱글톤 객체를 만약 가져오는 경우 Thread Safe 하지 않음
        SingletonClass.getSingletonObject();
        
    }
}

서블릿에 여러 쓰레드가 접근할 수도 있기때문에 멤버 변수의 경우 Thread Safe 하지 않다.

그러나 doGet이나 doPost메서드 같은 경우는 하나의 스레드 영역에서 실행됨으로 내부의 local 변수들은 Thread Safe 하다.

 

 

동시성을 구현할때 명심해야할 것

동시성을 구현해놓은 코드가 일으키는 문제를 방어하는 원칙과 기술

 

단일 책임 원칙지켜라

동시성 코드는 SRP 원칙을 통해 다른 코드와 분리해야한다.

 

이전에 SOLID 원칙을 정리하며 SRP에 대해 포스팅한 적이 있었다. SRP에 대한 내용을 잘 모르겠다면 아래 링크를 참고하자.

https://devkingdom.tistory.com/296?category=838914 

 

객체지향 SOLID 원칙 - SRP, OCP, LSP, ISP, DIP

오늘은 간단하게 객체지향 SOLID 5대 원칙에 대해 정리해두려고한다. 1.SRP (Single Responsibility Principle) - 단일 책임 원칙 한 클래스는 하나의 책임만 가져야 한다. SRP 원칙은 클래스가 하나의 기능만

devkingdom.tistory.com

 

자료범위를 제한하라

공유 자료를 최대한 줄이고, 자료를 캡술화해야한다.

 

공유 객체를 사용하는 코드 내 임계영역을 synchronized 키워드를 보호해야한다.

다만 이러한 임계영역의 수를 최소화하기 위해 노력하여야한다.

 

자료의 복사본을 이용하라

구현 초기부터 자료를 스레드가 공유하지 않도록 짜는 것이 좋다.

객체를 복사하여 읽기전용으로 사용하는 방법도 있고, 스레드가 객체를 복사하여 사용한 후, 스레드가 해당 복사본에서 결과를 가져오는 방법도 있다.

복사본 생성이나 GC 비용은 동기화 이슈를 해결하기 위해 충분히 감당할만 하다.

 

스레드는 가능한 독립적으로 구현하라

다른 스레드와 자료를 공유하지 않도록 해야한다.

위의 서블릿 예처럼 각 스레드는 클라이언트 요청 하나를 처리한다.

모든 정보는 클라이언트의 요청에서 가져오고 로컬변수에 저장해야 한다.

 

라이브러리를 이해하라

Thread Safe한 Collection을 사용해야한다. 예를 들면 ConcurrentHashMap나 AtomicLong같은 것들이 Thread Safe한 것이다.

그리고 서로 무관한 작업을 수행할때는 executor 프레임워크를 사용해야한다.

그리고 스레드가 Blocking 되지 않는 방법을 사용해서 프로그래밍 하기를 권장한다.

 

동기화하는 메서드 사이에 존재하는 의존성을 이해하라

공유 객체 하나에는 하나의 메서드만 사용하자.

 

아래 코드를 보자. 아래 코드는 정수에 대한 Iterator를 클라이언트와 서버에서 사용하는 것을 구현한 내용이다.

 

클라이언트

// 스레드가 공유하는 자료
IntegerIterator iterator = new IntergerIterator();

// 스레드에서 호출되는 코드
while(iterator.hasNext()) {
    int nextValue = iterator.next();
    //nextValue로 로직수행
}

서버

public class IntergerIterator implements Iterator<Integer> {
    private Integer nextValue = 0;
    
    public synchronized boolean hasNext() {
    	return nextValue < 100000;
    }
    
    public synchronized Integer next() {
    	if (nextValue == 100000) throw new IteratorPastEndException();
        return nextValue++;
    }
    
    public synchronized Integer getNextValue() {
    	return nextValue;
    }
}

위 코드의 동작방식을 예상해 보자.

 

만약 Thread1 이 hasNext() 메서드를 호출한 뒤 true를 받고, 잠시 다른일 에 선점당한다.

갑자기 Thread2가 hasNext() 메서드를 호출한 뒤 true를 받고 next()를 호출된다.

이 후 Thread1이 실행을 재개하여 next()를 또 호출하면

 

nextValue가 의도치 않은 값이 나오게 될 것이다. 이는 Thread Safe하지 못한 코드가 된다.

 

이러한 무제는 메서드 사이에 의존성을 만들어 해결할 수 있다. 

클라이언트에서 잠금, 서버에서 잠금, 어댑터 패턴을 이용한 잠금 이렇게 세가지 방법을 소개하겠다.

 

클라이언트에서 잠금 (Client Based Lock)

 

클라이언트

// 스레드가 공유하는 자료
IntegerIterator iterator = new IntergerIterator();

// 스레드에서 호출되는 코드
while(true) {
    int nextValue;
    synchronized (iterator) {
    	if (!iterator.hasNext()) break;
        nextValue = iterator.next();
    }
    // nextValue 로 로직 수행
}

서버

public class IntergerIterator implements Iterator<Integer> {
    private Integer nextValue = 0;
    
    public synchronized boolean hasNext() {
    	return nextValue < 100000;
    }
    
    public synchronized Integer next() {
    	if (nextValue == 100000) throw new IteratorPastEndException();
        return nextValue++;
    }
    
    public synchronized Integer getNextValue() {
    	return nextValue;
    }
}

클라이언트에서 락을 걸어 Thread Safe하게 블록을 보호해 줄 수 있다..

그러나 이방법은 자원을 사용하는 클라이언트마다 위의 처리를 해줘야하기 때문에 비효율적이고 프로그래머들이 실수할 가능성이 높아진다.

 

서버에서 잠금 (Server Based Lock)

 

클라이언트

// 스레드에서 호출되는 코드
while(true) {
    int nextValue = iterator.getNextOrNull();
    if (nextValue == null) break;
    // nextValue로 로직 수행
}

 

서버

public class IntergerIteratorServerLocked {
    private Integer nextValue = 0;
    
    public synchronized Integer getNextOrNull() {
    	if (nextValue < 100000) return nextValue++;
        else return null;
    }
}

서버에서 두개로 나눠졌던 동작을 하나로 합쳐 락을 건다.

클라이언트에서는 보호된 메서드를 호출하기만 하면 된다. 하지만 외부에서 주입받은 라이브러리거나 서버 소스를 수정하기 힘든경우에 이방법은 사용하기가 힘들다.

 

Adpater Pattern을 이용한 잠금 (Adapter Based Lock)

 

동시성 구현이 안되어 있는 서버 코드

public class IntergerIterator implements Iterator<Integer> {
    private Integer nextValue = 0;
    
    public synchronized boolean hasNext() {
    	return nextValue < 100000;
    }
    
    public synchronized Integer next() {
    	if (nextValue == 100000) throw new IteratorPastEndException();
        return nextValue++;
    }
    
    public synchronized Integer getNextValue() {
    	return nextValue;
    }
}

 

어댑터 패턴으로 동시성 구현

public class ThreadSafeIntegerIterator {
    private IntegerIterator iterator = new IntegerIterator();
    
    public synchronized Integer getNextOrNull() {
    	if (iterator.hasNext()) return.next();
        return null;
    }
}

 

어댑터에서 서버에서 서버에서 두개로 나눠졌던 동작을 하나로 합쳐 락을 건다.

이렇게 구현하면 클라이언트에서는 보호된 메서드를 호출하기만 하면 된다.

서버의 코드가 외부코드라서 수정할 수 없을 경우, 이러한 방식으로 어댑터를 만들어 사용하면 된다.

 

동시성 코드 테스트 방법

동시성 코드는 무조건 해야한다.

동시성 코드를 테스트했다고 100% 안전한 코드라고 판단하기는 어렵다. 그래도 충분한 테스트는 위험을 낮춘다.

 

문제를 노출하는 테스트케이스를작성하라

프로그램의 설정과 시스템 설정과 부하를 바꿔가며 자주 돌려라

테스트가 실패하면 원인을 추적하라

다시 돌렸더니 통과한다는 이유로 그냥 넘어가선 안된다

 

테스트 코드에 보조 코드를 넣어서 돌려라.

테스트 코드에 드물게 발생하는 오유를 자주 발생시키도록 보조 코드를 추가하여 테스트해야한다.

 

코드에 wait(), sleep(), yield(), priorty() 함수를 적절하게 이용하여 직접 보조코드를 구현하거나

보조코드를 넣어주는 도구를 사용하여 보조코드를 넣어주자.

예를 들어 다양한 위치에 ThreadJigglePoint.jiggle()을 추가해 무작위로 sleep(), yield() 가 호출되도록 만든다

그리고 테스트 환경에서 보조코드를 돌려보자

 

동시성 코드를 실제 환경이나 테스트 환경에서 돌려보라.

다양한 요청과 상황에서 동시성 코드가 정상적으로 동작하는지 확인해야한다.

 

배포하기전에 테스트환경에서 충분히 오랜시간 검증이 필요하고, 동시성 코드를 배포한 후에 지속적인 모니터링으로 문제가 발생하는지 지켜봐야한다. 

 

Ref.
로버트 C.마틴, 『클린코드』, 도서출판인사이트(2021), p225~244, p407~446

반응형