C++·Python·Java·JavaScript 성능 최적화: 프로파일링부터 알고리즘·메모리·언어별 기법까지
이 글의 핵심
느린 코드를 감으로 고치기 시작하면 정작 병목이 아닌 곳에 시간을 쓰게 됩니다. 측정 없이 최적화하지 말라는 원칙과 우선순위를 먼저 정하고, 알고리즘 개선이 미세 튜닝보다 효과가 큰 이유, 언어마다 메모리를 다루는 방식이 달라 같은 최적화가 통하지 않는 지점을 비교해 체크리스트로 정리합니다.
들어가며: 성능 최적화의 원칙
성능 최적화는 측정 → 분석 → 개선 → 검증의 반복입니다. 추측이 아닌 데이터 기반으로 접근해야 합니다.
이 글에서 다루는 것:
- 언어별 프로파일링 도구
- 알고리즘 최적화
- 메모리 최적화
- 캐싱 전략
- 실무 최적화 사례
최적화 원칙
최적화의 3대 원칙
flowchart LR
A[최적화 시작] --> B[1. 측정]
B --> C[2. 병목 찾기]
C --> D[3. 최적화]
D --> E[4. 검증]
E --> B
1. 측정 먼저 (Measure First)
❌ "이 코드가 느릴 것 같아" (추측)
✅ "프로파일러로 측정한 결과 이 함수가 80% 시간 소요" (데이터)
2. 병목 찾기 (Find Bottleneck)
전체 실행 시간: 10초
├─ 함수 A: 0.1초 (1%)
├─ 함수 B: 8초 (80%) ← 병목!
└─ 함수 C: 1.9초 (19%)
→ 함수 B를 최적화하면 가장 큰 효과
3. 80/20 법칙
코드의 20%가 실행 시간의 80%를 차지
→ 그 20%만 최적화하면 충분
“80/20”은 정확한 비율이 아니라 경험칙이지만, 그 배경에는 암달의 법칙이라는 분명한 계산이 있습니다. 전체 시간 중 비율 p를 차지하는 부분을 s배 빠르게 만들면 전체 속도 향상은 1 / ((1 - p) + p / s)입니다. 위 예에서 함수 A(1%)를 무한히 빠르게 만들어도 전체는 약 1%밖에 줄지 않지만, 함수 B(80%)를 두 배 빠르게 만들면 10초가 6초로 줄어듭니다. 반대로 말하면 함수 B를 아무리 최적화해도 나머지 20%(2초) 아래로는 내려갈 수 없다는 뜻이기도 합니다. 그래서 병목을 하나 해결하면 다시 측정해서 새 병목을 찾아야 하고, 목표 성능에 도달하면 멈추는 것이 합리적입니다.
측정에서 가장 흔한 실수는 측정 자체가 틀린 경우입니다. C++을 디버그 빌드(-O0)로 측정하면 운영 빌드와 병목 위치가 전혀 다르게 나오고, Java나 JavaScript처럼 JIT 컴파일을 하는 런타임은 처음 몇 천 번의 실행이 인터프리터로 돌아서 짧은 벤치마크는 워밍업 시간을 재는 셈이 됩니다. 또 계산 결과를 아무 곳에도 쓰지 않는 벤치마크 코드는 컴파일러가 루프 자체를 지워 버려 “0.001초” 같은 비현실적인 결과가 나옵니다. 그래서 언어마다 전용 도구(C++의 Google Benchmark, Java의 JMH, Python의 timeit)가 있으며, 이 도구들은 반복 측정, 워밍업, 결과 최적화 방지를 대신 처리해 줍니다.
최적화 우선순위
graph TB
A[최적화 우선순위] --> B[1. 알고리즘]
A --> C[2. 자료구조]
A --> D[3. 캐싱]
A --> E[4. 병렬화]
A --> F[5. 언어/컴파일러]
B --> B1[On² → On]
C --> C1[배열 → 해시맵]
D --> D1[중복 계산 제거]
E --> E1[멀티스레드]
F --> F1[컴파일러 옵션]
예제:
# ❌ O(n²) 알고리즘
def has_duplicate(arr):
for i in range(len(arr)):
for j in range(i + 1, len(arr)):
if arr[i] == arr[j]:
return True
return False
# ✅ O(n) 알고리즘 (해시셋 사용)
def has_duplicate(arr):
seen = set()
for x in arr:
if x in seen:
return True
seen.add(x)
return False
# 성능 차이: 100만 개 배열 (중복이 없는 최악의 경우)
# O(n²): 약 5천억 번 비교 → 파이썬에서는 수 시간
# O(n): 100만 번 순회 → 1초 미만
알고리즘이 1순위인 이유는 개선 폭의 규모가 다르기 때문입니다. 컴파일러 옵션이나 언어별 미세 조정은 보통 몇십 퍼센트, 잘해야 몇 배의 차이를 만들지만, O(n²)를 O(n)으로 바꾸는 것은 입력이 커질수록 수천 배, 수만 배의 차이가 됩니다. 이 예제에서 해시셋 버전은 원소마다 x in seen 조회를 평균 O(1)로 하는 대신 원소 수만큼의 메모리를 추가로 씁니다. 시간과 메모리를 맞바꾸는 것이 대부분의 알고리즘 최적화의 본질이며, 메모리가 극도로 제한된 환경이라면 정렬 후 인접 원소를 비교하는 O(n log n) 방법이 절충안이 됩니다. 입력이 수십 개 정도로 작다면 O(n²) 버전이 오히려 빠를 수도 있으니, 복잡도 개선도 결국 실제 데이터 크기에서 측정해 판단해야 합니다.
프로파일링
언어별 프로파일링 도구
| 언어 | 도구 | 사용법 |
|---|---|---|
| C++ | gprof, Valgrind, perf | g++ -pg, valgrind --tool=callgrind |
| Python | cProfile, line_profiler | python -m cProfile script.py |
| Java | VisualVM, JProfiler | JVM 옵션 또는 IDE 통합 |
| JavaScript | Chrome DevTools, Node.js Profiler | node --prof script.js |
C++ 프로파일링
# gprof 사용
g++ -pg -O2 main.cpp -o main
./main
gprof main gmon.out > analysis.txt
# Valgrind Callgrind
valgrind --tool=callgrind ./main
kcachegrind callgrind.out.*
# perf (Linux)
perf record ./main
perf report
세 도구는 측정 방식이 달라서 결과를 읽는 법도 다릅니다. gprof는 컴파일할 때 함수마다 계측 코드를 삽입(-pg)하므로 함수 호출 횟수는 정확하지만, 계측 코드 자체가 작은 함수의 비용을 부풀리고 인라인된 함수는 보이지 않습니다. Callgrind는 가상 CPU에서 명령어를 하나하나 세기 때문에 결과가 매우 정확하고 재현 가능하지만 실행이 수십 배 느려집니다. perf는 일정 간격으로 CPU가 어디를 실행 중인지 샘플링하는 방식이라 오버헤드가 거의 없어 운영 환경에 가까운 조건에서 측정할 수 있고, 요즘 Linux에서 첫 번째로 권하는 도구입니다. perf로 함수 이름을 제대로 보려면 -g(디버그 심볼)를 켜고 빌드하고, 호출 경로까지 보려면 perf record -g와 함께 -fno-omit-frame-pointer로 컴파일하는 것이 좋습니다. 결과를 플레임 그래프로 그려 보면 어떤 호출 경로가 시간을 쓰는지 한눈에 들어옵니다.
출력 예제:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
80.00 0.80 0.80 1 800.00 800.00 slow_function
15.00 0.95 0.15 100000 0.00 0.00 fast_function
5.00 1.00 0.05 1 50.00 50.00 main
Python 프로파일링
import cProfile
import pstats
def slow_function():
total = 0
for i in range(1000000):
total += i
return total
def fast_function():
return sum(range(1000000))
# 프로파일링
cProfile.run('slow_function()', 'profile_stats')
# 결과 분석
p = pstats.Stats('profile_stats')
p.sort_stats('cumulative')
p.print_stats(10)
cProfile 결과에서 tottime은 함수 자체에서 쓴 시간, cumtime은 그 함수가 호출한 하위 함수까지 포함한 시간입니다. cumulative로 정렬하면 “어느 상위 흐름이 느린가”를, tottime으로 정렬하면 “실제로 CPU를 먹는 함수가 무엇인가”를 볼 수 있으므로 두 가지를 모두 확인하는 편이 좋습니다. cProfile은 함수 호출마다 기록하는 방식이라 작은 함수를 수백만 번 호출하는 코드에서는 프로파일러 오버헤드로 결과가 왜곡될 수 있습니다. 실행 중인 프로세스에 붙여서 오버헤드 없이 샘플링하고 싶다면 py-spy top --pid 1234나 py-spy record가 유용하며, 운영 서버의 느린 요청을 조사할 때 특히 편리합니다.
line_profiler (줄 단위 프로파일링):
# pip install line_profiler
@profile
def my_function():
total = 0
for i in range(1000000): # 이 줄이 느림
total += i
return total
# 실행
# kernprof -l -v script.py
JavaScript 프로파일링
Chrome DevTools:
// 1. Chrome DevTools 열기 (F12)
// 2. Performance 탭
// 3. Record 버튼 클릭
// 4. 작업 수행
// 5. Stop 버튼 클릭
// 6. Flame Chart 분석
function slowFunction() {
let total = 0;
for (let i = 0; i < 1000000; i++) {
total += i;
}
return total;
}
console.time('slowFunction');
slowFunction();
console.timeEnd('slowFunction');
// slowFunction: 5.234ms
Node.js 프로파일링:
# V8 프로파일러
node --prof script.js
node --prof-process isolate-*.log > processed.txt
# Clinic.js
npm install -g clinic
clinic doctor -- node script.js
console.time은 코드 한 구간의 경과 시간을 빠르게 확인하는 용도로는 좋지만, 한 번의 측정은 JIT 최적화 전후, 가비지 컬렉션 발생 여부에 따라 크게 흔들립니다. 같은 코드를 여러 번 반복해 측정하고 첫 몇 번의 결과는 버리는 것이 기본입니다. node --prof가 만드는 로그는 읽기 어려워서, 요즘은 node --cpu-prof로 .cpuprofile 파일을 만들어 Chrome DevTools에서 열거나, node --inspect로 DevTools를 연결해 브라우저와 같은 화면으로 분석하는 경우가 많습니다. Node.js 서버가 느릴 때는 CPU보다 이벤트 루프가 동기 작업(큰 JSON 파싱, 동기 파일 I/O)에 막혀 있는 경우가 흔한데, Clinic.js의 doctor가 이런 이벤트 루프 지연을 진단해 줍니다.
알고리즘 최적화
시간복잡도 개선
예제 1: 중복 찾기
# ❌ O(n²) - 느림
def find_duplicates(arr):
duplicates = []
for i in range(len(arr)):
for j in range(i + 1, len(arr)):
if arr[i] == arr[j] and arr[i] not in duplicates:
duplicates.append(arr[i])
return duplicates
# ✅ O(n) - 빠름
def find_duplicates(arr):
seen = set()
duplicates = set()
for x in arr:
if x in seen:
duplicates.add(x)
seen.add(x)
return list(duplicates)
# 성능 차이: 10만 개 배열
# O(n²): 약 50억 번 비교 (n²/2)
# O(n): 10만 번 순회 + 해시 조회
첫 번째 버전에는 눈에 잘 띄지 않는 비용이 하나 더 있습니다. arr[i] not in duplicates는 리스트에서 찾는 선형 탐색이라, 중복이 많을수록 이중 루프 안에서 또 한 번 O(k) 탐색을 합니다. 파이썬에서 in 연산자는 리스트에서는 O(n), 집합과 딕셔너리에서는 평균 O(1)이라는 차이를 기억해 두면 이런 숨은 비용을 금방 찾을 수 있습니다. 두 번째 버전은 결과를 set으로 모았다가 list로 바꾸므로 순서가 보장되지 않는다는 점도 달라집니다. 처음 등장한 순서가 필요하다면 dict.fromkeys()나 순서를 기록하는 리스트를 함께 써야 합니다.
예제 2: 두 수의 합
// ❌ O(n²)
vector<pair<int,int>> twoSum(vector<int>& arr, int target) {
vector<pair<int,int>> result;
for (int i = 0; i < arr.size(); i++) {
for (int j = i + 1; j < arr.size(); j++) {
if (arr[i] + arr[j] == target) {
result.push_back({i, j});
}
}
}
return result;
}
// ✅ O(n) - 해시맵 사용
vector<pair<int,int>> twoSum(vector<int>& arr, int target) {
unordered_map<int, int> seen;
vector<pair<int,int>> result;
for (int i = 0; i < arr.size(); i++) {
int complement = target - arr[i];
if (seen.find(complement) != seen.end()) {
result.push_back({seen[complement], i});
}
seen[arr[i]] = i;
}
return result;
}
해시맵 버전은 “지금 보는 수와 더해서 target이 되는 수(complement)를 이미 봤는가”를 O(1)로 확인합니다. 두 버전의 결과가 완전히 같지는 않다는 점에 주의해야 합니다. 이중 루프는 조건을 만족하는 모든 쌍을 찾지만, 해시맵 버전은 같은 값이 여러 번 나오면 seen[arr[i]] = i가 마지막 인덱스로 덮어쓰기 때문에 일부 쌍을 놓칩니다(예: [1, 1, 1]에서 target 2). 최적화 후에도 결과가 같은지 테스트로 확인하는 과정이 반드시 필요한 이유입니다. seen.find(complement)로 찾은 뒤 다시 seen[complement]로 접근하면 해시 계산을 두 번 하므로, 찾은 이터레이터(it->second)를 재사용하는 편이 조금 더 효율적입니다.
캐싱 (메모이제이션)
# ❌ 중복 계산
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# fibonacci(40): 몇 초 소요
# ✅ 메모이제이션
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# fibonacci(40): 0.001초
단순 재귀 피보나치는 같은 값을 반복 계산해서 호출 횟수가 약 1.6ⁿ으로 늘어나고, fibonacci(40)만 해도 수억 번의 호출이 일어납니다. lru_cache는 인자를 키로 결과를 저장해 각 n을 한 번만 계산하게 만들어 O(n)으로 바꿉니다. 메모이제이션은 순수 함수(같은 인자에 항상 같은 결과, 부수 효과 없음)에만 써야 하며, 인자가 리스트나 딕셔너리처럼 해시할 수 없는 타입이면 TypeError: unhashable type: 'list'가 납니다. maxsize=None은 캐시 크기에 제한이 없다는 뜻이라, 서로 다른 인자로 수백만 번 호출되는 함수에 쓰면 메모리가 계속 늘어납니다. 웹 서버처럼 오래 도는 프로세스에서는 maxsize를 정하거나 만료 시간이 있는 캐시를 쓰는 것이 안전합니다.
메모리 최적화
C++ 메모리 최적화
// ❌ 불필요한 복사
void process(vector<int> data) { // 복사 발생
// ...
}
// ✅ 참조 사용
void process(const vector<int>& data) { // 복사 없음
// ...
}
// ❌ 작은 객체를 힙에 할당
for (int i = 0; i < 1000000; i++) {
int* p = new int(i); // 느림
delete p;
}
// ✅ 스택 사용
for (int i = 0; i < 1000000; i++) {
int value = i; // 빠름
}
// ❌ 빈번한 재할당
vector<int> vec;
for (int i = 0; i < 1000000; i++) {
vec.push_back(i); // 재할당 발생
}
// ✅ 미리 예약
vector<int> vec;
vec.reserve(1000000); // 재할당 방지
for (int i = 0; i < 1000000; i++) {
vec.push_back(i);
}
힙 할당이 비싼 이유는 할당자가 적당한 크기의 빈 블록을 찾고, 스레드 간 동기화를 하고, 나중에 해제할 때 다시 목록에 돌려놓는 작업을 매번 하기 때문입니다. 게다가 여기저기 흩어진 힙 객체는 CPU 캐시에서 연속으로 읽을 수 없어 접근 자체도 느려집니다. 다만 위의 new int(i) 예제는 최적화 컴파일러가 할당과 해제를 통째로 없앨 수도 있는 단순한 경우라, 실제 코드에서 문제가 되는 것은 루프 안에서 std::string이나 std::vector 임시 객체를 계속 만들었다 버리는 패턴입니다. 루프 바깥에 버퍼를 하나 만들어 clear()하고 재사용하면 할당을 크게 줄일 수 있습니다. vector는 용량을 배수로 늘려서 push_back이 평균 O(1)이므로 reserve의 효과는 원소 수가 많거나 원소의 이동 비용이 클 때 주로 나타납니다.
Python 메모리 최적화
# ❌ 리스트 (메모리 많이 사용)
numbers = [i for i in range(1000000)] # 36MB
# ✅ 제너레이터 (메모리 절약)
numbers = (i for i in range(1000000)) # 200 bytes
# ❌ 문자열 연결 (느림)
result = ""
for i in range(10000):
result += str(i) # 매번 새 문자열 생성
# ✅ join 사용 (빠름)
result = "".join(str(i) for i in range(10000))
# ❌ 전역 변수 (느림)
global_var = 0
def increment():
global global_var
global_var += 1
# ✅ 지역 변수 (빠름)
def increment(var):
return var + 1
리스트와 제너레이터의 차이는 결과를 한꺼번에 만들어 두느냐, 필요할 때 하나씩 만드느냐입니다. 100만 개 정수 리스트는 포인터 배열(약 8MB)과 정수 객체들(각 28바이트)로 약 36MB를 쓰지만, 제너레이터는 “다음 값을 계산하는 방법”만 들고 있어 크기가 거의 일정합니다. 대신 제너레이터는 한 번만 순회할 수 있고, len()이나 인덱싱이 안 되며, 두 번째로 순회하면 아무것도 나오지 않습니다. 결과를 여러 번 써야 하는데 제너레이터로 바꾸면 두 번째 사용에서 빈 결과가 나오는 버그가 생기므로, 대용량 데이터를 한 번 흘려보내는 처리(파일 줄 단위 처리 등)에 쓰는 것이 맞습니다.
문자열 +=는 문자열이 불변이라 원칙적으로는 매번 새 문자열을 만듭니다. CPython은 참조가 하나뿐인 문자열에 한해 제자리에서 늘리는 최적화를 해 주기 때문에 위 예제가 생각보다 빠르게 돌기도 하지만, 이는 구현 세부 사항이라 PyPy 등 다른 구현이나 조건이 조금만 달라져도 사라집니다. "".join()은 전체 길이를 먼저 계산해 한 번에 할당하므로 어떤 환경에서든 안정적으로 빠릅니다. 전역 변수가 느린 이유는 지역 변수는 배열 인덱스로 바로 접근(LOAD_FAST)하지만 전역 변수는 딕셔너리 조회(LOAD_GLOBAL)를 거치기 때문인데, 차이가 의미 있는 것은 수백만 번 도는 루프 안뿐입니다.
Java 메모리 최적화
// ❌ 불필요한 객체 생성
for (int i = 0; i < 1000000; i++) {
String s = new String("hello"); // 느림
}
// ✅ 문자열 리터럴 사용
for (int i = 0; i < 1000000; i++) {
String s = "hello"; // 빠름 (String Pool)
}
// ❌ StringBuilder 없이 연결
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 느림
}
// ✅ StringBuilder 사용
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();
new String("hello")는 문자열 풀에 이미 있는 "hello"를 복사해 매번 새 객체를 힙에 만듭니다. 대부분의 JVM은 이런 짧은 수명의 객체를 매우 빠르게 할당하고 수거하지만(젊은 세대 GC), 루프가 수백만 번 돌면 GC 횟수가 늘어나 지연 시간에 영향을 줍니다. 루프 안의 result += i는 String이 불변이라 반복할 때마다 지금까지의 내용을 새 문자열로 복사하므로 전체 비용이 O(n²)입니다. 컴파일러가 + 연산을 내부적으로 최적화하더라도 그것은 한 문장 안의 연결에만 적용되고 루프를 건너서는 적용되지 않습니다. StringBuilder는 내부 버퍼를 배수로 늘려 가며 제자리에서 이어 붙이므로 O(n)이고, 최종 길이를 알면 new StringBuilder(예상크기)로 재할당도 줄일 수 있습니다.
언어별 최적화
C++ 최적화
컴파일러 최적화:
# 최적화 레벨
g++ -O0 main.cpp # 최적화 없음 (디버그)
g++ -O1 main.cpp # 기본 최적화
g++ -O2 main.cpp # 권장 최적화
g++ -O3 main.cpp # 공격적 최적화
# 추가 옵션
g++ -O3 -march=native -flto main.cpp
# -march=native: CPU 최적화
# -flto: Link Time Optimization
인라인 함수:
// ❌ 함수 호출 오버헤드
int add(int a, int b) {
return a + b;
}
// inline 키워드: 최적화 빌드에서는 위 함수도 이미 인라인됨
inline int add(int a, int b) {
return a + b;
}
// 람다: 호출 대상이 타입으로 고정되어 인라인되기 쉬움 (보장은 아님)
auto add = [](int a, int b) { return a + b; };
현대 C++ 컴파일러에서 inline 키워드는 성능 힌트라기보다 “헤더에 정의해도 여러 번 정의 오류가 나지 않게 해 달라”는 링크 규칙에 가깝습니다. -O2 이상에서는 컴파일러가 add 같은 작은 함수를 키워드와 상관없이 인라인하고, 반대로 inline을 붙인 큰 함수를 인라인하지 않기도 합니다. 인라인이 실제로 막히는 경우는 함수 정의가 다른 .cpp 파일에 있어 컴파일러가 본문을 볼 수 없을 때인데, 이때 도움이 되는 것은 키워드가 아니라 앞에서 본 -flto(링크 시간 최적화)입니다. 람다가 인라인되기 쉬운 이유는 각 람다가 고유한 타입이라 std::sort 같은 템플릿에 넘겼을 때 호출 대상이 컴파일 시점에 정해지기 때문이며, 같은 람다라도 std::function에 담으면 간접 호출이 되어 인라인되지 않을 수 있습니다.
캐시 친화적 코드:
// ❌ 캐시 미스 많음 (열 우선 접근)
for (int j = 0; j < N; j++) {
for (int i = 0; i < N; i++) {
matrix[i][j] = 0;
}
}
// ✅ 캐시 친화적 (행 우선 접근)
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
matrix[i][j] = 0;
}
}
// 성능 차이: 10000×10000 행렬처럼 캐시보다 큰 경우
// 열 우선: 매 접근이 다른 캐시 라인 → 캐시 미스가 대부분
// 행 우선: 한 캐시 라인의 원소를 연속으로 사용 → 크게 빠름
CPU는 메모리를 한 바이트씩이 아니라 캐시 라인(보통 64바이트) 단위로 가져옵니다. C++의 2차원 배열은 행 단위로 연속 저장되므로, 행 우선으로 순회하면 한 번 가져온 캐시 라인의 int 16개를 모두 쓰고 다음 라인으로 넘어가고, 하드웨어 프리페처가 다음 라인을 미리 가져오기도 쉽습니다. 열 우선으로 순회하면 매 접근이 다른 행, 즉 다른 캐시 라인을 건드리므로 가져온 64바이트 중 4바이트만 쓰고 버리게 됩니다. 알고리즘의 복잡도는 똑같이 O(N²)인데도 실행 시간이 몇 배 차이 나는 이유가 이것이며, “빅오가 같아도 메모리 접근 패턴이 성능을 결정한다”는 대표적인 예입니다. 같은 원리로 연결 리스트보다 vector가, 포인터로 연결된 객체 그래프보다 연속 배열에 필드별로 담는 구조(SoA)가 순회 성능에서 유리한 경우가 많습니다.
Python 최적화
리스트 컴프리헨션:
# ❌ 느림
result = []
for i in range(1000000):
result.append(i * 2)
# ✅ 빠름 (append 메서드 조회·호출이 없음)
result = [i * 2 for i in range(1000000)]
# ✅ 메모리 절약 (제너레이터: 리스트를 만들지 않고 필요할 때 하나씩 계산)
result = (i * 2 for i in range(1000000))
내장 함수 사용:
# ❌ 느림
total = 0
for i in range(1000000):
total += i
# ✅ 빠름 (반복이 C로 구현된 내장 함수 안에서 일어남)
total = sum(range(1000000))
NumPy 사용:
import numpy as np
# ❌ Python 루프 (느림)
arr = list(range(1000000))
result = [x * 2 for x in arr]
# 원소마다 파이썬 객체 생성·타입 확인
# ✅ NumPy (빠름)
arr = np.arange(1000000)
result = arr * 2
# 연속된 C 배열을 한 번에 처리 (벡터화)
파이썬 코드가 느린 근본적인 이유는 x * 2 한 번에도 타입 확인, 연산자 찾기, 결과 객체 할당, 참조 카운트 갱신이 모두 인터프리터 수준에서 일어나기 때문입니다. 리스트 컴프리헨션과 내장 함수는 이 반복의 일부를 C 코드 안으로 옮겨 오버헤드를 줄이고, NumPy는 데이터 자체를 파이썬 객체가 아닌 연속된 기계어 수준의 배열로 저장해 반복 전체를 C(그리고 CPU의 SIMD 명령)로 처리합니다. 그래서 NumPy는 배열 전체에 같은 연산을 적용할 때 수십 배 이상 빠르지만, for x in arr:처럼 NumPy 배열을 파이썬 루프로 하나씩 꺼내면 원소마다 파이썬 객체로 변환하는 비용이 추가되어 오히려 일반 리스트보다 느려집니다. NumPy를 도입했는데 빨라지지 않았다면 대부분 이 경우입니다. 벡터화하기 어려운 반복 로직이라면 Numba나 Cython으로 해당 함수만 컴파일하는 방법도 있습니다.
Java 최적화
Stream vs 반복문:
List<Integer> numbers = IntStream.range(0, 1000000)
.boxed()
.collect(Collectors.toList());
// ❌ Stream (느림)
long sum = numbers.stream()
.mapToInt(Integer::intValue)
.sum();
// 스트림 파이프라인 객체 생성 + 언박싱
// ✅ 반복문 (빠름)
long sum = 0;
for (int num : numbers) {
sum += num;
}
// 파이프라인 오버헤드 없음 (JIT 워밍업 후에는 차이가 줄 수 있으니 JMH로 측정)
오토박싱 회피:
// ❌ 오토박싱 (느림)
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
list.add(i); // int → Integer 변환
}
// ✅ 원시 타입 배열 (빠름)
int[] arr = new int[1000000];
for (int i = 0; i < 1000000; i++) {
arr[i] = i;
}
List<Integer>의 각 원소는 헤더를 가진 별도의 Integer 객체이고, 리스트에는 그 객체를 가리키는 참조만 저장됩니다. 그래서 int[]는 원소당 4바이트인 데 비해 List<Integer>는 참조와 객체 헤더까지 원소당 대략 1620바이트를 쓰고, 순회할 때도 참조를 따라 흩어진 객체를 읽어야 해서 캐시 효율이 떨어집니다. 127 범위만 캐시되므로 그 밖의 값은 매번 새 객체가 만들어진다는 점도 알아 두면 좋습니다. 같은 이유로 Integer는 -128Integer 두 개를 ==로 비교하면 캐시 범위 안에서는 true, 밖에서는 false가 되는 유명한 버그가 생깁니다. 대량의 숫자를 다룬다면 원시 배열이나 IntStream, 또는 Eclipse Collections·fastutil 같은 원시 타입 컬렉션 라이브러리가 적합합니다.
JavaScript 최적화
배열 메서드 최적화:
const arr = Array.from({ length: 1000000 }, (_, i) => i);
// ❌ 느림
let sum = 0;
arr.forEach(x => sum += x);
// 원소마다 콜백 호출 (JIT이 인라인하면 차이가 줄어듦)
// ✅ 빠름
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}
// 콜백 없이 인덱스로 직접 접근
// ✅ 간결함 (내장 함수, 역시 콜백 호출이 있음)
const sum = arr.reduce((acc, x) => acc + x, 0);
V8 같은 최신 JavaScript 엔진은 자주 실행되는 코드를 최적화 컴파일러로 다시 컴파일하면서 forEach의 콜백을 인라인하기도 하므로, 이 차이는 엔진 버전과 코드 모양에 따라 크게 달라집니다. 수백만 번 도는 핫 루프가 아니라면 읽기 쉬운 reduce나 for...of를 쓰는 편이 낫고, 측정해서 병목으로 확인된 곳만 인덱스 루프로 바꾸는 것이 합리적입니다. JavaScript에서 더 큰 성능 차이를 만드는 것은 대개 루프 문법이 아니라 객체의 모양(hidden class)을 일정하게 유지하는 것입니다. 같은 생성 위치에서 만든 객체에 속성을 나중에 추가하거나 순서를 바꿔 넣으면 엔진의 인라인 캐시가 무력화되어 속성 접근이 느려집니다.
객체 생성 최적화:
// push 방식: V8에서는 대부분 충분히 빠름
const objects = [];
for (let i = 0; i < 100000; i++) {
objects.push({ id: i, name: `User${i}` });
}
// 미리 할당: 구멍 난(holey) 배열로 시작해 항상 빠르지는 않음 → 측정 필요
const objects = new Array(100000);
for (let i = 0; i < 100000; i++) {
objects[i] = { id: i, name: `User${i}` };
}
C++의 reserve와 비슷해 보이지만 JavaScript에서는 결과가 다를 수 있습니다. V8은 배열을 원소 종류에 따라 내부적으로 구분해 최적화하는데, new Array(100000)은 빈 칸(hole)이 있는 배열로 시작해 “holey” 종류로 분류되고, 이 표시는 나중에 모든 칸을 채워도 되돌아가지 않습니다. holey 배열은 원소를 읽을 때마다 빈 칸 여부와 프로토타입 체인을 확인해야 해서, 이후의 순회가 오히려 조금 느려질 수 있습니다. push는 내부 버퍼를 배수로 늘려 가므로 평균 O(1)이고 대부분의 경우 충분히 빠릅니다. 이 예제처럼 “직관적으로 빠를 것 같은” 최적화가 엔진 내부 사정 때문에 반대로 작동하는 경우가 있다는 점이, 이 글 전체에서 측정을 먼저 강조하는 이유입니다. 이 객체 생성 비용 자체가 병목이라면 배열 할당 방식보다 객체 수를 줄이거나(필요한 필드만 만들기) 재사용하는 쪽이 효과가 큽니다.
정리
최적화 체크리스트
측정:
-
프로파일러로 병목 확인
-
실행 시간 측정
-
메모리 사용량 측정 알고리즘:
-
시간복잡도 개선 (O(n²) → O(n))
-
적절한 자료구조 선택
-
캐싱/메모이제이션 메모리:
-
불필요한 복사 제거
-
메모리 누수 확인
-
객체 재사용 언어별:
-
C++: 컴파일러 최적화, 인라인, 캐시 친화적
-
Python: 내장 함수, NumPy, 제너레이터
-
Java: 오토박싱 회피, StringBuilder
-
JavaScript: 배열 메서드, 객체 풀
핵심 원칙
- 측정 먼저: 추측하지 말고 측정
- 병목 집중: 80/20 법칙
- 알고리즘 우선: 언어보다 알고리즘
- 가독성 유지: 과도한 최적화 금지
다음 단계
각 언어의 자세한 최적화 기법은 아래 글을 참고하세요:
자주 묻는 질문 (FAQ)
Q. Python 코드에서 느린 부분을 어떻게 찾아내나요?
A. 먼저 python -m cProfile script.py나 cProfile.run으로 함수 단위 실행 시간을 측정하고, pstats로 cumulative 기준 정렬해 상위 함수를 확인합니다. 병목 함수를 찾았다면 line_profiler의 @profile로 줄 단위 시간을 보면서 어느 반복문이 문제인지 좁힙니다. 추측으로 코드를 고치기보다 측정 결과에서 가장 큰 비중을 차지하는 부분부터 최적화하고, 고친 뒤 다시 측정해 효과를 검증해야 합니다.
같이 보면 좋은 글
- Valgrind Memcheck로 C++ 메모리 누수 찾아내기
- Node.js 성능 최적화 | 클러스터링, 캐싱, 프로파일링
- C++ 성능 최적화 순서: 복사 제거, 할당 줄이기, 캐시 지역성, 컴파일러 옵션