Code Optimization#
백엔드 성능 최적화는 현대 웹 애플리케이션 개발에서 필수적인 요소이다. 사용자 경험을 향상시키고, 서버 자원을 효율적으로 활용하며, 확장성을 보장하기 위해서는 다양한 코드 최적화 기법을 적절히 적용해야 한다.
Streaming of Large Requests/Responses#
스트리밍의 중요성#
대용량 데이터를 처리할 때 전통적인 방식은 전체 데이터를 메모리에 로드한 후 처리하는 것이다.
이 방식은 다음과 같은 문제점을 갖고 있다:
- 메모리 사용량 급증으로 인한 서버 부하 증가
- 응답 지연 시간 증가
- 타임아웃 발생 가능성
- 클라이언트 대기 시간 증가
스트리밍 방식은 데이터를 작은 청크(chunk)로 나누어 순차적으로 처리함으로써 이러한 문제를 해결한다.
스트리밍 구현 예시#
Node.js에서의 파일 스트리밍:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| const fs = require('fs');
const http = require('http');
// 스트리밍 없이 파일 전송 (메모리에 전체 파일 로드)
http.createServer((req, res) => {
fs.readFile('./large-file.mp4', (err, data) => {
if (err) throw err;
res.writeHead(200, {'Content-Type': 'video/mp4'});
res.end(data);
});
}).listen(3000);
// 스트리밍 방식으로 파일 전송
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'video/mp4'});
const fileStream = fs.createReadStream('./large-file.mp4');
fileStream.on('error', (error) => {
console.error('Stream error:', error);
res.end();
});
// 파이프를 통해 파일 스트림을 응답 스트림으로 연결
fileStream.pipe(res);
}).listen(3001);
|
Spring Boot에서의 응답 스트리밍:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @RestController
public class StreamingController {
@GetMapping(value = "/download-large-file", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<StreamingResponseBody> downloadLargeFile() {
return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=large-file.csv")
.body(outputStream -> {
// 청크 단위로 데이터 생성 및 쓰기
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream))) {
for (int i = 0; i < 1_000_000; i++) {
writer.write("Line " + i + " with some data\n");
// 일정 간격으로 버퍼 플러시
if (i % 10_000 == 0) {
writer.flush();
}
}
}
});
}
}
|
스트리밍의 활용 사례#
- 대용량 파일 다운로드: 사용자가 다운로드를 시작하자마자 첫 번째 청크부터 전송 시작
- 실시간 로그 모니터링: 로그 데이터를 실시간으로 스트리밍하여 모니터링 시스템에 전달
- 비디오/오디오 스트리밍: 미디어 파일을 청크 단위로 전송하여 빠른 재생 시작
- 대용량 데이터베이스 쿼리 결과: 결과를 청크 단위로 클라이언트에 전송
- 대용량 CSV/JSON 파일 처리: 파일을 한 줄씩 처리하여 메모리 사용량 최소화
프로파일링의 중요성#
코드 프로파일링은 애플리케이션의 실행 흐름을 분석하여 성능 병목점을 찾아내는 과정이다.
다음과 같은 정보를 수집한다:
- 함수 호출 빈도 및 실행 시간
- 메모리 사용량 및 할당 패턴
- CPU 사용률
- I/O 작업 시간
- 데이터베이스 쿼리 실행 시간
프로파일링 도구#
Java 애플리케이션 프로파일링 도구
- JProfiler: 메모리, CPU 사용량, 스레드 상태 등 상세 분석
- VisualVM: JDK에 포함된 무료 프로파일링 도구
- YourKit: 메모리 누수, 스레드 분석에 탁월
- Spring Boot Actuator: 애플리케이션 메트릭 제공
Node.js 프로파일링 도구
- Node.js 내장 프로파일러:
--prof
플래그 사용 - Clinic.js: CPU, 메모리, 이벤트 루프 지연 등 분석
- New Relic: 실시간 성능 모니터링
- flamegraphs: 호출 스택 시각화
프로파일링 과정 예시#
Node.js 애플리케이션 프로파일링:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // 프로파일링할 간단한 Express 애플리케이션
const express = require('express');
const app = express();
// 비효율적인 함수 (의도적인 예시)
function inefficientCalculation(n) {
let result = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
result += i * j;
}
}
return result;
}
app.get('/efficient', (req, res) => {
const result = req.query.num * 2;
res.json({ result });
});
app.get('/inefficient', (req, res) => {
const num = parseInt(req.query.num) || 1000;
const result = inefficientCalculation(num);
res.json({ result });
});
app.listen(3000);
|
프로파일링 실행:
1
2
3
4
5
6
7
8
| # CPU 프로파일링 수행
node --prof server.js
# 부하 테스트 실행
ab -n 100 -c 10 "http://localhost:3000/inefficient?num=1000"
# 프로파일 결과 해석
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt
|
프로파일링 결과 분석 및 최적화#
프로파일링 결과를 분석하여 다음과 같은 최적화를 수행할 수 있다:
- 핫스팟 식별: 실행 시간이 오래 걸리는 함수 찾기
- 불필요한 함수 호출 제거: 중복 호출, 조건부 실행 등
- 알고리즘 개선: 시간 복잡도가 높은 알고리즘 교체
- 메모리 누수 식별 및 해결: 참조가 해제되지 않는 객체 찾기
- 비동기 처리 최적화: 블로킹 코드를 비동기로 전환
최적화 예시:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 최적화 전: O(n²) 시간 복잡도
function inefficientCalculation(n) {
let result = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
result += i * j;
}
}
return result;
}
// 최적화 후: O(1) 시간 복잡도
// 수학적 공식 사용: sum(i*j) for i,j from 0 to n-1 = (n-1)*n*(n-1)*n/4
function efficientCalculation(n) {
return (n - 1) * n * (n - 1) * n / 4;
}
|
Optimization of Algorithms and Data Structures Used#
시간 복잡도와 공간 복잡도의 중요성#
알고리즘의 효율성은 시간 복잡도(실행 시간)와 공간 복잡도(메모리 사용량)로 측정된다.
백엔드 성능을 최적화하기 위해서는 적절한 알고리즘과 데이터 구조를 선택하는 것이 중요하다.
알고리즘/데이터 구조 | 최적 사용 사례 | 피해야 할 사례 |
---|
해시 테이블 | 빠른 검색, 삽입, 삭제 (O(1)) | 순서가 중요한 경우 |
배열/리스트 | 순차 접근, 인덱스 기반 접근 | 빈번한 검색 작업 |
트리 (이진 검색 트리) | 정렬된 데이터 검색 (O(log n)) | 불균형 데이터 |
힙 | 우선순위 큐, 최대/최소값 빠른 접근 | 일반적인 검색 작업 |
그래프 | 네트워크 관계, 경로 탐색 | 단순 데이터 저장 |
알고리즘 최적화 사례#
정렬 알고리즘 최적화:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| // 최적화 전: 버블 정렬 (O(n²))
public void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 교환
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
// 최적화 후: 퀵 정렬 (평균 O(n log n))
public void quickSort(int[] arr, int low, int high) {
if (low < high) {
int partitionIndex = partition(arr, low, high);
quickSort(arr, low, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, high);
}
}
private int partition(int[] arr, int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
i++;
// 교환
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 피벗 위치 교환
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
return i + 1;
}
|
검색 알고리즘 최적화:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| # 최적화 전: 선형 검색 (O(n))
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1
# 최적화 후: 이진 검색 (O(log n)) - 정렬된 배열에서만 사용 가능
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
|
데이터 구조 최적화 사례#
리스트 대신 해시맵 사용:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 최적화 전: 배열에서 검색 (O(n))
function findUserByIdUsingArray(users, userId) {
for (let i = 0; i < users.length; i++) {
if (users[i].id === userId) {
return users[i];
}
}
return null;
}
// 최적화 후: 해시맵 사용 (O(1))
function findUserByIdUsingMap(userMap, userId) {
return userMap.get(userId) || null;
}
// 초기화
const userMap = new Map();
users.forEach(user => userMap.set(user.id, user));
|
Optimizing Critical Paths and Frequently Accessed Endpoints#
핵심 경로 식별#
웹 애플리케이션에서 일반적으로 핵심 경로(critical path)는 다음과 같다:
- 인증/로그인 처리: 사용자 세션 생성 및 관리
- 검색 기능: 사용자가 자주 사용하는 데이터 검색
- 결제 프로세스: 전자상거래 사이트의 결제 흐름
- 데이터 대시보드: 실시간 데이터 표시
- 파일 업로드/다운로드: 대용량 데이터 전송
엔드포인트 최적화 전략#
캐싱 적용#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| @RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
private final CacheManager cacheManager;
// 생성자...
@GetMapping("/{id}")
@Cacheable(value = "products", key = "#id")
public Product getProduct(@PathVariable Long id) {
// 이 결과는 캐싱됨 (동일한 ID로 재요청 시 DB 접근 없음)
return productService.findById(id);
}
@GetMapping("/popular")
@Cacheable(value = "popularProducts", sync = true)
public List<Product> getPopularProducts() {
// 계산 비용이 높은 인기 제품 목록 조회
return productService.findPopularProducts();
}
@PutMapping("/{id}")
@CachePut(value = "products", key = "#id")
public Product updateProduct(@PathVariable Long id, @RequestBody Product product) {
// 제품 업데이트 후 캐시도 갱신
return productService.update(id, product);
}
@DeleteMapping("/{id}")
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(@PathVariable Long id) {
// 제품 삭제 후 캐시에서도 제거
productService.delete(id);
}
}
|
데이터베이스 쿼리 최적화#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // 최적화 전: N+1 문제 발생
public List<Order> getOrdersWithItems() {
List<Order> orders = orderRepository.findAll();
// 각 주문마다 별도의 쿼리 실행 (N+1 문제)
for (Order order : orders) {
order.getItems().size(); // 지연 로딩 강제 실행
}
return orders;
}
// 최적화 후: 조인 쿼리 사용
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
public List<Order> findOrdersWithItemsByStatus(@Param("status") OrderStatus status);
|
비동기 처리#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| @RestController
public class AsyncController {
private final TaskService taskService;
@GetMapping("/process")
public CompletableFuture<ResponseEntity<String>> processAsync() {
return CompletableFuture.supplyAsync(() -> {
// 시간이 오래 걸리는 작업
taskService.performLongRunningTask();
return ResponseEntity.ok("Processing completed");
});
}
@GetMapping("/parallel-tasks")
public CompletableFuture<List<Result>> executeParallelTasks() {
CompletableFuture<Result> task1 = taskService.executeTask1();
CompletableFuture<Result> task2 = taskService.executeTask2();
CompletableFuture<Result> task3 = taskService.executeTask3();
// 모든 작업이 완료될 때까지 기다림
return CompletableFuture.allOf(task1, task2, task3)
.thenApply(v -> Stream.of(task1, task2, task3)
.map(CompletableFuture::join)
.collect(Collectors.toList()));
}
}
|
응답 압축#
1
2
3
4
| // Spring Boot application.properties
server.compression.enabled=true
server.compression.min-response-size=1024
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
|
Node.js Express:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const compression = require('compression');
const express = require('express');
const app = express();
// 응답 압축 미들웨어 적용
app.use(compression({
level: 6, // 압축 레벨 (0-9)
threshold: 1024, // 압축 적용을 위한 최소 크기 (바이트)
}));
app.get('/large-data', (req, res) => {
// 대용량 JSON 응답
const largeData = generateLargeData();
res.json(largeData);
});
|
Utilizing Compiled Languages like Go or Rust#
컴파일 언어의 장점#
컴파일 언어는 코드를 기계어로 미리 변환하여 실행 속도가 빠르고, 메모리 관리가 효율적이다. Go와 Rust는 백엔드 개발에 적합한 현대적인 컴파일 언어이다.
특성 | Go | Rust | Node.js (JavaScript) | Python |
---|
실행 속도 | 매우 빠름 | 매우 빠름 | 중간 | 느림 |
메모리 효율성 | 높음 | 매우 높음 | 중간 | 낮음 |
동시성 처리 | 고루틴 (경량 스레드) | 스레드 안전성 보장 | 이벤트 루프 | GIL 제한 |
컴파일 검사 | 정적 타입 | 강력한 타입 시스템 | 동적 타입 | 동적 타입 |
학습 곡선 | 중간 | 가파름 | 완만함 | 완만함 |
Go 언어 예시: 웹 서버#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
| package main
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
)
// 제품 정보 구조체
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
// 제품 데이터베이스 (예시용 인메모리 저장소)
var (
products = []Product{
{ID: 1, Name: "노트북", Price: 1299.99},
{ID: 2, Name: "스마트폰", Price: 799.99},
{ID: 3, Name: "헤드폰", Price: 199.99},
}
mu = sync.RWMutex{} // 동시성 제어를 위한 뮤텍스
)
// 모든 제품 조회 핸들러
func getProductsHandler(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(products)
}
// 병렬 작업 처리 핸들러
func processDataHandler(w http.ResponseWriter, r *http.Request) {
// 동시에 여러 데이터 소스에서 정보 수집
resultCh := make(chan map[string]interface{}, 3)
// 병렬 처리 시작
go fetchDataSource1(resultCh)
go fetchDataSource2(resultCh)
go fetchDataSource3(resultCh)
// 결과 수집
results := make([]map[string]interface{}, 0, 3)
for i := 0; i < 3; i++ {
result := <-resultCh
results = append(results, result)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
func fetchDataSource1(ch chan<- map[string]interface{}) {
// 첫 번째 데이터 소스에서 정보 가져오기 (시뮬레이션)
time.Sleep(100 * time.Millisecond)
ch <- map[string]interface{}{
"source": "db1",
"data": []string{"item1", "item2", "item3"},
}
}
// fetchDataSource2, fetchDataSource3 함수도 유사하게 구현...
func main() {
// 라우트 설정
http.HandleFunc("/products", getProductsHandler)
http.HandleFunc("/process", processDataHandler)
// 서버 시작
log.Println("서버 시작: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
|
Rust 언어 예시: 고성능 API 서버#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
// 제품 정보 구조체
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Product {
id: u32,
name: String,
price: f64,
}
// 애플리케이션 상태
struct AppState {
products: RwLock<Vec<Product>>,
}
// 모든 제품 조회 핸들러
async fn get_products(data: web::Data<Arc<AppState>>) -> impl Responder {
let products = data.products.read().unwrap();
HttpResponse::Ok().json(&*products)
}
// 제품 추가 핸들러
async fn add_product(
data: web::Data<Arc<AppState>>,
product: web::Json<Product>,
) -> impl Responder {
let mut products = data.products.write().unwrap();
products.push(product.into_inner());
HttpResponse::Created().finish()
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// 초기 제품 데이터
let products = vec![
Product { id: 1, name: "노트북".to_string(), price: 1299.99 },
Product { id: 2, name: "스마트폰".to_string(), price: 799.99 },
Product { id: 3, name: "헤드폰".to_string(), price: 199.99 },
];
// 공유 상태 생성
let app_state = Arc::new(AppState {
products: RwLock::new(products),
});
// 서버 시작
println!("서버 시작: http://localhost:8080");
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(app_state.clone()))
.route("/products", web::get().to(get_products))
.route("/products", web::post().to(add_product))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
|
컴파일 언어의 실제 적용 사례#
- 성능 중심 마이크로서비스: 특정 고성능이 필요한 서비스만 Go/Rust로 구현
- API 게이트웨이: 대량의 요청을 처리하는 엔트리 포인트
- 실시간 데이터 처리: 대용량 로그 분석, 실시간 통계 등
- 시스템 유틸리티: 백업, 모니터링 도구 등
- 레거시 시스템 대체: 성능 병목이 있는 기존 시스템 점진적 교체
Architectural Styles and Service Decomposition#
주요 백엔드 아키텍처 스타일#
모놀리식 아키텍처#
모든 구성요소가 단일 애플리케이션에 통합된 형태이다.
장점:
- 개발 및 배포 단순성
- 컴포넌트 간 직접 호출 가능
- 디버깅 용이성
단점:
- 확장성 제한
- 부분 장애가 전체 시스템 장애로 이어질 수 있음
- 기술 스택 선택 제한
마이크로서비스 아키텍처#
작고 독립적인 서비스들로 시스템을 분해하는 접근 방식이다.
장점:
- 서비스별 독립적 확장 가능
- 기술 스택 다양화 가능
- 장애 격리
- 팀별 독립적 개발 및 배포
단점:
- 분산 시스템 복잡성
- 서비스 간 통신 오버헤드
- 트랜잭션 관리 복잡성
- 운영 및 모니터링 복잡성
서비스 분해 전략#
도메인 기반 분해#
비즈니스 도메인별로 서비스를 분리하는 방식이다.
예시 (이커머스 플랫폼):
- 제품 서비스: 제품 카탈로그 관리
- 주문 서비스: 주문 처리 및 이력 관리
- 결제 서비스: 결제 처리 및 환불
- 배송 서비스: 배송 추적 및 관리
- 사용자 서비스: 사용자 인증 및 프로필 관리
기능적 분해#
기술적 기능별로 서비스를 분리하는 방식이다.
예시:
- 인증 서비스: 사용자 인증 및 권한 관리
- 알림 서비스: 이메일, SMS, 푸시 알림 발송
- 검색 서비스: 고급 검색 기능
- 파일 스토리지 서비스: 파일 업로드 및 관리
- 분석 서비스: 데이터 분석 및 리포팅
서비스 간 통신 최적화#
효율적인 서비스 간 통신은 마이크로서비스 아키텍처의 성능을 좌우하는 핵심 요소이다.
동기식 vs. 비동기식 통신#
동기식 통신(REST, gRPC):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // REST API 호출 예시 (Spring WebClient 사용)
@Service
public class OrderService {
private final WebClient webClient;
public OrderService(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.baseUrl("http://payment-service").build();
}
public Mono<OrderResponse> processOrder(Order order) {
return webClient.post()
.uri("/payments")
.body(Mono.just(new PaymentRequest(order.getId(), order.getAmount())), PaymentRequest.class)
.retrieve()
.bodyToMono(PaymentResponse.class)
.map(paymentResponse -> {
// 결제 응답 처리
return new OrderResponse(order.getId(), paymentResponse.getStatus());
});
}
}
|
비동기식 통신(메시지 큐):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| // Kafka 메시지 생산자 예시
@Service
public class OrderService {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public OrderService(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void processOrder(Order order) {
// 주문 처리 로직
OrderEvent orderEvent = new OrderEvent(order.getId(), OrderStatus.CREATED, order.getItems());
// 이벤트 발행 (비동기)
kafkaTemplate.send("order-events", order.getId().toString(), orderEvent)
.addCallback(
result -> log.info("Order event published: {}", order.getId()),
ex -> log.error("Failed to publish order event", ex)
);
}
}
// Kafka 메시지 소비자 예시 (다른 서비스에 구현)
@Service
public class PaymentService {
@KafkaListener(topics = "order-events")
public void processOrderEvent(OrderEvent orderEvent) {
// 주문 이벤트 처리
if (orderEvent.getStatus() == OrderStatus.CREATED) {
// 결제 처리 로직
processPayment(orderEvent);
// 결제 완료 이벤트 발행
PaymentEvent paymentEvent = new PaymentEvent(
orderEvent.getOrderId(),
PaymentStatus.COMPLETED
);
kafkaTemplate.send("payment-events", paymentEvent);
}
}
}
|
통신 최적화 전략#
- 서비스 메시(Service Mesh) 활용: Istio, Linkerd 등을 사용하여 통신 인프라 추상화
- 회로 차단기(Circuit Breaker) 패턴: 장애 확산 방지
- API 게이트웨이: 클라이언트와 서비스 사이의 중간 계층
- 로컬 캐싱: 반복적인 서비스 호출 방지
- 배치 요청: 여러 작은 요청을 하나의 큰 요청으로 통합
Managing Network Issues: Setting Appropriate Connection Timeouts and Implementing Efficient Retry Mechanisms#
연결 타임아웃 설정#
외부 시스템과의 통신에서 적절한 타임아웃 설정은 매우 중요하다. 타임아웃이 너무 짧으면 불필요한 재시도가 발생하고, 너무 길면 리소스가 낭비된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // RestTemplate 타임아웃 설정 예시
@Bean
public RestTemplate restTemplate() {
// 연결 타임아웃, 읽기 타임아웃 설정
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3000); // 3초
factory.setReadTimeout(5000); // 5초
return new RestTemplate(factory);
}
// WebClient 타임아웃 설정 예시
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) // 연결 타임아웃
.responseTimeout(Duration.ofSeconds(5)); // 응답 타임아웃
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
|
효율적인 재시도 전략#
네트워크 통신은 일시적인 장애가 발생할 수 있으므로, 적절한 재시도 메커니즘을 구현하는 것이 중요하다.
Spring Retry 활용 예시#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| @Configuration
@EnableRetry
public class RetryConfig {
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
// 지수 백오프 전략 설정 (재시도 간격이 점점 늘어남)
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(1000); // 초기 재시도 간격 (1초)
backOffPolicy.setMultiplier(2); // 간격 증가 승수
backOffPolicy.setMaxInterval(10000); // 최대 재시도 간격 (10초)
retryTemplate.setBackOffPolicy(backOffPolicy);
// 재시도 정책 설정
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(3); // 최대 재시도 횟수 (초기 시도 포함)
retryTemplate.setRetryPolicy(retryPolicy);
return retryTemplate;
}
}
@Service
public class ProductService {
private final RestTemplate restTemplate;
private final RetryTemplate retryTemplate;
// 생성자 주입...
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public Product getProductById(Long id) {
return restTemplate.getForObject("/products/" + id, Product.class);
}
// 수동 재시도 로직
public Order processOrder(OrderRequest request) {
return retryTemplate.execute(context -> {
try {
// 외부 결제 서비스 호출
PaymentResponse response = paymentService.processPayment(request.getPaymentInfo());
// 주문 완료 처리
return orderRepository.save(new Order(request, response.getTransactionId()));
} catch (HttpServerErrorException | SocketTimeoutException e) {
// 일시적 오류로 간주하고 재시도
log.warn("결제 처리 실패, 재시도 중...", e);
throw e;
} catch (Exception e) {
// 영구적 오류로 간주하고 재시도하지 않음
log.error("결제 처리 심각한 오류", e);
throw new NonRetryableException("결제 처리 불가", e);
}
});
}
}
|
재시도 시 주의사항#
- 멱등성(Idempotency) 보장: 동일한 요청을 여러 번 실행해도 결과가 동일해야 함
- 회로 차단기(Circuit Breaker) 결합: 지속적인 실패 시 재시도 중단
- 로깅 및 모니터링: 재시도 패턴 분석을 위한 로깅
- 백오프 전략: 지수 백오프로 서버 부하 감소
Minimizing Overhead Through Batch Processing#
배치 처리는 여러 작업을 그룹화하여 한 번에 처리함으로써 네트워크 및 처리 오버헤드를 줄이는 기법이다.
데이터베이스 배치 처리#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // 배치 삽입 예시 (JPA/Hibernate)
@Service
@Transactional
public class ProductImportService {
private final EntityManager entityManager;
private static final int BATCH_SIZE = 500;
// 생성자 주입...
public void importProducts(List<Product> products) {
for (int i = 0; i < products.size(); i++) {
entityManager.persist(products.get(i));
// BATCH_SIZE마다 플러시 및 클리어
if (i % BATCH_SIZE == 0) {
entityManager.flush();
entityManager.clear();
}
}
// 남은 항목 처리
entityManager.flush();
entityManager.clear();
}
}
|
API 요청 배치 처리#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // 프론트엔드에서 여러 API 요청을 하나로 묶는 예시
async function batchGetUserData(userIds) {
// 개별 요청 대신 배치 요청
const response = await fetch('/api/users/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds })
});
return response.json();
}
// 백엔드 배치 처리 API (Node.js/Express)
app.post('/api/users/batch', async (req, res) => {
const { userIds } = req.body;
// 단일 쿼리로 여러 사용자 조회
const users = await User.find({ _id: { $in: userIds } });
// ID를 키로 하는 맵 생성
const userMap = {};
users.forEach(user => {
userMap[user._id] = user;
});
res.json(userMap);
});
|
메시지 배치 처리#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| // Kafka 배치 생산자 설정 예시
@Bean
public ProducerFactory<String, EventData> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
// 배치 설정
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // 배치 크기 (바이트)
props.put(ProducerConfig.LINGER_MS_CONFIG, 50); // 배치 지연 시간 (ms)
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy"); // 압축 타입
return new DefaultKafkaProducerFactory<>(props);
}
// Kafka 배치 소비자 설정 예시
@Bean
public ConsumerFactory<String, EventData> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
// 배치 설정
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500); // 한 번에 가져올 최대 레코드 수
props.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1024 * 1024); // 최소 데이터 크기 (1MB)
props.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 500); // 최대 대기 시간 (ms)
return new DefaultKafkaConsumerFactory<>(props);
}
|
배치 처리의 장단점#
장점:
- 네트워크 및 I/O 오버헤드 감소
- 처리량 증가
- 리소스 사용 효율성 향상
단점:
- 복잡성 증가
- 지연 시간 증가 가능성
- 오류 처리 복잡성
종합적인 백엔드 최적화 사례 연구#
실제 상황에서는 여러 최적화 기법을 종합적으로 적용해야 한다.
다음은 고트래픽 전자상거래 플랫폼의 백엔드 최적화 사례이다.
초기 상태 문제점
- 제품 목록 페이지 로딩 속도 느림 (평균 2초)
- 결제 프로세스 중 간헐적인 타임아웃 발생
- 대규모 트래픽 발생 시 시스템 불안정
- 메모리 사용량 급증 현상
적용된 최적화 전략
- 데이터 액세스 최적화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| // 최적화 전: N+1 문제 발생
@GetMapping("/products/category/{categoryId}")
public List<ProductDto> getProductsByCategory(@PathVariable Long categoryId) {
List<Product> products = productRepository.findByCategoryId(categoryId);
// N+1 문제: 각 제품마다 개별 쿼리 발생
return products.stream()
.map(product -> {
ProductDto dto = new ProductDto(product);
dto.setCategoryName(product.getCategory().getName()); // 지연 로딩
dto.setInventoryStatus(inventoryService.getStatus(product.getId())); // 추가 쿼리
return dto;
})
.collect(Collectors.toList());
}
// 최적화 후: 조인 쿼리와 배치 조회
@GetMapping("/products/category/{categoryId}")
public List<ProductDto> getProductsByCategory(@PathVariable Long categoryId) {
// JOIN FETCH로 카테고리 정보도 함께 조회
List<Product> products = productRepository.findWithCategoryByCategoryId(categoryId);
// 제품 ID 목록 추출
List<Long> productIds = products.stream()
.map(Product::getId)
.collect(Collectors.toList());
// 재고 정보 배치 조회
Map<Long, InventoryStatus> inventoryMap = inventoryService.getStatusBatch(productIds);
// DTO 변환
return products.stream()
.map(product -> {
ProductDto dto = new ProductDto(product);
dto.setCategoryName(product.getCategory().getName()); // 이미 로딩됨
dto.setInventoryStatus(inventoryMap.get(product.getId())); // 맵에서 조회
return dto;
})
.collect(Collectors.toList());
}
|
- 캐싱 전략 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| @Configuration
@EnableCaching
public class CachingConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
// 다양한 TTL을 가진 캐시 설정
cacheManager.setCaches(Arrays.asList(
new CaffeineCache("products",
Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build()),
new CaffeineCache("categories",
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(12, TimeUnit.HOURS)
.build()),
new CaffeineCache("product-recommendations",
Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build())
));
return cacheManager;
}
}
@Service
public class ProductService {
// 제품 조회 캐싱
@Cacheable(value = "products", key = "#id")
public Product getProductById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// 인기 제품 목록 캐싱
@Cacheable(value = "product-recommendations", key = "'popular'")
public List<Product> getPopularProducts() {
// 복잡한 계산 또는 집계 쿼리
return productRepository.findPopularProducts();
}
// 제품 업데이트 시 캐시 갱신
@CachePut(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
// 제품 삭제 시 캐시 제거
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
}
|
- 비동기 처리 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| @Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final NotificationService notificationService;
private final AsyncTaskExecutor taskExecutor;
// 생성자 주입...
@Transactional
public OrderResult createOrder(OrderRequest orderRequest) {
// 1. 주문 생성 (동기식)
Order order = new Order(orderRequest);
order = orderRepository.save(order);
// 2. 재고 확인 및 예약 (동기식)
inventoryService.reserveStock(order.getItems());
// 3. 결제 처리 (동기식 - 핵심 프로세스)
PaymentResult paymentResult = paymentService.processPayment(order);
order.setPaymentStatus(paymentResult.getStatus());
order = orderRepository.save(order);
// 4. 이메일 알림 전송 (비동기식)
CompletableFuture.runAsync(() -> {
notificationService.sendOrderConfirmation(order);
}, taskExecutor);
// 5. 배송 처리 요청 (비동기식)
CompletableFuture.runAsync(() -> {
shippingService.initiateShipping(order);
}, taskExecutor);
// 6. 분석 데이터 수집 (비동기식)
CompletableFuture.runAsync(() -> {
analyticsService.recordOrderEvent(order);
}, taskExecutor);
return new OrderResult(order.getId(), paymentResult.getTransactionId());
}
}
|
- 스트리밍 처리 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
| @RestController
public class ReportingController {
private final OrderRepository orderRepository;
// 생성자 주입...
// 최적화 전: 전체 데이터를 메모리에 로드
@GetMapping("/reports/orders/csv")
public ResponseEntity<byte[]> downloadOrdersCsv() {
List<Order> orders = orderRepository.findAll(); // 모든 주문을 메모리에 로드
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try (CSVWriter writer = new CSVWriter(new OutputStreamWriter(outputStream))) {
// CSV 헤더
writer.writeNext(new String[] {"Order ID", "Date", "Customer", "Amount", "Status"});
// 데이터 행
for (Order order : orders) {
writer.writeNext(new String[] {
order.getId().toString(),
order.getCreatedAt().toString(),
order.getCustomerName(),
order.getTotalAmount().toString(),
order.getStatus().toString()
});
}
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=orders.csv")
.contentType(MediaType.parseMediaType("text/csv"))
.body(outputStream.toByteArray());
}
// 최적화 후: 스트리밍 처리
@GetMapping("/reports/orders/csv")
public ResponseEntity<StreamingResponseBody> streamOrdersCsv() {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=orders.csv")
.contentType(MediaType.parseMediaType("text/csv"))
.body(outputStream -> {
try (CSVWriter writer = new CSVWriter(new OutputStreamWriter(outputStream))) {
// CSV 헤더
writer.writeNext(new String[] {"Order ID", "Date", "Customer", "Amount", "Status"});
// 페이지 단위로 데이터 스트리밍
int pageSize = 1000;
int pageNumber = 0;
Page<Order> orderPage;
do {
orderPage = orderRepository.findAll(PageRequest.of(pageNumber++, pageSize));
for (Order order : orderPage.getContent()) {
writer.writeNext(new String[] {
order.getId().toString(),
order.getCreatedAt().toString(),
order.getCustomerName(),
order.getTotalAmount().toString(),
order.getStatus().toString()
});
}
// 각 페이지 처리 후 버퍼 플러시
writer.flush();
} while (orderPage.hasNext());
} catch (IOException e) {
throw new RuntimeException("CSV 생성 오류", e);
}
});
}
}
|
최적화 결과:
- 제품 목록 페이지 로딩 속도 개선: 2초 → 300ms (85% 감소)
- 결제 성공률 향상: 95% → 99.8%
- 서버 리소스 사용량 감소: CPU 사용률 70% → 30%
- 처리량 증가: 초당 트랜잭션 수 500 → 2000
용어 정리#
참고 및 출처#