⚠︎ 주의 ⚠︎
1. 코드에서 악취가 날 수도 있습니다.
2. 작성자도 다시 꺼내보기 어려운, 오래전에 작성한 코드입니다. (3년 전)
3. "어? 이거 어디서 본 코드 같은데" 네, 맞습니다.
Prologue
안녕하세요. 포트폴리오 만들 겸 경험 정리할 겸 글을 쓰게 되었습니다. 저는 어쩌다 보니 프로젝트 3개를 진행하면서 전부 웹소켓을 담당했습니다. 이전 플젝의 리팩터링은 생각지도 못했고 새로운 프로젝트를 진행할 때 조금씩 변형만 했습니다. 이제 와서 제 코드를 살짝 들춰보니 무슨 괴물을 만들어냈는지 모르겠습니다..
그래서?
그래도 그때 나름대로 회의하면서 "이렇게하면 성능 최적화가 되는 건가?"하고 구현만 했었는데 .. 성능 테스트도 못했고 만족스러운 결과도 얻지 못했습니다. 그래서! 이 시리즈는 ...
총 3개의 프로젝트를 하면서 작성했던 코드를 먼저 셀프-리뷰를 한 후에 마지막 프로젝트에서 적용하려다가 시간 상의 이유로 적용하지 못했던 궁극의 "웹소켓 고도화"를 해보려 합니다.
+ 피드백 댓글/메일 환영합니다.
What is WebSocket?
그래서 웹소켓이 뭘까요..?
"학교에서 배웠던 TCP/UDP 소켓 통신에서 사용하는 그 소켓이 맞나요?"
네, 맞습니다. 아, 살짝 달라요.
소켓은 ...
소켓(socket)은 버클리 대학에서 만들어져 1982년 BSD UNIX 4.1에서 처음 ...
아... 역사 시간이 아닙니다. 일단 네트워크 통신을 이해하기 위해서는 포트번호와 소켓의 개념을 명확히 아는 것이 우선입니다.
"엥 결국에 포트번호로 소켓을
구분하는 거 아닌가요 ?-?"
물론, 이 둘은 밀접한 연관성을 가지고 있습니다.
먼저 포트번호에 대해서 알아보겠습니다.
Port Number?
포트번호는 특정 서비스를 식별하는 데 사용되는 숫자입니다. 포트번호는 보통 IP 주소 뒤에 붙어있다는 건 공공연히 아는 사실이죠. IP 주소는 네트워크 상의 장치를 식별하는 역할을 하고, 포트번호는 해당 장치에서 실행 중인 특정 서비스를 식별합니다. 이 둘을 합치면? Socket 주소가 됩니다.
Socket?
소켓은 네트워크 통신의 종단점, Endpoint 입니다. 소켓은 IP 주소와 포트번호의 조합이며, 네트워크 상에서 프로세스 간 통신 경로를 설정합니다. 여기에 TCP나 UDP처럼 어떤 프로토콜을 사용할 것이냐도 포함됩니다.
또한 소켓은 두 가지 유형으로 나뉩니다. 흔히들 아는 TCP 소켓과 UDP 소켓이죠. 이 둘의 차이를 잘 모르겠다면 당장 전공책을 피고 OSI 7 계층을 공부하십시오.
그래서 이 둘을 도대체 어떻게 써먹는 건가요?
예시를 들어봅시다. 클라이언트 소켓과 서버 소켓이 있습니다. 클라이언트 애플리케이션이 서버에 연결을 시도할 때, 클라이언트는 임의의 포트를 할당받아 소켓을 생성합니다.(192.168.1.2:51515) 서버 애플리케이션은 특정 포트를 열어서 클라이언트 요청을 수신합니다. 웹서버는 192.168.1.1:80 소켓을 열어 두고, 클라이언트 요청을 대기합니다. 서버와 클라이언트가 통신을 시작하면 소켓 페어가 형성됩니다. 이를 통해 양방향 데이터 전송이 이루어지는 것이죠.
그러면 그냥 소켓을 쓰면 되지 않나요?
자, 그러면 왜 웹소켓이 등장하게 되었냐부터 생각해봐야 합니다. 시간이 흐르면서 웹 애플리케이션이 점점 복잡해지며 실시간 양방향 통신의 필요성이 증가했었다, HTTP 프로토콜은 Stateless, Connectionless 하기 때문에 그렇다.라는 이야기가 나오겠죠? 그런데 여기서는 소켓과 웹소켓을 통해 이야기해 보도록 하겠습니다.
소켓 프로그래밍은 복잡합니다.
웹소켓이 쉽다고 느껴질 때도 있었습니다. 연결 잘 되고, 데이터 잘 넘어오기만 하면 되는 거 아닌가 생각했었습니다. 제대로 다뤄보지 못한 거죠. 어려운 기술 맞습니다.
소켓으로 내려가보자면, 소켓은 다음과 같은 특징을 가지고 있죠.
1. 기본 소켓(TCP/UDP)은 저수준 네트워크 통신입니다. 운영 체제 수준에서 관리됩니다.
2. 연결 설정, 데이터 전송, 오류 처리 같은 걸 모-두 개발자가 직접 관리해야 합니다.
3. 방화벽과 프록시 서버 설정이 복잡합니다.
4. 일반 소켓 통신은 보안을 보장해주지 않습니다. SSL/TLS 같은 별도의 암호화 계층이 필요합니다.
SSL/TLS는 흔히들 아시는 HTTPS와 관련이 있습니다. TLS는 다양한 종류의 보안 통신(HTTPS, FTPS, SMTPS)을 하기 위한 프로토콜입니다. 보안 연결을 설정하고, 데이터를 암호화하는 역할을 맡고 있습니다. HTTP 위에 TLS를 적용해서 보안을 강화한 프로토콜이 HTTPS죠. TLS 핸드셰이크니 뭐니도 알고 가면 좋긴 한데 웹소켓 시리즈니 여기선 SSL/TLS != HTTPS 정도만 가져갑시다.
그럼 WebSocket 핸드셰이크를 알면 되겠죠?
그전에, 제가 소켓의 특징에서 연결 설정, 데이터 전송, 오류 처리 등을 모두 개발자가 직접 관리해야 한다고 했었습니다.
웹소켓도 똑같이 개발자가 관리해야 하는 거 아닌가요? 벅벅 ..
라고 할 수도 있겠지만 WebSocket 프로그래밍에서는 기본적인 소켓 프로그래밍보다는 많은 복잡한 작업들이 추상화되어 있습니다. 저수준 네트워킹 작업에 비해 고수준 API를 통해 간단하게 실시간 양방향 통신을 구현할 수 있습니다.
학부생 시절에 팀 프로젝트로 만들었던 UDP 다중 채팅 프로젝트를 잠깐 열어볼까요?
Github 계정이 저 당시에도 있긴 했지만 주어진 시간 대비 리소스 부족으로 인해.. Git을 모르는 팀원들에게 Git을 알려줄 수 없었습니다.. 주석으로 달려있는 보급형 커밋 내역 ...z
일단은 TCP 소켓 동작 과정을 봅시다.
소켓을 생성하고, IP + Port를 바인드 하고, 클라이언트가 접속하기를 기다렸다가 연결 요청이 오면 확인하고 연결하고 ... 벌써부터 머리 아프죠? 프로젝트는 환경 설정이 70%입니다. 개발은 다들 금방 해요.
UDP 소켓은 연결하지 않습니다. 그렇기 때문에 절차가 좀 더 간소한 편입니다.
아니, 이 양반아. Connect()가 있는데 왜 연결하지 않는다고 하냐?
네, 설명드리겠습니다. 엄연히 UDP 소켓에서의 connect()와 TCP 소켓에서의 connect()는 다르게 동작합니다. 사실, 저 동작 방식 이미지에서 connect() 부분을 제거해도 문제없습니다. 코드로 한번 볼까요?
강조 표시를 하고 싶어서 코드 블록 말고 이미지로 가져왔습니다. 소켓 주소를 구조체에다 넣어주었습니다.
// 소켓 주소 구조체 초기화
SOCKADDR_IN serveraddr;
그다음, 클라이언트 측에서는 이 정보를 활용해서 sendto()가 호출될 때마다 인자로 같이 넣어주는 형태입니다.
어때요, 간단하죠? 그런데 왜 connect()가 있을까요? 안 써도 잘만 되는데? 왜 Why?
UDP 소켓에서 connect()의 역할을 알아봅시다.
1. 기본 송신 주소를 설정합니다! 매번 목적지 주소를 지정할 필요가 없어짐.
2. 메시지 필터링이 가능합니다! 지정된 주소에서만 오는 데이터만 수신.
3. ICMP 오류 메시지를 수신할 수 있게 됩니다! (목적지 네트워크 도달 불가능한 경우를 감지)
근데, 님은 왜 connect()를 안 쓰고 구현했음?
음, 핑계를 대보자면 당시에 학교 기숙사에 거주하고 있었습니다. 서버를 제가 담당했으니 방에 꽂아둔 공유기 설정 만지면서 포트포워딩을 했었죠.. 다른 층에 거주하는 친구한테 부탁해서 클라이언트 코드를 건네고 테스트를 시켜보았습니다. 채팅이 잘 되긴 했었습니다. 잘 되긴 했는데 문제는 학교 네트워크가 아닌 다른 원격 환경에서는 제 서버로 접근이 안되었다는 점 ..
사감님께 부탁해서 건너 건너 요청을 드려봤는데 보안 이슈로 거절당했습니다. 그래서 본가에 거주하는 다른 팀원들이 서버를 열어야만 하는 상황이었습니다. 그래서 원활한 테스트를 위해 connect()를 쓰지 않고 구현했습니다. 그 당시에는 Git을 사용하지 않아서 코드 한 줄 바꿔도 업데이트된 파일을 공유하는 절차도 번거로웠거든요.
사실 다른 조들 죄다 TCP로 프로젝트하길래 좀 힙해 보이고 싶어서 UDP로 채팅 만들었는데 3년 뒤에 다시 들춰보니 그땐 신경도 쓰지 못했던 부분들을 좀 더 알게 되고 UDP의 비연결 지향 특성을 유지하면서도 적당한 명분이 생겨서 완전 럭키매드잖아 🍀
.. 좀 더 첨언을 하자면 connect()를 쓰면 sendto() 대신에 TCP에서 사용하는 send()를 쓸 수 있습니다.
이건 connect()와는 별개의 이야기인데, UDP 소켓 통신은 서버의 소켓 하나만 열어놓으면 만사 OK.
TCP처럼 새로운 클라이언트가 생길 때마다 소켓을 새로 열지 않아도 됩니다.
이제 서버 코드 부분을 같이 살펴봅시다.
// socket()
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == INVALID_SOCKET) err_quit("socket()");
// bind()
SOCKADDR_IN serveraddr;
ZeroMemory(&serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERVERPORT);
retval = bind(sock, (SOCKADDR*)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) err_quit("bind()");
// 데이터 통신에 사용할 변수
SOCKADDR_IN clientaddr;
int addrlen;
char buf[BUFSIZE + 1];
HANDLE ThreadHandle = NULL;
USERINFO tmpinfo;
// 클라이언트와 데이터 통신
while (1) {
if (ThreadHandle == NULL) {
// 데이터 받기
addrlen = sizeof(clientaddr);
retval = recvfrom(sock, buf, BUFSIZE, 0,
(SOCKADDR*)&clientaddr, &addrlen);
if (retval == SOCKET_ERROR) {
err_display("recvfrom()");
continue;
}
//첫 연결
v.push_back(clientaddr);
//onlineUser.push_back(clientaddr);
buf[retval] = '\0';
//중복체크 초기화 (첫번째 닉네임)
DUPNICKINFO tmpdupinfo;
tmpdupinfo.dupnick = buf;
tmpdupinfo.dupnickcount = 0;
dupInfo.push_back(tmpdupinfo);
...
따로 connect()를 호출하지 않아도 됩니다! 클라이언트 측에서 서버가 열어놓은 소켓 주소만 잘 맞춰서 전송하면 끝!
아니 웹소켓인데 이런 거까지 알아야 하나요? 그리고 올려주신 건 UDP잖아요. 심지어 Java도 아님.
알려준다던 WebSocket 핸드셰이크는 언제 알려줌?
자, 그동안 low한 레벨의 소켓을 왜 주저리 주저리 했느냐. 저 번거로운 작업을 추상화하고 쉽게 웹에서 사용할 수 있도록 한 것이 웹소켓이거든요. 상단에 첨부한 코드 이미지는 첫 번째 프로젝트에서 웹소켓을 연결할 때 작성한 코드입니다. 유저가 채팅방에 들어가면-? 페이지가 마운트 될 때 웹소켓에 연결하는 형태였습니다.
클라이언트에서는 그냥 new WebSocket()하면 끝. 이게 다진 않지만 일단은요.
아래는 다른 프로젝트에서 적용했던 nginx 설정 파일의 일부분입니다.
location /api/ws {
proxy_pass http://gateway:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
...
}
리버스 프록시를 통해 클라이언트 요청을 웹소켓 업그레이드 헤더를 추가하고 서버로 넘겨버렸습니다. 그럼 서버는 HTTP 핸드셰이크를 통해 WebSocket 연결을 업그레이드합니다.
그럼 웹소켓 연결의 Request와 Response 구조를 잠깐 보고 지나가겠습니다.
Request
GET ws://{주소}:{포트번호}/ HTTP/1.1
Host: {서버 주소}
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: b6gjhT32u488lpuRwKaOWs==
Response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: rG8wsswmHTJ85lJgAE3M5RTmcCE=
지금은 서비스를 돌리고 있는 프로젝트가 없어서 예제를 가져왔습니다. 대충 어떤 느낌이냐면,
- 웹소켓 업그레이드 Request를 GET 요청으로 보냈을 때 Sec-WebSocket-Key를 같이 보냅니다.
- 이 base64로 인코딩 된 Key는 핸드셰이크 응답을 검증하기 위한 값으로 이루어져 있습니다.
- 서버로부터 온 Accept 값을 클라이언트에서 검증한 후 Accept되면 핸드셰이크를 종료하고 웹소켓을 통해 양방향 데이터 송수신을 할 수 있게 되는 것입니다.
이보세요. 굳이 웹소켓을 쓰지 않고도 클라이언트와 서버의 연결을 일정 시간 동안 유지하는
Keep-Alive가 있지 않습니까?
정확합니다. Connection 헤더에 keep-alive를 추가하면 됩니다! 그러면 클라이언트가 서버에 연결을 유지하도록 요청합니다. 서버는 일정 시간 동안 연결을 유지하며, 추가 요청을 같은 연결에서 처리합니다.
요청의 형태는 다음과 같죠.
GET /index.html HTTP/1.1
Host: {주소}
Connection: keep-alive
But, 다음과 같은 이유로 권장하지는 않는 방식입니다.
그리고 무엇보다 겉으로 보이는 형태는 연결을 유지하므로 양방향인 것처럼 보이겠지만 한계가 있습니다. 애초에 keep-alive의 목적이 각 HTTP 요청에 대해 새로운 TCP 연결을 열 필요성을 없애는 것일 뿐.. 연결이 지속되는 동안에는 기본적으로 HTTP Request/Response 패턴입니다. 서버에서는 클라이언트가 요청한 응답 외에는 다른 데이터를 주지 못합니다.
WebSocket!?
자, 이제 왜 웹소켓이 나오게 되었는지 어느 정도 알 것 같죠? 왜 7 계층이지만 4 계층인 TCP에 의존하는지도 감이 잡힐 겁니다. TCP 기반 소켓 API를 대체할 목적으로 나온 녀석이니까요. 소켓에 대한 이해를 한번 점검하는 것이 좋을 것 같았습니다. ws나 wss 같은 부분은 프로젝트 셀프-리뷰를 하면서 짚어보겠습니다. 인프라적 부분도 나와야 해서 ^^;
한 줄로 정리하자면,
소켓은 Endpoint, 웹소켓은 Protocol
이번 주제는 여기까지입니다.
본편에서는 가볍게 실시간 통신 방식에 대해서만 알아보고 첫 번째 프로젝트에서 고민했던 부분을 집중적으로 다루면서 리뷰를 해보도록 하겠습니다.. 아마 다음과 같은 양식이 될 거 같네요.
실시간 통신 방식
- HTTP Polling
- Long Polling
- HTTP Streaming
첫번째 프로젝트 프리뷰
첫번째 프로젝트는 백엔드 팀원이 5명이었습니다. 그러다 보니 5명 모두 풀스택을 했어야 했고.. api를 만들면서 알아서 Vue3로 프론트 작업도 해야 했습니다. 저는 그때 막 SprinBoot를 배웠던 상태였는데 뭐부터 해야 할지 갈팡질팡 하느라 정신이 없었습니다. 역할 배분도 어려워서 "하나라도 잘하자"라는 마인드로 채팅을 맡았습니다. 웹소켓 코드를 구글링으로 뚝딱 거리던 도중.. Kafka와 RabbitMQ를 알게 되었고...
Prologue가 아닌 본편에서 뵙겠습니다. (_ _)
'Devlog > SpringBoot' 카테고리의 다른 글
[Spring Boot] Properties Encryption (0) | 2024.07.18 |
---|---|
[spring] java.lang.IllegalStateException: Module entity with name: [name] should be available 발생 (0) | 2023.06.23 |