최근 6주 동안 '데이터 엔지니어링 첫걸음' 과정을 들었습니다. 데이터 분석가도 데이터가 어디서 오는지 어떻게 쌓이는지 이해해야 한다고 느꼈기 때문입니다. 모르는 자의 용기도 한몫 했고요!
과정을 통과하려면 과제를 매주 Github으로 제출해야 합니다. 그런데 한 과제에서 자잘한 수정을 포함, 총 열 네 번을 커밋하는 일이 있었습니다. 저에게는 많은 챌린지와 배움이 있었던 터라 기록으로 남기려고 합니다.
1. 배경
싱글벙글 엉망진창 과제하기
과제 목표는 파이썬으로 데이터 파일을 읽어오고, 그 안에서 원하는 데이터 또는 집계 결과를 가져오는 것이었습니다. 판다스(Pandas, 데이터프레임을 다루는 파이썬의 라이브러리)를 쓰지 않고 알고리즘 만으로 해결해야 했습니다 (판다스에서는 그룹 집계만 써도 바로 해결되는 것들인데.. 🥲).
그래도 어떻게든 머리를 굴려 코드가 돌아가게는 만들었습니다.
하지만.. 아시죠..?
모르는 것을 모르는 나
우리가 아는 것과 모르는 것에는 네 가지 분면이 있다고 합니다.
그 중 저는 모르는 것을 모르는 상태에 있었어요.. 😆 '자료 구조'나 '알고리즘'에 대해서 특히요! 대충이라도 안다고 믿었고, 어떻게든 코드를 만들기만 하면 되는 것 아닌가? 라고 오판하기도 했어요. 그리고 누구보다 든든한 챗집사님도 있으니까요(?). 그런데 완전 잘못 알았던 것이죠..! 컴퓨터 과학의 기본적인 내용을 잘 몰랐다는 것을 이번 기회에 깨닫게 되었습니다.
2. 대략적인 커밋 스토리..
- 첫 번째 커밋 : 일단 냄
- 두 번째 커밋 : Git 상대 경로 처리 오류 (이것만 잘못된 줄 알았음) 해결
- 세 번째 커밋 : 리턴 자료 구조 문제 해결 / 파이썬 문법 문제 해결
- 네 번째 커밋 : 파일스트림 비효율성 문제 일부 해결
- 다섯 번째 커밋 : 데이터를 잘못 뽑은 문제 해결 /
- 여섯 번째 커밋 : 잘못된 로직 수정
- 일곱 번째 커밋 ~ 열 네번째 커밋 : 잘못된 로직 수정 / 오타 수정 / 알고리즘의 복잡도 문제와 파일스트림 비효율성 문제 해결 (여전히 문제)
3. 배움 (Lesson)
1️⃣ 알고리즘 성능 고려하기
알고리즘은 길찾기와 비슷합니다. 현재 지점에서 목표 지점까지 가려면 수많은 경로가 있을 거예요. 이왕 동일한 도착지에 가는 거라면 가장 빠르게, 혹은 가장 리소스를 덜 들이는 방법이 좋겠지요. 따라서 알고리즘의 '성능(효율성)'에 대해 고민해야 했습니다.
효율성을 위해 생각해야 할 개념은 시간 복잡도와 공간 복잡도입니다. 여기서 시간 복잡도는 알고리즘 실행까지 걸리는 시간을, 공간 복잡도는 알고리즘이 필요로 하는 메모리양을 말합니다. 똑같은 결과를 내더라도 실행 시간이 적어야 하며, 메모리를 덜 차지할 수록 유리합니다.
예를 들어, 도시 별로 평균 연봉을 계산해야 하는 목표가 있었습니다.
Table : data (딕셔너리 자료구조로 되어 있습니다)
|name|city|age|earnings |
|민아 |서울 |34 | 9000 |
|용주 |서울 |40 | 12000 |
|경민 |수원 |24 | 3000 |
|주환 |제주 |32 | 7000 |
|서율 |제주 |24 | 5000 |
예시
data = [{"name": "민아", "city": "서울", "age": 34, "earnings": 9000}, ~ {"name": "서율", "city": "제주", "age": 24, "earnings": 5000}]
이걸 코드1과 코드2로 나눠서 보겠습니다.
참고로 코드 1과 코드 2의 단 하나의 차이는 avg_earnings의 위치입니다 (들여쓰기가 다릅니다).
코드1.
data 테이블의 행(민아, 용주, 경민, 주환, 서율)을 돌 때마다 평균을 구합니다. 시간이 오래 걸릴 수밖에 없습니다.
# 빈 딕셔너리 만들기
city_earnings = {}
# 반복문 구성
for row in data:
# 변수 지정
city, earnings = row['city'], int(row['earnings'])
# 조건문
# city가 처음 언급되었다면 city key에 city 변수를 넣어주고, value에는 빈 리스트를 만들어줌
if city not in city_earnings:
city_earnings[city] = []
# city가 딕셔너리에 있다면 해당 키의 리스트에 earnings 변수를 추가해줌
city_earnings[city].append(earnings)
# 평균 구하기 (반복문 안에 있어 비효율적)
avg_earnings = {city:sum(earnings)/len(earnings) for (city, earnings) in city_earnings.items()}
코드2.
data 테이블을 돌면서 딕셔너리를 구성해놓았습니다. 이후 한꺼번에 평균을 구하기 때문에 코드1보다는 성능이 개선되었어요.
# 빈 딕셔너리 만들기
city_earnings = {}
# 반복문 구성
for row in data:
# 변수 지정
city, earnings = row['city'], int(row['earnings'])
# 조건문 : city가 처음 언급되었다면 city key에 city 변수를 넣어주고, value에는 빈 리스트를 만들어줌
if city not in city_earnings:
city_earnings[city] = []
# city가 딕셔너리에 있다면 해당 키의 리스트에 earnings 변수를 추가해줌
city_earnings[city].append(earnings)
# 평균 구하기 (for문 밖으로 나옴)
avg_earnings = {city:sum(earnings)/len(earnings) for (city, earnings) in city_earnings.items()}
인덴트 하나 때문에 코드의 성능이 바뀌기도 합니다. 코드를 작성할 때 가능한 여러 알고리즘을 생각해보고, 성능을 비교하여 결정하는 습관을 가져야겠다는 것을 배울 수 있었어요.
참, SQL 언어에도 쿼리 튜닝이라는 개념이 있더라구요! 일을 하면서 쿼리를 작성할 때는 일단 원하는 조건의 데이터를 출력하는 데 급급했던 기억이 납니다. 마치 경주마처럼 그냥 냅다 달리는 것이죠. 하지만 정말 비효율적인 쿼리를 돌릴 때는 대기 시간이 점점 길어졌고, 심한 경우 쿼리를 중단할 때도 있었어요. 시간과 자원을 덜 쓰는 쿼리 방식도 함께 고민해야겠습니다.
2️⃣ 간결한 것이 효율적인 건 아니다.
코드2로 다시 돌아가보겠습니다. 개선된 코드는 성능이 우수할까요? 위의 코드에서는 sum(리스트의 합계를 구하는 함수)가 포함돼 있어요.
min, sum와 같은 함수들을 흔히 '비싼 함수'라고 표현합니다. 주어진 리스트를 다 돌아야 하기 때문입니다. 물론 코드는 반복문을 썼을 때보다는 짧아졌지만, 중첩 반복문 때문에 생긴 문제는 코드의 간결성으로 해결할 수 없었습니다.
좀더 자세히 알아볼까요?
코드2에서는 이런 형태로 알고리즘이 흘러가는데요.
* 위의 data 표를 한 줄 씩 돌아본다.
* 서울이 빈 딕셔너리 안에 없네? 그럼 리스트에 서울을 key로 넣고, value는 빈 리스트를 만들어 두자.
* 다음 번에 돌아보니 이미 서울이 딕셔너리 안에 있네? 그러면 value에 있는 리스트에 값을 넣어보자. 9000 -> 다음에는 12000
* 그러면 city_earnigs 딕셔너리는 이런 식으로 구성된다.
* city_earnings = {"서울":[9000, 12000], "수원": [3000], "제주": [7000, 5000]}
* city_earnings 딕셔너리에서 각 도시에 대한 값(earnings)의 합계, 갯수를 구해 평균을 구해준다.
* avg_earnings = {"서울": 10500, "수원": 3000, "제주": 6000}
각각 도시(city) 라는 key에 빈 리스트를 만들고, 값을 추가해주고 이렇게 딕셔너리를 구성해놓은 상태인데요. 추가로 도시 별로 for문을 돌며 각 리스트를 또 돌며 earnings의 합계를 구하고, 갯수를 세는것을 알 수 있습니다.
조금 더 나은 결과를 위해서라면 이런 방법이 더 나았을 것입니다.
코드3.
# 빈 딕셔너리 만들기
city_earnings = {}
# 반복문 구성
for row in data:
# 변수 지정
city, earnings = row['city'], int(row['earnings'])
# 조건문
# city가 처음 언급되었다면 city key에 city 변수를 넣어주고, value에는 [0, 0]을 만들어줌
(앞의 0은 earnings의 합계가 될 거고, 뒤의 0은 earnings의 갯수가 됨)
if city not in city_earnings:
city_earnings[city] = [0, 0]
# city가 딕셔너리에 있다면 해당 키의 earnings와 카운트 수를 각각 더해줌
city_assets[city][0] += earnings
city_assets[city][1] += 1
# 평균 구하기
avg_earnings = {city:sum(earnings)/len(earnings) for (city, earnings) in city_earnings.items()}
코드3에서는 아래의 과정으로 알고리즘이 흘러갑니다. sum이라는 비싼 연산을 사용하지 않고도 평균액을 구할 수 있었습니다.
* 위의 data 표를 한 줄 씩 돌아본다.
* 서울이 빈 딕셔너리 안에 없네? 그럼 리스트에 서울을 key로 넣고, value는 0, 0으로 채워두자.
* 다음 번에 돌아보니 이미 서울이 딕셔너리 안에 있네? 그러면 value에 있는 리스트에 값을 추가하자.
* 그러면 이때 돌면서 sum과 count를 구할 수 있음!
* earnings의 합계 처음에는 0 -> 0+ 9000 -> 0+9000+12000
* count는 0 -> 1 -> 2
* 그러면 city_earnigs 딕셔너리는 이런 식으로 구성된다.
* city_earnings = {"서울":[21000, 2], "수원": [3000, 1], "제주": [12000, 2]}
* city_earnings 딕셔너리에서 각 도시에 대한 값(earnings, count)를 계산하는 것만으로 평균을 구할 수 있다.
* avg_earnings = {"서울": 21000/2, "수원": 3000/1, "제주": 12000/2}
3️⃣ 필요한 것과 필요하지 않은 것을 구분한다.
알고리즘을 짤 때 꼭 필요한 것과 불필요한 것을 구분하면 시간 복잡도와 공간 복잡도 모두 개선할 수 있습니다.
1) 필요한 데이터만 불러오기
예를 들어 엑셀 파일을 불러온다고 가정해 보겠습니다.
아래 파일에서 도시 별 연봉 정보를 구한다고 하면 'city' 컬럼과 'earnings' 컬럼만 가지고 오면 됩니다.
# employee_earnings 엑셀 파일에 있는 데이터
|name|city|age|earnings |
|민아 |서울 |34 | 9000 |
|용주 |서울 |40 | 12000 |
|경민 |수원 |24 | 3000 |
|주환 |제주 |32 | 7000 |
|서율 |제주 |24 | 5000 |
수정 전 : 불필요한 컬럼까지 싸그리 가져오기
연산에 필요한 'city', 'earnings' 외에 불필요한 컬럼도 같이 가져옵니다. 그러면 공간 메모리도 많이 차지할 뿐더러, 가져오는 데 시간도 더 듭니다.
# openpyxl 불러오기(설치되어 있다고 가정)
import openpyxl
# 워크북 불러오기
wb = openpyxl.load_workbook("employee_earnings.xlsx")
# 활성화된 시트 가져오기
ws = wb.active
# 데이터 가져오기
flgas = False
earning_data = []
for row in ws:
if not flags:
flags = True
continue
data.append({
'name': row[0].value, #필요 없음
'city': row[1].value,
'age': row[2].value, #필요 없음
'earnings': row[3].value
})
수정 후 : 불필요한 'name', 'age' 컬럼을 가져오지 않았습니다.
# openpyxl 불러오기(설치되어 있다고 가정)
import openpyxl
# 워크북 불러오기
wb = openpyxl.load_workbook("employee_earnings.xlsx")
# 활성화된 시트 가져오기
ws = wb.active
# 데이터 가져오기
flgas = False
earning_data = []
for row in ws:
if not flags:
flags = True
continue
data.append({
'city': row[1].value,
'earnings': row[3].value
})
(+ 수정 버전도 2️⃣의 원리를 고려한다면, 더 효율적인 코드로 만들 수 있습니다..!)
참고로 SQL에서 서브쿼리나 CTE를 만들 때도 유사한 방식을 적용해볼 수 있습니다. 서브쿼리나 CTE를 만들 때 SELECT 문에 '\*' 를 사용하는 것은 매우 매우 비효율적인 방법일 수 있습니다🙅🏻♀️. 필요한 컬럼만 그때그때 가져옵시다 🙂
2) 파일 스트림이 필요 없다면 닫아야 합니다.
csv나 json 파일을 가져올 때는 파이썬 내장 라이브러리를 사용하는데요. 이걸 가져올 때 유용하게 사용할 수 있는 방법이 with를 활용하는 것입니다.
with문은 파이썬에서 외부 파일 등을 가져올 때, 이 파일을 안전하게 다루기 위해서 사용하는데요. with문 안에서 처리를 하고 이 블록을 빠져나오면 파일이 자동적으로 닫힙니다. 블록을 빠져나오기 전까지는 계속 파일이 실행되고 있는 것이기 때문에 이걸 잘 닫고 나와야 합니다.
파일을 불러와서 하나의 변수에 저장을 해뒀다면, 이 변수는 with문 밖에서도 활용할 수 있습니다. 이때는 with문 밖에서 변수만 가지고 처리해도 됩니다.
4️⃣ 당연한 것을 당연하게 처리한다.
부끄럽게도.. 저는 파이썬의 기본적인 문법 규칙을 잘 몰랐습니다. 바로 콜론(:) 사용에 대한 것이었는데요.
파이썬에서는 콜론 사용 시 양쪽 띄어쓰기를 하지 않습니다..
뭐든 띄어쓰는 데 익숙해서 이런 당연한 것들을 잘 처리하지 못했는데요.
기초 문법에 어긋나지 않게 코드를 작성해야겠다는 것을 배울 수 있었습니다.
3. 느낀점
1) 기본이 가장 중요했습니다.
* 데이터 엔지니어링 과정이었지만 '기초' 에서 부족한 점이 많다는 것을 느꼈습니다.
* 사용하고 있는 언어의 특성이나, 자료구조, 알고리즘부터 명확하게 이해해야 한다고 느꼈습니다.. 파이썬도 안다고 생각하지 않고 차근차근 다시 공부해보려고 해요!
2) 효율, 효율을 생각하자!
* 알고리즘은 효율입니다..! 효율적으로 생각하는 습관을 가져야겠습니다.
* 여러 알고리즘을 구성해보고 비교해보는 습관을 가져야겠습니다.
3) Commit의 즐거움도 배울 수 있었습니다. 마음껏 커밋하고 고치기
- 처음엔 한번에 잘 통과하자는 생각으로 신중에 신중을 기하고 과제를 제출하려고 했었습니다. 하지만 원하는 대로 통과하지 않았다고 해서 실망할 필요가 없다는 것을 알게 되었습니다.
- 첫 커밋이 그대로 통과되는 경우는 없었습니다. 일종의 '초안'인 셈입니다. commit 1회의 과정은 무조건 필요하구나! 어차피 수정하고 수정해야 하는구나! 이렇게 생각하니까 첫 시도가 조금 가벼워진 느낌이 들었습니다.
- 커밋을 하고 고민하면 할 수록 잘 배울 수 있었습니다. 단순히 강의나 글로만 봤다면 이렇게 절실히(?) 알고리즘 성능에 대해 배울 수 없었을 거예요. 뭐든 해보는 것이 안해보는 것보다 배움의 강도가 큽니다
- 커밋의 과정이 결국은 '성공적인' 코드를 만들기 위한 하나의 과정이라는 것입니다. 결국 좋은 코드를 만들 것이고, 그 안에서 여러 시도가 있었을 때 코드가 더 정교해진다는 것을 배울 수 있었습니다. 마찬가지로 제가 하고 있는 실패들도 그 안에 배움이 있고, 좋은 결과를 위한 과정이라고 생각해요. 그러면 실패를 훨씬 긍정적으로 받아들일 수 있을 것 같습니다.
2024년에는 commmit의 자세로 가볍게 시도하고 꼼꼼히 고쳐나가며 배워야겠습니다.
마지막으로 작성된 과제도 사실 더 효율적인 방법이 있었습니다.
heap이라는 자료 구조를 이요하는 것인데, 이 부분은 다시 공부해보고 다뤄보겠습니다!
엉망진창 코드도 하나하나 꼼꼼히 봐주시고, 성장을 위해 끝까지 코멘트 해주신 선생님께도 다시 한번 감사의 말씀을 드립니다..!! (정말 은혜로운 분입니다..)
'분석가의 데이터 이야기 > SQL과 DB' 카테고리의 다른 글
[SQL] MySQL을 쓰다 PostgreSQL을 쓰면서 느낀 차이 (희망편 ②) (1) | 2024.02.07 |
---|---|
[SQL] MySQL을 쓰다 PostgreSQL을 쓰면서 느낀 차이 (희망편 ①) (2) | 2024.02.02 |
[SQL] MySQL에서 텍스트를 여러 행으로 분리하기(Melt down) (2) | 2024.01.29 |
[DE] csv파일 데이터베이스에 인입하기 (feat. psycopg) (1) | 2024.01.22 |
댓글