이 자료는 "에어콘" 사의 "자바 병렬 프로그래밍" 이라는 도서의 내용을 학습하면서 정리한 내용입니다. 예제로 제시된 소스코드 및 자세한 설명은 책을 참조하세요~
04. 객체 구성
- 스레드 안전성을 확보한 개별 컴포넌트를 가져다가 안전한 방법을 동원해 서로 연결해 사용한다면 규모 있는 컴포넌트나 프로그램을 좀 더 쉽게 작성할 수 있다.
4.1. 스레드 안전한 클래스 설계
- 객체가 갖고 있는 여러 가지 정보를 해당 객체 내부에 숨겨 두면 전체 프로그램을 다 뒤져볼 필요 없이 객체 단위로 스레드 안전성이 확보되어 있는지 확인할 수 있다.
클래스가 스레드 안전성을 확보하도록 설계하고자 할 대에는 다음과 같은 세 가지를 고려해야 한다.
- 객체의 상태를 보관하는 변수가 어떤 것인가?
- 객체의 상태를 보관하는 변수가 가질 수 있는 값이 어떤 종류, 어떤 범위에 해당하는가?
- 객체 내부의 값을 동시에 사용하고자 할 때, 그 과정을 관리할 수 있는 정책
- n개의 변수를 갖는 객체의 상태는 n개 변수가 가질 수 있는 값의 전체 조합이라고 생각할 수 있다.
- A라는 객체 내부에 다른 객체 B를 가리키는 변수를 사용하고 있다면, A 객체 내부의 변수뿐만 아니라 B 객체 내부에 들어 있는 변수의 조합까지 A 객체가 가질 수 있는 전체 상태 범위에 포함시켜야 한다.
- 객체 내부의 여러 변수가 갖고 있는 현재 상태를 사용하고자 할 때 값이 계속해서 변하는 상황에서도 값을 안전하게 사용할 수 있도록 조절하는 방법을 동기화 정책이라고 한다. 동기화 정책에는 객체의 불변성, 스레드 한정, 락 등을 어떻게 적절하게 활용해 스레드 안전성을 확보할 수 있으며 어떤 변수를 어떤 락으로 막아야 하는지 등의 내용을 명시한다. 동기화 정책는 항상 문서로 작성해 둬야 한다.
4.1.1. 동기화 요구사항 정리
- 객체와 변수가 가질 수 있는 가능한 값의 범위를 상태 범위 ( state space ) 라고 한다. 상태 범위가 좁으면 좁을수록 객체의 논리적인 상태를 파악하기가 쉽다. 실제 상태 변수가 아닌 녀석들을 final 을 지정해두면 상태 범위를 크게 줄여주기 때문에 생각해야 할 논리의 범위를 줄일 수 있다. ( 불변객체는 모두가 final 이어서 상태가 하나인 녀석이다. )
- 클래스가 특정 상태를 가질 수 없도록 구현해야 한다면, 해당 변수는 클래스 내부에 숨겨둬야만 한다. 변수를 숨겨두지 않으면 외부에서 클래스가 '올바르지 않다' 고 정의한 값을 지정할 수 있기 때문이다. 그리고 특정한 연산을 실행했을 때 올바르지 않은 상태 값을 가질 가능성이 있다면 해당 연산은 단일 연산으로 구현해야 한다. 반대로 클래스에서 변수의 값에 별다른 제약 조건을 두지 않는다면 클래스의 유연성과 실행 성능을 높인다는 측면에서 이와 같은 동기화 방법이나 캡슐화 기법을 사용하지 않아도 되겠다.
- 여러 개의 변수를 통해 클래스의 상태가 올바른지 아닌지를 정의한다면 연산을 단일 연산으로 구현해야 한다. 다시 말하면, 서로 연관된 값은 단일 연산으로 한번에 읽거나 변경해야 한다는 말이다.
객체가 가질 수 있는 값의 범위와 변동 폭을 정확하게 인식하지 못한다면, 스레드 안전성을 완벽하게 확보할 수 없다. 클래스의 상태가 정상적이라는 여러 가지 제약 조건이 있을 때 클래스의 상태를 정상적으로 유지하려면 여러 가지 추가적인 동기화 기법을 적용하거나 상태 변수를 클래스 내부에 적절히 숨겨야 한다.
4.1.2. 상태 의존 연산
- 현재 조건에 따라 동작 여부가 결정되는 연산을 상태 의존( state dependent ) 연산이라고 한다.
- wait 와 notify 를 사용하면 특정 상태가 원하는 조건에 다다를 때까지 효율적으로 기다릴 수 있다. 어떤 동작을 실행하기 전에 특정한 조건을 만족할 때까지 기다리도록 프로그램하고자 한다면, wait 와 notify 를 사용하는 대신 세마포어(Semaphore)나 블로킹 큐(Blocking Queue)와 같이 현재 알려져 있는 여러 가지 라이브러리를 사용하는 편이 훨씬 간단하고 안전한다.
4.1.3. 상태 소유권
- 객체의 상태는 해당 객체에 포함되어 모든 객체와 변수가 가질 수 있는 전체 상태의 부분 집합이다. 부분 집합인 이유는 변수를 통해 객체의 상태를 정의하고자 할 때, 해당 객체가 실제로 "소유하는" 데이터만을 기준으로" 삼아야 하기 때문이다.
- 대부분의 경우 소유권과 캡슐화 정책은 함께 고려하는 경우가 많다. 캡슐화 정책은 내부에 객체와 함께 상태 정보를 숨기기 때문에 객체의 상태에 대한 소유권이 있다. 특정 변수에 대한 소유권을 갖고 있기 때문에 특정 변수의 상태가 올바르게 유지되도록 조절하는 락 구조가 어떻게 움직이는지에 대해서도 소유권을 갖는다.
- 소유권이란 말은 통제권이라는 말과 비슷한 의미를 갖지만, 특정 변수를 객체 외부로 공개하고 나면 해당 변수에 대한 통제권을 어느 정도 잃는다.
- 클래스는 일반 메소드나 생성 메소드로 넘겨받은 객체에 대한 소유권을 갖지 않는다는 게 일반적인 모양이지만, 넘겨받은 객체의 소유권을 확보하도록 메소드를 특별하게 작성하면 소유권을 확보할 수도 있다.
- "소유권 분리" 는 컬렉션 클래스를 놓고 볼 때 컬렉션 내부의 구조에 대한 소유권은 컬렉션 클래스가 갖고, 컬렉션에 추가되어 있는 객체에 대한 소유권은 컬렉션을 호출해 사용하는 클라이언트 프로그램이 갖는 구조이다.
4.2. 인스턴스 한정
- 객체를 적절하게 캡슐화하는 것으로도 스레드 안전성을 확보할 수 있는데, 이런 경우 흔히 '한정' 이라고 단순하게 부르기도 하는 '인스턴스 한정' 기법을 활용하는 셈이다. 특정 객체가 다른 객체 내부에 완벽하게 숨겨져 있다면 해당 객체를 활용하는 모든 방법을 한눈에 확실하게 파악할 수 있고, 따라서 객체 외부에서도 사용할 수 있는 상황보다 훨씬 간편하게 스레드 안전성을 분석해 볼 수 있다.
데이터를 객체 내부에 캡슐화해 숨겨두면 숨겨진 내용은 해당 객체의 메소드에서만 사용할 수 있기 때문에 숨겨진 데이터를 사용하고자 할 때에는 항상 지정된 형태의 락이 적용되는지 쉽고 정확하게 파악할 수 있다.
- 객체 내부에서 사용할 목적으로 한정되어 있는 데이터는 사용 범위 밖으로 유출되면 안 된다. 특정 클래스 인스턴스에 한정( private 지정된 변수 )시키거나, 블록 내부에 한정( local 변수 )시키거나, 특정 스레드에 한정( 특정 스레드 내부에서는 이 메소드에서 저 메소드로 넘어다닐 수 있지만, 다른 스레드로는 넘겨주지 않는 객체 ) 시킬 수도 있다.
- 범위가 다르다 해도 한정된 객체는 제한된 범위를 벗어나서는 안 된다. 물론 객체가 알아서 한 곳에서만 있을 수는 없고, 개발자가 충분히 주의를 기울여야 한다.
- 인스턴스 한정 기법은 클래스를 구현할 때 스레드 안전성을 확보할 수 있는 가장 쉬운 방법이다. 인스턴스 한정 기법을 사용하면 동기화를 위해 락을 적용하는 방법도 마음대로 선택할 수 있다.
- Collections.synchronizedList 와 같은 팩토리 메소드들은 기본 클래스에 스레드 안전성을 확보하는 방법으로 대부분 데코레이터 패턴( Decorator Pattern ) 을 활용하며, 이런 팩토리 메소드의 결과로 만들어진 래퍼클래스는 기본 클래스의 메소드를 호출하는 연동 역할만 하면서 그와 동시에 모든 메소드가 동기화되어 있다.
- Java API 문서에도 보면 스레드 안전성을 제대로 확보하려면 래퍼 클래스를 통하지 않고 원래 객체에 직접 접근해 사용하는 일은 없어야 한다고 설명한다.
- 반복 객체(iterator)나 내부 클래스 인스턴스를 사용하면서 공개한다면 한정됐어야 할 객체를 간접적으로 외부에 유출시킬 가능성이 있다.
인스턴스 한정 기법을 사용하면 전체 프로그램을 다 뒤져보지 않고도 스레드 안전성을 확보하고 있는지 쉽게 분석해 볼 수 있기 때문에 스레드에 안저한 객체를 좀 더 쉽게 구현할 수 있다.
4.2.1. 자바 모니터 패턴
- 자바 모니터 패턴을 따르는 객체는 변경가능한 데이터를 모두 객체 내부에 숨긴 다음 객체의 암묵적인 락으로 데이터에 대한 동시 접근을 막는다.
- 자바 모니터 패턴의 가장 큰 장점 가운데 하나는 바로 간경함이다. 자바 모니터 패턴은 단순한 관례에 불과하며 일정한 형태로 스레드 안전성을 확보할 수만 있다면 어떤 형태의 락을 사용해도 무방하다.
- 객체 자체의 암묵적인 락( 또는 외부에서 사용할 수 있도록 공개되어 있는 락) 을 사용하기 보다는 락으로 활용하기 위한 private 객체를 준비해 두면 여러 가지 장점을 얻을 수 있다. 만약 락이 객체 되부에 공개되어 있다면 다른 객체도 해당하는 락을 활용해 동기화 작업에 함께 참여할 수 있다.
4.2.2. 예제 : 차량 위치 추적
- 외부에서 변경 가능한 데이터를 요청할 경우 그에 대한 복사본을 넘겨주는 방법을 사용하면 스레드 안전성을 부분적이나마 확보할 수 있다. 하지만 성능에 문제가 발생할 수 있다. 추가적으로 복사하도록 구현한다면, 외부에서 가져간 정보는 바뀌지 않는다는 점을 알아둬야 한다. 외부 프로그램에서 뭘 하려고 하는지에 따라 장점이 되거나 또는 단점이 될 수도 있다.
4.3. 스레드 안전성 위임
- 단순히 Map 인스턴스를 Collections 클래스의 unmodifiableMap 메소드로 감싸는 것으로는 deepCopy 의 기능을 다하지 못하는데, unmodifiableMap 은 컬렉션 자체만 변경할 수 없게 막아주며 그 안에 보관하고 있는 객체의 내용을 손대는 것은 막지 못하기 때문이다. HashMap 의 생성자에 HashMap 을 넘겨 복사하는 기능도 앞서 설명한 것과 동일하게, 값을 직접 복사하는 게 아니라, 참조를 복사하기 때문에 올바른 결과를 얻을 수 없다.
- 위임( Delegate ) 는 스레드 안전성을 내부의 특정 클래스가 처리하도록 하는 것을 말한다. ( 예를 들면 AtomicLong )
4.3.1. 예제 : 위임 기법을 활용한 차량 추적
4.3.2. 독립 상태 변수
- 위임하고자 하는 내부 변수가 두 개 이상이라 해도 두 개 이상의 변수가 서로 '독립적'이라면 클래스의 스레드 안전성을 위임할 수 있는데, 독립적이라는 의미는 변수가 서로의 상태 값에 대한 연관성이 없다는 말이다.
- CopyOnWriteArrayList 는 리스너 목록을 관리하기에 적당하게 만들어져 있는 스레드 안전한 List 클래스.
4.3.3. 위임할 때의 문제점.
- 의존성 조건을 위배하는 상태라면, 각각의 변수가 모두 스레드 안전한 클래스라고 하더라도 전체적으로 스레드 안전성을 잃을 수 있다. 두 개 이상의 변수를 사용하는 복합 연산 메소드를 갖고 있다면 위임 기법만으로는 스레드 안전성을 확보할 수 없다. 이런 경우에는 내부적으로 락을 활용해서 복합 연산이 단일 연산으로 처리되도록 동기화해야 한다.
클래스가 서로 의존성 없이 독립적이고 스레드 안전한 두 개 이상의 클래스를 조합해 만들어져 있고 두 개 이상의 클래스를 한번에 처리하는 복합 연산 메소드가 없는 상태라면, 스레드 안전성을 내부 변수에게 모두 위임할 수 있다.
4.3.4. 내부 상태 변수를 외부에 공개.
상태 변수가 스레드 안전하고, 클래스 내부에서 상태 변수의 값에 대한 의존성을 갖고 있지 않고, 상태 변수에 대한 어떤 연산을 수행하더라도 잘못된 상태에 이를 가능성이 없다면, 해당 변수는 외부에 공개해도 안전하다.
4.3.5. 예제 : 차량 추적 프로그램의 상태를 외부에 공개
4.4. 스레드 안전하게 구현된 클래스에 기능 추가.
- 현재 만들어져 있는 클래스를 사용하는 것은 좋은 방법이다. 이미 만들어져 있는 클래스를 사용하면 개발에 필요한 시간과 자원을 절약할 수 있고, 개발할 때 오류가 발생할 가능성도 줄어들고 ( 이미 사용 중인 기능은 충분히 테스트가 끝났다고 볼 수 있다. ), 유지보수 비용도 절감할 수 있다. 대부분 필요한 기능이 상당 부분 구현되어 있고 일부는 찾을 수 없는 정도에서 그치는 경우가 많다. 그러면 필요한 기능을 구현해 추가하면서 스레드 안전성도 계속해서 유지하는 방법을 찾아야 한다.
- 단일 연산 하나를 기존 클래스에 추가하고자 한다면 해당하는 단일 연산 메소드를 기존 클래스에 직접 추가하는 방법이 가장 안전하다. 하지만 외부 라이브러리를 가져다 사용하는 경우에는 라이브러리의 소스코드를 갖고 있지 않을 수도 있고, 소스코드를 갖고 있다 해도 자유럽게 고쳐 쓰지 못할 경우가 많다. 만약 소스코드를 갖고 있고, 수정할 수 있다고 해도 이미 만들어져 있는 클래스의 동기화 정책을 정확하게 이해하고 추가하고자 하는 메소드도 정확한 방법으로 동기화시켜야 한다. 기능을 추가하고자 할 때 기존의 클래스에 새로운 메소드를 추가하면 단일 클래스 내부에서 동기화를 맞출 수 있기 때문에 구현이나 유지보수 입장에서 쉬운 것은 사실이다.
- 기능을 추가하는 또 다른 방법은 기존 클래스를 상속받는 방법인데, 이 방법은 기존 클래스를 외부에서 상속받아 사용할 수 있도록 설계했을 때나 사용할 수 있다.
- 기존 클래스를 상속받아 기능을 추가하는 방법은 기존 클래스에 직접 기능을 추가하는 방법보다 문제가 생길 위험이 훨씬 많다. 동기화를 맞춰야 할 대상이 두 개 이상의 클래스에 걸쳐 분산되기 때문이다. 만약 상위 클래스가 내부적으로 상태 변수의 스레드 안전성을 보장하는 동기화 기법을 약간이라도 수정한다면 그 하위 클래스는 본의 아니게 적절한 락을 필요한 부분에 적용하지 못할 가능성이 높기 때문에 쥐도 새도 모르게 동기화가 깨질 수 있다. ( Vector 클래스의 경우에는 동기화 기법이 클래스 정의 문서에 명시되어 있기 때문에 이런 문제를 별로 걱정하지 않아도 된다. )
4.4.1. 호출하는 측의 동기화
- Collections.synchronizedList 메소드를 사용해 동기화시킨 ArrayList 에는 위에서 소개했던 두 가지 방법, 즉 기존 클래스에 메소드를 추가하거나 상속받은 하위 클래스에서 추가 기능을 구현하는 방법을 적용할 수 없다. 동기화된 ArrayList 를 받아간 외부 프로그램은 받아간 List 객체가 synchronizedList 메소드로 동기화되었는지를 알 수 없기 때문이다. 클래스를 상속받지 않고도 클래스에 원하는 기능을 추가할 수 있는 세 번째 방법은 도우미 클래스를 따로 구현해서 추가 기능을 구현하는 방법이다.
- 제 3의 도우미 클래스를 만들어 사용하려는 방법을 올바르게 구현하려면 클라이언트 측 락( client-side lock ) 이나 외부 락( external lock ) 을 사용해 List 가 사용하는 것과 동일한 락을 사용해야 한다. Vector 클래스와 Collections.synchronizedList 메소드에 대한 문서를 읽어보면 Vector 클래스 자체나 synchronizedList 의 결과 List 를 통해 클라이언트측 락을 지원한다는 것을 알 수 있다.
- 특정 클래스를 상속받아 원하는 기능을 단일 연산으로 추가하는 방법은, 락으로 동기화하는 기능을 여러 개의 클래스에 분산시키기 때문에 그다지 안정적이지 못한 방법이라고 한다면, 제 3의 클래스를 만들어 클라이언트 측 락 방법으로 단일 연산을 구현하는 방법은 특정 클래스 내부에서 사용하는 락을 전혀 관계없는 제 3의 클래스에서 갖다 쓰기 때문에 훨씬 위험해 보이는 방법이다. 락이나 동기화 전략에 대한 내용을 정확하게 구현하고 공지하지 않은 클래스를 대상으로 클라이언트 측 락을 적용하려면 충분히 주의를 기울여야 한다.
- 클라이언트 측 락은 클래스 상속과 함께 봤을 때 여러 가지 공통점, 예를 들어 클라이언트나 하위 클래스에서 새로 구현한 내용과 원래 클래스에 구현되어 있던 내용이 밀접하게 연관되어 있다는 등의 공통점이 있다. 하위 클래스에서 상위 클래스가 캡슐화한 내용을 공개해버리는 것처럼 클라이언트 측 락을 구현할 때도 캡슐화되어 있는 동기화 정책을 무너뜨릴 가능성이 있다.
4.2.2. 클래스 재구성
- 기존 클래스에 새로운 단일 연산을 추가하고자 할 때 좀 더 안전하게 사용할 수 있는 방법이 있는데, 바로 재구성(composition) 이다.
- Composition 을 사용하면, 내부 클래스와 다른 수준에서 락을 활용하면서, 해당 클래스의 락을 사용해 동기화하기 때문에 내부 클래스가 스레드 안전한지 아닌지는 중요하지 않고 신경 쓸 필요도 없다. 심지어는 불러다 사용한 클래스가 내부적으로 동기화 정책을 뒤바꾼다 해도 신경 쓸 필요가 없다. 물론 이런 방법으로 동기화 기법을 한 단계 더 사용한다면 전체적인 성능의 측면에서는 약간 부정적인 영향이 있을 수도 있지만, Composition 에서 사용한 동기화 기법은 이전에 사용했던 클라이언트 측 락 등의 방법보다 훨씬 안전하다.
4.5. 동기화 정책 문서화하기.
- 클래스의 동기화 정책에 대한 내용을 문서로 남기는 일은 스레드 안전성을 관리하는데 있어 가장 강력한 방법 가운데 하나라고 볼 수 있다. ( 그리고 가장 많이 배척하고 사용하지 않는 방법이기도 하다. )
구현한 클래스가 어느 수준까지 스레드 안전성을 보장하는지에 대해 충분히 문서를 작성해둬야 한다. 동기화 기법이나 정책을 잘 정리해두면 유지보수 팀이 원활하게 관리할 수 있다.
- 동기화 정책은 전체적인 프로그램 설계의 일부분이며 반드시 문서로 남겨야 한다. 당연한 말이지만 설계와 관련한 여러 가지 내용을 결정짓기에 가장 좋은 시점은 바로 설계 단계이다.
- 동기화 정책을 구성하고 결정하고자 할 때에는 여러 가지 사항을 고려해야 한다. 어떤 변수를 volatile 로 지정할 것인지, 어떤 변수를 사용할 때는 락으로 막아야 하는지, 어떤 변수는 불변 클래스로 만들고 어떤 변수를 스레드에 한정시켜야 하는지, 어떤 연산을 단일 연산으로 만들어야 하는지를 따져봐야 한다. 물론 이런 사항 가운데 일부분은 단순히 구현상의 문제라고 볼 수도 있으며, 이런 내용에 대해서는 유지 보수 팀에서 봐야할 문서 정도만 필요할 수도 있다. 하지만 어떤 부분은 동기화 기법이 외부에도 영향을 미치기 때문에 클래스를 설계하는 문서에 그 내용을 명확하게 표시해야 한다.
- 최소한 클래스가 스레드 안전성에 대해서 어디까지 보장하는지는 문서로 남겨야 한다. 클래스가 스레드에 안전한가? 락이 걸린 상태에서 콜백 함수를 호출하는 경우가 있는가? 클래스의 동작 내용이 달라질 수 있는 락이 있는지? 이런 질문에 대해서 라이브러리 사용자가 아무렇게나 추측하는 위험한 상황을 만들지 않는 게 좋다. 만약 외부에서 여러분이 개발한 클래스를 놓고 클라이언트 측 락을 사용하지 못하게 하려면 그렇게 해도 좋다. 하지만 클라이언트 측 락을 사용할 수 없다고 적어놓아야 한다.
- 여러분이 개발한 클래스에 단일 연산을 추가하고자 한다면 어떤 락으로 동기화해야 안전하게 구현할 수 있는지에 대해서 문서로 알려줘야 한다. 내부적으로 사용하는 특정 상태 변수를 락으로 동기화시켰다면 유지보수 인력이 알아볼 수 있도록 적어둬야 한다. 방법도 아주 간단하다. 자바 5부터 사용할 수 있는 @GuardedBy 등의 어노테이션(annotation)만 활용해도 훌륭하다. 동기화를 맞출 때 사용하는 아주 작은 기법이라도 반드시 적어두자. 후임자나 유지보수 인력에게는 굉장히 큰 도움이 된다.
4.5.1. 애매한 문서 읽어내기
- 표준이 명확하지 않으면 개발자는 명확하지 않은 부분을 모두 '추측'할 수밖에 없다. 이왕 추측하는 김에 좀 더 잘 추측하는 방법이 있징 않을까? 스펙에 명확하게 정의되어 있징 않은 부분을 좀 더 근접하게 추측하려면 스펙을 작성하는 사람의 입장에서 생각해야 한다.
- 단일 스레드 환경이라면 특정 객체를 만들 의미가 없기 때문에 설계 과정에서 멀티스레드 환경을 염두에 뒀다고 추측할 수 있고, 따라서 표준에서 직접적으로 언급하지는 않았지만 특정 객체는 스레드 안전성을 확보하도록 구현되어 있다고 추측할 수 있다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
[Java] 병렬 프로그래밍 - 작업 실행 (0) | 2012.08.07 |
---|---|
[Java] 병렬 프로그래밍 - 구성 단위 (0) | 2012.08.03 |
[Android/안드로이드] Android Compatibility Package (0) | 2012.07.27 |
[Android/안드로이드] dispatchTouchEvent vs. onInterceptTouchEvent vs. onTouchEvent vs. OnTouchListener.onTouch 누가 먼저 불릴까? (4) | 2012.07.19 |
[Android/안드로이드] 디바이스 별 플리킹 min distance & min velocity 구하기. (0) | 2012.07.17 |
댓글