2011년 11월 19일 토요일

Python Persistence (1) : Pickle 모듈

Python 3.2 Pickle 모듈

오늘 포스트는 인제대학교 의용공학과 전공 과정인 중급실용전산 수업에서 다뤘던 Persistence 관련 모듈 중 Pickle에 관한 것이다. 컴퓨터 프로그래밍에 있어 영속화를 익히는 과정은 대체적으로 ASCII 포맷의 Text 파일 다루기, Binary 포맷의 파일 다루기, 시리얼화(serialization), 마크업(HTML, XML 등의 ASCII 또는 그 외 Binary Markup), 데이터베이스 전체를 다루거나 또는 그 일부만 언급한 책자도 많다. 위 과정에서 채택한 주교재인 Head First Programming 에선 시리얼화를 이용한 영속화 모듈인 Pickle과 Shelve를 다루지 않으므로 이를 보강하는 의도에서 이 포스트와 다음 포스트인 Python Persistence (2) : Shelve 모듈에서 이들에 대해 살펴보도록 한다.

Pickle이나 Shelve와 같은 시리얼화를 이용한 영속화 작업을 수행하는 이유는 프로그램에서 생성한 객체의 실행 시 담고 있는 그 상태 값을 그대로 컴퓨터의 저장 매체에 저장했다가 추후에 그대로 사용하기 위한 목적을 쉽게 달성하기 위해서다. C#이나 Java 등에서도 이러한 시리얼화를 지원하지만 Python은 Pickle 및 Shelve 모듈로 간단히 구현할 수 있도록 했다.

Pickle 모듈을 이용한 코드 예제는 간단한 일정관리용 콘솔 프로그램으로 하도록 한다. 우선 콘솔용 UI를 아래와 같이 작성했고, 그 출력은 다음과 같다.

  1: #!/usr/bin/env python
  2: # -*- coding: UTF-8 -*-
  3: 
  4: def clearAll():
  5:     return []
  6: 
  7: def printList(items):
  8:     print ("-" * 30)
  9:     print ("%d개의 일정이 등록되어 있습니다." % len(items))
 10:     for item in items:
 11:         print("\t%s" % item)
 12:     print ("-" * 30)
 13: 
 14: import pickle
 15: 
 16: toDo = [] # 사용자가 입력한 일정 담을 리스트
 17: menu = ["출력", "전체 제거", "추가"]    # 메뉴 리스트
 18: running = True
 19: 
 20: print ("*" * 30)
 21: print ("일정관리")
 22: print ("*" * 30)
 23: 
 24: while running:
 25:     option = 1
 26: 
 27:     for m in menu:
 28:         print ("%d. %s" % (option, m))
 29:         option += 1
 30:     print ("%d. %s" % (option, "종료"))
 31:     try:
 32:         command = int(input("원하는 메뉴 번호를 입력하세요: "))
 33:     except ValueError:
 34:         command = 0
 35: 
 36:     if command == option:
 37:         running = False
 38:     elif command == 1:
 39:         printList(toDo)
 40:     elif command == 2:
 41:         toDo = clearAll()
 42:     elif command == 3:
 43:         do = input("\t일정명: ")
 44:         toDo.append(do)
 45:         printList(toDo)


figure01



Pickle 모듈을 사용하기 위해서는 14행과 같이 모듈을 import 한다. Pickle에서는 파일을 바이너리 포맷으로 다루게 되므로 파일 읽거나 쓸 때 파일모드는 반드시 binary 포맷으로 해야 한다. 즉 open() 내장 함수의 모드 파라메터를 ‘rb’와 ‘wb’로 해야 한다. 우선 파일을 저장하는 save() 함수를 다음과 같이 작성했다. Pickle 모듈의 파일 저장 함수는 dump() 이다. 오류처리 구문을 제외하면 이 함수는 with 문을 통해 파일 처리를 Python의 Context Manager에 위임하고, 저장할 객체(이 예제에서는 list object)와 파일 핸들러를 pickle.dump() 함수에 파라메터로 넘겨주는 것이 다이다.



  1: def save(items, fname="items.todo"):
  2:     """
  3:     save todo items into binary file
  4:     """
  5:     try:
  6:         with open(fname, "wb") as fh:
  7:             pickle.dump(items, fh)
  8:     except IOError as ioerr:
  9:         print ("File Error: %s" % str(ioerr))
 10:     except pickle.PickleError as pklerr:
 11:         print ("Pickle Error: %s" % str(pklerr))


위 save() 함수를 첫 코드에 작성 후, 첫 코드의 46번째 줄에 save(toDo)를 추가하면 프로그램 실행 시 파일이 생성된다. 여기서 save(toDo) 코드는 while 문이 종료되는 시점에 호출되도록 들여쓰기(indentation)을 주의해야 한다. 코드 일부와 그 실행 시 예는 아래와 같으며, 생성된 파일(items.todo)에 저장된 내용도 그 아래 표시했다.



23: ...
24: while running:
25: ...
45:         printList(toDo)
46: save(toDo)


figure02



figure03



이제 프로그램 실행 시 Pickle 모듈로 저장한 파일이 있다면 이를 읽어 들여 되돌려 주는 load() 함수를 추가한다.  기존에 생성된 파일이 없으면 save() 함수에 빈 리스트 하나를 파라메터로 넘겨 비어있는 파일 하나를 만들도록 했다. 그 코드는 아래와 같다. 오류처리 구문을 제외하면, save() 함수와 마찬가지로 파일 핸들러를 얻고 이를 pickle.load() 함수에 파라메터로 넘겨 파일을 읽어 들이면 된다.



  1: def load(fname="items.todo"):
  2:     """
  3:     load todo items binary file into memory
  4:     and assigns its values to corresponding object's states
  5:     """
  6:     savedItems = [] # 빈 리스트 생성
  7:     try:
  8:         with open(fname, "rb") as fh:
  9:             savedItems = pickle.load(fh)
 10:     except IOError as ioerr:
 11:         save(savedItems, fname) # create empty file
 12:     except pickle.PickleError as pklerr:
 13:         print ("Pickle Error: %s" % str(pklerr))
 14:     finally:
 15:         return savedItems


이 프로그램의 메인 부분에 해당하는 첫 코드의 16행 toDo = [] 코드를 아래와 같이 수정 후 프로그램을 실행 후, 출력 메뉴를 선택하면 그 아래 그림에 나타낸 것과 같이 앞서 save() 함수 적용 후 입력한 내용이 출력되는 것을 확인 할 수 있다.



...
16: toDo = load() # 사용자가 입력한 일정 담을 리스트
...


figure04



이 포스트에서는 Python에서 Pickle 모듈을 이용한 영속화 예제를 살펴봤다. 이 예제의 경우 단일한 일정목록을 그 개수에 제한 없이 사용할 수 있지만 날짜와 같은 구분 지을 수 있는 키로 여러 데이터 세트를 상황에 맞게 사용하려면 리스트 대신 딕셔너리 객체를 이용하면 되지만 시리얼화로 저장한 파일 내용 전체를 읽어 들여야 해서 비효율 적이다. 필요한 부분만 별도로 관리할 수 있는 모듈이 포스트 처음에 언급한 Shelve 모듈이다. 이는 다음 포스트에서 살펴보도록 한다. 이 프로그램의 전체 코드는 아래와 같다.



  1: #!/usr/bin/env python
  2: # -*- coding: UTF-8 -*-
  3: #-------------------------------------------------------------------------------
  4: # Name:        TodoListPickle
  5: # Purpose:      Pickle 모듈을 이용한 파일 영속화(persistence) 예제
  6: #
  7: # Author:      oscarpark
  8: #
  9: # Created:     18-11-2011
 10: # Copyright:   (c) oscarpark 2011
 11: # Licence:     Licence Free
 12: #-------------------------------------------------------------------------------
 13: 
 14: 
 15: def clearAll():
 16:     return []
 17: 
 18: def printList(items):
 19:     print ("-" * 30)
 20:     print ("%d개의 일정이 등록되어 있습니다." % len(items))
 21:     for item in items:
 22:         print("\t%s" % item)
 23:     print ("-" * 30)
 24: 
 25: def save(items, fname="items.todo"):
 26:     """
 27:     save todo items into binary file
 28:     """
 29:     try:
 30:         with open(fname, "wb") as fh:
 31:             pickle.dump(items, fh)
 32:     except IOError as ioerr:
 33:         print ("File Error: %s" % str(ioerr))
 34:     except pickle.PickleError as pklerr:
 35:         print ("Pickle Error: %s" % str(pklerr))
 36: 
 37: def load(fname="items.todo"):
 38:     """
 39:     load todo items binary file into memory
 40:     and assigns its values to corresponding object's states
 41:     """
 42:     savedItems = [] # 빈 리스트 생성
 43:     try:
 44:         with open(fname, "rb") as fh:
 45:             savedItems = pickle.load(fh)
 46:     except IOError as ioerr:
 47:         save(savedItems, fname) # create empty file
 48:     except pickle.PickleError as pklerr:
 49:         print ("Pickle Error: %s" % str(pklerr))
 50:     finally:
 51:         return savedItems
 52: 
 53: import pickle
 54: 
 55: toDo = load() # 사용자가 입력한 일정 담을 리스트
 56: menu = ["출력", "전체 제거", "추가"]    # 메뉴 리스트
 57: running = True
 58: 
 59: print ("*" * 30)
 60: print ("일정관리")
 61: print ("*" * 30)
 62: 
 63: while running:
 64:     option = 1
 65: 
 66:     for m in menu:
 67:         print ("%d. %s" % (option, m))
 68:         option += 1
 69:     print ("%d. %s" % (option, "종료"))
 70:     try:
 71:         command = int(input("원하는 메뉴 번호를 입력하세요: "))
 72:     except ValueError:
 73:         command = 0
 74: 
 75:     if command == option:
 76:         running = False
 77:     elif command == 1:
 78:         printList(toDo)
 79:     elif command == 2:
 80:         toDo = clearAll()
 81:         save(toDo)
 82:     elif command == 3:
 83:         do = input("\t일정명: ")
 84:         toDo.append(do)
 85:         printList(toDo)
 86: save(toDo)



2011년 11월 18일 금요일

측정 단위 문제: How to avoid the dependence on the choice of measurement units?


우리는 다양한 부면의 데이터를 수집하고 이들 속에 어떤 상관(correlation) 혹은 연관(association) 관계를 찾는다. 이들 데이터 중 일부는 인공의 기준을 세우고 단위를 부여한다. 동일한 속성을 가리키는 값이라 해도 그 단위에 따라 다른 값으로 나타나는데 이로 인해 그 결과가 상이하게 되기도 한다.
예를 들어, 4명의 나이와 키를 아래와 같이 수집했고, 이를 아래 표와 같이 정리했다고 하자.

Table 1. Age(in years) and height(in centimeters) of 4 people.

Person
Age (years)
Height (cm)
A
25
180
B
30
180
C
25
165
D
30
165


Python으로 이들 데이터를 matplotlib 모듈을 이용해 도표를 그려보자.
참고로 matplotlib는 Python 2.x 만 제공하고 있고 또 LaTeX를 기능을 사용하는 경우도 있기 때문에 공식 웹 사이트가 아닌 다른 곳에서 Python 3.2용 matplotlib를 받아서 설치 해야 한다.
이 사이트는 Unofficial Windows Binaries for Python Extension Packages로 그 외에도 유용한 패키지가 많다. 그리고 Windows용 TeX는 TeX Live를 받아서 설치 했는데 Linux Mint에서와 마찬가지로 texlive-latex-base와 texlive-latex-recommended 뿐만 아니라 texlive-math-extra와 texlive-fonts-extra도 함께 설치 해야 한다. 또 Windows에서 TeX Live 패스 설정을 하지 않는다면 문제가 발생하는데, 이는 googling으로 해결하면 된다. 물론 TeX 설치가 힘들다면 그냥 matplotlib 모듈에서 TeX 기능을 사용치 않거나 굳이 써야 하면 Mac이나 Linux에서 사용하는걸 권한다. (개인적으론 Mac에서 설정 잡는 것이 편했다.)


  1: import matplotlib.pyplot as plt
  2: 
  3: fig = plt.figure()
  4: 
  5: fig.suptitle('The dependence on the choice of measurement units', fontsize=14, fontweight='bold')
  6: ax = fig.add_subplot(111)
  7: fig.subplots_adjust(top=0.85)
  8: 
  9: ax.set_title('Height versus age')
 10: ax.set_xlabel('Age (YEARS)')
 11: ax.set_ylabel('Height (CM)')
 12: 
 13: x = [25, 30, 25, 30] # age
 14: y = [180, 180, 165, 165] # height
 15: persons = ["A", "B", "C", "D"]
 16: 
 17: ax.scatter(x,y,s=120, zorder=2)
 18: 
 19: for i in range(4):
 20:     #ax.plot(x[i], y[i], c="red", marker = 'o')
 21:     #ax.scatter(x[i], y[i],s=70, zorder=2)
 22:     ax.text(x[i], y[i] * 1.01 , persons[i], color='black')
 23: 
 24: ax.axis([15, 50, 150, 190])
 25: ax.grid()
 26: plt.show()
 27: 


위 코드를 실행하면 다음과 같은 도표를 확인 할 수 있다. 이 도표를 통해 데이터를 눈으로 가까운 것과 먼 것을 나눠보면 {A, B}와 {C, D}가 될 것이다.


Plot of height (in cm) versus age corresponding to Table 1


Table 1에서 신장 값을 센티미터 단위로 나타냈던 것을 피트 단위를 이용한다면 아래 Table 2와 같이 나타난다. (1ft = 30.48 cm)


Table 2. Age(in years) and height(in feet) of 4 people.
Person
Age (years)
Height (feet)
A
25
5.91
B
30
5.91
C
25
5.41
D
30
5.41

이 데이터를 도표 상 나타내도록 코드를 일부 수정하여 아래 코드에 나타내었다. 단, 수정된 위치 및 추가 된 것만 나타냈다. 위 코드 행과 맞춰보면 된다.

...
11: ax.set_ylabel('Height (Ft)')
...
16: y = [float(("%1.2f") % (i / 30.48)) for i in y]
...
24: ax.axis([15, 50, 3, 8])

 Plot of height (in feet) versus age corresponding to Table 2



이 도표를 통해 데이터를 눈으로 가까운 것과 먼 것을 나눠보면 {A, C}와 {B, D}가 될 것이다. 물론 이 두 도표는 Height 축의 비율(scale)에 의한 착시 현상이 존재한다. 아래 코드와 같이 24 행을 수정하면 figure01과 같이 나타낼 수 있다. 결국 이 비율 문제도 이 포스트에서 다루는 문제와 같은 것이다.


24: ax.axis([15, 50,
        float(("%1.2f") % (150 / 30.48)),
        float(("%1.2f") % (190 / 30.48))])

이런 문제를 해결하기 위해서는 데이터를 단위가 없는 표준화된 데이터로 변환하는데 일반적으로 표준편차(standard deviation)를 구해 표준점수(standard score, z-score)로 환산한다. 다만 표준편차의 경우 데이터 값과 그 표본 평균(일반적으로 산술평균)차를 제곱함으로 인해 점수가 부풀려지는 경우가 발생할 수 있다.

절대 평균 편차(mean absolute deviation)을 구하면 이런 경향을 피할 수 있다. 절대 평균 편차는 각 값과 표본 평균 차의 절대치를 취해 원래 값과 비례하도록 한다.

A numerical formula for the mean absolute deviation

이를 구하여 도표로 그리는 코드를 다음과 같이 작성했고 그 결과는 아래와 같다. 이 코드를 실행 후, 다시 17행에 있는 피트 단위로 변환하는 코드의 주석을 제거 후 실행해도 동일한 결과를 얻게 된다. 14~16행에 데이터를 좀 더 추가해서 수행해보는 것도 좋다.

  1: import matplotlib.pyplot as plt
  2: import math
  3: 
  4: fig = plt.figure()
  5: 
  6: fig.suptitle('The dependence on the choice of measurement units', fontsize=14, fontweight='bold')
  7: ax = fig.add_subplot(111)
  8: fig.subplots_adjust(top=0.85)
  9: 
 10: ax.set_title('Height versus age')
 11: ax.set_xlabel('Age (z-score)')
 12: ax.set_ylabel('Height (z-score)')
 13: 
 14: x = [25, 30, 25, 30] # age
 15: y = [180, 180, 165, 165] # height
 16: persons = ["A", "B", "C", "D"]
 17: #y = [float(("%1.2f") % (i / 30.48)) for i in y]
 18: 
 19: # calculates means of x and y
 20: mx, my = math.fsum(x) / len(x), math.fsum(y) / len(y)
 21: 
 22: # calculates mads of x and y
 23: sx, sy = (1/len(x)) * math.fsum(math.fabs(i - mx) for i in x), \
 24:         (1/len(y)) * math.fsum(math.fabs(i - my) for i in y)
 25: 
 26: # calculates each z-scores of x and y elements
 27: # and assigns them into new lists madx, mady
 28: # z_socre = (xi - mean) / s_mad
 29: madx, mady = [ (i - mx) / sx for i in x ], \
 30:             [ (i - my) / sy for i in y ]
 31: 
 32: ax.scatter(madx, mady, s=120, zorder=2)
 33: 
 34: for i in range(4):
 35:     ax.text(madx[i], mady[i] * 1.2 , persons[i], color='black')
 36: 
 37: ax.axis([-2, 2, -2, 2])
 38: ax.grid()
 39: plt.show()

Plot of standardized height versus standardized age

이 포스트에서는 동일한 속성에 대한 여러 단위에 의한 표현형을 가지는 값을 단위를 배제하고 표준 점수로 나타내는 과정을 Python 3.2와 matplotlib 모듈로 작성해 보였다. 여기에 보인 도표는 모두 matplotlib에서 그려진 이미지를 저장한 것이다. 실제 코드를 실행하면 아래 그림과 같은 윈도우가 실행된다. 저장 버튼을 눌러 PNG 포맷 이미지로 저장 매체에 저장할 수 있다.

이 포스트를 통해 원래 전하고자 했던 바는, 컴퓨터 프로그래밍 언어에 있어 마일스톤(milestone)인 Python의 간편함과 유용성을 보이고자 한데 있다. 물론 matplotlib 모듈의 경우 Windows 환경에서 Python 3.2와 조합 시 설치부터 어려움을 유발하고 또한 matplotlib 자체가 차트 및 그래프의 A to Z까지 모두 제공하다 보니 일견 어려울 수도 있지만 matplotlib 웹 사이트갤러리예제를 참조하다 보면 Python 문법 차체의 간결함과 일관된 패턴이 상승효과를 유발해 수월하게 익힐 수 있다. 이제 공과 대학은 Matlab과 작별을 고해도 될 시점이라 생각한다.



2011년 11월 17일 목요일

데이터 이전: 파이썬과 SQLite3

분산된 웹 환경하에서 여러 데이터를 취합하거나 혹은 단일 데이터 저장소로부터 내려받은 데이터를 다른 저장소로 이전해야 하는 경우가 종종 발생한다. 예를 들어 공개된 생명정보 데이터베이스로부터 쿼리 결과를 FASTA 포맷으로 다운로드 받아 이를 전문화된 라이브러리 구축을 위해 별도의 데이터 저장소로 옮기는 등의 작업이 빈번히 발생할 수 있다.

참고로, 사적으로 여러 차례 언급해왔지만 단 한번 수행하고 오랜 시간 동안 반복하지 않을 일이라면, 예를 들어, 일년에 두세 차례 수행하고 말 일이라면, 그 데이터 양이 얼마든지 간에 손으로 해치우고 마는 것이 훨씬 일을 간결하게 할 수 있다. 혹은 데이터 양이 많고 일부분에서 손이 많이 가며 반복적 패턴이 있다면 딱 그 부분만 처리하도록 코드를 작성하는 것이 더 효율적이다.

figure01

이 포스팅은 파이썬으로 데이터 이전을 수행하는 과정에 있어 많은 부분을 자동적으로 처리하기 위해 고려해야 할 부분과 내가 선호하는 방법에 대해 풀어 설명하는 목적으로 작성했다. 여기서 다루는 내용은 Microsoft SQL Server 예제 데이터베이스인 AdventureWorks로부터 ASCII 포맷으로 내려 받은 데이터를  파이썬과 이에 내장된 SQLite 모듈을 이용해 SQLite3 데이터베이스로 이전하는 코드이다.

AdventureWorks 데이터베이스의 Address, Customer, CustomerAddress, Product, ProductCategory, ProductModel, SalesOrderHeader, SalesOrderDetail 테이블로부터 일부 데이터를 가공해 ASCII 포맷 Text 파일로 내려 받았다. 이들 테이블에 관한 상세 내역은 AdventureWorks Data Dictionary 페이지를 참조토록 한다. 첨언하면, 데이터베이스 설계, 프로그래밍, 관리, 튜닝 등을 공부하는데 이런 예제 데이터베이스를 활용하는 것이 가장 효율적인 방법이었다.

figure02

각 필드 구분자는 탭(\t)을 사용했고 이 값들과 사상(mapping)하는 필드 명은 아래 SQLite3 데이터베이스에 테이블 생성을 위한 SQL DDL(Data Definition Language)문으로 확인 할 수 있다.

SQLite는 프로그래밍 라이브러리로 이는 단일 데이터베이스 파일을 In-Memory에서 다루는 데이터베이스 엔진이다. SQLite는 여러 이종 플랫폼에서 광범위하게 이용되고 있다. 이에 관한 상세한 내용은 SQLite 공식 웹 사이트에서 확인 할 수 있다.

SQLite에 새로운 데이터베이스 파일을 생성하기 위해 다음 명령을 실행 후, SQL DDL로 테이블을 생성했다.

figure03

  1: DROP TABLE IF EXISTS SalesOrderDetail;
  2: DROP TABLE IF EXISTS SalesOrderHeader;
  3: DROP TABLE IF EXISTS Product;
  4: DROP TABLE IF EXISTS ProductModel;
  5: DROP TABLE IF EXISTS ProductCategory;
  6: DROP TABLE IF EXISTS CustomerAddress;
  7: DROP TABLE IF EXISTS Customers;
  8: DROP TABLE IF EXISTS Address;
  9: 
 10: CREATE TABLE Address
 11: (
 12:         AddressID                       INTEGER,
 13:         AddressLine1                    TEXT,
 14:         AddressLine2                    TEXT,
 15:         City                            TEXT,
 16:         StateProvince                   TEXT,
 17:         CountryRegion                   TEXT,
 18:         PostalCode                      TEXT,
 19:         PRIMARY KEY(AddressID ASC)
 20: );
 21: 
 22: CREATE TABLE CustomerAddress
 23: (
 24:         CustomerID                      INTEGER,
 25:         AddressID                       INTEGER,
 26:         AddressType                     TEXT,
 27:         PRIMARY KEY(CustomerID ASC, AddressID ASC)
 28: );
 29: 
 30: CREATE TABLE Customers
 31: (
 32:         CustomerID                      INTEGER,
 33:         Title                           TEXT,
 34:         FirstName                       TEXT,
 35:         MiddleName                      TEXT,
 36:         LastName                        TEXT,
 37:         Suffix                          TEXT,
 38:         CompanyName                     TEXT,
 39:         EmailAddress                    TEXT,
 40:         Phone                           TEXT,
 41:         SalesPerson                     TEXT,
 42:         PRIMARY KEY(CustomerID ASC)
 43: );
 44: 
 45: CREATE TABLE Product
 46: (
 47:         ProductID                       INTEGER,
 48:         Name                            TEXT,
 49:         ProductNumber                   TEXT,
 50:         Color                           TEXT,
 51:         StandardCost                    REAL,
 52:         ListPrice                       REAL,
 53:         Size                            TEXT,
 54:         Weight                          TEXT,
 55:         ProductCategoryID               INTEGER,
 56:         ProductModelID                  INTEGER,
 57:         PRIMARY KEY(ProductID ASC)
 58: );
 59: 
 60: CREATE TABLE ProductCategory
 61: (
 62:         ProductCategoryID               INTEGER,
 63:         ParentProductCategoryID         INTEGER,
 64:         Name                            TEXT,
 65:         PRIMARY KEY(ProductCategoryID ASC)
 66: );
 67: 
 68: CREATE TABLE ProductModel
 69: (
 70:         ProductModelID          INTEGER,
 71:         Name                    TEXT,
 72:         PRIMARY KEY(ProductModelID ASC)
 73: );
 74: 
 75: CREATE TABLE SalesOrderHeader
 76: (
 77:         SalesOrderID                    INTEGER,
 78:         RevisionNumber                  INTEGER,
 79:         OrderDate                       TEXT,
 80:         DueDate                         TEXT,
 81:         ShipDate                        TEXT,
 82:         Status                          INTEGER,
 83:         OnlineOrderFlag                 INTEGER,
 84:         SalesOrderNumber                TEXT,
 85:         PurchaseOrderNumber             TEXT,
 86:         AccountNumber                   TEXT,
 87:         CustomerID                      INTEGER,
 88:         ShipToAddressID                 INTEGER,
 89:         BillToAddressID                 INTEGER,
 90:         ShipMethod                      TEXT,
 91:         CreditCardApprovalCode          INTEGER,
 92:         SubTotal                        REAL,
 93:         TaxAmt                          REAL,
 94:         Freight                         REAL,
 95:         TotalDue                        REAL,
 96:         PRIMARY KEY(SalesOrderID ASC)
 97: );
 98: 
 99: CREATE TABLE SalesOrderDetail
100: (
101:         SalesOrderID                    INTEGER,
102:         SalesOrderDetailID              INTEGER,
103:         OrderQty                        INTEGER,
104:         ProductID                       INTEGER,
105:         UnitPrice                       REAL,
106:         UnitPriceDiscount               REAL,
107:         LineTotal                       REAL,
108:         PRIMARY KEY(SalesOrderDetailID ASC)
109: );

여기서는 테이블 간 관계성(relationship)을 설정하지 않았는데, 그 이유는 SQLite 엔진은 외래 키 사용이 기본적으로 막혀 있기 때문이다. 이를 PRAGMA foreign_keys = ON; 명령으로 옵션 변경을 할 수 있지만 기본 키만 설정했다. SQLite 엔진에서 외래 키 옵션을 확인하기 위해서는 PRAGMA foreign_keys; 라 입력하면 0(불가) / 1(가능)으로 설정 값이 나온다.


SQLite의 데이터 타입은 5가지(TEXT, INTEGER, REAL, BLOB, NONE)이며, 이에 관한 상세한 사항은 다음 웹 페이지 Datatypes In SQLite Version3 를 참고토록 한다.


생성한 테이블 다이어그램은 아래와 같다.


figure04


예제 데이터베이스 AdventureWorks 데이터는 아래 그림과 같이 ASCII 포맷으로 데이터 타입에 관한 원래 정보는 해당 파일에서 이미 소실된 상태이다.


figure05


각 값들과 SQLite 테이블의 필드 순서는 일치 시켰으므로 그 순서는 중요치 않지만 각 데이터 타입에 맞게 처리해야 할 필요가 있다.


그래서 각 테이블 마다 필드에 사상하는 데이터 타입을 기술한 scheme.meta 라는 이름의 ASCII 파일을 생성해 아래 내용을 저장했고 이를 데이터 이전 작업에서 쉐도우 데이터로 활용했다.

  1: Address;int,str,str,str,str,str,str
  2: CustomerAddress;int,int,str
  3: Customers;int,str,str,str,str,str,str,str,str,str
  4: Product;int,str,str,str,float,float,str,str,int,int
  5: ProductCategory;int,int,str
  6: ProductModel;int,str
  7: SalesOrderHeader;int,int,str,str,str,int,int,str,str,str,int,int,int,str,int,float,float,float,float
  8: SalesOrderDetail;int,int,int,int,float,float,float

세미콜론으로 구분되는 두 필드는 각각 테이블 명과 각 필드 데이터 타입에 사상하는 파이썬 데이터 타입 리스트이다. 파이썬에서는 이를 읽어, 키-값 쌍을 가지는 딕셔너리를 생성하여 메타 정보로 활용한다. 아래는 이 과정을 수행하는 코드 일부이며 전체 코드는 포스트 마지막에 표시한다.


fname은 해당 함수의 인자로 파일 경로이며, with 구문으로 파일을 열어 파이썬의 Context Manager에 그 처리를 이양했기 때문에 파일 닫기 작업은 불필요하다.

  1: with open(fname, "r") as fh:
  2:   for line in fh:
  3:       t, s = line.split(";")
  4:       q[t] = s.replace("\n", "")
  5:   return q

데이터 베이스에 여러 값을 입력하는데 여러 테크닉이 존재하는데 여기서는 여러 쿼리를 실행하는 방법을 선택했다. 이는 일반적으로 손쉽게 행하는 반복 구문을 이용해 한번에 하나의 레코드 세트를 프로그램에서 집어 넣는 것이 아니라 레코드 세트에 해당하는 복수의 투플(tuple)을 가진 리스트를 생성 후 이를 하나의 쿼리와 묶어 처리토록 하는 것이다.


예를 들어 ABC란 테이블에 5개 필드에 두 개의 데이터 열을 넣고자 하면 값 리스트는 rows = [(1, 2, 3, 4, 5), (6, 7, 8, 9, 10)]과 같이 생성한 후 아래 코드와 같이 실행하면 된다.

  1: cursor.executemany("INSERT INTO ABC VALUES(?, ?, ?, ?, ?)", rows)
  2: db.commit()

각 데이터 파일을 읽어 위 코드와 같이 테이블 별 데이터 세트로 만든 후, cursor.executemany(querySlot, dataSetList)로 호출하면 되는데 이를 먼저 보인 후 고려 사항에 대해 다룬다.

  1: for key in tablesScheme.keys():
  2:     if VERBOSE:
  3:         print("TABLE: %s" % key, end=", ")
  4:     rows = []
  5:     scheme = tablesScheme[key].split(",")
  6:     with open("AW" + key + ".txt", "r") as fh:
  7:         for data in fh:
  8:             value = data.replace("\n", "").split("\t")
  9:             rows.append(tuple(i for i in evalRow(scheme, value)))
 10:         cursor.executemany("INSERT INTO %s VALUES(%s)"
 11:             % (key, createSlot(scheme)), rows)
 12:         db.commit()
 13: 
 14:         if VERBOSE:
 15:             print("%d rows are effected." % cursor.rowcount)


1 행에서 tablesScheme는 scheme.meta 파일을 읽어 생성한 딕셔너리로 tablesScheme.keys()는 모든 등록된 키를 되돌려준다. 이 후 각 키(즉 테이블 이름)으로 그 값을 받아와 scheme 리스트로 할당한다.


ASCII 파일(AW[테이블명].txt)를 열어 데이터를 받아와 한 줄씩 처리한다. rows 리스트에 담는 것은 레코드 세트(데이터 파일의 개별 행)의 값을 가진 투플로 만들어야 하므로 9 행에서는 tuple() 내장 함수로 들어오는 값을 모아 투플로 생성 후 rows 리스트에 담았다(append).


여기서 문제는 텍스트 파일에서 읽어 들인 값은 모두 문자열로 취급되고 그 데이터 타입에 대한 정보가 없으므로 이를 데이터 쉐도우 파일 scheme.meta에 기입한 데로 데이터 타입을 인식토록 하는 것이다.이를 수행하는 evalRow() 함수를 작성했고 그 내용은 아래와 같다.

  1: for i in range(len(meta)):
  2:     if data[i] == "NULL": yield None
  3:     elif meta[i] == "str": yield data[i]
  4:     else:
  5:         yield eval(meta[i] + "(" + data[i] + ")")
  6: 

1행은 첫 번째 파라메터 meta 인수로 받은 데이터 쉐도우의 필드 개수만큼 반복을 수행한다.


2행에서 value 인수로 받은 입력 할 데이터 값이 NULL로 표기 되어 있으면 파이썬의 None 타입 되돌린다.


3행에서 데이터 쉐도우의 필드 타입이 str이면 그 값 그대로 되돌린다.


4~5행에서 그 외 int나 float이면 이를 eval() 함수로 문자열 값을 evaluation 토록 했다. 아래 코드와 같이 4~5행을 작성해도 되나 직접적인 변환(casting) 보다는 파이썬이 직접 처리토록 하는 것을 선호하기 때문에 eval() 함수를 활용했다.

  1: elif meta[i] == "int": yield int(data[i])
  2: else: yield float(data[i])

반환 값은 return 구문 대신 yield로 생성자(generator) 함수로 만들었다. 이 때문에 각 값에 대한 판별이 완료될 때마다 값을 함수 호출 지점으로 반환하며 이로 인해 이전 코드 9 행과 같이 for 루프로 해당 함수에서 나오는 값을 하나씩 처리할 수 있다.


이전 코드 11행의 createSlot() 함수는 scheme 리스트에 담긴 원소 수 만큼 물음표(?)를 쉼표로 구분해 생성하는 함수로 그 코드는 아래와 같다. 생성한 결과를 리턴 시 문자열 맨 마지막의 두 개는 잉여의 쉼표와 공백이 하나 더 붙으므로 문자열[0:-2]로 문자열의 마지막 두 개는 제외토록 제한 했다.

  1: return ("?, " * len(scheme))[0:-2]

이에 관한 전체 코드는 다음과 같다. 96행 VERBOSE 변수는 1로 설정했는데, 0으로 설정할 경우 프로그램 수행 과정 상 아무런 출력을 하지 않는다. INSERT, UPDATE, DELETE 구문 수행 후 적용 받은 레코드 세트 수를 확인하려면 90 행과 같이 cursor.rowcount 속성 값을 확인하면 된다.

  1: #!/usr/bin/env python
  2: # -*- coding: utf-8 -*-
  3: #-------------------------------------------------------------------------------
  4: # Name:        adworksmigration
  5: # Purpose:     adventureworks sample data베이스에서
  6: #              text파일로 가져온 파일을 읽어
  7: #              sqlite database로 migration
  8: #
  9: # Author:      oscarpark
 10: #
 11: # Created:     16-11-2011
 12: # Copyright:   (c) oscarpark 2011
 13: # Licence:     licence free
 14: #-------------------------------------------------------------------------------
 15: 
 16: def createSlot(scheme):
 17:     """
 18:     주어진 리스트 원소 개수 만큼 '?, ' 를 생성하고 마지막 ', '는 제외 후 리턴
 19:     """
 20:     return ("?, " * len(scheme))[0:-2]
 21: 
 22: def evalRow(meta, data):
 23:     """
 24:     meta 리스트 내 나열된 타입에 맞춰
 25:     같은 index의 data 리스트 내 값을 캐스팅하여 리턴함.
 26:     """
 27:     if not meta or not data:
 28:         raise ValueError("meta리스트나 data리스트가 빈 리스트 입니다.")
 29: 
 30:     if len(meta) != len(data):
 31:         raise ValueError("meta리스트와 data리스트 크기가 같아야 합니다.")
 32:     try:
 33:         for i in range(len(meta)):
 34:             if data[i] == "NULL": yield None
 35:             elif meta[i] == "str": yield data[i]
 36:             else:
 37:                 yield eval(meta[i] + "(" + data[i] + ")")
 38:     except Exception as err:
 39:         raise (err)
 40: 
 41: def getScheme(fname):
 42:     """
 43:     테이블 명과 필드 타입이 들어 있는 파일을 읽어
 44:     키-값 쌍의 딕셔너리를 리턴함.
 45:     """
 46:     q = {}      # create an empty dictionary for returning
 47:     try:
 48:         # open file
 49:         with open(fname, "r") as fh:
 50:             for line in fh:
 51:                 t, s = line.split(";")
 52:                 q[t] = s.replace("\n", "")
 53:             return q
 54:     except IOError as ioerr:
 55:         raise (ioerr)
 56:     except Exception as err:
 57:         raise (err)
 58: 
 59: def patchData(VERBOSE):
 60:     import sqlite3
 61: 
 62:     tablesScheme = getScheme("scheme.meta")
 63: 
 64:     db = sqlite3.connect("AdventureWorks.sdb")
 65: 
 66:     cursor = db.cursor()
 67: 
 68:     for key in tablesScheme.keys():
 69:         if VERBOSE:
 70:             print("TABLE: %s" % key, end=", ")
 71:         rows = []
 72:         scheme = tablesScheme[key].split(",")
 73:         with open("AW" + key + ".txt", "r") as fh:
 74:             for data in fh:
 75:                 value = data.replace("\n", "").split("\t")
 76:                 rows.append(tuple(i for i in evalRow(scheme, value)))
 77:             """
 78:             NOTE: 위 for loop와 아래 cursor.excutemany / db.commit의
 79:                     indentation 주의
 80:             여러 쿼리를 실행해야 할 경우, executemany() 메소드로 가능하다.
 81:             쿼리 VALUES 절에서 이하에서 각 필드값에 물음표(?)로 대체하고,
 82:             두번째 파라메터에 해당 값에 대응하는 투플의 리스트를 준다.
 83:             단 물음표 개수는 반드시 투플의 원소 개수와 일치해야 한다.
 84:             """
 85:             cursor.executemany("INSERT INTO %s VALUES(%s)"
 86:                 % (key, createSlot(scheme)), rows)
 87:             db.commit()
 88: 
 89:             if VERBOSE:
 90:                 print("%d rows are effected." % cursor.rowcount)
 91: 
 92:     cursor.close()
 93:     db.close()
 94: 
 95: def main():
 96:     VERBOSE = 1
 97:     patchData(VERBOSE)
 98: 
 99: if __name__ == '__main__':
100:     main()
101: 

위 모듈 adworksmigration.py를 직접 실행 시 데이터 이전 작업이 수행되며 그 결과는 아래와 같이 출력됐다. 위 코드의 patchData() 함수 내 데이터베이스 / 파일 이용 부분에는 별도의 예외 처리는 작성하지 않았다. 99행 조건절로 인해 이 모듈을 import 시에는 patchData() 함수가 바로 실행되지 않는다.


figure06