Framework/Database2009. 3. 12. 16:31

이전 글을 작년 7월에 써서 올해 1월 12일날 포스팅을 해서 기억도 잘 안난다 -ㅅ-; (심지어 거기에 나온 내용들은 이미 7년전쯤에 해본 내용이다.)

  public void testStatic() throws Exception {
    DBManager dbm = new MSSQLPoolDBManager("com.microsoft.jdbc.sqlserver.SQLServerDriver", 
         
"jdbc:microsoft:sqlserver://novision:1433;DatabaseName=test", "bleu", "bleu") ;
    Connection conn = null;
    ResultSet rs = null;
    PreparedStatement pstmt = null;

    try {
      conn = dbm.getConnection()
      pstmt = conn.prepareStatement(getProcSQL());
      rs = pstmt.executeQuery()
      populate(rs)
    catch (SQLException ex) {
      throw ex;
    finally {
      closeWithoutException(rs);
      closeWithoutException(pstmt);
      dbm.freeConnection(conn)
    }
  }

  private String getProcSQL() {
    return "select custNo, fixLength from customer_tblc";
  }

  private void populate(ResultSet rowsthrows SQLException {
    int i = 0;

    while (rows.next() && i++ < 10) {
      System.out.print(rows.getString(1));
    }
    System.out.println();

  }

"위 평범한 JDBC 코드는 customer_tblc에서 10건을 가져와서 10건을 화면에 찍는 프로그램이다"
는 사실이 아니라는 걸 얘기 했었다.

위 코드를 default cursor인 Forward Only cursor로 실행하면(빨간색 화살표는 여러번 반복된다는 뜻)


   1. WAS는 쿼리 실행을 요청한다. 
   2-1. DBServer에서는 cusotmer_tblc의 row를 읽는대로 Working Memory에 올린다.
   2-2  동시에 WorkingMemory에 어느정도의 데이타가 쌓이면(대략 2-8k) 바로 WAS에 전송한다.
   2-3  WAS는 패킷을 받아서 받았다는 confirm 메시지를 전송한다. 

   위 2-1 ~ 2-3 과정은 DB가 쿼리의 모든 row를 읽을때까지 반복된다. 
   WAS에서는 사용하는 10건의 row를 받았고 이후의 데이타는 쓸모 없지만 주구장창 모두 받아야 한다. 
   3. WAS는 나머지 데이타도 모두 받아서 버린후에 DB close를 요청한다.
   DB서버는 WorkingMemory를 정리하고 close 된다.

만약 테이블에 단지 20건 정도라면 괜찮지만 1000000건만 되도 99.9999%의 비용을 더 소모하였다.

단점은
  - WAS에서 필요한 양과 상관없이 해당 쿼리의 모든 데이타를 읽어야 한다.(컴퓨터에게 읽는다는 의미는 하드에 있는 바이트 배열을 메모리에 복제하였다는 얘기이다.)
  - WAS에서 필요한 양과 상관없이 모든 데이타를 네트워크에 전송함으로서 막대한 네트워크 부하가 발생한다. 
  - 모든 데이타를 받아야 종료(DB Close) 할수 있다.

장점
   - 읽는대로 바로 바로 전송해버리기 때문에 DB 메모리가 많이 소모되지 않는다.
   - 네트워크에 전송할때 여러 row를 묶어서 패킷으로 전송한다.
   - 커서는 비동기적으로 작동하기 때문에 최소한의 정보 - 현재 커서의 위치 - 만을 유지할 수 있다.

  public void testStatic() throws Exception {
    Connection conn = null;
    ResultSet rs = null;
    PreparedStatement pstmt = null;

    try {
      conn = dbm.getConnection()
      pstmt = conn.prepareStatement(getProcSQL(), ResultSet.TYPE_SCROLL_INSENSITIVE
                                                , ResultSet.CONCUR_READ_ONLY)
;
      rs = pstmt.executeQuery()
      populate(rs)//
    catch (SQLException ex) {
      throw ex;
    finally {
      closeWithoutException(rs);
      closeWithoutException(pstmt);
      dbm.freeConnection(conn);
    }
  }

위와같이 insensitive cursor로 실행하면


  1. WAS는 쿼리 실행을 요청한다.  
  2. DB는 쿼리의 "모든" 결과를 메모리에 snapshot 형태로 만든다. 모든 데이타의 load를 완료하면 Client의 신호를 기다린다
  3. Client(WAS)는 Fetch(rs.next())를 요청한다.
  4. DB는 cursor 정보를 유지하면서 currentRow의 정보를 Client에게 보낸다. 
  5. 패킷(1개의 row만 들어있음)을 받았다는 confirm 메시지를 보낸다.
  3,4,5의 동작을 9번 더 반복한다
  
  6. WAS는 DB close를 요청한다.
  DB는 결과셋과 cursor정보를 메모리에서 삭제하고 연결정보를 close한다.  


ForwadOnly와 달리 3번, 4번, 5번은 비동기적으로 작동한다. 즉 모든 결과셋을 Snapshot형태로 메모리에 모두 올리고 나서야 Client의 Fetch(res.next())를 실행할 수 있다.(이동안 Client은 Hang 상태이다. 역시 이전 글의 네트웨크 모니터링 캡쳐화면과 같이 보도록 하자.)


단점은
  - WAS에서 필요한 양과 상관없이 해당 쿼리의 모든 데이타를 읽어야 한다.(컴퓨터에게 읽는다는 의미는 하드에 있는 바이트 배열을 메모리에 복제하였다는 얘기이다.)
  - DB에서 읽은 모든 Row를 결과셋에 Snapshot 형태로 저장해야 하기 때문에 공유 자원인 DB 메모리가 많이 소모된다. (이 메모리는 재사용되지 않는다. 즉 같은 프로그램을 2번 실행한다는 것은 SnapShot을 2번 유지한다는 것을 뜻한다. )
   - 동기적으로 작동하기 때문에 Client의 fetch 요구시마다 row단위의 데이타 전송이 이루어진다.(패킷의 크기가 작다.)
   - cursor를 해당 프로그램이 종료될때까지(DB close 할때까지) 유지해야 하고 더 많은 정보를 관리해야 한다.
   - 첫번째 Row가 Client에 전송되는 시간이 느리다.


장점 
   - Client가 필요한 양만 네트워크에 전송한다. 


단점이 너무 심각해 보이는 위 두가지 경우는 황당해 보이겠지만 90% 이상의 사이트에서 발생하고 있는 현실이다. 말 그대로 불편한 진실인 것이다. (이 블로그에서 소개하는 DB Framework는 위 방식으로 작동하지 않는다.)


커서의 종류는 보통 ForwardOnly, Static, Keyset, Dynamic 네가지 종류가 있으며 JDBC 코드로는 작성하기는 어렵지만 다음에는 나머지 두개의 커서의 실행과정도 분석해 보자. (앞서 언급했듯이 JDBC에는 커서를 지정한다기 보다 다른 선택에 의해 자동 종속되고 Keyset과 Dynamic가 종속되는 경우는 극히 드물다. 반면에 C#등의 MS 계열은 커서 모드를 설정할 수 있도록 하고 있다. 다만 일부 조건을 만족시켜야 된다.)






'Framework > Database' 카테고리의 다른 글

Framework (Servant)  (0) 2009.03.12
Framework (DBController)  (0) 2009.03.12
Framework (Handler)  (0) 2009.03.08
Framework (Rows)  (0) 2009.03.07
Framework (IQueryable)  (0) 2009.03.06
Posted by bleujin
Framework/Database2009. 3. 8. 20:15

MDL을 구문을 실행하는 execUpdate와 Select를 실행하는 execQuery에 추가로 Select지만 Java의 일반 객체로 바꿔주는 execHandlerQuery가 있다.

앞서 서비스와 서비스의 교환은 중립적인 메시지 교환이라고 했는데 Rows 객체는 실질적으로 중립적인 객체라고 하기 어렵다. JIT 연결과 SQL Exception을 없에는 등 몇가지 점을 수정하긴 했지만 기본적으로 java.sql.ResultSet을 상속받았기 때문이다.

그래서 잠시 딴 생각을 해보자. ResultSet이 왜 필요한가? 클라이언트 서비스에서 DB서비스에 접근할때 response 될때 정말 필요한것은 Value 그 자체이지 특별히 ResultSet 이라는 인터페이스가 필요하지는 않다. 그리고 이러한 중립적인 Value 객체는 java.util에 많이 있다. 또는 EJB 처럼 POJO를 사용할 수도 있다.

그렇다고
bean.setName(rows.getString("name") ;
bean.setEmpNo(rows.getString("empNo") ;
bean.setSal(rows.getString("sal") ;
와 같이 반복적으로 직접 값을 셋팅하는 것은 불편하다.

개인적으로 어려운 걸 쉽게 만들기는 어렵다고 생각한다. 효율적인 SQL을 만드는 것은 어렵다. 이를 단순히 하이버네트식의 객체 인터페이스로 바꾼다고 해서 어려운 SQL을 쉽게 만들기는 아주아주 어렵다고 생각한다. 다만 복잡한걸 간결하게 만들기는 그렇게까지 어렵지 않다. 복잡하다는 것은 과정이 길다는 것을 말하고 이전의 여러 단계로 거쳐야 하는 과정을 몇단계를 줄여줄 수만 있으면 된다. 아직 이에 대한 연구가 행해진 걸 본적은 없지만 대부분의 버그는 어려운 로직보다는 반복적인 로직에서 좀더 많은 버그가 발생하지 않을까 생각한다. 인간은 기본적으로 반복적인 작업에 최적화 되어 있지 못하다. 그런 것은 컴퓨터에 맡기는 게 좀더 좋다.

ResultSet을 특정 Bean의 List로 바꾸는 것은 리플렉션을 이용하면 간단하다. 그리고 운이 좋게도 Apache Open source인 dbutils에 이미 대부분 구현되어 있어서 베껴왔다. 어느 것이나 마찬가지라고 생각하지만 기존의 사례를 살펴보는 것은 항상 도움되는 일이다.

예컨대 이런식으로 사용한다.
 public void testHandler(){
  IQueryable query = dc.createUserProcedure("employee@listBy(?)").addParam(10);
  ObjectWrapper wrapper = dc.getObjectWrapper(query, new BeanListHandler(Employee.class));
  List<Employee> list = (List<Employee>)wrapper.getObject() ;
  for(Employee e : list){
   System.out.println(e);
  }
 }



ResultSetHandle 인터페이스의 handle(ResultSet rs) 메소드 하나만 구현하면 되기 때문에 구조는 매우 간단하다. 앞에서 구조적 중복을 없에주는게 프레임워크라고했는데 여기서의 중복은 소스 증복에 가깝기 때문에 알아채는 것은 쉬운일이다.


ArrayHandler :
ResultSet의 첫번째 Row를 Object[]로 바꿔준다.

ArrayListHandler :
ResultSet의 모든 Row를 Object[]의 List로 바꿔준다.

BeanHandler :
첫번째 Row를 POJO Bean으로 바꿔준다.

BeanListHandler :
모든 Row를 POJO Bean의 List로 바꿔준다.

MapHandler :
첫번째 Row를 key(columnName), value의 Map으로 바꿔준다.

MapListHandler : 
모든 Row를 Map의 List로 바꿔준다.

LimitMapListHandler :
일부 범위의 Row만 Map의 List로 바꿔준다.

ScalarHandler :
첫번째 Row의 특정 column을 값으로 바꿔준다.

SimpleXMLStringBufferHandler :
모든 Row를 XML 형태의 StringBuffer로 바꿔준다.

CSVStoreHandler :
모든 Row를 CSV 파일 형태로 저장한다.


다시 말하자면 프레임워크는 어렵운걸 쉽게 만들어 주기 보단 복잡한걸 간결하게 만들어주는게 우선이다. 어려운걸 쉽게 만들기가 이론적으로 불가능은 아니지만 무척 어렵기 때문에 - 아이러니 하지만 쉽게 만들 수 있다는건 어려운게 아니다. 물론 혁신의 가능성을 부정하는 것은 아니지만.. - 그런것은 쉽게 도전할 성질의 것이 아니다. 복잡한걸 간결하기 만들기 위해서는 단계를 줄이고 순서를 없애야 한다.

'Framework > Database' 카테고리의 다른 글

Framework (DBController)  (0) 2009.03.12
Framework -커서(ForwardOnly, Sensitive(=Static))  (0) 2009.03.12
Framework (Rows)  (0) 2009.03.07
Framework (IQueryable)  (0) 2009.03.06
Framework (DBManager)  (0) 2009.03.04
Posted by bleujin
Framework/Database2009. 3. 7. 19:27


테스트를 위해 오라클에 패키지를 만들자.
Procedure 구문을 몰라도 그냥 대충 의도만 보면 된다.

--// select 는 cursor type으로 return 하므로 사용자 cursor type을 만든다.

CREATE OR REPLACE PACKAGE SCOTT.Types
    as type cursorType is ref cursor ;
    FUNCTION dummy return number ;
END ;

CREATE OR REPLACE PACKAGE BODY SCOTT.Types
as FUNCTION dummy return Number
is
 BEGIN
   return 1 ;
 End dummy ;
END ;

--// 그냥 dummy 함수 만들어 둔다.


-- // test에 사용할 Package
CREATE OR REPLACE PACKAGE Employee
is
    FUNCTION  listBy(v_deptNo in number) return Types.cursorType ;
    FUNCTION  addDeptWith(v_deptNo in number, v_dname in varchar2, v_loc in varchar2) return number ;
    PROCEDURE addEmpBatch(v_empno in number, v_ename in varchar2, v_deptno in varchar2) ;
END Employee;


CREATE OR REPLACE PACKAGE BODY employee
is
    FUNCTION listBy(v_deptno IN Number)
    return types.cursorType is rtn_cursor types.cursorType ;
    begin
        Open rtn_cursor For
        Select empno, ename, job from emp where deptno = v_deptno ;
       
        return rtn_cursor ;
    end ;

    FUNCTION  addDeptWith(v_deptNo in number, v_dname in varchar2, v_loc in varchar2)
    return number is
    begin
        insert into dept values(v_deptno, v_dname, v_loc) ;
        return SQL%ROWCOUNT ;
    end ;
   
    PROCEDURE addEmpBatch(v_empno in number, v_ename in varchar2, v_deptno in varchar2)
    is
    begin
        insert into emp(empno, ename, deptno) values(v_empno, v_ename, v_deptno);
    end ;

end ;

employee package에는
listBy : 부서번호를 받아서 해당 부서의 사원들의 리스트
addDeptWith : 부서를 추가하는 function
addEmpBatch : 사원을 추가하는 procedure

정도만 있다고 이해하면 된다.


package test.db;

import junit.framework.TestCase;

import com.bleujin.framework.db.DBController;
import com.bleujin.framework.db.Rows;
import com.bleujin.framework.db.manager.OracleCacheDBManager;
import com.bleujin.framework.db.procedure.IQueryable;
import com.bleujin.framework.db.servant.StdOutServant;

public class TestRows extends TestCase {

  private DBController dc;

  public void testSelect() {

    IQueryable query = dc.createUserProcedure("employee@listby(?)").addParam(20);
    // IQueryable query = dc.createUserCommand("select empno, ename, job from emp where deptno = ?").addParam(20);
    Rows rs = dc.getRows(query);
    System.out.println(rs);
  }

  public void tearDown() throws Exception {
    // 원래는 시스템 종료시 단 한번 호출
    dc.getDBManager().destroyPoolConnection();
  }

  public void setUp() throws Exception {
    // 원래는 시스템 시작시 단 한번 호출
    dc = new DBController("test"new OracleCacheDBManager("jdbc:oracle:thin:@novision:1521:bleujin""scott""tiger"5)new StdOutServant(StdOutServant.All));
    dc.getDBManager().initPoolConnection();
  }
}


간단한 코드를 작성하면 위와 같다.
위의 코드에서는 junit에서 setUp()가 tearDown()은 모든 테스트 메소드 시작과 종료시 호출되므로 테스트 메소드 마다 풀을 만들었다 없앴다 해야 하지만 테스트 코드이므로 신경끄자. 

   
IQueryable query = dc.createUserProcedure("employee@listby(?)").addParam(20);
   Rows rs = dc.getRows(query);
   System.out.println(rs);


OracleCacheDBManager는 UserProcedure의 @앞의 employee는 packageName으로 @뒤의 listBy는 function 혹은 procedure로 해석한다. (앞 글에서 말한대로 이는 DBManager가 결정하는 것일뿐 위의 문장이 항상 PL/SQL을 요구하는것은 아니다.)

난 도저히 프로시저 따위는 못쓰겠다 한다면 
   IQueryable query = dc.createUserCommand("select empno, ename, job from emp where deptno = ?").addParam(20);
와 같이 UserCommand를 사용해도 되긴 한다.



여기서 말하고자 하는 객체는 리턴값인 Rows로서 이 프레임워크에서 중요한 역할을 하고 있다.

위 프로그램을 실행하면
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE RowSet PUBLIC '-//Sun Microsystems, Inc.//DTD RowSet//EN' 'http://java.sun.com/j2ee/dtds/RowSet.dtd'>

<RowSet>
  <data>
    <row>
      <EMPNO>129848</EMPNO>
      <ENAME>m7qpklg</ENAME>
      <JOB>SALESMAN</JOB>
    </row>
    ......
    <row>
      <EMPNO>139387</EMPNO>
      <ENAME>zvk6yp6</ENAME>
      <JOB>SALESMAN</JOB>
    </row>
  </data>
</RowSet>

가 나온다.


Rows는 java.sql.ResultSet을 상속받고 있으므로 기존의 JDBC 처럼 다루면 된다. java.sql.ResultSet과 주요한 차이점은 두가지가 있다.

첫째 close를 하지 않는다.(쿼리 실행시에 getConnection()도 하지 않는다.) 
java.sql.ResultSet과는 달리 Rows는 HTTP와 마찬가지로 JIT식으로 처리를 하고 connectionless 한 환경에서 동작하기 때문이다. - 이 방식의 장단점은 좀 길기 때문로 다른 글에서 언급 - 클라이언트 코드를 작성하는 쪽에서는 수백번-혹은 수천번의 연결하고 실행하고 끊고 예외처리하고 등을 해야 할 필요가 없다. 단지 사원의 리스트를 던져줘라고만 하면 충분하고 또 그래야만 한다. 나머지 과정은 너무 귀찮고 반복적이라 신경쓰고 싶지 않는 것이다.

HTTP는 규약상 connection less 한 규약이지만 실제 사용되는 HTTP 1.1은 내부적으로는 한 페이지에 포함된 글 뿐만 아니라 여러개의 그림도 같은 connection을 통해 가져온다. 좀더 빠르기 때문이다. DB Framework는 풀링 처리를 하기 때문에 그다지 차이가 나진 않지만 그러함에도 2개이상의 Query를 실행시키고 싶다면

  
public void testSelect2(){
    UserProcedures upts = dc.createUserProcedures("two query";
    IQueryable query1 = dc.createUserProcedure("employee@listby(?)").addParam(20);
    IQueryable query2 = dc.createUserCommand("select * from dept where deptno = ?").addParam(10);
    
    upts.add(query1).add(query2;
    Rows rs1 = dc.getRows(upts);
    Rows rs2 = rs1.getNextRows();
  }
와 같이 여러개의 Query를 UserProcedures 객체에 담아서 실행시키면 Rows는 chain 식으로 연결되어 리턴한다.



둘째 SQL실행시에 SQLException을 던지지 않는다.

이전 글인 예외처리의 예외에서 언급한바와 같이 실험실의 바깥에서는 현실적으로 대부분의 SQLException에 대해 특별한 처리를 할게 없다. 그래서 실제로는 runtime exception은 RepositoryException을 던진다. DB가 다운됐거나 SQL오류등으로 혹은 해당 컬럼명이 없어서 SQLException이 발생했을경우 에러 페이지를 보여주고 로그에 기록하는 것 말고 대체 무엇을 할수 있을까? 다른 무언가를 해야 한다고 하더래도 아마도 그 상대적인 확률은 아주 낮을 것이다.

여기서 중요한건 클라이언트 코드 작성자가 선택권을 가지는 것이다. 99%의 A와 1%의 B가 있다면 A를 기본값으로 하고 B도 필요하다면 할수 있는 방법이 상식적이지 않을까? 그리고 만약 반대로 1%의 A와 99%의 B 환경이 있다면 그에 대한 대책도 준비해 줘야 한다. checked exception을 처리할 확률이 작다면 그냥 runtime exception을 catch 하는게 좋지만 오히려 checked exception으로 해야할 경우가 더 많다면 Facade인 DBController을 재정의 하면 된다.

그러나 Facade인 DBController가 쿼리 실행시 SQLException을 감춰버릴 수 있었지만 리턴값의 Rows의 상위 인터페이스인 java.sql.ResultSet은 모든 메소드가 SQLException을 던지도록 되어 있고  이 규약을 깨는것은 혼란을 가져올수 있으므로 좋지 못하다. 다른 어떤 방법이 있을까?

여기서는 다시 두가지 대안을 제시한다
하나는 역시 ResultSet을 상속받지만 모든 메소드에 SQLException을 반환하지 않는  new NRows(Rows rows, RuntimeException ex) 의 컴포지션 객체를 제공하는 방식이고
두번째는 handler를 통해 java의 일반 객체로 바꾸는 방식이다. (이는 다음에 설명)
   


이쯤 되면 슬슬 프레임워크가 기본적으로 갖춰야 할 미덕이 보인다.
첫째 간결한 API가 좋다. 만약 1-2-3 단계를 거쳐서 무언가를 해야한다면 한단계로 줄이는 걸 고민해라.
둘째 빈도의 차이가 발생한다면 확률이 높은 쪽을 기본값으로 한다.
셋째 그래도 클라이언트는 항상 선택할 수 있어야 하고 때에 따라서는 확률이 바뀌는 환경도 고려해야 한다.

'Framework > Database' 카테고리의 다른 글

Framework -커서(ForwardOnly, Sensitive(=Static))  (0) 2009.03.12
Framework (Handler)  (0) 2009.03.08
Framework (IQueryable)  (0) 2009.03.06
Framework (DBManager)  (0) 2009.03.04
Framework (구조적 중복 제거)  (0) 2009.02.21
Posted by bleujin
Framework/Database2009. 3. 6. 04:57

DB는 서비스와 개체가 아닌 개념으로서 접근해야 한다는 얘기는 앞에서 강조하였다. 서비스로서 접근하는 의사 코드는 이를테면 아래와 같다.

Case1 )
Message msg = DBService.newMessage("messageName") ;
msg.setValue("userId", "bleujin") ;
msg.setValue("passwd", "pwd") ;
ResultValue result =  DBService.execute(msg) ;

여기서 중요한 것은 Message와 ResultValue는 JDBC 객체야 아니여야 한다는 점이다. JDBC 객체가 아니기 때문에 모든 문장에서 SQLException을 처리해야 할 필요도 없고(이는 나중에 다시..) Connection을 꼭 닫아야 하는 강박관념이 휘말리지 않아도 된다.

비슷하게 JDBC 구문을 작성해 보자.
Case 2)
Connection conn = DBConnectionManager.getConnection() ;

PrepareStatement pstmt = conn.prepareStatement("query") ;
pstmt.setString(1, "bleujin") ;
pstmt.setString(2, "pwd") ;
ResultSet rs = pstmt.execute();



비슷해 보이는가? 언뜻 보면 비슷하게 보이지만 사실 전혀 비슷하지 않다. 개체니 개념이니를 떠나서 실제적인 문제를 지적해보자.

첫째로 Case2는 지저분한 예외처리를 반복해야 한다.
둘째로 Case2는 리소스 정리를 잊지 말고 해주어야 한다. -> DB는 중요한 리소스인데 일부 코드의 실수가 전체 서비스를 중단시킨다.
셋째로 클라이언트 쪽 코드가 너무 많은걸 알고 있기 때문에 이후 유지보수에 어려움이 많다.
넷째로 실제 트랜잭션등의 코드가 들어가기 시작하면 비지니스 로직이 아닌 JDBC 코드로 지저분해진다.

문제점을 요약하면 이전글에서 말한대로 매번 구조적인 중복이 발생한다는 사실이다.
그에 비해 Case1은 JDBC와 중립적인 Java 객체를 사용했기 때문에 JDBC의 반복적인 중복으로부터 자유롭다.

그러나 우리는 왜 Case2식의 코드를 작성하는가? 비슷한 코드가 매번 반복된다고 할 때 케이스별로 차이가 발생하는 부분은 pstmt.setString()을 호출하는 부분이다. 다른 부분은 매번 반복된다. 반복되는 줄 알면서도 쉽게 지우기 어려운 이유은 PrepareStatement 객체인 pstmt가 바로 Connection 인스턴스인 conn에서 생기기 때문이다. 다시 말해서 Connection은 PrepareStatement 객체에게 일종의 끊기 어려운 족쇄같은 것이기 때문에 이를 떼어내야 한다.

pstmt가 하는일은 사실 그냥 index와 value값을 가지고 있기만 하면 되는 간단한 일이다. 그렇다는 얘기는 굳이 PrepareStatement를 먼저 생성해야 할 필요가 없다. 비슷한 인터페이스를 가지고 내부에 param을 저장할 수 있는 Map객체를 가진 객체 - 이를테면 Message라고 하자 - 를 만들고 여기에 값을 set 한다음에 실제 execute가 발생할때 그때 PrepareStatement를 생성해서 Message에 저장된 값을 설정한 다음에 실행시키면 그만이다.

의사코드로 작성하면

class Message {
    private Map values = new HashMap() ;
    private String name  ;
    public Message() {
        this.name = name ;
    }
    public void setString(int index, String value) {
       values.add(new Integer(index), value) ;
    }
    ......

   public int execute() {
       Connection conn = getConnection() ;
       PrepareStatement pstmt= conn.prepareStatement(getMessageName()) ;
       for(..){
            pstmt.setValue(....) ;
       }
       int result =  pstmt.execute() ;
       pstmt.close() ;
       conn.close() ;
       return result ;
   }

}
와 같이 Wrapper 객체를 만들어 Laze Execution을 시키면 문제는 간단하다. 이렇게 작성하면 더이상 PrepareStatement처럼 connection에 메이지 않아도 된다.

좀더 생각해야할 문제는 그 다음부터이다. 처음 드는 생각은 그럼 select는 ? 이고 두번째는 그럼 트랜잭션은? 이다.



Select는 ?

다시 일반적인 JDBC 코드를 보자
ResultSet rs = pstmt.execute();
  ....  // Case별로 다른 코드
rs.close() ;
psmts.close() ;
conn.close() ;


중간 .... 부분의 코드는 비지니즈 로직마다 다르다. 앞의 setParam때와 마찬가지로 이 부분의 매번 다르기 때문에 어쩔수 없는 족쇄 코드인 close 구문이 - 그리고 예외 처리 구문이 - 뒤 따라 온다. 이 부분은 먼저 전체범위 처리와 부분범위 처리에 대한 이해와 커서에 대한 이해 글을 먼저 읽어야 하지만 간단하게 요약하면 클라이언트 커서를 사용하는 중립적인 객체를 사용하면 된다. 우리가 필요한건 ResultSet에 있는 값이기 때문에

class Message {
    ....

    Rows execQuery(){
        ......
        ResultSet rs = pstmt.execute();
        Rows rows = new Rows() ;
        rows.populate(rs) ;          // rs의 값을 rows에 담는다. rows는 일종의 커다른 맵이라 생각하자.
        rs.close() ;
        psmts.close() ;
        conn.close() ;
        return rows ;
    }
}
와 같이 JDBC의 ResultSet 인터페이스를 impl하였지만 실제 connection less 한 상태에서도 값을 얻을 수 있는 Rows에 값을 담아서 던지면 클라이언트 코드는 커넥션에 대해 신경쓸 필요없다.

클라인트 코드는 대충 이렇게 작성하면 된다.
Rows rows = msg.execQuery() ;
..... rows를 가지고 지 할일 한다.

그리고 범위를 벗어나면 rows는 알아서 가비지 컬렉션 된다.



Transaction은 ?

트랜잭션은 좀더 쉽다. Message 배열을 가지는 Messages 객체를 만들어서 여러개의 메시지를 담은다음 각 메시지의 execute()를 호출하는 execute를 만들면 된다. 대충 아래와 같다.

Messages extends Message {

     public int execUpdate() {
        int result = 0 ;
        Connection conn = getConnection() ;
        for(Message msg : messages){
            result += msg.execute(conn) ;
        }
        return result ;
     }
}

이 기종의 트랜잭션의 처리와 조건에 따른 트랜잭션 처리는 좀더 어려운 주제이므로 여기서는 생략하자. 코드가 조금 어려울 뿐 인터페이스 자체가 중립적이므로 이것도 굳이 상관없다.


DB를 서비스로 접근해야 한다는 얘기를 하기 위해 객체 이름을 Message로 했을뿐 실제 클래스 이름으로 바꿔보면 대충 아래와 같다.


DBController dc = new DBController() ;
IUserProcedure upt = dc.createUserProcedure("findCustomer(?,?)") ;
upt.addParam(1, "bleujin").addParam(2, "pwd") ;
Rows rows = dc.execQuery(upt) ;


여기서 주목해야 할 것은 findCustomer이란 문자열은 무언인가? 하는 점이다. 대부분의 프로그래머들은 Stored Procedure에 안좋은 감정을 가지고 있다. 싫어하는 첫번째 이유는 SP가 DB 종속적이고 두번째로 비지니스 코드를 종속적인 SP에 담아서는 안된다는 이유다. 속도가 빠르다라던가 보안에 좋다 등의 장점도 있지만 아마 그런점은 애써 무시한다.

장점은 그렇다치더라도 단점은 과연 타당한가?

첫번째 이유는 이전에도 강조했다 시피 일부 DB만 SP를 지원하기 때문에 SP를 쓰지 말아야 한다는 것 자체가 종속에서 벗아나지 못했다는 뜻이다. DB종속에서 진정 벗어나고 싶으면 DB가 SP를 지원하든 안하든 Client 코드는 그에 영향 받지 않아야 한다가 좀더 유연한 코드이다. 위의 findCustomer를 어떻게 해석하냐는 앞에서 얘기한 DBManager에 달렸다. 이를테면 OracleDBManager는 execUpdate가 호출될때 findCustomer이라는 SP를 찾아서 실행한다. MSSQLDBManager는 findCustomer이라는 function을 찾아서 실행한다. 그마저도 없는 HSQL이나 H2같은 Manager들은 지정된 xml에서 findCustomer이라는 이름으로 설정된 SQL을 얻어와서 실행한다. 다시 말해서 DB가 지원하면 쓰면 그만이고 안 지원하면 안쓰면 되고 지원하는데도 안쓰고 싶으면 안 쓸 방도도 만들어 주어야 한다는 것이다.

사실 개인적으로 SP의 가장 큰 장점으로 생각하는 부분은 속도나 보안등의 이유가 아니라 SP나 MSSQL의 function은 컴파일이 되는 그 자체에 장점이 있다고 생각한다. xml 파일에 저장된 query문은 그냥 String일뿐이다. 따라서 slect empNo, ename from emp where deptNo < 20 이라는 SQL문이 저장되어 있을때 정말 emp라는 테이블이 있는지 emp라는 테이블에는 empNo라는 컬럼이 있는지 그리고 select를 slect로 잘못 쓰지는 않았는지는 확인해 주지 못한다. 컴파일 하지 않는 언어인 Javascript를 사용하면서 우리가 얼마나 많은 초보적인 문법 실수를 저지르는지 그리고 유지보수에 문제를 많이 가지고 있는지 생각해 보면 이는 자명한 일이다. SP나 Function은 실시간으로 검사하기 때문에 DBA가 테이블 이름을 잘못 rename한다면 이를 바로 알려준다. xml 파일에 저장되어 있다면 사용자가 에러를 일으킨후 톰캣이 수천라인의 에러 로그를 토해내고야 알수 잇는 반면에 말이다.

두번째 비지니스 코드는 종속적인 SP에 담아서는 안된다라는 주장에 대해 밝혀보자.
이는 먼저 비즈니스 코드란 무엇인가에 대한 개념이 잡히지 않는 소리다. 추상화를 적절히 사용했다면 비즈니스 코드란 findCustomer이란 이름 그 자체이지 결코 SQL Query가- select cname from customer where custNo = ? 식의 -  비지니스 코드가 아니다. 비즈니스 코드에 대해 알고있는 사람들은 이 버튼을 누르면 findCustomer가 실행된다라고 알아야지 이런 저런 테이블에서 이런저런 쿼리를 던집니다 라고 알아야 하는게 아니다. 물론 나도 IF문으로 도배된 수천라인의 SP로 작성된 코드를 감싸는게 아니다. 문제는 지나치게 과잉 처리된 SP에 문제가 있는 거지 SP 자체를 사용하는게 종속적인 비지니스 로직을 사용하는게 아니라는 말이다.


다시 실제적인 문제로 돌아와서 Query의 타입은 실제로 여러가지가 있다.




최상위 인터페이스인 IQueryable는 MDL을 실행하는 execUpdate와 Select를 담당하는 execQuery 3개의 메소드가 있다.
execUdpate : MDL 구문 실행
execQuery : Select 형 구문
execHandlerQuery : HandlerQuery


UserProcedure :
UserProcedure는 String 하나를 생성자로 받는데 이 String이 어떻게 해석되는가는 저 앞쪽의 DBManager에 달려 있다. 같은 오라클 DB라도 이걸 프로시저 이름으로 해석하느냐 아니면 매핑되어 있는 SQL로 해석하느냐는 다르다. 앞의 이야기는 주로 이 클래스를 기준으로 설명하였다. DBManager는 이 클래스를 상속받는 OracleUserProcedure와 MSSQLProcedure 등을 상황에 따라 사용한다.

UserCommand :
테스트나 기타 간단한 확인을 위해서 Query문 그 자체를 받는다. 실제로는 잘 사용하지 않는다. 사실 UserCommand를 만든 이유는 UserCommandBatch를 위해서다.
UserCommandBatch :
10000개의 insert를 해야 할때 만개의 PrepareStatement를 생성하는 건 바보같은 짓이기 때문에 JDBC의 배치처리 구문을 사용한다. setParam시 배열을 사용한다. 실제 10000개의 인서트시 DB Call을 한번 사용하기 때문에 반복적인 배치처리를 해야 할 때 사용한다. 배치작업은 JDBC 규약대로 하나의 트랜잭션으로 자동 처리된다.

UserProcedureBatch :
테스트 결과 Procedure를 배치로 사용하는 것은 일반 SQL의 배치보다 효율이 많이 떨어진다. 그러나 건수가 아주 많지 않다면 굳이 새로 만들필요없이 대충 사용한다.

TimeOutQuery :
다른 IQueryable를 생성자로 받아서 별도의 Thread로 해당 Query를 모니터하면서 지정된 시간이 지나면 query를 캔슬시킨다. 기본적으로 JDBC API에는 cancel API가 있지만 DB 벤더에 따라 구현 여부가 다르다. Decorator 패턴의 활용

UserProcedures :
여러개의 다른 IQueryable를 하나의 Transaction으로 처리한다. Composite 패턴의 기본 활용이다. 만약 select 하는 IQueryable를 UserProcedures에 여러개 담아서 실행하면 어떻게 될까? 이런 경우 여러개의 sql을 모두 실행시켜서 return값인 Rows를 chain 형태로 엮는다. firstRows.getNextRows() 형태로 다음 결과 셋을 얻을 수 있다.

XAUserProcedure :
앞의 UserProcedures는 여러개의 IQuery를 하나의 DB에 트랜잭션 처리하는 것에 반해 두개 이상의 DBManger를 사용하면서 이기종간(이를테면 오라클과 MSSQL)의 IQueryable들을 하나의 트랜잭션으로 처리하고자 할때 사용한다.

CombinedUserProcedures :
위의 다이어그램에는 표시되지 않았지만 아주 가끔 먼저 select 를 해보고 특정 조건에 따라서 해야할 일이 달라지는 경우가 있다. 이런 케이스는 경우의 수가 천차 만별이지만 - 이를테면 먼저 insert를 하고 커밋을 하지 않는 상태에서 다음 select를 던져보고 update를 할건지 delete를 할건지 아니면 rollback를 선택한다. - 최대한 일반적인 상황을 가정해 만들어본 클래스이다. 아주 없다고는 할수 없지만 확률상 많이 나타나지 않으므로 아주 독특한 경우는 상속받아서 새로이 구현하는 것도 고려해볼 수 있다.


쿼리의 종류에는 여러가지가 있지만 아직 10년간 프로그래밍하면서 위의 케이스를 벗어나는 타입은 아직 없었다. 사실 있더래도 상관없다. 이는 모두 실험실 안의 코드이고 실험실 바깥에서는 상관하지 않으니 새로운 타입의 IQueryable를 구현하면 그만이기 때문이다.



여기서 다시 햄버거 가게가 생각나겠지만 왜 복잡하게 IUserCommand 인터페이스를 사용하고 MSSQLUserCommand와 OracleUserCommand 를 따로 만들어야 하는지에 대해서 말하자면 JDBC에 표준 API가 있음에도 사실상 표준 API만으로 JDBC 코드를 만들기는 어렵다.

이는 달리 말해서 단순히 DB의 특정 기능을 사용하지 않는다고 DB로의 독립이라는 목표를 달성하기가 매우 어렵다는 것을 뜻한다.  이는 Framework 차원에서 주의깊게 접근해야 한다. 예컨데 Clob 처리를 보면 Oracle과 MSSQL은 처리 방식이 전혀 다르다. 해당 Framework를 사용하는 Client는 그딴거에 상관하고 싶어하지 않으므로 실제 impl하는 컨크리트 클래스들이 여러개씩 존재하고 어떤 컨크리트 클래스들을 사용할 것인가는 DBManager가 판단한다. (좀더 정확히 말하자면 DBManager의 RepositoryService 객체가 판단한다. )


이글의 요지는 이렇다. 많은 프로그래머들이 알고 있듯이 JDBC의 표준 API만으로 JDBC 프로그래밍을 하는건 무척 어렵다. 이상과는 다르게 DB벤더마다 조금씩 다른 API를 사용해야 하고 단순히 특정 기능을 사용하지 않는다고 소위 DB에 독립적인 코드를 만든다는 것은 어렵다. - 그 밖에도 어려움은 많다 - 그리고 DB Framework에서는 이런 문제에 대하여 클라이언트 코드는 전혀 JDBC 코드를 사용하지 않도록 Wrapper Object 등을 통해 차단막을 설치해버리면 Framework 안의 실험실 코드는 철처히 벤더 의존적인 코드로 만들어도 상관이 없다. 실험실 바깥과는 Service와 독립적인 객체로 통신하는 느슨한 연결을 통해 달성할 수 있는 장점이다.


'Framework > Database' 카테고리의 다른 글

Framework (Handler)  (0) 2009.03.08
Framework (Rows)  (0) 2009.03.07
Framework (DBManager)  (0) 2009.03.04
Framework (구조적 중복 제거)  (0) 2009.02.21
Framework (실험실 코드)  (0) 2009.02.20
Posted by bleujin
Framework/Database2009. 3. 4. 16:48

실험실 밖에서는 개체로서가 아닌 개념으로서의 DB를 인식해야 한다는 걸 명심하고 이제 실험실 안의 코드를 보자. 일단 실험실 밖에서야 어떻게 돌아가든 실험실 안에서는 경제적 효율성이 아니라 순수한 수학적 효율성을 가지는 코드를 작성해야 한다.(공학적 효율성 = 경제적 효율성 + 수학적 효율성) 따라서 비록 추상 메소드이긴 하지만 DBManager가 가지는 getConnection 등의 db 식의 메소드에 대한 추상적인 접근은 이후에 하기로 하고 일단 추상클래스인 DBManager를 볼모로 잡아둔다는 가정하에 실험실 안에서는 repository service가 아니라 general db로 인식이 가능하다.

DBManager Class Diagram






DBManager 클래스는 상속이 잘못 사용된 대표적인 예와 닮아 보인다. 브랜든 골드패더의 책에서처럼 햄버거를 상속하여 치즈 햄버거를 만들고 양파 추가한 치즈 햄버거를 만들고 토마토를 추가한 치즈 햄버거를 만들고 양상추를 추가한 치즈 햄버거를 만들고 물론 양파만 추가한 햄버거도 만들고... 등등 데코레이터 패턴을 설명할때 잘못된 사용으로 나오는 상속의 부적절한 사용예와 무척 닮았다. 

게다가 JDBC라는 규약을 만든 이유가 통일적인 인터페이스를 위해서이기에 jdbc와 사용자 정보만 넘기면 어느 DB든 연결이 가능하므로 클래스 하나면 될텐데 지나치게 복잡해 보인다.

위 CD를 보면 DBManager를
MSSQLDBManager
   MSSQL2000 용 표준 연결 관리 클래스이다. Pool을 사용하지 않는다.

OracleDBManager 
   Oracle 용 표준 연결 관리 클래스이다. Pool을 사용하지 않는다. 아주 기본적인 코드이므로 8i이상에서는 모두 동작한다.

WASDBManager
   WAS에 등록된 DB 연결정보를 사용한다. Pool을 사용하는지의 여부는 WAS에게 맡긴다. 아주 기본적인 코드이므로 WAS종류에 상관없이 동작한다.

HSQLDBManager
   HSQL용 표준연결 관리 클래스이다.

MYSQLDBManager
   MYSQL용 표준 연결 관리 클래스이다.

H2DBManager
  H2용 표준 연결 관리 클래스이다. 

DBManager를 상속받은 각 DB별 Manager를 보면 DB의 종류만 틀릴뿐 큰 차이가 없어보인다. 물론 WAS의 경우같이 jdbcURL대신 DSN등을 받는 등의 조그만 인터페이스 차이가 있겠지만 이렇게 많은 클래스를 만들 필요가 있었을까? 복잡해 보인다고 할 수 잇지만 실제로는 그닥 별 차이 없다. 왜냐하면 대부분의 프레젝트에서 사용하는 Manager는 대부분 하나이기 때문이다. 단지 하나의 Manager를 사용한다면 이렇게 많은 Class를 만들어야 할 이유는 더더욱 없어보이지만 일단 넘어가자. 

MSSQLDBManger를 상속받는 MSSQLCacheDBMaanger, MSSQLPoolDBManger을 보자. 이 두개의 클래스의 차이는 커넥션 풀링 구현의 차이이다. 하나는 객체 풀링을 사용하고 다른 하나는 DBCP의 풀링을 사용한다. DBCP의 커넥션 풀링이 일반적으로 쓰이는 방식이므로 중복되지 않도록 PoolHelper에 캡슐화하여 사용한다. 커넥션 풀링에 완전 무결한 방법이 있어 그것 하나만 사용하면 좋겠지만 사실 그렇게 간단한 문제는 아니다. 

이상과는 다르게 JDBC는 완벽하게 통일된 인터페이스를 보장해 주지는 못한다. 아니 그렇게 하기 위해서는 얼마간의 무언가를 희생해야 한다. 

첫번째로 JDBC Dirver의 버그 문제이다. JDBC는 인터페이스 일뿐으므로 실제 벤더 혹은 일반 IT기업이 구현한 Driver에는 버그들이 종종 발견된다. 물론 버그를 돌아갈 꼼수 코드는 있다. 다만 돌아가는 순간 이미 JDBC의 표준 인터페이스를 위반하게 되고 해당 클래스의 재사용은 불가해진다. 

두번째로 JDBC Driver 각각의 효율성을 최대한 살려주기 위함이다. JDBC의 표준 코드와 다른 방식으로 효율적인 방법을들 사용할 수 있다. 오라클은 사실 Driver 버전마다 조금씩 다른 풀링 방법을 제시하는데 이때 JDBC의 인터페이스를 사용하지 않는다 .수백번의 다양한 테스트를 해본결과 일반적인 DBCP의 커넥션 풀링보다 약 10-15% 정보 빠르다라는 결과를 얻었다. 그러나 그렇다고 해서 항상 OracleCacheDBManager를 쓸수는 없는 일이다. DB가 오라클이 아닐수도 있고 오라클이 제공하는 커넥션 풀링은 버전을 타니까 말이다. 


세번째로 테스트와 안전한 연결을 위해서이다. 예를 들어 MSSQL의 연결을 담당하는 클래스 3개는 모두 다른 방식으로 작성되었다. 이는 2개의 컴퓨터가 동시에 다운되기 어려운 것처럼 3개의 클래스 모두가 잘못되었을 확률을 줄여준다. 연결 관리는 너무 중요하고 짧은 코드지만 여기에 문제가 없다라고 자신하기는 힘들다. 단순히 코드의 문제가 아니라 지역적인 문제가 얽히면 얼마든지 버그는 끼어들 수 있다.

네번째로 지역적인 문제가 있다. 예컨데 Oracle9iCacheDBManagerWithRetry의 경우 DB가 같은 LAN상이 아닌 방화벽 너머에 있었는데 이때 방화벽이 지속적으로 모니터링을 하여 연결이 오래된 경우 자동으로 연결을 끊어버리기 때문에 Pooling을 사용할 수 없었다. 그렇다고 매번 연결을 맺는 방식은 너무 느려서 사용할 수 없었다. 그리고 방화벽의 정책을 바꿀수도 없다고 클라인트가 주장하였기 때문에 방화벽이 연결을 끊으면 기존의 풀링 객체를 무효화시키고 다시 생성해서 Retry를 하는 클래스이다. 우습지만 이런 비슷한 일은 현실에서 비일비재하게 일어난다. 만능 커넥션 풀링하는 클래스 하나만을 가지고 쓸 수 없는 이유다.




CD 자체는 DBManager를 상속받아 DB마다 혹은 풀링 정책마다 따른 차이를 구현한 정도이므로 매우 간단하다. 그러함에도 DBManger를 별도로 분리해서 설명하는 다른 이유가 있다. 

보통의 경우 DB Library는 대부분
  +getConnection(String jdbcURL, String userId, String passwd)
  +execute(String sql)
  +query(String sql)
  +freeConnection(conn)
형태의 구조를 가지는게 일반적이다. 열고 실행하고 닫고... 의 클래스가 무난해 보이지만 역으로 생각해보자. 위 방식이 하나의 책임을 가지고 있다면 execute할때 innerMethod로 getConnection과 freeConnection을 호출해서 사용해도 되지 않을까?

다시 말해서
+execute(String sql){
   getConnection() ;
   // 원래 execute Code
   freeConnection(conn) ;
}
와 같이 바꿀 수 있는가?

단순히 물리적으로 public 메소드를 하나만 가져야 한다는 의미는 아니다. 접근자의 경우 외부에서 호출 가능한 접근자는 public 하나뿐이므로 SRP의 원칙에 따라 public 메소드 하나만을 가지는 클래스를 만들기는 사실상 쉽지 않다. 다만 SRP에서 말하는 하나의 책임이란 개념적인 published Method 개념에 대한 상상이다.몇몇 다른 언어는 published라는 접근자를 사용하지만 자바에는 없기 때문에 추상적인 접근이 필요하다.


이렇게 바꿀 수 없는 이유는 여러가지가 있다.

첫째 항상 하나의 sql만 실행하리라는 보장이 없다.

둘째 sql 실행방법은 다양하다. 단순히 select와 MDL(insert, delete, update 등의 Modify Definition Language)의 차이뿐 아니라 batch 처리해야 sql도 있다.

셋째 연결과 실행사이에는 부가적인 작업이 있다. 대표적으로 트랜잭션 처리가 있고 로그나 정책에 따른 예외처리등의 다른 작업이 끼어들게 된다.

이와같은 이유로 무난해 보였던 열고.실행하고.닫고는 이 경우 하나의 책임이라고 할 수 없다. sql을 실행하는 것은 열고 닫고와 다른 책임이므로 별도의 클래스로 분리하는게 좋아보인다.

물론 이는 실험실 안에서의 이야기이다.
실험실 바깥에서 실험실은 단수이므로 추상화의 정도가 좀더 크지만 실험실 안에서의 추상화의 수준은 좀 더 낮다. SRP가 하나의 클래스 뿐 아니라 메소드 혹은 패키지 혹은 컴포넌트 혹은 서비스 그 자체에도 적용될 수 있는 것처럼 책임이란 뜻도 그 놓여진 곳에 따라 추상화 정도가 다르다. 실험실 바깥에서는 실험실을 개념으로서 접근하고 있으므로 getConnection, execute, freeConnection으로 접근하지 않아야 한다. (그리고 이게 이 프레임워크의 주 목적이기도 하다. ) 바깥에서 접근할때는 이게 물리적 DB로 인지하는게 아니므로 이런 정보를 주세요라는 request 하나로 표현되어야 한다.


# 에릭 감마의 public & published interface

http://lambda-the-ultimate.org/node/1400
A key challenge in framework development is how to preserve stability over time. The more miles a framework gets the better you understand how you should have built it in the first place. Therefore you would like to tweak and improve it. However, since your framework is heavily used you are highly constrained in what you can change. At this point it is crucial to have well defined APIs and to make it clear to the clients what is published API and what internal code is. For published APIs you should commit to stability and for internal code you have the freedom to change it.



'Framework > Database' 카테고리의 다른 글

Framework (Rows)  (0) 2009.03.07
Framework (IQueryable)  (0) 2009.03.06
Framework (구조적 중복 제거)  (0) 2009.02.21
Framework (실험실 코드)  (0) 2009.02.20
Framework (개요)  (0) 2009.02.20
Posted by bleujin
Framework/Database2009. 2. 21. 05:21

사실 개체로서의 인식과 개념적으로의 인식은 생각보다 큰 차이가 아니다. 손 안에 붕어빵에 대해서 끝업이 사고를 발전시켜보면 붕어빵의 공통점을 생각하게 되고 그 생각에서 좀 더 나아가면 개념으로서의 붕어빵을 생각할 수 있듯 이도 결국 추상화의 정도의 문제라고 생각한다.

개념에 대한 이야기는 잠시 접고 실제적인 이야기를 해보자. 그래. 개념으로서 서비스를 인식하게되면 어떤 잇점이 있는건가? 아니 그 전에 개체로서 인식하면 무슨 문제가 있는건가? 에 대한 답을 먼저 찾아야 한다.

package test.db;

import java.io.Closeable;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import junit.framework.TestCase;

public class InstanceDB extends TestCase{

  public void testCase1()  {
    initPool() ;  // 시스템 시작시 단 한번 호출
    
    
    ///////// start
    Connection conn = null;
    PreparedStatement stmt = null ;
    ResultSet rs = null ;
    try {
      conn = getConnection() ;
      stmt = conn.prepareStatement("select * from emp where rownum <= ?");
      stmt.setInt(15);
      rs = stmt.executeQuery() ;
      
      process(rs;
      
    catch (SQLException e) {
      e.printStackTrace();
    finally {
      try {if(rs != nullrs.close();catch(SQLException ex){ex.printStackTrace() ;}
      try {if(stmt != nullstmt.close();catch(SQLException ex){ex.printStackTrace() ;}
      try {if(conn != nullconn.close();catch(SQLException ex){ex.printStackTrace() ;}
    }
    
    ////// end

    destroyPool() // 시스템 종료시 단 한번 호출
  }

  private void process(ResultSet rsthrows SQLException {
    // rs로 실제 작업을 한다.
  }
  
  private Connection getConnection() throws SQLException {
    String url = "jdbc:oracle:thin:@novision:1521:bleujin" ;
    String user = "scott" ;
    String password = "tiger" ;
    return DriverManager.getConnection(url, user, password;
  }

  private void destroyPool() {
    
  }

  private void initPool() {
    try {
      String driverName = "oracle.jdbc.driver.OracleDriver" ;
      Class.forName(driverName);
    catch (ClassNotFoundException e) {
      e.printStackTrace();
    }
  }
}

보통의 JDBC 예제는 위와 같다. private 함수는 그닥 신경쓸 필요없고 커넥션 풀링 같은 자잘한 문제도 잊어버리기로 하자. 오직 testCase1 함수만 봤을때 언뜻 보기에 별 문제 없어 보인다. 그렇다 별문제 없다. 와~ 이제 집에가서 발딲고 잠이나-ㅅ- 가 아니라.. testCase1 함수는 객체지향의 1원칙 이라고 할 수 있는 DRY를 위반하였다. testCase1 함수 어디에 중복이 있는가? 

예제만을 보면 바로 찾기 어렵지만 사실 testCase함수의 Start와 end 사이가 통째로 중복이다. 왜냐하면 우리는 프로그램 하나를 만들때 testCase와 비슷한 소스를 적어도 수백개-수천개는 만들어야 하기 때문이다. 이를 앞에서 중복얘기할때 말한 구조적인 중복의 문제인데 이는 PMD에 근거한 Code Analyzer같은 툴로 검사해도 나오지 않는 중복이다. (initPool과 destroyPool은 풀링을 제대로 만든다면 프로그램이 뜰때와 내려갈때 한번만 호출이 될것이므로 이부분은 중복이 아니다. )

위 코드는
1. Connection을 얻는다
2. PrepareStatement 객체를 만들어 값을 셋팅한다.
3. 객체를 실행한다.
4. 그 결과값으로 ResultSet을 얻어 무언가 처리를 한다.
5. 사용한 리소스를 닫는다.
6. 예외 처리를 한다.
의 과정을 거치며 위 과정은 2,3번 항목에서 값만 바뀔뿐 수백 혹은 수천번이 반복되는 과정이다.
중복코드를 없애기 위해 getConnection을 함수로 만들어 호출한다지만 호출한다는 과정 자체가 중복이라는 뜻이다.

라이브러리가 단순히 코드의 중복을 없애주는 것과 달리 Framework란 이 과정의 중복을 없애줘야 한다.


package test.db;


import java.sql.SQLException;

import junit.framework.TestCase;

import com.bleujin.framework.db.DBController;
import com.bleujin.framework.db.Rows;
import com.bleujin.framework.db.manager.OracleCacheDBManager;
import com.bleujin.framework.db.procedure.IQueryable;
import com.bleujin.framework.db.servant.StdOutServant;

public class ConceptDB extends TestCase{

  private DBController dc ;
  public void testCase2() {
    initPool() ;  // 시스템 시작시 단 한번 호출

    IQueryable query = dc.createUserProcedure("emp@list(5)").addParam(5;
    Rows rs = dc.getRows(query;
    process(rs;
    
    
    destroyPool() // 시스템 종료시 단 한번 호출
  }
  private void process(Rows rs) {
    // process rs
  }
  private void destroyPool() {
    try {
      dc.getDBManager().destroyPoolConnection() ;
    catch (SQLException e) {
      e.printStackTrace();
    }
  }
  private void initPool() {
    try {
      dc = new DBController("test"new OracleCacheDBManager("jdbc:oracle:thin:@novision:1521:bleujin""scott""tiger" ,5)new StdOutServant(StdOutServant.All)) ;
      dc.getDBManager().initPoolConnection() ;
    catch (SQLException e) {
      e.printStackTrace();
    }
  }
}

Case2의 과정은 아래와 같다.
1. query 객체를 만든다
2. query 객체를 실행한다.
3. 그 결과값인 Rows를 얻어 무언가 처리를 한다.

Case2의 코드는 Case1과 열고 닫는 과정이 없어져서 단순히 줄이 짧다는 것 이상의 차이가 있다. Case1의 과정은 순차적이고 연속적이다. 1에서 6까지의 과정은 순서대로 이루어져야 하며 어느 과정 하나가 생략되어서는 안된다. 이를테면 5번 과정을 빠뜨리면 유한한 자원인 Connection 리소스의 문제로 이후에는 접속조차 안될것이고 6번 과정에서 예외 처리를 적절히 하지 못해도 마찬가지이다. 하지만 Case2는 과정의 단순화뿐 아니라 불연속적이다.

IQueryable 객체를 만들고 실행을 하지 않더라도 혹은 rs의 close()를 호출하지 않아도 전혀 문제가 되지 않는다. 왜냐하면 위에서 나오는 객체들은 자바의 일반 객체이므로 자바의 가비지 처리 알고리즘에 의해 자동적으로 알아서 처리되기 때문이다. 개체로서의 DB가 아나라 개념으로서 DB를 바라보면 구조적 중복을 상당히 없앨뿐 아니라 과정의 연속성의 필요도 없에버릴수 있다. 그럼으로써 프로그래머는 좀더 언어의 처리보다는 비지니스적인 문제에 집중할 수 있고 실수 가능성을 원천적으로 차단해 버린다. 서비스 코드에서는 오직 개념으로서의 DB만 보이므로 connectionless 같은 개체 문제는 아예 신경쓸 필요도 없게 되는 것이다.

귀찮은 반복코드인 예외처리를 없애는게 우선 좋아보이지만 위같이 아예 없어지면 과연 적절한 예외에 대한 상황처리는 어떻게 할거나? 아니 그보다 당장 트랜잭션 처리는 어찌되는거냐.. 같은 세세한 문제는 일단 다음에...


'Framework > Database' 카테고리의 다른 글

Framework (IQueryable)  (0) 2009.03.06
Framework (DBManager)  (0) 2009.03.04
Framework (실험실 코드)  (0) 2009.02.20
Framework (개요)  (0) 2009.02.20
Framework (블랙박스 증후군)  (0) 2009.02.07
Posted by bleujin
Framework/Database2009. 2. 20. 15:59

실험실 코드와 비실험실 코드를 분리한 후 가장 지켜야 할 원칙은 절대로 비실험실은 실험실 코드는 서로 관련이 없어야 한다는 것이다. (양방향성이다.)

이를 조금 더 확대하면 DB라는건 하나의 서비스이고 개발자가 만드는 제품도 하나의 서비스 이기 때문에 개발자가 만드는 제품은 DB를 개체로서의 DB가 아니라 개념으로서의 DB로 다루어야 한다.

추상적으로 A서비스와 B서비스가 있다고 할때 A서비스는 B서비스로 request를 하고 B 서비스는 A 서비스로 response를 한다. 이때 request와 response는 중립적인 메시지 형태가 되어야 하며 A.request는 B의 내부에 대해서 몰라야 하며 B.response는 A의 내부에 대해서 모르며 동시에 자기의 정체를 상세히 알려줘서는 안된다.

첫번째 A.request가 중립적인 메시지가 되기 위해서는 그리고 B가 개념으로서의 DB로 다루어질려면 request에는 개체로서 다루게 되는 select 문이나 clob 혹은 PrepareStatement 같은게 나와서는 안된다. 이야기가 다소 혼란이 있을지 모르므로 다시 앞에서 말한 "블랙박스처럼 DB를 다뤄라"와 비교를 해보자

먼저 객체지향의 개념과 개체의 구분을 해보자. 살고 있는 집에서 조금 떨어진 거리에 가끔 나가서 사먹는 붕어빵 파는 아저씨가 있다. 이때의 붕어빵은 개념이고 지금 밖에서 나가서 사온 지금 내 손에 들고 있는 따끈따근한 붕어빵은 개체다. Class와 Instance는 객체지향의 기본이니 별로 새삼스러울것도 없겠지만 이를 서비스에 적용해 보자.

블랙박스로서의 DB가 잘못됐다고 하는 이유는 DB를 개체로 다루되 개체의 특성- 이를테면 Procedure등을 사용하지 말고 Ansi SQL을 사용하라 - 을 무시하라가 그 주장이기 때문이다. 그러나 개념으로서의 DB 접근은 좀더 본질적이다.

비실험실인 서비스 코드에서 Select * From emp와 같은 SQL 문장이 나와서는 안되는 이유는 바로 Select문 자체가 개체로서의 DB에 접근하기 때문이다. 개념으로서의 DB는 데이타 저장 시스템일뿐이어야 하는데 위 간단한 SQL은 emp라는 테이블이 있는 바로 지금 옆에 설치되어 있는 DB 개체로 인식하고 있다.

프레임워크인 실험실 코드가 해야 할 일은 그래서 명백하다. A.request와 B.response의 중립적인 개념 코드(응?)를 해석해 주는 중간 레이어의 역할이다. 사실 컴퓨터 자체가 해석기관이고 랭귀지도 사람과 컴퓨터간의 해석 기관이라는 재귀적 관점에서 보면 프레임워크란 추상화 정도를 줄인 해석기관이라고 간주해도 크게 벗어나지 않는다.

package com.bleujin.framework.db.procedure;

import com.bleujin.framework.DBTestCase ;
import 
com.bleujin.framework.db.DBController;
import com.bleujin.framework.db.Rows;

public class TestLobTest extends DBTestCase{
  
  public void testDefault() throws Exception {
    // example 1
    
IUserProcedure u1 = dc.createUserProcedure("emp@add(?,?)";
    u1.addParam(110).addParam("bleujin";
    dc.execUpdate(u1;

    // example 2
    IUserProcedure u2 = dc.createUserProcedure("emp@list()";
    Rows rows = dc.getRows(u2;
    while(rows.next()){
       rows.getString("name";
    }
  }
}


위 코드는 DBFramework를 사용한 전형적인 예제이다. 먼저 A.request는 emp@add(?,?) 등의 중립적인 String으로 전달되고 있다. emp@add(?,?)를 어떻게 해석하는가는 실험실 코드인 DBManager 이하에서 결정할 문제일뿐 현재 서비스코드에서는 그에 대해 알 필요가 없다. 단지 실험실 바깥의 서비스 코드에서는 인자 2개를 셋팅한 UserProcedure를 호출했을 뿐이다.

두번째 예제에서 response인 Rows를 보면 JDBC의 ResultSet하고 닮긴 했지만 인터페이스만 공유할뿐 close를 호출해줘야 한다든가 예외처리를 해줘야 하는 책임이 없다. 사실 그냥 내부구조는 Map 형태의 일반 객체이기 때문이다. 일반적으로 JDBC의 ResultSet은 예외 처리와 적절한 시점에 close의 호출에 신경을 써야 하기 때문에 중립적인 메시지라고 보기 어렵다.

여기서는 DB를 순수 개념으로서의 Repository Service로서만 다룰뿐 실제의 개체는 모두 숨겨져 있다. 실제 개체로서의 DB는 바깥의 서비스에서는 알필요가 없는 것이다.


'Framework > Database' 카테고리의 다른 글

Framework (DBManager)  (0) 2009.03.04
Framework (구조적 중복 제거)  (0) 2009.02.21
Framework (개요)  (0) 2009.02.20
Framework (블랙박스 증후군)  (0) 2009.02.07
Framework (커서)  (0) 2009.01.12
Posted by bleujin
Framework/Database2009. 2. 20. 00:36

Database Framework를 작성한것은 사실 우연한 계기였다.
2003년 모회사에  입사하자마자 새버전의 솔류션 개발에 DBA로서 참여하게 되었는데 이전까지 DB전문가가 없었던 관계로 주로 하게될 역할은 모델링과 기초 설계 담당이었다.

그때까지 몇개의 웹스크립트와 몇개의 언어를 필요에 의해 겉핧기로만 알고 있었을뿐 개발자보다는 DBA나 모델러로서 오랫동안 일을 했기 때문에 자바의 실력은 아주 별로였다. (게다가 당시 내 주력언어는 VB와 C#이었다.) 고작해야 몇가지 패턴책을 보고 이것저것 몇가지 흉내만 가까스로 낼수 있는 정도였으니까.

이전버전의 제품은 수많은 버그와 유지보수의 문제로 몸살을 앓고 있었고 새로운 팀은 이러한 문제를 벗어나기 위한 한가지 방법으로서 System Framework를 먼저 제작하고 그 후에 비즈니스 요구사항을 가진 제품을 올리기로 합의하였다. 시간이 그리 넉넉하진 않았고 팀 자체가 새로 결성되었기에 먼저 팀는 인터넷의 여러 오픈소스 - 주로 아파치와 소스포지를 참고 - 를 참고하여 베이스로 깔고 목적에 맞게 보완/수정하는걸 기본전략으로 하였다.

당시 나는 CMS 도메인에 대해 기본지식이 없었기 때문에 인터넷이나 이전 버전을 참고하면서 논리적 디자인과 물리적 디자인 그리고 기초 데이타를 얻기위해 DB에 수천만간의 테스트 데이타를 작성해놓고 기초 성능 테스트를 하고 있었다.

문제가 생긴건 팀이 셋팅되고 한달쯤 지나 DB 디자인이 어느정도 확정이 되고 개발팀에게 ERD를 작성하여 모델 디자인을 설명하고 작성중이던 Framework 소스를 보게 된때였다. 그때까지는 개발자도 아니고 자바개발에 비교적 무심하였기 때문에 그냥 데이타베이스 쪽은 어떤가 하고 그냥 훓어봤을뿐이었다. 아마 그때 팀은 아파치 오픈소스인 현재의 common-dbutils의 모테인 릴리즈 이전버전을 사용하고 있었다. 지금 생각하면 우습지만 당시에는 커넥션 풀링을 쓴다는게 어려웠던 시절이고 마치 그게 대단한 기술로 보였던 시절이었다.(DB라는 리소스 특성상 커넥션하는 소스에 아주 조그만 버그만 있었도 프로그램이 중지되었으니까..) 그래서 커넥션 풀링이 된다는 것만으로 common-dbutils은 개발자들에게 아주 합당한 선택이었다. 

무심코 소스를 보게 된거지만 common-dbutils은 말 그대로 utility library일뿐 개념상으로 전혀 프레임워크가 아니었을뿐더러 만약 이 라이브러를 쓰면 DB의 대부분의 기능을 쓸수 없게 되리란걸 알게 되었다. 지금도 그렇지만 그때도 DB를 블랙박스로 다루어야 한다는 사상이 팽배해 있던 시절이라 "프로시저? 그거 특정 DB에만 있는거잖아 그걸 쓰면 안되지" 라던가 "DB 함수 그게 먼데? 그보단 일단 읽어서 자바 함수로 처리하자" 등등의 생각들이 지배하고 있었다.

이전글에서도 언급은 한적이 있지만 DB를 블랙박스로 다룬다는 것이 DB의 공통기능만을 써야 한다는 걸 - 이를테면 Ansi SQL만을 사용한다 등의 - 의미해서는 안된다. 오히려 DB와 상관없이 DB에 있는 모든 기능을 최대한 이용할 수 있는데까지 이용해야 한다. 물론 지금도 나아진게 없긴 하지만 DB를 단순히 저장매체의 종류로서만 사용한다면 DB가 수천만원을 호가할 이유가 없다.

어쨌거나 그걸 보고 팀장을 찾아가 이 프레임워크는 이런저런 문제가 있기 때문에 다시 설계/개발을 해야한다고 찾아가서 따지기 시작했다. 돌이켜 생각해보면 팀장으로서는 아마도 황당했으리라-ㅅ- 웬 처음 합류한 DBA가 몇시간 소스를 훔쳐보더니 개발이 어쩌고 저쩌고 하며 당시의 주류와는 다른 이상한 얘기를 해댔으니까. 삼사일간이나 이 문제로 투닥투닥 거린끝에 결국 "그럼 니가 한번 해보세요-ㅅ-"로 결론이 났고 자리로 돌아와선 괜히 말했나-ㅅ- 라는 후회가 슬쩍 들기도 하였지만 당시의 나는 고생끝에 작성한 DB 모델이 개발 소스의 요구에 의헤 훼손당하고 싶지 않아 오기를 부렸다.

그래서  주말에 이틀 날밤까고-ㅅ- 만든게 이 DBFramework 였다. 50개 안팍의 클래스가 6년동안 조금씩 기능이 추가되면서 약 150개의 클래스가 되었지만 기본 디자인은 전혀 바뀌지 않았다. 맨바닥 헤딩이었이면 훨씬 더 걸렸겠지만 이전에 C#으로 DCOM용으로 DB Framewor를 만들어본적이 있었고 그걸 1년동안 사용하면서 여러가지 단점에 대해서도 느낀바가 있었기 때문에 개념적인 문제보다는 Java라는 언어 때문에 고생하였다. 그 후로 어쩌다 보니 쭉 개발일도 하게 되면서 이제는 DBA보다는 개발자라는 명칭이 더 익숙하니 이 일은 내 IT 인생에 일종의 전환점이 되었다. 물론 딱히 좋은 전환점은 아니라고 쭉 생각하고 있긴 하지만 말이다 -ㅅ-;





당시에는 명확하게 정립이 된건 아니었지만 그 때에도 최우선했던 한가지는 실험실 코드의 분리였다. 다른 프레임워크와 마찬가지로 데이타베이스 프레임워크도 실제 코드에 쓰이기 위해서는 처음에 고려하지 못했던 다양한 요구사항들이 추가된다. 이를테면 SQL의 추적기능이 있어야 한다든가 느린 SQL은 취소할 수 있는 Undo 기능이 있어야 한다든가 로그 기능이 있어야 하거나 설정값을 조절할 수 있어야 한다든가 등등의 현실적 요구사항들이 나중에 스물스물 생겨나게 되는데 절대로 설계시 그러한 요구사항을 미리 고려해서는 안된다는 것이다. 



위 그림은 흔히 나오는 컴포넌트 디자인의 예이다. 오른쪽은 복잡도가 클래스에 비례하기 때문에 높은 반면에 왼쪽은 복잡도가 그리 높지 않다. 당연한 얘기이긴 한데 왼쪽과 같이 나누기 전에 먼저 고려해야 하는 것이 있다. 



멋지게 3차원 그림을 표현하고 싶지만능력이 안되니 위쪽과 아래쪽 컴포넌트를 높이에 따른 3차원이라고 생각하자 -ㅅ-
이 차원의 존재는 "관련있는"의 기준이 아닌 실험실과 비실험실의 차원이다. 물론 이것도 하나의 레이어의 일종이지만 별도의 차원으로 얘기하는 것은 이 차원이 가지는 특별성 때문이다. 

미리 나중에 생길지 모르는 비지니스 요구 사항을 대비하는 디자인이라는 것은 언뜻 그럴듯 해보이지만 사람은 앞으로 생길지 모르는 추가 요구사항을 고려하는 것 자체가 예언만큼이나 힘들고 그러한 것을 한다고 해도 불필요하게 과도한 복잡한 디자인을 유발할 가능성이 높다. 앞으로 나올 요구사항에 대비해야 하는 확장 가능한 디자인은 되려 간결한 설계에서 나오는 표현력을 떨어뜨리고 비지니스 정보의 확장 포인트가 중간에 끼어듬으로서 효율성을 감소시킨다.

프레임워크를 처음 만들때 가장 먼저 생각해야 할 분리 원칙은 비즈니스 요구사항과 상관없는 코드의 분리를 통한 미니멀리즘이다. 모든 일체의 기능적 요구사항은 무시하고 비기능적 요구사항 - 효율성, 테스트 용이성 등을 먼저 고려해여 레이어를 분리해야 한다. 그리고 이런 비기능적 요구사항이 구현될 실험실 레이어 부분을 현실 환경과 분리시켜야 한다.

위의 UML은 중요한 클래스의 Overview인데 파란색 사각형이 계속 말하는 실험실 코드이며 만약 비지니스 요구사항이 생긴다면 파란색 부분이 아닌 빨간색 부분에서 확장을 해야 한다.

'Framework > Database' 카테고리의 다른 글

Framework (DBManager)  (0) 2009.03.04
Framework (구조적 중복 제거)  (0) 2009.02.21
Framework (실험실 코드)  (0) 2009.02.20
Framework (블랙박스 증후군)  (0) 2009.02.07
Framework (커서)  (0) 2009.01.12
Posted by bleujin
Framework/Database2009. 2. 7. 03:24

DB 아키텍처에 대해 깊히 이해하지 못하는 객체지향 개발자가 흔히 하는 실수중의 하나는 DB에 대한 블랙박스 증후군이다.

이들은 마치 데이타베이스를 라디오의 건전지처럼 교체할 수 있어야 한다고 생각하고 데이타베이스의 특정 기능을 사용하는 것을 마치 나쁜짓인양 무슨 수를 써서라도 피하려고 한다. 그래도 데이타베이스 독립이라는 명록으로 데이타베이스의 기능을 사용하고 데이타베이스 활용을 거부한다. (데이타베이스 종속이란 말 자체가 사실 우물에 독뿌리기 오류를 범하고 있다. 종속이란 그리 듣기 좋은 단어가 아니기 때문이다.) 사실 데이터베이스의 독립을 이루기란 극히 어려울뿐 아니라 반대의 경우보다 훨씬 더 많은 비용이 든다.

이 문제는 크게 경제성과 비경제성 문제로 나눌수 있는데

첫번째로 블랙박스 증후군에 걸린 개발자는 수학이 아니라 공학이 가져야 하는 경제성 즉 비싼 데이타베이스 비용을 낭비하거나 이미 존재하는 기능임에도 직접 작정하는 수고를 통해 많은 시간을 낭비하게 된다.

두번째로 앞에서도 말한바와 같이 실질적으로 매우 어렵다. Ansi SQL만을 사용해서 어떤 제품을 만든다는 것은 Hello World 프로그램 따위에서나 생각할 수 있다. DB 벤더마다 Length, substring 같은 기본 함수들의 사용법이나 명칭등이 다르기 때문에 고작 쓸 수 있는 SQL이란 select * from emp 같은 정도를 사용할 수 있을 뿐이다.

세번째로 어려울뿐 아니라 어떤 문제는 사실 불가능한 것들도 있다. 예컨데 트랜잭션 모델의 경우 Oracle과 MSSQL은 많은 차이가 있고 이는 설사 같은 SQL을 사용했다고 하더라도 다른 결과를 일으키는 경우가 있다.

이러한 등등의 문제로 10년이 넘는 개발자의 생활중 사실 단 한번도 DB 독립을 갖춘 제품을 보지 못했다는 경험적인 이유도 있다. 그 많은 제품들은 물론 데이타베이스의 종속을 피하고자~ 라는 말이 대부분 있었음에도 불구하고 말이다. 실질적으로 제품정도의 코드에서 데이타베이스의 종속을 제거한 코드를 만드는것은 이론적으로 불가능은 아닐지라도 매우매우 어렵다.

그래서 객체지향과 DB는 서로간에 한발 양보를 하고 타협을 하게 되는데.
하나는 객체지향쪽에서 DB쪽으로 한발 접근한 DB를 객체형식으로 다루는 등의 하이버네이트식의 접근이고
다른 하나는 DB쪽에서 아예 객체기반 DB를 만드려는 시도이다.

여기서 두번째는 이 글의 주제와 맞지 않고 사실 논리적으로도 문제가 있다. DB의 종속을 피하기 위해 객체지향 DB를 사용하라는것 자체가 모순이다.(그리고 사실 객체기반 DB는 단순히 종속성의 문제때문에 개발된게 아니다. )


첫번째 하이버네이트식의 접근은 그 열렬한 추종자가 많이 줄긴 했지만 지금도 충분히 지나치게 과대평가라고 생각한다. 몇년전 하이버네이트 소스를 며칠동안 뜯어본적이 있었는데 그 대부분이 케이스별 SQL을 만드는 string 연산이였다. 지금은 나아졌는지 모르겠지만 어쨌거나 이방식은 겉보기에는 종속을 피할 수 있을지라도 DB의 효율성을 심각하게 감소시킨다. 무료(?)인 오픈소스 디비를 사용하면 비용이 안든다고 주장하지만 그 만큼의 저 효율을 어딘가에서 메꿔야 하기 때문에 그닥 납득하지는 못하겠다.

IBatis의 방법은 조금 더 현실적이긴 하지만 이도 역시 다른 문제가 있다. 이건 머 차차 쓰기로 하고 잠이나 -ㅅ-;;;;

'Framework > Database' 카테고리의 다른 글

Framework (DBManager)  (0) 2009.03.04
Framework (구조적 중복 제거)  (0) 2009.02.21
Framework (실험실 코드)  (0) 2009.02.20
Framework (개요)  (0) 2009.02.20
Framework (커서)  (0) 2009.01.12
Posted by bleujin
Framework/Database2009. 1. 12. 14:49

아마도 프로그래머로서 정규 데이타베이스 교육을 받았다면 데이타베이스 커서는 아마 피해야 할 무언가이다. 기본적으로 관계형 데이타베이스는 결과 셋(Set) 지향이기 때문에 레코드 지향의 커서는 가능한 사용하지 말라고 추천되어 지기 때문이다. 그래서 마치 해리포터의 볼트모트처럼 이름을 부르지 않는다면 괜찮을 것이다라는 미신에 휩싸여 프로그래머는 커서에 대해 알려고 하지 않는다. 하지만 사실 프로그래밍의 대부분은 레코드 기반이며 데이타베이스의 셋 지향 방식과 프로그래밍의 레코드 지향 방식의 부조화를 이따금 임피던스 불일치(impedance mismatch)라고 부른다. 이 불일치의 다리 역할을 하는 것이 커서(cursor)이므로 우리는 볼트모트의 이름을 부르는걸 두려워 해서는 안된다.

 

 

DB에 대해 강의 같은걸 할때 종종 처음 묻는 질문중의 하나는 아래와 같습니다.

"세계적인 다국적 기업 '다팔아회사'의 오라클의 customer_tblc 테이블에는 지금까지 등록된 1억명의 고객정보가 등록되어 있습니다. 자 select * from customer_tblc 라는 쿼리를 Toad나 Orange에서 실행하면 몇초만에 화면에 결과 정보가 보일까요?".

실제로 직접 해보면 바로 알수 있는 간단한 질문이지만 경험상 그자리에서 답을 맞추는 사람은 단순히 확률을 벗어나지 못하는 반 정도에 불과합니다. 간단합니다. 실행 버튼을 클릭하자마자 화면에 실행결과가 출력됩니다.

 

이 글은 커서에 대한 글이지만 관련하여 꼭 먼저 알아야 할 것(물론 그 밖에도 알아두어야 할것은 많습니다.:-)이 부분범위 처리이기 때문에 먼저 간단히 소개 하겠습니다. 부분 범위 처리란 실제 쿼리가 처리해야 할 범위와는 상관없이 일부만 억세스하여도 결과 조건을 만족할 수 있다면 일정 단위(Array Size 혹은 Fetch Size)가 되면 결과를 추출할 수 있다는 것을 말합니다. 원래 SQL이란 Java 등의 프로그래밍 언어처럼 처리과정을 기술하는 언어가 아니라 원하는 집합을 표현하는 것이므로 집합 모두에 대한 책임을 가지지만, 온라인 등에서는 집합의 일부분만 먼저 처리하여 제공하고 나머지는 추가적으로 원할 때 제공해도 된다면 사용자의 요구가 있을때까지 잠정적으로 수행을 멈추는 처리방식을 말합니다.

가령 쿼리의 결과로 100만건이 나오는데 그 결과의 집합을 눈으로 확인하고자 하는 것이라면 100만건을 모두 한꺼번에 화면에 보여주기보단 일단 일부분만 제공하고 나머지는 추가적으로 원할때 제공하더라도 문제될 것이 없습니다. 이러한 처리 방식은 주어진 조건을 만족하는 처리범위가 아무리 넓다고 하더라도 실제로 처리할 데이터는 아주 소량이 되므로 조건범위와 무관하게 처리량을 크게 줄일 수 있는 큰 장점이 있습니다.

 

예컨대 위의 고객 테이블을 대상으로(englishName 컬럼에는 인덱스가 없다고 가정합니다.)

  1. select * from customer_tblc where englishName > 'A'

  2. select * from customer_tblc where englishName > 'zzz'

 이 두개의 쿼리를 Toad로 실행했을때 1번 쿼리는 실행하자 마자 결과가 출력되지만 2번 쿼리는 답이 나올때까지 아마도 커피를 몇잔 들이키거나 상황에 따라서는 식사를 하고와도 여전히 모래시계를 볼수 있을 수도 있습니다. 결과의 전체 양과 상관없이 화면에 출력하고자 했던것이 단지 20건 정도였다면 1번 쿼리의 조건을 만족하는 row는 아주 많기 때문에 customer_tblc의 일부분만 읽어도 화면에 출력할 20건 정도를 보이는데 충분할 것입니다. 그에 비해 2번 쿼리는 cust_tblc 테이블의 전부를 읽어도 englishName이 zzz보다 큰 이름을 발견할 확률은 아주 작습니다. (어쩌면 한건도 없을지도 모릅니다.) 그렇기 때문에 한건도 없다는 결과를 보여주기 위해 1억건을 모두 읽지 않으면 안됩니다. (만약 englishName 컬럼에 인덱스가 있었다면 결과는 어떻게 됐을까요? 굳이 여기에 적지 않아도 아마 충분히 알 수 있으리라 생각합니다.)

 

부분범위 처리는 조건을 만족하는 모든 데이타를 보여주는게 아니라 결과중 일부만 추출하므로 사용자가 조건을 만족하는 데이타가 아주 많더라도 아주 빠른 수행속도를 낼 수 있습니다.

 

토막토막 -----------

만약에 위의 예제 상황과 동일한 상황에서

select * from customer_tblc where englishName > 'a' order by englishName

이라는 쿼리를 실행한다면 어떻게 될까요? 위의 경우는 아마도 커피는 커녕 내일 아침에 출근할때까지도 답을 나오지 않을 것입니다. 아마도 그전에 DB는 먹통이 되버리겠지만요. 무엇이 다를까요? where 조건을 만족하는 집합은 아주 많은데 왜 이경우는 부분범위가 되지 않을까요? 물론 조건을 만족하는 집합은 아주 많지만 본문에 예시한 경우와 달리 일부분만 읽고 결과 데이터를 화면에 보여줄수 없습니다. 왜냐하면 사용자는 englishName로 정렬된 데이타를 원했기 때문입니다. where 조건을 만족하는 데이타 adam을 먼저 찾았더라도 adam보다 정렬순위가 더 낮은 이름 20개가 더 있을지 모르니까요. 결국 1억건의 데이타 모두를 확인하지 않고서는 가장 낮은 정렬순위를 가지는 데이타를 추출할 수 없기때문에 위 경우는 전체범위 처리가 됩니다. 그러나 만약에 englishName 컬럼에 인덱스가 설정되어 있다고 가정한다면 이 경우는 부분범위 처리가 가능해집니다. 인덱스는 정렬되어 있기 때문에 인덱스의 앞부분만 읽고도 가장 정렬순위가 낮은 데이타 20개를 얻을 수 있기 때문이죠.

그렇다면 select * from where englishName > 'a' order by phoneNumber 라는 쿼리는 부분범위 처리가 될까요? 안될까요? 만약 가능하다면 인덱스가 어떻게 설정되어 있어야 할까요? 여러 테이블의 join이나 subquery를 사용할때는 어떻게 될까요? 사실 이 문제는 아주 중요한 것이지만 이 모든걸 말하기에는 지면이 충분치 않으므로 커서 얘기에 집중하기 위해 이 글에서는 아주 간단한 예만 들었습니다. 좀더 자세한 원한다면 참고 자료의 '대용량 데이타베이스 솔류선'을 참고하길 바랍니다.

--------------

 

어쩌면 어떤분은 부분범위 처리는 아주 상식적인 문제라고 생각했을지 모르지만. common is not common라는 속담대로 보편적으로 알려진 사실은 아닙니다. 그리고 결과 집합 추출시 잠정적으로 처리를 멈출수 있는 것은 DBMS가 수행속도의 향상을 위해 특별히 제공하고 있는 기능이므로 DBMS나 개발툴 혹은 드라이버 종류 그리고 버전에 따라서도 달라질 수 있습니다. (이를테면 MSSQL은 부분범위 처리가 가능한 DBMS이지만 MSSQL의 SQL 쿼리 분석기는 전체범위 처리를 하므로 모든 결과를 읽을때까지 화면에 보여주지 않습니다. 그에 반해 SQL Enterprize Manager는 부분범위 처리를 하는 Tool입니다.)

 

이제 본격적으로 커서에 대해 이야기 하기전에 테스트를 위해 간단한 예제 코드를 보겠습니다. (MSSQLPoolDBManager는 커넥션 풀링을 해주는 만든 클래스이지만 이 글은 커넥션 풀링과 하등의 관계가 없으므로 그냥 JDBC URL을 인자로 받는 DB 커넥션을 담당하는 클래스라고 생각하시면 됩니다.)

 

------- 예제 소스

 private DBManager dbm = new MSSQLPoolDBManager("com.microsoft.jdbc.sqlserver.SQLServerDriver", "jdbc:microsoft:sqlserver://bleudb:1433;DatabaseName=test", "bleu", "bleu") ;

 

....... (전반부 생략)

public void testStatic() throws Exception {
        Connection conn = null;
        ResultSet rs = null;
        PreparedStatement pstmt = null ;
        
        try {
            conn = dbm.getConnection();  //.............................. 1번

            pstmt = conn.prepareStatement(getProcSQL());

            rs = pstmt.executeQuery();    //..............................  2번

            populate(rs) ;                      //...............................  3번

        } catch (SQLException ex) {
            throw ex ;
        }
        finally {
            closeWithoutException(rs) ;
            closeWithoutException(pstmt) ;
            dbm.freeConnection(conn);  // ............................  4번
        }

 }

 

private String getProcSQL(){

return "select custNo, fixLength from customer_tblc";

}

 

private void populate(ResultSet rows) throws SQLException {
      int i = 0 ;

while(rows.next() && i++ < 10){
      System.out.print(rows.getString(1));
}
System.out.println() ;

}

..... (후반부 생략)

------ 예제 소스

 

테스트 조건은 MSSQL 2000에 Java1.5, MSSQL에서 제공하는 MSSQL2000용 JDBC 드라이버이며 DB서버와 테스트 서버는 별도의 컴퓨터로 분리된 환경입니다. 테스트에 사용된 customer_tblc은 약 59만건의 row를 가지고 있고 평균 row 사이즈는 150 byte 정도입니다. 사실 요즘의 DB 환경에서 59만건은 아주 작은 데이타지만 의미있는 결과를 확인하기에는 충분한 숫자입니다. 그리고 저는 잘못된 코드 예를 들기위해 제 컴퓨터가 멈춰버리기를 바라진 않습니다. :) 그리고 customer_tblc에는 PK로 cluster unique index가 설정되어 있습니다.(cluster 인덱스가 무언인가는 중요하지만 여기서는 설명하지 않겠습니다. 그게 먼지 모르는 분은 그냥 unique index 라고 생각해도 아래의 글을 읽는데 크게 무리는 없습니다.)

 

 

토막 토막 --------------------

위 예제에 사용된 customer_tblc 테이블 디자인은 아래와 같습니다.

CREATE TABLE [dbo].[customer_tblc] (
 [custNo] [int] NOT NULL ,                            -- 일련번호
 [freeLength] [varchar] (100) NOT NULL ,        -- 30-60 byte의 random 단어를입력함
 [fixLength] [char] (100) NULL                        -- 100byte의 고정길이를 가진다. (기본값 'abcde... XYZ')
)

GO

 

ALTER TABLE customer_tblc ADD CONSTRAINT customer_fixLength_dft DEFAULT ('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') FOR fixLength
GO
ALTER TABLE dbo.customer_tblc ADD CONSTRAINT customer_pk PRIMARY KEY CLUSTERED (custNo) ON [PRIMARY]

GO
COMMIT

 

테스트에 사용된 테이블의 디자인은 위와 같은데 평균 row의 사이즈를 150byte로 하면서 테스트와 상관없는 많은 컬럼을 만들고 싶지 않았기 때문에 컬럼명이 다소 이상하더라도 양해 부탁합니다.

---------------------

 

 

아마도 위 소스는 인터넷을 뒤지면 쉽게 찾을 수 있거나 따라하기식의 책에서 보여주는 패턴과 크게 다르지 않기 때문에 코드를 이해하는데 크게 어려움은 없을것이라고 생각합니다. customer_tblc을 읽어서 처음 10건을 화면에 출력하는 코드입니다. 갑자기 다소 단도직입적일지 모르지만 위 코드는 무엇이 잘못되었을까요? (조금 도움이 되고자 힌트를 드린다면 쿼리 자체는 아무런 잘못이 없습니다.) 흔히 볼수 있는 기본적인 소스라 생각할지 모르겠지만 위 코드는 형편없는 코드입니다. 심각한 것은 여러 인터넷 사이트나 심지어는 JDBC를 설명하는 책에도, JDBC Driver를 다운로드 받으면 얻게 되는 help 파일에도 위와 같이 나와 있다는 것입니다. 물론 순수하게 문법 그 자체만으로 본다면 틀리지는 않았습니다. 그러나 다시 말씀드리지만 위 코드는 형편없는 코드입니다.

 

형편없다는 뜻에는 여러가지 의미가 있을 수 있지만 그리고 위 코드는 여러가지 면에서 형편없지만 이 글에서는 효율성 한가지에만 집중하도록 하겠습니다. 왜 위 코드가 형편없는지 이유에 대한 답을 먼저 하기 전에 먼저 DBMS는 select * from customer_cblc쿼리의 결과중 단지 일부만 사용하였을때 customer_tblc의 모든 Row를 읽을것인지 아니면 rows.next가 호출된 만큼만 읽고(위 코드에서는 10건) 나머지는 읽지 않을것인지 에 대해서 생각해 봐야 합니다. DBMS는 위와 같은 상황에서 모든 로우를 다 읽을까요? 아니면 10건 혹은 일부만 읽을까요?

 

자 마음속에 어떤 대답을 떠올렸겠지만 좀더 신중히 생각해 보길 바랍니다. 충분히 생각해 보셨나요? 정답은 "코드가 동일하더라도 상황에 따라 다르다." 입니다. 여기서 상황이란 DBMS의 종류와 버전, 사용된 언어의 종류와 버전, JDBC 드라이버의 종류와 버전을 말하는 것이며 6가지중 한가지만 달라져도 결과는 달라질 수 있습니다. "그게 머야~" 라고 불만의 소리를 낼 독자님들을 위해서 위 경우에 한정되서 말한다면 위 코드는 테이블 전체를 읽었을 가능성이 아주 높습니다. [그림 1]은 위 코드를 실행시켰을때 MSSQL의 Trace 화면입니다..(이 글에서 행해진 모든 테스트 캡쳐 화면은 정확한 숫자를 확인하기 위해 테스트마다 DB를 재시동하였습니다.)

 

[그림 1 : 코드 1을 실행하였을때의 추적 화면]

 

 

위 그림에서 보다시피 위 코드는 쿼리를 실행시키는데 cpu 시간이 718ms가 걸렸고 11409 page를 읽었으며 총 걸린 시간은 6623 ms입니다. (MSSQL에 익숙하지 않은 분은 page라는 용어가 낯설수 있는데 이는 오라클의 block 개념으로서 대부분의 데이타베이스 read와 write는 row 단위로 이루어지지 않고 block 단위로 이루어집니다. 오라클의 block는 2kb - 16kb로 설정가능하지만 MSSQL은 8kb로 고정되어 있으며 이를 page라고 부릅니다. 일반적으로 하드 디스크 할당이 byte 단위가 아니라 최소 저장단위가 클러스터 단위로 쓰여지는것과 비슷한 원리입니다. 윈도우 NTFS 클러스터에 사용되는 기본값이 4kb 혹은 8kb이므로 page는 그 공배수로 지정된 것 같습니다.) <그림 1>의 트레이스를 간단하게 해석하자면 화면에 10건을 찍는 프로그램을 위해 11400 page 즉 11400*8kb = 약 90M를 읽어야 했고 총 걸린 시간은 6.6초인데 그 중 cpu 시간은 0.7초가 소요되었다는 의미입니다. 프로파일러를 처음 보낟거나 이전에 특별히 생각해보지 않았다면 당황스러운 수치입니다.

 

[그림 2 : 테스트에 사용된 customer_tblc 테이블의 크기 정보]

 

우리는 처음에 부분범위 처리에 대해서 이야기를 했고 select custNo, fixLength from customer_tblc은 부분범위 처리가 가능한 쿼리라고 하였습니다. 그런데 왜 부분범위가 적용되지 않은 것일까요. (우리는 마지막에 페이징 처리에 대한 이야기도 할것이므로 select top 10 custNo, fixLength from customer_tblc 식의 쿼리는 여기서 감안하지 않을 것입니다.) 사실 조금만 이성적으로 생각해 본다면 간단한 문제입니다.역지사지라는 사자성어를 여기에 적당한지는 차치하고서라도 자신이 DBMS라고 생각해봅시다.(여기를 보세요. 레드~썬). select custNo, fixLength from customer_tblc 이라는 쿼리를 실행하라고 받았습니다. 이걸 전체를 읽어야 할까요? 부분을 읽어야 할까요? 물론 DBMS는 통계정보를 통해 customer_tblc에는 아주 많은 row 있다는 사전정보를 알수도 있겠지만 사용자가 그 결과 전부를 사용할지 아니면 일부만 사용할지 알수 없는 상태에서 쿼리 그 자체만으로 전체를 읽어야 하는지 부분을 읽어야 할지는 알 수 없습니다. 그래서 쿼리만으로는 판단이 어렵기 때문에 대부분의 DBMS는 이 판단을 위해 한가지 정보를 더 필요로 합니다. 그게 바로 어떤 커서 방식을 사용할것인지에 대한 정보 입니다. 그러나 우리는 위의 소스 어디에도 커서에 대한 설정을 하지 않았습니다. 그리고 커서에 대한 설정을 특별히 하지 않았기 때문에 DBMS의 디폴트 커서를 사용하게 되고 그 결과가 <그림 1>과 같습니다.

 

좀 더 문제에 접근하기 위해 코드 1에 주석으로 달린 숫자부분에 중간중간 경과 시간을 확인하도록 하였고 그 결과는 아래와 같습니다.

2375 (2375ms 연결하는데 걸린시간 - 커넥션 풀링을 만들고 커넥션을 얻는데 2.3초가 걸렸지만 이 글은 커넥션 풀링에 대한 글이 아니기 때문에 이는 상관하지 말고 넘어가기로 합시다. )
2422 (2422-2375= 47ms 쿼리를 실행하는데 걸린시간 )
2453 (2453-2422= 31ms  화면에 처음 10건을 찍는데 걸린시간)

9069 (9069-2453= 6616ms close를 하는시간)

 

다시한번 당황스러울수 있는 결과입니다. 이럴수가~ 쿼리를 실행하는데 47ms가 걸리지 않았는데 close를 하는데 6.6초가 걸렸습니다. 이상하다고 생각할지 모르겠지만 이상하다고 생각하기 때문에 위 코드의 문제를 잘 발견하지 못합니다. 언뜻 생각하면 쿼리를 실행하고 10건을 화면에 찍는데까지는 아주 빠른시간(커넥션풀링을 만드는 시간을 제외하면 고작 78ms)이 걸렸는데 DB가 이상해서 close 하는데 무려 6초가 넘게 걸렸기 때문에 이는 DBMS가 고작 close 하는데 무슨 엉뚱한 일을 잔뜩 하고 있는 것처럼 보이기 때문입니다. (그래서인지 어떤 사람들은 가끔 close를 안하던 사람을 보기도 했습니다. -ㅅ-) 그리고 앞에서 단순한 쿼리에 무려 DB가 90M나 읽었다고 겁을 줬는데 쿼리를 실행하는데 고작 0.047초 밖에 걸리지 않을 걸 보고(?) DB의 놀라운 능력에 감탄하면서 한편으로는 90M라고는 하지만 0.047초인데 어때? 라는 생각을 가질 수도 있습니다.하지만 조금만 더 생각해 보면 코드 중간에 찍어서 확인한 시간들은 그다지 의미가 없습니다. <그림 1>의 추적 화면에서 확인할 수 있듯이 쿼리를 실행하기 위해 11000 page의 90M를 읽어야 했는데 최근의 하드 디스크의 하드웨어 발전 속도를 감안하더라도 90M를 0.047 sec 만에 읽는 다는 것은 비상식이며 DBMS의 추적 화면에서도 실제 쿼리는 6.5초가 걸린걸 확인할 수 있습니다. 그래서 여기서 0.047 sec은 쿼리를 실행하고 테스트 서버가 첫 row를 받았을때의 시간으로 보는게 타당합니다. 물론 프로그램에서는 처음 10건만 확인하면 되기 때문에 쿼리의 모든 결과가 완료되기까지 기다리지 않는 방식은 바람직해 보입니다.

 

그러나 왜 close하는데 6.6초나 걸리느냐에 의문에 답을 하기 위해 우리는 조금 다른 관점에서 생각을 해야 할 필요가 있습니다. 앞서 <그림 1>에 보다시피 DBMS는 90M를 읽었고 화면에는 단지 10건(150byte*10 = 1.5kb)만을 찍었습니다. 그럼 나머지 DBMS가 불필요하게 읽게 된 90M-1.5kb의 데이타는 어떻게 됐을까요?

 

잠시 여러분이 생각할동안 딴 얘기를 하자면 우주 공간에 수명이 다한 로켓이나 인공위성 파편들로 생긴 우주쓰레기를 데브리라고 합니다. 이러한 데브리 중 1cm 이상의 크기가 약 3만 5천개로 추정하고 있으며 데브리는 초속 8km로 지구 궤도를 돌고 있기 때문에 우주선이나 우주인에게 생명의 위협이 됩니다. 그래서 10cm이상의 데브리 약 3000개는 북미항공우주방위 사령부에서 리스트를 만들어 그 위치를 실시간으로 감시하고 있을 정도로 중요하게 인식되고 있습니다. 데브리 처럼 지구퀘도를 돌고 있지는 않지만 개인적으로는 위 경우에 생긴 110-1.5kb의 데이타를 저는 개인적으로 네트워크 데브리라고 부르곤 합니다. '우주쓰레기 어쩌고 저쩌고 하는걸 보니 자연적으로 휘발되진 않겠군' 이라고 짐작하셨다시피 위 코드 1을 실행하면 나머지 모든 데이타도 DB에서 위 코드를 실행한 컴퓨터로 네트워크 전송이 일어납니다. 다만 우주 쓰레기와는 달리 계속 지구를 공전하지는 않고 받자마자 쓰레기로 간주되어 사라지겠지만요.

 

[

메시지가 비동기적이다. (DB서버 컴퓨터와 테스트 애플리케이션 컴퓨터는 비 동기적으로 통신을 하였다.)

패킷 사이즈가 유동적이며 대부분 1개 이상의 row가 담겨 있으므로 패킷 길이가 좀 더 길다.(54047 패킷의 경우 10row가 있으며 총 길이는 1460byte 입니다.)

패킷 교환 갯수가 많다.(약 55000개의 패킷을 교환하였다.)

 

 

<그림 2>는 리셋한 이후 위 코드를 전송하고 테스트 컴퓨터(192.168.10.102)와 DB서버가(192.168.10.101) 주고받은 네트워크 패킷을 캡쳐한 화면입니다. 물론 그 대부분의 패킷은 쓰레기로 간주되어 사라질 운명인 590000 - 10 row의 데이타 들입니다. 패킷은 최소 512 byte 단위로 통신하고 그리고 패킷을 받으면 인사성있게:) 잘 받았다고 답인사도 해줘야 하기때문에 약 55000개의 패킷을 주고 받게 되었습니다. (그림 2의 하단부의 패킷의 사이즈가 1460인걸 보면 해당 패킷은 10개의 row를 담고 있으리라 추측됩니다.) 그래서 위에서 DB가 고작 close를 하는데 먼가 알수 없는 수상한 짓이라고 생각했던게 사실은 사용하지도 않을 데이타를 잔뜩 받고 잘 받았다고 답 인사하는 걸리는 시간입니다. 그래서 우리는 불필요한 데이타를 읽는데 DBMS의 시간을 6초나 소요하게 했을뿐 아니라 동시에 중요한 네트워크 버퍼를 90M나 낭비했던 것입니다. (그리고 그 네트워크 데브리 처리를 하느라고 테스트 컴퓨터도 무려 6초가 소요되었습니다.)

 

여기까지 이야기하면 상당히 충격적인 결과일지도 모릅니다. 그래서 그동안 라면 받침대로 사용해왔던 JDBC 관련서적들을 다시 천천히 읽다보면

conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
conn.setReadOnly(true) ;

rs.setFetchSize(DEFAULT_FETCHSIZE);

result.setTransactionIsolation(java.sql.Connection.TRANSACTION_READ_UNCOMMITTED);

와 같은 그럴듯해 보이는 코드들의 설명이 나옵니다. 게다가 위와같이 read only로 지정하거나 가장 낮은 레벨의 트랜잭션 레벨을 지정하면 웬지 더 빨라질것 같습니다. 바로 이게 함정입니다.(물론 함정을 판 사람은 없습니다. 스스로 함정을 만들어 빠지는 거죠) JDBC는 여러분이 잘 알다시피 규약일뿐 상세 구현은 Driver 구현업체가 담당하기 때문에 첫째로 규약대로 드라이버가 작성되는건 아니며, 두번째로 설사 구현이 되었다고 하더라도 어떤 특정한 선행 조건을 만족하지 않으면 코드만 저렇게 적어둔다고 작동하는게 아니라는 사실때문에 제대로 작동하지 않습니다. (심지어는 그 특정한 조건은 드라이버 별로 조금씩 다르기도 합니다. )

 

경험삼아 이야기 합니다만 여러분이 어떤 JDBC책을 열심히 뒤져봐도 아마도 [코드 1]의 문제를 해결하기 위한 답을 얻기는 힘들것입니다. 이 글이 커서에 관한 글이니 커서에 관한 문제라는걸 알았다고 하더라도 일단 기본적으로 JDBC에는 커서 설정하는 setCursor 같은 코드 자체가 없습니다. 커서에 대한 얘기를 찾아보면 프로그래밍 책에서는 updatabe resultset이니 holdable cursor 등등 다중 사용자의 update나 delete혹은 insert의 MDL 구문과 같이 사용될 경우의 예제만 있을 것이고, DBMS책에서는 Declare cursor 등의 특정 DB에 종속되어 있는 SQL 커서 얘기만 잔뜩 나올것입니다. 그리고 여기에서 이야기 하는 API Server 커서 - Select 시에 커서에 대한 이야기는 아마도 제대로 나와 있지 않을 것입니다.(커서에 일부 챕터를 할애한 경우는 드문드문 찾아볼 수 있지만 충분한 내용을 담고 있다고 하기에는 어려운 수준이며 표면적인 나열 수준을 벗어나지 못했습니다.)

 

전 오래 전부터 왜 대부분의 책에서 커서 이야기를 하지 않을까? 궁금해 했고 가능한 3가지의 이유가 있으리라 추측합니다. 첫번째로 책의 저자들 자신이 프로그래밍에서 사용되는 커서에 대해서 모르기 때문이고 두번째로 설사 안다고 하더라도 너무 복잡다양한 사항에서 그 모든 걸 설명하기란 무척 어렵기 때문입니다. 그리고 3번째로 그 모든걸 설명할 수 있다고 하더라도 실제 문제의 해결방법 역시 DB와 프로그래밍 언어, 드라이버의 문제 등으로 다양하기 때문에 하나의 분야를 가진 책에 담기가 어렵기 때문입니다. 그러나 그렇다고 해서 커서에 대한 내용을 무시할 수 있을만큼 배울 필요가 없는 내용이냐 하는것은 전혀 아닙니다. 10년동안의 프로그래밍과 DBA의 경험으로도 DBMS의 속도 장애와 관련한 대부분의 경우는 설정이나 쿼리 그 자체보다 커서의 잘못된 사용 - 커서의 문제라는게 비록 설정과 쿼리를 포함하는 복합적인 문제이긴 하지만 -에 있는 경우가 더 많기 때문입니다.

 

이미 말했듯이 JDBC는 직접적으로 커서를 설정하는 메소드가 없습니다. 아마도 커서는 프로그래밍보다는 DB에 더 가까운 얘기이고 DB마다 조금씩 다르기 때문에 WORA사상의 JDBC 표준 메소드에는 넣기 어려워서가 아닐까 추측합니다. 그러나 그렇다고 해서 JDBC가 커서를 사용하지 않느냐 하는것은 또 아닙니다. 커서는 마치 눈에 보이지 않는 공기처럼 DBMS를 사용하는 한 떨어질 수 없는 분야이기 때문입니다. JDBC에서는 Statement 객체를 만들때 ResultType이라는 상수타입을 설정할수 있는데 이 부분이 가장 커서와 가깝다고 할 수 있는 부분입니다. ResultType은 TYPE_FORWARD_ONLY, TYPE_SCROLL_INSENSITIVE, TYPE_SCROLL_SENSITIVE 3가지 종류가 있으며 역시 드라이버마다 조금씩 차이가 있지만 아무 설정도 하지 않을 경우 TYPE_FORWARD_ONLY로 설정될 경우가 많습니다. 그래서 위 코드에서 pstmt = conn.prepareStatement(getProcSQL()); 대신 pstmt = conn.prepareStatement(getProcSQL(), ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); 는 같은 의미이며 (위 예제의 드라이버와 DB 등이 동일하다면요.) ResultType을 ForwardOnly로 설정하면 [그림 1]과 [그림 3]의 결과가 나옵니다. 엄밀하게 말해 ForwardOnly나 SensitiveType 등은 커서의 종류가 아닙니다. 오히려 그 반대로 커서가 결정될때 종속적으로 결정되는 - 혹은 부가적으로 결정하는 - 타입에 불과하지만 JDBC에서는 Type을 설정하면 DB가 알아서 적절한(?) Cursor의 종류를 선택하게 됩니다. 좀 더 나중에 커서의 종류에 대해서 이야기를 하겠지만 미리 조금 얘기를 하면 위 코드에서 ForwardOnly Type로 설정하면 MSSQL의 경우 ForwardOnly cursor를 사용하게 되고, SensitiveType와 InSensitiveType에서는 Static cursor를 사용합니다.

 

그럼 pstmt = conn.prepareStatement(getProcSQL(), ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); 렇게 코드를 변경하고 코드를 실행시켜 보기로 합시다.(단 한줄만 바꿨지만 이 코드를 [코드 2]라고 하겠습니다.)

 

<그림 3>은 그렇게 바꾸고 실행한 결과의 DB Trace 화면입니다.

 

한줄만을 바꿨지만 무언인가가 <그림 1>과 달리 잔뜩 바꼈습니다. 일단 화면을 그대로 해석하자면 Read하는데 178만 page(단위는 bit나 kb가 아니라 page라고 불리는 8kb block 개념이라고 위에서 말했죠)를 읽었고 8706 page를 write 하였습니다. 그리고 cpu시간은 4초정도 걸렸고 총 6.7초가 걸렸습니다. 그리고 이전에는 보이지 않던 sp_cursorfetch에 관련한 새로운 열들이 보입니다. [코드 1]의 ForwardOnly와 비교하여 실행시간의 조금의 차이는 있지만 10건을 읽고자 하는것에 비하면 여전히 느리다는 사실은 동일합니다.

 

아니 그전에 잠깐 178만 page를 read 했다고 했던가요? 178만 page는 byte로 환산하면 무려 1500G에 해당되는 엄청난 양입니다. 이 테이블에는 약 58만건이 들어있으며 인덱스를 포함한 전체사이즈는 <그림 2>에 보다시피 130M에 입니다. 그런데 이게 무슨 경우인가요. 설사 한 page당 1 row가 들어있는 최악의 경우라 해도 최대 58만 page를 읽어야 하는게 아닌가요? 178만 page read는 도대체 어떻게 나온 숫자인가요? 이거 버그 아닌가요? 게다가 웃기게도 8706 page의 write는 또 먼가요? 쿼리는 분명 select인데 웬 write를 하고 있나요? 그리고 저 read도 0이고 write도 0인 쓰잘데기 없어 보이는 sp_cursorfetch는 왜 일어나고 있는건가요? 등등 무수한 의문이 떠오르는 캡쳐 화면입니다. (드디어 Trace 화면에 이 글의 주제인 cursor가 나오기 시작했습니다.)

 

일단 그 의문에 답하기 전에 [코드 1]과 마찬가지 위치에 중간 Time을 살펴보도록 하겠습니다.

1. 2375 (2375ms connect 얻는데 걸린시간 - 역시 이 시간은 신경쓰지 말도록 하죠)
2. 9187 (9187-2375= 6812ms 쿼리를 실행하는데 걸린시간 )
3. 9218 (9218-9187= 31ms  화면에 처음 10건을 찍는데 걸린시간)
4. 9220 (9220-9218= 2ms close를 하는시간)

 

[코드 1]의 결과와 비교하여 비슷하게 느린 속도이지만 어느 지점이 오래 걸렸는가가 다릅니다. close는 거의 시간이 걸리지 않았는데 이번에는 좀전에 0.047초 밖에 걸리지 않았던 쿼리가 6.8초가 걸렸다고 나옵니다. 우리는 쿼리 내용을 바꾼적이 없습니다 다만 쿼리를 실행할때 ResultSet.TYPE_SCROLL_INSENSITIVE Type을 실행하라는 것만 바꿨을 뿐이죠.

 

그래서 같은 코드를 ResultSet.TYPE_SCROLL_SENSITIVE로 바꿔봅시다.

pstmt = conn.prepareStatement(getProcSQL(), ResultSet.TYPE_SCROLL_SENSITIVE, ResultSet.CONCUR_READ_ONLY);  ( [코드 3] 이라고 부르겠습니다.)

<그림 4>는 [코드 3] Trace 화면입니다.

 

 

cpu 시간하고 duration 시간이 조금씩 바뀐거 말고는 <그림 3>과 아무런 차이가 없습니다. (cursor fetch가 나타나는거나 심지어는 read나 write page는 숫자도 동일합니다. ) cpu 시간이나 duration은 그냥 오차범위내의 시간이므로 사실 <그림 3>과 <그림 4>는 동일한 결과라고 봐도 무방합니다. 즉 위 예제코드에서 insensitive와 sensitive 차이는 아무것도 없습니다. 물론 그렇다는 것이 본래적으로 insensitive와 sensitive가 차이가 없다는 뜻은 아닙니다. 다만 이 코드에서는 아무런 차이도 불러 일으키질 않았다는 뜻이고 좀더 정확히 말하자면 여러분이 insensitive type을 쓴다고 코드에 적어놓는다고 하더라도 위 코드에서는 sensitive type으로 작동한다는 뜻입니다. (왜 결과가 같은데 왜 insensitive가 아니라 sensitive로 작동했다고 하는 건지에 대한 설명은 나중에 하겠습니다.) JDBC에서 선택할 수 있는 ResultType은 3가지지만 SensitiveType와 InSensitive타입은 위 경우에 차이가 없으므로 같이 얘기하겠습니다.

 

일단 고작(?) 58만 row를 읽는데(더군다나 그 대부분은 프로그래밍 코드에서 사용하지 않겠지만요) 왜 178만 page나 읽어야 했는지를 살펴보겠습니다.

여기서 간단한 질문~ 178만 page를 read하고 8706를 write 했을까요? 아니면 8706 page를 write하고 178만 page를 read 했을까요? 상식적으로 read한게 없는데 write를 할수는 없는 일이니 write를 먼저 한다는건 말이 되지 않습니다. 그렇지만 178만 page를 read 한다음에 8700 page를 write 했다고 하려니 이도 상식에 맞지 않습니다. 고작 8700 page를 쓰기위해 row수보다 많은 page를 read 해야 할 이유가 없어 보이기 때문이죠. 사실 위의 과정은 read-write-read 과정으로 진행되었습니다. 그리고 1번째 read와 3번째 read를 합쳐서 결과를 보여주다보니 178만 이라는 read 수가 나온것입니다. 과정을 단계적으로 보면 아래와 같습니다.

 

1. read

일단 DB는 select custNo, fixLength from customer_tlbc를 모두 읽습니다. <그림 1>에서 보다시피 select custoNo, fixLength from customer_tblc 쿼리는 11400 page를 읽으면 모든 row를 읽게 됩니다. (만약에 DB에 2개 이상의 CPU가 있고 병렬화 실행계획이 세워진다면 - 위와 같이 where 조건이 없는 쿼리는 병렬화 실행계획이 세워질 확률이 높습니다. - read page수는 달라집니다. 하지만 이 글을 읽는 분들은 이 이상 꼬인 문제를 보고 싶어하지 않을 것이므로 위 테스트는 모두 병렬화 계획이 실행되지 않도록 설정하였습니다.)

 

2.write (1단계 read를 마치고 write를 하는게 아니라 read를 진행하면서 write를 같이 합니다.)

우리는 freeLength 컬럼을 select 대상에 넣지 않았으므로 11400페이지을 읽었지만 custNo와 fixLength만 DB의 메모리에 write를 합니다. 해당 쿼리는 길이 (4byte +100byte)의 row를 590000개 읽었으므로 104*590000/1024 kb는 대략 59921 kb인데 한 페이지는 대략 8k이므로 대략 7490 page을 쓰면 됩니다. 어라 1200 page 정도가 남네요? 계산에 하나 빠트린게 있습니다. 새로운 결과셋은 일종의 "메모리에 보관되는 임시 테이블"이므로 모든 row은 자신을 구별할수 있는 unique한 메모리 주소를 가져야 합니다.(오라클의 경우 rowId는 의사 컬럼일뿐 실제 테이블이 rowId라는 컬럼을 가지거나 공간을 차지하지 않지만 - index에 rowId가 저장됩니다 - MSSQL에서는 여러번의 테스트를 통해 추측하건데 메모리에 set 집합을 만들경우 rowid의 역햘을 하는 컬럼을 가지는 걸로 보입니다. ) 위의 경우 MSSQL의 row별 unique한 ID 사이즈는 알려지지 않았으나 오라클의 경우 rowId는 18byte이므로 대략 18byte로 하고 다시 계산해보겠습니다. 그러면 평균 row길이는 (4+100+18)이므로 122*590000/1024/8 = 대략 8786 page 입니다. 우와 write page의 비밀이 풀렸습니다. 즉 우리는 먼저 customer_tblc의 모든 row를 읽는데 11400 page를 읽었고 쿼리 결과를 일종의 임시 테이블인 set 상태로 보관하기 위해 DBMS가 관리하는 메모리에 8700 page를 write 하였습니다. 그리고 끝인가요? 아뇨 아직 read 단계가 하나 더 남았습니다.

 

3. read

-----

<그림 3>에서 @P1은 Handle Number 입니다. Handle Number가 같은 쿼리라면 해당 테이블에 변경이 없는 경우 위 결과셋은 재 활용될 수 있습니다. Handle Number가 같기 위한 조건은 쿼리와 설정내용이 같아야 하며 쿼리에 사용된 테이블에 변경사항이 없어야 합니다. @P2가 8이라는건 나중에 다시 이야기 하겠지만 DBMS는 static cursor를 사용한다는 뜻입니다. @P3이 1 이라는건 read-only 모드라는 뜻입니다.

그리고 @P4는 rowcount인데 1-2 단계를 거친 상태이므로 589824라는 정확한 rowcount를 알고 있습니다.

 

------

 

메모리에 write 하는 결과 셋은 쿼리에 따라 사이즈가 매우 커질 수 있으므로 수백 혹은 수기가에 이르는 연속적인 메모리 공간을 확보하기란 매우 어려울 것이므로 보통의 경우 연속적인 공간이 아닌 메모리에 페이지 별로 조각 조각 흩어져 있을 수 있기 때문에 마치 링크드 리스트처럼 페이지들을 - 그리고 row 들을 - 연결하는 과정인 populate 과정을 실행하는데 여기가 중요합니다. 이 과정은 동기적 - 즉 클라이언트는 이 과정이 끝날때까지 기다려야 합니다 - 작업입니다. (이 과정이 비동기적으로 이루어지면 프로그램상에서 rows.abstolute(int row)나 rs.last()를 실행할수 없게되고 JDBC에서 sensitive type으로 쿼리를 실행할경우 resultSet은 scroll 가능하다라는 조건에 위반됩니다.)

 

앞에서 write한 set은 연속적인 공간을 확보하기 위해 빠른 row의 셀렉팅을 위해 실제로는 Balanced Tree 형태를 가지게 됩니다. 앞서의 계산대로 rowId를 18byte로 계산하면 한 page에는 약 400개 page의 첫 rowId를 저장 할수 있습니다. 그리고 그 400개의 page는 다시 160000개 page의 처음 rowId를 저장할수 있습니다. 현재 set은 8700 page이므로 3depth(level)로 충분히 표현이 가능한 수치입니다.(400보다 크고 160000보다는 작다.) 그래서 populate 과정에서 매 row당 평균 3번의 read가 일어납니다. (테스트에 의하면 전형적인 Balanced Tree 형태는 아닌걸로 생각됩니다. 보통의 경우 Balanced Tree는 Leaf Level은 모두 동일한 Depth를 가지도록 설계되는데 위의 경우 page수가 많을 수록 기대 depth수 보다 커지고 페이지수가 작을수록 기대 depth수 보다 작아집니다. ) 그래서 추가적으로 590000*약3 의 추가적인  read가 발생하게 됩니다. (오라클의 경우 SGA의 Database buffer cache에 block를 복사하는 개념이기 때문에 위 과정은 MSSQL의 과정일 뿐입니다. 다만 세부 과정은 다르지만 1.read -> 2.write -> 3.read 과정은 DB와 상관없이 동일합니다. 그리고 1과 2과정은 캐싱될 수 있습니다. )

 

 

즉 <그림 3>의 1789259 read와 8706 wrtie는 실제로는 (11400 read + 8700 write + 590000 *3.x read) 의 결과치입니다. ( 산수에 익숙하지 않으신분은 이때쯤 '집어치워~ '라며 더이상 읽게 되지 않을까 조금 걱정이 됩니다.  )

 

4. 그 이후의 과정을 이야기 하면

모든 row를 커서에 채운후 sp_cursorfetch 180150000, 2, 1, 1 로 맨 처음 위치로 돌아옵니다.(180150000은 handle 값이고 2는 next row라는 의미인데 populate를 완료한 후 last에서 next이므로 맨 처음으로 돌아옵니다. ) 그리고 sp_cursorfetch 18015000, 16, 1, 1(180150000은 handle값, 16은 absoleute row index, 1 rownum, 1 number of row) 로 처음의 1개 row를 fetch 합니다. 조건을 while(i++ < 10 && rows.next()) 렇게 적었다면 10개의 row를 fetch 했겠지만 코드는  while(rows.next() && i++ < 10) 와 같이 되어 있으므로 11개의 row를 fetch 합니다.

첫 row를 읽을때 다시 3 page를 읽지만(3 depth tree) 2번째 row는 같은 페이지에 있기 때문에 2번째 로우에서는 read가 일어나지는 않습니다. (위 source는 단지 10개의 행을 찍게 되어 있지만 1000개쯤 찍도록 바꾸고 다시 트레이스 화면을 보면 1페이지당 대략 55개의 row(8k/150)가 들어가 있으므로 약 55번의 fetch 마다 3번의 read가 일어납니다.그리고 sp_cursorclose를 하여 마무리 합니다.

 

 

위의 내용은 단지 기술적인 내용이지만 위 과정이 [코드 1]의 결과보다 나은가요. 아니면 나쁠까요?

 

일단 178만번 read의 숫자가 커 보입니다. (Duration 6.7초와 CPU 3.4 초의 차이인 3.3초는 대부분 Disk IO인 처음 1100 page(90M)를 Read하는데 걸린 시간일테고 CPU 3.4초의 대부분은 populate과정중의 177만 page의 Memory Read 속도일테니 심심한 분은 스펙상이 아닌 일반적으로 자신의 하드디스크와 메모리의 read 속도를 계산해 보는것도 재미있을지 모릅니다.-ㅅ-). 1번의 테스트와 비교하여 실제 총 Duration은 비슷 하다지만 (6.5초와 6.7초로 단지 0.2초의 차이지만 사실 정말로 그정도의 차이에 불과하냐고 물어보면 그 대답은 위 테스트만으로는 알 수 없다 입니다.  영화에서 대부분의 암살자는 단 한발의 사격으로 타겟을 죽이고 유유히 돌아서지만 실제로 정말 죽이는데 목적이 있는 거라면 2발 혹은 여러발을 쏘는게 확률이 훨씬 높아집니다. 치명적인 곳을 맞히지 못하더라도 출혈 과다로 죽일 수 있기 때문입니다. 갑자기 웬 엉뚱한 소리냐고 할지 모르겠지만 실제 걸린 속도를 한번의 테스트로 평가할 수는 없습니다. 그래서 퍼포먼스 테스트때는 총은 2발 쏴라라는 격언이 있습니다. 그래서 실제로는 테스트되는 컴퓨터 등의 사양에 따라 달라질수 있기에 총을 여러발 쏜후 확인해야 겠지만 여기서는 시간 그 자체보다 절대적으로 몇 page를 읽었는가가 더 중요합니다. ) 몇가지 단점을 가지고 있습니다.

 

첫번째로 화면에 첫 Row가 찍히는데 오래걸립니다. sensitive type은 동기적인 작업이 필요하므로 590000로우를 모두 읽기 전에는 단 한행도 클라이언트게 리턴시키지 않습니다. 그래서 처음 행을 화면에 찍는데 9초나 걸렸습니다. 그에 비해 테스트 1은 비 동기적으로 데이타를 받기 때문에 처음 행은 2.4초만에 찍을 수 있었습니다. 물론 테스트 1은 그 후에 네트워크 데브리를 처리하느라 close 하는데 6.6초나 걸리기 떄문에 전체 시간은 비슷하겠지만요.

 

두번째는 더 심각한 문제인데 write를 하느라고 공유 자원인 DBMS의 메모리를 약 70M나 소모했습니다.  데이타 베이스의 메모리는 혼자 쓰는 공간이 아니기 때문에 대부분 필요없게 될 데이타를 위해 70M 쓰느라 자주 사용되는 캐쉬에 올라와 있는 데이타를 메모리에서 내려버릴지도 모릅니다. 이를 흔히 미꾸라지라고 하는데요. LRU 알고리즘으로 자주 사용되는 데이타는 Disk IO를 하지 않기 위해 메모리에 로드가 되어 있는데, 위와 같은 코드가 실행되었을 때 충분한 여유 메모리가 없다면 캐쉬 데이타를 모두 지워 버릴 것입니다. 테스트 테이블의 데이타가 훨씬 더 많다면 최악의 경우에는 가상 메모리를 사용해야 할지도 모릅니다. (이때의 read 속도는 Memory IO가 아니기 때문에 무시 무시한 속도가 나올것입니다.) 냇가를 미꾸라지 한마리가 흐린다는 속담처럼 그냥 저냥 돌아가던 시스템이 위 코드처럼 미꾸라지 코드가 돌면 메모리에 캐쉬된 데이타는 모두 삭제 되버리므로 평소보다 훨씬 더 느려질 것입니다. A라는 특정 페이지만 갔다오면 기존에는 빨랐던 B..Z페이지마저 느려지다가 시간이 좀 지나면 다시 회복되고 다시 A 페이지를 누군가가 로드하면 또 B..Z 페이지가 느려지는 등의 같은 부분인데도 매번 속도 차이가 심하다면 이런 미꾸라지 코드를 의심할 필요가 있습니다. 사실 고작 10row를 보기 위해 메모리 90M를 쓰도록 하는것은 범죄에 가깝습니다.

 

세번째는 장점이지만 상황에 따라서는 단점이 될 수도 있습니다. [코드 1]의 경우 90M를 읽어서 클라이언트에게 90M모두를 보냈습니다. 클라이언트 입장에서는 단 10건만 사용하겠지만 DB는 데이타베이스는 비동기적으로 59만건의 데이타를 모두 보냅니다. 앞의 <그림 2>에서 DB와 클라이언트가 주고받은 패킷의 수는 5만개가 넘습니다. <그림 7>은 sensitive[insensitive] type으로 실행되었을때 네트워크 캡쳐 화면입니다. 패킷의 수는 45개 정도에 불과합니다. 비록 테이블의 모든 데이타를 읽었지만 네트워크를 통해 클라이언트로 전송된것은 단지 10 row 정도에 불과합니다. (실제로 해보면 12 row를 전송합니다. 하나는 while (i++ < 10 && rs.next()) 라고 하지 않고  while (rs.next() && i++ < 10) 로 했기 때문이고 또 하나는 <그림 2>에서 보다시피 last인 상태에서 first 상태로 이동하면서 first Row를 한번 더 전송합니다. 물론 이건 해당 JDBC Driver의 문제일뿐 일반적으로 적용되진 않습니다.) 그리고 매번 row를 받을때마다 잘 받았다고 답례 인사를 해줍니다. (2*12 를 제외한 나머지 패킷들은 연결정보를 설정하고 JDBC 드라이버의 기본 세팅정보, 쿼리등이 차지합니다.) 이건 분명 여기서는 장점으로 작용합니다. 네트워크 데브리를 만들지 않았으니까요. 대충 필요한 row*2 정도의 패킷만 주고 받으면 되니까요. 하지만 이는 다시 생각하면 단점이 되기도 합니다. 만약에 위 코드가 일부 만이 아니라 쿼리의 결과가 모두 필요한 경우를 가정해 봅시다. 어떤 where 조건이 있어서 그 결과인 1000건 모두를 출력해야 하는 프로그램이 있다고 해보죠. 결과를 모두 출력하고자 하는 경우라면 [코드 1]은 네트워크 데브리가 없습니다. 비 동기적으로 데이타를 전송하기 때문에 하나의 패킷에 10개의 row를 전송할 수도 있습니다. <그림 2>를 보면 하나의 메지지 프레임의 길이가 1450 byte 입니다. 이에 반해 테스트 2는 오직 한건씩 동기적으로 주고 받습니다. 클라이언트의 요구사항이 있을때(rows.next()) 하나씩의 row를 전송해주기 때문이죠. 모든 row가 필요하다는 가정에서 테스트 1은 59만 row를 전송하기 위해 5만 5천번의 패킷을 주고 받았지만 테스트 2는 115만번 이상의 패킷을 주고 받아야 할겁니다. 비록 패킷 사이즈의 총량은 동일하더라도 패킷의 수는 20배 가까이 늘어나므로 네트워크에 더 심각한 영향을 줍니다.

 

네번째로 - 이걸 장점이라고 부를 수 있을지 모르지만 - rs.absolute(int row)나 rs.last() 등이 메소드 들이 제대로 작동한다는 것입니다. rs.last()를 호출한후 rs.getRow()를 호출해 현재 쿼리결과의 갯수를 알수도 있습니다. [코드 1]은 비동기적으로 row를 받기 때문에 rs.absolute() 등이 작동하지 않고 갯수를 알기 위해서는 while(rs.isLast()) 까지 rs.next()를 해봐야 알 수 있습니다. 그래서 어떤 책들에서는 [코드 2]방식을 권장하곤 합니다. 단지 "XX건의 글이 등록되어 있습니다 " 식의 한줄의 메시지를 보여주기 위해서요. 하지만 상식적인 생각으로 간단히 알수 있는 사실이 있습니다. 여러분이 전체를 모두 읽지 않았다면 결코 정확한 count를 알수 없기 때문에 한 줄의 메시지를 위해 모든 row를 읽어서 메모리에 write를 해야 합니다. 그럴바에는 그냥 select count(*) from ... 의 쿼리를 한번 더 실행시키는 게 훨씬 더 빠릅니다. (select count(*) from... 도 DB에 따라서 테이블 전체를 읽는건 변함없습니다만 대부분의 상용 DB는 이런 경우에 Fast Full Scan의 MultiBlock IO을  할 확률이 높을뿐 아니라 모든 로우를 읽어서 클라이언트에 전송하거나 메모리에 write 할 필요가 없습니다. 필요한건 그냥 숫자 하나니까요). 쿼리 결과가 589824건이란 사실을 알기위해 589824 전체를 모두 전송하거나 전체를 데이타베이스 메모리에 write 하는건 당연히 아주 아주 바보짓입니다.

 

 

[위 그림에서 주목해야 할점 : 하나의 메시지마다 1건이 fetch 됐다.(메시지 사이즈 145), 클라이언트와 서버의 통신이 동기적이다(너한번, 나한번), 갯수가 작다 ]

 

 

아마도 어떤 분은 위의 경우는 특수한 경우일 뿐이다 라고 생각할지 모르겠습니다. 행운이 함께한다면 처음에 언급하였다시피 DB의 종류와 버전, Driver의 종류와 버전, 언어의 종류와 버전 이 6가지중 한가지만 바껴도 전혀 다른 결과가 나올수도 있으니까요. 하지만 위와 같은 형편없는 코드는 언어나 DB와 상관없이 아주 일반적으로 소개되어집니다.(다른 관점에서 형편없다는 이야기도 언젠가 할수 있기를 바랍니다.) 인터넷 사이트나 책 등에 권장하는 샘플코드로 쓰여있을뿐 아니라 개인적으로도 10년간의 프로그래밍과 컨설팅 경험 중 테이타베이스와 언어를 바꿔가면서 질리게 봐왔습니다. 물론 1차적인 책임을 코드와 그 코드 작성자에게만 돌릴수는 없습니다. 왜냐하면 그런 코드들은 쿼리의 결과가 아주 아주 작다면 문제가 드러나지 않을 것이고 한결같이 예로 든 테이블들은 아주 아주 크기가 작기 때문이죠. 중요한 것은 예제 테이블이 아니라 실제 테이블을 대상으로 row수가 많아지면 그럭저럭 돌아갔던 코드들이 아주 이상해져 버린다는 거죠. WORA가 모토인 Java는 더욱이 연결 문자열만 몇줄 바꿔주면 제대로 돌아가겠지라고 생각해버립니다.

 

악화가 양화를 망친다는 말처럼 사람들은 위와 같은 코드들을 당연시 하기 때문에 문제가 생겼을때 전혀 엉뚱한 방향으로 해결하려고 합니다. 테이블의 건수가 많아지니 느려졌다고 불평하며 하드웨어를 업그레이드하거나 업그레이드로도 따라가질 못하는 수준이 되면 테이블을 종으로 쪼개는 등의 역정규화를 아무렇지 않게 저질러 버리는 거죠. (이 때가 되면 프로그램으로 그 부분을 처리하느라고 거의 암호가 됩니다.) 그마저도 안되면 프로그래밍 언어나 DB를 바꿔버립니다. 그러다 어느날 우연한 조합으로 문제가 해결된것 처럼 보이면 문제는 역시 그거였군이라고 제멋대로 생각해 버립니다. 사실 현재 프로그램이 필요한 row만 정확히 처리할수 있다면 테이블의 row는 1000건이든 1000억건이든 하등의 상관이 없어야 합니다. 중요한건 프로그램이 필요한 row를 정확히 읽었느냐는 거죠. 비록 해결책이 상황마다 다르다- 어떤 상황에서는 DB 설정을 바꿔야 하며 어떤 상황에서는 프로그래밍 코드를 바꿔야 하며 어떤 상황에서는 다른 Driver를 사용해야 할 때도 있습니다. - 는 것때문에 문제가 되는 것이지 해결책이 전혀 없는 것은 아닙니다.

 

저는 커서가 DBA와 프로그래머의 간극의 문제라고 생각 합니다. DBA의 입장에서 select custNo, fixLength from customer_tblc는 전혀 잘못된 쿼리가 아닙니다. 부분범위 처리가 제대로 되었다면 이 쿼리는 0.01초 내에 처리가 되는 아주 가벼운 쿼리입니다. 그리고 DBA와 프로그래머 모두 여러개의 DB와 여러개의 버전 그리고 여러개의 드라이버와 여러개의 드라이버 버전, 여러개의 언어와 여러가지 언어의 버전 최소한의 경우의 수만 생각해도 수백 수천가지가 넘는 모든 경우의 수를 알 턱이 없습니다.(저도 그 많은 경우를 모릅니다. 다만 필요할 때 이 경우는 이렇군 이라는 사실을 테스트 하여 확인할수 잇을 뿐입니다.) 그래서 프로그래머는 현재 DB의 아키텍쳐에 신경쓰지 않고 DBA는 현재의 프로그래밍 처리 방식을 신경쓰지 않기 때문에 그 접점인 커서에서 폭탄이 꽝 하고 터져버리는 것입니다. 사실 저는 사람들이 커서가 중요하다는 걸 모른다고는 생각하지 않습니다. 다만 대부분의 프로그래머들은 "얼마나" 중요한지를 모르고 있을뿐입니다. 그리고 "단지 중요한것"과 "얼마나 중요한지를 아는 것" 의 차이는 필연적으로 결과에 매우 큰 차이를 일으킵니다.

 

많은 사람들은 WORA를 모토로 삼는 Javas는 물론이고 C# 등의 다양한 언어를 다루는데 있어서 데이타베이스를 마치 라디오의 건전지처럼 필요에 따라 교체될 수 있는 블랙박스처럼 취급합니다. 그리고 데이타베이스 종속은 마치 나쁜짓인양 무슨 수를 써서라도 피하고 싶어합니다. 개인적으로도 한명의 프로그래머로서 데이터베이스 종속을 피하고자 하는 것은 목표이긴 하지만 그게 단지 여러 데이타베이스의 교집합의 기능만을 사용하며 다른 모든 기능은 회피하는 것이라면 바람직하지 않다고 생각합니다. 그렇기에 종속을 피하는 과정은 연결 문자열 한줄만 바꿔주는 것처럼 그리 간단하지만은 않습니다. 이 부분에 대해 오랫동안 생각해 온 바가 있어서 언젠가 다시 기회가 되면 DB의 블랙박스 증후군이란 다른 주제로 찾아볼 수 있기를 기대합니다.

 

여기까지 꽤 긴 글이었지만 우리는 아직 문제를 해결하지 못했습니다. [코드 1](Forward Only)과 [코드 2](Sensitive, Insensitive -> Static] 두가지 모두 단점을 가지고 있었고 그 단점은 모른척 무시하기에는 너무 큽니다. 그러나 우리가 전혀 나아가지 못한 것은 아닙니다. 나쁜 방법을 알았고 왜 나쁜 방법인지를 배웠습니다. 그리고 더욱 중요한 "얼마나" 나쁜 방법인지를 알게 됐습니다. 4-5년쯤 마소를 통해 글을 쓰면서 "더 빠르다 혹은 더 느리다."가 중요한게 아니라 "어떤 상황에서 얼마만큼 더 빠른가 혹은 느린가"의 문제가 더 중요하다고 말한적이 있는데 이 글에서도 이 글의 "얼마나"가 더 중요하다고 생각합니다. 이 문제의 해결은 이 다음이나 이 다음다음 글에서 나오겠지만 모든것은 "문제의 인식"으로 시작하니까요.


해결은 다음글에 -ㅅ-..

 

 참고 문헌

대용량 데이타 베이스

MSSQL Inside

http://jtds.sourceforge.net/apiCursors.html

'Framework > Database' 카테고리의 다른 글

Framework (DBManager)  (0) 2009.03.04
Framework (구조적 중복 제거)  (0) 2009.02.21
Framework (실험실 코드)  (0) 2009.02.20
Framework (개요)  (0) 2009.02.20
Framework (블랙박스 증후군)  (0) 2009.02.07
Posted by bleujin