Real-time APIs

실시간 API의 개념과 중요성

실시간 API란 무엇인가?

실시간 API(Real-time API)는 클라이언트와 서버 간에 지속적인 연결을 유지하면서 데이터가 생성되거나 변경되는 즉시 자동으로 전송하는 API이다. 전통적인 RESTful API가 요청-응답 모델에 기반하여 클라이언트가 데이터를 명시적으로 요청해야 하는 것과 달리, 실시간 API는 새로운 정보가 발생하면 서버에서 클라이언트로 즉시 푸시(push)된다.

왜 실시간 API가 중요한가?

실시간 API는 다음과 같은 이유로 현대 애플리케이션 개발에서 중요한 위치를 차지한다:

  1. 즉각적인 사용자 경험: 사용자는 최신 데이터를 즉시 볼 수 있어 더 나은 사용자 경험을 제공한다.
  2. 리소스 효율성: 주기적인 폴링(polling)에 비해 네트워크 트래픽과 서버 부하를 줄일 수 있다.
  3. 양방향 통신: 클라이언트와 서버 간의 양방향 데이터 흐름을 지원한다.
  4. 확장성: 최신 실시간 기술은 수많은 동시 연결을 효율적으로 처리할 수 있다.

실시간 API 기술 및 프로토콜

실시간 API를 구현하기 위한 다양한 기술과 프로토콜이 있으며, 각각 고유한 특성과 장단점을 가지고 있다.

WebSocket

WebSocket은 가장 널리 사용되는 실시간 통신 프로토콜로, 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
// 클라이언트 측 WebSocket 구현 예시
const socket = new WebSocket('wss://api.example.com/realtime');

// 연결 이벤트 처리
socket.addEventListener('open', (event) => {
  console.log('WebSocket 연결이 열렸습니다');
  socket.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
});

// 메시지 수신 처리
socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('서버로부터 메시지 수신:', data);
  updateUI(data); // UI 업데이트 함수
});

// 오류 처리
socket.addEventListener('error', (event) => {
  console.error('WebSocket 오류 발생:', event);
});

// 연결 종료 처리
socket.addEventListener('close', (event) => {
  console.log('WebSocket 연결이 닫혔습니다', event.code, event.reason);
  // 재연결 로직 구현
  setTimeout(connectWebSocket, 5000);
});

장점:

단점:

Server-Sent Events (SSE)

SSE는 서버에서 클라이언트로의 단방향 통신을 위한 간단한 프로토콜로, 표준 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
// 클라이언트 측 SSE 구현 예시
const eventSource = new EventSource('https://api.example.com/events');

// 메시지 수신 처리
eventSource.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('이벤트 수신:', data);
  updateUI(data);
});

// 특정 이벤트 타입 처리
eventSource.addEventListener('update', (event) => {
  const updateData = JSON.parse(event.data);
  console.log('업데이트 이벤트:', updateData);
  handleUpdate(updateData);
});

// 오류 처리
eventSource.addEventListener('error', (event) => {
  console.error('SSE 오류 발생:', event);
  if (eventSource.readyState === EventSource.CLOSED) {
    // 연결이 닫힌 경우 재연결 시도
    setTimeout(() => {
      new EventSource('https://api.example.com/events');
    }, 5000);
  }
});

장점:

단점:

HTTP Long Polling

Long Polling은 전통적인 폴링의 효율성을 개선한 기법으로, 클라이언트가 서버에 요청을 보내고 새로운 데이터가 있을 때까지 연결을 유지한다.

 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
// 클라이언트 측 Long Polling 구현 예시
function longPoll() {
  fetch('https://api.example.com/poll', {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      'Last-Event-ID': lastEventId // 마지막으로 수신한 이벤트 ID
    },
    credentials: 'include'
  })
  .then(response => response.json())
  .then(data => {
    // 데이터 처리
    console.log('데이터 수신:', data);
    if (data.events && data.events.length > 0) {
      // 이벤트 처리
      data.events.forEach(event => {
        handleEvent(event);
        lastEventId = event.id; // 마지막 이벤트 ID 업데이트
      });
    }
    
    // 즉시 다음 Long Poll 요청 시작
    longPoll();
  })
  .catch(error => {
    console.error('Long Polling 오류:', error);
    // 오류 발생 시 잠시 대기 후 재시도
    setTimeout(longPoll, 5000);
  });
}

// 초기 Long Polling 시작
longPoll();

장점:

단점:

GraphQL Subscriptions

GraphQL은 쿼리 언어이지만, Subscriptions 기능을 통해 실시간 데이터 업데이트를 제공한다. 일반적으로 WebSocket 위에 구현된다.

 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
// Apollo Client를 사용한 GraphQL Subscription 예시
import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client';
import { WebSocketLink } from '@apollo/client/link/ws';
import { getMainDefinition } from '@apollo/client/utilities';
import { SubscriptionClient } from 'subscriptions-transport-ws';

// HTTP 링크 설정 (일반 쿼리 및 뮤테이션용)
const httpLink = new HttpLink({
  uri: 'https://api.example.com/graphql'
});

// WebSocket 링크 설정 (구독용)
const wsLink = new WebSocketLink(
  new SubscriptionClient('wss://api.example.com/graphql', {
    reconnect: true,
    connectionParams: {
      authToken: localStorage.getItem('token')
    }
  })
);

// 요청 유형에 따라 적절한 링크 선택
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink
);

// Apollo 클라이언트 설정
const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache()
});

// 구독 실행
const MESSAGES_SUBSCRIPTION = gql`
  subscription OnNewMessage {
    messageAdded {
      id
      content
      user {
        id
        name
      }
    }
  }
`;

// 컴포넌트에서 구독 사용
function ChatComponent() {
  const { data, loading, error } = useSubscription(MESSAGES_SUBSCRIPTION);
  
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  
  return (
    <div>
      <h3>New Message:</h3>
      <p>{data.messageAdded.content}</p>
      <p>From: {data.messageAdded.user.name}</p>
    </div>
  );
}

장점:

단점:

gRPC와 Protocol Buffers

gRPC는 구글이 개발한 고성능 RPC(원격 프로시저 호출) 프레임워크로, Protocol Buffers를 사용하여 데이터를 직렬화한다. 양방향 스트리밍을 지원하여 실시간 통신에 적합하다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Protocol Buffers 정의 예시 (chat.proto)
syntax = "proto3";

package chat;

service ChatService {
  // 양방향 스트리밍 RPC
  rpc ChatStream (stream ChatMessage) returns (stream ChatMessage) {}
}

message ChatMessage {
  string user_id = 1;
  string content = 2;
  int64 timestamp = 3;
}
 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
// Node.js에서 gRPC 서버 구현 예시
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const packageDefinition = protoLoader.loadSync('chat.proto');
const chatProto = grpc.loadPackageDefinition(packageDefinition).chat;

// 활성 스트림을 추적하는 배열
const activeStreams = [];

function chatStream(call) {
  // 새 스트림을 활성 스트림 목록에 추가
  activeStreams.push(call);
  
  // 클라이언트로부터 메시지 수신 시 처리
  call.on('data', (chatMessage) => {
    console.log(`메시지 수신: ${chatMessage.content} from ${chatMessage.user_id}`);
    
    // 모든 활성 스트림으로 메시지 브로드캐스트
    const broadcastMessage = {
      user_id: chatMessage.user_id,
      content: chatMessage.content,
      timestamp: Date.now()
    };
    
    activeStreams.forEach(stream => {
      try {
        stream.write(broadcastMessage);
      } catch (error) {
        console.error('스트림 쓰기 오류:', error);
      }
    });
  });
  
  // 스트림 종료 처리
  call.on('end', () => {
    // 종료된 스트림을 활성 목록에서 제거
    const index = activeStreams.indexOf(call);
    if (index !== -1) {
      activeStreams.splice(index, 1);
    }
    call.end();
  });
  
  // 오류 처리
  call.on('error', (error) => {
    console.error('스트림 오류:', error);
    const index = activeStreams.indexOf(call);
    if (index !== -1) {
      activeStreams.splice(index, 1);
    }
  });
}

// gRPC 서버 설정 및 시작
const server = new grpc.Server();
server.addService(chatProto.ChatService.service, {
  chatStream: chatStream
});

server.bindAsync(
  '0.0.0.0:50051',
  grpc.ServerCredentials.createInsecure(),
  (error, port) => {
    if (error) {
      console.error('서버 시작 실패:', error);
      return;
    }
    console.log(`gRPC 서버가 ${port}에서 실행 중입니다`);
    server.start();
  }
);

장점:

단점:

실시간 API 설계 시 고려사항

실시간 API를 설계할 때는 여러 중요한 측면을 고려해야 한다:

연결 관리

실시간 시스템에서 연결 관리는 핵심 과제이다:

  1. 연결 유지: 연결이 끊어질 경우의 자동 재연결 메커니즘을 구현해야 한다.
  2. 하트비트(Heartbeat): 연결이 활성 상태인지 주기적으로 확인하는 메커니즘이 필요하다.
  3. 연결 상태 모니터링: 클라이언트와 서버 모두 연결 상태를 추적하고 문제를 감지해야 한다.
  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
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// 클라이언트 측 WebSocket 연결 관리 예시
class RealtimeClient {
  constructor(url, options = {}) {
    this.url = url;
    this.options = {
      reconnectInterval: 1000,
      maxReconnectAttempts: 5,
      heartbeatInterval: 30000,
      ...options
    };
    
    this.socket = null;
    this.reconnectAttempts = 0;
    this.heartbeatTimer = null;
    this.listeners = {
      message: [],
      open: [],
      close: [],
      error: []
    };
    
    this.connect();
  }
  
  connect() {
    this.socket = new WebSocket(this.url);
    
    this.socket.addEventListener('open', (event) => {
      console.log('연결 성공');
      this.reconnectAttempts = 0;
      this.startHeartbeat();
      this.notifyListeners('open', event);
    });
    
    this.socket.addEventListener('message', (event) => {
      const data = JSON.parse(event.data);
      
      // 하트비트 응답 처리
      if (data.type === 'heartbeat') {
        return;
      }
      
      this.notifyListeners('message', data);
    });
    
    this.socket.addEventListener('close', (event) => {
      this.stopHeartbeat();
      this.notifyListeners('close', event);
      
      // 비정상 종료인 경우 재연결 시도
      if (!event.wasClean) {
        this.scheduleReconnect();
      }
    });
    
    this.socket.addEventListener('error', (event) => {
      this.notifyListeners('error', event);
      // 오류 발생 시 재연결 시도
      this.scheduleReconnect();
    });
  }
  
  scheduleReconnect() {
    if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
      console.error('최대 재연결 시도 횟수 초과');
      return;
    }
    
    this.reconnectAttempts++;
    const delay = this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1);
    
    console.log(`${delay}ms 후 재연결 시도 (${this.reconnectAttempts}/${this.options.maxReconnectAttempts})`);
    setTimeout(() => this.connect(), delay);
  }
  
  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.socket.readyState === WebSocket.OPEN) {
        this.socket.send(JSON.stringify({ type: 'heartbeat' }));
      }
    }, this.options.heartbeatInterval);
  }
  
  stopHeartbeat() {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }
  
  on(event, callback) {
    if (this.listeners[event]) {
      this.listeners[event].push(callback);
    }
    return this;
  }
  
  notifyListeners(event, data) {
    if (this.listeners[event]) {
      this.listeners[event].forEach(callback => callback(data));
    }
  }
  
  send(data) {
    if (this.socket && this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(JSON.stringify(data));
    } else {
      console.error('WebSocket이 연결되지 않았습니다');
    }
  }
  
  close() {
    this.stopHeartbeat();
    if (this.socket) {
      this.socket.close();
    }
  }
}

// 사용 예시
const client = new RealtimeClient('wss://api.example.com/realtime');

client.on('open', () => {
  console.log('연결됨');
  client.send({ type: 'subscribe', channel: 'orders' });
});

client.on('message', (data) => {
  console.log('메시지 수신:', data);
});

client.on('close', () => {
  console.log('연결 종료');
});

client.on('error', (error) => {
  console.error('오류 발생:', error);
});

확장성 관리

실시간 시스템은 많은 동시 연결을 처리해야 하므로 확장성을 고려한 설계가 필수적이다:

  1. 수평적 확장: 부하 분산을 위한 여러 서버에 걸친 확장 방법을 계획해야 한다.
  2. 메시지 브로커 사용: Redis, Kafka, RabbitMQ와 같은 메시지 브로커를 사용하여 서버 간 통신을 조정한다.
  3. 연결 샤딩: 지리적 위치나 사용자 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
 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
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
// Node.js + Redis + Socket.IO를 사용한 확장 가능한 실시간 서버 예시
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const Redis = require('ioredis');

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
  adapter: require('socket.io-redis')({
    pubClient: new Redis(process.env.REDIS_URL),
    subClient: new Redis(process.env.REDIS_URL)
  })
});

// Redis 구독 클라이언트 설정
const redisSubscriber = new Redis(process.env.REDIS_URL);
redisSubscriber.subscribe('global-events');
redisSubscriber.on('message', (channel, message) => {
  const event = JSON.parse(message);
  io.to(event.room || 'all').emit(event.type, event.data);
});

// 소켓 연결 처리
io.on('connection', (socket) => {
  console.log('새 클라이언트 연결:', socket.id);
  
  // 사용자 인증 및 초기화
  socket.on('authenticate', async (userData) => {
    try {
      // 사용자 인증 로직
      const user = await authenticateUser(userData.token);
      
      // 사용자 정보를 소켓에 저장
      socket.data.user = user;
      
      // 사용자별 룸에 조인
      socket.join(`user:${user.id}`);
      
      // 구독 채널에 조인
      if (userData.subscriptions) {
        userData.subscriptions.forEach(channel => {
          socket.join(`channel:${channel}`);
        });
      }
      
      socket.emit('authenticated', { success: true });
    } catch (error) {
      socket.emit('authenticated', { 
        success: false, 
        error: error.message 
      });
    }
  });
  
  // 채널 구독
  socket.on('subscribe', (channel) => {
    socket.join(`channel:${channel}`);
    socket.emit('subscribed', { channel });
  });
  
  // 채널 구독 해제
  socket.on('unsubscribe', (channel) => {
    socket.leave(`channel:${channel}`);
    socket.emit('unsubscribed', { channel });
  });
  
  // 연결 종료 처리
  socket.on('disconnect', () => {
    console.log('클라이언트 연결 종료:', socket.id);
    // 정리 로직
  });
});

// 이벤트 발행 헬퍼 함수
function publishEvent(type, data, room = null) {
  const redis = new Redis(process.env.REDIS_URL);
  redis.publish('global-events', JSON.stringify({
    type,
    data,
    room
  }));
  redis.quit();
}

// 외부 API에서 사용할 수 있는 이벤트 발행 엔드포인트
app.post('/api/events', express.json(), async (req, res) => {
  try {
    const { type, data, room } = req.body;
    await publishEvent(type, data, room);
    res.status(202).json({ success: true });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// 서버 시작
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`실시간 서버가 포트 ${PORT}에서 실행 중입니다`);
});

// 사용자 인증 함수 (실제 구현은 별도로 필요)
async function authenticateUser(token) {
  // 토큰 검증 및 사용자 정보 조회 로직
  return { id: 'user-123', name: 'John Doe' };
}

데이터 일관성

실시간 시스템에서 데이터 일관성을 유지하는 것은 중요한 과제이다:

  1. 이벤트 순서화: 이벤트가 발생한 순서대로 처리되도록 보장해야 한다.
  2. 충돌 해결: 동시 업데이트 충돌을 감지하고 해결하는 전략이 필요하다.
  3. 상태 동기화: 연결이 끊어졌다가 재연결될 때 상태를 동기화하는 메커니즘이 필요하다.
  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
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// 클라이언트 측 상태 동기화 구현 예시
class SynchronizedState {
  constructor(initialState = {}) {
    this.state = initialState;
    this.version = 0;
    this.pendingUpdates = [];
    this.listeners = [];
  }
  
  // 상태 업데이트 (로컬)
  update(patch) {
    const updateObject = {
      id: generateUUID(),
      version: this.version + 1,
      timestamp: Date.now(),
      patch
    };
    
    // 로컬 상태 업데이트
    this.applyUpdate(updateObject);
    
    // 업데이트를 서버로 전송
    this.pendingUpdates.push(updateObject);
    this.sendPendingUpdates();
    
    return updateObject.id;
  }
  
  // 업데이트 적용
  applyUpdate(updateObject) {
    if (updateObject.version > this.version) {
      // 패치 적용
      this.state = deepMerge(this.state, updateObject.patch);
      this.version = updateObject.version;
      
      // 리스너 알림
      this.notifyListeners();
    }
  }
  
  // 서버로부터 업데이트 수신
  receiveUpdate(serverUpdate) {
    // 로컬 업데이트와 서버 업데이트 간 충돌 확인
    const conflictIndex = this.pendingUpdates.findIndex(
      update => update.id === serverUpdate.id
    );
    
    if (conflictIndex !== -1) {
      // 승인된 업데이트는 대기열에서 제거
      this.pendingUpdates.splice(conflictIndex, 1);
    } else {
      // 서버 업데이트 적용
      this.applyUpdate(serverUpdate);
    }
  }
  
  // 서버와 재동기화
  resynchronize(serverState, serverVersion) {
    // 서버 상태가 더 최신인 경우
    if (serverVersion > this.version) {
      this.state = serverState;
      this.version = serverVersion;
      
      // 서버 상태보다 최신인 대기 중인 업데이트만 유지
      this.pendingUpdates = this.pendingUpdates.filter(
        update => update.version > serverVersion
      );
      
      // 대기 중인 업데이트 재적용
      this.pendingUpdates.forEach(update => {
        this.state = deepMerge(this.state, update.patch);
      });
      
      this.notifyListeners();
    }
  }
  
  // 대기 중인 업데이트 전송 (예시 구현)
  sendPendingUpdates() {
    if (this.pendingUpdates.length === 0) return;
    
    // WebSocket이 연결되어 있는지 확인
    if (socket && socket.readyState === WebSocket.OPEN) {
      this.pendingUpdates.forEach(update => {
        socket.send(JSON.stringify({
          type: 'state_update',
          update
        }));
      });
    }
  }
  
  // 변경 리스너 등록
  onChange(callback) {
    this.listeners.push(callback);
    return () => {
      this.listeners = this.listeners.filter(cb => cb !== callback);
    };
  }
  
  // 리스너 알림
  notifyListeners() {
    this.listeners.forEach(callback => callback(this.state));
  }
}

// 헬퍼 함수: 깊은 병합
function deepMerge(target, source) {
  const output = { ...target };
  
  if (isObject(target) && isObject(source)) {
    Object.keys(source).forEach(key => {
      if (isObject(source[key])) {
        if (!(key in target)) {
          output[key] = source[key];
        } else {
          output[key] = deepMerge(target[key], source[key]);
        }
      } else {
        output[key] = source[key];
      }
    });
  }
  
  return output;
}

// 헬퍼 함수: 객체 확인
function isObject(item) {
  return (item && typeof item === 'object' && !Array.isArray(item)); }

/ UUID 생성 헬퍼 함수
function generateUUID() {
	return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
		const r = Math.random() * 16 | 0;
		const v = c === 'x' ? r : (r & 0x3 | 0x8);
		return v.toString(16);
		});
	}
// 사용 예시
const appState = new SynchronizedState({
	todos: [],
	settings: { theme: 'light' }
});
// 리스너 등록
appState.onChange(state => {
	console.log('상태 변경:', state);
	renderUI(state);
});
// 로컬 업데이트
function addTodo(text) {
	appState.update({
	todos: [...appState.state.todos, {
		id: generateUUID(),
		text,
		completed: false
	}]
	});
}

보안 고려사항

실시간 API는 특별한 보안 과제를 제기한다:

  1. 인증 및 권한 부여: 연결 시점과 메시지 송수신 시점 모두에서 적절한 인증이 필요하다.
  2. 메시지 검증: 모든 수신 메시지의 형식과 내용을 철저히 검증해야 한다.
  3. 속도 제한: 클라이언트당 메시지 속도를 제한하여 서비스 거부(DoS) 공격을 방지한다.
  4. 전송 중 암호화: WebSocket을 사용할 때는 반드시 WSS(WebSocket Secure)를 사용해야 한다.
  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
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
// Node.js에서 인증 및 권한 부여가 포함된 WebSocket 서버 예시
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const https = require('https');
const fs = require('fs');

// HTTPS 서버 생성
const server = https.createServer({
  cert: fs.readFileSync('/path/to/cert.pem'),
  key: fs.readFileSync('/path/to/key.pem')
});

// WebSocket 서버 생성
const wss = new WebSocket.Server({ server });

// JWT 비밀 키
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

// 클라이언트별 메시지 속도 제한 추적
const rateLimits = new Map();

// 속도 제한 검사 함수
function checkRateLimit(clientId) {
  const now = Date.now();
  const clientRateInfo = rateLimits.get(clientId) || { 
    count: 0, 
    lastReset: now 
  };
  
  // 1분마다 카운터 리셋
  if (now - clientRateInfo.lastReset > 60000) {
    clientRateInfo.count = 0;
    clientRateInfo.lastReset = now;
  }
  
  // 분당 100개 메시지 제한
  if (clientRateInfo.count >= 100) {
    return false;
  }
  
  // 카운터 증가 및 저장
  clientRateInfo.count++;
  rateLimits.set(clientId, clientRateInfo);
  
  return true;
}

// 권한 확인 함수
function checkPermission(user, action, resource) {
  // 권한 확인 로직 구현
  // 예: 특정 채널에 대한 메시지 전송 권한 확인
  if (action === 'send' && resource.startsWith('channel:')) {
    const channelId = resource.split(':')[1];
    return user.channels.includes(channelId);
  }
  return false;
}

// 연결 이벤트 처리
wss.on('connection', (ws, req) => {
  // 초기 상태 설정
  ws.isAlive = true;
  ws.user = null;
  
  // 핑/퐁 하트비트 설정
  ws.on('pong', () => {
    ws.isAlive = true;
  });
  
  // 메시지 수신 처리
  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message);
      
      // 인증 메시지 처리
      if (data.type === 'auth') {
        // JWT 토큰 검증
        try {
          const decoded = jwt.verify(data.token, JWT_SECRET);
          ws.user = decoded;
          ws.clientId = decoded.id;
          
          // 인증 성공 응답
          ws.send(JSON.stringify({
            type: 'auth_response',
            success: true
          }));
        } catch (error) {
          // 인증 실패 응답
          ws.send(JSON.stringify({
            type: 'auth_response',
            success: false,
            error: 'Invalid token'
          }));
          
          // 인증 실패 시 연결 종료
          ws.terminate();
        }
        return;
      }
      
      // 인증되지 않은 메시지 거부
      if (!ws.user) {
        ws.send(JSON.stringify({
          type: 'error',
          code: 'unauthorized',
          message: 'Authentication required'
        }));
        return;
      }
      
      // 속도 제한 확인
      if (!checkRateLimit(ws.clientId)) {
        ws.send(JSON.stringify({
          type: 'error',
          code: 'rate_limited',
          message: 'Rate limit exceeded'
        }));
        return;
      }
      
      // 메시지 유형별 처리
      switch (data.type) {
        case 'subscribe':
          // 채널 구독 권한 확인
          if (checkPermission(ws.user, 'subscribe', `channel:${data.channel}`)) {
            // 구독 처리 로직
            ws.send(JSON.stringify({
              type: 'subscribed',
              channel: data.channel
            }));
          } else {
            ws.send(JSON.stringify({
              type: 'error',
              code: 'permission_denied',
              message: 'No permission to subscribe to this channel'
            }));
          }
          break;
          
        case 'message':
          // 메시지 전송 권한 확인
          if (checkPermission(ws.user, 'send', `channel:${data.channel}`)) {
            // 메시지 검증 (예: 콘텐츠 길이, 금지된 콘텐츠 등)
            if (!data.content || data.content.length > 1000) {
              ws.send(JSON.stringify({
                type: 'error',
                code: 'invalid_message',
                message: 'Message content invalid or too long'
              }));
              return;
            }
            
            // 메시지 처리 및 브로드캐스트 로직
            // …
          } else {
            ws.send(JSON.stringify({
              type: 'error',
              code: 'permission_denied',
              message: 'No permission to send to this channel'
            }));
          }
          break;
          
        default:
          ws.send(JSON.stringify({
            type: 'error',
            code: 'unknown_message_type',
            message: `Unknown message type: ${data.type}`
          }));
      }
    } catch (error) {
      // 메시지 처리 오류
      ws.send(JSON.stringify({
        type: 'error',
        code: 'message_processing_error',
        message: 'Failed to process message'
      }));
      console.error('메시지 처리 오류:', error);
    }
  });
  
  // 연결 종료 처리
  ws.on('close', () => {
    // 정리 로직
    if (ws.clientId) {
      rateLimits.delete(ws.clientId);
    }
  });
});

// 비활성 연결 감지를 위한 간격 검사
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      return ws.terminate();
    }
    
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

// 서버 종료 시 간격 타이머 정리
wss.on('close', () => {
  clearInterval(interval);
});

// 서버 시작
server.listen(8080, () => {
  console.log('Secure WebSocket 서버가 포트 8080에서 실행 중입니다');
});

4. 실시간 API 사용 사례

실시간 API는 다양한 애플리케이션 시나리오에서 활용된다:

실시간 API의 모범 사례

실시간 API를 구현할 때 다음과 같은 모범 사례를 고려해야 한다:

  1. 프로토콜 선택
    애플리케이션 요구사항에 맞는 적절한 프로토콜을 선택하는 것이 중요하다:

    1. 양방향 통신이 필요한 경우: WebSocket 또는 gRPC
    2. 서버에서 클라이언트로의 단방향 업데이트만 필요한 경우: SSE(Server-Sent Events)
    3. 브라우저 호환성이 중요한 경우: WebSocket 또는 Long Polling
    4. 높은 성능이 중요한 경우: gRPC 또는 최적화된 WebSocket
  2. 효율적인 메시지 형식
    메시지 형식은 효율성과 유연성을 고려해야 한다:

    1. JSON: 가독성이 좋고 대부분의 환경에서 지원되지만, 크기가 상대적으로 크다.
    2. MessagePack: JSON보다 압축률이 높으며 이진 형식으로 효율적이다.
    3. Protocol Buffers: 매우 효율적인 이진 직렬화 형식으로 스키마 정의가 필요하다.
    4. CBOR: JSON과 유사하지만 더 효율적인 인코딩을 제공한다.
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    // JSON 형식의 메시지 예시
    {
      "type": "update",
      "channel": "stocks",
      "data": {
        "symbol": "AAPL",
        "price": 150.25,
        "change": 2.75,
        "timestamp": 1633456789
      }
    }
    
    // JSON과 MessagePack 비교
    // 위 JSON 메시지의 크기: 약 106 바이트
    // MessagePack으로 인코딩 시 크기: 약 70 바이트
    
  3. 에러 처리 및 복원력
    실시간 시스템에서 오류는 불가피하므로 이에 대비해야 한다:

    1. 명확한 오류 메시지: 클라이언트가 이해하고 대응할 수 있는 명확한 오류 코드와 메시지를 제공한다.
    2. 자동 재연결: 연결 끊김 시 자동으로 재연결하는 메커니즘을 구현한다.
    3. 상태 복구: 재연결 후 누락된 메시지나 상태를 복구하는 방법을 제공한다.
    4. 점진적 기능 감소: 일부 기능에 문제가 있어도 가능한 많은 기능을 유지한다.
  4. 문서화
    실시간 API의 문서화는 특히 중요하다:

    1. 프로토콜 및 형식 설명: 사용된 프로토콜과 메시지 형식을 명확히 설명한다.
    2. 메시지 타입 및 필드: 모든 메시지 타입과 필드를 상세히 문서화한다.
    3. 이벤트 목록: 구독 가능한 모든 이벤트와 그 형식을 나열한다.
    4. 인증 및 권한: 인증 방법과 필요한 권한을 명확히 설명한다.
    5. 예제 코드: 다양한 언어와 플랫폼에 대한 예제 코드를 제공한다.
  5. 테스트 및 시뮬레이션
    실시간 API는 테스트가 복잡할 수 있으므로 적절한 도구와 방법이 필요하다:

    1. 부하 테스트: 많은 동시 연결을 처리할 수 있는지 확인한다.
    2. 지연 시간 테스트: 메시지 전달 지연 시간을 측정한다.
    3. 네트워크 조건 시뮬레이션: 불안정한 네트워크 환경에서의 동작을 테스트한다.
    4. 장애 시뮬레이션: 서버 장애나 연결 끊김 시의 복구 동작을 확인한다.

용어 정리

용어설명

참고 및 출처