Optimize API Response#
API 응답 최적화는 현대 웹 애플리케이션의 성능, 사용자 경험 및 자원 효율성을 크게 향상시키는 핵심 요소이다.
Enforcing Reasonable Payload Size Limits#
페이로드 크기는 API 성능에 직접적인 영향을 미친다. 대용량 데이터 전송은 네트워크 대역폭을 소모하고 서버 처리 시간을 증가시킨다.
페이로드 제한의 중요성#
- 네트워크 효율성: 작은 페이로드는 더 빠른 전송 시간을 의미한다. 연구에 따르면 모바일 환경에서 5MB 이상의 페이로드는 평균 응답 시간을 3-4초 증가시킨다.
- 서버 리소스 관리: 대형 페이로드를 처리할 때 서버의 메모리 사용량이 급증할 수 있다. 이는 특히 동시 요청이 많을 때 서버 과부하로 이어질 수 있다.
- 데이터베이스 효율성: 대형 데이터를 저장하고 검색하는 것은 데이터베이스 성능에 부담을 준다.
구현 전략#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Express.js에서 페이로드 크기 제한 설정 예제
const express = require('express');
const app = express();
// JSON 페이로드 크기를 1MB로 제한
app.use(express.json({ limit: '1mb' }));
// 폼 데이터 크기를 5MB로 제한
app.use(express.urlencoded({ extended: true, limit: '5mb' }));
// 특정 라우트에 대해 다른 제한 적용
app.post('/upload-profile-image', express.json({ limit: '2mb' }), (req, res) => {
// 프로필 이미지 처리 로직
});
|
모범 사례#
- 컨텐츠 유형별 제한: 이미지, 비디오, 텍스트 데이터에 대해 각기 다른 제한을 설정한다.
- 클라이언트 측 검증: 서버에 보내기 전에 클라이언트에서 파일 크기를 확인한다.
- 점진적 업로드: 대용량 파일은 청크(chunk) 단위로 분할하여 전송한다.
- 압축 권장: 가능한 경우 클라이언트 측에서 데이터 압축을 권장한다.
Enabling Compression for Responses#
데이터 압축은 전송되는 바이트 수를 감소시켜 네트워크 대역폭을 절약하고 응답 시간을 단축시킨다.
압축의 장점#
- 대역폭 감소: 압축은 전송되는 데이터 크기를 50-90% 감소시킬 수 있다.
- 응답 시간 개선: Facebook의 연구에 따르면 응답 압축으로 모바일 앱의 로딩 시간이 최대 35% 개선되었다.
- 확장성 향상: 같은 서버로 더 많은 요청을 처리할 수 있다.
주요 압축 알고리즘#
- Gzip: 가장 널리, 보편적으로 지원되는 압축 방식이다.
- Brotli: Google이 개발한 더 효율적인 압축 알고리즘으로, Gzip보다 평균 15-25% 더 나은 압축률을 제공한다.
- Deflate: 오래된 알고리즘이지만 여전히 일부 환경에서 사용된다.
구현 예제#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Node.js Express에서 압축 미들웨어 사용
const express = require('express');
const compression = require('compression');
const app = express();
// 압축 사용 설정
app.use(compression({
// 압축 수준 (0-9, 9가 최대 압축, 기본값은 6)
level: 6,
// 압축할 최소 바이트 (기본값 1KB)
threshold: 1024,
// 특정 MIME 타입만 압축
filter: (req, res) => {
const contentType = res.getHeader('Content-Type');
return /text|json|javascript|css|xml/i.test(contentType);
}
}));
|
압축 적용 시 고려사항#
- CPU 사용량: 압축은 CPU 리소스를 사용한다. 고압축 레벨은 더 많은 처리 시간이 필요하다.
- 동적 vs 정적 컨텐츠: 정적 컨텐츠는 미리 압축하여 저장할 수 있다.
- 컨텐츠 유형: 텍스트, JSON, HTML, CSS 및 JavaScript는 압축 혜택이 크다.
- 이미 압축된 형식: JPEG, PNG, PDF 등 이미 압축된 형식은 추가 압축 효과가 미미하다.
페이지네이션은 대량의 데이터를 관리 가능한 “페이지"로 분할하여 서버 부하를 줄이고 응답 시간을 개선한다.
페이지네이션 메커니즘#
- 오프셋 기반 페이지네이션: 가장 일반적인 방식으로, 특정 오프셋부터 제한된 수의 레코드를 가져온다.
- 커서 기반 페이지네이션: 마지막으로 검색된 항목의 고유 식별자를 사용한다.
- 시간 기반 페이지네이션: 타임스탬프를 기준으로 데이터를 분할한다.
오프셋 기반 VS 커서 기반 페이지네이션#
오프셋 기반 페이지네이션:
1
2
3
4
| -- 오프셋 기반 페이지네이션 SQL 예제
SELECT * FROM products
ORDER BY id
LIMIT 20 OFFSET 40; -- 3번째 페이지 (20개씩)
|
장점:
- 구현이 간단하다.
- 페이지 번호로 직접 이동할 수 있다.
단점:
- 오프셋이 커질수록 성능이 저하된다(DB는 오프셋까지의 모든 레코드를 스캔해야 함).
- 데이터 변경 시 결과가 일관되지 않을 수 있다.
커서 기반 페이지네이션:
1
2
3
4
5
| -- 커서 기반 페이지네이션 SQL 예제
SELECT * FROM products
WHERE id > 1042 -- 마지막으로 본 ID
ORDER BY id
LIMIT 20;
|
장점:
- 대규모 데이터셋에서 훨씬 효율적이다.
- 데이터가 변경되어도 일관된 결과를 제공한다.
- 인덱스를 효과적으로 활용한다.
단점:
- 구현이 더 복잡하다.
- 임의의 페이지로 직접 이동이 어렵다.
API 설계 모범 사례#
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 커서 기반 페이지네이션 API 응답 예제
{
"data": [
{ "id": 1043, "name": "Product A", … },
{ "id": 1044, "name": "Product B", … },
// …
],
"pagination": {
"next_cursor": "1062", // 다음 페이지의 시작점
"has_next_page": true,
"total_count": 1500 // 선택적으로 총 개수 제공
}
}
|
고급 페이지네이션 전략#
- 동적 페이지 크기: 클라이언트 환경(모바일 vs 데스크톱)에 따라 페이지 크기를 조정한다.
- 사전 로딩: 사용자가 다음 페이지로 이동할 가능성이 높을 때 미리 데이터를 로드한다.
- 스트리밍 응답: 매우 큰 데이터셋의 경우, 전체 응답을 기다리지 않고 데이터를 청크 단위로 스트리밍한다.
Minimising Unnecessary Processing or Expensive Computation on the Server#
서버의 계산 리소스는 한정되어 있으므로, 불필요한 처리를 제거하고 고비용 작업을 최적화하는 것이 중요하다.
최적화 전략#
결과 캐싱#
동일한 계산을 반복적으로 수행하는 대신 결과를 저장하고 재사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Node.js에서 메모리 캐싱 예제
const cache = new Map();
function expensiveCalculation(input) {
// 캐시에 결과가 있는지 확인
const cacheKey = JSON.stringify(input);
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
// 실제 계산 수행
console.time('calculation');
let result = 0;
// 복잡한 계산 시뮬레이션
for (let i = 0; i < 1000000; i++) {
result += Math.sqrt(i * input.value);
}
console.timeEnd('calculation');
// 결과 캐싱
cache.set(cacheKey, result);
return result;
}
|
분산 시스템에서는 Redis와 같은 외부 캐시 시스템을 활용할 수 있다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Redis를 이용한 캐싱 예제
const redis = require('redis');
const client = redis.createClient();
const { promisify } = require('util');
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);
async function expensiveCalculationWithRedis(input) {
const cacheKey = `calc:${JSON.stringify(input)}`;
// 캐시에서 확인
const cachedResult = await getAsync(cacheKey);
if (cachedResult) {
return JSON.parse(cachedResult);
}
// 계산 수행
const result = /* 복잡한 계산 */;
// 결과 캐싱 (60초 유효)
await setAsync(cacheKey, JSON.stringify(result), 'EX', 60);
return result;
}
|
계산 오프로딩#
일부 계산은 클라이언트 측에서 수행되거나 별도의 작업자 프로세스로 오프로드될 수 있다.
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
| // 백그라운드 작업 처리 예제 (Bull 큐 사용)
const Queue = require('bull');
const calculationQueue = new Queue('calculations');
// API 엔드포인트
app.post('/calculate', async (req, res) => {
const jobId = `calc-${Date.now()}`;
// 계산 작업을 큐에 추가
await calculationQueue.add({
id: jobId,
input: req.body,
userId: req.user.id
});
// 즉시 응답하고 작업 ID 반환
res.json({
jobId,
status: 'processing',
estimatedCompletionTime: '30s'
});
});
// 작업 상태 확인 엔드포인트
app.get('/calculation/:jobId', async (req, res) => {
const job = await calculationQueue.getJob(req.params.jobId);
// 작업 상태 반환
});
// 백그라운드 작업자에서 처리
calculationQueue.process(async (job) => {
// 실제 계산 수행
const result = /* 복잡한 계산 */;
// 결과 저장
return result;
});
|
조건부 계산#
모든 요청에 대해 모든 필드를 계산하는 대신, 필요한 경우에만 계산을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // GraphQL 예제 - 클라이언트가 요청한 필드만 계산
const resolvers = {
User: {
// 기본 필드는 DB에서 직접 가져옴
id: (user) => user.id,
name: (user) => user.name,
// 계산 비용이 높은 필드는 요청될 때만 계산
friendsCount: async (user, args, context) => {
// 요청에 포함된 경우에만 계산
const count = await context.db.friendship.count({
where: { userId: user.id }
});
return count;
},
// 매우 비용이 높은 필드
recommendedProducts: async (user) => {
// 복잡한 추천 알고리즘 실행
}
}
};
|
데이터베이스 쿼리 최적화#
복잡한 계산의 상당 부분은 데이터베이스에서 발생한다. 쿼리를 최적화하면 서버 부하를 크게 줄일 수 있다.
1
2
3
4
5
6
7
8
9
10
11
| -- 비효율적인 쿼리
SELECT * FROM orders
WHERE customer_id = 123
ORDER BY created_at DESC;
-- 최적화된 쿼리
SELECT id, status, total_amount, created_at
FROM orders
WHERE customer_id = 123
ORDER BY created_at DESC
LIMIT 10;
|
성능 모니터링 및 병목 식별#
서버의 불필요한 계산을 식별하려면 성능 모니터링이 필수적이다:
- 프로파일링 도구: Node.js의 경우 clinic.js, 0x 같은 도구를 사용한다.
- APM(애플리케이션 성능 모니터링) 솔루션: New Relic, Datadog 등을 활용한다.
- 로깅 및 메트릭스: 핵심 작업의 실행 시간을 로깅하고 추적한다.
API 응답 형식 최적화#
API 응답의 형식과 구조도 성능에 상당한 영향을 미친다.
JSON 최적화#
대부분의 API는 JSON 형식으로 응답한다.
JSON 구조를 최적화하면 응답 크기를 줄일 수 있다:
- 불필요한 데이터 제거: 클라이언트가 필요로 하지 않는 필드는 포함하지 않는다.
- 중첩 줄이기: 깊이 중첩된 구조는 파싱 비용이 증가한다.
- 일관된 명명 규칙: 짧고 의미 있는 속성 이름을 사용한다.
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
| // 최적화되지 않은 응답
{
"userData": {
"userIdentifier": "u123456",
"userFullName": "홍길동",
"userEmailAddress": "hong@example.com",
"userRegistrationDate": "2023-04-15T08:30:45Z",
"userLastLoginTimestamp": "2023-05-10T14:22:33Z",
"userPreferences": {
"userPreferredLanguage": "ko",
"userPreferredTheme": "dark",
"userNotificationSettings": {
"emailNotificationsEnabled": true,
"pushNotificationsEnabled": false
}
}
}
}
// 최적화된 응답
{
"user": {
"id": "u123456",
"name": "홍길동",
"email": "hong@example.com",
"registered": "2023-04-15T08:30:45Z",
"lastLogin": "2023-05-10T14:22:33Z",
"prefs": {
"lang": "ko",
"theme": "dark",
"notifications": {
"email": true,
"push": false
}
}
}
}
|
부분 응답 및 필드 선택#
클라이언트가 필요한 데이터만 요청할 수 있도록 하면 응답 크기를 줄일 수 있다:
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
| // Express.js에서 필드 선택 구현 예제
app.get('/users/:id', (req, res) => {
const userId = req.params.id;
const fields = req.query.fields ? req.query.fields.split(',') : null;
// 데이터베이스에서 사용자 정보 조회
getUserById(userId)
.then(user => {
// 요청된 필드만 포함
if (fields) {
const filteredUser = {};
fields.forEach(field => {
if (user[field] !== undefined) {
filteredUser[field] = user[field];
}
});
return res.json(filteredUser);
}
// 필드가 지정되지 않은 경우 전체 응답
res.json(user);
})
.catch(error => {
res.status(500).json({ error: error.message });
});
});
|
사용 예: /users/123?fields=id,name,email
응답 버전 관리#
API가 발전함에 따라 응답 형식도 변경될 수 있다. 버전 관리를 통해 클라이언트 호환성을 유지하면서도 응답을 최적화할 수 있다:
1
2
3
4
5
6
7
| app.get('/api/v1/users/:id', (req, res) => {
// 기존 형식의 응답
});
app.get('/api/v2/users/:id', (req, res) => {
// 최적화된 새 형식의 응답
});
|
고급 API 최적화 기법#
HTTP/2 및 HTTP/3 활용#
최신 HTTP 프로토콜은 성능 향상을 위한 다양한 기능을 제공한다:
- 멀티플렉싱: 단일 연결로 여러 요청/응답을 병렬 처리
- 헤더 압축: 요청/응답 헤더 크기 축소
- 서버 푸시: 클라이언트가 요청하기 전에 리소스 전송
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
| // Node.js에서 HTTP/2 서버 예제
const http2 = require('http2');
const fs = require('fs');
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
});
server.on('stream', (stream, headers) => {
const path = headers[':path'];
if (path === '/api/users') {
// 사용자 데이터 응답
stream.respond({
'content-type': 'application/json',
':status': 200
});
// 사용자 데이터를 응답하면서
stream.end(JSON.stringify({ users: […] }));
// 관련 리소스를 미리 푸시
const pushStream = stream.pushStream({
':path': '/api/user-preferences'
}, (err, pushStream) => {
pushStream.respond({
'content-type': 'application/json',
':status': 200
});
pushStream.end(JSON.stringify({ preferences: […] }));
});
}
});
server.listen(8443);
|
스트리밍 응답#
대용량 응답을 처리할 때는 전체 응답을 메모리에 로드하지 않고 청크 단위로 스트리밍하면 메모리 사용량을 줄이고 응답 시간을 단축할 수 있다:
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
| // Express.js와 MongoDB에서 스트리밍 응답 예제
app.get('/api/logs', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.write('{"logs":[');
let first = true;
const cursor = db.collection('logs').find().stream();
cursor.on('data', (log) => {
// 최초 항목이 아닌 경우 쉼표 추가
if (!first) {
res.write(',');
} else {
first = false;
}
res.write(JSON.stringify(log));
});
cursor.on('end', () => {
res.write(']}');
res.end();
});
cursor.on('error', (err) => {
// 오류 처리
res.end(`],"error":"${err.message}"}`);
});
// 클라이언트 연결이 끊어지면 스트림도 종료
req.on('close', () => {
cursor.close();
});
});
|
응답 캐싱 계층#
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
| // Redis를 사용한 API 응답 캐싱 예제
const express = require('express');
const redis = require('redis');
const { promisify } = require('util');
const app = express();
const client = redis.createClient();
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);
// 캐싱 미들웨어
async function cacheMiddleware(req, res, next) {
// URL을 캐시 키로 사용
const cacheKey = `api:${req.originalUrl}`;
try {
// 캐시에서 응답 확인
const cachedResponse = await getAsync(cacheKey);
if (cachedResponse) {
// 캐시된 응답이 있으면 즉시 반환
const data = JSON.parse(cachedResponse);
return res.json(data);
}
// 원본 res.json 메서드를 저장
const originalJson = res.json;
// res.json 메서드를 오버라이드하여 응답을 캐싱
res.json = function(data) {
// 응답 캐싱 (5분 유효)
setAsync(cacheKey, JSON.stringify(data), 'EX', 300)
.catch(err => console.error('캐싱 오류:', err));
// 원본 메서드 호출
return originalJson.call(this, data);
};
next();
} catch (error) {
console.error('캐시 미들웨어 오류:', error);
next();
}
}
// 읽기 전용 API에 캐싱 적용
app.get('/api/products', cacheMiddleware, (req, res) => {
// 제품 목록 조회 및 응답
});
app.get('/api/categories', cacheMiddleware, (req, res) => {
// 카테고리 목록 조회 및 응답
});
|
용어 정리#
참고 및 출처#