Why

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 를 쓰고 있습니다.

뭐가 다른 걸까요?
자세히 봅시다.

시니어의 yield 사용 이유, 제너레이터

시니어는 10만개 담는 변수 대신 제너레이터를 만들었어요.

제너레이터는, 데이터가 생길 때 마다, 한 row 씩 리턴하는 파이썬의 문법 입니다.
yield 를 사용하고요. 이 개념은 다른 언어도 많이 지원하는 개념 입니다.

  • ES6function* + yield
  • Kotlinsequence + yield
  • Golangyield 는 없지만 채널고루틴으로

아무튼, 목적은 대량의 데이터를 끝날때까지 순차적으로 한개씩 흘려주기 위함 입니다.

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 매튜 드림.

profile
CTO at Imweb, 20년차 개발 장인, 전) 플레이오토 CTO/창업자

9개의 댓글

comment-user-thumbnail
2024년 7월 8일

알잘딱깔센으로 코드 짜는 방법이군요

1개의 답글
comment-user-thumbnail
2024년 7월 8일

엄청 큰 용량일거 같으면 스트림으로 쪼개서 작업하는게 먼저 떠오르긴 하는데 실무하면서 그정도 까지는 만나본적은 없긴하네요 그리고 반복문이 섞인 로직이 주니어와 시니어의 차이가 잘 보이는게 맞는거 같아요 이번 케이스에서 설명해주신 메모리 말고도 반복문 안에서의 예외처리라던지.. 이번 글도 잘 봤습니다.

1개의 답글
comment-user-thumbnail
2024년 7월 9일

좋은 글 감사합니다. 예시로 들어주신 주니어와 시니어의 코드에 속도 차이는 있을까요?

2개의 답글
comment-user-thumbnail
2024년 8월 30일

좋은 글 감사합니다^^ 👍

답글 달기