1탄에서 시니어가 만들면 좋은(빠른) 이유에 대해서, CPU 최적화 접근 방식을 소개 했습니다.
시니어가 시간복잡도를 고려하는 모습을 보여드리고, 코드를 작성할 때 어떻게 하면 빠르게 결과를 낼지 생각해보게 하는데 목적이 있었죠.
[1탄 바로가기 - 시니어 엔지니어가 만들면 빠른 이유 #1]
오늘, 2탄은 루핑(Looping)을 주제로 삼았습니다.
시니어는 루핑에서 어떻게 효율을 올리는지 소개해 드리고자 합니다.
루핑은 반복 되는 로직 입니다.
while
, for
, 재귀함수
등으로 표현되는 로직이죠.
루핑은 프로그래밍의 꽃이기도 합니다.
우리가 대량의 작업을 빠르게 처리하기 위해
코드를 작성하는 것이잖아요?
루핑을 어디든 한번은 사용하실 겁니다.
프로그램에는 반드시 있죠.
그런데, 저는
이 루핑을 바라보는 마인드셋
에서
주니어와 시니어 간에 차이를 발견 했습니다.
루핑이 반복적인 일을 처리할 수 있다는 것은,
결국, 대량의 로직이 반복 실행 된다는 뜻입니다.
그러니까
1만번
의 루핑이라는 것은,
루프로 감싼 로직이 1만번
실행되는 것이죠.
그 코드가 시스템의 리소스를 점유하거나
메모리에 뭔가를 쌓는 다면,
순식간에 1만배
의 메모리를 사용하게 된다는 뜻입니다.
코드는 항상 메모리를 끼고 살죠.
계산을 하고 결과를 얻었으면,
어딘가 보관을 했다가
정리하고 답을 주어야 하니까요.
메모리를 쓰지 않을 수가 없습니다.
바로 이 지점에서, 주니어와 시니어의 코드 간에 차이가 생깁니다.
루프에 숨겨진 대량 메모리 사용의 심각성
을 주니어는 간과 하고는 합니다.
오늘도 이해하기 쉽게 예시를 들고 왔어요.
여기, 10만명
의 이메일 주소가 CSV파일에 담겨 있습니다.
여기서 @naver.com
를 사용하는 고객이 몇명인지 찾고 싶다고 해봅시다.
너무 쉬워서 로직이 바로 떠오르시죠?
자- 우리, 이 기능을 구현해 봅시다.
역시나 직관적인 파이썬을 사용 했습니다.
로직도 단순 합니다.
def read_csv(file_path):
with open(file_path, 'r', encoding='utf-8') as csvfile:
# 파일 전체를 몽땅 읽어서 content 변수에 담아요.
content = csvfile.read()
rows = content.split('\n')
# CSV의 첫번째 열만 모아서 List 로 리턴
return [row[0] for row in rows]
# 10만개의 이메일 주소를 CSV 파일에서 가져옵니다.
email_addresses = read_csv("./e.csv")
# @naver.com 로 끝나는 고객 수 세는 함수
def count_naver_emails(email_list):
count = 0
for email in email_list:
if email.endswith("@naver.com"):
count += 1
return count
# 함수 호출 및 결과 출력
naver_email_count = count_naver_emails(email_addresses)
print(f"@naver.com 고객 수: {naver_email_count}")
자연스럽죠?
주니어는 CSV파일을 몽땅 읽어서,
그걸 email_addresses
이라는 변수에 담고 루프를 돌렸습니다.
시니어의 코드 입니다.
또 약간 길어졌어요. 근데 yield
를 사용 하고 있네요.
import csv
def read_csv(file_path):
with open(file_path, 'r', newline='', encoding='utf-8') as csvfile:
# 몽땅 읽지 않도록, csv.reader 를 사용해서 읽어요.
csvreader = csv.reader(csvfile)
# 한줄씩 읽어서
for row in csvreader:
# CSV의 첫번째 열만 리턴
# 근데, yield 로 리턴하네요?
yield row[0]
# @naver.com 로 끝나는 고객 수 세는 함수 (주니어 코드와 똑같습니다)
def count_naver_emails(email_generator):
count = 0
for email in email_generator:
if email.endswith("@naver.com"):
count += 1
return count
# 제너레이터 생성
email_addresses_generator = read_csv('./e.csv')
# 함수 호출 및 결과 출력
naver_email_count = count_naver_emails(email_addresses_generator)
print(f"@naver.com 고객 수: {naver_email_count}")
로직 구조는 주니어 코드와 똑같습니다.
그런데… yield
를 쓰고 있습니다.
뭐가 다른 걸까요?
자세히 봅시다.
시니어는 10만개 담는 변수
대신 제너레이터
를 만들었어요.
제너레이터는, 데이터가 생길 때 마다, 한 row 씩 리턴
하는 파이썬의 문법 입니다.
yield
를 사용하고요. 이 개념은 다른 언어도 많이 지원하는 개념 입니다.
ES6
의 function*
+ yield
Kotlin
의 sequence
+ yield
Golang
은 yield
는 없지만 채널
과 고루틴
으로 아무튼, 목적은 대량의 데이터를 끝날때까지 순차적으로 한개씩 흘려주기
위함 입니다.
CSV를 읽는 로직을 제너레이터
로 변경하면
결과로 얻어진 10만개
를 한번에 리턴하는게 아니라,
파일에서 부터 1 row
씩 꺼내서 흘립니다.
1개씩 계속 리턴 하게 만들기 위해서 yield
를 사용하고요.
결국, 1개씩 갖고 올때 마다 내 루프 로직이 실행되도록 연결한 것입니다.
이런식으로 10만개
를 한번에 가져오지 않고 1개씩만
가져와요.
음?? 아직 이해가 안되실 수 있어요.
어쨋든, 방식은 동일한 것 같은데...
과연 무슨 차이가 있을까요?
메모리 사용량에 엄청난 차이가 있습니다.
이메일 주소 하나가 0.1KiB (102 Bytes)
라고 가정하고 계산해 봅시다.
10만개
처리하는데 10MiB (10,000KiB)
가 필요 했습니다.10만개
처리하는데 단 0.1KiB
만 필요하죠.더 좋은 것은, 데이터 량이 10배
인 100만개
가 되면,
주니어코드는 100MiB
가 필요한데,
시니어 코드는 시니컬하게 늘던 말던 항상 0.1KiB
만 필요하다는 사실입니다.
웹 개발자가 많이 보는 TPS / RPS
측면에서도,
주니어의 코드는 100명만 동시에 요청
해도 서버 메모리 1기가
가 필요해요.
시니어의 코드는 100명
이 와도 1MiB
도 안되는군요.
와우!
옆에 있는 형 언니
의 코드를 유심히 볼 이유가 생겼죠?
이러쿵 저러쿵 해도, 시니어의 코드리뷰가 도움되는 이유 입니다.
서버 메모리가 빵빵한건 사실 입니다.
그래서 사실 주니어 코드도 돌아가는데 별로 지장이 없었습니다.
그러나, 진가는 항상 서비스가 커졌을 때
, 사용자가 몰렸을 때
나오죠.
여러분이 만든 코드가, 서비스가 유명해지고 더 많은 사람이 사용하기 시작한다면
생전 처음 보는 OOM
오류로 멘붕
이 올지도 모릅니다.
프로그램은 메모리
없이 아무 것도 못합니다.
그런 메모리가 부족하면, 아무 조치도 못하고 바로 죽어버리는
무시무시한 Out Of Memory
오류를 만납니다.
시니어의 코드는 유저가 정말 많이 늘어도 OOM
을 만나기 쉽지 않습니다.
그 전에 다른 문제가 발생할 가능성이 큽니다.
오늘 이야기는 욕조에 물을 받는 방식과도 비교할 수 있어요.
주니어는 욕조랑 동일한 사이즈의 바가지에 물을 다 채워서 한번에 콱 넣습니다.
시니어는 그냥 호스를 연결해서 수도꼭지를 틀어버립니다.
주니어는 호스를 몰랐을 뿐일 가능성이 높죠.
결국, 경험의 중요성 입니다.
물론, 욕조가 아기용 욕조라면... 호스보다 바가지가 낫습니다.
시니어는 그것도 고려할 것이에요.
시니어는 루프 안에서 점유하게 될 리소스를 생각
하며 좋은 코드를 만듭니다.
그 리소스는 Memory
가 대표적 이죠.
제너레이터
뿐만 아니라, 전통적으로 스트림(Stream)
도 많이 사용 합니다.
오히려 스트림
이 더 범용적이고, 데이터의 흐름을 파이프로 이어갈 수 있는 데다, HTTP
도 스트림을 지원하여, 잘 이어주면 뭔가 굉장히 실용적
입니다.
예를 들면, 파일 다운로드
같은 것 말이죠.
여러분 웹하드 같은 파일 다운로드 서비스 많이 사용해 보셨죠?
한번 생각해 보세요.
내 컴퓨터에 달려 있는 메모리는 8기가
밖에 없는데,
메모리보다 큰 용량의 파일, 10기가
짜리를 어떻게 다운로드 받을까요?
결국, 이런 기능은 스트림
이나 제너레이터
가 아니면 구현이 불가능 합니다.
시니어의 복잡해 보이는 코드는, 다 이유가 있었습니다.
혹시 대량인데 아직 주니어 같은 코드가 있다?
제너레이터나 스트림을 도입해 보시기 바랍니다.
다음에도 좋은 글로 찾아올께요.
아임웹 CTO 매튜 드림.
알잘딱깔센으로 코드 짜는 방법이군요