
지금 회사에는 제품중 로또 시스템 있다. 실제 토요일 로또 추첨된 결과와 일치하는 로또를 가진 유저에게 현금을 주는 시스템이 있다.
최근 사용자가 증가하면서 일주일에 대략 8000만개의 로또가 생성되는데, 이를 추첨하는데만 12시간이 걸린다. (정확히는 데이터를 백업하고, 해당 유저를 정확히 뽑는데까지 걸리는 시간이다.)
하지만, 실제 로또 추첨을 보면 번호 추첨과 동시에 몇명이 뽑혔는지가 나온다. (거의 10초 내외로 나온다.)
이게.....어떤 시스템이길래 가능한걸까? 라는 생각이 들어서, 우리 회사에 적용할수 있는 시스템일까? 하는 호기심이 들어서 테스트를 해보았다.
테스트는 맥북 에어 (m4) 에서 실행했다.
1. 번호 생성
조건은 3억개의 로또 번호에서 특정 번호를 뽑는데, 얼마나 시간이 걸리는지 측정하는것이다.
3억개의 랜덤 로또 번호를 생성하는 로직이다.
참고로 해당 csv는 9.5G 정도 된다..
import csv
import random
from tqdm import tqdm
def generate_lotto_csv(path="lotto.csv", total=300000000):
with open(path, mode='w', newline='') as f:
writer = csv.writer(f)
for user_id in tqdm(range(1, total + 1)):
numbers = sorted(random.sample(range(1, 46), 6))
writer.writerow([user_id, "{" + ",".join(map(str, numbers)) + "}"])
if __name__ == "__main__":
generate_lotto_csv()
2. RDS (postgreSQL)
전통적인 RDS로 측정해보려한다.
helm - values.yaml
참고로 기본으로 실행하면, 3억건의 데이터 insert 도중 메모리 부족으로 서버가 죽는다. (그래서 메모리를 늘려줬다)
또한 파일로 데이터 insert시에는 root 권한이 필요하여 postgres의 패스워드를 추가했다.
# postgresql-helm/values.yaml
fullnameOverride: postgresql
auth:
username: testuser
password: testpass
database: testdb
postgresPassword: postgres
service:
type: ClusterIP
primary:
resources:
requests:
memory: 2Gi
cpu: 500m
limits:
memory: 6Gi
cpu: 2
persistence:
enabled: false
위에서 만든 로또 데이터를 postgres에 업로드 한다.
kubectl cp ./lotto.csv postgresql-0:/tmp/lotto.csv
postgres pod에 접속 및 postgres로 로그인 해준 후 (비번은 위에 values.yaml에 있다)
먼저 테이블을 생성해 준다.
참고로 테이블은 번호마다 각 칼럼을 지정하면 안된다. 생각해보면 1등은 6개의 숫자 칼럼이 정확히 일치하면 되지만, 2~5등 까지를 구하는게 극악으로 치닫게 된다. 어느 칼럼에 어떤 숫자가 있을지 알수 없기 풀스캔을 하는 상황에서 경우의 수가 6배로 늘어나게 된다.
그래서 array 형태로 정렬된 숫자로 저장한 후 검색하는걸로 정했다.
테이블은 다음과 같다.
CREATE TABLE lotto_entries (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
numbers INTEGER[6] -- 정렬된 6개 번호를 ARRAY로 저장
);
이제 업로드한 로또 데이터(csv)를 디비에 insert한다. (대략 40분 정도 걸렸다)
testdb=# COPY lotto_entries(user_id, numbers) FROM '/tmp/lotto.csv' DELIMITER ',' CSV;
COPY 300000000
testdb=# select * from lotto_entries limit 10;
id | user_id | numbers
----+---------+---------------------
1 | 1 | {5,12,13,20,30,42}
2 | 2 | {1,2,26,38,42,45}
3 | 3 | {4,10,12,13,18,23}
4 | 4 | {2,17,18,32,34,40}
5 | 5 | {13,19,27,30,40,41}
6 | 6 | {16,19,25,37,38,39}
7 | 7 | {3,9,10,22,30,34}
8 | 8 | {20,24,32,34,38,43}
9 | 9 | {1,4,7,11,15,29}
10 | 10 | {6,9,25,34,36,44}
(10 rows)
가상의 로또 번호로 추첨을 진행하자.
번호는 [3.5.7.13.25.44] 번이고, 보너스 번호는 41번이다.
각각 1등 부터 5등까지 몇명이 나오는지 확인하는 쿼리다.
WITH params AS (
SELECT ARRAY[3, 5, 7, 13, 25, 44]::int[] AS winning,
41 AS bonus
),
matches AS (
SELECT
user_id,
(
SELECT COUNT(*)
FROM unnest(numbers) n
WHERE n = ANY (winning)
) AS match_count,
numbers @> ARRAY[41] AS has_bonus
FROM lotto_entries, params
)
SELECT
COUNT(*) FILTER (WHERE match_count = 6) AS "1등",
COUNT(*) FILTER (WHERE match_count = 5 AND has_bonus) AS "2등",
COUNT(*) FILTER (WHERE match_count = 5 AND NOT has_bonus) AS "3등",
COUNT(*) FILTER (WHERE match_count = 4) AS "4등",
COUNT(*) FILTER (WHERE match_count = 3) AS "5등",
COUNT(*) AS "총 발행수"
FROM matches;
쿼리의 결과는 대충 40분이 걸려서 결과물이 나왔다. (물론 서버 사양에 따라 시간이 달라지길 것이다.)
1등 | 2등 | 3등 | 4등 | 5등 | 총 발행수
-----+-----+------+--------+---------+-----------
33 | 198 | 8556 | 408438 | 6733028 | 300000000
(1 row)
만일 이걸 실시간으로 당첨번호가 나오자 마자 알수 있으려면? 지금의 디비로는 불가능하다.
일단 3억건의 데이터를 서치하는것만으로도 몇분이 걸릴텐데,
칼럼 형식이 리스트다? 몇초내로 결과가 나오는건 불가능하다 (테이블을 전부 스캔해야 하므로 블가능)
데이터 저장 방식부터 바꿔야 한다.
3. spark
병렬 처리로 매우 큰 데이터 처리를 위해 쓰는 spark로 테스트틑 해보려고 한다.
이번엔 docker-compose 로 실행했다. ( helm으로 도저희 lotto.csv 를 옮길수가 없었다.. 왜 볼륨 마운트가 안되는지 모르겠다.)
총 워커 4대를 실행했다.
version: "3.8"
services:
spark-master:
image: bitnami/spark:3.5.1
container_name: spark-master
environment:
- SPARK_MODE=master
- SPARK_RPC_AUTHENTICATION_ENABLED=no
- SPARK_RPC_ENCRYPTION_ENABLED=no
- SPARK_LOCAL_STORAGE_ENCRYPTION_ENABLED=no
- SPARK_SSL_ENABLED=no
- IVY_HOME=/tmp/.ivy2
- SPARK_SUBMIT_OPTIONS=""
ports:
- "7077:7077" # Spark Master port
- "8080:8080" # Web UI
volumes:
- ./data:/data # 로컬 볼륨 마운트
deploy:
resources:
limits:
cpus: '4.0'
memory: 12g
spark-worker-1:
image: bitnami/spark:3.5.1
container_name: spark-worker-1
environment:
- SPARK_MODE=worker
- SPARK_MASTER_URL=spark://spark-master:7077
- SPARK_WORKER_MEMORY=6g
- SPARK_WORKER_CORES=4
- IVY_HOME=/tmp/.ivy2
- SPARK_SUBMIT_OPTIONS=""
ports:
- "8081:8081" # Worker Web UI
volumes:
- ./data:/data # 동일한 볼륨 마운트
depends_on:
- spark-master
deploy:
resources:
limits:
cpus: '4.0'
memory: 6g
spark-worker-2:
image: bitnami/spark:3.5.1
container_name: spark-worker-2
environment:
- SPARK_MODE=worker
- SPARK_MASTER_URL=spark://spark-master:7077
- SPARK_WORKER_MEMORY=6g
- SPARK_WORKER_CORES=4
- IVY_HOME=/tmp/.ivy2
- SPARK_SUBMIT_OPTIONS=""
ports:
- "8082:8081" # 포트 충돌 방지
volumes:
- ./data:/data
depends_on:
- spark-master
deploy:
resources:
limits:
cpus: '4.0'
memory: 6g
spark-worker-3:
image: bitnami/spark:3.5.1
container_name: spark-worker-3
environment:
- SPARK_MODE=worker
- SPARK_MASTER_URL=spark://spark-master:7077
- SPARK_WORKER_MEMORY=6g
- SPARK_WORKER_CORES=4
- IVY_HOME=/tmp/.ivy2
- SPARK_SUBMIT_OPTIONS=""
ports:
- "8083:8081" # 포트 충돌 방지
volumes:
- ./data:/data
depends_on:
- spark-master
deploy:
resources:
limits:
cpus: '4.0'
memory: 6g
spark-worker-4:
image: bitnami/spark:3.5.1
container_name: spark-worker-4
environment:
- SPARK_MODE=worker
- SPARK_MASTER_URL=spark://spark-master:7077
- SPARK_WORKER_MEMORY=6g
- SPARK_WORKER_CORES=4
- IVY_HOME=/tmp/.ivy2
- SPARK_SUBMIT_OPTIONS=""
ports:
- "8084:8081" # 포트 충돌 방지
volumes:
- ./data:/data
depends_on:
- spark-master
deploy:
resources:
limits:
cpus: '4.0'
memory: 6g
import time
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf, col
from pyspark.sql.types import ArrayType, IntegerType, StringType
import ast
# 시작 시간 기록
start_time = time.time()
WINNING_NUMBERS = {3, 5, 7, 13, 25, 44}
BONUS_NUMBER = 41
spark = SparkSession.builder \
.appName("LottoAnalysis") \
.master("spark://spark-master:7077") \
.config("spark.hadoop.fs.file.impl", "org.apache.hadoop.fs.LocalFileSystem") \
.getOrCreate()
df = spark.read.csv("file:///data/lotto.csv", header=False)
df = df.toDF("id", "numbers")
def parse_numbers(numbers_str):
try:
return list(map(int, ast.literal_eval(numbers_str)))
except Exception:
return []
parse_numbers_udf = udf(parse_numbers, ArrayType(IntegerType()))
df = df.withColumn("number_list", parse_numbers_udf(col("numbers")))
# 등수 판단 로직
def judge_lotto(user_numbers):
if len(user_numbers) != 6:
return "Invalid"
matched = len(set(user_numbers) & WINNING_NUMBERS)
bonus = BONUS_NUMBER in user_numbers
if matched == 6:
return "1등"
elif matched == 5 and bonus:
return "2등"
elif matched == 5:
return "3등"
elif matched == 4:
return "4등"
elif matched == 3:
return "5등"
else:
return "꽝"
judge_udf = udf(judge_lotto, StringType())
df = df.withColumn("rank", judge_udf(col("number_list")))
result_df = df.groupBy("rank").count().orderBy("rank")
result_df.show(truncate=False)
# 종료 시간 및 실행 시간 출력
end_time = time.time()
print(f"총 소요 시간: {end_time - start_time:.2f}초")
# Spark 세션 종료
spark.stop()
결과적으로 25분 정도가 걸렸다..
생각보다 시간이 많이 걸려서 놀랐다. RDS보다는 두배 빠르지만, 생각보다 빨라지진 않았다.
+----+---------+
|rank|count |
+----+---------+
|1등 |33 |
|2등 |198 |
|3등 |8556 |
|4등 |408438 |
|5등 |6733028 |
|꽝 |292849747|
+----+---------+
총 소요 시간: 1386.07초
혹시나 csv 파일 떄문인가 싶어서 parquet로 변환 후 실행했다.
import time
from pyspark.sql import SparkSession
from pyspark.sql.functions import udf, col
from pyspark.sql.types import ArrayType, IntegerType, StringType
import ast
# 시작 시간 기록
start_time = time.time()
WINNING_NUMBERS = {3, 5, 7, 13, 25, 44}
BONUS_NUMBER = 41
# Spark 세션 시작
spark = SparkSession.builder \
.appName("LottoAnalysis") \
.master("spark://spark-master:7077") \
.config("spark.hadoop.fs.file.impl", "org.apache.hadoop.fs.LocalFileSystem") \
.getOrCreate()
df = spark.read.parquet("file:///data/lotto.parquet") # 기존에 만든 parquet 사용
def parse_numbers(numbers_str):
try:
return list(map(int, ast.literal_eval(numbers_str)))
except Exception:
return []
parse_numbers_udf = udf(parse_numbers, ArrayType(IntegerType()))
df = df.withColumn("number_list", parse_numbers_udf(col("numbers")))
# 등수 판단 로직
def judge_lotto(user_numbers):
if len(user_numbers) != 6:
return "Invalid"
matched = len(set(user_numbers) & WINNING_NUMBERS)
bonus = BONUS_NUMBER in user_numbers
if matched == 6:
return "1등"
elif matched == 5 and bonus:
return "2등"
elif matched == 5:
return "3등"
elif matched == 4:
return "4등"
elif matched == 3:
return "5등"
else:
return "꽝"
judge_udf = udf(judge_lotto, StringType())
df = df.withColumn("rank", judge_udf(col("number_list")))
result_df = df.groupBy("rank").count().orderBy("rank")
result_df.show(truncate=False)
# 종료 시간 및 실행 시간 출력
end_time = time.time()
print(f"총 소요 시간: {end_time - start_time:.2f}초")
# Spark 세션 종료
spark.stop()
흠... 5분 정도 빨라진 20분 정도로 종료되었다. 확실히 25%정도 빨라졌기 때문에 parquet를 써야 한다.
결과적으로는 그냥 데이터가 많고, 서버 사양이 받춰주질 못해서 이만큼 시간이 걸리는거 같다.
+----+---------+
|rank|count |
+----+---------+
|1등 |33 |
|2등 |198 |
|3등 |8556 |
|4등 |408438 |
|5등 |6733028 |
|꽝 |292849747|
+----+---------+
총 소요 시간: 1316.83초
spark로 튜닝 해봤자. 몇초 차이가 날뿐이라 생각해서 다음으로 넘어갔다.
4. duckdb
사실 duckdb를 써본적이 없어서, 대용량 데이터에 성능이 좋다고 해서 해보았다.
Parquet 파일 직접 로딩 (데이터베이스로 import 불필요) 해서 메모리 상에서 직접 처리한다고 알고 있다.
version: "3.8"
services:
duckcli:
image: datacatering/duckdb:v1.3.2
container_name: duckcli
stdin_open: true
tty: true
volumes:
- ./data:/data
cli에 접속 후 다음의 쿼리를 실행하면 된다.
spark에서 사용한 parquet를 그대로 사용했다.
> SELECT
COUNT(*) FILTER (WHERE match_count = 6) AS "1등",
COUNT(*) FILTER (WHERE match_count = 5 AND has_bonus) AS "2등",
COUNT(*) FILTER (WHERE match_count = 5 AND NOT has_bonus) AS "3등",
COUNT(*) FILTER (WHERE match_count = 4) AS "4등",
COUNT(*) FILTER (WHERE match_count = 3) AS "5등",
COUNT(*) FILTER (WHERE match_count < 3) AS "꽝"
FROM (
SELECT
user_id,
array_length(array_intersect(
str_split(replace(replace(numbers, '{',''), '}',''), ','),
['3','5','7','13','25','44']
)) AS match_count,
list_contains(
str_split(replace(replace(numbers, '{',''), '}',''), ','),
'41'
) AS has_bonus
FROM '/data/lotto.parquet'
);
대략 1분 정도 걸렸다. 생각 보다 빨라서 놀랐다.
아마 서버만 받혀 준다면 충분히 10초 내외도 가능할꺼 같다.
100% ▕████████████████████████████████████████████████████████████▏
┌───────┬───────┬───────┬────────┬─────────┬───────────┐
│ 1등 │ 2등 │ 3등 │ 4등 │ 5등 │ 꽝 │
│ int64 │ int64 │ int64 │ int64 │ int64 │ int64 │
├───────┼───────┼───────┼────────┼─────────┼───────────┤
│ 33 │ 198 │ 8556 │ 408438 │ 6733028 │ 292849747 │
└───────┴───────┴───────┴────────┴─────────┴───────────┘
메모리를 많이 먹긴 하지만, 데이터를 메모리에 올려놓고 실행한다고 생각하면 딱히 많이 먹는것도 아니였다.
결론부터 말하면
postgres : 40분
spark : 20분
duckdb : 1분
으로duckdb가 압도적인 성능을 보여줬다.
물론 데이터가 더 크거나, string 데이터였으면 달라졌을수도 있지만, 왠만큼 큰 데이터 분석에는 duckdb를 써야 겠다.
끝
'server > 아키텍쳐' 카테고리의 다른 글
| 2. airflow + dbt (1) | 2025.08.31 |
|---|---|
| 1. dbt tutorial (0) | 2025.08.30 |
| rabbitmq 심화 (persistent / cluster) (0) | 2024.05.05 |
| rabbitmq start (1) | 2024.05.04 |
| airflow scheduler high cpu usage (1) | 2021.11.29 |