** C# 5 부터 Delegate를 이용한 비동기 호출 보단 async/awit 구문을 통한 비동기 호출을 많이 사용한다.

 

6.7 네트워크 통신

프로토콜: 컴퓨터 간의 통신에서 어떤 절차를 거쳐 통신을 주고 받을 것이냐에 대한 규칙이다.

가장 많이 사용하는 프로토콜은 TCP/IP (Transmission Control Protocol/Internet Protocol)이다.

 

6.7.1 System.Net.IPAddress

현재 널리 사용되는 TCP/IP의 IP(Internet Protocol)는 IPv4(Internet Protocol version 4)이다.

IPv4통신을 위해 네트워크 어댑터에 고유 주솟값이 부여되는데, 이를 IP 주소 라한다.

xxx.xxx.xxx.xxx 형식으로 표기하며 각 xxx는 0~255 범위(1바이트)의 숫자에 해당한다. 4바이트라는 점에서 표현 가능 범위가 약 42억으로 제한되어있다. 2011년 초 모든 IP 주소가 고갈됐다는 발표에 따라 IPv6(Internet Protocol version6)가 나왔다. 이 버전의 주소 용량은 총 128비트(16바이트)로 3.4 * 1038의 주소영역을 갖고 있다.

그러나 IPv4를 아직도 많이 사용하는 이유는 외부에서 접근 할 수 있는 공용(public) IP를 하나 갖고 있으면서 내부적으로 사용 가능한 개인(Private) IP를 부여해 인터넷을 사용할 수 있기 떄문이다.

또한 집에서 사용하는 IP는 공용 IP이지만, 인터넷 서비스 업체로부터 공용 IP를 컴퓨터가 켜질 때 대여받고 꺼질때 회수하는 시스템으로 할당되어진 IP 공용 주소들을 유동적으로 사용 가능하다. 집에서 AP(Access Point) 같은 공유기기를 사용한다면 ISP(인터넷 서비스 업체, Internet Service Provider)에서 제공받은 공용 ip 는 AP에 할당되고 AP에 연결된 다른 장비는 모두 개인 IP를 갖는다.

 

공용 IP가 모두 소진되어 제한된 수의 장비에만 할당 가능하나, 다양한 내부 IP활용 덕에 IPv6 도움없이 IPv4가 여전히 살아남을 수 있다.

 

using System.Net;

IPAddress ipAddr = IPAddress.Parse("202.182.182.21");
Console.WriteLine(ipAddr);

IPAddress ipAddr2 = new IPAddress(new byte[] { 202, 182, 182, 21 });
Console.WriteLine(ipAddr2);

#출력
202.182.182.21
202.182.182.21

위의 주소 형식은 IPv4 주소에 해당하는 예제다. C#에서 ip는 System.Net.IPAddress 타입으로 표현되며 Parse 정적 메서드가 제공되므로 이를 활용하거나 직접 숫자에 해당하는 바이트 값을 생성자에 전달 할 수 있다.

 

IPv6 표기 방식은 생략(책 p457~458참고)

 

6.7.2  포트

TCP/IP 통신의 식별자는 IP 주소로 IP 주소가 컴퓨터에 장착된 네트워크 어댑터를 식별하지만 OS상에서 실행 중인 프로그램까지는 구분할 수 없다. 여러 프로그램이 TCP/IP를 사용한다면 OS입장에선 네트워크 어댑터로 들어온 통신 데이터를 어디에 보내야 할지 판단하기 위해 포트를 이용한다.

 

포트는 0~65535 범위에 해당하는 값으로 BCL에서 별도의 타입으로 정의되지 않았고 단지 ushort(부호없는 2바이트 정수)로 표현한다.

 

서버 프로그램은 ip와 함께 포트를 이용해 통신을 대기하며 클라이언트는 특정 포트번호를 지정해 해당 포트번호를 갖는 서버와 연결할 수있다. 

다만 21번 포트는 ftp 서버, 25번 포트는 SMTP 서버, 80번 포트는 웹서버등 0~1024번까지 주요 통신을 위한 규약에 따라 이미 정해져있다. 강제 사항은 아니기에 프로그램의 포트번호를 25번 포트로 사용해도 되나 충돌 리스크로 인해 일반적으로 1025 ~ 65535 범위의 포트 번호로 결정하는 것이 좋다.

 

6.7.3   System.Net.IPEndPoint

TCP/IP 통신에서 접점(EndPoint)은 'ip 주소 + 포트'를 일컫는다. BCL에선 이 정보를 묶는 단일 클래스로 IPEndPoint 타입을 제공한다.

using System.Net;

IPAddress ipAddr = IPAddress.Parse("192.168.1.10");
IPEndPoint endPoint = new IPEndPoint(ipAddr, 9000); // ip주소 , 포트 결합.
Console.WriteLine(endPoint);

#출력
192.168.1.10:9000

 

6.7.4 System.Net.Dns

사용자로부터 도메인 명(www.naver.com) 문자열을 입력받으면 TCP/IP 통신을 하기 위해 대응되는 IP주소로 필히 변경해야 한다. 이때 사용 가능한 방법이 Dns 타입이다.

using System.Net;

IPHostEntry entry = Dns.GetHostEntry("www.naver.com");
foreach(IPAddress ipAddr in entry.AddressList)
{
    Console.WriteLine(ipAddr);
}

#출력
223.130.192.248
223.130.192.247
223.130.200.219
223.130.200.236

 

GetHostEntry 정적 메서드는 도메인 이름을 입력 받으면 시스템에 설정된 도메인 네임 서버(DNS: Domain Name Server)로 해당 이름의 IP를 조회한다.

결과로 돌려 받은 IPHostEntry 타입은 도메인 이름에 설정된 ip 목록을 IPAddress 타입의 배열인 AddressList 속성으로 제공한다.

 

예제 6.31 현재 컴퓨터에 할당된 ip 주소 출력

using System.Net;

string myCom = Dns.GetHostName();

Console.WriteLine($"컴퓨터 이름: {myCom}");

IPHostEntry entry = Dns.GetHostEntry(myCom);

foreach(IPAddress ipAddr in entry.AddressList)
{
    Console.WriteLine($"{ipAddr.AddressFamily} : {ipAddr}");
}

#출력
컴퓨터 이름: DESKTOP - UQLPAH3
InterNetworkV6: fe80::ade3:f437: f658: bee5 % 25
InterNetwork: 192.168.0.2

 

 

도메인 이름을 사용할 떄 단점은 DNS로부터 IP 주소를 조회해야기에 그 만큼 속도가 저하된다.

이로인해 Window OS에는 내부적으로 한번 조회된 적이 있는 도메인명과 ip 주소는 일정 시간 동안 저장해 두는 기능이 있다.

한번 조회 후 다음 조회 시 동일한 DNS 조회 요청이 오면 서버와의 통신 없이 미리 저장해 둔 IP 주소를 곧바로 반환함으로써 속도를 향상 시킨다.

 

도메인 이름의 이런 특징은 1개의 도메인 명에 n개의 ip 주소가 묶인 경우 일종의 부하 분산(load balance)역할을 한다.

 

 

6.7.5 System.Net.Sockets.Socket

운영체제는 TCP/IP 통신을 위해 소켓(socket)이란 기능을 만들었으며 소켓을 이용해 이종기기와 TCP/IP 통신이 가능하다. BCL에 Socket 타입을 제공한다.

public Socket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType);

Socket 생성자는 3개의 인자를 받는다.

using System.Net.Sockets;

Socket tcpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

Socket udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

// IPv6용 소켓을 생성하려면 첫 번째 인자에 AddressFamily.InterNetworkV6 값을 주면 된다.

소켓 조합의 수는 4650개의 사용법이 있으나, 현실적으로 위와 같이 2개의 조합만 사용한다.

 

첫번째 조합 'SocketType.Stream + Protocol.Tcp'로 생성된 소켓은 '스트림 소켓' 또는 'TCP 소켓'이라 한다.

두번째 조합 'SocketType.Dgram + ProtocolType.Udp' 로 생성된 소켓은 '데이터 그램 소켓' 또는 'UDP 소켓'이라 한다.

TCP와 UDP는 모두 IP 프로토콜 기반으로 동작한다.

기준 소켓
TCP UDP
연결성 통신 전에 반드시 서버로 연결(연결 지향성: Connection-oriented)

* 연결은 3-way handshake를 통해 이루어지며, 종료 시 4-way handshake로 세션을 종료합니다.
연결되지 않고 동작 가능(비연결 지향성: connectionless)
신뢰성 데이터를 보냈을 때 반드시 상대방은 받았다는 신호를 보내줌(신뢰성 보장) 데이터를 보낸 측은 상대방이 정상적으로 데이터를 받았는지 알 수 없음
순서 상대방은 데이터를 보낸 순서대로 받게 됨 데이터를 보낸순서와 상관없이 먼저 도착한 데이터를 받을 수 있음 
속도 신뢰성 및 순서를 확보하기 위한 부가적인 통신이 필요하므로 UDP에 비해 다소 느림 부가적인 작업을 하지 않으므로 TCP보다 빠름

 

파일 전송 시 TCP 소켓을 사용해야 신뢰성을 확보 할 수 있다. UDP 소켓을 사용 시 개발자가 직접 신뢰성 확보를 위한 데이터 검증절차를 추가함과 동시에 코드 자체의 안정성을 위한 추가 테스트도 필요한데 결과적으로 TCP 소켓에서 자동으로 해주는 기능과 크게 다를 바 없다. 그래서 TCP소켓을 사용한다.

실시간 스트리밍의 경우 렉이 걸리지 않게 하기 위해 UDP 사용이 적합하다.

 

Socket 타입은 IDisposable을 상속받는다. 따라서 소켓을 생성 후 필요가 없어지면 반드시 자원을 해제해야 한다.

// 소켓 자원 생성
using System.Net.Sockets;

internal class Program
{
    private static void Main(string[] args)
    {
        Socket sock = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp);

        //... 소켓을 사용해 통신


        // 반드시 소켓 자원해제
        sock.Close();
    }
}
더보기

IDisposable을 상속받으면 닷넷의 자동 자원 해제 기능이 제공되지 않습니다. 대신, 명시적으로 자원을 해제해야 합니다.

IDisposable을 구현하면 Dispose() 메서드를 정의할 수 있는데, 이 메서드는 객체가 더 이상 필요 없을 때 자원을 해제하는 책임을 가집니다. 이를 통해 시스템 리소스(예: 메모리, 파일 핸들, 네트워크 소켓 등)를 명시적으로 관리할 수 있습니다.

자동 자원 해제와 수동 자원 해제 차이

  • 자동 자원 해제: 가비지 컬렉터(GC)가 객체를 수집할 때, 메모리와 같은 관리되는 리소스는 자동으로 해제됩니다. 하지만 관리되지 않는 리소스(예: 소켓, 파일 핸들 등)는 자동으로 해제되지 않으므로 Dispose()를 통해 명시적으로 해제해야 합니다.
  • 수동 자원 해제: IDisposable을 구현하는 클래스에서 Dispose() 메서드를 호출하여 자원을 해제해야 합니다. 예를 들어, Socket 클래스와 같은 관리되지 않는 리소스를 사용하는 경우, Dispose() 메서드를 호출하여 소켓을 닫고 리소스를 해제해야 합니다.
  •  
using System;
using System.Net;
using System.Net.Sockets;

class Program
{
    static void Main()
    {
        // 소켓 생성
        using (Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            // 소켓 사용 코드
            socket.Connect(new IPEndPoint(IPAddress.Loopback, 8080));
        } // using 문이 끝나면 자동으로 Dispose()가 호출되어 자원 해제됨
    }
}

 

위 코드에서 using 블록을 사용하면, 블록이 끝날 때 Dispose()가 자동으로 호출되어 자원이 해제됩니다. IDisposable을 구현한 객체는 using 구문을 통해 자동으로 자원을 해제할 수 있습니다.

따라서, Socket과 같은 IDisposable을 구현한 객체는 명시적으로 자원을 해제해주어야 하며, 이를 위해 Dispose() 메서드를 호출하거나 using 구문을 사용하면 됩니다.

예제 6.32 소켓 프로그램 실습 코드

Thread serverThread = new Thread(serverFunc);
serverThread.IsBackground = true;
serverThread.Start();

Thread.Sleep(500); // 서버 소켓 스레드가 실행될 시간을 주기 위함.

//클라이언트 소켓이 동작하는 스레드
Thread clientThread = new Thread(clientFunc);
clientThread.IsBackground = true;
clientThread.Start();

Console.WriteLine("종료하려면 아무 키나 누르세요...");
Console.ReadLine();

void clientFunc(object? obj)
{
   // 클라이언트 소켓 코드 작성
}

void serverFunc(object? obj)
{
    //서버 소켓 코드 작성
}

https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/sockets/socket-services

 

Use Sockets to send and receive data over TCP - .NET

Learn how the Socket class exposes socket network communication functionality in .NET.

learn.microsoft.com

 

지리적으로 서로 다른 위치에 있는 컴퓨터 한대를 구해서 통신해보자.

 

6.7.5.1 UDP 소켓

 

예제 6.33 UDP 소켓: 서버 측 바인딩

using System.Net;
using System.Net.Sockets;

void serverFunc(object obj)
{
    Socket sock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

    IPAddress ipAddr = GetCurrentIPAddress();
    if(ipAddr == null)
    {
        Console.WriteLine("IPv4용 랜 카드가 없습니다.");
        return;
    }
    IPEndPoint endPoint = new IPEndPoint(ipAddr, 10200);
    
    sock.Bind(endPoint);

}

IPAddress GetCurrentIPAddress()
{
    IPAddress[] addrs = Dns.GetHostEntry(Dns.GetHostName()).AddressList;
    foreach(IPAddress addr in addrs)
    {
        if(addr.AddressFamily == AddressFamily.InterNetwork)
        {
            return addr;
        }
    }
    return null;
}

 

 

위 코드는 문제가 될 수 있다. 특정 IP와 Port에 묶이면(바인딩) 해당 IP를 소유한 네트워크 어댑터와 연결된 pc인 사용자2는 사용자 1과 통신이 가능하나 별도의 네트워크 어댑터와 연결된 사용자3은 통신이 불가하다.

상황에 따라 이러한 동작을 원할 수 있으나 일반적인 소켓 프로그램은 모든 IP에 대해 바인딩되기를 바랄 것이다.

 

이러한 문제를 해결하고자 각 IP에 바인딩되도록 여러 개의 소켓을 생성하는 것도 가능하다.

이러한 방식은 코드 작성을 번거롭게 만들 수 있다. 다행히도 소켓은 모든 IP에 대해 바인딩 할 수 있는 방법을 제공한다. 이 때 사용하는 특별한 주소가 "0.0.0.0" 이다.

 

예제 6.33을 다음과 같이 바꾸자.

using System.Net;
using System.Net.Sockets;

void serverFunc(object obj)
{
    Socket sock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

    IPAddress ipAddr = IPAddress.Parse("0.0.0.0");
    if(ipAddr == null)
    {
        Console.WriteLine("IPv4용 랜 카드가 없습니다.");
        return;
    }
    IPEndPoint endPoint = new IPEndPoint(ipAddr, 10200);
    
    sock.Bind(endPoint);

}

 

위와 같이 0.0.0.0이라는 주소를 사용하면 소켓프로그램은 사용자2, 사용자3 ... 모두가 접근 가능하다. 0.0.0.0 주소는 흔히 사용하기에 IPAddress 타입에는 그 주소를 나타내는 정적 공용 속성으로 Any값을 직접 노출하고 있으므로 다음과 같이코드를 줄 일 수 있다. 

using System.Net;
using System.Net.Sockets;

void serverFunc(object obj)
{
    Socket sock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

    IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, 10200);
    
    sock.Bind(endPoint);

}

 

UDP 소켓이 바인딩 되면 이후의 사용법은 매우 간단하다.

 

표 6.18 에서 설명한 기능을 구현해보자.

표6.18

  서버 클라이언트
기능 1. 클라이언트로부터 데이터를 받는다.
2. 받은 데이터에 "HELLO:"를 앞에 붙여 클라이언트 측에 다시 전송한다.
1. 1초 간격으로 현재 시간을 서버로 전송한다.
2. 서버로부터 데이터를 받는다.
3. 받은 데이터를 화면에 출력한다.

*1~3과정을 총 5번 수행 후 종료한다.
조건 1. 서버포트는 10200으로 대기
2. 데이터 인코딩은 양측 모두 UTF8로 정한다.
 

 

예제 6.34 UDP 서버 측 소켓 구현

using System.Net;
using System.Net.Sockets;
using System.Text;

serverFunc();
void serverFunc()
{
    using (Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
    {
        IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, 10200);

        serverSock.Bind(endPoint);

        byte[] recvBytes = new byte[1024];
        EndPoint clientEP = new IPEndPoint(IPAddress.None, 0);

        while (true)
        {
            int nRecv = serverSock.ReceiveFrom(recvBytes, ref clientEP);
            string txt = Encoding.UTF8.GetString(recvBytes, 0, nRecv);

            byte[] sendBytes = Encoding.UTF8.GetBytes("Hello: " + txt);
            serverSock.SendTo(sendBytes, clientEP);
        }
    }
}

 

클라이언트 측은 서버에 데이터를 전송할 것이므로 별도의 바인딩 과정은 필요없다.

udp 서버의 접점 정보만 알고 있으면 된다. 이 예제에선 동일 컴퓨터에서 실행하기에 현재 컴퓨터에서 제공되는 IP로 아무거나 사용 하면 된다.

이에 따라 GetCurrentIPAddress 메서드를 이용해 데이터를 서버로 전송할 것이다.

 

예제 6.35 UDP 클라이언트 소켓

using System.Net;
using System.Net.Sockets;
using System.Text;

clientFunc();
void clientFunc()
{
    Socket clientSock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
    EndPoint serverEP = new IPEndPoint(IPAddress.Loopback, 10200); //Loopback is 127.0.0.1 자기 자신의 주소.
    EndPoint senderEP = new IPEndPoint(IPAddress.None, 0);

    int nTimes = 5;
    while (nTimes-- > 0)
    {
        byte[] buf = Encoding.UTF8.GetBytes(DateTime.Now.ToString());
        clientSock.SendTo(buf, serverEP);

        byte[] recvBytes = new byte[1024];
        int nRecv = clientSock.ReceiveFrom(recvBytes, ref senderEP);
        string txt = Encoding.UTF8.GetString(recvBytes, 0, nRecv);

        Console.WriteLine(txt);
        Thread.Sleep(1000);
    }

    clientSock.Close();
    Console.WriteLine("UDP Client socket: Closed");
}

 

.상기 코드는 서버:클라이언트 = 1:1 통신으로 구현되있으나 1:N 통신으로 해도 상관없다.

 

예제 6.36 다중 UDP 클라이언트 실행.

using System.Net;
using System.Net.Sockets;
using System.Text;

for(int clientNum = 0; clientNum < 3; ++clientNum)
{
    Thread clientThread = new Thread(clientFunc);
    clientThread.IsBackground = true;
    clientThread.Start();
    clientThread.Join();
}

void clientFunc()
{
    Socket clientSock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
    EndPoint serverEP = new IPEndPoint(IPAddress.Loopback, 10200); //Loopback is 127.0.0.1 자기 자신의 주소.
    EndPoint senderEP = new IPEndPoint(IPAddress.None, 0);

    int nTimes = 5;
    while (nTimes-- > 0)
    {
        byte[] buf = Encoding.UTF8.GetBytes(DateTime.Now.ToString());
        clientSock.SendTo(buf, serverEP);

        byte[] recvBytes = new byte[1024];
        int nRecv = clientSock.ReceiveFrom(recvBytes, ref senderEP);
        string txt = Encoding.UTF8.GetString(recvBytes, 0, nRecv);

        Console.WriteLine($"{Thread.CurrentThread.ManagedThreadId} -> {txt}");
        Thread.Sleep(1000);
    }

    clientSock.Close();
    Console.WriteLine($"{Thread.CurrentThread.Name} -> UDP Client socket: Closed");
}


#출력
7->Hello: 4 / 12 / 2025 5:29:33 PM
7 -> Hello: 4 / 12 / 2025 5:29:34 PM
7 -> Hello: 4 / 12 / 2025 5:29:35 PM
7 -> Hello: 4 / 12 / 2025 5:29:36 PM
7 -> Hello: 4 / 12 / 2025 5:29:37 PM
 ->UDP Client socket: Closed
11 -> Hello: 4 / 12 / 2025 5:29:38 PM
11 -> Hello: 4 / 12 / 2025 5:29:39 PM
11 -> Hello: 4 / 12 / 2025 5:29:40 PM
11 -> Hello: 4 / 12 / 2025 5:29:42 PM
11 -> Hello: 4 / 12 / 2025 5:29:43 PM
 ->UDP Client socket: Closed
12 -> Hello: 4 / 12 / 2025 5:29:44 PM
12 -> Hello: 4 / 12 / 2025 5:29:45 PM
12 -> Hello: 4 / 12 / 2025 5:29:46 PM
12 -> Hello: 4 / 12 / 2025 5:29:47 PM
12 -> Hello: 4 / 12 / 2025 5:29:48 PM
 ->UDP Client socket: Closed

 

상기 코드는 하나의 UDP 서버가 여러대의 UDP 클라이언트로부터 요청을 받아 서비스하는 것과 같다.

 

UDP 소켓 특성

*비연결성(Connectionless): 클라이언트 측에서 명시적인 Connection 설정 과정 필요없다.

 

*신뢰성 결여: UDP 서버/클라이언트 모두 SendTo를 하고 있는데, 그렇게 전달된 데이터가 상대방에게 반드시 도착한다는 보장이 없다. 같은 pc에서 테스트하는 경우 대부분 전달되는 것을 볼 수 있지만, 중간에 거쳐가는 네트워크 장치가 많아질 수 록 상대방에게 데이터가 전달되지 않을 수도 있다는 점을 염두해 두자.

 

*순서 없음: 송신자가 SendTo 메서들르 순서대로 3번 호출하고 그에 따라 수신자가 ReceiveFrom 을 3번 호출 한 경우 송신자가 보낸 순서와 다르게 데이터를 받을 수 있다. 같은 PC에서 테스트하는 경우 거의 올바르게 전달되나 중간 네트워크 장치가 많아질 수록 순서가 뒤바뀔 가능성이 있다.

 

*최대 65,535 바이트 한계: SendTo 메서드에 전달하는 바이트의 크기는 65535을 넘을 수 없다. 좀 더 정확하게는 각종 데이터 패킷의 헤더로 인해 그 크기는 다소 줄어든다. 또한 udp 데이터가 거쳐가는 네트워크 장비 중에는 32KB 정도만을 허용하도록 제약하는 경우도 있으므로 SendTo 메서드에 많은 데이터를 보내는 것은 권장하지 않는다.

 

*파편화(fragmentation): UDP를 이용해 많은 데이터를 보내는 것은 좋지 않은 선택이다. 이론 상 최대 64KB의 데이터를 SendTo로 보낸다고 해도 이더넷 통신에서는 64kb가 약 1000바이트 정도로 분할되어 전송 될 수 있다. 그렇게 되면 64번의 데이터를 전송하게 되는데 이중 하나라도 중간에 패킷이 유실되면 수신 측의 네트워크 장치가 받은 63개의 패킷은 폐기 되어 버린다. 즉 한번에 보내는 UDP 데이터의 양이 많을 수록 데이터가 폐기될 확률이 더 높아진다.

 

*메시지 중심(message-oriented): 송신 측에서 한 번의 SendTo 메서드 호출에 1000바이트의 데이터를 전송했다면 수신 측에서도 ReceiveFrom 메서드를 한 번 호출했을 때 1000바이트를 받는다. 즉, SendTo에 전달된 64kb 이내의 바이트 배열은 상대방에게 정상적으로 보내는 데 성공하기만 한다면 ReceiveFrom 메서드에서는 그 바이트 배열 데이터를 그대로 한 번에 받을 수 있다.

메시지 중심의 통신이란 이런 식으로 보내고 받는 메시지 경계(message boundary)가 지켜짐을 의미한다.

 

UDP 통신은 신뢰성의 결여로인해 잘 사용하지 않는다. 안정성 확보를 위해선 TCP 소켓을 사용하자.

 

 

6.7.5.2 TCP 소켓

TCP 서버 소켓 생명주기

1. TCP 소켓 생성

2. Bind (IP + Port 연결)

3. Listen : 클라이언트로부터 연결을 받을 수 있도록 소켓 상태 전환

4. Accept: Listen 이후 연결된 클라이언트를 하나 꺼내와서 반환 (즉 클라이언트 연결을 허락)

(3, 4를 반복)

5. Close: 서버 소켓 종료

 

Bind 단계까지는 UDP 서버 소켓과 사용법이 동일하다. 다만 Stream 유형의 소켓을 생성 한 후 TCP 통신을 위한 접점(IP + Port)으로 Bind 메서드를 호출하면 된다.

 

using System.Net;
using System.Net.Sockets;

Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, 11200);
serverSock.Bind(endPoint);

 

TCP 역시 고유한 접점으로 바인딩되면 같은 OS에서 실행되는 어떠한 프로세스도 동일한 정보로 소켓 바인딩 할 수 없다. 바인딩이 완료된 TCP 서버 소켓은 Listen  메서드를 호출하면서 클라이언트로부터의 접속을 허용한다.

이때 Listen 메서드에 전달된 숫자 값은 클라이언트의 접속을 보관할 수 있는 큐의 길이를 나타낸다.

serverSock.Listen(10);

상기는 최대 10개의 클라이언트 접속을 큐에 보관 할 수 있다.

Socket clientSock = serverSock.Accept();

보관된 클라이언트 연결을 꺼내는 것은 Accept 메서드를 호출함으로써 가능하다.

 

이 부분이 UDP와 TCP의 큰 차이점이다. TCP 서버용 소켓 인스턴스는 클라이언트와 직접 통신할 수 없고 오직 새로운 연결을 맺는 역할만 한다. 클라이언트와의 직접적인 통신은 서버 소켓의 Accept에서 반환된 소켓 인스턴스로만 할 수 있다.

 

데이터 송수신을 위해 UDP는 SendTo/ReceiveFrom 을 사용했지만 TCP에서는 Send/Receive 메서드를 사용한다.

Send/Receive 메서드 호출 시 접점 정보를 알아내기 위한 IPEndPoint 인자를 전달할 필요없다. 단순히 Send는 TCP 연결을 맺은 상대 측에 데이터를 전송하고, Receive는 상대편으로부터 전송된 데이터를 수신하는 역할만 한다.

 

6.37 TCP 서버 측 소켓 구현

using System.Net;
using System.Net.Sockets;
using System.Text;

serverFunc();
void serverFunc()
{
    using (Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
    {
        IPEndPoint endPoint = new(IPAddress.Any, 11200);
        serverSock.Bind(endPoint);
        serverSock.Listen(10);

        while (true)
        {
            Socket clientSock = serverSock.Accept();
            byte[] recvBytes = new byte[1024];

            int nRecv = clientSock.Receive(recvBytes);
            string txt = Encoding.UTF8.GetString(recvBytes, 0, nRecv);

            byte[] sendBytes = Encoding.UTF8.GetBytes("Hello: " + txt);
            clientSock.Send(sendBytes);
            clientSock.Close();
        }
    }
}

 

TCP 클라이언트에서 UDP에 비교해서 Connect 단계만 추가되었다.

 

예제 6.38 TCP 클라이언트 측 소켓 구현

using System.Net;
using System.Net.Sockets;
using System.Text;

clientFunc();
void clientFunc()
{
    Socket sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    EndPoint serverEP = new IPEndPoint(IPAddress.Loopback, 11200);

    sock.Connect(serverEP);

    byte[] buf = Encoding.UTF8.GetBytes(DateTime.Now.ToString());
    sock.Send(buf);

    byte[] recvBytes = new byte[1024];
    int nRecv = sock.Receive(recvBytes);
    string txt = Encoding.UTF8.GetString(recvBytes, 0, nRecv);

    Console.WriteLine(txt);

    sock.Close();
    Console.WriteLine("TCP Client socket: Closed");
}

 

TCP 클라이언트 측에서 Connect를 호출하는 시점에서 TCP 서버는 반드시 Listen을 호출한 상태여야 한다. 그렇지 않을 시 tcp 클라이언트의 Connect 호출은 예외를 일으켜 통신에 실패한다. 

 

UDP 서버와 마찬가지로 TCP 서버도 다중 클라이언트의 연결을 허용하므로 스레드를 통해 다중 연결 할 수 있다.

 

 

TCP 소켓 특성

* 연결성(connection-oriented): TCP 통신은 서버측의 Listen/Accept와 클라이언트 측의 Connect를 이용해 반드시 연결이 이뤄진 다음 통신할 수 있다.

 

* 신뢰성: Send 메서드를 통해 수신 측에 데이터가 전달되면 수신 측은 내부적으로 그에 대한 확인(ACK) 신호를 송신 측에 전달한다. 따라서 TCP 통신은 데이터가 수신 측에 정상 전달됐는지 알 수 있고, 이 과정에서 ACK 신호가 오지 않으면 자동으로 데이터를 재전송함으로써 신뢰성을 확보한다.

 

*스트림 중심(stream-oriented): UDP에서 제공된 메시지 간의 경계가 없다. 예를 들어 10,000 바이트의 데이터를 Send 메서드를 이용해 송신하는 경우 내부적인 통신 상황에 따라 2048, 2048, 5904 바이트 단위로 잘라서 전송될 수 있다. 따라서 1번의 Send 메서드가 실행됐음에도 수신 측은 여러번 Receive 메서드를 호출해야만 모든 데이터를 받을 수 있다. 이렇게 메시지 경계를 가지지 않고 전달되는 것을 스트림 방식이라 한다.

 

* 순서 보장: 데이터를 보낸 순서대로 수신 측에 전달된다. 예를 들어 3번의 Send 메서드가 호출돼 각 100, 105, 102 바이트가 전송될 경우, 수신 측의 첫번째 Receive 메서드는 100바이트에 해당하는 데이터를 먼저 스트림 방식으로 수신 하게 된다.

 

6.7.5.3 TCP 서버 개선 - 다중 스레드와 비동기 통신

예제 6.37의 TCP 서버는 문제가 있는데 소켓 통신에 사용되는 모든 메서드가 기본적으로 동기 호출이다. 즉 I/O가 완료될 때 까지 Send/Receive 메서드를 호출한 스레드는 블로킹되므로 서버측에서 Accept를 빠르게 처리할 수 없는 문제가 있다.

더보기

TCP 서버에서 발생하는 이 문제는 소켓의 블로킹 I/O와 관련이 있습니다. 기본적으로 소켓 통신에서 사용되는 Send()나 Receive() 메서드는 동기적(blocking) 호출이기 때문에, I/O 작업이 완료될 때까지 해당 스레드는 다른 작업을 처리하지 못하고 블로킹 상태에 빠지게 됩니다. 이는 서버에서 Accept() 메서드도 처리하는 데 영향을 미칠 수 있습니다.

문제의 주요 원인:

  1. Blocking I/O:
    • Send(): 서버가 클라이언트로 데이터를 전송할 때, Send() 메서드는 전송이 완료될 때까지 호출한 스레드를 기다리게 만듭니다. 즉, 데이터가 클라이언트에게 전달될 때까지 서버 스레드는 다른 작업을 할 수 없습니다.
    • Receive(): 서버가 클라이언트로부터 데이터를 받을 때, Receive() 메서드는 데이터가 도착할 때까지 스레드를 블로킹 상태로 둡니다. 즉, 수신할 데이터가 없으면 서버는 해당 스레드에서 다른 작업을 처리할 수 없게 됩니다.
  2. Accept() 메서드:
    • 서버는 클라이언트의 연결 요청을 처리하기 위해 Accept() 메서드를 호출합니다. 이 메서드는 클라이언트가 연결을 시도할 때까지 블로킹되어, 서버가 여러 클라이언트를 동시에 처리할 수 없게 만듭니다.
    • Accept()는 새로운 연결을 수락할 때까지 서버 스레드를 블로킹하므로, 여러 클라이언트가 동시에 연결을 시도하는 경우, 서버는 이전 클라이언트의 연결을 완료한 후에야 다음 연결을 처리할 수 있습니다.

문제의 결과:

  • 서버가 동기적인 방식으로 Send(), Receive(), Accept()를 처리하면, 각각의 소켓 작업(예: 데이터 전송, 수신, 연결 수락)이 완료될 때까지 서버 스레드가 블로킹 상태에 빠지게 되어 서버의 성능과 응답성이 저하됩니다.
  • 클라이언트와의 통신 중에 서버가 다른 클라이언트와의 연결을 처리하는 데 지연이 발생하게 됩니다. 특히, 수많은 클라이언트 요청을 처리하려는 대규모 서버에서 이 문제는 더욱 두드러집니다.

해결 방법:

  1. 비동기 소켓 I/O:
    • 비동기 소켓(I/O): Send(), Receive(), Accept()와 같은 메서드를 비동기적으로 처리할 수 있습니다. .NET의 경우 BeginSend(), BeginReceive(), BeginAccept()와 같은 비동기 메서드를 사용하여 비동기적으로 소켓 작업을 처리할 수 있습니다. 이를 통해 스레드는 블로킹되지 않고 다른 작업을 처리하면서 동시에 소켓 작업을 완료할 수 있습니다.
    • 비동기 메서드를 사용하면, 서버는 클라이언트로부터 데이터를 비동기적으로 받고, 보내고, 연결을 수락하는 등의 작업을 동시에 처리할 수 있습니다.
  2. 멀티스레딩:
    • 멀티스레딩을 사용하여 각 연결에 대해 별도의 스레드를 할당할 수 있습니다. 이렇게 하면, 각 클라이언트에 대한 작업이 독립적으로 처리되므로 서버가 여러 클라이언트를 동시에 처리할 수 있게 됩니다. Accept()가 하나의 스레드를 블로킹하지 않도록, 새로운 연결을 받아들이기 위한 전용 스레드를 두고, 각 클라이언트에 대해 별도의 스레드를 할당하는 방식입니다.
  3. 이벤트 기반 프로그래밍:
    • 이벤트 기반 모델을 사용하여 비동기적으로 이벤트를 처리할 수 있습니다. 예를 들어, SocketAsyncEventArgs 클래스를 사용하면 비동기적으로 소켓 I/O 작업을 처리할 수 있습니다. 이 방법은 서버가 여러 연결을 동시에 관리하는 데 적합하며, 성능을 최적화할 수 있습니다.

요약:

소켓의 기본 메서드는 동기적으로 동작하기 때문에, 서버가 Send(), Receive(), Accept()를 블로킹 방식으로 처리하면 서버의 성능이 저하됩니다. 이 문제를 해결하려면 비동기 I/O 방식, 멀티스레딩, 또는 이벤트 기반 모델을 사용하여 서버의 응답성과 성능을 개선할 수 있습니다.

 

더보기

비동기(Asynchronous)**와 **멀티스레딩(Multithreading)**은 서로 다른 개념입니다. 둘 다 병렬적으로 작업을 처리하는 방식이지만, 그 동작 방식과 사용 방식에 차이가 있습니다.

1. 비동기(Asynchronous)

비동기한 스레드가 다른 작업을 기다리지 않고, 다른 작업을 실행할 수 있도록 하는 방식입니다. 비동기 작업은 주로 I/O 작업에서 사용됩니다. 예를 들어, 데이터베이스 쿼리, 파일 입출력, 네트워크 요청 등이 대표적인 비동기 작업입니다.

  • 동작 방식:
    • 비동기 작업은 한 작업이 완료될 때까지 기다리지 않고, 다른 작업을 동시에 실행합니다.
    • 작업이 끝나면 **콜백(callback)**을 통해 결과를 받거나, 후속 작업을 수행할 수 있습니다.
  • 예시:
    • 네트워크 요청을 보내고, 데이터가 응답될 때까지 기다리는 대신 다른 작업을 처리하고, 데이터가 돌아오면 결과를 처리합니다.
    • 예를 들어, .NET에서는 async와 await 키워드를 사용하여 비동기 작업을 쉽게 처리할 수 있습니다.
  • 장점:
    • 자원을 효율적으로 사용하며, 긴 I/O 작업 동안 CPU가 다른 작업을 할 수 있게 됩니다.
    • 대기 시간이 길거나 자주 발생하는 I/O 작업에서 성능을 크게 향상시킬 수 있습니다.
  • 단점:
    • 코드가 복잡해질 수 있고, 비동기 작업의 흐름을 이해하는 것이 어려울 수 있습니다.
    • 비동기 작업은 하나의 스레드에서 작업을 관리하지만, 여전히 여러 작업을 "동시적으로" 처리하는 것처럼 보이게 할 수 있습니다.

2. 멀티스레딩(Multithreading)

멀티스레딩여러 스레드를 동시에 실행하여 작업을 병렬로 처리하는 방식입니다. 각 스레드는 독립적으로 실행되며, 이를 통해 여러 작업을 동시에 처리할 수 있습니다.

  • 동작 방식:
    • 각 작업이 별도의 스레드에서 실행되므로, 각 작업이 동시에 실행됩니다.
    • 멀티스레딩을 사용하면 여러 CPU 코어를 활용할 수 있습니다.
  • 예시:
    • 여러 사용자가 동시에 접속하는 웹 서버에서, 각 클라이언트의 요청을 별도의 스레드에서 처리합니다.
    • 긴 계산 작업을 여러 스레드로 나누어 동시에 처리합니다.
  • 장점:
    • CPU를 효율적으로 사용할 수 있으며, 멀티코어 시스템에서 성능을 극대화할 수 있습니다.
    • 계산량이 많은 작업을 병렬로 처리하는 데 적합합니다.
  • 단점:
    • 스레드 간의 동기화가 필요하고, 이를 잘못 처리하면 **경쟁 조건(race condition)**이나 데드락(deadlock) 등의 문제가 발생할 수 있습니다.
    • 스레드를 많이 생성하면 컨텍스트 스위칭(각 스레드 간 전환)이 발생하여 오히려 성능 저하를 일으킬 수 있습니다.

비동기와 멀티스레딩의 차이점:

비동기 (Asynchronous)멀티스레딩 (Multithreading)
하나의 스레드에서 여러 작업을 순차적으로 실행하며, I/O 대기 중 다른 작업을 처리합니다. 여러 스레드를 사용하여 동시에 여러 작업을 병렬로 실행합니다.
주로 I/O 작업(파일, 네트워크 등)에 사용됩니다. CPU 바운드 작업(계산, 복잡한 처리 등)에 적합합니다.
작업이 완료될 때까지 기다리지 않고, 다른 작업을 동시에 처리합니다. 각 스레드가 독립적으로 실행되며, 모든 작업이 동시에 실행됩니다.
리소스 사용이 효율적이며, 스레드 간의 충돌이나 동기화 문제가 적습니다. 여러 스레드가 리소스를 공유하므로 동기화 문제나 경합이 발생할 수 있습니다.

결론:

  • 비동기한 스레드에서 여러 I/O 작업을 동시에 처리할 수 있게 하는 방식입니다.
  • 멀티스레딩여러 스레드를 사용하여 여러 작업을 병렬로 처리하는 방식입니다.
  • 두 방식은 동시에 사용할 수 있으며, 비동기 작업을 멀티스레딩 환경에서 처리할 수 있습니다. 예를 들어, 비동기 I/O 작업을 여러 스레드에서 동시에 실행하면 성능이 크게 향상될 수 있습니다.

 

블로킹 문제를 해결하기 위해 서버 소켓이 Accept로 반환받은 클라이언트의 처리를 별도의 스레드에 맡겨 처리할 수 있다.

 

using System.Net;
using System.Net.Sockets;
using System.Text;

serverFunc();
void serverFunc()
{
    using (Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
    {
        IPEndPoint endPoint = new(IPAddress.Any, 11200);
        serverSock.Bind(endPoint);
        serverSock.Listen(10);

        while (true)
        {
            Socket clientSock = serverSock.Accept();
            ThreadPool.QueueUserWorkItem((WaitCallback) clientSocketProcess, clientSock);

        }
    }
}

void clientSocketProcess(object? state)
{
    Socket clientSock = state as Socket;
    byte[] recvBytes = new byte[1024];

    int nRecv = clientSock.Receive(recvBytes);
    string txt = Encoding.UTF8.GetString(recvBytes, 0, nRecv);

    byte[] sendBytes = Encoding.UTF8.GetBytes("Hello: " + txt);
    clientSock.Send(sendBytes);
    clientSock.Close();
}

 

클라이언트 하나 당 스레드를 대응시켜 처리하는 것은 구현하기 쉬운 장점이 있으나 단점으로는 서버의 성능을 충분히 발휘할 수 없는 구조적 결함이 있다.

32비트 서버에서 사용자 프로그램이 사용 가능한 메모리 용량이 2GB였고, 하나의 스레드가 필요로 하는 스택의 메모리 크기가 1MB인점을 감안하면 2000개 정도의 스레드만 생성 가능하다.(동시 접속 클라이언트가 2000개로 제한)

64비트의 경우 2GB 메모리 용량 제약이 없어졌지만 스레드가 많아지면 스레드 문맥전환 부하가 발생하게 된다. 이에 따라 서버의 성능 저하로 이어진다.

이러한 문제를 비동기 통신을 사용함으로써 해결 가능하다. 소켓 통신 역시 OS에게는 I/O에 속하기 때문에 Socket 타입에는 Send, Receive 메서드에 대해 각각 BeginSend/EndSend, BeginReceive/EndReceive의 비동기 메서드가 제공된다.

 

6.37을 개선하면 다음과 같다.

 

예제 6.39 TCP 서버의 비동기 통신

using System.Net;
using System.Net.Sockets;
using System.Text;

public class AsyncStateData
{
    public byte[] Buffer;
    public Socket Socket;
}

class Program
{
    static void Main(string[] args)
    {
        //..[생략]..

    }

    private static void ServerFunc(object obj)
    {
        using(Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, 11200);
            serverSock.Bind(endPoint);

            serverSock.Listen(10);
            while (true)
            {
                Socket clientSock = serverSock.Accept();

                AsyncStateData data = new();
                data.Buffer = new byte[1024];
                data.Socket = clientSock;

                clientSock.BeginReceive(data.Buffer, 0, data.Buffer.Length, SocketFlags.None, AsyncReceiveCallback, data);
            }
        }
    }

    private static void AsyncReceiveCallback(IAsyncResult asyncResult)
    {
        AsyncStateData recvData = asyncResult.AsyncState as AsyncStateData;

        int nRecv = recvData.Socket.EndReceive(asyncResult);
        string txt = Encoding.UTF8.GetString(recvData.Buffer, 0, nRecv);

        byte[] sendBytes = Encoding.UTF8.GetBytes("Hello: " + txt);
        recvData.Socket.BeginSend(sendBytes, 0, sendBytes.Length, SocketFlags.None, AsyncSendCallback, recvData.Socket);

    }

    private static void AsyncSendCallback(IAsyncResult asyncResult)
    {
        Socket socket = asyncResult.AsyncState as Socket;
        socket.EndSend(asyncResult);

        socket.Close();
    }

    //...[생략]...
}

 

BeginReceive -> EndReceive -> BeginSend -> EndSend로 이어지는 기본적인 비동기 호출 패턴을 염두에 두면 serverFunc -> asyncReceiveCallback -> asyncSendCallback으로의 흐름이 보일 것이다.

serverFunc에서 호출된 BeginRecevie 메서드는 비동기 동작으로 인해 곧바로 제어가 반환된다. 그리고 while 루프의 처음으로 돌아가 Accept호출을 이어서 실행하게 된다. 이 떄문에 별도 스레드를 생성하지 않고도 또 다른 클라이언트의 연결을 지연없이 받아서 처리 가능하다.

그러나 Send/Recieve 메서드가 한 번씩 호출되는 정도의 간단한 프로그램이었어도 비동기 호출로 바뀌면서 구현이 과다하게 복잡해졌다. 이것이 비동기 호출의 가장 큰 단점이다. 따라서 고성능 tcp 서버를 구현하는 것이 아니라면 비동기 호출을 이용하기 보단 스레드와 클라이언트가 1:1로 대응하는 형식으로 만드는것이 선호된다.

 

UDP/TCP 통신 프로그램을 만들 때 개선해야 할 부분은 '예외 처리'다. Receive와 Send를 호출하는 도중 상대방의 네트워크 선이 뽑힐 수있고 컴퓨터가 꺼질 수 있다. 이때는 Receive/Send를 호출할 때 예외가 발생한다. 

이에 상기 코드들에 예외 처리(try-catch 구문)를 추가하자.

    private static void AsyncReceiveCallback(IAsyncResult asyncResult)
    {
        try
        {
            AsyncStateData recvData = asyncResult.AsyncState as AsyncStateData;

            int nRecv = recvData.Socket.EndReceive(asyncResult);
            string txt = Encoding.UTF8.GetString(recvData.Buffer, 0, nRecv);

            byte[] sendBytes = Encoding.UTF8.GetBytes("Hello: " + txt);
            recvData.Socket.BeginSend(sendBytes, 0, sendBytes.Length, SocketFlags.None, AsyncSendCallback, recvData.Socket);
        }
        catch(SocketException)
        {

        }
    }

    private static void AsyncSendCallback(IAsyncResult asyncResult)
    {
        Socket socket = asyncResult.AsyncState as Socket;
        try
        {
            socket.EndSend(asyncResult);
        }
        catch(SocketException)
        {

        }
        finally
        {
            socket.Close();
        }
    }

 

6.7.5.4 HTTP 통신

HTTP 통신은 TCP 서버/클라이언트의 한 사례다. 웹서버는 TCP 서버이고 웹브라우저는 TCP 클라이언트이다. HTTP 서버는 80포트를 사용한다. 따라서 마이크로소프트의 웹서버 접점정보는 'www.microsoft.com:80'이다.

 

HTTP 프로토콜의 기본은 '요청(Request)'과 응답('Response')이다. 접속한 클라이언트 측에서 먼저 요청을 보내면(send), 서버는 요청으로 받은 내용을 분석해 어떤 데이터를 넘겨줘야 할지를 판단하고 클라이언트 측으로 응답(response)을 보낸다.

 

웹브라우저를 사용했다면 URL(Uniform Resource Locator)에 익숙할 것인데 이는 '유일한 자원 지시자'로 해석되며 HTTP 요청은 서버 측에 자원에 대한 위치를 포함해야만 한다.

마이크로소프트 웹서버로부터 첫 페이지를 가져오는 경우 HTTP 요청은 다음과 같은 형식이다.

GET / HTTP/1.0(개행문자)
HOST: www.microsoft.com(개행문자)
(개행문자)

위의 요청은 실제 웹 브라우저에서 URL을 'http://www.microsoft.com'으로 지정했을 때 전송되는 데이터와 유사하다. 

 

웹문서의 HTML 소스를 그대로 보여주는 웹 브라우저를 다음과 같이 만들 수 있다.

using System.Net;
using System.Net.Sockets;

class Program
{
    static void Main(string[] args)
    {
        Socket sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        IPAddress ipAddr = Dns.GetHostEntry("www.microsoft.com").AddressList[0];

        EndPoint serverEP = new IPEndPoint(ipAddr, 80);

        sock.Connect(serverEP);

        // ... HTTP 프로토콜 통신 ...

        sock.Close();

    }
}

 

그리고 Send와 Receive는 이전에 설명한 요청/응답 과정에 따라 구현한다.

 

예제 6.40 tcp 소켓을 이용한 HTTP 통신

using System.Net;
using System.Net.Sockets;
using System.Text;

class Program
{
    static void Main(string[] args)
    {
        Socket sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        IPAddress ipAddr = Dns.GetHostEntry("www.microsoft.com").AddressList[0];

        EndPoint serverEP = new IPEndPoint(ipAddr, 80);

        sock.Connect(serverEP);

        string request = "GET / HTTP/1.0\r\nHost: www.microsoft.com\r\n\r\n";
        byte[] sendBuffer = Encoding.UTF8.GetBytes(request);

        //Microsoft 웹 서버로 HTTP 요청을 전송
        sock.Send(sendBuffer);

        //HTTP 요청이 전달됐으므로 Microsoft 웹 서버로부터 응답 수신
        MemoryStream ms = new();
        while (true)
        {
            byte[] rcvBuffer = new byte[4096];

            int nRecv = sock.Receive(rcvBuffer);
            if(nRecv == 0)
            {
                //서버 측으로부터 더 이상 받을 데이터가 없으면 0 반환
                break;
            }
            ms.Write(rcvBuffer, 0, nRecv);
        }

        sock.Close();

        string response = Encoding.UTF8.GetString(ms.GetBuffer(), 0, (int)ms.Length);

        Console.WriteLine(response);

        //서버 측에서 받은 HTML 데이터를 파일로 저장
        File.WriteAllText("page.html", response);
    }
}

 

코드 첫 줄에 request 변수의 문자열에 개행 문자 (\r\n)을 이용해 HTTP 요청을 만들었고 이렇게 완성된 HTTP 요청을 웹 서버로 전송하면 응답이 반환되어 최종적으로 그 결과를 파일(page.html)로 저장한다.

 

HTTP 프로토콜은 요청과 응답에서 2개의 개행문자(\r\n\r\n)를 구분자로 하여 HTTP 헤더(Header)와 본문(Body)의 내용을 구성한다.

 

웹 브라우저란 사용자가 주소란에 입력한 정보를 기반으로 HTTP 요청을 만들어 TCP 소켓으로 전송하고 그 응답으로 받은 텍스트 중에서 HTTP 본문에 해당하는 내용을 화면에 출력하는 프로그램이다.

 

예제 6.41 TCP소켓으로 구현한 HTTP 서버 (예제 6.37과 유사한 기본 뼈대)

using System.Net;
using System.Net.Sockets;

class Program
{
    static void Main(string[] args)
    {
        using (Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            Console.WriteLine("http://localhost:8000 으로 방문해 보세요.");

            IPEndPoint endPoint = new(IPAddress.Any, 8000);

            serverSock.Bind(endPoint);
            serverSock.Listen(10);

            while (true)
            {
                Socket clientSock = serverSock.Accept();
                ThreadPool.QueueUserWorkItem(httpProcessFunc, clientSock);
            }
        }
    }

    private static void httpProcessFunc(object? state)
    {
        Socket sock = state as Socket;

        //..[HTTP 프로토콜 통신에 따라 Send/Receive]...

        sock.Close();
    }
}

 

httpProcessFunc 메서드 내에서는 클라이언트 측에서 먼저 보낸 HTTP 요청을 읽어야 하므로 Receive를 한 후 적당한 HTTP 응답을 구성해서 Send로 돌려준다.

using System.Net;
using System.Net.Sockets;
using System.Text;

class Program
{
    static void Main(string[] args)
    {
        using (Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            Console.WriteLine("http://localhost:8000 으로 방문해 보세요.");

            IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, 8000);

            serverSock.Bind(endPoint);
            serverSock.Listen(10);

            while (true)
            {
                Socket clientSock = serverSock.Accept();
                ThreadPool.QueueUserWorkItem(httpProcessFunc, clientSock);
            }
        }
    }

    private static void httpProcessFunc(object? state)
    {
        Socket sock = state as Socket;

        byte[] reqBuf = new byte[4096];
        sock.Receive(reqBuf);

        string header =
            "HTTP/1.0 200 OK\nContent-Type: text/html; charset=UTF-8\r\n\r\n";
        string body =
            "<html><body><mark>테스트 HTML</mark> 웹페이지 입니다. </body></html>";

        byte[] respBuf = Encoding.UTF8.GetBytes(header + body);

        sock.Send(respBuf);
        sock.Close();
    }
}

 

P490 그림 6.34 TCP로 구현한 웹 서버를 웹브라우저에서 확인한 모습 을 확인하자.

(위 코드는 접근제한때문에 에러남)

 

메일을 전송하는 SMTP 프로토콜, 메일을 수신하는 POP3 프로토콜, 파일을 송수신하는 FTP 프로토콜 등 모두 HTTP 프로토콜과 유사한 방식으로 접근한다.

 

6.7.6 System.Net.Http.HttpClient

BCL에서는 HTTP 통신을 더 쉽게 사용 가능하게끔 HttpClient 타입이 제공된다.

 

예제 6.40의 코드를 다음과 같이 간단히 처리할 수 있다.

using System.Net;
using System.Net.Sockets;
using System.Text;

class Program
{
    static HttpClient _client = new();
    static void Main(string[] args)
    {
        string txt = _client.GetStringAsync("http://www.naver.com:80").Result;
        Console.WriteLine(txt);

    }
}

 

화면 출력 내용이 예제6.40과 달리 HTTP 헤더 영역이 제거되고 순수 HTTP 본문만 포함돼 있다.

이렇게 HttpClient 객체는 HTTP 통신과 관련된 요청/응답 데이터를 적절하게 해석하는 역할까지 대행하므로 TCP 소켓을 직접 사용해서 통신해야하는 불편함이 줄어든다.

 

*HttpClient의 GetStringAsync는 비동기 메서드로 보통 위와 같은 식으로 사용하지 않는다. c#5.0 부터 추가된 await 예약어를 이용한 호출을 할텐데 이에 대해 10.2 비동기호출 절에서 설명한다.

 

 

6.8 데이터 베이스

직렬화/역직렬화(6.3절, 2진데이터, xml, json)를 통해 파일에 특정 정보를 써넣을 수 있다. 그러나 정상적으로 회원 정보를 읽고 쓰려면 모든 데이터를 읽어서 회원 정보를 복원하고 가공한 다음 다시 모든 데이터를 파일에 써야한다. 간단한 데이터면 이러한 방식이 유용하나 데이터 규모가 MB, GB까지 늘어나면 매우 비효율적이다.

 

표 6.20 포맷이 정해진 회원정보

필드 의미 크기 근거
Name 이름 20byte 한국인의 이름이 최대 10자를 넘지 않는다고 가정, 유니코드 2바이트로 산정하여 20바이트 크기 필요
Birth 생년월일 8바이트 DateTime 데이터를 저장.  DateTime.Ticks 필드의 타입이 long이므로 8바이트
Email 전자메일 주소 100바이트 영문자 기준 100글자 이내로 가정
Family 가족 구성원 수 1바이트 0~255 까지 표현 가능하므로 충분히 한 가정의 구성원 수 담을 수 있음

파일에 회원 한 명의 정보당 129바이트가 소비된다. 각 필드로 이뤄진 하나의 회원 정보를 '레코드'라 한다.

회원 정보를 정의했다면 레코드 하나 당 129 바이트의 크기가 필요하다. 129바이트의 배수대로 회원 정보를 검색해 나갈 수 있다.

n번째 회원 정보를 가져오려면 FileStream.Position = 129 * (n - 1)로 지정해서 그로부터 129바이트의 값을 읽어 낸다.

 

레코드를 이용해 파일 입출력을 하다보면 파일 검색을 좀 더 효율적으로 만들 필요가 있다. 특정 이름으로 자주 검색 시 Name 필드에 대해 정렬된 인덱스 파일을 유지해야거나 파일에 대해 2개 이상의 스레드, 2개 이상의 프로세스에서 동시에 접근하는 것도 고려해야 한다. 이러한 작업을 안정적으로 구현한 sw가 관계형 데이터 베이스(RDB: relational database) 서버다.

 

6.8.1 마이크로소프트 SQL 서버

RDB 종류는 많지만 여기선 MS에서 개발한 SQL 서버를 다뤄보자. 

 

Microsoft SQL Server 2022 Express 다운

https://www.microsoft.com/ko-kr/sql-server/sql-server-downloads

 

SQL Server 다운로드 | Microsoft

지금 Microsoft SQL Server 다운로드를 시작하세요. 내 데이터와 워크로드에 가장 적합한 SQL Server 체험판 또는 버전, 에디션, 도구 또는 커넥터를 선택하세요.

www.microsoft.com

 

 

 

 

sql 서버 파일들이 제대로 생성되지 않아서(c:\Program Files\Microsoft SQL Server\번호\Shared) 

2019버젼으로 대체.

 

https://m.blog.naver.com/cjswo701/221146087226

 

MSSQL SQL 구성관리자 실행 에러 WMI 공급자에 연결할 수 없습니다 0x80041010 오류 해결방법

처음 보는 에러 네요..  sql 구성 관리자 사용 할 일이 있어서.. 켰는데 오오잉? " WMI 공급자에 ...

blog.naver.com

mof 파일도 없어서 해당 파일로 사용

 

https://www.microsoft.com/ko-kr/download/details.aspx?id=101064

 

Download Microsoft® SQL Server® 2019 Express from Official Microsoft Download Center

Microsoft® SQL Server® 2019 Express는 간단한 웹 사이트 및 데스크톱 응용 프로그램에 다양하고 안정적인 데이터 저장소를 제공하는 강력하고 안정적인 무료 데이터 관리 시스템입니다.

www.microsoft.com

 

 

 

데이터베이스(1개 이상의 테이블을 담을 수 있는 컨테이너)를 외부 프로그램에 서비스하는 것이 마이크로소프트 SQL 서버 같은 관계형 데이터베이스 SW다.

 

관계형 데이터베이스는 한번 정해진 칼럼의 데이터 타입을 향후 필요에 희해 다른 타입으로 변경하는 것이 어렵기에 초기에 데이터 타입을 적절히 선택해야 한다.

 

표 6.21 SQL 서버 2022에서 제공되는 칼럼 데이터 타입

데이터 타입 설명
char(크기) 1개 이상의 문자를 담을 수있도록 고정된 길이 할당(최대 크기: 8000) 
nchar(크기) 1개 이상의 다국어 문자를 담을 수있도록 고정된 길이 할당(최대 크기: 4000) 
varchar(크기) 1개 이상의 문자를 담을 수 있지만, 지정된 수의 범위에서 가변적으로 할당(최대 크기: 8000)
nvarchar(크기) 1개 이상의 다국어 문자를 담을 수 있지만, 지정된 수의 범위에서 가변적으로 할당(최대 크기: 4000)
varchar(max) varchar의 최대 용량이 8000이므로 그 이상의 문자열 데이터를 담을 때 사용하며 기존 SQL 버전의 text 타입과 동일(최대 크기: 2GB)
nvarchar(max) nvarchar의 최대 용량이 4000이므로 그 이상의 다국어 문자열 데이터를 담을 때 사용하며 기존 SQL 버전의 ntext 타입과 동일(최대 크기: 1GB)
bit 0과 1의 데이터 표현 (boolean)
tinyint 1바이트 범위의 숫자
smallint 2바이트 범위의 숫자
int 4바이트 범위의 숫자
bigint 8바이트 범위의 숫자
date 날짜 데이터만 포함
datetime 날짜와 시간데이터 포함
decimal -1038 + 1 ~ 1038 - 1의 숫자
float 단정도 실수
real 배정도 실수

 

문자열 타입에서 n 접두사가 붙은 것은 유니코드 문자(UCS-2)를 담는 것을 의미.

char(10): 영문 10자, 한글 5자  (10바이트)

nchar(10): 영문 10자, 한글 10자

 

char(10) : 고정 길이

H e l l o          

 

varchar(10) : 가변 길이

H e l l o

 

거의 비슷한 크기의 문자열을 담는 경우, 고정 길이로 인해 속도가 빠른 char를 지정하고, 임의의 문자열을 담는 경우 약간의 속도 저하는 있으나 디스크 용량을 절약할 수 있는 var-char가 권장된다.

 

 

6.8.1.2 SQL 쿼리

MS SQL서버에서 실행되는 SQL 쿼리(모든 관계형 데이터베이스 SW는 데이터를 조작하는 방법으로 SQL 쿼리 표준언어를 지원함)는 오라클 DB서버, MySQL 서버에서도 그대로 사용 가능할 수 있지만, 대체로 각 회사의 확장 쿼리 구문을 지원하기에 실행이 되지 않을 수 있다.

 

Create(생성) : 테이블에 데이터 생성

Retrieve == Read(조회) : 테이블에 있는 데이터를 조회

Update(갱신): 테이블에 저장된 기존 데이터 변경

Delete(삭제): 테이블에 저장 된 기존 데이터 삭제

 

자료생성: INSERT

INSET INTO [테이블 명]([칼럼명1], [칼럼명2]...) VALUES([값1], [값2]...)

INSERT INTO MemberInfo(Name, Birth, Email, Family)
  VALUES('Anderson', '1993-12-01', 'anderson@naver.com', 5)

 

 

데이터 조회속도는 행의 값이 많아질 수록 느려진다. 조회 속도를 향상 시키는 방법으론 Where 조건에 나열되는 칼럼에 인덱스(index)를 설정할 수 있다.

칼럼에 인덱스가 지정되면 데이터 베이스 엔진은 해당 값에 대한 정렬된인덱스 자료구조를 내부적으로 관리한다. Where Family >= 2 같은 조건이 수행되면 2 이상의 값을 쉽게 찾을 수 있어 조회 속도가 비약적으로 향상된다.

 인덱스 키 설정

 

Family 칼럼에 인덱스가 부여되면 나중에 데이터 수가 늘어나도 전체 레코드를 열람하기 보다는 정렬된 인덱스의 도움을 받게 되므로 검색 속도를 크게 향상시킬 수 있다.

 

테이블에는 인덱스 말고도 기본 키(PK: Primary Key)를 지정해 조회속도를 향상시킬 수 있다.

 

기본키와 인덱스의 차이는 해당 칼럼의 값이 고유 하냐에 달려있다. 고유한 값을 가진 칼럼은 기본 키로 지정하고, 고유한 값은 아니나 조회속도 향상 목적으로 정렬될 필요가 있으면 인덱스로 지정한다.

 

여기서 기본키는 중복되지 않는 Email 칼럼이 적당하다. 

 

기본키로 지정된 칼럼은 자동으로 정렬된 내부 인덱스를 가지며, 특성 상 기본 키가 있는 상태에서 다시 다른 칼럼을 기본 키로 지정할 수 없다(2개 이상 기본키 설정 불가, 그러나 복합 기본키라고 해서 두개 이상의 칼럼을 결합하여 기본키 구성 가능하며 이때는 두개 중 하나만 있을때는 기본키로 구성이 안됨.)

 

자료 갱신: UPDATE

UPDATE[테이블명] SET [칼럼명] = [값], .... WHERE[조건]

UPDATE MemberInfo SET Birth='1950-06-20' WHERE Email = 'anderson@naver.com';

 

UPDATE 역시 갱신 자료의 조건을 만족하는 데이터를 빠르게 찾을 수 있게 WHERE에 사용된 칼럼을 인덱스로 지정하는 것이 권장된다.

 

자료 삭제: DELETE

테이블의 데이터를 레코드 단위로 삭제하기 위해 DELETE 구문을 사용한다.

테이블의 모든 데이터 삭제
DELETE FROM [테이블명]

조건 지정해서 삭제
DELETE FROM [테이블명] WHERE [조건]

DELETE FROM MemberInfo WHERE Family >= 2 AND Name = 'Anderson';

 

 

6.8.2 ADO.NET 데이터 제공자

Microsoft.Data.SqlClient.dll 라이브러리를 설치하자. (도구 - 누겟패키지 관리자 - 패키지 관리자 콘솔 창 - 'Install-Package Microsoft.Data.SqlClient' 입력       또는 Nuget 패키지 참조에서 Microsoft.Data.SqlClient 패키지 설치 가능)  

 

db 프로그램은 TCP 서버로 대부분 동작하기에, TCP 클라이언트 프로그램에서 DB를 사용하려면 서버의 IP 주소 또는 컴퓨터 이름과 함께 포트 번호가 필요하다. SQL 서버의 경우 기본 1433 포트 번호를 사용하므로 만약 SQL 서버가 192.168.0.10 컴퓨터에서 실행 중이라면, 그 접점 정보는 192.168.0.10:1433이 된다.

 

SQL 서버와 통신하기 위해서는 데이터를 주고받는 프로토콜 형식도 알아야하지만 해당 프로토콜 형식을 개발자가 알 필요는 없다. DB를 만든 업체에서 프로토콜이 포함된 DB 통신을 위한 전용 라이브러리를 제작해서 배포하기 때문이다.

 

결과적으로 개발자는 DB 서버와의 통신 프로토콜이 아닌 해당 라이브러리를 어떻게 잘 사용하냐가 중요하다. 이러한 전용 라이브러리를 'ADO.NET 데이터 제공자(data provider)' 라고 하며, 모든 ADO.NET 데이터 제공자는 MS에서 미리 정의해 둔 다음의 공통 인터페이스를 상속받아 구현한다.

 

System.Data.IDbConnection 데이터 베이스 서버와의 연결을 담당하는 클래스가 구현해야 할 인터페이스 정의
System.Data.IDbCommand 데이터베이스 서버 측으로 실행될 SQL 문을 전달하는 클래스가 구현해야 할 인터페이스 정의
System.Data.IDbDataParameter IDbCommand에 전달되는 SQL문의 인자(Parameter)값을 보관하는 클래스가 구현해야 할 인터페이스 정의
System.Data.IDataReader 실행될 SQL문으로부터 반환받은 데이터를 열람하는 클래스가 구현해야 할 인터페이스
System.Data.IDbDataAdapter System.Data.DataTable 개체와 상호작용하는 Data Adapter 클래스가 구현해야 할 인터페이스 정의

 

데이터 베이스 서버(MySQL, PostgreSQL 등)는  MS의 ADO.NET 데이터 제공자처럼 라이브러리를 제공해준다.

 

표6.23 Microsoft SQL 서버 ADO.NET 데이터 제공자

인터페이스 SQL 서버용 ADO.NET 구현 클래스
System.Data.IDbConnection Microsoft.Data.SqlClient.SqlConnection
System.Data.IDbCommand Microsoft.Data.SqlClient.SqlCommand
System.Data.IDbDataParameter Microsoft.Data.SqlClient.SqlParameter
System.Data.IDataReader Microsoft.Data.SqlClient.SqlDataReader
System.Data.IDbDataAdapter Microsoft.Data.SqlClient.SqlDataAdapter

 

 

6.8.2.1 Microsoft.Data.SqlClient.SqlConnection

DB 사용을 위해선 클라이언트 앱이 DB서버에 연결해야한다. 소켓 프로그래밍에서 Socket.Connect 메서드를 호출해 서버 프로그램에 연결하는 동작에 해당한다.

일반 소켓 접속과는 다른 점이 있다면 각 ADO.NET 데이터 제공자마다 정형화된 '연결 문자열(connection string)'이 정해져 있다.

SqlClient 데이터 제공자의 경우 다음과 같은 형식이다.

Data Source=[서버]\[인스턴스명];Initial Catalog=[DB명];User ID=[계정명];Password=[비밀번호]

 

예제) 설치 데이터베이스와 접속 계정이 다음과 같다.

데이터베이스 서버 주소: 192.168.0.10
데이터베이스 인스턴스 이름: SQLEXPRESS
데이터베이스 이름: TestDB
SQL 서버 계정: sa
SQL 서버 계정 비밀번호: pw@2023

 

SQL 접근하기 위한 연결 문자열

Data Source=192.168.0.10\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pw@2023

 

SQL 서버 주소가 응용 앱이 실행될 컴퓨터와 동일하다면 다음과 같이 축약 가능하다.(서버 주소가 점(dot)으로 대체됨)

Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pw@2023

 

마지막으로 통신 보안 강화로 인해 암호화 설정을 해야한다. 기본적으로 Microsoft.Data.SqlClient는 서버와 암호화 통신을 하는데 이것이 정상 동작하려면 서버에 인증서가 구성돼야 한다. 

 

암호화 통신을 끄는 방법: 연결 문자열에 'Encrypt=False' 옵션 추가

Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pw@2023;Encrypt=False

 

인증서 설정이 유효하지 않은 채로 그대로 암호화 통신을 진행하는 법: 'TrustServerCertificate=True' 설정을 추가

Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pw@2023;TrustServerCertificate=True

 

데이터베이스 연결 문자열이 마련되면 다음과 같이 SqlConnection 개체를 사용해 DB에 접속하고 해제할 수 있다.

 

using Microsoft.Data.SqlClient;
using System;

class Program
{
    static void Main(string[] args)
    {
        SqlConnection sqlCon = new();
        string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";
        sqlCon.ConnectionString = connectionString;

        try
        {
            // DB에 연결
            sqlCon.Open();

            // SQL 쿼리 작성 (예: MemberInfo 테이블에서 모든 행을 조회)
            string query = "SELECT * FROM MemberInfo";

            // SqlCommand 객체 생성
            SqlCommand command = new SqlCommand(query, sqlCon);

            // 데이터 리더를 통해 결과 가져오기
            SqlDataReader reader = command.ExecuteReader();

            // 조회된 데이터를 출력
            while (reader.Read())
            {
                // 각 행의 데이터 출력 (예: 첫 번째 열은 'EmployeeID', 두 번째 열은 'EmployeeName'이라고 가정)
                Console.WriteLine($"ID: {reader[0]}, Name: {reader[1]}");
            }

            // 리더 닫기
            reader.Close();
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
        finally
        {
            // 연결 닫기
            sqlCon.Close();
        }
    }
}


# 출력
ID: Anderson, Name: 1993 - 12 - 01 오전 12:00:00
ID: Mark, Name: 1998 - 03 - 02 오전 12:00:00
ID: Json, Name: 1995 - 02 - 02 오전 12:00:00

 

 

6.8.2.2 Microsoft.Data.SqlClient.SqlCommand

DB 연결 시 해당 DB가 소유한 모든 자원을 SqlCommand 타입을 이용해(with 쿼리) 데이터를 조작할 수 있다.

 

CRUD 쿼리(INSERT, SELECT, UPDATE, DELETE)를 SqlCommand를 이용해 실행 가능하다.

 

표 6.24 SqlCommand의 쿼리 실행을 위한 메서드 종류

쿼리 종류 SqlCommand 메서드 설명
INSERT ExecuteNonQuery 영향받은 Row의 수를 반환
UPDATE
DELETE
SELECT ExecuteScalar 1개의 값을 반환하는 쿼리 수행
ExecuteReader 다중 레코드를 반환하는 쿼리 수행

 

INSERT, UPDATE, DELETE 구문은 값 반환보단 수행하는 것에 의미가 있는 반면 SELECT는 데이터 조회에서 반환한다는 차이가 있음을 염두에 두자.

 

 

INSERT 쿼리 실습

using Microsoft.Data.SqlClient;
using System;

class Program
{
    static void Main(string[] args)
    {
        try
        {
            string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";

            using (SqlConnection sqlCon = new SqlConnection(connectionString))
            {
                // DB에 연결
                sqlCon.Open();

                // SQL 쿼리 작성 (예: MemberInfo 테이블에 데이터 삽입)
                string query = "INSERT INTO MemberInfo(Name, Birth, Email, Family) Values('Jun', '1993-12-01', 'jlim@naver.com', 5)";

                // SqlCommand 객체 생성
                SqlCommand command = new SqlCommand(query, sqlCon);

                // 쿼리 실행 (데이터 삽입)
                int rowsAffected = command.ExecuteNonQuery(); // rowsAffected: 해당 sql 문을 실행 할 때 테이블에 영향을 받는 레코드의 수가 리턴됨. INSERT 구문으로 1개의 레코드가 추가됐으니, 1반환

                // 실행된 행 수 출력
                Console.WriteLine($"{rowsAffected} row(s) inserted.");  // 출력 결과 1
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}



/*
 * using: SqlConnection 객체는 using 블록 안에서 자동으로 닫히기 때문에, 명시적으로 sqlCon.Close()를 호출할 필요가 없습니다.
 * using 블록을 사용하면 SqlConnection 객체가 Dispose되는 시점에 자동으로 Close가 호출됩니다. 
 * Dispose는 IDisposable을 구현하는 객체에서 자원을 해제하는 메서드입니다. SqlConnection은 IDisposable을 구현하고 있기 때문에, 
 * using 블록을 벗어나면 Dispose 메서드가 호출되어 자동으로 Close가 됩니다.

 *따라서 using 블록 내에서 예외가 발생하더라도, Dispose 메서드가 호출되면서 SqlConnection 객체는 자동으로 닫히게 됩니다.
 *예외 처리 후에도 연결이 정상적으로 닫히므로, finally 블록에서 명시적으로 Close를 호출할 필요는 없습니다.
 * 
 */

 

 

DELETE 쿼리도 UPDATE와 사용법은 같지만 SqlCommand.CommandText 속성(string query)에 해당 쿼리 문자열을 넣는 차이만 있다.

using Microsoft.Data.SqlClient;
using System;

class Program
{
    static void Main(string[] args)
    {
        try
        {
            string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";

            using (SqlConnection sqlCon = new SqlConnection(connectionString))
            {
                // DB에 연결
                sqlCon.Open();

                // SQL 쿼리 작성 (예: MemberInfo 테이블에 데이터 삽입)
                //UPDATE
                //string query = "UPDATE MemberInfo SET Family=3 WHERE Email = 'jlim@naver.com'"; 
                
                //DELETE
                //string query = "DELETE FROM MemberInfo WHERE Email = 'jlim@naver.com'";

                // SqlCommand 객체 생성
                SqlCommand command = new SqlCommand(query, sqlCon);

                // 쿼리 실행 (데이터 삽입)
                int rowsAffected = command.ExecuteNonQuery();

                // 실행된 행 수 출력
                Console.WriteLine($"{rowsAffected} row(s) inserted.");  // 출력 결과 1
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

 

 

SELECT COUNT(*) FROM MemberInfo WHERE Family >= 2

위 커리 처럼 조건을 만족하는 레코드의 수를 정수로 반환하는데 SqlCommand로 이 값을 읽어들이려면 ExecuteScalar를 사용한다.

using Microsoft.Data.SqlClient;
using System;

class Program
{
    static void Main(string[] args)
    {
        try
        {
            string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";

            using (SqlConnection sqlCon = new SqlConnection(connectionString))
            {
                // DB에 연결
                sqlCon.Open();

                // SQL 쿼리 작성 (예: MemberInfo 테이블에 데이터 삽입)
                string query = "SELECT COUNT(*) FROM MemberInfo WHERE Family >= 2";

                // SqlCommand 객체 생성
                SqlCommand command = new SqlCommand(query, sqlCon);

                // 쿼리 실행 (데이터 삽입)
                object objValue = command.ExecuteScalar();

                int memberAffected = (int)objValue;
                // 실행된 행 수 출력
                Console.WriteLine($"{memberAffected} row(s) inserted.");  // 출력 결과 1
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

 

SELECT가 단일 값을 반환 시 ExecuteSclar 메서드 사용하고 그렇지 않고 모든 쿼리를 실행할 때는 ExecuteReader 메서드를 사용한다.(eg. Select * From MemberInfo)

 

SqlCommand는 SqlConnection 객체가 맺어놓은 연결 위에서 실행된다. 성능 상의 이유로 SqlConnection.Open과 Close 구간은 가능한 한 빨리 실행될 수 있게 쿼리 실행 이외의 지연이 발생하는 다른 코드는 넣지 않는 것을 권장한다.

 

6.8.2.3 Microsoft.Data.SqlClient.SqlDataReader

ADO.NET 데이터 제공자는 명령을 실행할 수 있는 IDbCommand와 레코드를 읽어 낼 수 있는 IDataReader 인터페이스를 별도 구분해 놓았다. 따라서 이를 상속받는 모든 ADO.NET 데이터 제공자는 동일 규칙에 따르고 있으며 SqlClient도 ㅇ)ㅖ외는 아니다.

 

DataReader는 SELECT문을 수행한 결과를 한 행(ROW)씩 차례대로 끝까지 읽는 기능이다. 

 

예제 6.34 DataReader를 이용한 테이블 내용 조회

using Microsoft.Data.SqlClient;
using System;

class Program
{
    static void Main(string[] args)
    {
        try
        {
            string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";

            using (SqlConnection sqlCon = new SqlConnection(connectionString))
            {
                // DB에 연결
                sqlCon.Open();

                // SQL 쿼리 작성 
                string query = "SELECT * FROM MemberInfo";

                // SqlCommand 객체 생성
                SqlCommand command = new SqlCommand(query, sqlCon);

                // 쿼리 실행 (데이터 삽입)
                SqlDataReader reader = command.ExecuteReader();

                while (reader.Read()) //읽어야 할 데이터가 남아 있다면 true, 없다면 false 반환
                {
                    string name = reader.GetString(0);
                    DateTime birth = reader.GetDateTime(1);
                    string email = reader.GetString(2);
                    byte family = reader.GetByte(3);

                    Console.WriteLine($"{name}, {birth}, {email}, {family}");
                }
                reader.Close();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

# 출력

Anderson, 1993-12-01 오전 12:00:00, anderson @naver.com, 5
Mark, 1998-03-02 오전 12:00:00, mark @naver.com, 1
Json, 1995-02-02 오전 12:00:00, Json @naver.com, 3
Jun, 1993-12-01 오전 12:00:00, jlim @naver.com, 5

 

SqlDataReader 를 이용한 조회 코드는 전형적인 while 루프 처리이다. 변수 reader는 최초에 아무것도 가리키지 않다가Read를 호출하면서 다음행의 레코드를 가리킨다. (내부적으로 레코드를 가리키는것을 커서 (cursor)라 부르며 해당 코드의 경우 커서가 전방향(forward)으로 이동한다.

 

더 이상 읽을 레코드가 없으면 false = reader.Read()    -> false를 반환하여 while루프를 빠져나온다.

  

SqlDataReader가 SELECT 결과물을 담고 있는 상태는 아니며 커서가 가리키는 위치는 SQL 서버의 데이터베이스와 연결된 상태에서 테이블로부터 반환될 데이터의 행이 된다. Read 동작은 SqlConnection 객체가 데이터베이스 서버에 연결된 상태에서만 가능하다.

 

Read 메서드를 한번 호출하면 커서가 다음 행으로 이동하며, 각 행에 속한 칼럼별 데이터는 SqlDataReader에서 제공되는 Get... 메서드를 이용해 구할 수 있다. 각각의 Get... 메서드는 인자로 레코드의 칼럼 순서를 받고 반환값으로 그에 해당하는 데이터가 나온다.

SQL 서버의 칼럼 타입에 대해 각각 대응되는 C#데이터 타입은다음과 같다.

 

표6.25 SQL 데이터 타입과 C# 데이터 타입

데이터 타입 닷넷 데이터 타입 SqlDataReader메서드
char(크기) string GetString
nchar(크기)
varchar(크기)
nvarchar(크기)
varchar(max)
nvarchar(max)
bit bool GetBoolean
tinyint byte GetByte
smallint short GetInt16
int int GetInt32
bigint long GetInt64
date DateTime GetDateTime
datetime
decimal decimal GetDecimal
float float GetFloat
real double GetDouble

 

DataReader가 열려 있는 동안 SQL 서버와 연결이 유지돼야하고 이 시간이 길어질 수록 SQL 서버의 처리성능은 낮아진다.

고로, SqlDataReader를 사용하는 while 루프는 가능한 한 빨리 끝내는 것을 권장한다.

또한 DataReader를 사용 후 반드시 Close 메서드를 호출해야만 같은 연결 개체에서 명령을 실행 할 수 있다.

 

6.8.2.4 Microsoft.Data.SqlClient.SqlParameter

  쿼리문을 만드는 방법은 2가지다.

첫째 문자열을 연결해서 구성한다.

using Microsoft.Data.SqlClient;
using System;

class Program
{
    static void Main(string[] args)
    {
        try
        {
            string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";

            // 아래 변수 4개는 사용자로부터 값 입력받은 것으로 가정
            string name = "Cooper";
            DateTime birth = new DateTime(1990, 2, 7);
            string email = "cooper@naver.com";
            int family = 4;


            using (SqlConnection sqlCon = new SqlConnection(connectionString))
            {
                // DB에 연결
                sqlCon.Open();

                // SQL 쿼리 작성 
                string query = $"INSERT INTO MemberInfo(Name, Birth, Email, Family) " +
                    $"VALUES('{name}', '{birth.ToShortDateString()}', '{email}', {family})";

                // SqlCommand 객체 생성
                SqlCommand command = new SqlCommand(query, sqlCon);

                command.ExecuteNonQuery();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

 

상기 코드에서 문자열연산을 하는데 두가지 약점이 있다.

1. 보안 취약

- SQL 문법에 해당하는 문자열을 사용자가입력하는 경우, 수행되는 쿼리가 의도치 않은 결과를 낳을 수 있다.

이를 'SQL 주입(injection)'이라고 하며 심각한 보안 결함에 해당한다. 

2. 서버 측의 쿼리 수행 성능 저하 

- SQL 서버는 수행되는 쿼리를 내부적인 컴파일 과정을 거쳐 실행 계획(execution plan)을 생성한다. 그리고 한번 수행된 쿼리의 경우 실행 계획을 캐싱해서 다음에 동일 쿼리가 수행되면 빠르게 수행 할 수 있게 한다. 하지만 하나의 단일 쿼리문으로 수행되는 경우 동일 쿼리가 발생할 확률이 낮아지므로 캐시로 인한 성능이 좋지 않다.

 

이런 문제점은 매개변수화된 쿼리(parameterized query)를 사용하면 해결된다. 즉, 실행될 쿼리 문 중에서 변수처럼 사용될 영역을 별도로 구분해서 쿼리를 전달하는 것이다.

 

예제 6.44 매개변수화된 쿼리 사용

using Microsoft.Data.SqlClient;
using System;

class Program
{
    static void Main(string[] args)
    {
        try
        {
            string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";

            // 사용자로부터 값 입력받은 변수들
            string name = "Watson";
            DateTime birth = new DateTime(1992, 4, 27);
            string email = "watson@naver.com";
            int family = 4;

            using (SqlConnection sqlCon = new SqlConnection(connectionString))
            {
                // DB에 연결
                sqlCon.Open();

                // SQL 쿼리 작성 (파라미터화된 쿼리)
                string query = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES(@Name, @Birth, @Email, @Family)";

                // SqlCommand 객체 생성
                SqlCommand command = new SqlCommand(query, sqlCon);

                // 파라미터 추가
                command.Parameters.AddWithValue("@Name", name);
                command.Parameters.AddWithValue("@Birth", birth);
                command.Parameters.AddWithValue("@Email", email);
                command.Parameters.AddWithValue("@Family", family);

                // 쿼리 실행
                command.ExecuteNonQuery();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

 

CommandText에 지정된 쿼리에 기존의 값을 직접 포함하는 대신 @접두사와 함께 변수 이름을 지정했다.

해당 쿼리가 SqlCommand에 의해 수행되면 각 변수는 SqlCommand.Parameters 컬렉션에 포함된 같은 이름의 SqlParameter 객체의 값이 대응되어 처리된다

 

보안이 점점 중요해지기에 반드시 매개변수화된 쿼리를 사용해서 응용프로그램을 만들어야 한다.

사용자가 입력한 값은 SQL 쿼리의 파라미터 값이 된다. 중요한 점은, 매개변수화된 쿼리에서 사용자가 입력한 값은 SQL 코드의 일부로 해석되지 않고, 단순히 데이터로 취급함으로써 SQL Injection을 방지할 수 있다.

 

매개변수화된 쿼리의 장점 요약

  • 보안성 향상: 사용자의 입력값을 안전하게 처리하여 SQL 인젝션 공격을 방어한다.
  • 성능 향상: 쿼리 템플릿을 재사용함으로써 실행 계획을 캐시하고, 반복적인 쿼리 수행 시 성능을 최적화한다.

매개변수화된 쿼리는 SQL 서버에서 쿼리 실행 계획의 재사용을 가능하게 하여, 성능과 보안을 모두 개선할 수 있는 중요한 기법이다.

 

6.8.2.5 Microsoft.Data.SqlClient.SqlDataAdapter

 

SqlDataAdapter 타입은 Select 목적으로 SqlDataReader로 데이터를 읽으면서 While 루프 문에 많은 코드를 집어넣어 SqlConnection 객체의 연결이장시간 지속되는 문제를 해결함과 동시에 일부 편의성 기능을 포함시켰다.

 

예제 6.45 SqlDataAdapter를 이용한 테이블 내용 조회 (6.34 기반)

using Microsoft.Data.SqlClient;
using System;
using System.Data;
using static Azure.Core.HttpHeader;
using System.Reflection;

class Program
{
    static void Main(string[] args)
    {
        try
        {
            string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pwd1234;Encrypt=False";


            DataSet ds = new();

            using (SqlConnection sqlCon = new SqlConnection(connectionString))
            {
                SqlDataAdapter sda = new("SELECT * FROM MemberInfo", sqlCon);
                sda.Fill(ds, "MemberInfo");
            }
            ds.WriteXml(Console.Out); // DataSet이 가진 내용을 콘솔 화면에 출력.
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

#출력
< NewDataSet >
  < MemberInfo >
    < Name > Anderson </ Name >
    < Birth > 1993 - 12 - 01T00: 00:00 + 09:00 </ Birth >
    < Email > anderson@naver.com </ Email >
    < Family > 5 </ Family >
  </ MemberInfo >
  < MemberInfo >
    < Name > Mark </ Name >
    < Birth > 1998 - 03 - 02T00: 00:00 + 09:00 </ Birth >
    < Email > mark@naver.com </ Email >
    < Family > 1 </ Family >
  </ MemberInfo >
  < MemberInfo >
    < Name > Json </ Name >
    < Birth > 1995 - 02 - 02T00: 00:00 + 09:00 </ Birth >
    < Email > Json@naver.com </ Email >
    < Family > 3 </ Family >
  </ MemberInfo >
  < MemberInfo >
    < Name > Jun </ Name >
    < Birth > 1993 - 12 - 01T00: 00:00 + 09:00 </ Birth >
    < Email > jlim@naver.com </ Email >
    < Family > 5 </ Family >
  </ MemberInfo >
  < MemberInfo >
    < Name > Cooper </ Name >
    < Birth > 1990 - 02 - 07T00: 00:00 + 09:00 </ Birth >
    < Email > cooper@naver.com </ Email >
    < Family > 4 </ Family >
  </ MemberInfo >
  < MemberInfo >
    < Name > Cooper </ Name >
    < Birth > 1990 - 02 - 07T00: 00:00 + 09:00 </ Birth >
    < Email > cooper@naver.com </ Email >
    < Family > 4 </ Family >
  </ MemberInfo >
  < MemberInfo >
    < Name > Watson </ Name >
    < Birth > 1992 - 04 - 27T00: 00:00 + 09:00 </ Birth >
    < Email > watson@naver.com </ Email >
    < Family > 4 </ Family >
  </ MemberInfo >
</ NewDataSet >

 

SqlDataAdapter의 Fill 메서드는 예제 6.34에서 수행되는 while 루프의 코드를 대신 수행해준다.

데이터를 최대한 빨리 읽어내어 DataSet 개체에 채워 넣고 곧바로 연결 개체의 사용을 중지한다.  실제 ds.WriteXml 메서드를 이용해 해당 변수에 담긴 내용을 화면에 출력하면 상기 출력과 같이 SELECT 결과에 해당하는 모든 데이터를 포함하고 있음을 알 수 있다.

 

6.8.3 데이터 컨테이너

데이터 컨테이너(data container)란 데이터를 담고 있는 용도의 타입이다.

데이터 컨테이너 타입은 사용자가 직접 만들 수 있으며, 닷넷에서 제공되는 타입을 사용할 수도 있다.

둘 간의 차이를 통해 DataSet개념을 알아보자.

 

6.8.3.1 일반 닷넷 클래스

데이터 컨테이너로서 '단순한 유형의 닷넷 클래스(POCO: Plain Old CLR Object)'를 사용 할 수 있다.

 DB에서 정의한 테이블은 칼럼의 집합에 불과하여이를 닷넷 클래스로 표현하는 방법은 쉽다.

MemberInfo 테이블에 해당하는 데이터 컨테이너를 POCO로 정의하면 다음과 같다.

public class MemberInfo
{
    public string Name;
    public DateTime Birth;
    public string Email;
    public byte Family;
}

  

이를 기반으로 CRUD를 수행하는 데이터베이스 조작 클래스를 만들어보자.

 

예제 6.46 POCO와 대응되는 MemberInfoDAC

using Microsoft.Data.SqlClient;
using System.Collections;

public class MemberInfo
{
    public string Name;
    public DateTime Birth;
    public string Email;
    public byte Family;
}
public class MemberInfoDAC
{
    SqlConnection _sqlCon;

    public MemberInfoDAC(SqlConnection sqlCon)
    {
        _sqlCon = sqlCon;
    }

    void FillParameters(SqlCommand cmd, MemberInfo item)
    {
        SqlParameter paramName = new("@Name", System.Data.SqlDbType.NVarChar, 20);
        paramName.Value = item.Name;

        SqlParameter paramBirth = new(@"Birth", System.Data.SqlDbType.Date);
        paramBirth.Value = item.Birth;

        SqlParameter paramEmail = new("@Email", System.Data.SqlDbType.NVarChar, 100);
        paramEmail.Value = item.Email;

        SqlParameter paramFamily = new("@Family", System.Data.SqlDbType.TinyInt);
        paramFamily.Value = item.Family;


        cmd.Parameters.Add(paramName);
        cmd.Parameters.Add(paramBirth);
        cmd.Parameters.Add(paramEmail);
        cmd.Parameters.Add(paramFamily);
    }

    public void Insert(MemberInfo item)
    {
        string query = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES(@Name, @Birth, @Email, @Family)";

        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Update(MemberInfo item)
    {
        string query = "UPDATE MemberInfo SET Name=@Name, Birth=@Birth, Family=@Family WHERE Email=@Email";

        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Delete(MemberInfo item)
    {
        string query = "DELETE FROM MemberInfo WHERE Email=@Email";

        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public MemberInfo[] SelectAll()
    {
        string query = "SELECT * FROM MemberInfo";
        ArrayList list = new();

        SqlCommand cmd = new(query, _sqlCon);

        using (SqlDataReader reader = cmd.ExecuteReader())
        {
            while (reader.Read())
            {
                MemberInfo item = new MemberInfo();
                item.Name = reader.GetString(0);
                item.Birth = reader.GetDateTime(1);
                item.Email = reader.GetString(2);
                item.Family = reader.GetByte(3);
                
                list.Add(item);
            }
        }
        return list.ToArray(typeof(MemberInfo)) as MemberInfo[];
    }
}
더보기

개선 코드

using Microsoft.Data.SqlClient;
using System;
using System.Collections.Generic;

public class MemberInfo
{
    public string Name;
    public DateTime Birth;
    public string Email;
    public byte Family;
}

public class MemberInfoDAC
{
    SqlConnection _sqlCon;

    public MemberInfoDAC(SqlConnection sqlCon)
    {
        _sqlCon = sqlCon;
    }

    // SQL 쿼리의 파라미터를 채우는 메서드
    void FillParameters(SqlCommand cmd, MemberInfo item)
    {
        SqlParameter paramName = new("@Name", System.Data.SqlDbType.NVarChar, 20);
        paramName.Value = item.Name;

        SqlParameter paramBirth = new("@Birth", System.Data.SqlDbType.Date);
        paramBirth.Value = item.Birth;

        SqlParameter paramEmail = new("@Email", System.Data.SqlDbType.NVarChar, 100);
        paramEmail.Value = item.Email;

        SqlParameter paramFamily = new("@Family", System.Data.SqlDbType.TinyInt);
        paramFamily.Value = item.Family;

        cmd.Parameters.Add(paramName);
        cmd.Parameters.Add(paramBirth);
        cmd.Parameters.Add(paramEmail);
        cmd.Parameters.Add(paramFamily);
    }

    // MemberInfo 객체를 데이터베이스에 삽입하는 메서드
    public void Insert(MemberInfo item)
    {
        string query = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES(@Name, @Birth, @Email, @Family)";
        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    // MemberInfo 객체를 업데이트하는 메서드
    public void Update(MemberInfo item)
    {
        string query = "UPDATE MemberInfo SET Name=@Name, Birth=@Birth, Family=@Family WHERE Email=@Email";
        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    // MemberInfo 객체를 삭제하는 메서드
    public void Delete(MemberInfo item)
    {
        string query = "DELETE FROM MemberInfo WHERE Email=@Email";
        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    // 모든 MemberInfo 객체를 조회하는 메서드
    public MemberInfo[] SelectAll()
    {
        string query = "SELECT * FROM MemberInfo";
        List<MemberInfo> list = new();

        SqlCommand cmd = new(query, _sqlCon);

        using (SqlDataReader reader = cmd.ExecuteReader())
        {
            while (reader.Read())
            {
                MemberInfo item = new MemberInfo
                {
                    Name = reader.GetString(0),
                    Birth = reader.GetDateTime(1),
                    Email = reader.GetString(2),
                    Family = reader.GetByte(3)
                };

                list.Add(item);
            }
        }
        return list.ToArray(); // List를 바로 MemberInfo[] 배열로 변환
    }
}

주요 변경 사항:

  1. ArrayList를 List<MemberInfo>로 교체:
    • ArrayList는 타입이 정해지지 않아, MemberInfo[]로 변환할 때 형변환이 필요하다. 하지만 List<MemberInfo>를 사용하면, 타입 안전성이 보장되어 더 깔끔하고 유지보수가 쉬운 코드가 된다.
  2. ToArray() 메서드 사용:
    • List<MemberInfo>에서 ToArray()를 호출하면, MemberInfo[] 타입의 배열이 반환된다. 이제 명시적인 형변환 없이도 타입에 맞는 배열을 반환할 수 있다.
  3. SQL 쿼리 및 파라미터 처리:
    • FillParameters 메서드는 여전히 잘 작동하며, 매개변수화된 쿼리를 사용하여 SQL 인젝션을 방지하고 있다.

 

 

MemberInfoDAC 타입은 일반 닷넷 클래스로 정의된 MemberInfo 타입을 기반으로 데이터 베이스 테이블에 CRUD 연산을 모두 수행한다.

이렇게 정의된 데이터 컨테이너 타입과 그에 따른 DAC(Data Access Component)클래스를 이용하면 쉽게 데이터베이스와 연동 가능하다.

 

예제 6.47 MemberInfoDAC + POCO를 이용한 DB 연동

using Microsoft.Data.SqlClient;
using System;
using System.Collections;
using System.Collections.Generic;

string connectionString = @"Data Source=.\SQLEXPRESS;Initial Catalog=TestDB;User ID=sa;Password=pne1234;Encrypt=False";

MemberInfo item = new();
item.Name = "Sophia";
item.Birth = new DateTime(2002, 1, 2);
item.Email = "Sophia@naver.com";
item.Family = 0;

using (SqlConnection sqlCon = new(connectionString))
{
    sqlCon.Open();

    MemberInfoDAC dac = new(sqlCon);

    dac.Insert(item); // 신규 데이터 추가

    item.Name = "Jane";
    dac.Update(item); // 데이터 내용 업데이트

    MemberInfo[] list = dac.SelectAll(); // 데이터 조회
    foreach (MemberInfo member in list)
    {
        Console.WriteLine(member.Email);
    }

    // 중복 제거 (최신 Birth 기준으로 하나만 남김)
    dac.DeleteDuplicatesKeepLatest();
    foreach (MemberInfo member in list)
    {
        Console.WriteLine(member.Email);
    }
    // dac.Delete(item); // 데이터 삭제
}

public class MemberInfo
{
    public string Name;
    public DateTime Birth;
    public string Email;
    public byte Family;
}

public class MemberInfoDAC
{
    SqlConnection _sqlCon;

    public MemberInfoDAC(SqlConnection sqlCon)
    {
        _sqlCon = sqlCon;
    }

    void FillParameters(SqlCommand cmd, MemberInfo item)
    {
        SqlParameter paramName = new("@Name", System.Data.SqlDbType.NVarChar, 20);
        paramName.Value = item.Name;

        SqlParameter paramBirth = new("@Birth", System.Data.SqlDbType.Date);
        paramBirth.Value = item.Birth;

        SqlParameter paramEmail = new("@Email", System.Data.SqlDbType.NVarChar, 100);
        paramEmail.Value = item.Email;

        SqlParameter paramFamily = new("@Family", System.Data.SqlDbType.TinyInt);
        paramFamily.Value = item.Family;

        cmd.Parameters.Add(paramName);
        cmd.Parameters.Add(paramBirth);
        cmd.Parameters.Add(paramEmail);
        cmd.Parameters.Add(paramFamily);
    }

    public void Insert(MemberInfo item)
    {
        string query = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES(@Name, @Birth, @Email, @Family)";
        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Update(MemberInfo item)
    {
        string query = "UPDATE MemberInfo SET Name=@Name, Birth=@Birth, Family=@Family WHERE Email=@Email";
        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Delete(MemberInfo item)
    {
        string query = "DELETE FROM MemberInfo WHERE Email=@Email";
        SqlCommand cmd = new(query, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public MemberInfo[] SelectAll()
    {
        string query = "SELECT * FROM MemberInfo";
        ArrayList list = new();

        SqlCommand cmd = new(query, _sqlCon);

        using (SqlDataReader reader = cmd.ExecuteReader())
        {
            while (reader.Read())
            {
                MemberInfo item = new MemberInfo();
                item.Name = reader.GetString(0);
                item.Birth = reader.GetDateTime(1);
                item.Email = reader.GetString(2);
                item.Family = reader.GetByte(3);

                list.Add(item);
            }
        }
        return list.ToArray(typeof(MemberInfo)) as MemberInfo[];
    }

    // ✅ 중복 제거 (최신 Birth 기준으로 하나만 남김)
    public void DeleteDuplicatesKeepLatest()
    {
        string query = @"
            WITH CTE AS (
                SELECT *,
                    ROW_NUMBER() OVER (
                        PARTITION BY Name, Email, Family
                        ORDER BY Birth DESC
                    ) AS rn
                FROM MemberInfo
            )
            DELETE FROM CTE WHERE rn > 1;
        ";

        using SqlCommand cmd = new(query, _sqlCon);
        cmd.ExecuteNonQuery();
    }
}

CTE는 임시테이블.

 

 

위 구조를 기억하자. 실무프로젝트에서는 데이터베이스를 연동할때 이런 방식을 사용한다.

응용프로그램에서 직접 SqlCommand를 이용해 데이터베이스 조작을 하지 않고 테이블 단위로 crud 작업을 담당하는 DAC 클래스를 만들어 그것을 이용해 간접적으로 연동한다. 

즉 응용 프로그램과  DB 사이에 '층(Layer)'을 두는 것이다.

 

실제 MemberInfoDAC 클래스는 EXE 어셈블리를 생성하는 프로젝트로부터 분리해서 별도의 라이브러리 DLL에 담는 것이 일반적이다.

배포 조차도 다른 컴퓨터로 분리(DAC 구성요소를 노출하는 Web API를 제공하는 방식으로 계층을 나눔)하기도 한다. 이런식으로 프로그램의 로직을 논리적/물리적으로 분리하는 것을 계층(tier)을 나눈다고 표현한다.

 

층을 나눴을 때의 가장 큰 장점은 변화에 대한 수용력이 높다는 것이다. 예를들어, 데이터베이스 관리자가 나중에 MemberInfo 테이블의 이름을 Members로 바꿨다고 가정하면, 응용 프로그램에서 SqlCommand를 직접 사용하는 방식으로 코드를 만들었을 때 테이블명을 바꾸기 위해 전체 프로그램을 검토해야한다. 반면 DAC 계층을 나눠서 코드를 작성했다면 단순히 MemberInfoDAC 클래스의 쿼리 중에서 테이블 이름만 수정하면 된다. 컴파일된 결과물을 업데이트하기도 쉽다.

 

SqlCommand가 사용된 모든 exe/dll 을 교체하는 것보다 MemberInfoDAC 클래스가 포함된 dll만 교체하는 편이 낫다.

 

나눠진 층 수에 따라 2-tier, 3-tier로 이름이 명명된다. 프로젝트 규모를 산정하여 적절한 층 수로 나눠야한다.

 

6.8.3.2 System.Data.DataSet

DataSet은 ms에서 닷넷에 포함시킨 범용 데이터 컨테이너다. 

 

db에 테이블을 정의하면서 칼럼을 지정한 것을 닷넷에서 System.Data.DataColumn 타입을 이용해 표현 가능하다.

 

예제 6.48 개별 칼럼 정보를 구성

using System.Data;

DataColumn nameCol = new("Name", typeof(string));
DataColumn birthCol = new("Birth", typeof(DateTime));
DataColumn emailCol = new("Email", typeof(string));
DataColumn familyCol = new("Family", typeof(byte));

 

칼럼이 준비되면 데이터베이스의 테이블도 정의가 가능하다. 칼럼이모인 것이 테이블이므로 System.Data.DataTable을 이용해 데이터베이스의 MemberInfo 테이블과 동일한 상황을 재현 할 수 있다. 

 

예제 6.49 칼럼 정보를 포함한 DataTable 정의

using System.Data;

DataColumn nameCol = new("Name", typeof(string));
DataColumn birthCol = new("Birth", typeof(DateTime));
DataColumn emailCol = new("Email", typeof(string));
DataColumn familyCol = new("Family", typeof(byte));

DataTable table = new("MemberInfo");
table.Columns.Add(nameCol);
table.Columns.Add(birthCol);
table.Columns.Add(emailCol);
table.Columns.Add(familyCol);

 

DataTable 객체가 SQL 문을 지원하지는 않지만 CRUD를 다음과 같이 구성했다.

using System.Data;

DataColumn nameCol = new("Name", typeof(string));
DataColumn birthCol = new("Birth", typeof(DateTime));
DataColumn emailCol = new("Email", typeof(string));
DataColumn familyCol = new("Family", typeof(byte));

DataTable table = new("MemberInfo");
table.Columns.Add(nameCol);
table.Columns.Add(birthCol);
table.Columns.Add(emailCol);
table.Columns.Add(familyCol);


//INSERT: 4개 레코드 생성
table.Rows.Add("Anderson", new DateTime(1950, 5, 20), "anderson@naver.com", 2);
table.Rows.Add("Jason", new DateTime(1950, 5, 20), "jason@naver.com", 5);
table.Rows.Add("Mark", new DateTime(1950, 5, 20), "mark@naver.com", 3);
table.Rows.Add("Juicy", new DateTime(1950, 5, 20), "juicy@naver.com", 4);

//SELECT: 가족 구성원이 1명 이상인 레코드 선택
DataRow[] members = table.Select("Family >= 1");
foreach(DataRow row in members)
{
    Console.WriteLine($"{row["Name"]}, {row["Birth"]}, {row["Email"]}, {row["Family"]}");
}

//UPDATE: 4번째 레코드의 Name 컬럼의 값을 Juicy -> Jenny로 변경
table.Rows[3]["Name"] = "Jenny";

//DELETE: 4번째 레코드 삭제
table.Rows.Remove(table.Rows[3]);

 

마지막으로, 테이블도 정의했다면 해당 테이블이 모인 데이터베이스도 나타낼 수 있는데 이것이 바로 System.Data.DataSet이다.

DataSet은 DataTable의 묶음을 보관하는 컨테이너 역할을 한다.

 

using System.Data;

DataColumn nameCol = new("Name", typeof(string));
DataColumn birthCol = new("Birth", typeof(DateTime));
DataColumn emailCol = new("Email", typeof(string));
DataColumn familyCol = new("Family", typeof(byte));

DataTable table = new("MemberInfo");
table.Columns.Add(nameCol);
table.Columns.Add(birthCol);
table.Columns.Add(emailCol);
table.Columns.Add(familyCol);


//INSERT: 4개 레코드 생성
table.Rows.Add("Anderson", new DateTime(1950, 5, 20), "anderson@naver.com", 2);
table.Rows.Add("Jason", new DateTime(1950, 5, 20), "jason@naver.com", 5);
table.Rows.Add("Mark", new DateTime(1950, 5, 20), "mark@naver.com", 3);
table.Rows.Add("Juicy", new DateTime(1950, 5, 20), "juicy@naver.com", 4);

//SELECT: 가족 구성원이 1명 이상인 레코드 선택
DataRow[] members = table.Select("Family >= 1");
foreach(DataRow row in members)
{
    Console.WriteLine($"{row["Name"]}, {row["Birth"]}, {row["Email"]}, {row["Family"]}");
}

//UPDATE: 4번째 레코드의 Name 컬럼의 값을 Juicy -> Jenny로 변경
table.Rows[3]["Name"] = "Jenny";

//DELETE: 4번째 레코드 삭제
table.Rows.Remove(table.Rows[3]);

DataSet ds = new();
ds.Tables.Add(table);

 

SqlDataAdapter 코드를 사용했던 예제 6.45에서 구한 ds 변수의 내용을 열람해보자.

 

예제 6.50 DataSet과 연동되는 DataAdapter

using Microsoft.Data.SqlClient;
using System.Data;

string connectionString = @"Data Source=.\SQLEXPRESS; Initial Catalog=TestDB;User ID=sa;Password=pw1234;Encrypt=False";

DataSet ds = new();

using(SqlConnection sqlCon = new(connectionString))
{
    SqlDataAdapter sda = new("SELECT * FROM MemberInfo", sqlCon);
    sda.Fill(ds, "MemberInfo"); //DataSet에 SELECT 결과를 담는다.
}

// DataSet에 포함된 테이블 중에서 "MemberInfo"를 찾고
DataTable dt = ds.Tables["MemberInfo"];

//Select로 반환된 데이터 레코드를 열람.
foreach(DataRow row in dt.Rows)
{
    Console.WriteLine($"{row["Name"]}, {row["Birth"]}, {row["Email"]}, {row["Family"]}");
}

 

DataSet의 융통성 있는구조는 기존에 데이터컨테이너로써 POCO를 사용하던 것을 대체 할 수 있다.

 

그림 6.39 구조에서 DataSet 도입시 다음과 같이 간단히 바뀐다.

 그림 6.40 DataSet으로 통합된 데이터 컨테이너

 

DataSet을 데이터 컨테이너로 사용하면 테이블마다 일일이 정의해야 했던 POCO 클래스를 생략할 수 있다는 장점이 있다.

 

예제 6.46의 MemberInfoDAC 을 DataSet 타입으로 다시 작성한 코드다.

 

예제6.51 DataSet기반의 MemberInfoDAC 정의 

using Microsoft.Data.SqlClient;
using System.Data;

public class MemberInfoDAC
{
    SqlConnection _sqlCon;
    DataTable _table;

    public MemberInfoDAC(SqlConnection sqlCon)
    {
        _sqlCon = sqlCon;

        DataColumn nameCol = new("Name", typeof(string));
        DataColumn birthCol = new("Birth", typeof(DateTime));
        DataColumn emailCol = new("Email", typeof(string));
        DataColumn familyCol = new("Family", typeof(byte));

        _table = new DataTable("MemberInfo");
        _table.Columns.Add(nameCol);
        _table.Columns.Add(birthCol);
        _table.Columns.Add(emailCol);
        _table.Columns.Add(familyCol);
    }

    public DataRow NewRow()
    {
        return _table.NewRow();
    }

    void FillParameters(SqlCommand cmd, DataRow item)
    {
        SqlParameter paramName = new SqlParameter("@Name", SqlDbType.NVarChar, 20);
        paramName.Value = item["Name"];


        SqlParameter paramBirth = new SqlParameter("@Birth", SqlDbType.Date);
        paramBirth.Value = item["Birth"];


        SqlParameter paramEmail = new SqlParameter("@Email", SqlDbType.NVarChar, 100);
        paramEmail.Value = item["Email"];


        SqlParameter paramFamily = new SqlParameter("@Family", SqlDbType.TinyInt);
        paramFamily.Value = item["Family"];

        cmd.Parameters.Add(paramName);
        cmd.Parameters.Add(paramBirth);
        cmd.Parameters.Add(paramEmail);
        cmd.Parameters.Add(paramFamily);
    }

    public void Insert(DataRow item)
    {
        string txt = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES(@Name, @Birth, @Email, @Family)";
        SqlCommand cmd = new(txt, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Update(DataRow item)
    {
        string txt = "Update MemberInfo SET Name=@Name, Birth=@Birth, Family=@Family WHERE Email=@Email";
        SqlCommand cmd = new(txt, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Delete(DataRow item)
    {
        string txt = "DELETE FROM MemberInfo WHERE Email=@Email";

        SqlCommand cmd = new(txt, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public DataSet SelectAll(DataRow item)
    {
        DataSet ds = new();
        SqlDataAdapter sda = new("SELECT * FROM MemberInfo", _sqlCon);
        sda.Fill(ds, "MemberInfo");

        return ds;
    }
}

 

예제 6.47은 다음과 같이 바뀐다.

예제 6.52 DataSet을 사용한 데이터베이스 연동

using Microsoft.Data.SqlClient;
using System.Data;

string connectionString = "...[생략: 연결 문자열]...";
using(SqlConnection sqlCon = new SqlConnection(connectionString))
{
    sqlCon.Open();
    MemberInfoDAC dac = new(sqlCon);

    DataRow newItem = dac.NewRow();
    newItem["Name"] = "Chris";
    newItem["Birth"] = new DateTime(1985, 12, 5);
    newItem["Email"] = "chris@naver.com";
    newItem["Family"] = 2;

    dac.Insert(newItem);

    newItem["Name"] = "John";
    dac.Update(newItem);

    DataSet ds = dac.SelectAll();
    foreach(DataRow member in ds.Tables["MemberInfo"].Rows)
    {
        Console.WriteLine(member["Email"]);
    }
    dac.Delete(newItem);
}

public class MemberInfoDAC
{
    SqlConnection _sqlCon;
    DataTable _table;

    public MemberInfoDAC(SqlConnection sqlCon)
    {
        _sqlCon = sqlCon;

        DataColumn nameCol = new("Name", typeof(string));
        DataColumn birthCol = new("Birth", typeof(DateTime));
        DataColumn emailCol = new("Email", typeof(string));
        DataColumn familyCol = new("Family", typeof(byte));

        _table = new DataTable("MemberInfo");
        _table.Columns.Add(nameCol);
        _table.Columns.Add(birthCol);
        _table.Columns.Add(emailCol);
        _table.Columns.Add(familyCol);
    }

    public DataRow NewRow()
    {
        return _table.NewRow();
    }

    void FillParameters(SqlCommand cmd, DataRow item)
    {
        SqlParameter paramName = new SqlParameter("@Name", SqlDbType.NVarChar, 20);
        paramName.Value = item["Name"];


        SqlParameter paramBirth = new SqlParameter("@Birth", SqlDbType.Date);
        paramBirth.Value = item["Birth"];


        SqlParameter paramEmail = new SqlParameter("@Email", SqlDbType.NVarChar, 100);
        paramEmail.Value = item["Email"];


        SqlParameter paramFamily = new SqlParameter("@Family", SqlDbType.TinyInt);
        paramFamily.Value = item["Family"];

        cmd.Parameters.Add(paramName);
        cmd.Parameters.Add(paramBirth);
        cmd.Parameters.Add(paramEmail);
        cmd.Parameters.Add(paramFamily);
    }

    public void Insert(DataRow item)
    {
        string txt = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES(@Name, @Birth, @Email, @Family)";
        SqlCommand cmd = new(txt, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Update(DataRow item)
    {
        string txt = "Update MemberInfo SET Name=@Name, Birth=@Birth, Family=@Family WHERE Email=@Email";
        SqlCommand cmd = new(txt, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public void Delete(DataRow item)
    {
        string txt = "DELETE FROM MemberInfo WHERE Email=@Email";

        SqlCommand cmd = new(txt, _sqlCon);
        FillParameters(cmd, item);
        cmd.ExecuteNonQuery();
    }

    public DataSet SelectAll()
    {
        DataSet ds = new();
        SqlDataAdapter sda = new("SELECT * FROM MemberInfo", _sqlCon);
        sda.Fill(ds, "MemberInfo");

        return ds;
    }
}

 

POCO를 미정의해도  DAC계층과 데이터를 주고받는데 문제가 없다.

물론 DataSet에는 다음과 같은 단점이 있다.

 

1. 메모리 증가

POCO로 정의된 클래스는 정확히 그 데이터를 표현하기 위한 용량만큼의 메모리만을 사용하지만 범용적인 목적의 DataSet은 예제 6.51에서 본 것처럼 DataColumn, DataRow, DataTable타입이 생성되고, 그에 따라 내부적으로 유지되는 메모리 용량이 증가한다. 그 밖에 직렬화할 때도 xml형식으로 데이터를 표현함에 따라 메모리 부하는 더 증가한다.

 

2. 형식 안전성 미지원

DataRow에서 값을 보관하는 단위는 object이다. 예를 들어, byte 타입인 Family 칼럼도 DataRow 객체에 보관될때는 object로 변환되어 박싱/언박싱 문제가 발생한다. 또한 DataRow에 지정하는 칼럼명에 오타 발생 시 컴파일할 때 그 사실을 알 수 없다. 마찬가지로 DataRow에 어떤 칼럼이 있는지 알기 힘들다.

 

반면, POCO의 경우 해당 클래스 정의를 찾는 것도 직관적이고 비주얼스튜디오 같은 도구에서는 인텔리센스 도움으로 해당 객체에 포함된 멤버에 점(dot)을 찍으면 나열되는 기능이 제공되므로 코드 작성도 편하고 오타 리스크도 거의 없다.

 

 [정리]

데이터 컨테이너는 db에 정의된 '관계형 테이블 구조'를 프로그래밍 언어에 정의된 타입에 대응시킨 것을 의미한다.

이를 가리켜 'ORM(Object-Relational-Mapping)' 흔히 OR 매핑이라 하는데 OR매핑과 관련된 기술 개선은 지금도 계속되고 있다.

엔터티 프레임워크, NHibernate 프레임워크등은 모두 OR매핑이 가능한 라이브러리다.

 

6.8.4 데이터베이스 트랜잭션

트랜잭션: 다수의 쿼리 실행이 모두 실패하거나 모두 성공하는 논리적 단위.

 

예제 A->B 1000원 송금 절차

1. 은행은 A의 계좌에 남은 금액에서 1000원 차감

UPDATE[계좌테이블] SET [잔액] = [잔액] - 1000 WHERE [계좌소유자] = 'A'

2. 은행은 B 계좌에 1000원 증가시킴.

UPDATE[계좌테이블] SET [잔액] = [잔액] + 1000 WHERE [계좌소유자] = 'B'

 

이 과정에서 1번은 성공하고, 2번은 네트워크 회선이 끊겨 실패하면? A계좌의 금액만 차감시킨 DB 상태를 갖게 되는데 이를 방지하고자 응용 프로그램 개발자는 쿼리 실행에 따른 결과를 체크해서 다시 1번 과정을 취소하는 코드를 준비해 둬야 한다.

 

문제는 상황에 따라 모두 성공/실패시켜야 할 쿼리가 많아질 수 있다는 점과 이런 식의 조치는 개발자의 실수가 개입될 여지가 있다는 것이다.

이러한 문제는 트랜잭션의 도입으로 한번에 해결되며, 트랜잭션 사용 시 다음과 같은 4가지 특성이 보장 된다.

 

ACID

1. Atomicity(원자성) : 트랜잭션과 관련된 작업이 모두 수행되거나 수행되지 않음을 보장.

2. Consistency(일관성) : 트랜잭션이 실행을 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 의미.

3.  Isolation(고립성): 트랜잭션을 수행할 때 다른 트랜잭션의 연산 작업이 끼어들지 못하게 보장. 이는 트랜잭션 밖에 있는 어떤 연산도 중간 단계의 데이터를 볼 수 없음을 의미. 

4. Durability(지속성): 성공적으로 수행된 트랜잭션은 영원히 반영돼야 함. 또한 모든 트랜잭션은 로그가 남아 시스템 장애가 발생하기 전의 상태로 되돌릴 수 있다. 예를 들어, 데이터베이스의 내용을 실수로 삭제했다면 트랜잭션 로그를 통해 다시 복원할 수 있다.

 

상기 4가지 특성 중 원자성이 응용프로그램 개발자에게 의미가 있다.

트랜잭션의 처음과 끝을 소스코드에서 SqlTransaction 객체를 이용해 지정 가능하다.

using Microsoft.Data.SqlClient;
using System;
using System.Data;

string connectionString = @"Data Source=.\SQLEXPRESS; Initial Catalog=TestDB;User ID=sa;Password=pwd1234; Encrypt=False";

using (SqlConnection sqlCon = new SqlConnection(connectionString))
{
    sqlCon.Open();

    using (SqlTransaction transaction = sqlCon.BeginTransaction())
    {
        try
        {
            // INSERT
            string insertSql = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES(@Name, @Birth, @Email, @Family)";
            using (SqlCommand insertCmd = new SqlCommand(insertSql, sqlCon, transaction))
            {
                insertCmd.Parameters.AddWithValue("@Name", "John");
                insertCmd.Parameters.AddWithValue("@Birth", new DateTime(1990, 1, 1));
                insertCmd.Parameters.AddWithValue("@Email", "john@example.com");
                insertCmd.Parameters.AddWithValue("@Family", 3);
                insertCmd.ExecuteNonQuery();
            }

            // UPDATE
            string updateSql = "UPDATE MemberInfo SET Name = @NewName WHERE Email = @Email";
            using (SqlCommand updateCmd = new SqlCommand(updateSql, sqlCon, transaction))
            {
                updateCmd.Parameters.AddWithValue("@NewName", "Johnny");
                updateCmd.Parameters.AddWithValue("@Email", "john@example.com");
                updateCmd.ExecuteNonQuery();
            }

            // DELETE
            string deleteSql = "DELETE FROM MemberInfo WHERE Email = @Email";
            using (SqlCommand deleteCmd = new SqlCommand(deleteSql, sqlCon, transaction))
            {
                deleteCmd.Parameters.AddWithValue("@Email", "john@example.com");
                deleteCmd.ExecuteNonQuery();
            }

            // 모든 작업이 성공했으면 커밋
            transaction.Commit();
            Console.WriteLine("모든 작업이 성공적으로 커밋되었습니다.");
        }
        catch (Exception ex)
        {
            transaction.Rollback();
            Console.WriteLine($"오류 발생: {ex.Message}\n모든 변경사항이 롤백되었습니다.");
        }
    }
}

 

Connection 개체를 Open하고, BeginTransaction 메서드를 호출하는 순간부터 트랜잭션은 시작된다. 이후 수행되는 SqlCommand는 트랜잭션에 소속되고 Commit 메서드를 호출해야만 데이터베이스에 모두 반영된다.

 

만약 Commit 메서드를 호출하지 않거나 SqlTransaction.Abort 메서드를 호출하면 해당 트랜잭션은 실패한 것이 되고 그 사이에 수행된 SqlCommand로 인한 데이터베이스 변경은 모두 원복(rollback) 된다.

 

예제 6.53 SqlCommand에 트랜잭션 적용

using Microsoft.Data.SqlClient;
using System.Data;

string connectionString = @"Data Source=.\SQLEXPRESS; Initial Catalog=TestDB;User ID=sa;Password=pwd1234; Encrypt=False";

using (SqlConnection sqlCon = new SqlConnection(connectionString))
{
    sqlCon.Open();

    using (SqlTransaction transaction = sqlCon.BeginTransaction())
    {
        string txt = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES('{0}', '{1}', '{2}', {3})";
        SqlCommand cmd = new();
        cmd.Connection = sqlCon;
        cmd.Transaction = transaction;
        cmd.CommandText = string.Format(txt, "Fox", "1970-01-25", "fox@naver.com", "5");
        cmd.ExecuteNonQuery();

        cmd.CommandText = string.Format(txt, "Dana", "1972-01-25", "fox@naver.com", "1");
        cmd.ExecuteNonQuery(); // PK 중복(이메일) 에 따른 예외 발생

        transaction.Commit();
    }
}

SqlCommand.Transaction 속성에 transaction 변수가 대입됬는데 반드시 이래야만 트랜잭션에 참여하게 된다. 또는

using (SqlCommand insertCmd = new SqlCommand(insertSql, sqlCon, transaction))




new SqlCommand(sql, sqlCon, transaction)는
곧
SqlCommand cmd = new SqlCommand(sql, sqlCon);
cmd.Transaction = transaction; // 명시적 지정
 코드 와 같다.

 코드와 같이 transaction을 인자로 넘겨주어야 트랜잭션에 참여한다.

 

fox@naver.com 값이 중복됨으로써 두번째 ExcuteNonQuery를 실행 시 예외가 발생한다. 따라서 Commit 메서드가 호출되지 않게 되고 전체 트랜잭션은 실패한다.  실행 후, SSMS 도구를 이용해 MemberInfo 테이블의 내용을 보면 "fox@naver.com" 값을 담은 레코드가 하나도 없는 것을 확인 할 수 있다. 

만약 예제 6.53에서 트랜잭션 관련 코드가 없었다면 첫번쨰 ExecuteNonQuery의 수행결과는 데이터베이스에 반영됐을 것이다.

하지만 트랜잭션의 참여로  인해 Commit 메서드가 호출되지 않았기에 모든 쿼리의 수행이 취소된다.

 

SqlTransaction을사용할 때 일일이 SqlCommand 마다 트랜잭션 변수 대입은 번거로움이 있다. 이러한 불편함을 해소하고자 자 닷넷 2.0에서 System.Transactions.TransactionScope 타입이 추가됐다.

 

using Microsoft.Data.SqlClient;
using System.Data;
using System.Transactions;

string connectionString = @"Data Source=.\SQLEXPRESS; Initial Catalog=TestDB;User ID=sa;Password=pwd1234; Encrypt=False";

using (SqlConnection sqlCon = new SqlConnection(connectionString))
{
    using (TransactionScope tx = new())
    {
        sqlCon.Open();

        string txt = "INSERT INTO MemberInfo(Name, Birth, Email, Family) VALUES('{0}', '{1}', '{2}', {3})";
        SqlCommand cmd = new();
        cmd.Connection = sqlCon;
        cmd.CommandText = string.Format(txt, "Fox", "1970-01-25", "fox@naver.com", "5");
        cmd.ExecuteNonQuery();

        cmd.CommandText = string.Format(txt, "Dana", "1972-01-25", "fox@naver.com", "1");
        cmd.ExecuteNonQuery(); // PK 중복(이메일) 에 따른 예외 발생

        tx.Complete();
    }
}

 

TransactionScope와 SqlTransaction은 트랜잭션 시작 지점에도 차이가 있다.

SqlTransaction의 경우 연결 개체가 Open한 이후에만 BeginTransaction 메서드를 호출해 트랜잭션을 시작할 수 있었지만, TransactionScope는 반대로 연결개체가 Open하기 이전에 미리 생성돼 있어야 한다.

 

직관성이나 기타 부가적인 기능상의 이유로 TransactionScope를 더 선호하는 추세다.

+ Recent posts