파이썬의 파일입출력 정리

프로그램에서 처리한 결과는 화면에 출력되기도 하지만, 나중에 따로 사용하기 위해서 별도의 저장 공간에 기록해 둘 필요도 있을 수 있다. 별도의 공간에 기록해두고 다시 쓴다는 것은 곧 파일의 형태로 디스크에 기록하고, 다시 파일의 내용을 읽어들일 수 있어야 한다는 것이다. 이처럼 파일에 어떤 데이터를 기록하고 다시 파일을 읽어들이는 것은 초보자가 생각하기에는 어려운 과정일 수도 있지만, 사실 “입출력”이라는 프로그램의 기본적인 기능에 관련된 부분이기 때문에 대부분의 프로그래밍 언어가 이에 대한 기본적인 API를 제공해주고 있다.

파일을 보는 시각에 대해

파일 입출력에서 API 사용법보다 중요한 것은 파일에 데이터를 어떤 식으로 기록하고 읽을 것인가에 대한 것이다. 보통 프로그래밍 언어 자습서에서는 파일의 종류를 두 가지로 구분하여 소개한다. 하나는 이진 데이터를 기록하는 바이너리 파일이고 다른 하나는 “일반 텍스트”를 저장하는 텍스트파일이다. 하지만 “일반 텍스트”라는 표현은 사실 형용모순인데, 왜냐하면 “일반 텍스트 파일”이라는 것은 존재하지 않기 때문이다! 파이썬을 비롯한 모든 프로그래밍 언어에서 컴퓨터는 사실 문자를 인식할 수 없고 0과 1로 된 숫자값만 인식할 수 있다. 그래서 이진수로 표현할 수 있는 데이터는 정수 혹은 실수의 수학적 숫자값이다. 컴퓨터에서 글자라는 것은 어떤 글자의 표를 정해놓고 그 표에서 “몇 번째에 있는 글자”라는 식으로 맵핑하여 인식한다. 따라서 본질적으로 텍스트 정보는 일련의 숫자값 정보이다.

현대 프로그래밍 언어에서 이러한 텍스트를 위한 만들어진 거대한 코드표를 유니코드라 한다. 파이썬3에서 사용하는 문자열 규격역시 유니코드 문자열이다. 그리고 유니코드 문자열은 파일에 기록하거나 네트워크를 통해 전송하는 등의 처리를 하기 전에 일련의 이진데이터로 인코딩되어야 한다. 우리가 UTF-8이나 EUC-KR이라고 하는 인코딩은 이럴 때 사용된다. 인코딩된 문자열 정보는 결국 바이너리 데이터이기 때문에 프로그래밍 언어가 읽고 쓰는 유일한 데이터 포맷은 이진파일 밖에 없다고 봐야 한다.

따라서 파이썬에서 일반적으로 데이터 파일을 읽고 쓰는 방법은 다음의 절차를 거친다고 보면 된다.

  1. 인코딩된 데이터와 문자열을 상호 변환하기 위해서는 어떤 인코딩을 사용할 것인지를 결정해야 한다. (보통 이것은 UTF8을 쓴다고 생각하면 된다.)
  2. 모든 데이터 파일은 결국 이진데이터 파일이며, 이진 데이터는 파이썬 내에서 bytesbytearray로 취급된다.
  3. 문자열을 이진데이터 파일에 기록하기 위해서는 문자열을 bytes 타입으로 변환한 후에(이것이 인코딩이다.) 그대로 파일에 쓰면 된다. 하지만 이 과정은 파이썬 내부에서 이루어진다.
  4. 이진포맷으로 기록된 텍스트는 bytes로 읽어들여지며, 다시 인코딩을 사용해서 이를 문자열로 변환된다. 이것이 텍스트 파일을 읽는 절차이다.

파이썬3에서는 파일을 열 때, 포맷이 텍스트 모드라면 3, 4의 과정을 내부적으로 자동으로 처리한다. 따라서 문자열을 그대로 읽고 쓰는 것처럼 다룰 수 있다.

사실 이 부분이 파이썬 2보다는 3를 써야하는 가장 큰 이유이기도 하다. 파이썬 3에서는 문자열과 바이트 배열이 명확하게 구분되며, 명시적인 인코딩/디코딩을 거쳐 서로의 타입으로 변환된다. 하지만 파이썬 2에서는 그런 거 없었고, 입출력 과정에서 글자가 깨지는 증상이 생기면 프로그래머만 죽어나는 상황이 된다.

자, 그러면 데이터 파일에 무엇을 쓰고 읽게 되는지 알게 되었으니, 실제로 어떻게 하는지를 알아보자.

open

파일을 읽고 쓰기 위해서는 먼저 파일을 “열어야”한다. 파일을 여는 행위는 기본함수인 open()을 사용할 수 있다. 이 함수에서 사용하는 파라미터와 리턴값은 다음과 같다.

open(파일경로와이름, 모드, 인코딩) -> 파일 디스크립터
  • 먼저 어떤 파일에 쓰거나 파일을 읽을 것이냐에 대해서 파일 이름을 결정해주어야 한다. 기본적으로 문자열로 된 파일의 이름을 주면 된다.
  • 모드는 파일을 가지고 어떤 일을 할 것인지를 결정한다.
  • 텍스트 파일을 쓰거나 읽기 위해서는 인코딩을 지정해야 한다.

open() 함수를 이용해서 파일을 열게 되면 우리는 파일 디스크립터(file descriptor)라는 것을 얻게 된다. 파일 디스크립터는 파일의 내용을 액세스하기 위한 일종의 핸들 같은 것이다. 디스크립터가 제공하는 다음의 메소드를 사용할 수 있다.

  • file.write(데이터) : 주어진 데이터를 파일에 쓴다. 이 동작을 하기 위해서는 파일을 쓰기 모드로 열어야 한다.
  • file.read(바이트) : 주어진 바이트 수 만큼 파일의 내용을 읽어들인다. 바이트 수를 주지 않으면 파일의 끝까지 읽게 된다.
  • file.close() : 파일을 닫는다.

파일의 모드는 데이터의 종류와 액션의 종류가 된다.

플래그의미비고
r읽기모드

w쓰기모드파일을 쓰기 모드로 연다. 파일에 데이터를 쓰면 기존 파일의 내용은 모두 날아간다. 주어진 파일이 존재하지 않으면 새로운 파일을 만든다.
x쓰기 전용새 파일 쓰기 모드로 연다. 주어진 이름의 파일이 존재하면 에러가 발생한다.
a추가모드파일을 추가 모드로 연다. 기존 파일의 내용의 끝에 새 내용을 추가하여 기록한다.
+갱신모드파일을 읽기와 쓰기가 모두 가능한 모드로 연다.
rb이진 읽기 모드이진 파일을 읽기 모드로 연다. 읽어들인 데이터는 디코딩되지 않은 바이트배열이 된다.
wb이진 쓰기 모드이진 파일을 쓰기 모드로 연다. 쓰는 데이터는 raw 데이터 그대로 쓰인다.

기본적인 텍스트 파일 읽기

기본적인 텍스트 파일 읽기 동작을 수행하는 코드를 보자. 다음의 예제는 주어진 텍스트 파일을 읽어서 그 내용을 출력하는 프로그램이다.

import sys   ## 1)

if len(sys.argv) > 1: ## 2)
    filename = sys.argv[1]
    file = open(filename, 'r', encoding="utf8")  ## 3)
    text_str = file.read()  ## 4)
    print(text_str)  
    file.close()  ## 5)
  1.  스크립트를 실행할 때 인자를 받기 위해서 sys 모듈이 필요하다.
  2. 실행 인자는 sys.argv에 저장되어 있다. 이 리스트의 첫 인자는 스크립트 파일 이름이며, 그 이후부터가 전달된 인자이다.
  3. open() 함수를 이용해서 파일을 텍스트 읽기 모드로 열었다. 이 때 파일의 인코딩은 utf8이라고 본다.
  4. 파일의 전체 내용을 읽어서 text_str 에 할당한다. 이 값은 문자열 타입이다.
  5. 파일의 내용을 출력한 후에 파일을 닫아준다.

기본적인 텍스트 파일 쓰기

이번에는 텍스트 파일을 쓰는 방법에 대해서 살펴보자. 간단하게 구구단 7단을 출력하는 프로그램을, 파일로 저장하는 방식으로 수정해가는 방식으로 살펴보겠다. 먼저 구구단 7단은 다음과 같이 출력할 수 있다.

for a in range(1, 9):
  line = f'7 * {a} = {a * 7}'
  print(line)

이 코드를 구구단 7단을 파일로 저장하는 과정을 살펴보자. 파일을 읽는 것과 거의 비슷하다. 다만 file.read() 대신에 file.write()를 써서 파일에 데이터를 쓴다는 것만 차이가 있다.

file = open('seven.txt', 'w', encoding='utf8')
for a in range(1, 9):
  line = f'7 * {a} = {a * 7}\n'  ## 1)
  file.write(line)
file.close()

다만 주의할 것은 각 라인의 뒤에는 “줄바꿈 문자”가 들어가야 한다는 점이다. 줄 바꿈 문자가 없는 텍스트는 “일련의 단어”가 되기 때문에 모든 내용이 한줄로 출력될 것이다. 각 라인을 한 번씩 쓰는 방법도 있지만, 다음과 같이 모든 라인을 하나의 텍스트로 join 한 후에 한 번만 기록하는 방법도 있다.

lines = '\n'.join(f'7 * {a} = {a * 7}' for a in range(1, 10))
file = open('seven.txt', 'w', encoding='utf8')
file.write(lines)
file.close()

컨텍스트 매니저

파일을 사용하기 위해 열었다가 다시 닫는 일이 번거로울 수 있는데, 파일 디스크립터는 “컨텍스트 매니저”의 일종으로 정의되어 있다. 컨텍스트 매니저는 파이썬의 with 구문에 사용될 수 있는 객체를 말한다. 파일을 읽어서 출력하던 최초의 코드는 다음과 같이 다시 쓰일 수 있다.

with open('myfile.txt', encoding='utf8') as f:
  contents = file.read()
  print(contents)

이 코드는 open() 함수로 파일을 열고 그 리턴되는 디스크립터를 f 라는 이름에 바인딩한다. 이후 들여쓴 블럭 내에서 f는 파일 핸들로러 기능하게 된다. 코드 블럭에서 명시적으로 f.close()를 호출하지는 않지만, 들여쓰인 코드 블럭의 실행이 끝나서 블럭을 빠져나오는 시점에 자동으로 f.close()가 호출되어 파일이 닫히게 된다. 이 방식은 예외 처리 등에 의해서 파일을 닫아야 하는 코드가 여러 군데에 들어가야 하는 상황에서도 닫힘을 자동으로 처리하게 되기 때문에 깔끔한 처리를 가능하게 한다.

텍스트 파일을 줄 단위로 읽기

텍스트 파일에서 가장 흔히 쓰이는 케이스는 텍스트 파일의 내용을 한 줄씩 읽어들여서 처리하는 것이다.  예를 들어서 어떤 연속된 데이터들에 대해서 하나의 레코드를 하나의 행으로 변환하여 파일에 쓴 경우, 이를 거꾸로 읽어들여서 처리하려면 파일을 줄 단위로 읽는 편이 조금 더 처리가 용이할 수 있다. 텍스트 파일을 줄 단위로 처리하는 방법에는 크게 세 가지가 있을 수 있다.

  1. 파일의 전체 내용을 한 번에 읽어서 개행 문자로 분리하는 것
  2. 파일을 한 번에 한줄씩만 읽는 처리를 계속하는 것
  3. 파일 디스크립터를 for 문에 사용하는 것

먼저 첫번째 방법은 가장 무식한(?) 방법이다. 텍스트 파일은 여러 줄로 구분된 데이터이므로, 데이터를 file.read()와 같이 한번에 전체를 다 읽어들인 다음에 문자열의 .split() 메소드를 사용해서 각 라인별로 끊어서 문자열의 리스트를 만드는 방법이다. 이 방식은 편리할 수는 있지만, 텍스트 파일의 크기가 매우 큰 경우에 모든 콘텐츠를 메모리로 다 읽어들인다는 부분이 문제가 될 수 있다.

with open('seven.txt') as f:
  lines = f.read().split()
  for line in lines:
    print(line)

이 방법을 위한 보다 편리한 메소드로 file.readlines() 가 있다. 모든 라인을 읽어서 각 라인 텍스트의 리스트를 리턴하는 함수이다.

두 번째 방법은 한 번에 필요한 만큼 한 줄씩 읽어들여서 처리하는 방법이다.  텍스트 파일의 크기가 한정되지 않았다면 비교적 안전하고 또 널리 쓰이는 방법이다.

with open('seven.txt') as f:
  while True:   
    line = f.readline()
    if not line:
      break
    print(line)
    

세 번째 방법은 조금 특별한데, 가장 권장되는 방법이다. 기본적으로 텍스트 읽기 모드로 열린 파일은 매번 각 라인을 읽어서 리턴하는 방법을 스스로가 알고 있다. 이러한 동작은 파일의 끝까지 읽어들였을 때까지 반복될 수 있는데, 이 성질은 마치 반복자나 제너레이터와 유사하다. 따라서 파일 디스크립터는 그 자체로 각 라인의 텍스트를 만들어내는 제너레이터와 동일하게 취급될 수 있다. 이 말은 for ... in  구문에서 파일 디스크립터를 그대로 써도 무방하다는 것이다.

with open('seven.txt') as f:
  for line in f:
    print(line)

파일 디스크립터는 느긋한 제너레이터이므로 위 코드는 아무리 큰 텍스트 파일을 열었다하더라도 한 라인의 크기가 적절한 수준이라면 안전한 방법인 동시에 코드 역시 깔끔하게 처리된다. 다음 코드는 이 방법을 응용한 것으로 한 파일의 각 라인을 읽어들여서 대문자로 변환하여 출력하는 방법이다.

with open('sometext.txt', encoding='utf8') as f:
  for line in (x.upper() for x in f):
    print(line)

텍스트 파일 복사하기

텍스트 파일을 읽어서 한줄씩 출력하고 다시 이를 다른 파일에 쓰는 프로그램을 작성해보자. 하나의 함수로 작성할 것이다. 이는 위에서 배운 몇 가지 사실만 기억한다면 매우 간단하게 작성될 수 있다.

  • 원본 파일과 대상 파일을 각각 읽기 모드와 쓰기 모드로 열어야 한다. 따라서 프로그램 전체 코드는 2개의 with 문이 중첩된 모양이 될 것이다.
  • 읽어들인 각 라인을 출력한 후에 대상 파일에 기록하면 된다. 행 단위로 처리되기 때문에 기록할 때에는 개행문자를 붙여주면 된다.
def copy_and_print(filename):
  with open(filename, encoding="utf8") as f1:
    targetfile = 'copied_' + filename
    with open(targetfile, 'w', encoding='utf8') as f2:
      for line in f1:
        print(line)
        f2.write(line + '\n')

이진 파일 복사하기

이진 파일을 복사하는 방법도 간단하다. 파일을 이진 모드로 두 개 열고 한쪽에서 읽을 데이터를 다른쪽으로 쓰면 된다. 단, 여기서 주의해야 할 점은 read()를 통해서 한 번에 모든 내용을 다 읽어들이는 것은 좀 위험하다는 것이다. 텍스트파일보다 훨씬 더 높은 확률로 이진 파일은 가용 메모리 보다 클 수 있다는 점이다.

.read(size) 메소드는 파일의 데이터를 주어진 사이즈만큼의 바이트 수를 읽어들인다. 파일을 읽어들이다가 파일의 끝을 만나면 파일의 끝까지만 읽는다. 이렇게 읽어들인 이진데이터는 아무런 변환없이 그대로 대상파일에 기록한다. 버퍼로 읽어들인 데이터가 없다면 모든 처리가 완료된 셈이다.

def copy_bin_file(filename):
  targetfile = 'copied_' + filename
  chunk_size = 1024 * 4096 ## 4MB 단위로 읽는다.
  with open(filename, 'rb') as f1:
    with open(targetfile, 'wb') as f2:
      while True:
        ## 최대 청크 사이즈까지 데이터를 읽은 후 이를 대상 파일에 쓴다.
        ## 이 과정을 더 이상 읽을 데이터가 없을 때까지 반복한다.
        buffer = f1.read(chunk_size)
        if not buffer:
          return
        f2.write(buffer)

간단한 합계, 평균 프로그램

학생의 이름, 국어, 수학, 영어 점수를 입력받아서 각 학생의 총점과 평균, 그리고 전체 학생의 수학 점수의 평균을 출력하는 프로그램을 작성한다고 생각해보자. 물론 키보드를 이용해서 각 데이터를 입력받는 식으로 프로그램을 작성할 수도 있다. 하지만 그렇게 일일이 타이핑하는 것은 너무나 수고스러운 일이니 다음과 같이 텍스트 파일에 개별 학생의 성적이 기입된 데이터가 있다고 가정하자.

홍길동 87 90 82
최점례 91 80 87
오서방 73 92 99
...

그렇다면 우리는 다음과 같은 방식으로 데이터를 처리할 수 있을 것이라 생각해볼 수 있다.

  1. 각 라인의 데이터는 모두 문자열이며, 이름, 국어, 수학, 영어 점수 순으로 나뉜다. (이름에는 띄어쓰기가 없다고 가정한다.)
  2. 각 라인의 데이터를 하나의 사전으로 변환할 수 있다.
  3. 이 말은 전체 데이터는 사전의 리스트로 변환될 수 있다는 것이다.
  4. 리스트의 각각의 사전에 대해서 세 개의 점수 값을 합계, 평균 내는 것은 간단한 일이다.
  5. 리스트의 각 사전에 대해서 특정한 과목 키를 이용해서 그 값을 합계, 평균 낼 수도 있을 것이다.

먼저 각 학생의 총점과 평균을 출력하는 함수를 작성해보자.

def print_personal_marks(aDict):
  keys = ('국어', '수학', '영어')
  total = sum(aDict[x] for x in keys)
  avg = total / 3
  print(f'{이름: aDict['이름'] - 총점: {total}, 평균: {avg:.2f}')

다음은 정수의 리스트를 받아서 총점과 합계를 출력하는 함수이다.

def print_class_marks(marks):
  total = sum(marks)
  avg = total / len(marks)
  print(f'총점: {total}, 평균: {avg:0.2f}')

자 이제 파일을 읽어서 성적 정보를 파싱하는 코드를 생각해보자. 한 줄의 정보를 입력받은 것은 다음과 같이 처리할 수 있다.

aLine = ....
keys = ('국어', '수학', '영어')
name, *marks = aLine.split(' ')  # 공백으로 분리한 첫 값은 name, 그외 값은 marks라는 리스트로
## marks의 각 요소를 정수로 변환하고 키와 조합하여 사전으로 만든다.
anItem = dict(zip(keys, (int(x) for x in marks)))
## 여기에 이름을 추가하여 주고 데이터에 추가한다.
anItem['이름'] = name

자 위 코드는 “한줄의 데이터” ==> “하나의 사전객체”로 변환하는 과정을 표현하고 있다. 따라서 “여러 줄의 데이터”가 있다면 이 동작을 맵핑하여 “모든 사전의 리스트”로 변환할 수 있을 것이다. 개별 데이터를 변환하고 출력하는 부분을 모두 함수로 만들면 여러줄의 리스트는 간단히 성적의 데이터 리스트로 변환되고, 각각 학생의 성적과 전체 수학 성적은 매우 간단하게 출력할 수 있다.

### 위의 코드를 함수로 정의하고 읽어들인 데이터로 성적 데이터를 만든다.
def process_line(line):
  keys = ('국어', '영어', '수학')
  name, *marks = line.split()
  anItem = dict(zip(keys, (int(x) for x in marks)))
  anItem['이름'] = name
  return anItem

with open('data.txt', encoding='utf8') as f:
  data = [process_line(line) for line in f]
  ## 각 학생의 성적을 출력한다.
  for x in data:
    print_personal_marks(x)

  ## 전체 수학 성적을 출력한다. 
  print("전체 수학 성적")
  print_class_marks([item['수학'] for item in data])

정리

파일 입출력과 관계된 내용에 대해 많은 것들을 살펴보았는데, 대략 다음과 같이 정리할 수 있겠다.

  1. 파일은 항상 이진 데이터로 저장되지만, 실질적으로 많이 쓰이는 입출력은 텍스트파일에 관한 것이다. 따라서 텍스트 파일을 읽고 쓸 때에는 인코딩 정보를 항상 알고 있어야 한다.
  2. open()으로 파일을 열 수 있다. 가능하면 with 문과 함께 쓰는 편이 편리하다.
  3. 파일은 한 번에 전부 읽거나 한 번에 한줄씩 읽을 수 있다. 한 번에 한 줄씩 읽는 경우라면 파일 디스크립터를 파일 각 라인의 제너레이터라고 간주할 수 있다.

실제로 개념을 보이기 위한 예제를 제외하면 파일을 읽고 쓰는 코드는 매우 사소한 부분을 차지한다는 것을 알 수 있다. 파이썬은 입출력을 단순화하는 쪽으로 발전해왔고, 실제로 마지막 예를 보면 파일을 읽고 쓰는 것보다는 데이터를 다루는 부분에 집중하는 식으로 문제를 해결하는 것을 볼 수 있다. 늘 하는 이야기지만, 가급적 어떤 코드를 작성할 때 문제를 한 번에 해결하려고 하기보다는 개별 데이터를 다루는 코드를 함수로 작성하고, 데이터의 리스트 (혹은 연속열)을 다루는 식으로 이를 확장해 나가도록 하자. 그러면 그외의 입출력과 관련된 부분은 이러한 리스트와 함수를 조합하는 과정의 코드들과 아주 자연스럽게 어울리는 식으로 연결될 수 있다.