성태의 닷넷 이야기
홈 주인
모아 놓은 자료
프로그래밍
질문/답변
사용자 관리
사용자
메뉴
아티클
외부 아티클
유용한 코드
온라인 기능
MathJax 입력기
최근 덧글
[정성태] 제가 큰 실수를 했군요. ^^; Delegate를 통한 Bein...
[정성태] Working with Rust Libraries from C#...
[정성태] Detecting blocking calls using asyn...
[정성태] 아쉽게도, 커뮤니티는 아니고 개인 블로그입니다. ^^
[정성태] 질문이 잘 이해가 안 됩니다. 우선, 해당 소스코드에서 ILis...
[양승조
] var대신 dinamic으로 선언해서 해결은 했습니다. 맞는 해...
[양승조
] 또 막혔습니다. ㅠㅠ var list = props[i].Ge...
[양승조
] 아. 감사합니다. 어제는 안됐던것 같은데....정신을 차려야겠네...
[정성태] "props[i].GetValue(props[i])" 코드에서 ...
[정성태] 저렇게 조각 코드 말고, 실제로 재현이 되는 예제 프로젝트를 압...
글쓰기
제목
이름
암호
전자우편
HTML
홈페이지
유형
제니퍼 .NET
닷넷
COM 개체 관련
스크립트
VC++
VS.NET IDE
Windows
Team Foundation Server
디버깅 기술
오류 유형
개발 환경 구성
웹
기타
Linux
Java
DDK
Math
Phone
Graphics
사물인터넷
부모글 보이기/감추기
내용
<div style='display: inline'> <h1 style='font-family: Malgun Gothic, Consolas; font-size: 20pt; color: #006699; text-align: center; font-weight: bold'>C# - SuperSimpleTcp 사용 시 주의할 점</h1> <p> 혹시 SuperSimpleTcp를 사용하는 분이 계실까요? <br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > SuperSimpleTcp ; <a target='tab' href='https://github.com/jchristn/supersimpletcp'>https://github.com/jchristn/supersimpletcp</a> </pre> <br /> ^^ 지난 글에도 한 번 언급한 라이브러리인데,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > C# - TCP KeepAlive에 새로 추가된 Retry 옵션 ; <a target='tab' href='https://www.sysnet.pe.kr/2/0/13531'>https://www.sysnet.pe.kr/2/0/13531</a> </pre> <br /> 그런대로 사용법이 재미있습니다. 우선, 간단하게 서버를 <a target='tab' href='https://github.com/jchristn/supersimpletcp'>README에 있는 기본 예제 코드</a>를 살짝 바꿔 시작해 보겠습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > using SuperSimpleTcp; using System; using System.Text; namespace ConsoleApp1 { // <a target='tab' href='https://www.nuget.org/packages/SuperSimpleTcp'>Install-Package SuperSimpleTcp</a> internal class Program { static void Main(string[] args) { // instantiate using (SimpleTcpServer server = new SimpleTcpServer("*:18500")) { // set events server.Events.ClientConnected += ClientConnected; server.Events.ClientDisconnected += ClientDisconnected; <span style='color: blue; font-weight: bold'>server.Events.DataReceived += DataReceived;</span> // let's go! server.Start(); Console.ReadKey(); } } static void ClientConnected(object sender, ConnectionEventArgs e) { Log($"[{e.IpPort}] client connected"); } static void ClientDisconnected(object sender, ConnectionEventArgs e) { Log($"[{e.IpPort}] client disconnected: {e.Reason}"); } <span style='color: blue; font-weight: bold'>static void DataReceived(object sender, DataReceivedEventArgs e)</span> { Log($"[{e.IpPort}]: Received - {Encoding.UTF8.GetString(<span style='color: blue; font-weight: bold'>e.Data.Array, 0, e.Data.Count</span>)}"); SimpleTcpServer socket = sender as SimpleTcpServer; if (socket == null) { return; } <span style='color: blue; font-weight: bold'>socket.Send(e.IpPort, "Hello World from server");</span> } private static void Log(string text) { Console.WriteLine($"[{DateTime.Now:mm ss fff}] {text}"); } } } </pre> <br /> 위의 코드를 보면, 서버이면서도 Accept를 이용한 클라이언트 소켓을 내부적으로 감춰 클라이언트로의 Send, Receive를 "Ip:Port"의 조합으로 식별해 송수신할 수 있도록 추상화시켰습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // 클라이언트에서 오는 데이터는 SimpleTcpServer.DataReceived 이벤트로 처리하고, static void DataReceived(object sender, DataReceivedEventArgs e) { // 데이터를 보낸 클라이언트의 식별은 "e.IpPort"를 이용 Log($"[{e.IpPort}]: Received - {Encoding.UTF8.GetString(e.Data.Array, 0, e.Data.Count)}"); SimpleTcpServer socket = sender as SimpleTcpServer; if (socket == null) { return; } // 해당 클라이언트로의 데이터 송신은 "e.IpPort"로 식별해 처리 socket.Send(e.IpPort, "Hello World from server"); } </pre> <br /> 클라이언트도 유사한 사용자 경험으로 처리하도록 추상화를 했기 때문에 사용법이 비슷합니다. 단지, 서버를 식별할 필요는 없으므로 IpPort에 대한 식별만 Send 시에 할 필요가 없습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > using SuperSimpleTcp; using System; using System.Text; namespace ConsoleApp2 { // Install-Package SuperSimpleTcp internal class Program { static void Main(string[] args) { string host = "192.168.100.50"; // instantiate <span style='color: blue; font-weight: bold'>SimpleTcpClient client = new SimpleTcpClient($"{host}:18500");</span> // set events client.Events.Connected += Connected; client.Events.Disconnected += Disconnected; <span style='color: blue; font-weight: bold'>client.Events.DataReceived += DataReceived;</span> // let's go! client.Connect(); // once connected to the server... <span style='color: blue; font-weight: bold'>client.Send("Hello, world!");</span> Console.ReadKey(); } static void Connected(object sender, ConnectionEventArgs e) { Log($"*** Server {e.IpPort} connected"); } static void Disconnected(object sender, ConnectionEventArgs e) { Log($"*** Server {e.IpPort} disconnected"); } <span style='color: blue; font-weight: bold'>static void DataReceived(object sender, DataReceivedEventArgs e) { Log($"[{e.IpPort}] {Encoding.UTF8.GetString(e.Data.Array, 0, e.Data.Count)}"); }</span> private static void Log(string text) { Console.WriteLine($"[{DateTime.Now:mm ss fff}] {text}"); } } } </pre> <br /> 대충 사용법을 아시겠죠? ^^<br /> <br /> <hr style='width: 50%' /><br /> <br /> 그런데, SuperSimpleTcp가 너무 추상화를 잘해서 간과할 수 있는 문제점이 하나 있는데요, 바로 TCP 통신은 Stream-oriented 방식이므로 단순히 한 번의 DataReceived만으로 처리할 수 없는 경우가 발생할 수 있다는 점입니다.<br /> <br /> 일례로, 대개의 경우는 TCP로 데이터 송/수신을 할 때 패킷의 구조를 정의할 것이고 그로 인해 가변적인 데이터 길이를 갖는 것이 일반적입니다. 하지만 위의 경우는 DataReceived 측에서 단순히 TCP 레벨에서 올려주는 한 번의 recv 호출로 받은 데이터만을 다루기 때문에 재조합에 따른 문제가 발생합니다.<br /> <br /> 재현을 위해, 위의 예제에서 서버 측의 DataReceived에서 Send를 2번 호출하도록 바꾸면,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > static void DataReceived(object sender, DataReceivedEventArgs e) { Log($"[{e.IpPort}]: Received - {Encoding.UTF8.GetString(e.Data.Array, 0, e.Data.Count)}"); SimpleTcpServer socket = sender as SimpleTcpServer; if (socket == null) { return; } <span style='color: blue; font-weight: bold'>socket.Send(e.IpPort, "Hello World from server"); socket.Send(e.IpPort, "Hello World from server");</span> } </pre> <br /> 이제 클라이언트는 네트워크 상황에 따라 이렇게 데이터를 나눠 받거나,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > [53 19 549] *** Server 192.168.0.22:18500 connected [53 19 581] [192.168.0.22:18500] Hello World from server [53 19 581] [192.168.0.22:18500] Hello World from server </pre> <br /> 두 번의 send 데이터를 한 번에 수신하기도 하거나,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > [53 21 564] *** Server 192.168.0.22:18500 connected [53 21 575] [192.168.0.22:18500] <span style='color: blue; font-weight: bold'>Hello World from serverHello World from server</span> </pre> <br /> 만약 두 번에 이은 send 전송이 또 있다면 연이어 받기도 합니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > [53 21 564] *** Server 192.168.0.22:18500 connected [53 19 581] [192.168.0.22:18500] Hello World from server [53 19 581] [192.168.0.22:18500] <span style='color: blue; font-weight: bold'>Hello World from server이어진 데이터</span> </pre> <br /> 위의 경우는 단순한 텍스트였지만, 만약 JSON 데이터를 Send/Receive로 받는 식이었다면,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // json을 2번 전송한다고 가정 Send({ "name": "kevin", "age": 5 }) Send({ "name": "winnie", "age": 5 }) </pre> <br /> 수신 측에서는 다음과 같이 한꺼번에 오는 경우도 발생하기 때문에,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > { "name": "kevin", "age": 5 }{ "name": "winnie", "age": 5 } </pre> <br /> DataReceived에서 곧바로 JsonSerializer.Deserialize를 호출했다면 오류가 발생할 수 있습니다. 또한, 데이터 길이가 긴 데이터를 송신한다면 수신 측에서는 나눠서 받는 상황도 고려해야 합니다. (이런 경우에도 Json 데이터라면 완벽하게 조합되지 않아 Deserialize에 실패합니다.)<br /> <br /> 이런 문제를 없애려면, 송신 측에서는 반드시 데이터 길이에 대한 정보를 줘야 하고,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > string text = new string('a', 16384 * 2); byte [] buffer = BitConverter.GetBytes(text.Length); <span style='color: blue; font-weight: bold'>socket.Send(e.IpPort, buffer);</span> // 처음 4바이트는 무조건 데이터 길이 socket.Send(e.IpPort, text); // 이후 해당하는 데이터만큼 전송 </pre> <br /> 수신 측에서는 (대충 만들어도 복잡한) 패킷 조합을 하는 코드를 다음과 같이 추가해야 합니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > static MemoryStream _assembleBuffer; static void DataReceived(object sender, DataReceivedEventArgs e) { // 먼저 4바이트를 읽어 데이터의 전체 크기를 알아내고, int dataLength = BitConverter.ToInt32(e.Data.Array, 0); if (_assembleBuffer == null && dataLength == e.Data.Count - 4) { // 한 번에 전체 데이터를 받았다면 처리 ProcessData(e.Data.Array, 4); return; } // 한 번에 받지 못했다면, 패킷 조합을 위한 단계로 진입 if (_assembleBuffer == null) { _assembleBuffer = new MemoryStream(dataLength); _assembleBuffer.Write(e.Data.Array, 4, e.Data.Count - 4); return; } else { _assembleBuffer.Write(e.Data.Array, 0, e.Data.Count); if (_assembleBuffer.Position != _assembleBuffer.Capacity) { return; } } // 모든 데이터가 조합이 되었다면 처리 ProcessData(_assembleBuffer.ToArray(), 0); return; } static private void ProcessData(byte[] array, int offset) { string text = Encoding.UTF8.GetString(array, offset, array.Length - offset); text = text.Substring(0, 100); Log($"offset: {offset}, length: {array.Length}, {text}"); } </pre> <br /> 실제로 제가 테스트한 환경에서는 서버에서 Send(4), Send(32768) 2번의 호출에 대해 수신 시 다음과 같이 다양한 유형으로 처리가 되었습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > [유형 1] Recv(4 + 32768) 모두 한 번에 수신 [유형 2] Recv(4), Recv(32768)로 두 번에 수신 [유형 3] Recv(4), Recv(7300), Recv(20440), Recv(5028)과 같은 식으로 다중 수신 </pre> <br /> 심지어 위의 코드는, 송신 측에서 전달한 데이터가 또다시 연이어 붙어 있는 경우를 고려하지도 않은 것입니다. 예를 들어, Send(4), Send(32768), Send(4), Send(80)과 같은 식으로 4번 데이터 송신을 한 경우에, Recv(32768)을 완성하는 마지막 패킷에 4바이트가 따라붙는 경우는 무시한 코드입니다.<br /> <br /> 따라서, 현실적으로 저 코드를 만들려면 SimpleTcpClient를 상속해 Stream-oriented를 고려해 데이터를 수신하는 DataReceived 이벤트 핸들러를 완성하고, 다시 그렇게 해서 조합된 데이터를 외부에 이벤트로 알리는 PacketReceived와 같은 유형의 이벤트 핸들러를 제공해야 합니다. 게다가, 저 Recv를 서버 측에서 구현하려면 더더욱 복잡해지는데요, 서버는 IpPort로 클라이언트를 구분하기 때문에 DataReceived 코드에서 그에 대한 처리도 추가해야 합니다.<br /> <br /> 이런 것을 고려해 봤을 때, 과연 저 코드가 SuperSimpleTcp의 이름에 걸맞은지 충분히 고민을 해야 합니다.<br /> <br /> <hr style='width: 50%' /><br /> <br /> 이 외에도, Stream-oriented라는 성격에 걸맞지 않은 또 다른 문제점이 DataReceived에 대한 이벤트를 ThreadPool에 태워 발생시킨다는 점입니다. 아래는 수신 후 이벤트를 발생시키는 코드인데,<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > // https://github.com/jchristn/SuperSimpleTcp/blob/master/src/SuperSimpleTcp/SimpleTcpClient.cs#L903 // ....[생략]... if (data != null) { _lastActivity = DateTime.Now; Action action = () => _events.HandleDataReceived(this, new DataReceivedEventArgs(ServerIpPort, data)); if (<span style='color: blue; font-weight: bold'>_settings.UseAsyncDataReceivedEvents</span>) { <span style='color: blue; font-weight: bold'>_ = Task.Run(action, token);</span> } else { <span style='color: blue; font-weight: bold'>action.Invoke();</span> } _statistics.ReceivedBytes += data.Count; return data; } // ....[생략]... </pre> <br /> 저렇게 UseAsyncDataReceivedEvents 옵션(기본값: true)에 따라 스레드 풀을 사용하고 있으므로, 결국 차례대로 Recv가 호출되었다고 해도 스레드 풀의 상황에 따라 순서가 지켜지지 않는 문제가 발생합니다. 달리 말해, (순서가 지켜진다는 특징을 가진) TCP의 데이터 수신이 순서에 맞지 않을 수 있다는 것이고, 심지어 그것이 기본 동작이라는 점입니다. (github <a target='tab' href='https://github.com/jchristn/SuperSimpleTcp/discussions/205'>이슈에도 이 문제로 질문</a>이 있습니다.)<br /> <br /> 물론, 이 문제는 단순히 옵션을 false로 하면 해결되므로 이전에 다뤘던 재조합의 문제보다는 가볍습니다.<br /> <br /> <hr style='width: 50%' /><br /> <br /> 마지막으로, 원래 Socket의 경우 서버로 동작했을 때 Close를 하면 기존에 Accept 시킨 Socket에 대해서는 아무런 영향을 미치지 않습니다.<br /> <br /> <pre style='margin: 10px 0px 10px 10px; padding: 10px 0px 10px 10px; background-color: #fbedbb; overflow: auto; font-family: Consolas, Verdana;' > C# - Server socket이 닫히면 Accept 시켰던 자식 소켓이 닫힐까요? ; <a target='tab' href='https://www.sysnet.pe.kr/2/0/13550'>https://www.sysnet.pe.kr/2/0/13550</a> </pre> <br /> 하지만, SuperSimpleTcp의 경우 (Stop이 아닌) Dispose 메서드를 호출하면 내부적으로 관리하고 있던 모든 자식 Socket을 닫는 코드를 수행한다는 차이점이 있습니다. 이 경우는 부작용이라기보다는 때로는 이렇게 동작하는 것을 원할 수 있기 때문에 장점으로 보입니다. ^^<br /> <br /> <hr style='width: 50%' /><br /> <br /> 전체적으로, 제 평가는, SuperSimpleTcp는 웬만하면 쓰지 말라는 권고를 드리고 싶습니다. Toy 프로젝트 정도의 단순한 통신이라면 모를까, 현업에서 사용할 만한 라이브러리는 아니라고 봅니다.<br /> <br /> (<a target='tab' href='https://www.sysnet.pe.kr/bbs/DownloadAttachment.aspx?fid=2133&boardid=331301885'>첨부 파일은 이 글의 예제 코드를 포함</a>합니다.)<br /> </p><br /> <br /><hr /><span style='color: Maroon'>[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]</span> </div>
첨부파일
스팸 방지용 인증 번호
1142
(왼쪽의 숫자를 입력해야 합니다.)