Polling 방식으로 인한 서버 다운 문제 해결하기
문제 상황
2025년 3월 8일, 강사님에게 열심히 개발한 투표 기능과 질문 기능을 사용해달라고 건의하며, 약 30명의 수강생이 동시에 투표와 질문 기능을 사용하기 시작했습니다.

투표/질문 기능이 폴링 방식으로 구현되어 있었다보니, 기존 스냅샷 요청에 더해 트래픽이 약 3배로 증가했습니다. 이로 인해 스냅샷이 제대로 생성되지 않고 투표 결과가 반영되지 않더니, 얼마 지나지 않아 서비스가 완전히 응답하지 않게 되었습니다.
원인 분석
스냅샷 조회/투표 조회/질문 조회 기능은 Polling 방식으로 실시간 데이터 동기화를 구현했습니다.
const POLLING_INTERVAL = 1000; // 1초마다 폴링
function useInterval(callback, delay) {
useEffect(() => {
const intervalId = setInterval(callback, delay);
return () => clearInterval(intervalId);
}, [callback, delay]);
}
const fetchSnapshots = useCallback(async () => {
if (!roomUuid) return;
try {
const response = await fetch(`/api/rooms/${roomUuid}/snapshots`);
// 데이터 처리...
} catch (error) {
console.error("Error fetching snapshots:", error);
}
}, [roomUuid]);
// Polling - 1초마다 지속적으로 API 호출
useInterval(() => {
fetchSnapshots();
}, POLLING_INTERVAL);Polling 방식의 가장 큰 문제는 과도한 서버 부하, 불필요한 네트워크 트래픽 문제가 있습니다. 투표하지 않아도 1초마다 투표 결과를 조회하고, 질문하지 않아도 1초마다 채팅 목록을 조회합니다. 이로 인해 서버에 엄청난 부하가 걸리게 되고, 대량의 불필요한 네트워크 트래픽이 발생하게 됩니다.
또한 실시간성 부족 문제도 있습니다. Polling 방식은 최대 1초의 지연 시간이 발생할 수 있으며, 이를 개선하기 위해 폴링 간격을 더 짧게 설정하면 서버 부하가 더욱 증가하는 딜레마가 있습니다.
해결 방안
1차 시도: 인스턴스 유형 증가 (임시방편)
원활한 수업 진행을 위해 급한 대로 EC2 인스턴스 유형을 t3.medium으로 증가시켜 대응했습니다. 이로 인해 서비스는 다시 정상화되었고, 수업을 무사히 마칠 수 있었습니다.
하지만 이는 임시방편에 불과했습니다. 사용자가 늘어나면 또다시 같은 문제가 발생할 것이 뻔했기에 제대로 된 해결책이 필요하다는 결론에 도달했습니다.
2차 시도: WebSocket 도입 (근본적 해결)
WebSocket은 양방향 통신을 지원합니다. 서버에서 클라이언트로 직접 데이터를 푸시할 수 있으며, 데이터 변경이 발생했을 때만 통신하면 되기 때문에 불필요한 요청을 줄일 수 있습니다.
또한 서버 부하를 획기적으로 감소시킵니다. 초기 연결 이후에는 지속적인 HTTP 요청이 필요 없으며, 이벤트 기반 통신으로 효율성이 크게 증대됩니다. 30명이 접속해도 분당 5,400개의 요청이 아닌, heartbeat 정도만 발생합니다.
마지막으로 실시간성을 보장합니다. 데이터 변경이 발생하면 즉시 클라이언트에 전달되므로 지연 시간을 최소화할 수 있습니다.
이러한 이점들 덕분에 WebSocket 도입을 최종 결정하게 되었습니다.
WebSocket 구현
스냅샷이 생성되어 모든 사용자에게 실시간으로 반영되는 흐름은 다음과 같습니다:
- 사용자 A가 스냅샷 생성 버튼 클릭
- Backend에서 스냅샷 저장 후 WebSocket으로 알림 전송
- 해당 방의 모든 클라이언트(사용자 A, B, C…)가 WebSocket 메시지 수신
- UI에 새 스냅샷 실시간 반영
Backend
@Service
@RequiredArgsConstructor
public class SnapshotService {
private final SnapshotRepository snapshotRepository;
private final SnapshotSocketService snapshotSocketService;
// ...
@Transactional
public SnapshotResponseDTO.SnapshotCreateResponse saveSnapshot(Long roomId, SnapshotRequestDTO.SnapshotCreateRequest request) {
// ...
// Snapshot 저장
Snapshot savedSnapshot = snapshotRepository.save(snapshot);
// WebSocket 알림 전송 - 스냅샷 생성 알림
try {
SnapshotResponseDTO.SnapshotDetailResponse snapshotDetail =
SnapshotResponseDTO.SnapshotDetailResponse.builder()
.snapshotId(savedSnapshot.getSnapshotId())
.title(savedSnapshot.getTitle())
.description(savedSnapshot.getDescription())
.code(savedSnapshot.getCode())
.createdAt(savedSnapshot.getCreatedAt())
.comments(List.of()) // 새로 생성된 스냅샷은 댓글이 없음
.build();
snapshotSocketService.notifySnapshotCreated(room, snapshotDetail);
} catch (Exception e) {
// WebSocket 알림 실패 시 로깅만 하고 계속 진행
log.warn("스냅샷 생성 WebSocket 알림 전송 실패: roomId={}, snapshotId={}, error={}", room.getRoomId(), savedSnapshot.getSnapshotId(), e.getMessage());
}
return response;
}
}@Slf4j
@Service
@RequiredArgsConstructor
public class SnapshotSocketService {
private final SimpMessagingTemplate messagingTemplate;
public void notifySnapshotCreated(Room room, SnapshotResponseDTO.SnapshotDetailResponse snapshot) {
log.info("[WebSocket] 스냅샷 생성 알림: roomId={}, roomUuid={}, snapshotId={}", room.getRoomId(), room.getUuid(), snapshot.getSnapshotId());
SnapshotSocketDTO.SnapshotCreatedResponse response =
SnapshotSocketDTO.SnapshotCreatedResponse.builder()
.roomId(room.getRoomId())
.snapshot(snapshot)
.timestamp(LocalDateTime.now())
.build();
// 해당 방의 모든 사용자에게 브로드캐스트
messagingTemplate.convertAndSend("/topic/room/" + room.getUuid() + "/snapshots", response);
log.info("[WebSocket] 스냅샷 생성 알림 전송 완료: roomId={}, roomUuid={}", room.getRoomId(), room.getUuid());
}
}Frontend
export default function CodeShareRoomPage() {
const [snapshots, setSnapshots] = useState<any[]>([]);
const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null);
const [isAuthorized, setIsAuthorized] = useState<boolean>(false);
// ...
// 초기 스냅샷 로드
const fetchSnapshots = useCallback(async (): Promise<void> => {
if (!roomInfo?.uuid || !isAuthorized) return;
const response = await fetch(`/api/rooms/${roomInfo.uuid}/snapshots`);
const data = await response.json();
const formattedSnapshots = data.data
.map((snapshot: any) => ({ /* ... */ }))
.sort((a: any, b: any) => b.createdAt - a.createdAt);
setSnapshots(formattedSnapshots);
}, [roomInfo?.uuid, isAuthorized]);
// WebSocket을 통한 실시간 스냅샷 업데이트
const handleSnapshotUpdate = useCallback((newSnapshot: any) => {
if (!newSnapshot.id) return;
setSnapshots((prev) => {
// 중복 체크
if (prev.some((snapshot) => snapshot.id === newSnapshot.id)) {
return prev;
}
return [newSnapshot, ...prev];
});
}, []);
// 웹소켓 관리
const { publishCode } = useWebSocketManager({
roomInfo,
isAuthorized,
onSnapshotUpdate: handleSnapshotUpdate,
// ...
});
// ...
}개선 결과
WebSocket 도입 후, Polling 방식과 비교한 주요 개선 결과는 다음과 같습니다:
| 항목 | Polling (Before) | WebSocket (After) | 개선율 |
|---|---|---|---|
| 분당 요청 수 (30명 기준) | 5,400개 | ~180개 (heartbeat) | 96.7% 감소 |
| 실시간성 | 최대 1초 지연 | 즉시 반영 | - |
| 네트워크 트래픽 | 높음 | 낮음 | - |