iBatis와 DBCP는 Java 기반의 웹서비스에서 많이 사용하고 있는 프레임워크들이다. 이 글에서는 이 프레임워크에서 흔히 접하기 힘든 부분들을 집중적으로 살펴봄으로써 잘 알려지지 않은 세밀한 부분의 이야기를 해보겠다. 참고로 여기서는 iBatis-2.3.0-677, commons-dbcp-1.2.2 소스를 기준으로 설명한다.


최동순 dongsoon.choi@nhn.com|오픈 소스 프로젝트들에 관심이 많은 개발자로 NHN 게임 개발 센터에서 장애 대응 업무를 맡고 있다. 장애 원인 파악 과정에서 다양한 오픈 소스 프레임워크들을 접하게 되었고 이 글은 그 과정에서 알게 된 내용들을 정리한 것이다. “내 사전에 원인 불명 장애는 없다”는 마음가짐으로 오늘도 열심히 살아가고 있다.

iBatis속에 숨은 JDBC 이야기

iBatis Statement Pooling

iBatis는 모든 쿼리를 prepared statement로 실행시킨다. 기본적인 statement pool도 제공한다. 일반적인 statement와 비교해 prepared statement의 장점은 다들 알고 있는 것처럼 반복 실행 시 준비과정 없이 바로 실행해 애플리케이션 입장에서 좀 더 빠른 응답을 받을 수 있고 DBMS 입장에서는 CPU 사용율을 낮출 수 있다는 것이다. 우리는 흔히 statement pooling은 JDBC 3.0부터 지원하며 JDBC 드라이버에 의해서만 이루어지는 것으로 알고 있지만 iBatis의 경우 기본값으로 JDBC 드라이버의 statement pool을 사용하기 전에 내부적으로 사용하는 SessionScope 객체에서 java.util.Map을 이용해 prepared statement를 캐싱한다(<리스트 1>, <리스트 2> 참조). 하지만 scope가 같은 세션(iBatis의 세션)으로만 한정되는 문제 때문에 실제적인 효용성은 JDBC 드라이버 단에서의 statement pool을 이용한 캐싱보다 떨어진다고 볼 수 있다.

<리스트 1> iBatis의 SqlExecutor.java
private PreparedStatement prepareStatement(SessionScope session, Connection conn, String sql, Integer rsType) throws SQLException {

if (session.hasPreparedStatementFor(sql)) {
return session.getPreparedStatement((sql));
} else {
PreparedStatement ps = conn.prepareStatement
(sql, rsType.intValue()
,ResultSet.CONCUR_READ_ONLY);
session.putPreparedStatement(delegate, sql, ps);
return ps;
}
}

<리스트 2> iBatis의 SessionScope.java
public class SessionScope extends BaseScope {

private boolean commitRequired;
private Map preparedStatements;

public boolean hasPreparedStatement(String sql) {
return preparedStatements.containsKey(sql);
}

DBCP를 Statement Pool로 활용

위에서 언급한 것처럼 statement pooling은 JDBC 3.0 스펙에 추가된 내용이다. 따라서 JDBC 드라이버가 3.0 스펙을 지원하지 않으면 사용할 수 없다. 하지만 JDBC 2.0 스펙만 지원하는 JDBC 드라이버를 사용할 때라도 커넥션 풀로 DBCP를 사용하고 있다면 poolPreparedStatements를 true 옵션을 설정해서 DBCP를 커넥션 풀뿐만이 아닌 statement pool로도 사용할 수 있다. 이럴 경우는 반드시 maxOpenPreparedStatements 옵션을 같이 사용해 커넥션 당 풀링할 prepared statement의 적절한 개수를 설정해줘야 한다. 그렇지 않을 경우 런타임 시 Out of Memory 등의 에러가 발생할 수 있다.

iBatis의 Auto Transaction
JDBC 커넥션은 기본값으로 autoCommit 모드가 true인 상태로 동작한다. 하지만 iBatis는 JDBC 커넥션의 autoCommit 모드를 false로 설정하고 모든 메소드를 트랜잭션 기반으로 실행시킨다. iBatis의 Transaction 객체의 init 메소드를 보면 <리스트 3>과 같다.

<리스트 3> iBatis의 JDBCTransaction.java
private void init() throws SQLException, TransactionException {
// Open JDBC Transaction
connection = dataSource.getConnection();

// AutoCommit
if (connection.getAutoCommit()) {
connection.setAutoCommit(false);
}

}

현재 커넥션의 autoCommit 모드를 확인해 true면 무조건 false로 설정하는 것이다. 이렇게 JDBC 커넥션의 autoCommit 모드를 false로 설정하고 트랜잭션에 대한 관리는 iBatis의 TransactionManager가 담당하게 된다(iBatis 가이드 문서의 auto transaction).

iBatis의 TransactionManager가 트랜잭션을 관리하는 방식은 단순히 SessionScope 객체가 가지고 있는 boolean 타입의 commitRequired 변수를 통해서다. 

commitRequired의 기본값은 false이고 select 문이 실행될 경우는 해당 값을 변경하지 않는다. 대신 insert, update, delete 문들이 실행될 때에는 해당 값을 true로 설정해 <리스트 4>처럼 트랜잭션이 commit 될 수 있도록 한다. 실제적으로 ibatis에서 select 문 같은 경우는 실행된 후에 DBMS로 commit/roll back 등이 날아가지 않는다. 다만 내부적으로 유지하는 트랜잭션의 state 정보만 STATE_COMMITTED로 변경된다.

<리스트 4> ibatis의 TransactionManager.java
public void commit(SessionScope session) throws SQLException, TransactionException {
Transaction trans = session.getTransaction();
TransactionState state = 
session.getTransactionState();

if (session.isCommitRequired() || forceCommit) {
trans.commit();
session.setCommitRequired(false);
}
session.setTransactionState
(TransactionState.STATE_COMMITTED);
}

그렇다고 트랜잭션이 시작만 되고 끝나지 않은 상태에서 DBCP로 반환되는 것을 걱정할 필요는 없다. iBatis가 커넥션을 사용하고 DBCP로 반환할 때는 commons-pool이 가지고 있는 GenericObjectPool의 returnObject() 메소드를 호출하게 되는데 이 호출의 흐름은 결국 PoolableObjectFactory의 passivate Object() 메소드를 호출하게 되고 passivatieObject() 메소드는 <리스트 5>처럼 현재 커넥션의 autoCommit 모드가 false이고 readOnly 모드가 아닐 경우 rollback() 메소드를 호출한다. 그리고 다음 if 문에서 JDBC 커넥션의 autoCommit 모드를 true로 설정한다. 즉 이 부분에서 앞서 실행된 select 문에 대한 롤백이 호출돼 해당 트랜잭션이 정리될 수 있는 것이다. 이 부분이 흔히 DBA와 이야기하게 되는 애플리케이션에서 보낸 적이 없는 공 롤백이 발생되는 부분이다.

<리스트 5> DBCP의 PoolableObjectFactory.java
public void passivateObject(Object obj) throws Exception {
if(obj instanceof Connection) {
Connection conn = (Connection)obj;
if(!conn.getAutoCommit() && !conn.isReadOnly()) {
conn.rollback();
}
conn.clearWarnings();
if(!conn.getAutoCommit()) {
conn.setAutoCommit(true);
}
}
if(obj instanceof DelegatingConnection) {
((DelegatingConnection)obj).passivate();
}
}

정리하면 JDBC 커넥션은 기본값으로 autoCommit=true 모드이고 iBatis에 의해 autoCommit=false 모드로 동작한다. 하지만 DBCP에 반환될 때는 다시 autoCommit=true 모드로 설정해 반환된다. 결국 DBCP가 풀링하는 JDBC 커넥션은 모두 autoCommit=true 모드 상태인 것이다.

난해한 DBCP의 속성 이야기아파치 Commons 프로젝트의 서브 프로젝트인 DBCP는 내부적으로 commons-pool을 사용하는 커넥션 풀이다. 커넥션 풀은 요즘 웹 애플리케이션에서 필수적으로 사용되는 대표적인 컴포넌트지만 DBCP 속성 값들이 다른 커넥션 풀들의 속성 값들과는 차이가 있어 각 속성의 의미를 직관적으로 이해하는 것이 쉽지 않다. 그렇기에 DBCP를 제대로 사용하기 위해서는 DBCP의 각 속성의 의미를 제대로 이해하는 것이 필요하다고 보고 각 속성에 대해 설명해보겠다. 먼저 DBCP의 내부구조를 살펴보자.

커넥션 생성은 DBCP에서 이루어진다. DBCP는 Poolable Connection 타입의 커넥션을 생성하고 생성한 커넥션을 ConnectionEventListener에 등록한다. ConnectionEvent Listener는 애플리케이션이 사용한 커넥션을 풀로 반환시키기 위해 JDBC 드라이버가 호출할 수 있는 callback 메소드를 가지고 있다. 이렇게 생성된 커넥션은 commons-pool의 addObject 메소드를 통해서 커넥션 풀에 추가된다. 이때 Commons-pool은 <그림 1>처럼 내부적으로 현재 시간을 담고 있는 Timestamp와 추가된 커넥션의 레퍼런스를 한 쌍으로 하는 ObjectTime stampPair라는 자료구조를 생성한다. 그리고 이들을 LIFO(Last In First Out) 형태의 CursorableLinkedList로 관리한다.

<그림 1> ObjechTimestamp의 구조

commons-DBCP의 속성 의미먼저 DBCP의 초기화와 관련된 옵션들부터 확인해 보자.

- maxActive : 서비스에서 동시에 사용될 수 있는 최대 커넥션 개수 
- maxIdle : 커넥션 풀에 유지될 수 있는 idle 상태 커넥션의 최대 개수로, 그 이상의 커넥션들에 대해서는 반환 시에 커넥션 풀로 반환되는 것이 아니라 제거(real destroy)된다.
- minIdle : 커넥션 풀에 유지하고 있어야 하는 idle 상태 커넥션의 최소 개수로, DBCP는 evictor 스레드를 통해 적어도 minIdle 개수만큼은 유지시킨다.
- maxWait : 이미 커넥션 풀에서 사용 중인 커넥션의 개수가 maxActive 개수인 경우 getConnection 요청은 설정된 maxWait만큼 기다리게 된다. 만약 maxWait 후에도 사용할 수 있는 여분의 커넥션이 없을 경우는 ‘Cannot get a connection...’ 같은 에러를 발생시키게 된다.

<그림 2>는 maxActive와 maxIdle이 각각 8인 상황에서 numActive, numIdle이 각각 4인 경우를 나타낸다.

<그림 2> DBCP의 초기화 관련 속성

다음으로 커넥션의 유효성 검사를 위한 테스트와 관련된 옵션들을 확인해 보자. 

JDBC 커넥션의 유효성은 validationQuery 옵션에 설정된 쿼리를 실행해 확인할 수 있다. 그러므로 아래와 같은 세 가지 테스트 옵션을 사용하고 싶다면 반드시 validation Query에 하나 이상의 결과를 반환하는 쿼리를 설정해야 한다.

- testOnBorrow: pool에서 커넥션을 얻어올 때 테스트 실행, 기본 값은 true
- testOnReturn: pool로 커넥션을 반환할 때 테스트 실행, 기본 값은 false
- testWhileIdle: evictor 스레드가 실행될 때 (timeBetweenEviction RunMillis > 0) pool 안에 있는 idle 상태의 커넥션을 대상으로 테스트 실행, 기본 값은 false

마지막으로 실제 커넥션 풀을 우리가 설정한 옵션대로 관리해주는 Evictor 스레드와 관련된 속성들을 확인해 보자.

Evictor는 <그림 3>처럼 timeBetweenEvictionRunMillis에 설정된 시간 간격마다 수행되는 스레드로 크게 세 가지의 역할을 담당한다. 첫 번째는 커넥션 풀 내의 idle 상태의 커넥션 중에서 오랫동안 사용되지 않은 커넥션을 추출해 제거한다. Evictor 실행 시 설정된 numTestsPerEvictionRun 개수만큼 Cursorable LinkedList의 ObjectTimestampPair를 확인해 ObjectTime stampPair의 Timestamp 값과 현재 시간의 Timestamp 값의 차이가 minEvictableIdleTimeMillis를 초과할 경우 해당 커넥션을 제거한다. 두 번째는 testWhileIdle 옵션이 true일 경우 첫 번째 작업 시 minEvictableIdleTimeMillis를 초과하지 않은 커넥션에 대해서 추가로 유효성 검사(validationQuery)를 수행해 문제가 있을 경우 해당 커넥션을 제거한다. 세 번째는 지금까지의 두 가지 조건의 추출 작업으로 커넥션 풀에 커넥션의 개수가 초기 설정된 minIdle 개수보다 작을 경우 minIdle 개수만큼 커넥션을 생성해 유지시키는 역할을 수행한다.

<그림 3> Evictor 관련 속성

지금까지 알아본 속성에 근거해서 testWhileIdle=true && timeBetweenEvictionRunMillis > 0인 조건일 경우 Evictor 스레드의 동작 방식을 살펴보자.

- 주어진 timeBetweenEvictionRunMillis 시간 간격으로 evict() 메소드 실행
- evict() 메소드 실행 시 minEvictableIdleTimeMillis 시간과 커넥션의 timestamp 값을 비교해서 evict 실시 & validationQuery를 실행해 커넥션의 validation 체크
- ensureMinIdle() 메소드 실행: pool에 있는 커넥션 개수가 minIdle보다 작을 경우 커넥션을 새로 생성해 minIdle 개수를 맞춤

Evictor 스레드는 동작 시에 커넥션 풀(GenericObjectPool)에 락을 걸고 동작하기 때문에 너무 자주 실행하면 서비스 실행에 부담을 줄 수 있다. 또한 numTestsPerEvictionRun의 값을 크게 설정할 경우 Evictor 스레드가 검사해야 하는 커넥션 개수가 많아져서 락을 점유하고 있는 시간이 길어지게 되므로 역시 서비스 실행에 부담을 줄 수 있다. 게다가 커넥션 유효성 검사를 위한 testXXXX 옵션을 어떻게 설정하느냐에 따라 애플리케이션의 안정성 및 DBMS의 부하가 달라질 수 있다. 그러므로 Evictor 스레드와 testXXXX 옵션을 사용할 때는 DBA와 상의해서 설정하는 것이 좋다.

maxWait! 기다리기만 하면 되는 거야?

maxWait의 부적절한 설정은 일반적인 상황에서는 큰 문제가 되지 않지만 사용자가 갑자기 급증한다거나 DBMS에 장애가 발생된 경우 3∼4급 장애를 1∼2급 장애로 확대시킬 수 있어 주의가 요구된다. 이에 대해 좀 더 자세히 살펴보도록 하자. 이 글에서 다루는 내용의 DBCP 설정은 maxActive=5, maxIdle=5, minIdle=5로 가정한다.

TPS(Transaction Per Seconds)

<그림 4> 사용자 요청의 처리과정

상황을 단순화시켜 사용자의 요청 A는 <그림 4>와 같이 하나의 요청이 10개의 쿼리를 실행시킨다고 가정하자. 또한 각 쿼리의 평균 실행 시간은 50ms라고 가정하면 전체 10개의 쿼리 실행 시간은 500ms가 되고 결국 요청에 대한 최종 응답 시간은 500ms라고 생각할 수 있다. 물론 요청에 대한 응답을 주기 위해 다른 컴포넌트들도 시간을 소비하게 되지만 무시 가능할 정도의 값이라고 생각해 제외했다.

위와 같은 가정에서 시스템 전체의 TPS를 대략적으로 산출해 보면 <그림 5>와 같다. 요청 하나의 응답시간이 500ms이므로 커넥션 풀에 이용 가능한 idle 상태의 커넥션이 5개인 상황에서는 동시에 5개의 요청을 500ms 동안 처리하게 된다. 따라서 1초 동안에는 10개의 요청을 처리할 수 있고 성능 지수는 10TPS라고 볼 수 있다.

<그림 5> 커넥션 풀의 커넥션 개수가 5인 경우

TPS와 커넥션 개수와의 관계 
커넥션의 개수가 TPS와 밀접한 관계가 있는 것은 <그림 6>처럼 처리할 요청 수가 증가하게 되더라도 커넥션 풀의 커넥션 개수가 5개인 이상 10TPS 이상의 성능을 낼 수 없게 되기 때문이다. 1번부터 5번까지의 요청이 실행되고 있는 동안은 커넥션 풀에 여분의 커넥션이 없기 때문에 6번부터 10번까지의 요청은 wait 상태가 돼 여분의 커넥션이 생길 때까지 설정한 maxWait 값만큼 기다리게 된다.

<그림 6> Waiting으로 인한 성능 저하 발생

이를 해결하는 가장 쉬운 방법은 <그림 7>처럼 단순히 커넥션 풀의 커넥션 개수를 늘려주면 된다. 

<그림 7> 커넥션 개수 증가로 인한 TPS 증가

커넥션의 개수를 5에서 10으로 증가시키면 전체적인 성능도 10TPS에서 20TPS로 증가된다. 하지만 일반적으로 DBMS의 리소스는 다른 서비스들과 공유해 사용하는 경우가 많기 때문에 무조건 커넥션 개수를 크게 설정할 수 없는 상황이 많다. 따라서 예상 접속자 수 및 서비스 과정에서의 실 부하를 측정해 최적의 값을 설정하는 것이 중요하다. 그러므로 <그림 6>의 wait 값을 적당하게 조절해주는 것이 무한히 커넥션 개수를 증가시키지 않고 최적의 시스템 환경을 구축하는 데 중요한 역할을 한다. 즉 ‘maxWait로 어떤 값이 설정됐는지’가 일시적인 과부하 상태에서 드러나는 시스템의 전체적인 견고함을 결정짓는 것이다.

그렇다면 적당한 maxWait의 값은 얼마일까? 이 부분을 이해하기 위해서는 DBCP 이외의 톰캣의 동작방식도 고려해야 한다. 톰캣은 스레드 기반으로 동작해 사용자의 요청을 처리한다. DBCP가 커넥션 풀을 가지고 있는 것처럼 톰캣도 내부에 스레드 풀(wait set)을 가지고 있어 <그림 8>처럼 사용자의 요청이 들어올 때마다 스레드 풀에서 하나씩 스레드를 꺼내 요청을 처리한다.

<그림 8> 톰캣 스레드의 waiting

우리가 중점적으로 살펴볼 부분은 1∼5번의 요청이 처리되기 전에 또 다른 요청이 들어오게 되면서부터 시작된다. 즉 동시에 6개의 요청이 들어왔을 경우 <그림 8>처럼 6번 요청은 여분의 커넥션이 없으므로 maxWait 값만큼 기다리게 된다. 여기서 중요한 사실은 기다리는 주체가 톰캣의 스레드라는 점이다.

만약 maxWait의 값이 10000ms이라면?

초당 10TPS의 처리량을 지닌 시스템은 그 이상의 요청에 대해서는 결국 해당 스레드들이 10초 동안 wait 상태가 된다. 이런 식으로 사용자의 요청이 증가하면 결국 톰캣 스레드 풀의 모든 스레드는 소진돼 톰캣은 아래와 같은 에러를 출력하며 먹통이 될 것이다.

심각: All threads (512) are currently busy, waiting. Increase maxThreads (512) or check the servlet status

더욱 억울한 것은 결국 10초 동안의 wait 상태가 풀리고 커넥션을 획득해 사용자의 요청을 열심히 처리하고 응답을 보내도 그 응답을 받을 사용자는 이미 떠나고 난 뒤라는 점이다. 클릭 후 2∼3초 내에 반응이 없으면 새로 고침이나 다른 페이지로 이동하는 것이 보통인 인터넷 사용자의 패턴을 생각해 보면 쉽게 이해가 되리라. 결국은 기다리는 사람도 없는 요청에 대한 응답 처리를 위해 쓸데없는 낭비만 한 셈이 된다. 단순화시키면 사용자 입장에서 1초가 넘는 maxWait 값의 설정은 아무런 의미가 없다는 결론을 얻을 수 있다.

만약 maxWait의 값이 5ms라면?

그럼 반대로 너무 작게 설정되어 있을 경우는 어떤 문제가 발생할까? 여러분의 상상대로다. 과부하 시 커넥션 풀에 여분의 커넥션이 없을 때마다 바로 바로 ‘Cannot get a Connection...’ 에러가 리턴될 것이고 사용자들은 너무 빈번히 에러 페이지를 보게 될 것이다.

마무리는 장애 확산 방지 대책
결론은 DB 관련 이슈로 톰캣의 스레드를 너무 오랫동안 잡고 있으면 톰캣까지 장애가 확대되고 결국 1∼2급 장애로 발전하게 되므로 DBCP 입장에서는 차라리 에러가 발생되더라도 빨리 톰캣의 스레드들을 릴리즈할 수 있어야 한다는 것이다. 10TPS의 처리량을 지닌 시스템이 견딜 수 없도록 사용자가 갑작스럽게 증가하게 되면 결국 어떠한 설정으로도 장애를 피할 수는 없겠지만 적절한 DBCP 옵션 설정만으로도 3∼4급 장애가 1∼2급 장애로 확대되는 것을 막을 수 있다고 본다.

마치며
지금까지 다룬 내용들은 정상적인 서비스 환경에서는 크게 도움이 되지 않을 수도 있다. 하지만 장애 발생 시 원인 파악을 위해 서비스에서 사용하고 있는 프레임워크들의 기본 구조와 동작 방식을 파악하고 있는 것은 분명 큰 도움이 될 것이다.

iBatis의 maxXXXX 옵션들
일반적으로 잘 모르는 maxXXXX 시리즈의 옵션이 하나 더 있다. 바로 iBatis 에서 사용하고 있는 옵션으로 sqlMapConfig 파일의 settings에 속성으로 설정한다.

<리스트 6> sqlMapConfig.xml의 settings 샘플<settings
        cacheModelsEnabled=”true”
        enhancementEnabled=”true”
        lazyLoadingEnabled=”true”
        maxRequests=”512”
        maxSessions=”128”
        maxTransactions=”32”
        useStatementNamespaces=”true”
      />

- maxRequest : 한꺼번에 SQL문을 수행할 수 있는 스레드의 최대 개수
- maxSessions : 주어진 시간동안 활성될 수 있는 세션(iBatis)의 최대 개수
- maxTransactions : 한꺼번에 SqlMapClient.startTransaction()에 들어갈 수 있는 스레드의 최대 개수

위의 세 가지 옵션은 수정하더라도 반드시 maxRequests > maxSessions >= maxTreansactions를 만족시켜야 한다. 

iBatis는 설정한 maxSessions 이상의 session이 생성되는 것을 막기 위해 Throttle 객체를 guarded suspension 패턴으로 구현하고 있다. 톰캣 쪽에 설정하는 maxThreads, iBatis 쪽에 설정하는 maxSessions, DBCP쪽에 설정하는 maxActive는 모두 비슷한 목적으로 사용되고 있는 옵션들이다. 

세 가지 옵션들의 관계는 간단히 <그림 9>와 같이 설명할 수 있다(maxThreads=6, maxSessions=4, maxActive=2 로 가정).

<그림 9> maxXXX 시리즈 옵션들

톰캣의 max Threads가 6으로 설정되어 있을 경우 사용자의 요청에 대해서 동시에 6개의 TP-Processor 스레드만 사용하게 된다. 

iBatis의 maxSessions가 4로 설정되어 있을 경우 1~4의 요청을 처리하는 스레드는 계속 작업을 진행하게 되지만 5, 6번의 요청을 처리하는 스레드는 설정한 maxSessions 개수를 넘었기 때문에 각각 Throttle. increment() 메소드에서 순차적으로 Throttle 객체의 wait set에서 wait하게 되며 1~4의 요청을 처리하는 스레드가 작업을 마치고 notify해 주기를 기다리게 된다.

maxSessions 범위 안의 1∼4의 요청을 처리하는 스레드는 executeQuery 부분에서 getConnection()을 하게 되는데 이때 DBCP의 maxActive가 2로 설정되어 있을 경우에도 1, 2의 스레드만 커넥션을 획득해 작업을 계속 진행하게 되고 3, 4번 요청을 처리하는 스레드는 maxWait만큼 waiting하며 여분의 커넥션이 생기길 기다리게 된다.

정상적인 환경에서는 1, 2번 요청에 대한 처리가 완료되면 커넥션을 풀로 반환하여 3, 4번이 커넥션을 획득하여 남은 작업을 처리하게 되고 1, 2번의 요청에 대한 DB 작업이 완료되었으므로 각각 LOCK.notify()를 호출해 Throttle 객체의 wait set에 있는 스레드를 깨워서 5, 6번 요청을 처리하는 스레드들이 자신의 작업을 처리할 수 있게 되는 방식이다. 하지만 maxRequest, maxSessions, maxTransactions 옵션들의 경우 그 효용성보다 구조적인 버그가 존재해서인지 2.3.1 버전 이후부터는 제거되었다.


출처 - http://www.imaso.co.kr/?doc=bbs/gnuboard.php&bo_table=article&wr_id=40288



Posted by linuxism
,