'val in [1,2]'을 사용하면 안 되는 이유

ilotoki·2023년 10월 5일
0
post-thumbnail

아마 프로그래밍을 처음 시작할 때 한 값이 어떤 값인지 확인할 때 분명히 다음과 같은 코드를 짜보았을 것이다.

if val == 1 or val == 2 or val == 3:
     print('value matched.')

이런 코드를 사용하지 못하는 것은 아니지만 (사실은 경우에 따라서 - val이 1이 되는 경우가 매우 흔한 경우 - 더 빠를 수도 있다) 솔직히 말하면 그리 효율적인 코드는 아니다.
만약에 코드를 더 추가해야 한다면? 혹은 만약 검사해야 할 값이 100개라면 어떻게 될까?
저런 방식으로 코드를 짠다면 재사용성도 없고, 쉽게 코드를 변경할 수도 없다.

이 글에서는 이 단순한 코드를 매우 다양한 방식으로 리팩토링할 것이다. 과연 리팩토링을 할 꺼리가 있을까 싶을 정도로 작고 간단한 코드를 어떻게 리팩토링이 가능한지를 보면 분명히 놀랄 것이라 생각한다.

for문

for문을 배운 지 얼마 되지 않은 상태라 for문을 이용해보고 싶다는 생각이 들었다면 다음처럼 for문을 이용할 수도 있을 것이다.

for i in [1, 2, 3]:
    if val == i:
        print('value matched.')
        break

좋은 코드는 아니다. indent가 2번이나 들어갔고 코드도 4줄이나 된다.
가장 중요한 것은 의미를 파악하기가 전보다 더 어려워졌다는 것이다. 이런 간단한 문제에 for, if, break같은 로직을 이용하는 것은 마치 닭 잡는데 소 잡는 칼을 쓰는 격이다. 지금은 로직이 하나만 들어갔기에 망정이지, 만약에 이게 다른 복잡한 코드의 깊은 indent 속에 들어가 있다고 생각해 보자. 자체는 어렵지 않지만 분명 머릿속을 흐뜨려놓은 좋지 않은 코드이다.

for문을 사용해야 하는 경우

다만 저 코드가 항상 나쁜 것은 아니다. 만약 값을 준비하는데 두 줄 이상의 코드가 필요하다면 좋을 수도 있다.

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줄이면 되고 직관적이기까지 하니 쓰지 않을 이유가 없다!

...정말 그럴까? 저 코드를 조금 더 분석해 보자.

immutable 사용하기

우선 우리가 사용한 것은 mutable인 리스트이다. 하지만 여기에서 immutable인 튜플을 사용하면 어떨까?
좋은 생각이다. 이 케이스에서는 immutable인 튜플을 사용하는 것이 좋다. 왜일까?

  • 의미적 깔끔함
    immutable을 사용하면 '이 변수는 바뀌지 않는 수'라는 의미를 전달할 수 있다. 따라서 튜플을 사용하면 다른 프로그래머나 미래의 자신에게 조금 더 명확하게 메시지를 전달할 수 있다.
    특히 리스트보다는 튜플이 대괄호 대신 괄호를 사용해 사람에 따라서 더 보기 편하다고 생각할 수도 있다. 이는 순전히 사람의 선호도에 달려 있지만, 리스트를 사용할 필요가 없는 경우 튜플을 더 선호하는 경우가 더 많다고 개인적으로 생각한다.
  • 메모리 절약
    immutable을 이용하면 메모리를 살짝 아낄 수 있다. 구현이나 값을 어떻게 저장했는가에 따라 다를 수 있지만, 예를 들어 0부터 99까지를 저장한 리스트는 856바이트이지만 튜플은 840바이트이다. 아주 조금의 차이이고, 파이썬에서 메모리 효율성을 따지는 것은 그리 좋은 생각이 아니지만 immutable의 의미적 깔끔함에 더해 조금의 덤을 받았다고 생각하면 좋다.
    하지만 메모리를 절약하기 위해 immutable을 사용하는 것은 말리고 싶다. 만약 mutable을 사용하는 것이 적절한 때가 온다면 mutable을 쓰는 것이 주저하지 말자.
  • 성능 향상?
    튜플을 활용하면 이론상 성능 향상이 존재할 수 있다. 하지만 필자가 확인해본 결과 오차 범위 이상의 성능 향상이 보여지지는 않았다.

튜플을 사용하면 다음과 같이 코드가 바뀐다.

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까지 아주 간단해서 개선할 것이 있었나 싶었던 코드를 완전히 깔끔하게 바꾸는 데에 성공했다.
아무리 작은 코드의 한 조각이더라도 주의 깊게 바라보면 개선의 여지가 보일 수 있으니 언제나 코드를 주의깊게 바라보도록 하자.

0개의 댓글