[JAVA / 네트워크] 간단한 통신 메세지 프로토콜 구성하기
Development/Java

[JAVA / 네트워크] 간단한 통신 메세지 프로토콜 구성하기

반응형

해당 글은 직접 Message를 다루는 코드를 짜보는 거에 의의를 두고


만약 실제로 메세지 송/수신에 필요한 프로토콜을 쓰고 싶다면 이거말고 

MessagePack(MsgPack), Protobuf, Flatbuffers, Thrift, GRPC를 사용하도록 합시다




사실 이 글은 필자가 직접 느껴왔던 어려움을 적는(?) 포스팅이다.


대학교 재학 중, 네트워크 과목을 이수하면서 JAVA와 C계열 언어로 채팅 프로그램을 개발해야 했던 적이 있었는데


그 때는 진짜 아무런 기초가 없었기 때문에 지금와서 돌이켜보면 매우 비효율적으로 메세지 송/수신을 했다.


컴퓨터에서 다른 컴퓨터로 데이터를 전송하기 위해선 논리적인 연결이 필요한데, 그것이 많이 들어봤을법한 TCP / UDP / HTTP 등으로 연결을 하는 것이다.


위의 대표적인 세 가지 프로토콜은 현재 온라인 게임에 사용되고 있으며 각각의 특징들을 가지고 있다.


TCP : 연결 지향 / 신뢰 보장

UDP : 비연결형 / 신뢰 보장 X

HTTP :  비연결형 / 신뢰 보장 X


연결과 비연결의 차이는 간단하다.


연결은 대상지와 계속 연결을 하고 있는 것이며(전화를 통해 서로 붙잡고 있다고 보는 것이 이해가 쉽다)


비연결은 우편함을 통한 메세지를 주고받듯이, 반드시 그 자리에 없어도 통신은 진행된다(따라서 종종 패킷 손실이 일어나기도 한다)


프로토콜은 연결 "방식"에 관한 것이다. 연결을 계속 유지할 것인지, 아님 동시접속을 늘리기 위해서 연결을 유지하지 않고 통신을 하던지 말이다.


이럴때 메세지를 주고받는 포맷이 존재해야 한다.


엄연히 말하자면 어떤 데이터형을 주고 받던지, 컴퓨터에서 내부적으로 동작하는 것은 "Byte" 단위로 데이터를 주고 받는다.


이 것을 String 혹은 다른 Object 형식으로 주고 받게 되면 그에 따른 박싱 / 언박싱이 발생하게 된다.


그렇기 때문에 대부분의 경우 Byte[]의 형태로 패킷을 구성하고 보내게 된다.


이 때 메세지 포맷을 어떤 클래스로 정의해도 상관없고, 구조체로 정의해도 상관이 없다.


다만 비용측면에서 접근했을 때, 그리 효율적이진 않을 것이라고 본다. 

또한 구조체의 경우 정해진 길이만큼 메세지가 항상 일정해야 하는데, 채팅이나 전투에 관련된 메세지의 경우


그런 메세지가 항상 동일한 길이를 가진다고 가정하기엔 힘든 부분도 있다.


부끄러운 얘기를 꺼내자면 대학생때 네트워크를 처음 접했을때는 이러한 규칙 없이 무조건 그냥 송/수신을 해서 String을 Parsing 하는 형태로 개발했었다.


한 예로,  [#gompang#Hi there ~] 이라는 문자열이 있다고 했을 때 Tokenizer를 사용해서 "#" 을 Delimeter로 잡고 파싱하면


gompang

Hi there~


이라는 문자열이 분리되게 된다. 이런식으로 모든 메세지를 처리했었는데, 

나중에 가서는 알아보기도 어렵고 만약 게임이 이런식으로 처리했더라면 필요 없는 곳에서


부하를 겪게되는 경우가 발생할 것이라고 생각했다.


자, 그래서 하고 싶은 얘기는 채팅이던 게임메세지이건 어떤 방식으로 처리하는것이 가장 효율적인가를 생각해보자는 것이다.


간단하게 다음과 같이 구성할 수 있을 것이다.


예를 들어 1024Byte가 있다고 했을 때


맨 앞의 1바이트는 Header로

나머지 1023바이트는 Data로 정의하는 것이다.


byte[] msg = new byte[1024]; 에서


msg[0] 이 바로 헤더가들어갈 자리인 것이다.


0~255까지의 숫자를 표현할 수 있으니 왠만한 프로그램에는 사용이 될 것이다.

그보다 많은 경우 맨 앞 2자리는 2바이트인 short로 구성하면 될 것이다.


이런식으로 헤더를 구성하고 이를 알기 쉽게 enum으로 구성하면 가독성이 좋아질 것이다.


C#을 기준으로 설명하자면


public enum Protocol : byte{


CHAT,

LOGIN_REQ,

LOGIN_RES,

BATTLE_START,

...


}


이런식으로 구성이 가능하고, 각각은 사실 0,1,2,3,4... 의 숫자값을 가질 뿐이다(enum에 대한 소리)


사용할 땐


msg[0] = Protocol.CHAT;


이런식으로 정의를 하게 된다면 송/수신하는 서버 클라이언트가 알기 쉽게 개발할 수 있다.


나머지 데이터부분은 사실 많이 남는데 어떻게 보내는게 가장 기초적인가 알아보면


송신하는 쪽 : 헤더 1바이트 / string 10바이트 / int 4바이트 / object 200바이트


를 보냈다고 가정했을 때 단순히 수신하는 쪽에서도 똑같은 바이트 크기만큼 읽어서 이를 변환해주면 된다.


수신하는 쪽도 메세지를 수신한 후 앞에서 1바이트는 헤더로 짤라내고


나머지 10바이트 만큼은 읽어서 string으로 변환을 시킨다. 거기서부터 offset을 또 4바이트 만큼 줘서 읽은 뒤에 그 값을 또 int로 변환을 시킨다.


나머지 200바이트만큼은 읽어서 객체를 다시 만들면 된다

(객체를 byte에서 다시 복구시키는건 여타 클래스 및 라이브러리등이 지원을 해준다 가장 간편한 건 JSON)


이런식으로 구성을 하게 된다면 단순히 위에 적어놓은 것처럼 string을 길게 나열할 필요도 없이 깔끔하게 읽고 보낼 수 있을 것이다.


그냥.. 갑자기 예전에 네트워크 강의를 처음 들을때 생각이 떠올라서 적어본다.


지금은 각종 게임서버 프레임워크를 집중적으로 연구하고 있기 때문에 예전에 했던 행동들이 얼마나 비효율적이었는지를 잘 깨닫고있다.


#1. JAVA에서 MessagePacker 구현하기!


일단  C#으로 먼저할까 JAVA로 먼저 할까 고민을 했었는데, 일단 C#은 지금 쓰고 있으므로 

오랜만에 자바를 이용해서 간단한 메세지 송/수신을 위한 클래스를 구현해보았다.


정말 간단한 기능만 포함된 기초 클래스이기 때문에 사용은 일단 무리는 없을것으로 보이나, 실제 서비스에 적용하긴 부족한 라이브러리임


앞서 말씀드렸던 모든 데이터를 byte로 변환하여 하나의 메세지를 만드는것에 초점을 두고 만들었음..!


메세지에 추가 가능한 데이터형 : Int, Double, Float, String, Byte


간단하게 어떻게 사용하는지부터 소개를 하겠습니다.




package TestClass;

import java.nio.ByteOrder;

import MsgPacker.MessagePacker;
import MsgPacker.MessageProtocol;

public class TestClass {

	public static void main(String args[]){
		
		MessagePacker msg = new MessagePacker();
		
		double t_val = 3.142341;
		float t_val2 = 3.4112f;
		int t_val3 = 1234;
		
		msg.SetEndianType(ByteOrder.BIG_ENDIAN); // JVM 기본 타입은 BIG_ENDIAN
		
		msg.SetProtocol(MessageProtocol.WHISPHER);
		
		msg.add(t_val);
		msg.add(t_val2);
		msg.add(t_val3);
		msg.add("한글 메세지 테스트입니다. tes tMessage !1234");
		byte[] data = msg.Finish();
		
		// SEND 종료
		
		msg = new MessagePacker(data);
		
		byte protocol = msg.getProtocol();
		
		switch(protocol){
			case MessageProtocol.BATTLE_END:{
				System.out.println("Protocol : BATTLE_END");
				break;
			}
			case MessageProtocol.BATTLE_START:{
				System.out.println("Protocol : BATTLE_START");
				break;
			}
			case MessageProtocol.CHAT:{
				System.out.println("Protocol : CHAT");
				break;
			}
			case MessageProtocol.WHISPHER:{
				System.out.println("Protocol : WHISPHER");
				break;
			}
		}
		
		System.out.println(msg.getDouble());
		System.out.println(msg.getFloat());
		System.out.println(msg.getInt());
		System.out.println(msg.getString());
	}
}



첨부할 두개의 클래스를 import를 한 후, 생성자를 통하여 새로운 MessagePacker를 생성하면 됩니다.


1. MessagePacker msg = new MessagePacker(); // 기본으로 1024바이트가 설정되어 있음


이후 메세지의 프로토콜을 설정할 차례인데, 이는 MessageProtocol 클래스의 상수 byte를 이용해서 넣어줍니다.


2. msg.setProtocol(MessageProtocol.CHAT); // 프로토콜 설정


예시로 CHAT, BATTLE_START, BATTLE_END 등등으로 설정했는데, 이는 개발자 입맛에 맞게 수정하시면 됩니다.


위와 같은 방식으로 MessageProtocol.CHAT, MessageProtocol.TEST ... 이런식으로 사용합니다


이 byte는 메세지의 맨 앞에 Header로 붙게 되며, 추후 수신하는 측에서 getProtocol() 함수로 읽어들일 수 있습니다.


프로토콜을 설정한 뒤, 데이터를 삽입하기 시작합니다.


3.

msg.add("String -> byte[] Added!"); // 영어 및 특수문자 테스트

msg.add((byte)3); // Byte 추가 

msg.add(41412352); // int 추가

msg.add((double)3.1402112221); // Double 추가

msg.add((float)3.1423); // Float 추가

msg.add("한글 변환 테스트 메세지입니다! Test."); // 한글, 영문 특수문자 테스트

msg.add(5030403); // int 추가


현재 지원하는 데이터형은 Double, Float, Int, String 입니다.


4.msg.Finish();


함수를 호출하여 메세지를 종결합니다. (꼭 해야함!)


리턴형은 byte[] 배열형태로 넘어옵니다. (사용 안하더라도 호출해야 함)


얻어온 바이트 배열을 이용해서 소켓으로 데이터를 전송하면 됩니다.





수신측에서는 송신하는 순의 역순으로 데이터를 얻어오면 됩니다. ( 순서는 정확히 똑같아야 한다는걸 알아두셔야 합니다.)


while문을 돌면서 byte[] 의 끝이 나올때까지 계속 읽어서 임의의 byte[] array 를 얻었다고 가정합시다.


그 byte[] 에서 우리가 원하는 데이터들을 추출해야 하려면 역순입니다.


1. MessagePacker received = new MessagePacker(data); // MessagePacker 클래스를 다시 생성한다.


그리고 프로토콜이 포함된 바이트의 맨 처음을 얻어와야 하니 getProtocol() 함수를 이용해서 얻어옵시다.


2. byte protocol = msg.getProtocol(); // 메세지의 헤더를 가져온다


프로토콜을 얻어왔으니 어떤 프로토콜인지 분기를 해서 각각 상황에 맞는 행동을 하도록 로직을 짜야합니다.


3. 

switch(protocol){

case MessageProtocol.CHAT:{

System.out.println("Protocol : CHAT !");

break;

}

case MessageProtocol.BATTLE_END:{

System.out.println("Protocol : BATTLE_END !");

break;

}

case MessageProtocol.WHISPHER:{

System.out.println("Protocol : WHISPHER !");

break;

}

case MessageProtocol.BATTLE_START:{

System.out.println("Protocol : BATTLE_START !");

break;

}

default :{

break;

}

}


지금은 그냥 프로토콜 이름만 출력해주고 있지만, 귓속말이 오면 귓속말을 하도록, 전투가 시작되면 전투를 하도록 하는

로직을 작성하면 된다.


프로토콜을 읽었으니 데이터를 읽어야한다.


데이터는 아까 삽입했던 데이터 순으로 그대로 읽어온다.


4.

System.out.println(msg.getDouble());

System.out.println(msg.getFloat());

System.out.println(msg.getInt());

System.out.println(msg.getString());


이런식으로 메세지 송/수신을 처리한다면 보다 간편하게 채팅이나 게임 로직을 구성할 수 있다.


C# 으로도 만들어둘까 했는데 귀찮아서 아마 안할 것 같다..


소스코드를 전부 공개하는 만큼, 사용하실 땐 꼭 출처를 밝히고 사용해주셨으면 좋겠습니다 :D


개선사항이나 필요한 기능들은 직접 추가해서 사용하셔도 무방하고 저에게 건의해주시면 생각은 해보겠습니다

(아마 시간날때 짜놓은거라.. 귀찮아서 방치하지 않을까 생각을 하지만서도..?)


마지막으로 MessagePacker의 기능 간략 설명을 마무리로 이만 포스팅 마치겠습니다.


* JAVA NIO를 이용한 데이터들(byte, int, double, float, string)의 byte 직렬화 제공

* 한글, 영문, 특수문자 지원

* 메세지 길이 최적화 기능 지원(1024바이트로 객체를 생성했어도 pack()함수 호출 시 예를 들면, 200바이트의 메세지로 최적화 가능)

* 프로토콜 설정기능으로 직관적인 가독성 증가 -> 단순히 MessageProtocol.CHAT_SEND 이런식으로 byte를 비교할 수 있으므로.



2016.6.12 리뉴얼

* 기존 bye[] 배열을 직접 핸들링 했었는데, 이를 ByteBuffer 클래스를 이용하여 사용하는 것으로 개선(속도 및 안정성 향상)

* 기존 pack()함수를 호출했어야 했는데, 이 또한 넣은 데이터 만큼 압축해서 리턴해주는 방식으로 개선

* float형 데이터 정확도 오류인 부분 해결

* Endian 설정옵션 추가 구현(기존 소스에서는 동작 X)




올려드린 두 클래스는 서로 참조가 가능하도록 한 패키지 내에 두셔야 합니다.



이런식으로 한 패키지에


반응형