지난 여름, 연구실에서 사용자 적응형 다크 모드 프로그램과 관련해서 연구를 진행했었습니다.
엎어졌지만...
그때 구상했던 전체 시스템 아키텍처의 경우, 클라이언트에서 서버로 요청을 보내서 수정 및 구현을 하는게 아니라 서버에서 클라이언트로 요청을 보내 서버의 요청대로 클라이언트에서 수정을 진행해주어야 했습니다.
그 말인 즉슨 HTTP 요청 - 응답 구조만으로는 한계가 있음을 의미합니다.
1. HTTP의 한계와 WebSocket의 사용
전통적인 웹통신 구조인 HTTP 요청-응답 모델은 항상 "클라이언트가 요청 → 서버가 응답" 하는 단방향 구조를 가지고 있습니다.
이 방식은 일반적인 웹사이트나 API 요청에는 충분하지만 제가 구상했던 사용자 적응형 다크 모드 시스템 처럼 서버가 클라이언트의 상태를 제어해야하는 구조에는 적합하지 않았습니다.
예를 들어, 서버가 전달받은 정보를 바탕으로 "지금 사용자의 화면 밝기를 낮춰야겠다"는 판단을 내렸다고 합시다.
HTTP 기반 구조라면 서버는 클라이언트가 요청을 보내기 전까지는 아무것도 할 수 없습니다.
서버가 먼저 행동을 지시해야하는데 HTTP의 경우 서버가 먼저 행동을 '지시'할 수 있는 방법이 없는 것이죠.
이런 문제를 해결하기 위해 사용할 수 있는 것이 바로 양방향 통신인 WebSocket입니다.
WebSocket이란?
WebSocket은 클라이언트와 서버가 항상 연결된 상태로 실시간 양방향 통신을 주고 받을 수 잇는 프로토콜입니다.
한 번의 handshake 이후에는 서버가 언제든 클라이언트로 메세지를 보낼 수 있고, 클라이언트 역시 별도의 요청 없이 데이터를 즉시 서버에 보낼 수 있습니다.
즉 WebSocket을 사용하면, 요청에만 반응하는 웹의 기존 패러다임에서 벗어나 지속적이고 상호적인 연결을 구축할 수 있습니다.
| HTTP | WebSocket | |
| 연결 구조 | 요청 후 응답으로 종료 | 한 번 연결 후 지속 유지 |
| 통신 방향 | 단방향 (client → server) | 양방향 (client ↔ server) |
| 사용 예시 | 일반 API, 데이터 요청 | 채팅, 알림, 실시간 제어 |
| 전송 프로토콜 | HTTP/HTTPS | ws:// 또는 wss:// |
그래서 저는 이 WebSocket 통신을 이용하여 서버가 클라이언트에게 "폰트 크기를 조정해라, "화면의 대비를 키워라" 등의 명령을 전달하면 클라이언트에서 해당 내용을 실시간으로 적용하는 시스템을 구상했습니다.
2. 시스템 설명 및 구현
2.1 전체 시스템 구상
다시 한 번 설명하자면, (음.. 여기서 주제 관련 자세한 이야기는 할 수 없지만...) 구상한 시스템 아키텍처 상 서버가 클라이언트를 제어하는 구조여야 했고, 이를 위해서는 WebSocket 통신을 도입하는 방향으로 진행을 해야했습니다.
Chrome Extension으로 해당 시스템을 만들어야 했고 Chrome Extension은 대부분 TypeScript로 이루어져있습니다.
그래서 JavaScript를 사용하는 Backend Framework인 Node.js로 서버를 구축하고 Chrome Extension의 background.ts를 클라이언트로 설정해 서버에서 직접 확정 프로그램 UI를 제어하는 구조를 시도 했습니다.
그 과정에서 CSP, popup 제약 등 여러 시행착오를 겪었지만, 결국 background 기반 WebSocket 통신이 가장 안정적이라는 결론에 도달 했습니다.
2.2 왜 background.ts?
왜 background에서 WebSocket 통신으 다루는 것이 안정적인지에 대한 이야기를 해보겠습니다.
Chroem 확장 프로그램은 크게 3가지 주요 실행 단위로 구성됩니다.
| 설명 | 실행 특성 | |
| popup | 사용자가 확장 아이콘을 클릭했을 때 뜨는 UI 화면 | 일시적 (UI 닫히면 종료) |
| content script | 웹페이지에 삽입되어 DOM을 조작하는 스크립트 | 페이지 단위로 로드/종료 |
| background | 확장 프로그램 전체의 백엔드 로직을 담당하는 스크립트 (Service Worker 형태) |
이 중 background는 말 그대로 확장 프로그램의 두뇌 역할을 합니다.
popup이 사라져도, 탭이 닫혀도 background는 살아남아 확장 전체의 상태를 관리하고 서버와의 연결을 유지합니다.
WebSocket의 핵심은 "한 번 연결되면 계속 살아있는 상태로 데이터를 주고받는 것"입니다.
하지만 popup이나 content script는 수명이 짧기 때문에 WebSocket 연결을 여기에 두면 다음과 같은 문제가 발생합니다.
- popup을 닫으면 연결이 바로 끊김
- 페이지가 새로고침되면 연결이 초기화됨
- 백그라운드에서 서버의 메시지를 수신할 수 없음
또 추가적으로 Chrome Extension은 보안상 Content Security Policy(CSP)가 매우 엄격합니다.
그래서 Chrome Extension에서 WebSocket 연결은 항상 실행중인 background에서 관리하고, 필요할 때 popup이나 content script와 메시지를 주고받는 구조가 제일 안정적인 것입니다.
2.3 기본 시스템 구조 설계 및 구현
그래서 제가 구상한 기본적인 시스템 구조는 다음과 같습니다.
[ Chrome Extension ]
└─► background.ts
└─ WebSocket 연결
└─ 서버에 메시지 전송 / 수신 처리
[ Node.js Backend Server ]
└─ WebSocket 서버 (ws://localhost:3001)
└─ 메시지 수신 → 처리 → 응답 전송
초기 세팅 과정
벡엔드 폴더로 접속하여 아래 명령어를 실행해 필요한 기본 패키지를 설치했습니다.
npm install
npm install ws
npm install express
그리고 지금은 기본적인 부분만 구축했기 때문에 index.js 파일에서 WebSocket 통신을 관리하도록 기본 코드를 작성해놓앗습니다.
추후에는 node index.js 명령어로 서버를 실행할 수 있고 서버가 성공적으로 실행되면 대기중인 WebSocket 서버도 활성화됩니다.
서버 측 구현
아래 코드는 단순히 연결된 클라이언트의 메세지를 받아 같은 내용을 echo 형태로 되돌려주는 구조입니다.
index.js
const WebSocket = require('ws');
// WebSocket 전용 포트
const PORT = 3001;
// 서버 생성
const wss = new WebSocket.Server({ port: PORT });
wss.on('connection', (ws) => {
console.log('✅ 클라이언트 WebSocket 연결됨');
ws.on('message', (message) => {
console.log('📨 클라이언트 메시지:', message.toString());
// 응답 전송
ws.send(`서버 응답: ${message}`);
});
ws.on('close', () => {
console.log('❌ 클라이언트 연결 종료');
});
});
console.log(`🟢 WebSocket 서버 실행 중 (ws://localhost:${PORT})`);
하지만 추후에는 이부분에 다크모드 상태 업데이트 등의 사용자 환경 제어 명령을 추가할 수 있습니다.
클라이언트 측 구현 (background.ts)
아래 코드는 Chrome Extension의 background.ts 파일에서 WebSocket 클라이언트로 동작하도록 구현한 부분입니다.
저희는 다크 모드 관련 확장 프로그램인 DarkReader의 3.5.4 버전에서 수정을 실행했습니다.
아래 코드는 해당 확장 프로그램의 background.ts 파일에 추가한 내용입니다.
https://github.com/darkreader/darkreader
GitHub - darkreader/darkreader: Dark Reader Chrome and Firefox extension
Dark Reader Chrome and Firefox extension. Contribute to darkreader/darkreader development by creating an account on GitHub.
github.com
popup에서 직접 서버에 연결을 시도하면 CSP(Content Security Policy) 제약으로 차단되기 때문에, 항상 실행되는 background service worker가 서버와의 지속적인 연결을 담당하도록 설계했습니다.
// WebSocket 변수
let socket: any = null;
// WebSocket 연결 함수
function connectWebSocket() {
socket = new WebSocket('ws://localhost:3001');
socket.onopen = () => {
console.log('[bg] WebSocket 연결됨');
if (socket && socket.readyState === 1) {
socket.send('[bg] 확장에서 서버 연결됨');
}
};
socket.onmessage = (event) => {
console.log('[bg] 서버 응답:', event.data);
chrome.runtime.sendMessage({ from: 'server', data: event.data });
};
socket.onclose = () => {
console.warn('[bg] 연결 끊김 → 재연결 예정');
setTimeout(connectWebSocket, 3000);
};
socket.onerror = (err) => {
console.error('[bg] WebSocket 오류:', err);
if (socket) socket.close();
};
}
// popup에서 보내는 메시지 수신 → 서버로 전달
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.from === 'popup' && socket && socket.readyState === 1) {
console.log('[bg] popup → 서버:', msg.data);
socket.send(msg.data);
}
});
// 확장 프로그램 초기화 시 WebSocket 연결 실행
loadConfigs(() => {
extension = new DarkReader.Extension(new DarkReader.FilterCssGenerator());
onExtensionLoaded.invoke(extension);
// WebSocket 연결 시작
connectWebSocket();
});
3. 작동 확인
darkreader 3.5.4는 mainfest v2로 구버전입니다.
해당 버전으로 제작한 크롬 확장 프로그램은 더이상 크롬에서 지원해주지 않는다는 점을 참고해주시면 좋을 것 같습니다.
(제가 개발할 당시에는 아직까지 지원해주는 상태였고, 필요시에는 크롬을 다운그레이드하여 실행하기도 했습니다)
3.1 빌드 폴더 생성
먼저 확장 프로그램 코드를 빌드합니다.
아래 명령어를 실행하면 자동으로 build 폴더가 생성됩니다.
npm run release
빌드 과정에서 필요한 패키지가 없다는 에러가 뜬다면 npm이 안내하는 대로 추가 패키지들을 설치해 주세요.
저 명령을 실행한 후에 추가적으로 설치가 필요하다고 안내되면 저는 다 설치했던 것으로 기억합니다.
3.2 Chrome 확장 프로그램 등록
크롬 주소창에 chrome://extensions 를 입력해 확장 프로그램 관리 페이지로 이동합니다.
오른쪽 상단의 개발자 모드를 활성화합니다.

3.1 단계에서 생성된 build.zip을 압축 해제합니다.
확장 프로그램 관리 페이지에서 "압축해제된 확장 프로그램 로드" 버튼을 클릭하고 방금 압축 해제한 buil 폴더를 선택해 등록합니다.
아래 사진에 Dark Reader 3.5.4가 등록된 것 보이시죠?
등록이 완료되면 확장 프로그램이 크롬에 추가되고, 확장 아이콘을 통해 popup 화면을 열 수 있습니다.

3.3 백엔드 서버 실행 및 연결 확인
이제 WebSocket 서버를 실행해보겠습니다.
VS Code 터미널에서 backend 폴더로 이동한 뒤 아래 명령어를 입력합니다.
node index.js
정상적으로 실행되면 콘솔에 다음과 같은 로그가 출력됩니다:
🟢 WebSocket 서버 실행 중 (ws://localhost:설정한 포트 번호)
✅ 클라이언트 WebSocket 연결됨
그리고 크롬 확장 프로그램 관리 페이지에서 아래 사진 속 뷰 검사 페이지(background/background.html)에 접속합니다.

아래 로그를 확인할 수 있다면 연결 성공입니다!
[bg] WebSocket 연결됨
[bg] 서버 응답: [bg] 확장에서 서버 연결됨

4. 추가 화면 수정 로직 구현
앞서 작성한 코드에서 좀 더 수정하여 5초마다 랜덤한 다크모드 설정을 서버가 전송하고 확장 프로그램이 이를 받아 동적으로 적용하는 기능을 구현했습니다.
코드 구현
// index.js
const WebSocket = require('ws');
// WebSocket 전용 포트
const PORT = 3001;
// 서버 생성
const wss = new WebSocket.Server({ port: PORT });
wss.on('connection', (ws) => {
console.log('✅ 클라이언트 WebSocket 연결됨');
ws.on('message', (message) => {
console.log('📨 클라이언트 메시지:', message.toString());
// 응답 전송
ws.send(`서버 응답: ${message}`);
});
// 5초마다 임의의 JSON 데이터 전송
const intervalId = setInterval(() => {
const data = {
type: 'UPDATE_FILTER',
payload: {
mode: 1, // 다크모드
brightness: Math.floor(Math.random() * 100) + 50,
contrast: Math.floor(Math.random() * 100) + 50,
grayscale: Math.floor(Math.random() * 100), // 0~99
sepia: Math.floor(Math.random() * 100), // 0~99
useFont: Math.random() > 0.5, // true/false 랜덤
fontFamily: "Open Sans", // 예시 폰트
textStroke: Math.floor(Math.random() * 3), // 0~2
invertListed: false,
siteList: []
}
};
console.log('서버가 전송하는 JSON:', data);
ws.send(JSON.stringify(data));
}, 5000);
ws.on('close', () => {
console.log('❌ 클라이언트 연결 종료');
clearInterval(intervalId);
});
});
console.log(`🟢 WebSocket 서버 실행 중 (ws://localhost:${PORT})`);
// background.ts
// WebSocket 변수
let socket: any = null;
// WebSocket 연결 함수
function connectWebSocket() {
socket = new WebSocket('ws://localhost:3001');
socket.onopen = () => {
console.log('[bg] WebSocket 연결됨');
if (socket && socket.readyState === 1) {
socket.send('[bg] 확장에서 서버 연결됨');
}
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'UPDATE_FILTER') {
console.log('[bg] 서버에서 받은 UPDATE_FILTER payload:', data.payload);
// config를 동적으로 갱신 (Object.assign 대신 for...in 사용)
for (var key in data.payload) {
if (data.payload.hasOwnProperty(key) && extension.config.hasOwnProperty(key)) {
extension.config[key] = data.payload[key];
}
}
// 모든 탭에 스타일 적용
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => {
if (tab.id && tab.url) {
extension["addStyleToTab"](tab); // protected이지만 강제 접근
}
});
});
}
} catch (e) {
console.log('[bg] 서버 응답:', event.data);
}
};
socket.onclose = () => {
console.warn('[bg] 연결 끊김 → 재연결 예정');
setTimeout(connectWebSocket, 3000);
};
socket.onerror = (err) => {
console.error('[bg] WebSocket 오류:', err);
if (socket) socket.close();
};
}
// popup에서 보내는 메시지 수신 → 서버로 전달
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.from === 'popup' && socket && socket.readyState === 1) {
console.log('[bg] popup → 서버:', msg.data);
socket.send(msg.data);
}
});
loadConfigs(() => {
extension = new DarkReader.Extension(new DarkReader.FilterCssGenerator());
onExtensionLoaded.invoke(extension);
//websocket 연결
connectWebSocket();
});
작동 확인
브라우저 콘솔을 통해 전송된 JSON 데이터와 적용 결과를 모두 확인할 수 있었으며, 설정 값이 5초 간격으로 정상적으로 변경되고 반영되는 것을 확인했습니다.

