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 등 이미 압축된 형식은 추가 압축 효과가 미미하다.

Efficient Pagination for Large Datasets

페이지네이션은 대량의 데이터를 관리 가능한 “페이지"로 분할하여 서버 부하를 줄이고 응답 시간을 개선한다.

페이지네이션 메커니즘

  • 오프셋 기반 페이지네이션: 가장 일반적인 방식으로, 특정 오프셋부터 제한된 수의 레코드를 가져온다.
  • 커서 기반 페이지네이션: 마지막으로 검색된 항목의 고유 식별자를 사용한다.
  • 시간 기반 페이지네이션: 타임스탬프를 기준으로 데이터를 분할한다.

오프셋 기반 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) => {
  // 카테고리 목록 조회 및 응답
});

용어 정리

용어설명

참고 및 출처