최근 약 2주간의 작업을 통해, 약 6년여 간의 시간을 함께 했던 Python 2.7과 작별 했습니다. 전부터 필요한 일이었지만, 다른 우선순위에 밀리던 이 일이 Slack의 TLS 1.2 버전 업그레이드 요청에 따라 최우선으로 올라왔습니다. 마침 AWS로의 이전과 무중단 배포를 위한 시스템 구조 변경등이 계획되어 있어 이 3가지를 함께 진행하게 되었습니다.

python 코드만 10만줄이 넘는 이 묵직한 서비스를 옮기면서 자주 발생하던 공통된 이슈를 정리해보았습니다. python2가 우리에게 제공하던 다양한 기능이 python3로 넘어오면서 만들어내는 문제들도 함께 정리해보았습니다.

2와 3의 차이로 인한 문제

urlliburllib2

Python2에서 urllib은 url과 관련된 모든일을 해주던 만능 라이브러리였습니다. 물론 requests를 사용하는 것이 좋지만, url 기반의 통신에서 유틸성으로 제공되는 함수들은 여러가지 상황에서 큰 도움이 됩니다. 운영중인 서비스 코드에서도 urllib.unquote()urllib2.urlopen() 등은 코드 전체에서 많은 비중을 차지하고 있었습니다.

Python3에서 이 두 모듈이 제공해주던 모든 기능은 역할과 명칭에 따라 모두 정리되어 각각 아래의 모듈로 이동되었습니다.

  • urllib.request : URL을 열고 읽는 모든 함수
  • urllib.error : urllib.request에 의한 예외들
  • urllib.parse : URL 구문 분석과 관련된 모든 함수
  • urllib.robotparser : robots.txt 를 읽고 구분하기 위한 모든 함수

이로인해 아래와 같은 코드들은 모두 대치되었습니다.

# 2.x
import urllib2
source = urllib2.urlopen('something_my_url')

# 3.x
import urllib.request
source = urllib.request.urlopen('something_my_url')
# 2.x
import urllib
source = urllib.unquote(request.body)

# 3.x
import urllib.parse
source = urllib.parse.unquote(request.body.decode())
# decode 가 추가된 이유는 bytes vs str 에서.

urllib 이슈를 해결하기에 앞서 우리 개발팀은 python3로 urllib을 다룬 경험이 많지 않았기에, import 문을 어떻게 구성할것인지에 대해 충분한 고민을 하지 못했습니다.

import urllib.parse
from urllib import parse
from urllib.parse import unquote

위 3가지 방안에 대해 구체적인 논의나 고민을 진행하지 못한 채, 기존 2.x 코드의 형식을 그대로 유지하여 구성한 후 정책을 세워 일괄 수정하는 것으로 계획을 잡았습니다.

bytes vs str

Python 2.7에서 사용하던 모든 유니코드 문자열은 u''와 같은 방식으로 string 앞에 u를 표기해주어야 합니다. Python 3.x에서는 이와 반대로, 모든 유니코드 문자열이 string 클래스의 기본으로 정의되고, bytes string을 b''와 같은 방식으로 선언하도록 변경되었습니다.

즉, 기존에 코드 상에 존재하는 모든 u''''로 변경되어야 하고, 반대로 bytes string으로 읽어오는 모든 문자열과의 연산에는 b'' 형식의 bytes class 오브젝트가 대상이되어야 합니다.

# 2.7
country = u'고려'
print type(country)
>>> <type 'unicode'>

country = 'usa'
print type(country)
>>> <type 'str'>

# 3.x
country = '고려'
print(type(country))
>>> <type 'str'>

country = b'usa'
print(type(country))
>>> <class 'bytes'>

# 특이점
## python 2.7에서는 b'' 방식의 표기를 적용해도 str type으로 지정됨.
country = b'usa'
print type(country)
>>> <type 'str'>

integer / integer

python2에서 3로의 전환에 가장 꼼꼼히 들여봐야 할 부분입니다. python3에서는 division이 int() 변수형에 영향을 받지 않도록 변경되었습니다. 아래 결과 값 비교를 통해 이해할 수 있습니다.

# 2.7
print 11/4
>>> 2
print type(11/4)
>>> <class 'int'>

# 3.x
print(11/4)
>>> 2.75
print(type(11/4))
>>> <class 'float'>

2.7에서의 결과와 다르게, python 3.x는 integer간의 나누기에 자동 형변환을 제공합니다. 기존처럼 몫을 구하기 위해 /를 사용하는 방식은 python 3.x에서는 사용할 수 없습니다. 동일한 결과값을 얻기 위해서는 // 를 사용할 수 있습니다.

print(11/4)
>>> 2.75
print(11//4)
>>> 2

기존과 같은 결과값을 기대해야 한다면, integer로의 형변환을 적용하여 대응하거나 //를 이용하여 동일한 결과값을 유지하는 방법 2가지 중 선택해야 합니다.

dict.keys() dict.items() dict.values()

Python2에서는 리스트를 반환하던 dict의 함수들이 이제 iterator를 반환합니다. for문이나 in과 같이 list와 iterator가 동일한 동작을 하는 로직에서는 큰 문제사항은 없지만, list 를 이용한 코드 구성(dict.items()[1])은 TypeError가 발생합니다.

다른 변경점들과 마찬가지로, 코드를 통해 로직을 확인하고, 위와 같은 방식으로 이용되는 케이스를 모두 수정해주어야 합니다.

Django의 python2와 3의 차이로 인한 문제

unicodestr 그리고 string handling

Django의 Model 클래스의 prompt 및 object 출력값이 다른 함수를 사용하도록 변경됩니다.

# 2.7
class MyModel(models.Model):
    def __unicode__(self):
		return self.name + u'_2.7'

# 2.7 shell
obj = MyModel()
obj
>>> <MyModel: toolate_2.7>

# 3.x
class MyModel(models.Model):
    def __str__(self):
        return self.name + u'_3.x'

# 3.x shell
obj = MyModel()
obj
>>> <MyModel: toolate_3.x>

위와 같이 Python의 버전을 3로 올릴때는, 기존 Model 혹은 Class에 선언되어있는 unicode 함수가 str 함수로 변경되어야 합니다. Class가 호출하는 출력값 함수명이 변경되었기 때문입니다. 만일, str 함수를 선언하지 않은 상태로 버전이 올라가게 될 경우, 클래스의 출력값을 이용하는 로직들(Django에서 제공되는 admin 기능)이 정상적으로 이용하기 어려워집니다.

request와 response (3.5 해당)

Python 3.0부터 3.5 버전까지는 json 라이브러리가 unicode string만을 해석합니다. 이러한 과정은 python 2.7에서 python 3.x로 전환할때 어느 수준까지 변경할 것인지에 따라 다르겠지만, 2.7에서 사용하는 라이브러리의 대부분이 3.5까지는 대부분 지원합니다. (라이브러리의 버전에 따라 차이가 존재하지만) 이로 인해 3.x로 전환할때에 발생할 수 있는 문제를 최소화 하는 버전으로 3.5가 추천되지만 3.5는 json 라이브러리의 불통스러움 때문에, 아래와 같은 문제를 새로 겪게 만듭니다.

Python3 기반의 Django에서는 Python 2.7 기반과 달리 Request와 Response에 포함된 전송값들의 형식이 변경되었습니다(단 header는 기존처럼 str, input/output stream은 bytes). 위에서 변경된 bytes vs string 이슈에서 이어지는 부분입니다. response의 경우 큰 문제가 발생하지 않지만, ajax로 들어온 request의 json data를 읽을때는 반드시 decode 과정을 거쳐야 합니다.

# 2.7
import json
print type(request.body)
>>> 
params = json.loads(request.body)


# 3.x
import json
params = json.loads(request.body.decode('utf-8'))

버전을 올리며

이번 업데이트에는 1주간의 기능 테스트, 1주간의 coverage 테스트를 진행하며 약 2주간의 테스트 + 코드 변경 과정을 수행했습니다. 테스트 과정에서 발생하는 문제 상황을 스프레드 시트로 공유하고, 해당 문제의 해결책을 함께 공유해 각 도메인별 담당자가 동일한 문제 상황을 테스트 없이 바로 해결하는 방식으로, 진행되었습니다.

서비스 코드의 개선 뿐만아니라 배포 방식 개편과 서버 이전 작업이 모두 진행되었기 때문에, 서비스 전반에서 많은 개선 사항이 있었습니다. 그럼에도 효율적인 테스트 계획을 통해 업데이트 이후 약 48시간 동안 유저 오류사항이 0건 발생하는 소기의 성과를 달성했습니다.

비록 많이 늦은 시점에, 이미 많은 취약점이 남아있는 3.5 버전으로 올라간 수준이지만, 이번 경험을 통해 팀 전체가 버전 관리에 있어서 자신감을 갖고 진행하게 될 것 같습니다. 근 시일내에 3.7까지 python 버전을 개선하고, Django 버전도 올려야겠습니다.