제 홈페이지의 모든 글은 anti-nhn license에 따릅니다.



대용량 서비스 - 3. 캐쉬

속도 향상에 관련된 여러 가지 방법 중 캐쉬에 대한 이야기를 하려고 합니다. 저는 ehcache를 사용했습니다. ehcache에 관한 문서는 많습니다. 자세한 사용법은 구글형한테 물어보세요^^

1. 캐쉬 서버 구성

캐쉬서버를 별도로 둘 것인지 아니면 개별 컨테이너(tomcat, jboss 등등) 에서 직접 메모리에서 관리할 것인지, 또 서버는 몇 대나 둘 것인지를 고려해야 합니다.

별도의 캐쉬 서버를 두는 것은 비교적 좋은 서버 1대에 캐쉬 정보만 모아서 처리하면 되기 때문에 캐쉬 관리가 비교적 용이합니다. 하지만, 컨테이너 서버와 데어터 통신이 일어나며, 컨테이너 서버에서는 네트워크를 통해 읽어온 데이터를 다시 메모리에 올려서 결과를 처리해야 하기 때문에 캐쉬 성능은 상대적으로 떨어집니다. ( 물론, 그래도 일반적으로 디비 서버에서 데이터를 읽어오는 것보다는 훨씬 좋습니다.)

개별 컨테이너에서 관리하게 될 경우는 보통 메모리에 있는 객체를 바로 사용하기 때문에 네트워크 작업 및 데이터를 사용할 때 마다 메모리에 별도로 올려야 하는 일이 없기 때문에 속도가 빠릅니다. 하지만 문제가 생겼을 때 캐쉬에 관련된 문제인지 프로그램 문제인지 파악하는 게 상대적으로 더 어렵습니다.

캐쉬 서버를 별도로 두건, 개별 컨테이너에서 캐쉬를 관리하건 간에 서버가 여러 대일 경우에는 데이터를 공유하는 방식도 고려해야 합니다. 데이터가 서로 맞아야 하기 때문입니다.

다수의 서버간의 데이터 전파는 전체 전파와 변경 사항만 전파 두 가지로 나뉩니다. 전체 전파는 데이터가 생성, 삭제, 변경 될 때마다 전파하는 방식이고, "변경 사항만 전파" 하는 방식은 데이터의 삭제와 변경 만 전파하는 방식입니다. 변경 사항만 전파하는 경우에는 각각의 서버가 알아서 데이터를 생성하고 삭제나 변경 등이 있을 때만 다른 서버에 알려주는 방식입니다.

관리 편의를 위해서 변경은 따로 두지 않는 것을 권장합니다. 캐쉬란 말 그대로 원천 데이터가 아닌 속도를 빠르게 하기 위해서 쓰는 원천 데이터의 복사본일 뿐입니다. 따라서 캐쉬를 삭제하고 그 캐쉬가 필요한 경우에는 원천 데이터에서 다시 조회해 오면 됩니다. 캐쉬의 변경은 프로그램을 굉장히 복잡하게 하며, 일반적으로 크케 성능을 향상시키는데 도움이 되지도 않습니다. 캐쉬의 내용 변경보다는 그냥 캐쉬 삭제를 하는 게 관리가 훨씬 용이합니다.


2. 캐쉬의 live time

캐쉬의 생존 시간에 대한 문제를 얘기해 봅시다.

1초에 1번씩 요청되는 데이터가 있을 때, 캐쉬의 생존 주기를 10초라고 칩시다. 그러면 대략 캐쉬의 hit rate는 90%가 될 겁니다. (11초 동안 11번의 요청이 있을 것이고, 첫번째 요청에 대해서는 캐쉬에서 데이터를 가져올 수 없을 것이고, 이 후의 10번의 요청은 캐쉬에서 데이터를 가져올 것입니다. 즉 11 번의 요청 중 10번은 캐쉬에서 가져오게 됩니다.)
이 때 live time을 10 배로 늘여봤자, hit rate는 99%가 됩니다. 시간을 10배로 늘여도 캐쉬 효율은 고작 9% 향상될 뿐입니다. (보통 시간을 10배로 늘이면, 메모리 사용량은 거의 10배가 늘어납니다. )

따라서 메모리가 정말 충분하지 않으면, 짧은 시간이라도 캐쉬를 사용하는 게 효율을 극도로 향상 시킬 수 있습니다.
반대로 live time을 마구 늘인다고 캐쉬의 효율이 마구 증가하지도 않습니다. 적절한 메모리 사용량을 체크해서 적절하게 캐쉬의 갯수 및 live time을 조절해야 합니다.


3. 데이터의 출처가 다양할 경우는 data chain을 만들자.

사용자의 GPS 정보를 가져와서 우편번호 정보로 변환해 주는 것을 생각해 봅시다. 구글 같은데 때려 보면 그런 정보들이 나옵니다.

매번 구글에 요청하는 것은 무리입니다.( 구글에 너무 많이 요청하면 막힙니다.) 그래서 구글에서 조회한 데이터는 디비에 저장을 하기로 했습니다.(캐쉬는 일반적으로 메모리입니다. 용량이 넘칠 수 있으니 가능하면 메모리가 아닌 영속적으로 저장할 수 있는 Database와 같은 것이 필요할 겁니다.) 그래서 3가지 방법(캐쉬, 디비, 구글 통신) 으로 데이터를 조회할 수 있습니다.

데이터 조회 정책은 다음과 같습니다.

1. 캐쉬를 뒤진다. - 있으면 데이터 넘기고 끝
2. 없으면 디비를 뒤진다. - 있으면 데이터를 가져와서 캐쉬에 저장하고 끝
3. 없으면 구글에 때린다. - 정보 조회가 성공적이면, 데이터를 디비에 저장하고, 캐쉬에도 저장하고 끝


2번 입장에서는 디비에서 조회를 해왔든 구글 가서 가져왔든 캐쉬에 저장한다는 것은 동일합니다.

이것을 chain of responsibility 패턴을 이용해서 정리하면 깔끔해집니다.

각각의 chain은 다음과 같이 정의 됩니다.


체인 이름 조회 방법 이전 단계 성공시 하는 일
CacheChain캐쉬에서 조회조회된 정보 캐쉬에 저장
DatabaseChainDB에서 조회조회된 정보 디비에 저장
GoogleChain구글에 데이터 요청없음

요청 데이터는 위치 정보입니다. 아래와 같이 정의합니다.( gpsLat, gpsLng 이란 위 경도 값을 가지며 Serializable을 implements 하고 있으며, getter 가 있습니다. Serializable 은 캐쉬에 저장될 때 필요하기 때문에 지정했습니다.)

public class Location implements Serializable {
    private static final long serialVersionUID = 1L;
    private final BigDecimal gpsLat;
    private final BigDecimal gpsLng;
    public Location(BigDecimal gpsLat, BigDecimal gpsLng) {
        this.gpsLat = gpsLat;
        this.gpsLng = gpsLng;
    }
    public BigDecimal getGpsLat() {
        return gpsLat;
    }
    public BigDecimal getGpsLng() {
        return gpsLng;
    }
    //equals , hashCode 잘 구현
}

Chain에서 하는 일을 추상화 시켜보면, 자기 체인에서 데이터를 가져오는 것이전 단계에서 성공시 뭔가를 할 수 있는 것 두 가지가 필요합니다.

따라서 체인은 아래와 같이 정의합니다.(generics 부분은 복잡해 질 수 있으므로 일단 대충...)

public abstract class DataChain<K, V> {
private DataChain<K, V> nextChain;
public DataChain<K, V> setNextChain(DataChain<K, V> nextChain) {
this.nextChain = nextChain;
return nextChain;
}
public final V get(K key){
V v = getChainData(key);
//조회에 실패했고 다음 체인이 있으면..
if (v == null && nextChain != null) {
v = nextChain.get(key);
//다음 체인에서 조회 성공시 할 일을 한다.
if (v != null) {
onSuccess(v);
}
}
return v;
}
protected abstract V getChainData(K key);
protected void onSuccess(V value){
}
}

외부로 공개되는 public method 2개 protected method 2개입니다.
setNextChain 은 일반적인 chain of resposiblity 에 등장하는 setNext 입니다.
그리고 get 이 데이터를 가져오는 메쏘드입니다.

protected로 정의된 것은 2개가 있는데 getChainData 는 자기 체인에서 데이터를 가져오는 방법을 정의하면 됩니다.
onSuccess는 다음 체인에서 데이터를 성공적으로 가져왔을 때 처리하는 로직을 넣으면 됩니다.


대략 코드는 아래와 같이 됩니다.

-- 캐쉬용 체인
public class CacheChain extends DataChain<Location, String>{
protected String getChainData(Location key) {
//캐쉬에서 데이터 뽑아서 리턴
}
protected void onSuccess(String value) {
//캐쉬에 데이터 저장
}
}

-- 디비용 체인
public class DataBaseChain extends DataChain<Location, String>{
protected String getChainData(Location key) {
//디비에서 데이터 뽑아서 리턴
}
protected void onSuccess(String value) {
//디비에 데이터 저장
}
}

-- 구글용 체인
public class GoogleChain extends DataChain<Location, String>{
protected String getChainData(Location key) {
//구글에서 데이터 뽑아서 리턴
}
//얘는 성공했을 때 자기 체인에서 할 일이 없다.
}

위와 같이 만들고 

DataChain<Location, String> dc = new CacheChain().setNext(new DataBaseChain()).setNext(new GoogleChain());

과 같이 한 번만 만들어 놓고

String zipcode = dc.get(new Location(new BigDecimal(127.312), new  BigDecimal(37.432));

와 같이 호출하면 우편번호를 뽑아낼 수 있습니다. Chain of responsiblity 가 늘 그렇듯, 클라이언트는 어느 체인에서 처리했는지 관심 없습니다.

게다가 구글에 막히면 daum에 때리는 Chain을 추가한다거나 하는 식의 프로그램 변경이 매우 용이합니다. 또 onFail 같은 것을 만들면, default value 를 chain을 이용해서 처리할 수도 있습니다. 아니면 이 디비 저 디비에 데이터가 산재해 있을 경우 (메모리 디비도 있고, 디스크 디비도 있을 때나 master, slave 구조의 replication 등을 쓸 때) 조회하는 순서를 chain의 순서로 정의하면 됩니다.


4. 최대한 캐쉬를 써야하는 것들

무거운 디비 쿼리
count 와 같이 부하를 많이 줄 수 있는 디비 쿼리는 캐쉬를 써서 유지하면 쿼리 갯수를 획기적으로 줄일 수 있습니다.

통신
위에서 설명한 것처럼 구글 같은데 요청을 날리는 것은 비용이 많이 듭니다. 이런 것도 반드시 캐쉬를 써서 성능을 높이는 게 좋습니다.

거의 변경되지 않는 데이터
우편번호 주소 시스템과 같은 경우는 어징간해서는 변경되지 않습니다. 이런 것들은 디비 조회보다 캐쉬에 영속적으로 박아놓고 쓰는 게 훨씬 빠릅니다.
게시판 데이터도 상대적으로 수정이 많은 것 같지만, 쓰기와 읽기 비율이 1 : 10 정도만 되어도 캐쉬를 쓰는 게 훨씬 유리합니다. 총 11번의 요청(1번 쓰기 + 10번 읽기 ) 중 디비에 접속하는 것은 2번 (1번 쓰기 + 1번 읽기) 밖에 안 되기 때문에 산술적으로 디비 접속 횟수가 확 줄어듭니다.(물론, 쓰기와 읽기의 비용이 다르고 그런 것들은 있지만 어쨌거나 읽기 접속 9번은 하지 않아도 되니까요.)


by 삼실청년 | 2011/11/25 13:36 | 컴터질~ | 트랙백 | 덧글(4)

제 홈페이지의 모든 글은 anti-nhn license에 따릅니다.



java.lang.Object 메쏘드 분석 6 - wait , notify

몇 년만에 java.lang.Object에 대한 마지막 글을 씁니다.

toString은 분석 안 할 겁니다. toString은 알아볼 수 있게 잘 구현하면 됩니다. 이클립스의 toString 구현하기 기능을 쓰시면 됩니다.

또 java.lang.Object에 private으로 registerNatives 함수가 있는데, 이것도 분석 안 할겁니다.

wait과 notify는 한 묶음으로 같이 이해하셔야 합니다.


wait은 아래와 같이 3가지 오버로딩된 메쏘드로 있습니다.

    public final void wait() throws InterruptedException               : 누군가 깨울 때까지 기다리겠음. 안 깨워주면 안 일어남.
    public final native void wait(long timeout) throws InterruptedException    : 누군가 깨워주거나 timeout 까지 안 깨워주면 알아서 일어나겠음.
    public final void wait(long timeout, int nanos) throws InterruptedException  : 누군가 깨워주거나 timeout에 nanos 까지 고려한 시간까지 안 깨워주면 일어나겠음. ( 그러나 실제 코드에서 nanos는 크게 의미가 없음.)

포인트는 가운데 놈만 native!! 즉, 첫번째 놈과 마지막 놈은 가운데 놈에 적당히 인자를 넣어서 호출합니다.( 마지막 놈의 원래 의도는 그게 아니었던 것 같지만, 어쨌거나 까보니 그렇게 생겼네요.) 

애들은 전부 기다리기 위한 애들이고, 깨우는 것은 notify()와 notifyAll()로 합니다. notify()는 하나만 깨우는 거고, notifyAll()은 전부 깨우는 겁니다. 그 외에는 비슷하다고 보면 됩니다. 이 후부터는 따로 구분하지 않는 한 notify라고 하면 notifyAll도 똑같다고 보시면 됩니다. 자세한 건 뒤에서 다시 다루겠습니다.



wait의 의미는 "기다린다" 입니다. "무엇이" "무엇을 기준으로" "언제까지" 기다리는 것인지를 명확히 이해해야 합니다.

1. 무엇이?
기다리는 주체는 wait이 호출된 Thread 입니다. wait 코드가 있는 곳에서 Thread.currentThread() 했을 때 나오는 그 Thread.

2. 무엇을 기준으로?
obj.wait() 이 호출되었다면, obj를 기준으로 기다리는 것입니다. 기준이 왜 중요한가 하면 깨우는 기준과 wait하는 기준이 같기 때문입니다.

3. 언제까지?
다음 중 하나가 만족할 때까지입니다.
* timeout 될 때까지.
* notify나 notifyAll로 깨워줄 때까지. ( notify에 대해서는 아래서 자세히 설명할 겁니다.) 여기서 중요한 것은 깨우는 기준이 wait을 가진 instance입니다. 즉, obj.wait() 이라고 호출이 되었으면, obj.notify()가 호출되어야 깨어납니다. 물론 notify()는 다른 Thread에서 호출합니다.
* Thread의 interrupt가 호출 될 때까지. 얘도 notify와 비슷하지만 기준이 다릅니다. 잠자는 Thread를 기준으로 깨웁니다. obj.wait()이 걸려있다고 하더라도 그것을 건 Thread를 기준으로 깨웁니다. 그리고 이렇게 깨우면 위의 두 가지 경우와는 다르게 InterruptedException이 발생합니다. 이건 언젠가 먼 훗날 Thread에 관련된 글을 쓰겠습니다. 다 쓰면 링크 달께요^^
* 기타 있어서는 안 될 상황. Thread.stop() 이나 jvm이 죽는 다거나.. 등등등..


obj.wait을 걸기 위해서는 반드시 그 obj를 기준으로 synchronized 블럭을 만들고 그 안에서 걸어야 합니다.

synchronized (obj) { 
while (뭔가 조건){
obj.wait();
}
}

synchronized 의 obj와 obj.wait()에서의 obj는 같다는 게 포인트죠. 이게 다르면 IllegalMonitorStateException 이 발생합니다. synchronized에 대해서 명확하게 이해하셔야 하며, 잘 모를 때는 요기 클릭!!

wait은 반드시 loop 안에서 쓰라고 합니다. 왜냐하면 여러 개의 Thread에서 접근하기 때문에 항상 조건을 따져봐야 하기 때문입니다. 이 내용은 뒤에 notify와 묶어서 다시 다루겠습니다.



wait set이라는 개념이 있는데, 어떤 object가 소유한 set 이라고 보시면 됩니다.
obj.wait이 호출되면
1. Thread.currentThread() 가 멈춘다.
2. obj의 wait set에 그 Thread가 들어간다.
3. synchronized 로 잡은 monitor의 lock을 푼다. 

로 보시면 됩니다. 왜 obj의 wait set에 들어가냐하면 나중에 notify() 깨울 기준이 되기 때문입니다.

wait이 걸리면 synchronized 블럭에서 일단 빠져나온다고 보시면 됩니다. 즉 모니터를 놔 줍니다. 그리고 notify에 의해서 깨어나면 다시 synchronized 안 으로 들어간다고 보시면 됩니다.


obj.nofiy() 는 obj.wait을 호출해서 obj의 wait set에 박혀서 잠자고 있는 Thread를 깨워줍니다. 여러 놈이 있을 때 notify()는 그 중 아무 놈이나 한 놈을 깨우고, notifyAll()은 전부 깨웁니다.

notify도 마찬가지로 synchronized 블럭 안 에 있어야 합니다.

synchronized (obj) { 
obj.notify();
}

wait과 마찬가지로 같은 monitor!! 입니다.

obj.notify()가 호출되는 시점에는 아직 synchronized 블럭 안이기 때문에 그에 의해서 깨어난 쓰레드는 다시 시작을 못합니다. wait()도 같은 모니터를 가지기 때문에 wait()이 호출된 이후의 코드를 호출하려면 다시 synchronized 구문 안에 들어가야 하는데 notify()가 아직 synchronized 구문을 빠져나오지 않았으므로 아직 실행이 안 되는 겁니다. notify를 실행한 Thread가 synchronized 블럭을 빠져나가는 순간부터 monitor 쟁탈전이 벌어지지만 notify에 의해 깨어난 Thread가 그 mornitor를 쟁취할 수 있을 지는 모르는 일입니다. 아래 코드를 실행시켜 보시면 프로그램이 안 끝나는 사태를 볼 수 있습니다. (여러 번 하다 보면 한 번쯤은 일어날 겁니다.)

public class ThreadTest {
private volatile boolean active = false;
private volatile int i = 0;

public synchronized void mWait() throws InterruptedException {         // 모니터 쟁탈 포인트 1. synchronized method라서.
while (!active) {
wait();   //모니터 쟁탈 포인트 2. 깨어나는 순간 모니터를 다시 획득해야 Thread가 진행됨.
}
active = false;
System.out.println("print\t" + i);
}

public synchronized void mNotify() { // 모니터 쟁탈 포인트 3. 마찬가지로 synchronized method라서.
active = true;
notifyAll();
i++;
System.out.println("after notify\t" + i);
}

private static class WaitCaller extends Thread{
private final ThreadTest a;
public WaitCaller(ThreadTest a) {
this.a = a;
}
public void run() {
try {
a.mWait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private static class NotifyCaller extends Thread{
private final ThreadTest a;
public NotifyCaller(ThreadTest a) {
this.a = a;
}
public void run() {
a.mNotify();
}
}
public static void main(String[] args)  {
final ThreadTest a = new ThreadTest();
final int cnt = 2;
for (int i = 0; i < cnt; i++) {
new WaitCaller(a).start();
}
for (int i = 0; i < cnt; i++) {
new NotifyCaller(a).start();
}
}
}

ThreadTest라는 놈은 wait에서 loop를 위한 active라는 멤버 변수를 가지고 있습니다. 그리고 notify가 호출될 때 마다 값이 증가하는 int로 i 를 가지고 있습니다.
이 ThreadTest에 대한 인스턴스 1개를 만들어서 wait을 호출하는 WaitThread 가 2개 (WaitCaller 1 , 2 라고 가정) 뜨고, notify를 호출하는 NotifyCaller가 2개 (NotifyCaller 1,2)가 뜹니다. 

monitor 쟁탈전이 일어나는 포인트는 총 3개 입니다. 4개의 Thread 중 어떤 놈이 모니터를 쟁취하느냐에 따라 결과가 달라집니다.

        WaitThread 1: 포인트 1에서 모니터 획득.
        WaitThread 1: 포인트 2에서 모니터 상실.(wait에 빠지니까..)
        WaitThread 2: 포인트 1에서 모니터 획득.
        WaitThread 2: 포인트 2에서 모니터 상실.(wait에 빠지니까..)
NotifyThread 1: 포인트 3에서 모니터 획득.
        NotifyThread 2: 포인트 3에서 모니터 획득 실패.
NotifyThread 1: notify 호출하고 i 증가 시키고 할 꺼 다하고 죽음. 당연히 모니터 상실.

여기서 보면 notifyAll로 호출되었기 때문에 WaitThread 1,2 가 모두 포인트 2에서 모니터를 얻으려고 하며, NotifyThread 2도 모니터를 획득하려고 하고 있으므로, NotifyThread 1을 제외한 3개의 Thread가 모니터를 노림.

시나리오 1.
WaitThread 1: 포인트 2에서 모니터 획득. 일 하고 죽고 모니터 놔주고.
WaitThread 2: 포인트 2에서 모니터 획득. 그러나 while 문 때문에 active가 false이므로 모니터 다시 상실하고 다시 wait 상태로.
NotifyThread 2: 포인트 3에서 모니터 획득. 할거 다하고 모니터 놓고 죽음.
WaitThread 2: NotifyThread2 덕에 깨어나서 할 거 하고 죽음.
아래와 같은 결과

after notify 1
print 1
after notify 2
print 2


시나리오 2.
NotifyThread 2: 포인트 3에서 모니터 획득. 할거 다하고 모니터 놓고 죽음.
WaitThread 1: 포인트 2에서 모니터 획득. 일 하고 죽고 모니터 놔주고.
WaitThread 2: 포인트 2에서 모니터 획득. 그러나 while 문 때문에 active가 false이므로 모니터 다시 상실하고 다시 wait 상태로.
아래와 같은 결과 - 안 끝남.. WaitThread 2가 wait 상태에 빠졌는데 더 이상 아무도 안 깨워줌.

after notify 1
after notify 2
print 2


지금 모든 가능한 시나리오를 설명한 것도 아니고 쓰레드도 꼴랑 4개 뿐인데도 마구 복잡해질 기미가 보이고 있습니다.

위 프로그램에서의 문제는 갯수에 관련된 게 없다는 것입니다. wait이 기다리는 조건이 boolean 값이기 때문에 두번 true로 설정해 봤자 1번 true로 설정한 것과 별반 다르지 않지요.

뭔가 좀 비슷하지만 아래 프로그램을 실행시켜보면 멈추지 않습니다. 아래 프로그램은 notify에서 list에 값을 넣고, wait에서 그 list에서 값을 빼내는 프로그램입니다.  위에서 얘기한 시나리오 2가 다음과 같이 달라집니다.

NotifyThread 2: 포인트 3에서 모니터 획득. 할거 다하고 모니터 놓고 죽음.
WaitThread 1: 포인트 2에서 모니터 획득. 일 하고 죽고 모니터 놔주고.
WaitThread 2: 포인트 2에서 모니터 획득. while 문에서 보니 아직 list에 뭔가 남아있으므로 while문 빠져나오고 실행하고 죽고.
데드락 같은 거 안 생김!



import java.util.LinkedList;


public class ThreadTest{
private volatile LinkedList<Integer> list = new LinkedList<Integer>();

public synchronized void mWait() throws InterruptedException {
while (list.isEmpty()) {
wait(1000);
}
Integer i = list.pop();
System.out.println("print\t" + i);
}

public synchronized void mNotify(int i) {
notifyAll();
list.add(i);
System.out.println("after notify\t" + i);
}

private static class WaitCaller extends Thread{
private final ThreadTest threadTest;
public WaitCaller(ThreadTest threadTest) {
this.threadTest = threadTest;
}
public void run() {
try {
threadTest.mWait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private static class NotifyCaller extends Thread{
private final ThreadTest threadTest;
private final int i;
public NotifyCaller(ThreadTest threadTest, int i) {
this.threadTest = threadTest;
this.i = i;
}
public void run() {
threadTest.mNotify(i);
}
}
public static void main(String[] args) throws InterruptedException {
final ThreadTest threadTest = new ThreadTest();
final int cnt = 2;
for (int i = 0; i < cnt; i++) {
new WaitCaller(threadTest).start();
}
for (int i = 0; i < cnt; i++) {
new NotifyCaller(threadTest,i).start();
}
}
}


loop 안에서 wait을 걸어야 하는 이유?
여러 개의 쓰레드 중 wait에 관련된 thread가 있고, notify에 관련된 thread가 있는데, 모두 하나의 모니터 안에서 놀게 됩니다. 따라서 wait에 관련된 thread가 어떤 일을 처리하고 나서 다음에 모니터를 가지는 놈이 또 다른 wait에 관련된 놈이면 또 어떤 일을 처리하려고 할 겁니다.

위의 예제 중 두번째 예제를 보면 while문이 없을 경우에 문제가 생길 수 있습니다.
list에 1개가 있을 때 , WaitCalller 1이 list.pop()을 실행하고, 끝나려는데, WaitCaller 2도 list.pop()을 실행하려고 합니다. 이미 WaitCalller 1이 가져갔기 때문에 WaitCaller 2에서는 에러가 날 수 있습니다.



notify는 가급적 자제 하고 notifyAll을 쓰기를 권장합니다.

notify는 하나의 Thread만 깨웁니다. WaitThread 1,2가 잠자고 있을 때 notifyAll은 한 번만 호출 되어도 두 쓰레드가 모두 깨어나고,  모니터 쟁탈전을 하더라도 어쨌건 둘이 순차적으로 실행은 될 겁니다. 하지만, notify로 깨울 경우 둘 중 하나만 깨어나기 때문에 좀비 Thread가 생길 가능성이 생깁니다. 따라서 가급적 notifyAll()을 쓰는 게 바람직 합니다.

by 삼실청년 | 2011/10/28 00:09 | 컴터질~ | 트랙백 | 덧글(5)

제 홈페이지의 모든 글은 anti-nhn license에 따릅니다.



대용량 서비스 만들기 - 2. Thread safe vs none Thread safe

Thread 관련된 문제는 항상 강조하는 게 "버그 잡기 힘들다." 입니다. 
Thread 관련 얘기 중 첫번 째로 Thread safe에 대해서 시작해보겠습니다.

1. 원자성

원자성이란 CPU가 처리하는 하나의 단일 연산을 말합니다.
int a=1; 과 같은 코드는 원자성입니다.
int a=1+1; 도 원자성입니다.
그러나
a++;은 원자성이 아닙니다.
a++은 a= a+1 과 같은 의미인데, (a값을 읽는다) + (읽은 값에 1을 더해서 a에 대입한다.) 라는 2개의 원자성 연산의 조합입니다.
원자성을 가진 연산이 2개 이상 있을 때 그것은 원자성이 보장되지 않습니다. 아래의 코드를 실행시켜보세요.

public class AtomicTest {
 private int a=0;
 public int incrementAndGet(){
  return a++;
 }
 public static void main(String[] args) {
  final AtomicTest test = new AtomicTest();
  for (int i = 0; i < 100; i++) {
   new Thread(){
    public void run(){
     for (int j = 0; j < 1000; j++) {
      System.out.println(test.incrementAndGet());
     }
    }
   }.start();
  }
 }
}

100개의 Thread가 1000번씩 증가시켰으면 100,000이 나와야 할 것 같지만 보통 그 보다 좀 덜 나옵니다.
초기값이 0인 a에 대해 a++연산을 Thread A, B가 처리할 경우,
1. A Thread가 a의 값을 읽음. (a=0)
2. B Thread가 a의 값을 읽음. (a=0)
3. A Thread가 a의 값 계산 후 대입. (a=0+1 = 1);
4. B Thread가 a의 값 계산 후 대입. (a=0+1 = 1);
와 같이 흘러가면 결국 a=1이 됩니다. 두번 돌았으니 2가 되어야 하지만 말입니다.

원자성 연산의 조합은 절대 원자성 연산이 아닙니다.



2. Thread safe

Thread safe란 여러 개의 쓰레드에서 동시에 호출해도 문제가 되지 않는 클래스입니다. Thread safe에 대해서 정확히 알려면, 변수의 scope과 자바의 메모리 관리 등에 대해서 좀 알아야 합니다. 자바 기초 책에 나오는 클래스 변수, 지역 변수 등을 명확히 이해해야 하며, 메인 메모리와 워킹 메모리에 대해서 대략적인 가닥은 잡으셔야 합니다.

Thread safe 하게 프로그램을 만들려면 일반적으로 synchronized 구문을 씁니다.
StringBuffer 와 StringBuilder, Vector 와 ArrayList, Hashtable과 HashMap 같은 것들이 synchronized가 걸려있고 안걸려 있고 정도의 차이만을 보이는 대표적인 클래스들입니다.(물론, 그게 전부는 아니지만 핵심입니다.)


StringBuffer, Vector, Hashtable 등이 자바 초기에 등장한 애들입니다. 얘들은 synchronized가 걸려있는 대표적인 애들입니다. synchronized가 걸리면 안 걸린 것에 비해서 당연히 느립니다. 즉, 정말 synchronized 가 걸려있어야 하는지를 보고 그렇다면 그런 애들을 쓰면 됩니다.

먼저 StringBuffer는 일반적으로 쓸 일이 없습니다. 여러 개의 Thread에서 하나의 String을 조합하는 것은 일반적으로 굉장히 이상한 일입니다. 다만 StringBuffer 자체를 인자로 받는 라이브러리를 쓸 때는 어쩔 수 없지만 일반적으로는 쓸 일이 없습니다. 보통은 StringBuilder를 사용하면 됩니다.

Vector와 Hashtable 등을 쓸 수는 있지만, iterator 등을 돌 때는 주의해야 합니다. iterator를 돌면서 추가 삭제를 하는 것은 위험합니다. 다음 프로그램은 에러를 발생시킵니다.(쓰레드 관련 프로그램이 늘 그렇듯... 안 발생할 수도 있습니다.)

public class VectorIteratorTest {
 public static void main(String[] args) {
  final Vector<Integer> a = new Vector<Integer>();
  for (int i = 0; i < 100; i++) {
   a.add(i);
  }
  
  new Thread(){
   public void run(){
    for (int i = 100; i < 1000; i++) {
     a.add(i);
    }
   }
  }.start();
  
  for (Integer integer : a) {
   System.out.println(integer);
  }
 }
}

Exception in thread "main" java.util.ConcurrentModificationException
 at java.util.AbstractList$Itr.checkForComodification(AbstractList.java:372)
 at java.util.AbstractList$Itr.next(AbstractList.java:343)
 at thread.VectorIteratorTest.main(VectorIteratorTest.java:24)

요런일이 생깁니다. Thread safe 하기 때문에 문제가 안 될 것 같지만, iterator 부분이 문제 입니다. 일단 요기 를 읽어보시고...
전부 단일 연산인 것 같지만 iterator를 도는 게 단일 연산인 것은 아니기 때문에 생기는 문제입니다. 저걸 단일 연산으로 맞추고 싶다면 iterator 바깥부분을 synchronized (a) { } 로 감싸주어야 합니다.


3. 잘못 쓰기 쉬운 라이브러리

date format을 처리하기 위해 SimpleDateFormat 클래스를 씁니다. 이놈은 thread safe 하지 않습니다. new 로 매번 생성하거나 생성된 객체를 clone 떠서 사용하기를 권장합니다.

Jaxb Marshaller는 xsd에서 나온 bean 객체를 이용하여 xml을 만들어낼 때 쓰는 놈인데, 이 놈도 thread safe 하지 않습니다. 게다가 이놈은 생성비용도 굉장히 비쌉니다. 생성 과정을 전부 살펴보진 않아서 정확히는 모르겠지만, 동시에 수백개를 생성하게 되면 생성 속도가 몇 초 단위로 나오더군요. 이건 pool을 사용해서 처리했습니다. jakarta common에 있는 common-pool 을 사용했습니다.

Servlet이나 JSP는 Flyweight 로 구현되었습니다. 즉 instance가 1개이기 때문에 동시에 여러 thread가 접근하면 문제가 생길 수 있습니다. 흔히 디비에서 조회한 결과를 멤버 변수에 저장하는 실수를 범하곤 하는데, 인스턴스가 1개이기 때문에 엉뚱한 사람이 그 결과를 보게 될 수 있습니다. 즉 Servlet이나 JSP는 Thread safe하게 제작되어야 합니다.!

다음 예제는 잘못된 서블릿입니다.


public class WrongServlet extends HttpServlet {

private static final long serialVersionUID = 1L;

    private String userName;


protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

fillUserName(request);

response.getWriter().write(userName);

}

private void fillUserName(HttpServletRequest request) {

userName = (String) request.getSession().getAttribute("USER_NAME");

}

}


여러 개의 request가 들어오면 A thread가 set한 userName을 B thread가 읽어 들일 수 있습니다. 



4. Thread 관련된 것은 꼭 테스트를!!


Thread에 관련된 문제는 말씀드렸다시피 잘못 짜도 일반적으로 발생하지 않습니다. 어느 순간 발생하기도 하기 때문에 잡아내기가 정말 힘듭니다. 결과의 정합성에 대해서 "Thread 차원에서" 테스트를 반드시 거치기 바랍니다.


언젠가 Lock에 대해서 다룰 예정인데, 역시 테스트가 굉장히 중요합니다. lock 관련 문제는 잘 나가다가 어느 순간 뻗어버리는 일을 유발할 수 있습니다. 자세한 건 다음에...

by 삼실청년 | 2011/10/27 21:59 | 트랙백 | 덧글(3)

제 홈페이지의 모든 글은 anti-nhn license에 따릅니다.



대용량 서비스 만들기 - 1. 적당한 데이터 클래스

프로그램에서는 데이터를 다룰 일 들이 참 많습니다. 필요에 따라 적절한 데이터 타입을 선언하는 게 매우 중요할 수 있습니다.

1. 모여야만 의미가 있는 것은 클래스로 만들자.

위도와 경도 데이터가 있다고 칩시다. 이는 어떤 위치을 표현하기 위한 것인데, 위도만으로도 경도만으로도 의미가 없습니다. 게다가 위도와 경도는 생김새도 매우 비슷합니다.(float이나 BigDecimal 등으로 표현 가능한 값들입니다.) 즉, 바꿔 쓰는 실수를 하기가 쉬운 경우입니다.
따로 따로 의미를 가질 수 없는 경우는 이런 데이터를 담을 수 있는 클래스를 별도로 만드는 게 좋습니다.

대략 다음과 같은 클래스가 정의가 될 겁니다.

import java.math.BigDecimal;

public final class Location {
 private final BigDecimal lat; //위도
 private final BigDecimal lng;  //경도

 public Location (BigDecimal lat, BigDecimal lng) {
  this.lat = lat;
  this.lng = lng;
 }

 public BigDecimal getLat() {
  return lat;
 }

 public BigDecimal getLng() {
  return lng;
 }
}

이렇게 만들면 따로 따로 쓰는 것보다 훨씬 안전합니다.

아래와 같은 함수 호출 스택을 타고 처리하는 과정이 있다고 가정합시다. 아래에서 Request 클래스는 lat과 lng의 최초값을 가지는 클래스라고 칩니다. HttpServletRequest 같은 걸 생각하시면 됩니다.
foo1 (Request req) -> foo2 (BigDecimal lat, BigDecimal lng) ->  ... -> fooN (BigDecimal lat, BigDecimal lng)

fooN 에서 lat과 lng이 바뀐 것을 발견했습니다. 과연 어디서 잘못된 것일까요? foo1 부터 fooN까지 다 뒤져봐야 합니다. 그러나, 함수가 다음과 같이 되어있다고 가정하면 얘기가 다릅니다.

foo1 (Request req) -> foo2 (Location point)  -> ...-> fooN (Location point)

fooN에서 잘못되었다면,foo1에서 Location 클래스를 만들 때 잘못 만들었거나 fooN에서 거꾸로 받아썼거나 둘 중 하나 입니다. (물론 중간에 Location 객체를 이상하게 만들어 내서 인자를 바꿔서 전달하는 것도 가능은 하지만 별로 현실적인 얘기는 아닙니다.)

즉, 프로그램이 잘못되었을 때 점검해야 할 포인트가 확 줄어듭니다.

이런 데이터 클래스를 만들 때는 다음 사항에 주의해야 합니다.

1. hashCode와 equals 구현이 필요한 지 고려해야 합니다.
HashMap이나 캐쉬 등에서 키로 사용될 여지가 있을 경우 구현해야 합니다. 이클립스의 기능을 이용하면 쉽습니다. 소스에서 오른쪽 클릭 -> Source -> Generate hashCode and equals  를 누르면 손 코딩 안해도 됩니다.

2. Serializable을 고려해야 합니다.
일반적인 데이터 객체는 하드디스크나 디비에 저장되거나 외부와 통신에 사용될 여지가 있습니다. 이럴 경우 Serializable을 구현해주어야 합니다. 아...Serializable은 한번 꼭 정리해야 할 것 같은데... 나중에 정리되면 링크 달겠습니다.ㅜㅜ

3. Cloneable을 고려해야 합니다.
어떤 데이터 객체는 복사되어 값이 살짝 수정되어 쓰인다거나 할 일이 있습니다. 또는 Prototype 패턴으로 객체가 생성되는 케이스도 있을 수 있습니다. 자세한 것은 요기를 참고해주세요.^^

4. 가능하면 Immutable로 만드는 게 좋습니다.
이건 요기를 참고해주세요.  

5. toString 을 반드시 구현하세요.
예제는 코드가 길어져서 생략했지만, 로그 등을 찍을 때 toString 을 구현하는 게 매우 좋습니다. 

2. Map 이나 List 등 Container 사용을 자제하자.

위와 같은 Location을 맵으로 만들면 다음과 같이 될 겁니다. 아래에서 request는 대충 뭔가 요청을 담고 있는 클래스의 인스턴스라고 칩니다.

Map<String, BigDecimal> location = new HashMap<String, BigDecimal>();
location.put("lat" , request.getParameter("lat"));
location.put("lng" , request.getParameter("lng"));

조금 전에 언급한 사항 들이 대부분 해결되었습니다. hashCode나 equals는 HashMap이 알아서 해줄 것이고, Serializable도 해결되었고, Cloneable도 해결되었습니다.(Cloneable 은 변수 타입을 Map 이 아니라 HashMap으로 하면 됩니다.)
Immutable 문제만 빼고는 깔끔하게 해결된 듯하지만 위와 같은 코드는 바람직하지 않습니다.

1. 데이터 클래스는 변경이 용이합니다.
기존의 Location 클래스에 주소 정보까지 넣는 걸로 바꾸려고 합니다. 시도/구군/동면 등의 정보를 넣으려고 하면 Location 객체를 쓸 경우 Location 객체 안에서만 바꿔주면 됩니다.(위와 같은 경우 Location 클래스의 Constructor에서 주소정보를 가져오는 부분을 추가하고 각각의 단위 주소에 대해서 getter 들을 만들어 주면 됩니다.)  하지만 Map을 쓸 경우 저렇게 만들어지는 클라이언트 코드를 전부 찾아서 바꿔야 합니다.
이것보다 더 큰 문제는 특정 필드를 제거할 때 생깁니다. 필드를 제거하게 되면 클래스에서는 멤버 변수 1개와 getter 1개 정도 제거하면 될 것이고, 제거된 함수를 쓰는 부분이 어딘지는 금방 알아낼 수 있습니다.(이클립스가 다 결켜줍니다.^^ compile 에러니까요.)
하지만 Map을 쓰면 ............................ 그냥 제거하지 마십시오..........

2. 데이터 클래스는 에러를 더 빨리 잡아낼 수 있습니다.
각각의 필드에 대해서 명확한 getter 메쏘드가 존재합니다. 하지만 Map을 쓰게 되면, get("lat") 과 같이 인자로 던져줘야 하는데 인자가 String이므로 오타가 나더라도 프로그램 실행을 해봐야 알 수 있습니다. Compile 타임에 에러를 잡아낼 수 있는 것과 Runtime에 에러를 잡아낼 수 있는 것의 차이가 생깁니다.

3. Map은 데이터의 일관성을 보장해 주지 않습니다.
Map은 <키, 값> 구조이므로 상황에 전혀 적절치 않는 데이터가 들어가는 것도 허용합니다. 위와 같은 코드에 location.put("owner", new BigDecial("8001011234567)); 과 같이 그 위치의 건물에 대한 주인의 주민번호를 BigDecimal로 표현한 이상한 값도 넣을 수 있습니다. 그나마 generic이 있기 때문에 이렇지 generic마저 없었으면... 매우 끔찍한 사태가 발생할 수도 있습니다.
또, 상황에 따라 get("뭐시기")를 했을 때 있을 수도 없을 수도 있습니다.반드시 뭔가 들어가 있다는 것을 보장해주지 않습니다. 이에 반해 데이터 클래스를 사용하면 Constructor에서 유효성 검사를 할 수 있습니다.

4. 속도도 느려지고 메모리 낭비가 생깁니다.
Map에서 데이터를 뽑는 게 결코 데이터 클래스의 getter보다 빠를 수 없고, Map은 어떤 식으로든 성능을 위해서 일단 데이터 클래스보다 더 많은 메모리를 확보합니다.


이보다 더 황당한 다음과 같은 코드도 있습니다.

List<BigDecimal> location = new ArrayList<BigDecimal>();
location.add(request.getParameter("lat"));
location.add(request.getParameter("lng"));

0번째 값은 위도 1번째 값은 경도로 쓰는 겁니다. 절대...절대.. 이러지 마십시오..

Container는 일반적으로 generic을 사용해 표현이 가능한데 <String> 과 같이 변수의 타입만 설정합니다. String은 사람의 이름을 표시할 수도 있고, 나라 이름을 표시할 수도 있습니다. Container를 사용할 때는 generic에 들어갈 것이 변수 타입이 아니라 표현할 데이터가 무엇인지를 정확하게 정의할 수 있을 때만 써야합니다.
즉, Map<String으로 표현된 사람이름, String으로 표현된 나라이름> 과 같이 generic 안의 타입뿐 아니라 타입이 의미하는 바가 정확히 무엇이다라고 정의할 수 있는 경우에만 Container를 사용하셔야 합니다.

3. 일반적으로 init함수는 틀렸다.

위와 같은 Location 클래스에서 주소 정보를 가져오는 로직이 들어갔다고 칩시다.(매우 무겁게도 구글 같은데에 통신을 해서 정보를 조회해야 할 겁니다.)
이런 경우 init 함수를 만들고 그 함수를 호출하면 구글에 통신해서 주소 정보를 세팅하도로 프로그램을 하는 경우가 많습니다. 하지만 주소 값이 반드시 필요하다면, Constructor에서 하면 됩니다.

주소값이 경우에 따라 필요하다면 다음 두 가지 해결 방법이 있습니다.

1. 주소에 관련된 getter에서 위치값이 설정되었는 지 확인합니다. 다음과 같은 코드가 가능합니다.

 private String address;
 public String getAddress() {
  if (address == null) {
   setAddressUsingHttp();
  }
  return address;
 }

 private void setAddressUsingHttp() {
     // 뭔가 통신해서 address라는 값을 세팅해주는 로직
 }

2. 또 다른 데이터 클래스를 만듭니다. 이 경우 기존의 Location 클래스를 Builder 처럼 이용하면 됩니다.

public final class Location{
 private final BigDecimal lat;
 private final BigDecimal lng;
 public Location(BigDecimal lat, BigDecimal lng) {
  this.lat = lat;
  this.lng = lng;
 }

 ... getter 들
 
 public LocationWithAddress getLocationWithAddress(){
  return new LocationWithAddress();
 }
 
 public class LocationWithAddress{
  private final String address;
  
  private LocationWithAddress(){
   // Constructor 안에서 http를 이용해서 lat, lng 으로 address를 읽어와서 세팅..
  }

  //Location 클래스에도 있는 method들..
  public BigDecimal getLat() {
   return lat;
  }
  public BigDecimal getLng() {
   return lng;
  }
 }
}


위와 같이 하면 LocationWithAddress라는 클래스의 생성자가 private 이므로 외부에서 생성이 불가능합니다. 오로지 Location을 통해서만 생성 가능하기 때문에 뭔가 잘못되었을 때 찾아내기가 쉽습니다.

보통의 init 함수는 대부분 Constructor에서 해결 가능한 것입니다. 게다가 일반적으로는 init을 호출하지 않으면 뭔가 문제가 생기도록 init 함수를 만드는데 이는 프로그램을 복잡하게 만들며,에러 가능성도 증대시킵니다.

그러면 servlet 이나 servlet filter 등에서도 init을 쓰는데 이것은 바른 사용일까요?
이 경우는 괜찮습니다. servlet이나 filter는 데이터형 클래스가 아닙니다. 즉, 사용자가 직접 Constructor를 호출한다거나 하는 식으로 instanciate 하지 않습니다. 게다가 모든 lifecycle이 톰캣 등에서 관리가 되기 때문에 이런 경우는 init이 호출되었는지 안 되었는지 등에 대해서 사용자가 신경쓸 게 없습니다.
Constructor는 직접 호출하지 않는 이상 상속이 안 됩니다. 따라서 상속 가능한 init 이라는 함수를 만들어서 쓰는 게 이런 경우는 훨씬 깔끔합니다.



정리하며..

사실 이번 글에서 하고 싶었던 얘기는 General한 프로그램과 Specific 한 프로그램에 대한 얘기였습니다. General 하다는 것은 위에서 얘기한 Map 같은 컨테이너를 쓰는 것과 같은 겁니다. 어징간하면 다 받아주는 거죠. Specific하다는 것은 위에서 Location 클래스와 같은 것을 만들어 쓴다는 것입니다. 딱 지정된 것만 받아들이는 겁니다.
일반적으로 General 함을 더 바람직한 것으로 생각하는 경향이 있는데, 결코 그렇지 않습니다. 기본적으로는 Specific한 케이스를 고려하고 거기서 더 general하게 풀어줄 수 있는 영역을 찾아내야 합니다.

by 삼실청년 | 2011/10/02 18:30 | 컴터질~ | 트랙백 | 덧글(3)

제 홈페이지의 모든 글은 anti-nhn license에 따릅니다.



대용량 서비스 만들기

대용량 서비스를 만드는 데 있어서 필요한 지식과 제가 간과해서 삽질했던 것들 등을 정리해보려고 합니다. java와 MySQL을 사용하였고, 가능하면 Mysql 에 국한되는 내용은 다루지 않을 생각입니다.


제 나름대로 좋은 프로그램을 정의하자면

1. 편리한 사용.
2. 빠른 속도.
3. 좋은 설계.

이 3가지를 뽑습니다. (물론, 에러가 없어야 하고 안정적이어야 하며 데이터는 항상 적합한 데이터가 나와야 합니다. 정말 너무나 당연한 이런 얘기들은 여기서는 다루지 않겠습니다. )

이 세 가지 중 개발자의 영역은 "빠른 속도"와 "좋은 설계"의 영역입니다.
개발자 취향마다 다를 수는 있겠지만, 그 둘 사이의 비중을 3:7 정도로 봅니다.

좋은 설계는 유지 보수성에 대한 얘기이고, 빠른 속도는 알고리즘과 락에 대한 얘기가 주가 될 것입니다.

경우에 따라서 두 가지가 충돌하는 수도 있는데, 일반적으로 저는 유지 보수성을 지키는 방향으로 결정합니다. 속도를 향상시킨다는 것은 결국 코드를 수정한다는 뜻인데 유지 보수성이 현격하게 떨어지는 코드를 수정한다는 것은 정말 미칠 일입니다.

그 둘 사이의 대표적인 충돌이 Builder pattern입니다. 일반적으로 Builder pattern은 비스무레한 객체 하나를 더 만들어내는 것 밖에 안 됩니다. 그냥 bean 객체 같은 거 잘 만들어 놓고 잘 사용하는 것보다 결코 빠를 수 없습니다. 게다가 gc가 버려야 할 객체를 하나 더 만들게 되죠. 하지만 유지보수 측면에서의 장점 때문에 쓰게 됩니다.

시작하기 전에 몇 가지만 말씀드리고 다음 글부터 본격적으로^^

OOP에 대한 기초가 부족한 분은 OOP에 대해서 먼저 제대로 공부하시기 바랍니다. OOP에 대한 부분까지는 본 글에서 설명하지 않습니다. OOP는 결코 초보자에게 쉬운 부분이 아닙니다. 제 경험상 OOP를 이해하고 있다는 사람 중 80%는 이해하지 못하고 있습니다. OOP나 자바의 기초를 결코 간과하지 마십시오.


각종 디자인 패턴에 대한 얘기들을 많이 할 겁니다. 대부분의 디자인 패턴은 성능보다는 좋은 설계를 위한 것입니다.
아쉽게도 프로그램의 세계에서는 만병통치약은 없습니다. 이런 저런 케이스에서 어떤 패턴을 어떻게 쓰는지에 대해서 설명하려 합니다.

by 삼실청년 | 2011/09/29 00:30 | 컴터질~ | 트랙백 | 덧글(4)

◀ 이전 페이지          다음 페이지 ▶