Socket.IO, Angular 및 Node.js를 사용하여 실시간 앱을 만드는 방법
소개
WebSocket은 서버와 클라이언트 간의 전이중 통신을 허용하는 인터넷 프로토콜입니다. 이 프로토콜은 일반적인 HTTP 요청 및 응답 패러다임을 뛰어넘습니다. WebSockets를 사용하면 클라이언트가 요청을 시작하지 않고도 서버가 클라이언트에 데이터를 보낼 수 있으므로 일부 매우 흥미로운 애플리케이션이 가능합니다.
이 자습서에서는 실시간 문서 공동 작업 애플리케이션(Google Docs와 유사)을 빌드합니다. 이를 위해 Angular 7을 사용할 것입니다.
GitHub에서 이 예제 프로젝트의 전체 소스 코드를 찾을 수 있습니다.
전제 조건
이 자습서를 완료하려면 다음이 필요합니다.
- Node.js를 로컬에 설치했습니다. Node.js를 설치하고 로컬 개발 환경을 만드는 방법에 따라 수행할 수 있습니다.
- WebSocket을 지원하는 최신 웹 브라우저.
이 튜토리얼은 원래 Node.js v8.11.4, npm v6.4.1 및 Angular v7.0.4로 구성된 환경에서 작성되었습니다.
이 튜토리얼은 Node v14.6.0, npm v6.14.7, Angular v10.0.5 및 Socket.IO v2.3.0에서 검증되었습니다.
1단계 - 프로젝트 디렉토리 설정 및 소켓 서버 생성
먼저 터미널을 열고 서버와 클라이언트 코드를 모두 포함할 새 프로젝트 디렉토리를 만듭니다.
- mkdir socket-example
다음으로 프로젝트 디렉터리로 변경합니다.
- cd socket-example
그런 다음 서버 코드를 위한 새 디렉터리를 만듭니다.
- mkdir socket-server
그런 다음 서버 디렉토리로 변경하십시오.
- cd socket-server
그런 다음 새 npm
프로젝트를 초기화합니다.
- npm init -y
이제 패키지 종속성을 설치합니다.
- npm install express@4.17.1 socket.io@2.3.0 @types/socket.io@2.1.10 --save
이러한 패키지에는 Express, Socket.IO 및 @types/socket.io
가 포함됩니다.
이제 프로젝트 설정을 완료했으므로 서버용 코드 작성으로 이동할 수 있습니다.
먼저 새 src
디렉토리를 만듭니다.
- mkdir src
이제 src
디렉토리에 app.js
라는 새 파일을 만들고 원하는 텍스트 편집기를 사용하여 엽니다.
- nano src/app.js
Express 및 Socket.IO에 대한 requi
문으로 시작합니다.
const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
보시다시피 우리는 Express를 사용하고 있으며 Socket.IO는 기본 WebSocket에 대한 추상화 계층을 제공합니다. WebSocket을 지원하지 않는 이전 브라우저에 대한 폴백 메커니즘 및 방 생성 기능과 같은 멋진 기능이 함께 제공됩니다. 잠시 후에 이것이 실제로 작동하는 것을 보게 될 것입니다.
실시간 문서 협업 애플리케이션의 목적을 위해 문서
를 저장할 방법이 필요합니다. 프로덕션 설정에서는 데이터베이스를 사용하려고 하지만 이 자습서의 범위에서는 문서
의 메모리 내 저장소를 사용합니다.
const documents = {};
이제 소켓 서버가 실제로 수행할 작업을 정의해 보겠습니다.
io.on("connection", socket => {
// ...
});
이것을 분해합시다. .on(...)
은 이벤트 리스너입니다. 첫 번째 매개변수는 이벤트의 이름이고 두 번째 매개변수는 일반적으로 이벤트 페이로드와 함께 이벤트가 실행될 때 실행되는 콜백입니다.
첫 번째 예는 클라이언트가 소켓 서버에 연결할 때입니다(연결
은 Socket.IO에서 예약된 이벤트 유형임).
하나의 소켓 또는 여러 소켓(즉, 브로드캐스팅)에 대한 통신을 시작하기 위해 콜백에 전달할 소켓
변수를 얻습니다.
safeJoin
회의실 참여 및 퇴장을 처리하는 로컬 함수(safeJoin
)를 설정할 것입니다.
io.on("connection", socket => {
let previousId;
const safeJoin = currentId => {
socket.leave(previousId);
socket.join(currentId, () => console.log(`Socket ${socket.id} joined room ${currentId}`));
previousId = currentId;
};
// ...
});
이 경우 클라이언트가 방에 참가하면 특정 문서를 편집하고 있는 것입니다. 따라서 여러 고객이 같은 방에 있으면 모두 같은 문서를 편집하고 있는 것입니다.
기술적으로 소켓은 여러 방에 있을 수 있지만 한 클라이언트가 동시에 여러 문서를 편집하는 것을 원하지 않으므로 문서를 전환하는 경우 이전 방을 떠나 새 방에 참여해야 합니다. 이 작은 기능이 이를 처리합니다.
소켓이 클라이언트에서 수신하는 세 가지 이벤트 유형이 있습니다.
getDoc
addDoc
editDoc
소켓에서 클라이언트로 보내는 두 가지 이벤트 유형은 다음과 같습니다.
문서
문서
문서 가져오기
첫 번째 이벤트 유형인 getDoc
에 대해 작업해 보겠습니다.
io.on("connection", socket => {
// ...
socket.on("getDoc", docId => {
safeJoin(docId);
socket.emit("document", documents[docId]);
});
// ...
});
클라이언트가 getDoc
이벤트를 내보낼 때 소켓은 페이로드(우리의 경우 ID일 뿐임)를 가져오고 해당 docId
로 방에 참가한 다음 내보낼 것입니다. 저장된 문서
는 시작 클라이언트에만 반환됩니다. 여기서 socket.emit(document, ...)
가 작동합니다.
addDoc
두 번째 이벤트 유형인 addDoc
에 대해 작업해 보겠습니다.
io.on("connection", socket => {
// ...
socket.on("addDoc", doc => {
documents[doc.id] = doc;
safeJoin(doc.id);
io.emit("documents", Object.keys(documents));
socket.emit("document", doc);
});
// ...
});
addDoc
이벤트에서 페이로드는 document
객체이며, 현재 클라이언트에서 생성한 ID로만 구성됩니다. 우리는 소켓에 해당 ID의 방에 참여하도록 지시하여 향후 편집 내용을 같은 방에 있는 모든 사람에게 방송할 수 있도록 합니다.
다음으로 서버에 연결된 모든 사람이 작업할 새 문서가 있음을 알기를 원하므로 io.emit(documents, ...)
기능을 사용하여 모든 클라이언트에 브로드캐스트합니다.
socket.emit()
와 io.emit()
의 차이점에 유의하세요. io
버전은 우리 서버에 연결된 모든 사람에게 방출하기 위한 것입니다.
editDoc
세 번째 이벤트 유형인 editDoc
에 대해 작업해 보겠습니다.
io.on("connection", socket => {
// ...
socket.on("editDoc", doc => {
documents[doc.id] = doc;
socket.to(doc.id).emit("document", doc);
});
// ...
});
editDoc
이벤트를 사용하면 페이로드는 키 입력 후 해당 상태의 전체 문서가 됩니다. 데이터베이스의 기존 문서를 교체한 다음 현재 해당 문서를 보고 있는 클라이언트에게만 새 문서를 브로드캐스트합니다. 특정 방에 있는 모든 소켓으로 방출하는 socket.to(doc.id).emit(document, doc)
를 호출하여 이를 수행합니다.
마지막으로 새 연결이 만들어질 때마다 새 연결이 연결할 때 최신 문서 변경 사항을 수신하도록 모든 클라이언트에 브로드캐스트합니다.
io.on("connection", socket => {
// ...
io.emit("documents", Object.keys(documents));
console.log(`Socket ${socket.id} has connected`);
});
소켓 기능이 모두 설정되면 포트를 선택하고 수신합니다.
http.listen(4444, () => {
console.log('Listening on port 4444');
});
터미널에서 다음 명령을 실행하여 서버를 시작합니다.
- node src/app.js
이제 문서 협업을 위한 완전한 기능을 갖춘 소켓 서버가 생겼습니다!
2단계 — @angular/cli 설치 및 클라이언트 앱 만들기
새 터미널 창을 열고 프로젝트 디렉토리로 이동합니다.
다음 명령을 실행하여 Angular CLI를 devDependency
로 설치합니다.
- npm install @angular/cli@10.0.4 --save-dev
이제 @angular/cli
명령을 사용하여 Angular Routing이 없고 스타일링을 위한 SCSS가 있는 새 Angular 프로젝트를 만듭니다.
- ng new socket-app --routing=false --style=scss
그런 다음 서버 디렉토리로 변경하십시오.
- cd socket-app
이제 패키지 종속성을 설치합니다.
- npm install ngx-socket-io@3.2.0 --save
ngx-socket-io
는 Socket.IO 클라이언트 라이브러리에 대한 Angular 래퍼입니다.
그런 다음 @angular/cli
명령을 사용하여 문서
모델, 문서 목록
구성 요소, 문서
를 생성합니다. > 구성 요소 및 문서
서비스:
- ng generate class models/document --type=model
- ng generate component components/document-list
- ng generate component components/document
- ng generate service services/document
이제 프로젝트 설정을 완료했으므로 클라이언트용 코드 작성으로 이동할 수 있습니다.
앱 모듈
app.modules.ts
를 엽니다.
- nano src/app/app.module.ts
그리고 FormsModule
, SocketioModule
및 SocketioConfig
를 가져옵니다.
// ... other imports
import { FormsModule } from '@angular/forms';
import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';
그리고 @NgModule
선언 전에 config
를 정의합니다.
const config: SocketIoConfig = { url: 'http://localhost:4444', options: {} };
이것이 서버의 app.js
에서 이전에 선언한 포트 번호임을 알 수 있습니다.
이제 imports
배열에 추가하여 다음과 같이 표시합니다.
@NgModule({
// ...
imports: [
// ...
FormsModule,
SocketIoModule.forRoot(config)
],
// ...
})
이렇게 하면 AppModule
이 로드되는 즉시 소켓 서버에 대한 연결이 시작됩니다.
문서 모델 및 문서 서비스
document.model.ts
를 엽니다.
- nano src/app/models/document.model.ts
그리고 id
및 doc
를 정의합니다.
export class Document {
id: string;
doc: string;
}
document.service.ts
를 엽니다.
- nano src/app/services/document.service.ts
그리고 클래스 정의에 다음을 추가합니다.
import { Injectable } from '@angular/core';
import { Socket } from 'ngx-socket-io';
import { Document } from 'src/app/models/document.model';
@Injectable({
providedIn: 'root'
})
export class DocumentService {
currentDocument = this.socket.fromEvent<Document>('document');
documents = this.socket.fromEvent<string[]>('documents');
constructor(private socket: Socket) { }
getDocument(id: string) {
this.socket.emit('getDoc', id);
}
newDocument() {
this.socket.emit('addDoc', { id: this.docId(), doc: '' });
}
editDocument(document: Document) {
this.socket.emit('editDoc', document);
}
private docId() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
}
여기서 메서드는 각각 소켓 서버가 수신하는 세 가지 이벤트 유형을 내보낸다는 것을 나타냅니다. 속성 currentDocument
및 documents
는 클라이언트에서 Observable
로 사용되는 소켓 서버에서 방출된 이벤트를 나타냅니다.
this.docId()
에 대한 호출을 확인할 수 있습니다. 이것은 문서 ID로 할당할 임의의 문자열을 생성하는 작은 개인 메서드입니다.
문서 목록 구성 요소
문서 목록을 sidenav에 넣겠습니다. 지금은 임의의 문자열인 docId
만 표시됩니다.
document-list.component.html
을 엽니다.
- nano src/app/components/document-list/document-list.component.html
그리고 내용을 다음으로 바꿉니다.
<div class='sidenav'>
<span
(click)='newDoc()'
>
New Document
</span>
<span
[class.selected]='docId === currentDoc'
(click)='loadDoc(docId)'
*ngFor='let docId of documents | async'
>
{{ docId }}
</span>
</div>
document-list.component.scss
열기:
- nano src/app/components/document-list/document-list.component.scss
그리고 몇 가지 스타일을 추가합니다.
.sidenav {
background-color: #111111;
height: 100%;
left: 0;
overflow-x: hidden;
padding-top: 20px;
position: fixed;
top: 0;
width: 220px;
span {
color: #818181;
display: block;
font-family: 'Roboto', Tahoma, Geneva, Verdana, sans-serif;
font-size: 25px;
padding: 6px 8px 6px 16px;
text-decoration: none;
&.selected {
color: #e1e1e1;
}
&:hover {
color: #f1f1f1;
cursor: pointer;
}
}
}
document-list.component.ts
를 엽니다.
- nano src/app/components/document-list/document-list.component.ts
그리고 클래스 정의에 다음을 추가합니다.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { DocumentService } from 'src/app/services/document.service';
@Component({
selector: 'app-document-list',
templateUrl: './document-list.component.html',
styleUrls: ['./document-list.component.scss']
})
export class DocumentListComponent implements OnInit, OnDestroy {
documents: Observable<string[]>;
currentDoc: string;
private _docSub: Subscription;
constructor(private documentService: DocumentService) { }
ngOnInit() {
this.documents = this.documentService.documents;
this._docSub = this.documentService.currentDocument.subscribe(doc => this.currentDoc = doc.id);
}
ngOnDestroy() {
this._docSub.unsubscribe();
}
loadDoc(id: string) {
this.documentService.getDocument(id);
}
newDoc() {
this.documentService.newDocument();
}
}
속성부터 시작하겠습니다. documents
는 사용 가능한 모든 문서의 스트림입니다. currentDocId
는 현재 선택된 문서의 ID입니다. 문서 목록은 우리가 어떤 문서에 있는지 알아야 하므로 sidenav에서 해당 문서 ID를 강조 표시할 수 있습니다. _docSub
는 현재 또는 선택된 문서를 제공하는 Subscription
에 대한 참조입니다. ngOnDestroy
수명 주기 메서드에서 구독을 취소하려면 이것이 필요합니다.
loadDoc()
및 newDoc()
메서드는 아무 것도 반환하거나 할당하지 않습니다. 소켓 서버로 이벤트를 발생시키고 Observable로 다시 이벤트를 발생시킨다는 점을 기억하세요. 기존 문서를 가져오거나 새 문서를 추가하기 위해 반환되는 값은 위의 Observable
패턴에서 실현됩니다.
문서 구성 요소
이것은 문서 편집 표면이 될 것입니다.
document.component.html
을 엽니다.
- nano src/app/components/document/document.component.html
그리고 내용을 다음으로 바꿉니다.
<textarea
[(ngModel)]='document.doc'
(keyup)='editDoc()'
placeholder='Start typing...'
></textarea>
document.component.scss
를 엽니다.
- nano src/app/components/document/document.component.scss
그리고 기본 HTML textarea
에서 일부 스타일을 변경합니다.
textarea {
border: none;
font-size: 18pt;
height: 100%;
padding: 20px 0 20px 15px;
position: fixed;
resize: none;
right: 0;
top: 0;
width: calc(100% - 235px);
}
document.component.ts
를 엽니다.
- src/app/components/document/document.component.ts
그리고 클래스 정의에 다음을 추가합니다.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { Document } from 'src/app/models/document.model';
import { DocumentService } from 'src/app/services/document.service';
@Component({
selector: 'app-document',
templateUrl: './document.component.html',
styleUrls: ['./document.component.scss']
})
export class DocumentComponent implements OnInit, OnDestroy {
document: Document;
private _docSub: Subscription;
constructor(private documentService: DocumentService) { }
ngOnInit() {
this._docSub = this.documentService.currentDocument.pipe(
startWith({ id: '', doc: 'Select an existing document or create a new one to get started' })
).subscribe(document => this.document = document);
}
ngOnDestroy() {
this._docSub.unsubscribe();
}
editDoc() {
this.documentService.editDocument(this.document);
}
}
위의 DocumentListComponent
에서 사용한 패턴과 유사하게 현재 문서의 변경 사항을 구독하고 현재 문서를 변경할 때마다 소켓 서버에 이벤트를 발생시킵니다. 즉, 다른 클라이언트가 우리와 동일한 문서를 편집하는 경우 모든 변경 사항을 볼 수 있으며 그 반대의 경우도 마찬가지입니다. 우리는 RxJS startWith
연산자를 사용하여 사용자가 앱을 처음 열 때 작은 메시지를 보냅니다.
AppComponent
app.component.html
을 엽니다.
- nano src/app.component.html
그리고 내용을 다음으로 대체하여 두 개의 사용자 지정 구성 요소를 구성합니다.
<app-document-list></app-document-list>
<app-document></app-document>
3단계 - 실행 중인 앱 보기
소켓 서버가 여전히 터미널 창에서 실행 중인 상태에서 새 터미널 창을 열고 Angular 앱을 시작하겠습니다.
- ng serve
별도의 브라우저 탭에서 http://localhost:4200
인스턴스를 두 개 이상 열고 작동 상태를 확인합니다.

이제 새 문서를 만들고 두 브라우저 창에서 모두 업데이트되는 것을 볼 수 있습니다. 한 브라우저 창에서 변경하고 다른 브라우저 창에 반영된 변경 사항을 볼 수 있습니다.
결론
이 자습서에서는 WebSocket 사용에 대한 초기 탐색을 완료했습니다. 실시간 문서 협업 애플리케이션을 구축하는 데 사용했습니다. 서버에 연결하고 여러 문서를 업데이트 및 수정하기 위해 여러 브라우저 세션을 지원합니다.
Angular에 대해 자세히 알아보려면 연습 및 프로그래밍 프로젝트에 대한 Angular 주제 페이지를 확인하십시오.
Vue.js와 Socket.IO 통합에 대해 더 알고 싶다면.
추가 WebSocket 프로젝트에는 실시간 채팅 애플리케이션이 포함됩니다. React 및 GraphQL을 사용하여 실시간 채팅 앱을 구축하는 방법을 참조하십시오.