아마 프로그래밍을 처음 시작할 때 한 값이 어떤 값인지 확인할 때 분명히 다음과 같은 코드를 짜보았을 것이다.
if val == 1 or val == 2 or val == 3:
print('value matched.')
이런 코드를 사용하지 못하는 것은 아니지만 (사실은 경우에 따라서 - val이 1이 되는 경우가 매우 흔한 경우 - 더 빠를 수도 있다) 솔직히 말하면 그리 효율적인 코드는 아니다.
만약에 코드를 더 추가해야 한다면? 혹은 만약 검사해야 할 값이 100개라면 어떻게 될까?
저런 방식으로 코드를 짠다면 재사용성도 없고, 쉽게 코드를 변경할 수도 없다.
이 글에서는 이 단순한 코드를 매우 다양한 방식으로 리팩토링할 것이다. 과연 리팩토링을 할 꺼리가 있을까 싶을 정도로 작고 간단한 코드를 어떻게 리팩토링이 가능한지를 보면 분명히 놀랄 것이라 생각한다.
for문을 배운 지 얼마 되지 않은 상태라 for문을 이용해보고 싶다는 생각이 들었다면 다음처럼 for문을 이용할 수도 있을 것이다.
for i in [1, 2, 3]:
if val == i:
print('value matched.')
break
좋은 코드는 아니다. indent가 2번이나 들어갔고 코드도 4줄이나 된다.
가장 중요한 것은 의미를 파악하기가 전보다 더 어려워졌다는 것이다. 이런 간단한 문제에 for, if, break같은 로직을 이용하는 것은 마치 닭 잡는데 소 잡는 칼을 쓰는 격이다. 지금은 로직이 하나만 들어갔기에 망정이지, 만약에 이게 다른 복잡한 코드의 깊은 indent 속에 들어가 있다고 생각해 보자. 자체는 어렵지 않지만 분명 머릿속을 흐뜨려놓은 좋지 않은 코드이다.
다만 저 코드가 항상 나쁜 것은 아니다. 만약 값을 준비하는데 두 줄 이상의 코드가 필요하다면 좋을 수도 있다.
for i in [1, 2, 3]:
result = some_function(i)
result.some_setting = 'hello, world!'
if val == result:
print('value matched.')
break
확실히 이 코드를 한 줄로 짜는 것은 조금 무리가 있을 것이다.
혹은 early return을 사용할 수도 있다.
for i in [1, 2, 3]:
result = some_function(i)
result.some_setting = 'hello, world!'
if val != result:
continue
print('value matched.')
# break # 만약 1번만 있다면
이 코드를 사용해야 하는 경우도 있다! 바로 result가 여러 번 val이 되고, 각각의 경우 프로세싱을 해야 하는 경우이다.
하지만 그런 경우가 아니라면 속도를 향상시키기 위해 break를 넣어야 되어서 빠르게 코드가 더러워지고 한 로직이 이리 저리 떨어져 있어 불쾌한 코드가 된다.
물론 이 경우에는 사이즈가 매우 작아 굳이 그렇게까지 할 필요는 없지만 만약 코드 크기가 길어진다면 함수로 빼는 것은 매우 좋은 선택지가 될 수 있다.
def process(i):
print(f'The value is {i}')
for i in [1, 2, 3]:
if val == i:
process(i)
break
물론 이 코드에 사용하기에는 과하게 비대하다.
일반적으로 가장 많이 쓰이는 해결책은 다음과 같다.
if val in [1, 2, 3]:
print('value matched.')
많은 == 대신에 리스트 하나로 대체했고, 보기에도 좋으며 이해하기에도 좋다.
딱 2줄이면 되고 직관적이기까지 하니 쓰지 않을 이유가 없다!
...정말 그럴까? 저 코드를 조금 더 분석해 보자.
우선 우리가 사용한 것은 mutable인 리스트이다. 하지만 여기에서 immutable인 튜플을 사용하면 어떨까?
좋은 생각이다. 이 케이스에서는 immutable인 튜플을 사용하는 것이 좋다. 왜일까?
튜플을 사용하면 다음과 같이 코드가 바뀐다.
if val in (1, 2, 3):
print('value matched.')
의미적으로 깔끔하고, 좋아 보인다. 그럼 된 걸까?
...아직 개선이 여지가 더 있다. 조금 더 봐 보자.
이 개선을 항상 해야 하는 것은 아니다. 소규모 프로젝트이거나 값이 외부에서 재사용될 가능성이 적다면 이 개선은 생략해도 무관하다.
리터럴(1, True같은 값을 의미한다)을 바로 사용하는 것(변수에 저장하지 않고 바로 사용하는 것)을 금지하는 조직이 있을 정도로 리터럴에 이름을 붙이는 것은 중요하다.
리터럴을 남발하면 나중에 다른 사람이 봤을 때 그 리터럴 무슨 역할을 했는지 알기가 어렵다. 따라서 리터럴을 변수에 저장해 그 변수의 이름으로써 역할을 추측할 수 있도록 하는 것이 좋다.
의미를 명확하게 한다는 점에 더불어, 변수로 뺄 경우 다른 코드에서 사용할 수도 있다. 이른바 '재사용성'이 높아진다.
만약 construct time이 큰 경우 속도의 이점도 있다.
하지만 약 120ns(만약 요소 개수가 적을 경우 차이는 7ns까지 좁혀진다) 정도의 차이로 체감할 수 있을 정도는 아니다.
timeit으로 확인해본 결과는 다음과 같다.%timeit 100 in [0, 1, 2, ... 98, 99] # 556 ns ± 8.97 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each) the_list = list(range(100)) %timeit 100 in the_list # 437 ns ± 8.55 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)하지만 이는 컴퓨터마다, 구현마다, 파이썬 버전마다 다를 수 있다. 꼭 자신의 환경에서 확인해 보고 사용하자.
이 경우에도 리터럴인 튜플을 상수 변수로 뺄 수 있을 것이다.
# 실제 코드에서는 각각의 '수'가 무엇을 나타내는지도
# 작성하는 것이 맞지만 이 글에선 편의상 생략한다.
FLAGS = (1, 2, 3)
if flag in FLAGS:
print('value matched.')
이제 이 코드의 역할이 무엇인지 조금 더 명확하게 알 수 있다.
하지만 개선은 여기에서 멈추지 않는다.
set 사용하기지금까지의 코드는 리스트와 튜플을 이용했다.
리스트와 튜플의 문제는 뭘까? 바로 값은 찾는데 O(n)의(선형적) 시간이 걸린다는 점이다. 만약 리스트나 튜플을 사용할 수밖에 없는 상황이라면 몰라도, 이 경우는 값은 찾는 것이 해당 값이 목적이다. 이러한 경우에 최적화된 자료 구조가 바로 set(집합)이다. Set은 해시 테이블을 이용해 값을 찾는 데에 O(1)의 시간이 걸리도록 할 수 있다.
# 실제 코드에서는 각각의 '수'가 무엇을 나타내는지도
# 작성하는 것이 맞지만 이 글에선 편의상 생략한다.
FLAGS = {1, 2, 3}
if flag in FLAGS:
print('value matched.')
깔끔하고, 의미를 잘 알 수 있으며, 빠른 코드가 완성되었다.
우리는 처음 or을 이용한 코드부터 for문, 튜플 사용, 변수로 뺌, 그리고 set까지 아주 간단해서 개선할 것이 있었나 싶었던 코드를 완전히 깔끔하게 바꾸는 데에 성공했다.
아무리 작은 코드의 한 조각이더라도 주의 깊게 바라보면 개선의 여지가 보일 수 있으니 언제나 코드를 주의깊게 바라보도록 하자.