아마 프로그래밍을 처음 시작할 때 한 값이 어떤 값인지 확인할 때 분명히 다음과 같은 코드를 짜보았을 것이다.
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까지 아주 간단해서 개선할 것이 있었나 싶었던 코드를 완전히 깔끔하게 바꾸는 데에 성공했다.
아무리 작은 코드의 한 조각이더라도 주의 깊게 바라보면 개선의 여지가 보일 수 있으니 언제나 코드를 주의깊게 바라보도록 하자.