모아챗
시그널링 서버 구현
초기 시그널링 서버 구현에서는 소켓의 ID를 기준으로 유저를 식별하도록 로직을 구현했습니다.
server.tssocket.on('join chat', () => {
for (const room of rooms) {
if (room.users.length === 1) {
userRoom = room;
break;
}
}
if (!userRoom) {
const newRoomId = crypto.randomBytes(10).toString('hex');
userRoom = { id: newRoomId, users: [] };
rooms.push(userRoom);
}
userRoom.users.push(socket.id); // userRoom 객체에 socket.id를 유저의 식별자로 설정
const otherUser = userRoom.users.find((id) => id !== socket.id);
if (otherUser) {
socket.emit('other user', otherUser);
socket.to(otherUser).emit('user joined', socket.id);
}
});
그러나 소켓 ID를 통해 유저를 식별하면 유저가 새로고침할 때마다 서버에서는 동일한 유저를 새로운 유저로 인식하게 되어 동일한 유저가 반복적으로 매칭되는 문제가 있었습니다.
해당 문제를 개선하기 위해 우선 브라우저 별로 고유한 식별자를 가져야한다고 생각해서, 클라이언트 접속 초기에 유저 별로 식별자를 proxy를 통해 응답 쿠키에 유저의 식별자를 담아 전송했습니다.
proxy.tsconst COOKIE_OPTION = {
httpOnly: true,
path: '/',
maxAge: 60 * 60 * 24,
};
export function proxy(request: NextRequest) {
const id = request.cookies.has('id');
const response = NextResponse.next();
if (!id) {
const randomUUID = crypto.randomUUID();
response.cookies.set('id', randomUUID, COOKIE_OPTION);
}
return response;
}
설정한 쿠키를 시그널링 서버와 통신할 때 auth 객체에 담아 전달하여 유저를 식별하도록 했습니다.
서버에서는 해당 쿠키의 값을 기준으로 동일한 유저가 매칭되지 않도록 필터링하여, 동일 유저의 반복적인 매칭 문제를 해결했습니다.
인프라 환경 구축
초기에는 AWS 서비스를 이용하면 배포와 인프라 관리를 빠르게 구성할 수 있다는 장점이 있어 클라우드 환경의 인프라를 구축했습니다.
클라우드 인프라 환경
이미지 버전을 간편하게 롤백하고 관리할 수 있는 ECR을 선택했습니다.
빌드된 도커 이미지를 ECR에 저장하고, CodeDeploy 에이전트를 통해 EC2에서 이미지를 가져와 컨테이너로 실행하도록 구성했습니다.
구조Github actions -> Docker build -> AWS ECR -> AWS CodeDeploy -> EC2
build.yml- name: Build Docker image
run: docker build -t IMAGE_NAME:latest .
- name: Push Docker image to ECR
- run: docker push ECR.amazonaws.com/moachat:IMAGE_NAME
- name: Create CodeDeploy Deployment
run: aws deploy create-deployment...
appspec.ymlversion: 0.0
os: linux
hooks:
ApplicationStart:
- location: scripts/application-start.sh
timeout: 300
하지만 서비스를 운영하면서 클라우드 환경의 편의성만으로는 해결하기 어려운 문제들이 생겼습니다.
데이터센터 노드에 문제가 발생하거나 외부 공격으로 인스턴스가 영향을 받는 상황을 경험했고, 배포 에이전트가 정상적으로 동작하지 않는 경우에는 제공되는 로그만으로 원인을 빠르게 파악하기 어려웠습니다. 또한 서비스를 지속적으로 운영하면서 발생하는 비용도 함께 고려하게 되었습니다.
이러한 경험을 통해 문제가 발생했을 때 직접 원인을 확인하고 인프라를 제어할 수 있는 범위를 확보할 필요가 있다고 판단했고, 운영 비용까지 고려하여 온프레미스 환경으로 전환했습니다.
온프레미스 인프라 환경
별도의 이미지 레지스트리를 직접 운영하지 않고 Docker 이미지를 관리하기 위해 GHCR을 사용했습니다.
이미지가 GHCR에 push 되면, 별도로 생성한 인프라 레포지토리를 트리거하고,
해당 레포지토리에서 SSH를 통해서 로컬 PC에 접속하여
GHCR에 저장된 최신 도커 이미지를 받아와 docker compose를 통해 묶어서 실행하도록 구현했습니다.
또한 Nginx를 리버스 프록시로 구성하여 외부 요청을 Docker 네트워크 내부의 서비스로 전달했습니다.
구조Github actions -> Docker build -> GHCR -> SSH -> On-premise server
build.yml- name: Build Docker Image
run: |
docker build --secret id=npm_token,env=NPM_TOKEN -t ghcr.io/...
- name: Login to GHCR
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io...
- name: Push Docker Image
run: |
docker push ghcr.io/...
- name: Trigger Infra Deploy
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
repository: ${{ github.repository_owner }}/infra
event-type: deploy-trigger
deploy.ymlon:
workflow_dispatch:
repository_dispatch:
types: [deploy-trigger]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Copy Files to On-Prem Server
uses: appleboy/scp-action@v1
with:
source: "docker-compose.yml,nginx/**,coturn/**"
target: /Users/${{ secrets.SERVER_USER }}/...
- name: Pull Latest Images and Restart Containers
uses: appleboy/ssh-action@v1
with:
script: |
...
$DOCKER_PATH compose pull
$DOCKER_PATH compose up -d --remove-orphans
default.confserver {
listen 443 ssl;
server_name ...;
ssl_certificate /etc/letsencrypt/live/...;
ssl_certificate_key /etc/letsencrypt/live/...;
# 비정상적인 Server Action 요청을 차단
if ($http_next_action ~ "^.{1,10}$") {
return 444;
}
location /socket.io/ {
proxy_pass http://server:4000/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://client:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
종속적인 클라우드 환경에서 독립적인 온프레미스 환경으로 전환하면서 통제가 어려운 변수들을 줄일 수 있었고, 운영 비용을 연간 약 $288 절약할 수 있었습니다.
Coturn 기반 TURN 서버 구축
기존에는 STUN 공개 서버로만 유저의 공인IP와 포트를 가져와 상대 피어와 P2P 연결을 지원했습니다.
그러나, 대칭형 NAT환경(Symmetric NAT)에서는 목적지가 달라질 때마다 공인 포트도 새로 발급하기 때문에
STUN 서버가 알아낸 주소의 포트와 실제 상대 피어와 통신할 때의 포트가 다르게 매핑되어 피어 연결이 되지 않는 문제가 있었습니다.
그래서 Coturn 기반 STUN/TURN 릴레이 서버를 구축하여, STUN으로 연결 가능한 세션에 대해서는 P2P로 연결하고, 실패할 경우에만 TURN 서버가 할당한 중계 주소를 거쳐 트래픽을 릴레이하도록 했습니다.
또한 무단 TURN Relay 사용을 방지하기 위해 TURN REST API 표준 방식(timestamp 기반 만료 + HMAC-SHA1 서명)에 따라 유효기간이 있는 임시 credential을 발급하도록 했고, Docker 가상 네트워크 주소를 공인 IP와 매핑하여 컨테이너 환경에서 발생하는 IP 불일치 이슈를 해결했습니다.
server.tsfunction generateTurnCredentials(userId: string): RTCIceServer[] {
const ttl = 600;
const timestamp = Math.floor(Date.now() / 1000) + ttl;
const username = `${timestamp}:${userId}`;
const secret = process.env.TURN_SECRET;
const host = process.env.TURN_HOST;
if (!secret || !host) {
throw new Error(
'TURN_SECRET or TURN_HOST is missing in environment variables'
);
}
const hmac = crypto.createHmac('sha1', secret);
hmac.update(username);
const credential = hmac.digest('base64');
// 구글 STUN을 fallback으로 사용
return [
{
urls: `stun:stun.l.google.com:19302`
},
{
urls: `stun:${host}:3479`,
},
{
urls: [
`turn:${host}:3479?transport=udp`,
`turn:${host}:3479?transport=tcp`,
],
username: username,
credential: credential,
},
];
}
// 매칭 시작 시 상대방과 본인의 ICE 서버를 발급 후 emit
const currentIceServers = generateTurnCredentials(clientID);
const partnerIceServers = generateTurnCredentials(partnerID);
currentSession.socket.emit('caller', {
id: partnerSession.socket.id,
iceServers: currentIceServers
});
partnerSession.socket.emit('callee', {
id: currentSession.socket.id,
iceServers: partnerIceServers
});
docker-compose.ymlnetworks:
app-network:
ipam:
config:
- subnet: 172.20.0.0/16
coturn:
image: coturn/coturn:latest
container_name: moachat_coturn
restart: unless-stopped
env_file:
- .env
command: >
-c /etc/coturn/turnserver.conf
--static-auth-secret=${TURN_SECRET}
--external-ip=${TURN_HOST}/172.20.0.10
ports:
- "3479:3479/udp"
- "3479:3479/tcp"
- "49152-49200:49152-49200/udp"
volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
networks:
app-network:
ipv4_address: 172.20.0.10
위 작업들 이후, P2P 연결이 어려운 네트워크 환경에서도 TURN Relay를 통한 연결이 정상적으로 이루어지는 것을 확인했습니다.
WebRTC 라이브러리 구현
WebRTC의 연결 상태와 생명주기, 시그널링 이벤트를 React 컴포넌트에서 함께 관리하면서 UI 코드의 복잡도가 증가했습니다.
이를 해결하기 위해 WebRTC 관련 상태와 로직을 별도의 클래스로 추상화하고 라이브러리로 분리했습니다.
chat.tsx// Before
const peerConnection = useRef<RTCPeerConnection>();
const socket = useRef<Socket>();
const sendChannel = useRef<RTCDataChannel>();
const userID = useRef<string>();
const [connectionState, setConnectionState] = useState<boolean>();
const [concurrentUsers, setConcurrentUsers] = useState<number>(1);
function initiateChat(id: string, isInitializer: boolean) {
const pc = new RTCPeerConnection({
iceServers: process.env.SERVER_LIST,
});
userID.current = id;
peerConnection.current = pc;
if (isInitializer) {
sendChannel.current = pc.createDataChannel('sendChannel');
sendChannel.current.onmessage = handleReceiveMessage;
pc.onnegotiationneeded = handleNegotiationNeededEvent;
}
pc.onicecandidate = handleICECandidateEvent;
pc.onconnectionstatechange = handleConnectionStateChange;
}
// handleOffer, handleICECandidateEvent, handleNegotiationNeededEvent...
// 위와 같은 WebRTC의 이벤트 핸들러들이 UI로직과 결합되어 있었습니다.
useEffect(() => {
socket.current = io(process.env.API_URL);
socket.current.emit('join chat');
socket.current.on('other user', (id: string) => initiateChat(id, true));
socket.current.on('user joined', (id: string) => initiateChat(id, false));
socket.current.on('offer', handleOffer);
socket.current.on('answer', handleAnswer);
socket.current.on('ice-candidate', handleNewICECandidateMsg);
socket.current.on('users', (users: number) => setConcurrentUsers(users));
socket.current.on('user exit', handleUserExit);
}, []);
기존 리액트 클라이언트 코드에서는 socket, peerConnection 등을 관리하기 위해, instance 변수를 선언하고 WebRTC 로직을 해당 컴포넌트에서 관리했습니다.
그러다보니 자연스럽게 기능의 추가, 핸들링 과정에서 복잡도가 증가하여, 라이브러리를 통해 소켓 이벤트들을 클래스 초기화 과정에서 등록하도록 구현했습니다.
WebRTCClient Libraryexport class WebRTCClient {
constructor(private readonly socket: Socket) {
this.registerSocketEvents();
}
private registerSocketEvents() {
this.socket.on('caller', ...)
this.socket.on('callee', ...)
this.socket.on('offer', ...)
this.socket.on('answer', ...)
this.socket.on('ice-candidate', ...)
this.socket.on('user exit', ...)
}
connect() {
this.socket.emit('find match');
}
disconnect() {
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
}
if (this.peerConnection) {
this.removeTracks();
this.peerConnection.close();
this.peerConnection = null;
}
this.partnerID = null;
}
}
Socket 이벤트 등록과 WebRTC 연결 상태는 클래스 내부에서 관리하고, 리액트에는 필요한 기능과 이벤트만 노출하도록 경계를 만들었습니다.
useConnection.ts// After
const [socket] = useState(() =>
io(process.env.NEXT_PUBLIC_API_URL!, {
auth: { id: clientID },
autoConnect: false,
})
);
const [client] = useState(() => new WebRTCClient(socket));
useEffect(() => {
socket.connect();
client.connect();
return () => {
socket.disconnect();
client.disconnect();
}
}, [])
이 구조로 변경하면서 리액트에서는 WebRTC 내부 상태에 따른 이벤트 처리에서 벗어나고, WebRTC의 연결과 생명주기는 WebRTCClient가 독립적으로 관리하도록 했습니다.
Event Emitter 패턴 도입
라이브러리 내부에서 발생하는 WebRTC 이벤트를 React가 필요한 형태로 구독할 수 있도록
on() / off() 기반의 Event Emitter 인터페이스를 구현했습니다.
WebRTCClient Libraryprivate emit<K extends keyof WebRTCEvents>(
event: K,
...args: Parameters<NonNullable<WebRTCEvents[K]>>
) {
this.listeners[event]?.forEach((cb) => {
(cb as any)(...args);
});
}
on<K extends keyof WebRTCEvents>(event: K, callback: WebRTCEvents[K]) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
off<K extends keyof WebRTCEvents>(event: K, callback: WebRTCEvents[K]) {
const callbacks = this.listeners[event];
if (!callbacks) return;
const index = callbacks.indexOf(callback);
if (index !== -1) callbacks.splice(index, 1);
}
// 예를 들어, onmessage 이벤트 발생 시,
// 해당 이벤트를 리스너에 등록하고 리액트 클라이언트에서 on 메소드를 통해 구독할 수 있도록 했습니다.
this.dataChannel.onmessage = (event) => {
this.emit('message', event);
};
useMessage.tsexport function useMessage(client: WebRTCClient) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
if (!client) return;
client.on('message', handleMessage);
return () => {
client.off('message', handleMessage);
};
}, []);
}
Offer Collision 처리
동시에 Offer가 생성되는 충돌 상황을 처리하기 위해 라이브러리에서 Polite / Impolite 역할을 적용했습니다.
이 구현에선 연결을 시작하는 Caller를 Impolite, Offer를 수신하는 Callee를 Polite로 지정했습니다.
WebRTCClient Library// caller (initiator)
this.socket.on(
'caller',
({ id, iceServers }: { id: string; iceServers: RTCIceServer[] }) => {
this.partnerID = id;
this.isPolite = false;
//...
});
// callee (receiver)
this.socket.on(
'callee',
({ id, iceServers }: { id: string; iceServers: RTCIceServer[] }) => {
this.partnerID = id;
this.isPolite = true;
//...
});
this.socket.on('offer', async (payload: Payload) => {
// offer가 발생했을 때 또는 현재 시그널링 상태가 stable가 아닐 경우를 충돌이 난 경우로 판단
const offerCollision =
this.isMakingOffer || this.peerConnection?.signalingState !== 'stable';
// Impolite peer는 협상 충돌이 발생하면 상대 Offer를 무시
const ignoreOffer = !this.isPolite && offerCollision;
if (ignoreOffer) return;
try {
const desc = new RTCSessionDescription(payload.sdp);
await this.peerConnection!.setRemoteDescription(desc);
const answer = await this.peerConnection!.createAnswer();
await this.peerConnection!.setLocalDescription(answer);
this.socket.emit('answer', {
target: payload.caller,
caller: this.socket.id,
sdp: this.peerConnection!.localDescription,
});
} catch (err) {
console.error('Error handle offer', err);
}
});
WebRTC 라이브러리를 구현하면서 UI를 담당하는 리액트 클라이언트와 경계를 분리하여, 각 역할에 맞춰 보다 더 충실한 개발을 할 수 있게 되었습니다.