CODICT

7. Python으로 데이터 다루기(하) 본문

Programming/Python

7. Python으로 데이터 다루기(하)

Foxism 2020. 3. 11. 04:45

활성화된 프로그램은 데이터를 램(RAM; Random Access Memory)에 저장합니다. 램은 아주 빠르지만, 비싸고, 일정한 전력 공급을 필요로 합니다. 전원이 꺼질 경우 메모리에 있는 모든 데이터가 사라집니다. 디스크 드라이브는 램보다 느리지만 용량이 넉넉하고, 비용이 싸며, 전원이 꺼지더라도 데이터를 유지합니다. 지금까지 컴퓨터 시스템 개발자들은 디스크와 램 사이의 격차를 줄이기 위해 상당한 노력을 기울였습니다. 프로그래머는 디스크와 같은 비휘발성(Nonvolatile) 장치를 사용하여 데이터를 저장하고 복구할 수 있는 지속성(Persistence)이 필요합니다.

이 게시글에서는 일반 파일, 구조화된 파일, 데이터베이스와 같이 특수 목적에 맞게 최적화된 데이터 스토리지의 각 특징에 대해 살펴봅니다. 입력과 출력 이외의 파일 작업은 추후에 작성될 게시글에서 다루도록 하겠습니다.

참고로, 이번 게시글은 비표준의 파이선 모듈의 예제를 다루는 첫 번째 게시글입니다 이 게시글에서 표준 라이브러리에 없는 모듈을 사용할 것입니다. 편리한 pip 명령을 사용하여 외부 모듈을 설치합니다. pip에 대한 사용법은 추후에 따로 게시글을 작성하도록 하겠습니다.

 

1. 파일 입출력

 

데이터를 가장 간단하게 지속하려면 보통 파일(Plain File)을 사용합니다. 이것을 플랫 파일(Flat File)이라 부르기도 합니다. 파일은 단지 파일 이름(Filename)으로 저장된 바이트 시퀀스입니다. 파일로부터 데이터를 읽어서 메모리에 적재하고, 메모리에서 파일로 데이터를 씁니다. 파이썬은 이러한 작업을 쉽게 할 수 있게 만들어줍니다. 이러한 파일 연산은 익숙하고 인기 있는 유닉스 같은 운영체제를 모델로 만들어졌습니다.

파일을 읽고 쓰기 전에, 파일을 열어야 합니다.

fileobj = open(filename, mode)

다음은 위 호출에 대한 간단한 설명입니다.

  • fileobj는 open()에 의해 반환되는 파일 객체입니다.
  • filename은 파일의 문자열 이름입니다.
  • mode는 파일 타입과 파일로 무엇을 할지 명시하는 문자열입니다.

mode의 첫 번째 글자는 작업을 명시합니다.

  • r: 파일 읽기
  • w: 파일 스기(파일이 존재하지 않으면 파일을 생성하고, 파일이 존재하면 덮어씁니다.)
  • x: 파일 쓰기(파일이 존재하지 않을 경우에만 해당합니다.)
  • a: 파일 추가하기(파일이 존재하면 파일의 끝에서부터 씁니다.)

mode의 두 번째 글자는 파일 타입을 명시합니다.

  • t(또는 아무것도 명시하지 않음): 텍스트 타입
  • b: 이진(Binary) 타입

파일을 열면 데이터를 읽거나 쓰기 위한 함수를 호출할 수 있습니다. 다음 코드에서 다룹니다.

파일을 열고 다 사용했으면, 파일을 닫아야 합니다.

다음 절에서 문자열로 파일을 생성하고 그것을 다시 읽어봅시다.

 

1.1. 텍스트 파일 쓰기: write()

 

다음은 예제에서 사용할 데이터 소스로, 특수 상대성 이론에 대한 오행시(Limerick)입니다.

>>> poem = '''There was a young lady named Bright,
Whose speed was far faster than light;
She started one day
In a relative way,
And returned on the previous night.'''
>>> len(poem)
#150

poem을 relativity 파일에 씁니다.

>>> fout = open('relativity', 'wt')
>>> fout.write(poem)
#150
>>> fout.close()

write() 함수는 파일에 쓴 바이트수를 반환합니다. wirte() 함수는 print() 함수처럼 스페이스나 줄 바꿈을 추가하지 않습니다. 다음은 print() 함수로 텍스트 파일을 만드는 예제입니다.

>>> fout = open('relativity', 'wt')
>>> print(poem, file = fout)
>>> fout.close()

여기서 한 가지 질문이 생깁니다. write()를 사용해야 할까요, print()를 사용해야 할까요? 기본적으로 print()는 각 인자 뒤에 스페이스를, 끝에 줄 바꿈을 추가합니다. 이전 예제에서는 relativity 파일에 줄 바꿈이 추가되었습니다. print()를 write()처럼 작동하려면 print()에 다음 두 인자를 전달합니다.

  • sep(구분자(Separator), 기본값은 스페이스('')입니다.)
  • end(문자열 끝(End String), 기본값은 줄 바꿈('\n')입니다.)

print()에 어떤 특정 값을 전달하지 않으면 두 인자는 기본값을 사용합니다. 빈 문자열을 두 인자에 전달해봅시다.

>>> fout = open('relativity', 'wt')
>>> print(poem, file = fout, sep = '', end = '')
>>> fout.close()

파일에 쓸 문자열이 크면 특정 단위(Chunk)로 나누어서 파일에 씁니다.

>>> fout = open('relativity', 'wt')
>>> size = len(poem)
>>> offset = 0
>>> chunk = 100
>>> while True:
	if offset > size:
		break
	fout.write(poem[offset:offset + chunk])
	offset += chunk

	
#100
#50
>>> fout.close()

처음에는 100 문자를 썼고, 다음에는 마지막 50 문자를 썼습니다.

만일 relativity 파일이 중요하다면, 모드 x를 사용하여 파일을 덮어쓰지 않도록 합니다.

>>> fout = open('relativity', 'xt')
#Traceback (most recent call last):
#  File "<pyshell#23>", line 1, in <module>
#    fout = open('relativity', 'xt')
#FileExistsError: [Errno 17] File exists: 'relativity'

이를 다음과 같이 예외로 처리할 수 있습니다.

>>> try:
	fout = open('relativity', 'xt')
	fout.write('stomp stomp stomp')
    except FileExistsError:
	print('relativity already exists!. that was a close one.')

	
#relativity already exists!. that was a close one.

 

1.2. 텍스트 파일 읽기: read(), readline(), readlines()

 

다음 코드와 같이 read() 함수를 인자 없이 호출하여 한 번에 전체 파일을 읽을 수 있습니다. 아주 큰 파일을 이와 같이 읽을 때 메모리가 소비될 수 있으므로 주의해야 합니다.

>>> fin = open('relativity', 'rt')
>>> poem = fin.read()
>>> fin.close()
>>> len(poem)
#150

한 번에 얼마만큼 읽을 것인지 크기를 제한할 수 있습니다. read() 함수가 한 번에 읽을 수 있는 문자수를 제한하려면 최대 문자수를 인자로 입력합니다. 한 번에 100 문자를 읽은 뒤 각 chunk 문자열을 poem 문자열에 추구하여 원본 파일의 문자열을 모두 저장해봅시다.

>>> poem = ''
>>> fin = open('relativity', 'rt')
>>> chunk= 100
>>> while True:
	fragment = fin.read(chunk)
	if not fragment:
		break
	poem += fragment

	
>>> fin.close()
>>> len(poem)
#150

파일을 다 읽어서 끝에 도달하면, read() 함수는 빈 문자열('')을 반환합니다. 이것은 if not fragment에서 fragment가 False가 되고, 결국 not False는 True가 되어 while True 루프를 탈출합니다.

또한 readline() 함수를 사용하여 파일을 라인 단위로 읽을 수 있습니다. 다음 코드는 파일의 각 라인을 poem 문자열에 추가하여 원본 파일의 문자열을 모두 저장합니다.

>>> poem = ''
>>> fin = open('relativity', 'rt')
>>> while True:
	line = fin.readline()
	if not line:
		break
	poem += line

	
>>> fin.close()
>>> len(poem)
#150

텍스트 파일의 빈 라인의 길이는 1이고('\n'), 이것을 True로 인식합니다. 파일 읽기의 끝에 도달했을 때 (read() 함수처럼) readline() 함수 또한 False로 간주하는 빈 문자열을 반환합니다.

텍스트 파일을 가장 읽기 쉬운 방법은 이터레이터(Iterator)를 사용하는 것입니다 이터레이터는 한 번에 한 라인식 반환합니다. 다음 코드는 이전과 비슷하지만, 코드 양은 더 적습니다.

>>> poem = ''
>>> fin = open('relativity', 'rt')
>>> for line in fin:
	poem += line

	
>>> fin.close()
>>> len(poem)
#150

앞의 모든 코드는 결국 하나의 poem 문자열을 만들었습니다. readline() 호출은 한 번에 모든 라인을 읽고, 한 라인으로 된 문자열들의 리스트를 반환합니다.

>>> fin = open('relativity', 'rt')
>>> lines = fin.readlines()
>>> fin.close()
>>> print(len(lines), 'lines read')
#5 lines read
>>> for line in lines:
	print(line, end = '')

	
#There was a young lady named Bright,
#Whose speed was far faster than light;
#She started one day
#In a relative way,
#And returned on the previous night.>>>

첫 네 라인은 각 문자열의 끝에 이미 줄 바꿈 문자가 있으므로 print() 함수에 줄 바꿈 문자를 지정하지 않았습니다. 마지막 라인에는 줄 바꿈 문자가 없어서, 대화식 인터프리터의 프롬프트 >>>가 같은 줄에 출력되었습니다.

 

1.3. 이진 파일 쓰기: write()

 

모드에 'b'를 포함시키면 파일을 이진 모드로 엽니다. 이 경우 문자열 대신 바이트를 읽고 쓸 수 있습니다.

먼저 0에서 255까지의 256바이트 값을 생성합니다.

>>> bdata = bytes(range(0, 256))
>>> len(bdata)
#256

이진 모드로 파일을 열어서 한 번에 데이터를 써봅시다.

>>> fout = open('bfile', 'wb')
>>> fout.write(bdata)
#256
>>> fout.close()

write() 함수는 파일에 쓴 바이트수를 반환합니다.

텍스트 파일처럼 특정 단위(Chunk)로 이진 데이터를 쓸 수 있습니다.

>>> fout = open('bfile', 'wb')
>>> size = len(bdata)
>>> offset = 0
>>> chunk = 100
>>> while True:
	if offset > size:
		break
	fout.write(bdata[offset:offset + chunk])
	offset += chunk

	
#100
#100
#56
>>> fout.close()

 

1.4. 이진 파일 읽기: read()

 

이진 파일을 읽는 것은 간단합니다. 파일을 'rb' 모드로 열기만 하면 됩니다.

>>> fin = open('bfile', 'rb')
>>> bdata = fin.read()
>>> len(bdata)
#256
>>> fin.close()

 

1.5. 자동으로 파일 닫기: with

 

열려 있는 파일을 닫지 않았을 때, 파이썬은 이 파일이 더 이상 참조되지 않다는 것을 확인한 뒤 파일을 닫습니다. 이것은 함수 안에 파일을 열어놓고 이를 명시적으로 닫지 않더라도 함수가 끝날 때 자동으로 파일이 닫힌다는 것을 의미합니다. 그러나 오랫동안 작동하는 함수 혹은 메인 프로그램에 파일을 열어 놓았다면, 파일에 쓰는 것을 마치기 위해 명시적으로 파일을 닫아야 합니다.

파이썬에는 파일을 여는 것과 같은 일을 수행하는 콘텍스트 매니저(Context Manager)가 있습니다. 파일을 열 때 'with 표현식 as 변수' 형식을 사용합니다.

>>> with open('relativity', 'wt') as fout:
	fout.write(poem)

콘텍스트 매니저 코드 블록의 코드 한 줄이 실행되고 나서(잘 수행되거나, 또는 문제가 있는 경우 예외 발생) 자동으로 파일을 닫아줍니다.

 

1.6. 파일 위치 찾기: seek()

 

파일을 읽고 쓸 때, 파이썬은 파일에서 위치를 추적합니다 tell() 함수는 파일의 시작으로부터의 현재 오프셋을 바이트 단위로 반환합니다. seek() 함수는 다른 바이트 오프셋으로 위치를 이동할 수 있습니다.ㅇ ㅣ함수를 사용하면 마지막 바이트를 읽기 위해 처음부터 마지막가지 파일 전체를 읽지 않아도 됩니다. seek() 함수로 파일의 마지막 바이트를 추적하여 마지막 바이트만 읽을 수 있습니다.

이전에 작성한 256바이트의 이진 파일('bfile')을 사용해봅시다.

>>> fin = open('bfile', 'rb')
>>> fin.tell()
#0

seek() 함수를 사용하여 파일의 마지막에서 1바이트 전 위치로 이동합니다.

>>> fin.seek(255)
#255

이제 파일의 마지막 바이트를 읽어봅시다.

>>> bdata = fin.read()
>>> len(bdata)
#1
>>> bdata[0]
#255

또한 seek() 함수는 현재 오프셋을 반환합니다.

seek() 함수의 형식은 seek(offset, origin)이며, 다음은 두 번째 인자 origin에 대한 설명입니다.

  • origin이 0일 때(기본값), 시작 위치에서 offset 바이트 이동합니다.
  • origin이 1일 때, 현재 위치에서 offset 바이트 이동합니다.
  • origin이 2일 때, 마지막 위치에서 offset 바이트 전 위치로 이동합니다.

또한 이 값은 표준 os 모듈에 정의되어 있습니다.

>>> import os
>>> os.SEEK_SET
#0
>>> os.SEEK_CUR
#1
>>> os.SEEK_END
#2

다른 방법으로 마지막 바이트를 읽어봅시다.

>>> fin = open('bfile', 'rb')

파일의 마지막에서 1바이트 전 위치로 이동합니다.

>>> fin.seek(-1, 2)
#255
>>> fin.tell()
#255

이제 파일의 마지막 바이트를 읽어봅시다.

>>> bdata = fin.read()
>>> len(bdata)
#1
>>> bdata[0]
#255

참고로, seek() 함수를 위해 tell() 함수를 호출할 필요는 없습니다. 코드에서는 두 함수가 같은 오프셋을 반환하는지 보여주기 위해 사용했습니다.

파일에서 현재 위치를 이동해봅시다.

>>> fin = open('bfile', 'rb')

다음 코드는 파일의 마지막에서 2바이트 전 위치로 이동합니다.

>>> fin.seek(254, 0)
#254
>>> fin.tell()
#254

1바이트 앞으로 이동합시다.

>>> fin.seek(1, 1)
#255
>>> fin.tell()
#255

이제 파일의 마지막 바이트를 읽어봅시다.

>>> bdata = fin.read()
>>> len(bdata)
#1
>>> bdata[0]
#255

이 함수들은 이진 파일에서 위치를 이동할 때 아주 유용하게 쓰입니다. 텍스트 파일에도 이 함수를 쓸 수 있으나, 아스키코드(한 문자당 1바이트)가 아니라면 오프셋을 계산하기 힘듭니다. 텍스트 인코딩에 따라 다르지만, 가장 인기 있는 인코딩(UTF-8)은 한 무자당 여러 바이트를 사용합니다.

 

2. 구조화된 텍스트 파일

 

간단한 텍스트 파일은 라인으로 구성되어 있습니다. 이 파일보다 더 구조화된 텍스트 파일을 써야 할 때가 있습니다. 어떤 프로그램에서 데이터를 저장하거나 이를 다른 프로그램으로 보낼 때, 구조화된 데이터가 필요합니다.

구조화된 텍스트 파일 형식은 아주 많지만, 대표적으로 몇 가지 형식을 살펴봅시다.

  • 탭('\t'), 콤마(', '), 수직선('|')과 같은 문자를 구분자(Separator) 혹은 분리자(Delimiter)로 사용합니다. 여기에서는 CSV(Comma-Separated Values)를 다룹니다.

  • 태그를 '<'와 '>'로 둘러쌉니다. 여기서는 XML(eXtensible Markup Language)과 HTML(HyperText Markup Language)을 다룹니다.

  • 구두점을 사용합니다. 여기서는 JSON(JavaScript Object Notation)을 다룹니다.

  • 들여쓰기를 사용합니다. 여기에서는 YAML(YAML Ain't Markup Language)을 다룹니다('YAML은 마크업 언어가 아닙니다'라는 뜻입니다.)

  • 프로그램 설정 파일과 같은 여러 가지 형식을 사용합니다.

이러한 구조화된 파일 형식은 적어도 하나의 파이썬 모듈로 읽고 쓸 수 있습니다.

 

2.1. CSV

 

구분된 파일(Delimited File)은 스프레드시트(Spreadsheet)와 데이터베이스(Database)의 데이터 교환 형식으로 자주 사용됩니다. 우리는 수동으로 CSV(Comma-Separated Values) 파일을 한 번에 한 라인씩 읽어서, 콤마로 구분된 필드를 분리할 수 있습니다. 그리고 그 결과를 리스트와 딕셔너리 같은 자료구조에 넣을 수 있습니다. 하지만 파일 구문 분석을 할 때 생각보다 더 복잡할 수 있기 때문에 표준 csv 모듈을 사용하는 것이 더 좋습니다.

  • 어떤 것은 콤마 대신 수직선('|')이나 탭('\t') 문자를 사용합니다.

  • 어떤 것은 이스케이프 시퀀스를 사용합니다. 만일 필드 내에 구분자를 포함하고 있다면, 전체 필드는 인용 부호로 둘러싸여 있거나 일부 이스케이프 문자가 앞에 올 수 있습니다.

  • 파일은 운영체제에 따라 개행 문자가 다릅니다. 유닉스는 '\n', 마이크로소프트는'\r\n', 애플은 '\r'을 썼지만 현재는 '\n'을 사용합니다.

  • 열(Column) 이름이 첫 번째 라인에 올 수 있습니다.

먼저 리스트를 읽어서 CSV 형식의 파일을 작성해봅시다.

>>> import csv
>>> villains = [
	['Doctor', 'No'],
	['Rosa', 'Klebb'],
	['Mister', 'Big'],
	['Auric', 'Goldfinger'],
	['Ernst', 'Blofeld']
	]
>>> with open('villains', 'wt') as fout: # 콘텍스트 매니저
	csvout = csv.writer(fout)
	csvout.writerows(villains)

이는 다음과 같은 villains 파일을 생성합니다.

Doctor, No
Rosa, Klebb
Mister, Big
Auric, Goldfinger
Ernst, Blofeld

다시 파일을 읽어봅니다.

>>> import csv
>>> with open('villains', 'rt') as fin: # 콘텍스트 매니저
	cin = csv.reader(fin)
	villains = [row for row in cin] # 리스트 컴프리헨션

	
>>> print(villains)
#[['Doctor', 'No'], ['Rosa', 'Klebb'], ['Mister', 'Big'], ['Auric', 'Goldfinger'], ['Ernst', 'Blofeld']]

위 예제에서 리스트 컴프리헨션이 등장합니다(이전 게시글에서 문법을 잠깐 살펴보고 와도 좋습니다). 그리고 reader() 함수를 사용하여 CSV 형식의 파일을 쉽게 읽을 수 있습니다. 이 함수는 for  문에서 cin 객체의 행을 추출할 수 있게 해 줍니다.

기본값으로 reader()와 writer() 함수를 사용하면, 열은 콤마로 나누어지고, 행은 줄 바꿈 문자로 나누어집니다.

리스트의 리스트가 아닌 딕셔너리의 리스트로 데이터를 만들 수 있습니다 villains 파일을 다시 한번 읽어봅시다. 이번에는 DictReader() 함수를 사용하여 열 이름을 지정합니다.

>>> import csv
>>> with open('villains', 'rt') as fin:
	cin = csv.DictReader(fin, fieldnames = ['first', 'last'])
	villains = [row for row in cin]

	
>>> print(villains)
#[OrderedDict([('first', 'Doctor'), ('last', 'No')]),
#OrderedDict([('first', 'Rosa'), ('last', 'Klebb')]),
#OrderedDict([('first', 'Mister'), ('last', 'Big')]),
#OrderedDict([('first', 'Auric'), ('last', 'Goldfinger')]),
#OrderedDict([('first', 'Ernst'), ('last', 'Blofeld')])]

쉽게 볼 수 있도록 출력을 임의로 조정하였습니다. 원래대로면 한 줄로 쭉 나열됩니다. DictWriter() 함수를 사용하여 CSV 파일을 다시 써봅시다. 또한 CSV 파일의 첫 라인에 열 이름을 쓰기 위해 writeheader() 함수를 호출합니다.

>>> import csv
>>> villains = [
	{'first': 'Doctor', 'last': 'No'},
	{'first': 'Rosa', 'last': 'Klebb'},
	{'first': 'Mister', 'last': 'Big'},
	{'first': 'Auric', 'last': 'Goldfinger'},
	{'first': 'Ernst', 'last': 'Blofeld'}
	]
>>> with open('villains', 'wt') as fout:
	cout = csv.DictWriter(fout, ['first', 'last'])
	cout.writeheader()
	cout.writerows(villains)

이는 헤더 라인과 함께 villains 파일을 생성합니다.

first, last
Doctor, No
Rosa, Klebb
Mister, Big
Auric, Goldfinger
Ernst, Blofeld

다시 파일을 읽어봅시다. DictReader() 호출에서 필드 이름의 인자를 배면, 첫 번째 라인(first, last)의 값은 딕셔너리의 키로 사용됩니다.

>>> import csv
>>> with open('villains', 'rt') as fin:
	cin = csv.DictReader(fin)
	villains = [row for row in cin]

	
>>> print(villains)
#[OrderedDict([('first', 'Doctor'), ('last', 'No')])
#OrderedDict([('first', 'Rosa'), ('last', 'Klebb')]),
#OrderedDict([('first', 'Mister'), ('last', 'Big')]),
#OrderedDict([('first', 'Auric'), ('last', 'Goldfinger')]),
#OrderedDict([('first', 'Ernst'), ('last', 'Blofeld')])]

쉽게 볼 수 있도록 출력을 임의로 조정하였습니다. 원래대로면 한 줄로 쭉 나열됩니다

 

 

2.2 XML

 

구분된 파일은 행(라인)과 열(라인에 속하는 필드)의 2차원 구조로 구성되어 있습니다. 프로그램 간에 자료구조를 교환하기 위해 텍스트를 계층구조, 시퀀스, 셋, 또는 다른 자료구조로 인코딩해야 합니다.

XML은 가장 잘 알려진 마크업 형식입니다. XML은 데이터를 구분하기 위해 태그를 사용합니다. 다음의 간단한 menu.xml 파일 코드를 살펴봅시다.

<?xml version="1.0">
<menu>
    <breakfast hours="7-11">
    	<item price="$6.00">breakfast burritos</item>
        <item price="$4.00">pancakes</item>
    </breakfast>
    <lunch hours="11-3">
    	<item price="$5.00">hamburger</item>
    </lunch>
    <dinner hours="3-10">
    	<item price="$8.00">spaghetti</item>
    </dinner>
</menu>

다음은 XML의 중요한 특징입니다.

  • 태그는 < 문자로 시작합니다. menu.xml의 태그는 menu, breakfast, lunch, dinner, item입니다.

  • 공백은 무시됩니다.

  • 일반적으로 <menu>와 같은 시작 태그는 다른 내용이 따라옵니다. 그러고 나서 </menu>와 같은 끝 태그가 매칭 됩니다.

  • 태그 안에 태그를 중첩할 수 있습니다 코드에서 item 태그는 breakfast, lunch, dinner 태그의 자식입니다. 또한 이 태그들은 menu 태그의 자식입니다.

  • 옵션 속성은 시작 태그에 나올 수 있습니다. 코드의 price는 item의 속성입니다.

  • 태그는 을 가질 수 있습니다. 코드의 각 item은 값을 가집니다. 예를 들어 두 번째 breakfast의 item은 pancakes 값을 가집니다.

  • thing이라는 태그에 값이나 자식이 없다면, <thing></thing>과 같은 시작 태그와 끝 태그가 아닌, <thing/>과 같은 단일 태그로 표현할 수 있습니다.

  • 속성, 값, 자식 태그의 데이터를 어디에 넣을 것인가에 대한 선택은 다소 임의적입니다. 예를 들어 마지막 item 태그를 <item price="$8.00" food="spaghetti"/> 형식으로 쓸 수 있습니다.

XML은 데이터 피드(Data Feed)와 메시지 전송에 많이 쓰입니다. 그리고 RSS(Rich Site Summary)와 아톰(Atom) 같은 하위 형식이 있습니다. 일부는 금융 분야와 같은 특화된 XML 형식을 가집니다.

XML의 두드러진 유연성은 접근법(Approach)과 능력(capability)이 다른 여러 파이썬 라이브러리에 영향을 미쳤습니다.

XML을 파싱(해석)하는 간단한 방법은 ElementTree 모듈을 사용하는 것입니다. menu.xml을 파싱 하여 태그와 속성을 출력하는 작은 프로그램을 만들어봅시다.

>>> import xml.etree.ElementTree as et
>>> tree = et.ElementTree(file = 'menu.xml')
>>> root = tree.getroot()
>>> root.tag
#'menu'
>>> for child in root:
	print('tag:', child.tag, 'attributes:', child.attrib)
    for grandchild in child:
    	print('\ttag:', grandchild.tag, 'attributs:', grandchild.attrib)
        
#tag: breakfast attributes: {'hours': '7-11'}
#	tag: item attributes: {'price': '$6.00'}
#	tag: item attributes: {'price': '$4.00'}
#tag: lunch attributes: {'hours': '11-3'}
#	tag: item attributes: {'price': $5.00'}
#tag: dinner attributes: {'hours': '3-10'}
#	tag: item attributes: {'price': '$8.00'}
>>> len(root) # menu의 하위 태그 수
#3
>>> len(root[0]) # breakfast의 item 수
#2

중첩된 리스트의 각 요소에 대해 tag는 태그 문자열이고, attrib는 속성의 딕셔너리입니다. ElementTree 모듈은 XML에서 파생된 데이터를 검색하고 수정할 수 있는 다양한 방법을 제공합니다. 심지어 XML 파일을 쓸 수 있습니다. 더 자세한 사항은 ElementTree 문서를 참고하세요.

기타 표준 파이썬 XML 라이브러리를 소개합니다.

  • xml.dom

    • 자바 스크립트 개발자에게 친숙한 DOM(Document Object Model)은 웹 문서를 계층구조로 나타냅니다. 이 모듈은 전체 XML 파일을 메모리에 로딩하여 XML의 모든 항목을 접근할 수 있게 합니다.

  • xml.sax

    • SAX(Sample API for XML)는 즉석에서 XML을 파싱 합니다. 즉, 한 번에 전체 XML 파일을 메모리에 로딩하지 않습니다. 그러므로 매우 큰 XML 스트림을 처리해야 한다면 이 모듈을 사용하는 것이 좋습니다.

 

2.3. HTML

 

웹의 기본 문서 형식으로, 엄청난 양의 데이터가 HTML로 저장되어 있습니다. 문제는 대부분의 HTML 파일이 규칙을 따르지 않아서 파싱이 어려울 수 있다는 것이니다. 또한 HTML의 대부분은 데이터를 교환하기보다는 결과를 표현하는 형태로 더 많이 사용됩니다. 이번 게시글에서는 아주 잘 정의된 데이터 형식을 다루고 있으므로, HTML에 대한 내용은 추후 작성될 게시글에서 살펴보겠습니다.

 

2.4. JSON

 

JSON(JavaScrpit Object Notation)은 자바스크립트를 넘어서, 데이터를 교환하는 아주 인기 있는 형식이 되었습니다. JSON은 자바스크립트의 서브셋이자, 유효한 파이썬 구문입니다. 프로그램 간에 데이터를 교환할 때, 파이썬과 JSON은 궁합이 잘 맞습니다. 추후에 작성될 게시글에서 웹 개발을 위한 JSON의 많은 코드를 살펴볼 것입니다.

다수의 XML 모듈과는 달리, JSON에는 하나의 메인 모듈이 있습니다. 이름도 기억하기 쉬운 json입니다. 이 모듈은 데이터를 JSON 문자열로 인코딩(Dumps)하고, JSON 문자열을 다시 데이터로 디코딩(Loads)할 수 있습니다. XML에서 사용했던 코드의 데이터로 파이썬의 자료구조를 만들어봅시다.

>>> menu = \
     {
	     "breakfast": {
		     "hours": "7-11",
		     "items": {
			     "breakfast burritos": "$6.00",
			     "pancakes": "$4.00"
			     }
		     },
	     "lunch": {
		     "hours": "11-3",
		     "items": {
			     "hamburger": "$5.00"
			     }
		     },
	     "dinner": {
		     "hours": "3-10",
		     "itmes": {
			     "spaghetti": "$8.00"
			     }
		     }
	     }

다음은 dums()를 사용하여 자료구조(menu)를 JSON 문자열(menu_json)로 인코딩합니다.

>>> import json
>>> menu_json = json.dumps(menu)
>>> menu_json
#'{"breakfast": {"hours": "7-11", "items": {"breakfast burritos": "$6.00", "pancakes": "$4.00"}}, 
#"lunch": {"hours": "11-3", "items": {"hamburger": "$5.00"}}, 
#"dinner": {"hours": "3-10", "itmes": {"spaghetti": "$8.00"}}}'

보기 좋게 출력은 일부 수정하였습니다. 원래대로라면 한 줄로 출력됩니다. 그다음은 loads()를 사용하여 JSON 문자열(menu_json)을 자료구조(menu2)로 디코딩합니다.

>>> menu2 = json.loads(menu_json)
>>> menu2
#{'breakfast': {'hours': '7-11', 'items': {'breakfast burritos': '$6.00', 'pancakes': '$4.00'}},
#'lunch': {'hours': '11-3', 'items': {'hamburger': '$5.00'}},
#'dinner': {'hours': '3-10', 'itmes': {'spaghetti': '$8.00'}}}

보기 좋게 출력은 일부 수정하였습니다. 원래대로라면 한 줄로 출력됩니다. menu와 menu2는 같은 키와 값을 가진 딕셔너리입니다. 키 순서는 딕셔너리 표준에 따라 달라질 수 있습니다.

다음은 datetime과 같은 모듈을 사용하여 객체를 인코딩 혹은 디코딩하는 도중에 예외가 발생하는 경우입니다(datetime 모듈은 추후에 작성될 다른 게시글에서 자세히 살펴보도록 하겠습니다).

>>> import datetime
>>> now = datetime.datetime.utcnow()
>>> now
datetime.datetime(2020, 3, 7, 17, 29, 20, 339103)
>>> json.dumps(now)
#Traceback (most recent call last):
	# 이하 생략
#TypeError: Object of type datetime is not JSON serializable

표준 JSON 모듈에서 날자 또는 시간 타입을 정의하지 않았기 때문에 예외가 발생한 것입니다. datetime 객체를 문자열과(추후에 작성될 다른 게시글에서 다루게 될) 에폭(Epoch) 값과 같이 JSON이 이해할 수 있는 타입으로 변환하면 됩니다.

>>> now_str = str(now)
>>> json.dumps(now_str)
#'"2020-03-07 17:29:20.339103"'
>>> from time import mktime
>>> now_epoch = int(mktime(now.timetuple()))
>>> json.dumps(now_epoch)
#'1583569760'

인코딩하는 중간에 datetime 값을 일반적인 데이터 타입으로 변환해야 한다면, 이에 대한 특수 변환 로직을 만드는 것은 까다로운 일입니다. JSON 문자열로 인코딩하는 방법은 상속을 통해 수정할 수 있습니다. 파이썬의 JSON 문서는 예외가 발생할 수 있는 복잡한 허수 인코딩에 대한 예제를 보여줍니다. datetime 값을 수정해봅시다.

>>> class DTEncoder(json.JSONEncoder):
	def default(self, obj):
		# isinstance()는 obj의 타입을 확인합니다.
		if isinstance(obj, datetime.datetime):
			return int(mktime(obj.timetuple()))
		# obj가 datetime 타입이 아니라면 기본 JSON 문자열로 인코딩합니다.
		return json.JSONEncoder.default(self, obj)
        
	
>>> json.dumps(now, cls = DTEncoder)
#'1583569760'

새로운 DTEncoder 클래스는 JSONEncoder의 서브(자식) 클래스입니다. datetime 값을 처리하기 위해 default() 메소드만 오버라이드하면 됩니다. 오버라이드 외의 다른 모든 부분은 부모 클래스가 처리합니다.

isinstance() 함수는 obj 객체가 datetime.datetime 클래스의 인스턴스인지 확인합니다. 파이썬에서 모든 것은 객체이기 때문에 isinstance() 함수는 어느 곳에서든 작동합니다.

>>> type(now)
#<class 'datetime.datetime'>
>>> isinstance(now, datetime.datetime)
#True
>>> type(234)
#<class 'int'>
>>> isinstance(234, int)
#True
>>> type('hey')
#<class 'str'>
>>> isinstance('hey', str)
#True

참고로, 자료구조에 대해 아무것도 모르는 상태에서, JSON과 다른 구조화된 텍스트 형식의 파일을 자료구조로 불러올 수 있습니다. 그리고 isinstance()와 타입에 대해 적절한 메소드를 사용하여 구조를 파악한 후 값을 볼 수 있습니다. 예를 들어 딕셔너리의 경우에는 key(), values(), items() 메소드로 내용을 추출할 수 있습니다.

 

2.5. YAML

 

JSON과 유사하게 YAML은 키와 값을 가지고 있지만, 날짜와 시간 같은 데이터 타입을 더 많이 처리합니다. 표준 파이선 라이브러리는 아직 YAML 처리를 지원하고 있지 않습니다. 그래서 yaml이라는 써드파티 라이브러리를 설치합니다. load()는 YAML 문자열을 파이썬 데이터로, dump()는 그 반대의 기능을 수행합니다.

다음은 캐나다 시인 제임스 매킨타이어(James McIntyre)의 정보와 그의 두 시가 담신 mcintyre.yaml파일입니다.

name:
    first: James
    last: McIntyre
dates:
    birth: 1828-05-25
    death: 1906-03-31
details:
    bearded: true
    themes: [cheese, Canada]
books:
    url: http://www.gutenberg.org/files/36068/36068-h/36068-h.htm
poems:
    - title: 'Motto'
      text: |
        Politeness, perseverance and pluck,
        To their possessor wil lbring good luck.
    - title: 'Canadian Charms'
      text: |
        Here industry is not in vain,
        For we have bounteous crops of grain,
        And you behold on every field
        Of grass and roots abundant yield, But after all the rgeatest charm
        Is the snug home upon the farm,
        And stone walls now keep cattle warm.

true, false, on, off와 같은 값은 부울형으로 변환됩니다. 정수와 문자열도 파이썬의 타입으로 변환됩니다. 다른 구문들은 리스트와 딕셔너리를 생성합니다.

>>> import yaml
>>> with open('mcintyre.yaml', 'rt') as fin:
>>>     text = fin.read()
>>> data = yaml.load(text)
>>> data['details']
#{'themes': ['cheese', 'Canada'], 'bearded': True}
>>> len(data['poems'])
#2

YAML파일에 맞게 생성된 자료구조는 한 레벨 더 들어가 있습니다. 딕셔너리/리스트/딕셔너리 참조로 다음과 같이 두 번째 시의 제목을 얻을 수 있습니다.

>>> data['poems'][1]['title']
#'Canadian Charms'

주의할 점은, PyYAML은 문자열에서 파이선 객체를 불러올 수 있으나 위험합니다. 신뢰할 수 없는 YAML을 불러온다면 load() 대신 safe_load()를 사용하세요. 아직까지는 항상 safe_load()를 사용하는 것이 좋습니다. 루비 온 레일즈 플랫폼에서 보호받을 수 없는 YAML 로딩의 절충안에 대한 글(war is peace)을 읽어보세요.

 

2.6. 보안 노트

 

객체를 어떤 파일로 저장하고, 다시 그 파일을 객체로 읽어오기 위해 이번 게시글에서 언급한 모든 타입을 사용할 수 있습니다 그러나 이 과정에서 보안 문제가 발생할 수 있습니다.

예를 들어 위키피디아의 billion laughs의 다음 XML 코드에는 10개의 엔티티(Entity)가 있습니다. 각 엔티티는 하위 레벨로 10배씩 확장되어 총 10억 개로 확장됩니다.

<?xml version="1.0"?>
<!DOCTYPE lolz [
 <!ENTITY lol "lol">
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
 <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
 <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
 <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
 <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
 <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
 <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
 <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>

billion laughs는 이전 절에서 언급한 모든 XML 라이브러리를 뚫어버립니다. Defused XML은 파이썬 라이브러리의 취약한 부분은 물론이고 billion laughs와 다른 공격을 나열하고 있습니다. 이 링크는 이러한 문제를 방지하기 위해 라이브러리의 설정을 변경하는 방법에 대한 가이드를 제공합니다. 또한 다른 라이브러리에 대한 프론트엔드 보안으로 defusedxml 라이브러리를 사용할 수 있습니다.

>>> # 보안되지 않은 parse
>>> from xml.etree.ElementTree import parse
>>> et = parse(xmlfile)
>>> # 보안된 parse
>>> from defusedxml.ElementTree import parse
>>> et = parse(xmlfile)

 

2.7. 설정 파일

 

대부분의 프로그램은 다양한 옵션이나 설정을 제공합니다. 동적인 것은 프로그램의 인자를 통해 제공하면 되지만, 정적인 것(또는 오래 지속되는 것)은 어딘가에 유지되어야 합니다. 빠른 설정 파일 형식을 스스로 정의한느 것에 현혹되기 쉽지만, 이를 뿌리쳐야 합니다. 대부분의 직접 만든 설정 파일은 지저분하고, 그렇게 빠르지 않습니다. 라이터(Writer) 프로그램과 리더(Reader) 프로그램(파서(Parse)라고도 함) 모두 관리해야 합니다. 프로그램에 적용할 수 있는 코드를 살펴봅시다.

여기서 윈도우 스타일의 .ini 파일을 처리하는 표준 configparse 모듈을 사용해봅시다. 이 파일은 키 = 값 형식의 섹션이 있습니다. 다음은 settings.cfg 파일의 에제입니다.

[english]
greeting = Hello

[french]
greeting = Bonjour

[files]
home = /usr/local
# 간단한 보간법 사용
bin = %(home)s/bin

설정 파일을 읽어서 자료구조로 변환하는 코드를 살펴봅시다.

>>> import configparse
>>> cfg = configparser.ConfigParser()
>>> cfg.read('settings.cfg')
#['settings.cfg']
>>> cfg
#<configparser.ConfigParser object at 0x1006be4d0>
>>> cfg['french']
#<Section: french>
>>> cfg['french']['greeting']
#'Bonjour'
>>> cfg['files']['bin']
#'/usr/local/bin'

설정 파일에 대한 보간법을 포함한 다른 옵션들은 configparse 문서를 참고하세요. 만약 두 단계보다 더 깊은 중첩 설정이 필요한 경우 YAML 혹은 JSON 파일 형식을 사용합니다.

 

2.8. 기타 데이터 교환 형식

 

다음의 이진 데이터 교환 형식은 일반적으로 더 간결하고 XML이라 JSON보다 빠릅니다.

이들은 이진 형식이기 때문에 텍스트 편집기로 쉽게 편집할 수 없습니다.

 

2.9. 직렬화하기: pickle

 

자료구조(객체)를 파일로 저장하는 것을 직렬화(Serialization)라고 합니다. JSON과 같은 형식은 파이썬 프로그램에서 모든 데이터 타입을 직렬화하는 컨버터가 필요합니다. 파이썬은 바이너리 형식으로 된 객체를 저장하고 복원할 수 있는 pickle 모듈을 제공합니다.

datetime 객체를 JSON 형식으로 인코딩했을 때 예외가 발생한 것을 기억하십니까? pickle 모듈에서는 문제가 되지 않습니다.

>>> import pickle
>>> import datetime
>>> now1 = datetime.datetime.utcnow()
>>> pickled = pickle.dumps(now1)
>>> now2 = pickle.loads(pickled)
>>> now1
#datetime.datetime(2020, 3, 7, 18, 38, 11, 557315)
>>> now2
#datetime.datetime(2020, 3, 7, 18, 38, 11, 557315)

또한 pickle은 우리가 만든 클래스와 객체에서도 작동합니다. 객체를 문자열로 취급할 때 'tiny' 문자열을 반환하는 Tiny 클래스를 정의해봅시다.

>>> import pickle
>>> class Tiny():
	def __str__(self):
		return 'tiny'

	
>>> obj1 = Tiny()
>>> obj1
#<__main__.Tiny object at 0x00000289DFC87908>
>>> str(obj1)
#'tiny'
>>> pickled = pickle.dumps(obj1)
>>> pickled
#b'\x80\x03c__main__\nTiny\nq\x00)\x81q\x01.'
>>> obj2 = pickle.loads(pickled)
>>> obj2
#<__main__.Tiny object at 0x00000289DFCE5D30>
>>> str(obj2)
#'tiny'

pickled는 직렬화된 obj1 객체의 이진 문자열입니다. obj1의 복사본을 만들기 위해 pickled를 다시 역직렬화하여 obj2 객체로 변환했습니다. dump()로 직렬화(Pickle)하고, load()로 역직렬화(Unpickle)합니다.

참고로, pickle은 파이선 객체를 만들 수 있기 때문에, 이전 절에서와 같은 보안 문제가 적용됩니다. 신뢰할 수 없는 것은 역직렬화하지 않을 것을 추천합니다.

 

3. 구조화된 이진 파일

 

일부 파일 형식은 특정 자료구조를 저장하기 위해 설계되었지만, 관계형 데이터베이스나 NoSQL 데이터베이스는 그렇지 않습니다. 이번 절에서는 구조화된 이진 파일에 대해 살펴봅니다.

 

3.1. 스프레드시트

 

마이크로소프트 엑셀과 같은 스프레드시트(Spreadsheet)는 광범위한 이진 데이터 형식입니다. ㅡ프레드시트를 CSV 파일로 저장하면 표준 csv 모듈을 사용하여 이 파일을 읽을 수 있습니다. 만약 이진 xls 파일이 있다면, 이 파일을 읽고 쓸 수 있는 써드파티 패키지인 xlrd를 사용하면 됩니다.

 

3.2. HDF5

 

HDF5(Hierarchical Data Format)는 다차원 혹은 계층적 수치 데이터를 위한 이진 데이터 형식입니다. 이것은 주로 아주 큰 데이터 집합(기가바이트 ~ 테라바이트)에 대한 빠른 임의적인 접근이 필요한 과학 분야에 주로 사용됩니다. 심지어 어떤 경우 HDF5는 데이터베이스의 좋은 대안이 될 수 있지만, 어떤 이유에선지 세상에 잘 알려져 있지 않습니다. HDF5는 쓰기 충돌에 대한 데이터베이스의 보호가 필요하지 않은 웜(WORM; Write Once/Read Many; 디스크에 데이터를 단 한 번만 쓸 수 있고, 그 후에는 데이터가 삭제되지 않도록 보호하는 데이터 저장 기술) 애플리케이션에 적합합니다.

  • h5py는 완전한 기능을 갖춘 저수준의 인터페이스입니다. 자세한 정보는 문서코드를 참고하세요.

  • PyTables는 약간 고수준의 인터페이스로, 데이터베이스와 같은 기능을 지원합니다. 자세한 정보는 문서코드를 참고하세요.

많은 양의 데이터를 저장 및 검색하고 일반적인 데이터베이스 솔루션뿐만 아니라 무너가 다른 기술을 고려하고자 하는 경우를 위해 여기에서 HDF5를 언급했습니다. HDF5의 모범 예는 HDF5 형식으로 곡을 내려받을 수 있는 데이터를 가진 Million Song dataset입니다.

 

4. 관계형 데이터베이스

 

관계형 데이터베이스(Relational Database)는 약 40년 된 기술로서, 컴퓨팅 세계의 유비쿼터스(Ubiquitous)로 자리 잡고 있습니다. 관계형 데이터베이스는 반드시 한 번 이상 사용하게 될 것입니다. 관계형 데이터베이스를 사용해보면 그들이 제공하는 기능에 감탄하게 될 것입니다.

  • 다수의 동시 사용자가 데이터에 접근

  • 사용자에 의한 데이터 손상으로부터의 보호

  • 데이터를 저장하고 검색하는 효율적인 방법

  • 스키마(Schema)에 의해 정의된 데이터와 제약조건(Constraint)에 한정되는 데이터

  • 다양한 데이터 타입과의 관계(Relationship)를 계산하는 조인(Join)

  • 명령형(Imperative)이기보다는 서술적인(Declarative) 질의 언어: SQL(Structured Query Language)

다양한 종류의 데이터 간의 관계를 테이블(Table) 형태로 표시하기 때문에 관계형이라고 부릅니다. 예를 들어 이전의 메뉴(Menu) 코드에서 각 항목(Item)과 가격(Price)은 관계가 있습니다.

테이블은 스프레드시트와 비슷한 행과 열로 이루어진 격자(Grid)입니다. 테이블을 생성하기 위해서는 테이블의 이름을 짓고, 테이블에 대한 열의 순서와 이름, 타입을 지정해야 합니다. 비록 열이 누락된 데이터(널; Null)를 허용할 수 있도록 정의할 수는 있지만, 각 행은 같은 열을 가집니다. 메뉴 코드에서는 판매 중인 각 항목이 하나의 행을 가진 테이블을 만들 수 있습니다. 각 항목은 가격에 대한 열을 포함하여 같은 열을 가집니다.

일반적으로 하나의 열 또는 열의 그룹은 테이블의 기본키(Primary Key)입니다. 기본키 값은 테이블에서 반드시 유일해야 합니다. 이는 테이블에 동일한 데이터를 추가하는 것을 방지해줍니다. 이 키는 질의를 빠르게 찾을 수 있도록 인덱싱(Indexing)되어 있습니다. 인덱스는 특정 행을 빨리 찾을 수 있도록 만들어주는 책의 색인처럼 작동합니다.

파일이 디렉터리 안에 있는 것처럼, 각 테이블은 상위 데이터베이스 내에 존재합니다 이러한 두 가지 수준의 계층구조는 조금 더 나은 조직을 유지할 수 있도록 해줍니다.

참고로, 데이터베이스는 서버, 테이블 컨테이너, 데이터 저장 등 다양한 용도로 사용됩니다. 이들을 동시에 다룰 때는 데이터베이스 서버, 데이터베이스, 데이터로 구분하여 부르면 도움이 될 것입니다.

키가 아닌 열값으로 행을 찾으려면, 그 열에 부차적인 인덱스를 정의합니다. 그렇지 않으면 데이터베이스 서버는 열 값과 일치하는 모든 행을 무차별 검색합니다. 즉 테이블 스캔을 합니다.

테이블은 외래키(Foreign Key)와 서로 연관될 수 있으므로 열 값은 이러한 외래 키에 대한 제약이 있을 수 있습니다.

 

4.1. SQL

 

SQL은 API나 프로토콜이 아닙니다. SQL은 어떤 결과를 얻기 위해 수행 과정을 나열하는 것이 아닌, 원하는 결과를 질의하는 서술형 언어이니다. SQL은 관계형 데이터베이스의 보편적인 언어입니다. SQL 질의(Query)는 클라이언트에서 데이터베이스 서버로 전송하는 텍스트 문자열입니다.

표준 SQL에 대한 다양한 정의가 있었습니다. 그러나 데이터베이스 회사들이 SQL을 개조하고 확장하여, 방언(Dialect)처럼 많은 SQL이 생겨났습니다. 관계형 데이터베이스에 데이터를 저장할 때 SQL은 호환성(Portability)을 제공합니다. 하지만 다양한 SQL과 운영에 대한 차이는 데이터를 또 다른 타입의 데이터베이스로 옮기기 어렵게 만듭니다.

SQL문에는 두 개의 주요 카테고리가 있습니다.

  • DDL(Data Definition Language; 데이터 정의어)

    • 테이블, 데이터베이스, 사용자에 대한 생성, 삭제, 제약조건(Constraint), 권한(Permission)을 다룹니다.

  • DML(Datq Manipulation Language; 데이터 조작어)

    • 데이터의 조회, 삽입, 갱신, 삭제를 다룹니다.

다음의 표에 기본 SQL DDL 명령어를 나열했습니다.

명령 SQL 패턴 SQL 예시 코드
데이터베이스 생성 CREATE DATABASE dbname CREATE DATABASE d
현재 데이터베이스 선택 USE dbname USE d
데이터베이스와 해당 테이블 삭제 DROP DATABASE dbname DROP DATABASE d
테이블 생성 CREATE TABLE tbname (coldefs) CREATE TABLE t (id INT, count INT)
테이블 삭제 DROP TABLE tbname DROP TABLE t
테이블의 모든 행 삭제 TRUNCATE TABLE tbname TRUNCATE TABLE t

참고로, 모든 명령어를 왜 대문자로 썼을까요? SQL은 대소문자를 구분하지 않습니다. 이는 코드에서 열 이름과 명령 키워드를 구분하기 위해 사용하는 관습입니다.

관계형 데이터베이스의 메인 DML 명령어는 CRUD(Create, Read, Update, Delete)로 알려져 있습니다.

  • 생성(Create): INSERT 문 사용
  • 조회(Read): SELECT 문 사용
  • 갱신(Update): UPDATE 문 사용
  • 삭제(Delete): DELETE 문 사용

다음의 표에 기본 SQL DML 명령어를 나열했습니다.

명령 SQL 패턴 SQL 코드 예제
행 추가 INSERT INTO tbname VALUES(~~~) INSERT INTO t VALUES(7, 40)
모든 행과 열 조회 SELECT * FROM tbname SELECT * FROM t
모든 행과 특정 열 조회 SELECT cols FROM tbname SELECT id, count FROM t
특정 행과 열 조회 SELECT cols FROM tbname WHERE condition SELECT id, count FROM t WHERE count > 5 AND id = 9
특정 열의 행값 변경 UPDATE tbname SET col = value WHERE condition UPDATE t SET count = 3 WHERE id = 5
행 삭제 DELETE FROM tbname WHERE condition DELETE FROM t WHERE count <= 10 OR id = 16

 

4.2. DB-API

 

API(Application Programming Interface)는 어떤 서비스에 대한 접근을 얻기 위해 호출하는 함수들의 집합입니다. DB-API는 관계형 데이터베이스에 접근하기 위한 파이썬의 표준 API입니다. DB-API를 사용하면 관계형 데이터베이스 각각에 대해 별도의 프로그램을 작성하지 않고, 여러 종류의 데이터베이스를 동작하기 위한 하나의 프로그램만 작성하면 됩니다. 이것은 자바의 JDBC 그리고 펄의 dbi와 유사합니다.

메인 함수는 다음과 같습니다.

  • connect()
    • 데이터베이스의 연결을 만듭니다. 이 함수는 사용자 이름, 비밀번호, 서버 주소 등의 인자를 포함합니다.
  • cursor()
    • 질의를 관리하기 위한 커서 객체를 만듭니다.
  • execute(), executemany()
    • 데이터베이스에 하나 이상의 SQL 명령을 실행합니다.
  • fetchone(), fetchmany(), fetchall()
    • 실행 결과를 얻습니다.

이어지는 절에서 언급되는 파이썬 데이터베이스 모듈은 DB-API를 따르지만, 확장 기능 및 세부사항에 차이가 있습니다.

 

4.3. SQLite

 

SQLite는 가볍고 좋은 오픈소스의 관계형 데이터베이스입니다. SQLite는 표준 파이선 라이브러리로 구현되어 있고, 일반 파일처럼 데이터베이스를 저장합니다. 이 파일은 서로 다른 컴퓨터와 운영체제에 대해 호환 가능합니다. SQLite는 간단한 관계형 데이터베이스 애플리케이션에 대한 호환성이 아주 뛰어난 솔루션입니다. SQLite는 MySQL, PostgreSQL처럼 완전한 기능을 제공하진 않습니다. 그러나 SQL을 지원하고, 동시에 여러 사용자를 관리할 수 있습니다. 웹 브라우저, 스마트폰, 그리고 다른 애플리케이션에서 SQLite를 임베디드(Embedded) 데이터베이스처럼 사용합니다.

우리는 사용하거나 생성하고자 하는 로컬 SQLite 데이터베이스 파일을 connect()로 연결하는 것으로 시작합니다. 이 파일은 다른 서버에서 테이블을 관리하는 디렉터리 구조와 유사한 데이터베이스입니다. 특수한 문자열 ':memory:'는 메모리에서만 데이터베이스를 생성합니다. 이것은 빠르고, 테스트에 유용합니다. 그러나 프로그램을 종료하거나 컴퓨터를 끄면 데이터가 사라집니다.

다음 코드에서 enterprise.db라는 데이터베이스를 생성합니다. 그리고 동물원 사업을 하기 위해 zoo 테이블을 생성합니다. 테이블의 열은 다음과 같습니다.

  • critter

    • 가변 길이 문자열, 동물 이름(기본키)

  • count

    • 정수, 현재 동물 수

  • damages

    • 부동소수점수, 관람객으로부터 받은 상처 등 동물의 손실 금액

>>> import sqlite3
>>> conn = sqlite3.connect('enteprise.db')
>>> curs = conn.cursor()
>>> curs.execute('''CREATE TABLE zoo
    (critter VARCHAR(20) PRIMARY KEY,
    count INT,
    damages FLOAT)''')
#<sqlite3.Cursor object at 0x00000289DFCC13B0>

3중 인용 부호는 SQL 질의와 같은 긴 문자열을 생성하는 데 편리합니다.

zoo에 동물을 추가합니다.

>>> curs.execute('INSERT INTO zoo VALUES("duck", 5, 0.0)')
#<sqlite3.Cursor object at 0x00000289DFCC13B0>
>>> curs.execute('INSERT INTO zoo VALUES("bear", 2, 1000.0)')
#<sqlite3.Cursor object at 0x00000289DFCC13B0>

플레이스홀더(Placeholder)를 사용하여 데이터를 안전하게 넣을 수 있습니다.

>>> ins = 'INSERT INTO zoo (critter, count, damages) VALUES(?, ?, ?)'
>>> curs.execute(ins, ('weasel', 1, 2000.0))
#<sqlite3.Cursor object at 0x00000289DFCC13B0>

세 값을 SQL 문의 세 물음표에 삽입할 예정이라고 표시한 후, 세 값의 튜플을 execute() 함수 인자로 전달합니다. 플레이스홀더를 사용함으로써 인용 부호를 억지로 구겨 넣을 필요가 없습니다. 그리고 플레이스홀더는 웹에서 악의적인 SQL 명령을 삽입하는 외부 공격(SQL 인젝션(Injection))으로부터 시스템을 보호합니다.

모든 동물을 조회해봅니다.

>>> curs.execute('SELECT * FROM zoo')
#<sqlite3.Cursor object at 0x00000289DFCC13B0>
>>> rows = curs.fetchall()
>>> print(rows)
#[('duck', 5, 0.0), ('bear', 2, 1000.0), ('weasel', 1, 2000.0)]

동물 수를 오름차순 정렬하여 조회합니다.

>>> curs.execute('SELECT * from zoo ORDER BY count')
#<sqlite3.Cursor object at 0x00000289DFCC13B0>
>>> curs.fetchall()
#[('weasel', 1, 2000.0), ('bear', 2, 1000.0), ('duck', 5, 0.0)]

내림차순으로도 조회해봅니다.

>>> curs.execute('SELECT * from zoo ORDER BY count DESC')
#<sqlite3.Cursor object at 0x00000289DFCC13B0>
>>> curs.fetchall()
#[('duck', 5, 0.0), ('bear', 2, 1000.0), ('weasel', 1, 2000.0)]

한 마리당 가장 많은 비용이 드는 동물은?

>>> curs.execute('''SELECT * FROM zoo WHERE
    damages = (SELECT MAX(damages) FROM zoo)''')
#<sqlite3.Cursor object at 0x00000289DFCC13B0>
>>> curs.fetchall()
#[('weasel', 1, 2000.0)]

곰(Bear)이라고 생각했을 것입니다. 정말 그런지 실제 데이터를 확인해보세요.

SQLite를 떠나기 전에 확인해야 할 것이 있습니다. 데이터베이스 연결과 커서를 열어 이들을 사용한 다음에는 다음과 같이 닫아주어야 합니다.

>>> curs.close()
>>> conn.close()

 

4.4. MySQL

 

MySQL은 매우 인기 있는 오프소스 관계형 데이터베이스입니다. SQLite와 달리 MySQL은 실제 서버로 사용합니다. 클라이언트는 네트워크를 통해 Mysql 서버에 접근할 수 있습니다.

MysqlDB 역시 매우 인기 있는 Mysql 드라이버이지만, 아직 파이썬 3으로 포팅되지 않았습니다. 다음의 표는 파이썬에서 MySQL에 접근하기 위한 드라이버입니다.

이름 링크 Pypi 패키지 임포트 비고
MySQL Connector http://bit.ly/mysql-cpdg mysql-connector-python mysql.connector  
PYMySQL https://github.com/petehunt/PyMySQL/ pymysql pymysql  
oursql http://pythonhosted.org/oursql/ oursql oursql MySQL C 클라이언트 라이브러리가 필요함

 

4.5. PostgreSQL

 

PostgreSQL은 완전한 기능을 갖춘 오픈소스 관계형 데이터베이스입니다. PostgreSQL은 MySQL보다 사용할 수 있는 고급 기능이 더 많습니다. 다음의 표는 파이썬에서 PostgreSQL에 접근하기 위한 드라이버입니다.

이름 링크 Pypi 패이키 임포트 비고
psycopg2 http://initd.org/psycopg/ psycopg2 psycopg2 PosterSQL 클라이언트 도구의 pg_config이 필요함
py-postgresql http://python.projects.pgfoundry.org/ py-postgersql postgresql  

psycopg2가 가장 인기 있지만, 이를 설치하기 위해서는 PostgreSQL 클라이언트 라이브러리가 필요합니다.

 

4.6 SQLAlchemy

 

SQL은 모든 관계형 데이터베이스와 완전히 같지 않습니다. 그리고 DB-API는 이를 해소해줍니다. 각 데이터베이스는 그들만의 다양한 기능과 철학을 바탕으로 구현되었습니다. 많은 라이브러리가 이러한 차이를 좁히려 노력하고 있습니다. 그중 가장 인기 있는 크로스 데이터베이스의 파이썬 라이브러리는 SQLAlchemy입니다.

SQLAlchemy는 표준 라이브러리는 아니지만, 많은 사람에게 잘 알려져 사용되고 있습니다. 다음 명령으로 여러분 시스템에 설치할 수 있습니다.

pip install sqlalchemy

여러 가지 수준에서 SQLAlchemy를 사용할 수 있습니다.

  • 가장 낮은 수준에서 데이터베이스 커넥션 을 처리합니다. SQL 명령을 실행하고, 그 결과를 반환합니다. DB-API와 가장 근접한 위치에 있습니다.

  • 다음 수준은 SQL 표현 언어, 즉 파이써닉한 SQL 빌더입니다.

  • 가장 높은 수준은 SQL 표현 언어를 사용하고, 관계형 자료 구조와 애플리케이션 코드를 바인딩하는 ORM(Object Relational Model)입니다.

이러한 수준에 대한 용어는 자연히 이해하게 될 것입니다. SQLAlchemy는 이전 절에서 본 데이터베이스 드라이버와 함께 작동합니다. 드라이버를 임포트할 필요는 없습니다. SQLAlchemy에서 제공하는 최초의 연결 문자열에서 드라이버를 선택합니다. 문자열은 아래와 같이 생겼습니다.

dialect + driver :// user : password @ host : port / dbname

각 문자열의 값은 다음과 같습니다.

  • dialect

    • 데이터베이스 타입

  • driver

    • 사용하고자 하는 데이터베이스의 특정 드라이버

  • user와 password

    • 데이터베이스 인증 문자열, 사용자와 비밀번호

  • host와 port

    • 데이터베이스 서버의 위치(서버의 데이터베이스 포트가 기본 설정이 아닌 경우에만 포트 번호를 입력합니다).

  • dbname

    • 서버에 연결할 데이터베이스 이름

다음의 표에 데이터베이스와 드라이버 목록을 나열했습니다.

데이터베이스 드라이버
sqlite pysqlite (또는 생략)
mysql mysqlconnector
mysql pymysql
mysql oursql
postgresql psycopg2
postgresql pypostgresql

 

엔진 레이어

먼저 SQLAlchemy의 가장 낮은 수준부터 살펴봅시다. 기본으로 제공하는 DB-API보다 좀 더 많은 기능이 있습니다.

파이썬에 이미 내장되어 있는 SQLite로 먼저 시도해봅시다. 여기서 SQLite의 연결에 대한 문자열의 인자인 host, port, user, password는 생략합니다. dbname은 데이터베이스를 어떤 파일에 저장할지 SQLite에 알려줍니다. dbname을 생략하면, SQLite는 메모리에 데이터베이스를 만듭니다. dbname이 슬래시(/)로 시작한다면, 이것은 절대 경로 파일 이름입니다(이 경우는 리눅스와 맥 OS X에 해당합니다. 윈도우에서는 C:\\와 같이 dbname을 시작할 수 있습니다). 슬래시로 시작하지 않으면 현재 디렉터리에서의 상대 경로를 의미합니다.

다음은 전체 프로그램의 각 부분에 대한 설명입니다.

먼저 우리가 필요로 하는 모듈을 임포트해야 합니다. 이 코드에서는 SQLAlchemy 메소드를 문자열 sa로 참조할 수 있도록 import 문에서 앨리어스(Alias)를 사용합니다. 다른 큰 이유가 있는 것은 아니고, sqlalchemy보다 글자 수가 적어서 입력하기 쉽습니다.

>>> import sqlalchemy as sa

데이터베이스에 연결하고, 메모리에 스토리지를 생성합니다(인자에 'sqlite:///:memory:'라고 입력해도 됩니다).

>>> conn = sa.create_engine('sqlite://')

세 열이 있는 데이터베이스 테이블 zoo를 생성합니다.

>>> conn.execute('''CREATE TABLE zoo
    (critter VARCHAAR(20) PRIMARY KEY,
    count INT,
    damages FLOAT)''')
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E0577978>

conn.execute()는 ResultProxy라는 SQLAlchemy 객체를 반환합니다. 이 객체가 무엇을 하는지는 곧 알게 될 것입니다.

우리는 여기서 데이터베이스 테이블을 생성했습니다. 그런데 이 데이터베이스 테이블은 이전에 생성했던 것입니다. 아무튼 축하드립니다.

이제 빈 테이블에 세 개의 데이터를 넣어봅시다.

>>> ins = 'INSERT INTO zoo (critter, count, damages) VALUES (?, ?, ?)'
>>> conn.execute(ins, 'duck', 10, 0.0)
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E0577CF8>
>>> conn.execute(ins, 'bear', 2, 1000.0)
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E0577DA0>
>>> conn.execute(ins, 'weasel', 1, 2000.0)
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E05776A0>

데이터베이스 테이블에 입력한 값이 있는지 확인해봅시다.

>>> rows = conn.execute('SELECT * FROM zoo')

SQLAlchemy에서 rows는 리스트가 아닙니다. 이것은 바로 출력해서 볼 수 없는 ResultProxy 객체입니다.

>>> print(rows)
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E0577C50>

그러나 이를 리스트처럼 순회하여 다음과 같이 한 번에 한 rows를 얻을 수 있습니다.

>>> for row in rows:
	print(row)

	
#('duck', 10, 0.0)
#('bear', 2, 1000.0)
#('weasel', 1, 2000.0)

이전에 본 SQLite의 DB-API 코드와 거의 똑같습니다. SQLAlchemy의 한 가지 장점은 코드 앞부분에서 데이터베이스 드라이버를 임포트하지 않아도 된다는 것입니다. 즉, SQLAlchemy가 연결 문자열에서 데이터베이스 타입을 알아냅니다. 이 코드에서 연결 문자열을 바꾸기만 하면 또 다른 타입의 데이터베이스를 알아서 연결할 것입니다. 또 하나의 장점은 커넥션 풀링(Connection Pooling)인데, 자세한 사항은 관련 문서 사이트를 참고하세요.

 

SQL 표현 언어

그다음 수준은 SQLAlchemy의 SQL 표현 언어입니다. 다양한 연산의 SQL을 생성하는 함수에 대해서 살펴봅니다. 표현 언어(Expression Language)는 하위 수준의 엔진 레이어보다 더 다양한 SQL문을 처리합니다. 표현 언어는 중간에서 관계형 데이터베이스 애플리케이션을 쉽게 접근할 수 있도록 해줍니다.

zoo 테이블을 생성하고, 데이터를 넣어봅시다. 다음 코드 또한 한 프로그램의 연속된 코드 조각입니다.

이전과 같이 임포트하고 데이터베이스에 연결합니다.

>>> import sqlalchemy as sa
>>> conn = sa.create_engine('sqlite://')

zoo 테이블을 정의하기 위해 SQL 문 대신 표현 언어를 사용합니다.

>>> meta = sa.MetaData()
>>> zoo = sa.Table('zoo', meta,
	       sa.Column('critter', sa.String, primary_key = True),
	       sa.Column('count', sa.Integer),
	       sa.Column('damages', sa.Float)
	       )
>>> meta.create_all(conn)

앞의 코드에서 여러 함수를 호출하는 부분을 살펴봅시다. Table() 메소드의 구조는 데이터베이스 테이블의 구조와 일치합니다. 테이블이 3개의 열을 퐇마하고 있으므로 Table() 메소드 호출의 괄호 안에 3개의 Column() 메소드 호출이 있습니다.

한편 zoo는 SQL 데이터베이스의 세계와 파이썬 자료구조의 세계를 연결하는 마법 객체입니다. 표현 언어 함수로 데이터를 삽입해봅시다.

>>> conn.execute(zoo.insert(('bear', 2, 1000.0)))
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E05A0B70>
>>> conn.execute(zoo.insert(('weasel', 1, 2000.0)))
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E05A0CF8>
>>> conn.execute(zoo.insert(('duck', 10, 0)))
#<sqlalchemy.engine.result.ResultProxy object at 0x00000289E05A0898>

다음에는 SELECT 문을 만들어봅시다(zoo.select() 메소드는 보통 SQL 문에서의 SELECT * FROM zoo와 같이 zoo 객체로 나타내는 테이블에서 모든 항목을 조회합니다).

>>> result = conn.execute(zoo.select())

마지막으로 입력한 결과를 살펴봅시다.

>>> rows = result.fetchall()
>>> print(rows)
#[('bear', 2, 1000.0), ('weasel', 1, 2000.0), ('duck', 10, 0.0)]

 

ORM 

이전 절에서 zoo 객체는 SQL과 파이썬 사이의 중간 수준(Middle-Level) 연결입니다. SQLAlchemy의 최상위 레이어에서 ORM(Object-Relational Mapper)은 SQL 표현 언어를 사용하지만, 실제 데이터베이스의 메커니즘을 숨깁니다. 그리고 ORM의 클래스를 정의하여 데이터베이스의 데이터 입출력을 처리합니다. '객체-관계 매핑'이라는 복잡한 단어 구문 속의 기본 아이디어는 여전히 관계형 데이터베이스를 허용하면서, 코드의 객체를 참조하여 파이썬처럼 작동하게 하는 것입니다.

Zoo 클래스를 정의하여 ORM으로 연결해봅시다. 이번에는 SQLite의 zoo.db 파일을 사용하여 ORM이 작동하는지 확인해봅니다.

앞의 두 절에서와 마찬가지로 다음 코드는 설명을 위해 분리한 하나의 프로그램입니다. 이해가 안 되어도 걱정하지 마세요. SQLAlchemy 문서에서 세부사항을 모두 얻을 수 있습니다. 이번 코드에서 ORM의 동작원리에 대해 생각해보고, 이번 장에서 배운 데이터베이스 접근 방법 중 여러분에게 맞는 방법을 선택하면 됩니다.

마찬가지로 맨 처음 sqlalchemy를 임포트합니다. 그리고 이번에는 다른 새로운 모듈도 임포트합니다.

>>> import sqlalchemy as sa
>>> from sqlalchemy.ext.declarative import declarative_base

데이터베이스에 연결합니다.

>>> conn = sa.create_engine('sqlite:///zoo.db')

SQLAlchemy의 ORM을 사용해봅시다. Zoo 클래스를 정의하고, 테이블의 열과 속성을 연결합니다.

>>> Base = declarative_base()
>>> class Zoo(Base):
	__tablename__ = 'zoo'
	critter = sa.Column('critter', sa.String, primary_key = True)
	count = sa.Column('count', sa.Integer)
	damages = sa.Column('damages', sa.Float)
	def __init__(self, critter, count, damages):
		self.critter = critter
		self.count = count
		self.damages = damages
	def __repr__(self):
		return "<Zoo({}, {}, {})>".format(self.critter, self.count, self.damages)

다음 한 줄로 마술처럼 데이터베이스와 테이블을 생성합니다.

>>> Base.metadata.create_all(conn)

이제 파이썬 객체를 생성하여 데이터를 삽입할 수 있습니다. ORM은 내부적으로 이들 객체를 관리합니다.

>>> first = Zoo('duck', 10, 0.0)
>>> second = Zoo('bear', 2, 1000.0)
>>> third = Zoo('weasel', 1, 2000.0)
>>> first
#<Zoo(duck, 10, 0.0)>

그다음 ORM을 SQL의 세계로 보냅니다. 데이터베이스와 대화할 수 있는 세션을 생성합니다.

>>> from sqlalchemy.orm import sessionmaker
>>> Session = sessionmaker(bind = conn)
>>> session = Session()

데이터베이스에 생성한 세 객체를 세션 내에 작성합니다. add() 함수는 하나의 객체를 추가하고, add_all()은 리스트를 추가합니다.

>>> session.add(first)
>>> session.add_all([second, third])

마지막으로 모든 작업을 (강제적으로) 완료합니다.

>>> session.commit()

작동하나요? 이 코드는 현재 디렉터리에서 zoo.db 파일을 생성합니다. 커맨드 라인에서 sqlite3 프로그램을 실행하여 확인해봅시다.

$ sqlite zoo.db
#SQLite version 3.6.12
#Enter ".help" for instructions
#Enter SQL statements terminated with a ";"
sqlite> .tables
#zoo
sqlite> select * from zoo;
#duck|10|0.0
#bear|2|1000.0
#weasel|1|2000.0

이 절의 목적은 ORM이 무엇이고, 이것이 높은 수준에서 어떻게 작동하는지 살펴보는 것이었습니다. SQLAlchemy에서 전체 튜토리얼을 제공합니다. 이를 읽고 나면 여러분 작업에 맞는 다음과 같은 수준을 결정할 수 있습니다.

  • 이전 SQLite 절과 같은 일반적인 DB-API

  • SQLAlchemy 엔진

  • SQLAlchemy 표현 언어

  • SQLAlchemy ORM

SQL의 복잡성을 피하기 위해 ORM을 사용하는 것은 자연스러운 선택처럼 보입니다. 여러분은 어느 것을 사용할 것인가요? 어떤 사람은 ORM을 피해야 한다고 생각하지만, 어떤 사람은 이에 대한 비판이 과도하다고 생각합니다. 누가 맞든 지 간에 ORM은 SQL을 추상화한 것이고, 모든 추상화된 것은 어떤 시점에서 문제가 발생합니다. 추상화는 허술(Leaky)합니다. ORM이 우리가 원하는 일을 하지 않는다면, ORM이 어떻게 작동하는지 그리고 SQL을 어떻게 고쳐야 하는지 알아내야 합니다. 인터넷에 농담조로 올라와 있는 글을 빌리자면, 어떤 한 문제에 직면했을 때 어떤 사람은 '그래, ORM을 사용하면 돼'라고 생각하지만, 그렇지 않습니다. ORM은 주로 간단한 애플리케이션에서 드물게 사용해야 합니다. 만일 애플리케이션이 간단하다면 바로 SQL(혹은 SQL 표현 언어)을 사용할 수 있습니다.

혹은 데이터셋(Dataset)과 같이 더 간단한 뭔가를 시도할 수도 있습니다. 이것은 SQLAlchemy 기반으로 구축되었고, SQL, JSON, CSV 저장소에 대한 간단한 ORM을 제공합니다.

 

5. NoSQL 데이터 스토어

 

어떤 데이터베이스는 관계형 데이터베이스가 아니며, SQL도 지원하지 않습니다. 이들은 매우 큰 데이터 집합을 처리하고, 데이터 정의에 대해 좀 더 유연하거나 커스텀 데이터 연산을 지원하기 위해 만들어졌습니다. 이들을 뭉뚱그려 NoSQL(예전에는 표현 그 자체로 no SQL의 의미를 가졌는데, 지금은 덜 대립하는 not only SQL이라는 의미를 지닙니다)이라 부릅니다.

 

5.1  dbm 형식

 

dbm 형식은 NoSQL이 나타나기 오래전부터 존재했습니다. 이는 키-값 저장 형식으로, 다양한 설정을 유지하기 위해 웹 브라우저와 같은 애플리케이션에 포함됩니다. dbm 데이터베이스는 다음과 같은 점에서 파이썬의 딕셔너리와 같습니다.

  • 키에 값을 할당합니다. 이것은 디스크에 있는 데이터베이스에 자동으로 저장됩니다.

  • 키로부터 값을 얻습니다.

다음의 간단한 예제를 봅시다. open() 메소드의 두 번째 인자에서 'r'은 읽기(Read), 'w'는 쓰기(write), 'c'는 읽기 / 쓰기(파일이 존재하지 않을 경우에는 파일 생성(Create))를 뜻합니다.

>>> import dbm
>>> db = dbm.open('definitions', 'c')

키-값 쌍을 생성하려면 딕셔너리처럼 키에 값을 할당합니다.

>>> db['mustard'] = 'yellow'
>>> db['ketchup'] = 'red'
>>> db['pesto'] = 'green'

그럼 우리가 할당했던 값을 확인해봅시다.

>>> len(db)
#3
>>> db['pesto']
#b'green'

이제 데이터베이스를 닫은 다음에 다시 열어서 우리가 저장한 값이 있는지 확인합니다.

>>> db.close()
>>> db = dbm.open('definitions', 'r')
>>> db['mustard']
#b'yellow'

키와 값은 바이트로 저장됩니다. 데이터베이스 객체 db를 순회할 순 없지만, len()을 사용하여 키의 수는 얻을 수 있습니다. get()과 setdefault()는 딕셔너리의 함수처럼 작동합니다.

 

5.2. Memcached

 

memcached는 민첩한 인메모리 키-값의 캐시 서버입니다. 주로 데이터베이스 앞단에 놓이거나 웹 서버의 세션 데이터를 저장하는 데 사용합니다. 리눅스나 맥 OS X, 윈도우 버전을 내려받을 수 있습니다. 이번 절을 실습하려면 memcached 서버와 파이썬 드라이버가 필요합니다.

memcached에는 많은 파이썬 드라이버가 있습니다. 그중 파이썬 3에서 작동하는 python3-memcached를 선택하여 다음과 같이 설치하면 됩니다.

$ pip install python-memcached

memcached 서버에 연결하여 다음과 같은 일을 수행할 수 있습니다.

  • 키에 대한 값을 설정하고 얻습니다.

  • 값을 증가하거나 감소시킵니다.

  • 키를 삭제합니다.

데이터는 지속되지 않아서, 이전에 쓴 데이터가 사라질 수 있습니다. 이것은 캐시 서버의 memcached에 대한 특징입니다. 이는 오래된 데이터를 제거하여 메모리 부족을 방지합니다.

여러 memcached 서버를 동시에 연결할 수 있습니다. 다음은 같은 컴퓨터에 연결하여 memcached 서버와 대화하는 코드입니다.

>>> import memcache
>>> db = memcache.Client(['127.0.0.1:11211'])
>>> db.set('marco', 'polo')
#True
>>> db.get('marco')
#'polo'
>>> db.set('ducks', 0)
#True
>>> db.get('ducks')
#0
>>> db.incr('ducks', 2)
#2
>>> db.get('ducks')
#2

 

5.3. Redis

 

Redis는 자료구조 서버입니다. Redis 서버에 있는 모든 데이터는 memcached처럼 메모리에 맞아야 합니다(디스크에 데이터를 저장할 수 있는 옵션이 있습니다). memcached와 달리 Redis는 다음과 같은 일을 할 수 있습니다.

  • 서버의 재시작과 신뢰성을 위해 데이터를 디스크에 저장합니다.

  • 기존 데이터를 유지합니다.

  • 간단한 문자열 이상의 자료구조를 제공합니다.

Redis 데이터 타입은 파이썬 데이터 타입에 가깝습니다. 그래서 Redis 서버는 여러 파이썬 애플리케이션에서 데이터를 공유하기 위한 중개 역할로 매우 유용합니다. 이러한 장점 때문에 Redis에 관한 예제를 좀 더 다룰 가치가 있다고 생각합니다.

GitHub에서 파이썬 드라이버 redis-py의 소스 코드와 테스트를 제공합니다. 온라인 문서 또한 제공하며, 다음과 같이 Redis를 설치할 수 있습니다.

$ pip install redis

Redis 서버는 문서화가 잘 되어 있습니다. localhost에 Redis를 설치하고 시작하면 이번 절의 프로그램을 실행할 수 있습니다.

 

문자열

단일 값과 한 키는 Redis 문자열입니다. 간단한 파이썬의 데이터 타입은 자동으로 변환됩니다. Redis 서버의 호스트(기본값: localhost)와 포트(기본값: 6379)에 연결합시다.

>>> import redis
>>> conn = redis.Redis()

redis.Redis('localhost') 혹은 redis.Redis('localhost', 6379)도 같은 결과를 얻습니다. 

모든 키를 나열합시다(지금까진 키가 없습니다).

>>> import redis
>>> conn = redis.Redis('localhost')
>>> conn.keys('*')
#[]

문자열(키: 'secret'), 정수(키: 'carats'), 부동소수점수(키: 'fever')를 설정합니다.

>>> conn.set('secret', 'ni!')
#True
>>> conn.set('carats', 24)
#True
>>> conn.set('fever', '101.5')
#True

키로 값을 얻어옵니다.

>>> conn.get('secret')
#b'ni!'
>>> conn.get('carats')
#b'24'
>>> conn.get('fever')
#b'101.5'

setnx() 메소드는 키가 존재하지 않는 경우에만 값을 설정합니다.

>>> conn.setnx('secret', 'icky-icky-icky-ptang-zoop-boing')
#False

이미 정의된 'secret' 값이 있어서 실패했습니다.

>>> conn.get('secret')
#b'ni!'

getset() 메소드는 이전 값을 반환하고, 동시에 새로운 값을 설정합니다.

>>> conn.getset('secret', 'icky-icky-icky-ptang-zoop-boing')
#b'ni!'

설정한 값이 적용되었을까요?

>>> conn.get('secret')
#b'icky-icky-icky-ptang-zoop-boing'

getrange()를 사용하여 부분 문자열을 얻어옵니다(오프셋은 파이썬과 같습니다. 시작: 0, 끝: -1).

>>> conn.getrange('secret', -6, -1)
#b'boing!'

setrange()를 사용하여 부분 문자열을 교체합니다(오프셋 0 사용).

>>> conn.setrange('secret', 0, 'ICKY')
#32
>>> conn.get('secret')
#b'ICKY-icky-icky-ptang-zoop-boing!'

mset()을 사용하여 한 번에 여러 키를 설정합니다.

>>> conn.mset({'pie': 'cherry', 'cordial': 'sherry'})
#True

mget()을 사용하여 한 번에 여러 값을 얻습니다.

>>> conn.mget(['fever', 'carats'])
#[b'101.5', b'24']

delete()를 사용하여 키를 지웁니다.

>>> conn.delete('fever')
#True

incr(), incrbyfloat()으로 값을 증가시키고, decr()로 값을 감소시킵니다.

>>> conn.incr('carats')
#25
>>> conn.incr('carats', 10)
#35
>>> conn.decr('carats')
#34
>>> conn.decr('carats', 15)
#19
>>> conn.set('fever', '101.5')
#True
>>> conn.incrbyfloat('fever')
#102.5
>>> conn.incrbyfloat('fever', 0.5)
#103.0

decrbyfloat()은 없습니다. 대신 음수 값으로 'fever'를 증가시켜봅시다.

>>> conn.incrbyfloat('fever', -2.0)
#101.0

 

리스트

Redis의 리스트는 문자열만 포함할 수 있습니다. 리스트는 값을 처음 삽입할 때 생성됩니다. 먼저 lpush()로 값을 삽입해봅시다.

>>> conn.lpush('zoo', 'bear')
#1

하나 이상의 값을 삽입할 수 있습니다.

>>> conn.lpush('zoo', 'alligator', 'duck')
#3

linsert()로 'bear' 값 이전(Before) 혹은 이후(After)에 삽입할 수 있습니다.

>>> conn.linsert('zoo', 'before', 'bear', 'beaver')
#4
>>> conn.linsert('zoo', 'after', 'bear', 'cassowary')
#5

lset()으로 오프셋에 삽입할 수 있습니다(리스트가 존재해야 합니다).

>>> conn.lset('zoo', 2, 'marmoset')
#True

rpush()로 값을 마지막에 삽입합니다.

>>> conn.rpush('zoo', 'yak')
#6

lindex()로 오프셋의 값을 얻습니다.

>>> lindex('zoo', 3)
#b'bear'

lrange()로 오프셋 범위에 있는 값들을 얻습니다(0과 -1을 지정하면 리스트의 모든 값을 얻습니다).

>>> conn.lrange('zoo', 0, 2)
#[b'duck', b'alligator', b'marmoset']

ltrim()으로 리스트를 정리(Trim)합니다. 지정한 오프셋의 범위만 남습니다.

>>> conn.ltrim('zoo', 1, 4)
#True

lrange()로 지정한 범위의 값을 얻습니다(0과 -1을 지정하면 리스트의 모든 값을 얻습니다).

>>> conn.lrange('zoo', 0, -1)
#[b'alligator', b'marmoset', b'bear', b'cassowary']

작업 큐를 구현하기 위해 Redis의 리스트와 발행-구독(Publish-Subscribe)하는 방법을 추후에 작성될 게시글에서 배우게 될 것입니다.

 

해시

Redis의 해시(Hash)는 파이썬의 딕셔너리와 비슷하지만 문자열만 포함할 수 있습니다. 그리고 해시는 깊고 중첩된 구조가 아닌, 한 단계 깊이의 구조를 만듭니다. song이라는 해시를 생성하여 실험해봅시다.

hmset()으로 song 해시에서 do와 re 필드를 한 번에 설정합니다.

>>> conn.hmset('song', {'do': 'a deer', 're': 'about a deer'})
#True

hset()으로 해시에서 하나의 필드 값을 설정합니다.

>>> conn.hset('song', 'mi', 'a note to follow re')
#1

hget()으로 하나의 필드값을 얻습니다.

conn.hget('song', 'mi')
#b'a note to follow re'

hmget()으로 여러 필드값을 얻습니다.

>>> conn.hmget('song', 're', 'do')
#[b'about a deer', b'a deer']

hkeys()로 해시의 모든 필드 키를 얻습니다.

>>> conn.hvals('song')
#[b'a deer', b'about a deer', b'a note to follow re']

hval()로 해시의 모든 필드 값을  얻습니다.

>>> conn.hvals('song')
#[b'a deer', b'about a deer', b'a note to follow re']

hlen()으로 해시 필드의 개수를 얻습니다.

>>> conn.hlen('song')
#3

hgetall()로 해시 필드의 모든 키와 값을 얻습니다.

>>> conn.hgetall('song')
#{b'do': b'a deer', b're': b'about a deer', b'mi': b'a note to follow re'}

키가 존재하지 않을 경우, hsetnx()로 필드를 설정합니다.

>>> conn.hsetnx('song', 'fa', 'a note that rhymes with la')
#1

 

다음 코드에서 볼 수 있듯이, Redis의 셋(Set)은 파이썬의 셋과 유사합니다.

셋에 하나 이상의 값을 추가해봅시다.

>>> conn.sadd('zoo', 'duck', 'goat', 'turkey')
#3

셋에 있는 값의 수를 얻습니다.

>>> conn.scard('zoo')
#3

셋의 모든 값을 얻습니다.

>>> conn.smembers('zoo')
#{b'duck', b'goat', b'turkey'}

셋에서 값을 삭제합니다.

>>> conn.srem('zoo', 'turkey')
#True

셋 연산을 하기 위해 두 번째 셋을 추가합니다.

>>> conn.sadd('better_zoo', 'tiger', 'wolf', 'duck')
#3

zoo와 better_zoo 셋을 인터섹션(공통 멤버를 얻음)합니다.

>>> conn.sinter('zoo', 'better_zoo')
#{b'duck'}

zoo와 better_zoo 셋을 인터섹션하고, 그 결과를 fowl_zoo 셋에 저장합니다.

>>> conn.sinterstore('fowl_zoo', 'zoo', 'better_zoo')
#1

fowl_zoo의 값을 확인해봅시다.

>>> conn.smembers('fowl_zoo')
#{b'duck'}

zoo와 better_zoo 셋을 유니온(모든 멤버를 얻음)합니다.

>>> conn.sunion('zoo', 'better_zoo')
#{b'duck', b'goat', b'wolf', b'tiger'}

유니온의 결과를 fabulous_zoo 셋에 저장합니다.

>>> conn.sunionstore('fabulous_zoo', 'zoo', 'better_zoo')
#4
>>> conn.smembers('fabulous_zoo')
#{b'duck', b'goat', b'wolf', b'tiger'}

zoo에는 있지만 better_zoo에는 없는 값은 무엇일까요? sdiff()로 셋의 디퍼런스(차집합)를 얻습니다. 그리고 sdiffstore()로 그 결과를 zoo_sale 셋에 저장합니다.

>>> conn.sdiff('zoo', 'better_zoo')
#{b'goat'}
>>> conn.sdiffstore('zoo_sale', 'zoo', 'better_zoo')
#1
>>> conn.smembers('zoo_sale')
#{b'goat}

 

정렬된 셋

가장 많은 용도로 쓰이는 Redis의 데이터 타입은 정렬된 셋(Sorted Set), 또는 zset입니다. 이것은 유일한 값의 셋이지만, 각 값은 연관된 부동소수점의 점수(Score)를 가집니다. 각 항목을 값 혹은 점수로 접근할 수 있습니다. 정렬된 셋은 다음과 같이 다양한 용도로 쓰입니다.

  • 게임 등에서 사용하는 순위판(Leader Board)
  • 보조 인덱스(Secondary Index)
  • 타임스탬프를 점수로 사용하는 시계열(Timeseries)

마지막 경우에 대한 코드를 살펴봅시다. 즉, 사용자가 방문한 타임스탬프를 추가해봅시다. 파이썬 time() 함수가 반환하는 유닉스의 에폭(Epoch) 값(추후에 작성될 게시글에서 좀 더 자세히 살펴볼 것입니다)을 사용합니다.

>>> import time
>>> now = time.time()
>>> now
#1583867137.2153141

첫 번째 손님을 추가합니다.

>>> conn.zadd('logins', 'smeagol', now)
#1

5분 후 다른 손님을 추가합니다.

>>> conn.zadd('logins', 'sauron', now + (5 * 60))
#1

두 시간 후 손님을 추가합니다.

>>> conn.zadd('logins', 'bilbo', now + (2 * 60 * 60))
#1

하루가 지난 후 손님을 추가합니다.

>>> conn.zadd('logins', 'treebeard', now + (24 * 60 * 60))
#1

bilbo는 몇 번째로 로그인했을까요?

>>> conn.zrank('logins', 'bilbo')
#2

bilbo는 언제 로그인했을까요?

>>> conn.zscore('logins', 'bilbo')
#1583874337.2153141

모든 사람이 로그인한 순서를 얻습니다.

>>> conn.zrange('logins', 0, '-1')
#[b'smeagol', b'sauron', b'bilbo', b'treebeard']

로그인한 시간을 포함하여 순서를 얻습니다.

>>> conn.zrange('logins', 0, -1, withscores = True)
#[(b'smeagol', 1583867137.2153141‬), (b'sauron', 1583867437.2153141‬),
#(b'bilbo', 1583874337.2153141), (b'treebeard', 1583953537.2153141‬)]

보기 좋게 출력에 강제 개행을 추가하였습니다. 원래대로라면 한 줄로 출력됩니다.

 

비트

비트는 대량의 숫자 집합을 공간-효율적인 방식으로 빠르게 처리합니다. 웹사이트에 등록된 사용자가 있다고 가정해봅시다. 사용자들이 얼마나 자주 로그인하는지, 특정한 날에 몇 명이 로그인하는지, 한 사용자가 같은 날에 얼마나 방문하는지 등의 정보를 추적한다고 생각해봅시다. 이때 Redis 셋을 사용할 수 있습니다. 그러나 사용자에게 증가하는 숫자로 된 ID를 할당했다면, 비트는 더 콤팩트하고 바르게 처리합니다.

각 날짜에 대한 비트셋(Bitset)을 생성합니다. 3개의 날짜와 3명의 사용자 ID로 테스트를 진행합니다.

>>> days = ['2020-03-11', '2020-03-12', '2020-03-13']
>>> big_spender = 1089
>>> tire_kicker = 40459
>>> late_joiner = 550212

각 날짜는 별도의 키입니다. 해당 날자에 특정 사용자 ID의 비트를 설정합니다. 예를 들면 첫 번째 날짜(2020-03-11)에 big_spender(ID: 1089)와 tire_kicker(ID: 40459)가 방문했습니다.

>>> conn.setbit(days[0], big_spender, 1)
#0
>>> conn.setbit(days[0], tire_kicker, 1)
#0

그다음 날 big_spender가 방문했습니다.

>>> conn.setbit(days[1], big_spender, 1)
#0

그 다음날 big_spender와 late_joiner가 방문했습니다.

>>> conn.setbit(days[2], big_spender, 1)
#0
>>> conn.setbit(days[2], late_joiner, 1)
#0

각 하루 동안의 방문자 수를 알아봅시다.

>>> for day in days:
    conn.bitcount(day)
    
#2
#1
#2

특정 날짜에 특정 사용자가 방문했는지 확인해봅시다.

>>> conn.getbit(days[1], tire_kicker)
#0

tire_kicker는 두 번째 날에 웹사이트를 방문하지 않았습니다.

3일 동안 매일 방문한 사용자는 몇 명일 까요?

>>> conn.bitop('and', 'everyday', *days)
#68777
>>> conn.bitcount('everyday')
#1

3명의 사용자 중 누가 매일 방문했는지 확인해봅시다.

>>> conn.getbit('everyday', big_spender)
#1

3일 동안 몇 명의 (유일한) 사용자가 방문했을까요?

>>> conn.bitop('or', 'alldays', *days)
#68777
>>> conn.bitcount('alldays')
#3

 

캐시와 만료

모든 Redis의 키는 TTL(Time-To-Live), 즉 만료일(Expiration Date)을 가집니다. 기본적으로 만료일은 끝이 없습니다. 키가 유지되는 시간을 지정하기 위해 expire() 함수를 사용합니다. 다음 코드와 같이 키가 유지되는 시간의 단위는 초(Second)입니다.

>>> import time
>>> key = 'now you see it'
>>> conn.set(key, 'but not for long')
#True
>>> conn.expire(key, 5)
#True
>>> conn.ttl(key)
#5
>>> conn.get(key)
#b'but not for long'
>>> time.sleep(6)
>>> conn.get(key)

expireat() 함수는 주어진 에폭 시간에 키를 만료합니다. 키 만료는 캐시를 적정 수준으로 유지하고, 로그인 세션을 제한하는 데 유용합니다.

 

5.4. 기타 NoSQL

 

여기에 나열된 NoSQL 서버는 메모리보다 큰 데이터를 처리하고, 이들 중 대다수는 여러 대의 컴퓨터를 사용합니다. 유명한 NoSQL 서버와 해다 파이썬 라이브러리를 다음의 표에 나열했습니다.

사이트 파이썬 API
Cassandra(http://cassandra.apache.org/) pycassa(http://github.com/pycassa/pycassa)
CouchDB(http://couchdb.apache.org/) couchdb-python(https://github.com/djc/couchdb-python)
HBase(http://hbase.apache.org/) happybase(https://github.com/wbolster/happybase)
Kyoto Cabinet(http://fallabs.com/kyotocabinet/) Kyotocabinet(http://bit.ly/kyotocabinet)
MongoDB(http://www,mongodb.org/) Mongodb(http://api.mongodb.org/python/current/)
Riak(http://basho.com/riak/) riak-python-client(https://github.com/basho/riak-python-client)

 

6. 풀텍스트 데이터베이스

 

마지막으로 풀텍스트 검색을 위한 데이터베이스의 특별한 카테고리가 있습니다. 이들은 모든 것을 인덱싱하여(미국의 7번째 대통령, 앤드루 잭슨의) 풍차와 큰 바퀴만한 치즈에 대한 이야기를 찾을 수 있게 해줍니다. 다음의 표에서 인기 있는 오픈소스의 코드와 파이썬 API를 볼 수 있습니다.

사이트 파이썬 API
Lucene(http://lucene.apache.org/) Pylucene(https://lucene.apache.org/pylucene/install.html)
Solr(http://lucene.apache.org/solr/) SolPython(http://wiki.apache.org/solr/SolPython)
ElasticSearch(http://www.elasticsearch.org/) Pyes(https://github.com/aparo/pyes/)
Sphinx(http://sphinxsearch.com/) Sphinxapi(http://bit.ly/sphinxapi)
Xapian(http://xapian.org/) Xappy(https://code.google.com/p/xappy/)
Whoosh(http://bit.ly/mchaput-whoosh) 파이썬으로 작성됨, API 포함