C# 6.0 완벽 가이드/ 네트워킹

  • .NET Framework System.Net.* 이름공간들에는 HTTP나 TCP/IP, FTP 같은 표준 네트워크 프로토콜을 이용해서 통신을 수행하는데 사용할 수 있는 다양한 클래스가 있다. 핵심 구성요소들을 요약하면 다음과 같다.
    • HTTP나 FTP를 통한 간단한 다운로드/업로드 연산을 위한 퍼사드 클래스 WebClient
    • 클라이언트 쪽 HTTP나 FTP 연산들을 저수준에서 제어할 수 있는 WebRequest 클래스와 WebResponse 클래스
    • HTTP 웹 API와 RESTful 서비스의 소비를 위한 HttpClient 클래스
    • HTTP 서버 작성을 위한 HttpListener 클래스
    • SMTP를 통한 메일 메시지 작성 및 전송을 위한 SmtpClient 클래스
    • 도메인 이름과 주소의 변환을 위한 Dns 클래스
    • 전송 계층과 네트워크 계층에 직접 접근하는데 쓰이는 TcpClient, UdpClient, TcpListener Socket 클래스
  • Windows 스토어 앱은 이 형식 중 일부에만, 구체적으로 말하면 WebRequest와 WebResponse, HttpClient 에만 접근할 수 있다. 그러나 Windows 스토어 앱은 Windows.Networking.Sockets에 있는 TCP 및 UDP 통신용 WinRT 형식들도 사용할 수 있다.

네트워크 구조

  • 아래 그림은 .NET 프레임워크의 네트워킹 형식들을 해당 통신 계층별로 배치한 그림이다. 대부분의 형식은 전송 계층(transport layer)과 응용 계층(application layer)에 있다.
    • 전송 계층은 바이트 송, 수신을 위한 기본 프로토콜들(TCP와 UDP)을 정의한다.
    • 응용 계층은 구체적인 응용을 위한 고수준 프로토콜들을 정의한다. 이를테면 웹 페이지 조회(HTTP), 파일 전송(FTP), 메일 전송(SMTP), 도메인 이름과 IP 주소 사이의 변환(DNS) 등이 이 계층에 속한다.

  • 프로그래밍을 하기에는 응용 계층이 가장 편하다. 그러나 전송 계층을 직접 다루어야 하는 때도 있다.
    • 크게 두 가지인데 하나는 .NET Framework가 제공하지 않는 기존 프로토콜을 응용 프로그램이 사용해야 하는 경우이다. 예컨대 POP3으로 메일을 조회하는 응용 프로그램을 만들려면 전송 계층에서 작업해야 한다.
    • 또 하나는 P2P(peer-to-peer) 클라이언트 같은 특별한 응용 프로그램을 위해 커스텀 프로토콜을 고안해야 하는 경우이다.
  • 응용 계층의 프로토콜 중 하나인 HTTP는 흔히 생각하는 웹 프라우징 이외의 다양한 용도로 활용할 수 있다는 점에서 특별하다.
    • HTTP의 기본 용법인 “이 URL에 해당하는 웹 페이지를 가져온다(get)”를 “이 인수들로 이 끝점(endpoint)을 호출한 결과를 가져온다”라는 용법으로 바꾸는 것은 간단한 일이다.
    • HTTP에는 get 외에 put과 post, delete라는 동사들이 있다. REST 기반 서비스의 작동 방식은 이 네 동사에 기초한다.
  • HTTP는 다층(multitier) 업무 응용 프로그램과 서비스 지향적 구조(SOA)에 유용한 여러 기능을 갖추고 있다.
    • 이를테면 인증과 암호화를 위한 프로토콜들, 메시지 분할 전송, 확장 가능한 헤더와 쿠기, 그리고 여러 서버 응용 프로그램이 하나의 포트와 IP 주소를 공유하는 능력 등이 그러한 기능의 예이다.
    • HTTP는 이처럼 유용하고 중요한 프로토콜이라서, .NET Framework도 HTTP를 잘 지원한다.
    • 이번 장에서 설명하는 형식들을 통해서 직접 지원할 뿐만 아니라, WCG나 Web Services, ASP.NET 같은 기술들을 통해서 고수준에서 간접적으로도 지원한다.
  • .NET Framework는 파일 송수신에 널리 쓰이는 인터넷 프로토콜인 FTP의 클라이언트 쪽 기능을 지원한다.
    • 서버 쪽 기능은 IIS나 Unix 기반 서버 소프트웨어의 형태로 지원된다.
두문자어 원문 참고
DNS Domain Name Service 도메인 이름(이를테면 ebay.com)과 IP 주소(이를테면 199.54.213.2)
FTP File Transfer Protocol 파일 송수신을 위한 인터넥 기반 프로토콜
HTTP Hypertext Transfer Protocol 웹페이지 조회 및 웹 서비스 운영
IIS Inertnet Information Services Microsoft의 웹 서버 서비스
IP Internet Protocol TCP와 UDP 아래에 있는 네트워크 계층 프로토콜
LAN Local Area Network 대부분의 LAN은 TCP/IP 같은 인터넷 기반 프로토콜들을 사용한다.
POP Post Office Protocol 인터넷 메일 조회
REST REpresentational State Protocol MS의 Web Services 대신 널리 쓰이는 구조로, 응답에 컴퓨터가 따라갈 수 있는 링크들을 활용한다는 점과 기본 HTTP 상에서 운용할 수 있다는 점이 특징이다.
SMTP Simple Mail Transfer Protocol 인터넷 메일 전송
TCP Transmission and Control Protocol 대부분의 상위 계층 서비스들이 기반으로 사용하는 전송 계층 인터넷 프로토콜
UDP Universal Datagram Protocol VoIP 같은 저부하(low-overhead) 서비스들에 쓰이는 전송 계층 인터넷 프로토콜
UNC Universal Naming Convention \\컴퓨터\공유이름\파일이름
URI Uniform Resource Identifier 보편적인 자원 명명 체계(http://www.amazon.com이나 mailto:joe@bloggs.org)
URL Uniform Resource Locator 흔히 URI와 같은 뜻으로 쓰인다. 엄밀히 말하면 URI한 부분집합이지만, 그런 의미로 쓰이는 경우는 점점 주록 있다.

 

주소와 포트

  • 통신이 작동하려면 컴퓨터 또는 장치에 주소가 있어야 한다. 인터넷에 쓰이는 주소 체계는 다음 2가지 이다.
    • IPv4
      • 현재 지배적으로 쓰이는 주소 체계이다. IPv4 주소는 32비트이다. IPv4 주소를 문자열로 표현할 떄는 십진수 4개를 마침표로 구분한 형태가 흔히 쓰인다. (101.102.103.104). 하나의 IPv4 주소는 전 지구적으로 고유하거나, 특정 서브넷(subnet, 부분망. 이를테면 회사 내부망) 안에서 고유하다.
    • IPv6
      • IPv4보다 새로운 128비트 주소 체계이다. IPv6 주소의 문자열 표현은 16비트 십육진수 여덟 개를 콜론으로 연결한 형태로 .NET Framework에서는 주소 전체를 대괄호 쌍으로 감싸야 한다([3EA0:FFFF:198A:E4A3:4FF2:54FA:41BC:8D31]
  • System.Net 이름공간의 IPAddress 클래스는 IPv4 또는 IPv6 주소를 대표한다. 이 클래스에는 바이트 배열을 받는 생성자와 적절한 형태의 문자열을 받는 정적 Parse 메서드가 있다.
IPAddress a1 = new IPAddress (new byte[] { 101, 102, 103, 104 });
IPAddress a2 = IPAddress.Parse("101.102.103.104");
Console.WriteLine(a1.Equals(a2));  // True
Console.WriteLine(a1.AddressFamily);  // InterNetwork

IPAddress a3 = IPAddress.Parse("[3EA0:FFFF:198A:E4A3:4FF2:54FA:41BC:8D31]");
Console.WriteLine(a3.AddressFamily);  // InterNetworkV6
  • TCP 프로토콜과 UDP 프로토콜은 하나의 IP 주소에 대해 65,535개의 포트(port)를 할당한다. 이 덕분에 한 주소의 한 컴퓨터에서 여러 응용 프로그램을 돌릴 수 있다.(각 응용 프로그램에 하나의 포트를 사용해서)
    • 여러 응용 프로토콜에는 표준 포트 번호가 배정되어 있는데, 예컨대 HTTP는 기본적으로 포트 80을, SMTP는 포트 25를 사용한다.
    • 49152에서 65535까지의 TCP, UDP 포트들은 그 어떤 표준 용도로도 배정되어 있지 않으므로 시험용이나 소규모 배치 상황에 적합하다.
  • IP 주소와 포트 번호의 조합은 하나의 공유한 종점(endpoint)를 나타낸다. 이를 대표하는 .NET 프레임워크의 클래스는 IPEndPoint이다.
    • 방화벽들은 포트들을 차단한다. 특히 기업환경에서는 포트 80(암호화되지 않은 HTTP용)이나 포트 443(보안 HTTP용) 같은 특정 포트 몇 개만 열어 두는 경우가 많다.
IPAddress a = IPAddress.Parse("101.102.103.104");
IPEndPoint ep = new IPEndPoint(a, 222);  // 222번 포트
Console.WriteLine(ep.ToString());  // 101.102.103.104:222

URI

  • URI는 인터넷이나 LAN의 한 자원(웹 페이지나 파일, 이메일 주소 등)을 서술하는 특별한 서식을 따르는 문자열이다. 예컨대 http://www.ietf.org나 ftp://myisp/doc.txt, mailto:joe@bloggs.com이 URI이다. URI의 정확한 서식은 IETF(Inernet Engineering Task Force)가 정의한다.
  • 하나의 URI는 여러 개의 요소로 구성된다. 흔히 쓰이는 구성요소는 스킴(scheme), 출처(authority: 전거), 경로(path)이다.
    • System 이름공간의 Uri 클래스는 주어진 URI 문자열을 자동으로 분해해서 각 구성요소에 대한 속성을 설정한다. 아래 그림에 그러한 속성들이 나와 있다.

  • Uri 클래스는 URI 문자열이 서식에 맞는지 점검하거나 URI 문자열을 그 구성요소들로 분해해야 할 때 유용하다. 그 외의 경우에는 그냥 문자열 형태의 URI를 사용하면 된다. 대부분의 네트워킹 메서드들은 Uri 객체를 받도록 중복적재된 버전과 문자열을 받도록 중복적재된 버전을 함께 제공한다.
  • Uri 객체를 생성할 때 생성자에 지정할 수 있는 문자열은 다음 3 종류이다.
    • URI 문자열, 이를테면 htttp://www.ebay.com이나 file://janespc/sharedpics/dolphin.jpg
    • 하드 디스크에 있는 지역 파일의 절대 경로, 이를테면 c:\myfiles\data.xls
    • LAN에 있는 파일의 UNC 경로, 이를테면 \\janespc\sharedpics\dolphin.jpg
  • 파일 경로나 UNC 경로는 자동으로 URI 문자열로 변환된다. 제일 앞의 ‘file:’ 프로토콜(스팀)이 추가되고 역슬래시들이 모두 슬래시로 바뀐다.
    • Uri 생성자들은 인스턴스를 실제로 생성하기 전에 문자열의 기본적인 전처리 작업도 수행한다. 이를테면 스팀과 호스트 이름을 소문자로 변경하고, 기본 포트 번호와 빈 포트 번호를 제거한다. “www.test.com”처럼 스킴이 없는 URI 문자열을 지정하면 생성자는 UriFormatException 예외를 던진다.
  • Uri 클래스에는 해당 URI가 지역 호스트(IP 주소 127.0.0.1)를 가리키는 지를 뜻하는 IsLoopback 속성과 지역 파일인지를 뜻하는 IsFile 속성, UNC 경로인지를 뜻하는 IsUnc 속성이 있다.
    • 어떤 Uri 인스턴스의 IsFile 속성이 true인 경우, 그 인스턴스의 LocalPath 속성에는 AbsolutePath 속성의 절대 경로를 지역 운영체제에 맞는 방식으로 변환한 경로가 들어 있다. 그 경로로는 이를테면 File.Open을 호출할 수 있다.
  • Uri 인스턴스의 속성들은 읽기 전용이다. 기존 Rui 객체를 수정하려면 쓰기 가능 속성들을 제공하는 UriBuilder 객체를 생성해서 속성들을 변경해야 한다. UriBuilder 객체의 Uri 속성은 변경된 속성들을 가진 Uri 객체를 돌려준다.
  • Uri는 경로들을 비교하거나 구성요소를 추룰하는 메서드들도 제공한다.
Uri info = new Uri ("http://www.domain.com:80/info/");
Uri page = new Uri ("http://www.domain.com/info/page.html");

Console.WriteLine(info.Host);  // www.domain.com
Console.WriteLine(info.Port);  // 80
Console.WriteLine(page.Port);  // 80 (Uri는 기본 HTTP 포트 번호를 알고 있음)

Console.WriteLine(info.IsBaseOf(page));  // True
Uri relative = info.MakeRelativeUri(page);
Console.WriteLine(relative.IsAbsoluteUri);  // False
Console.WriteLine(relative.ToString());  // page.html
  • 이 예제에서 relative 변수는 page.html 이라는 상대적인 URI를 나타내는 Uri 인스턴스를 참조한다. 이런 상대적 Uri 인스턴스에 대해 IsAbsoluteUri와 ToString 이외의 속성이나 메서드를 호출하면 예외가 발생한다. 상대적 Uri 객체를 직접 생성하려면 다음과 같이 하면 된다.
Uri r = new Uri("page.html", UriKind.Relative);
  • URI에서 후행 슬래시(제일 끝에 있는 슬래시)는 중요한 의미가 있다. URI에 경로 구성요소가 있는 경우, 후행 슬래시 존재 여부에 따라 서버가 요청을 처리하는 방식이 달라진다.
    • 예컨대 클라이언트가 http://www.albahari.com/nutshell/라는 URI를 요청했다면 HTTP 웹 서버는 사이트의 웹 루트 폴더에 있는 nutshell 하위 디렉터리에서 기본 문서(보통은 index.html)를 돌려줄 것이다.
    • 그러나 후행 슬래시가 없는 URI를 요청했다면 웹 서버는 사이트의 웹 루트에 있는 nutshell이라는 파일(확장자가 없는)을 찾을 것이다. 아마도 클라이언트가 요청한 것은 그 파일이 아닐 가능성이 크다.
    • 그런 파일이 없으면 대부분의 웹 서버는 사용자가 오타를 냈다고 가정하고  301 Moved Permanently 오류와 함께 후행 슬래시가 붙은 URI를 클라이언트에게 제시해서 다시 시도하게 한다.
    • .NET의 HttpClient는 기본적으로 301 오류를 보통의 웹브라우저와 같은 방식으로 투명하게 처리한다. 즉, 웹 서버가 제시한 URI로 다시 시도한다.
    • 따라서 프로그램이나 사용자가 후행 슬래시를 포함시켜야 할 URI에서 후행 슬래시를 실수로 빼먹은 경우에도 요청은 여전히 만족된다. 다만 불필요한 왕복 통신이 1회 추가된다.
  • Uri 클래스는 또한 여러 정적 보조 메서드도 제공한다. 예를 들어 EscapeUriString 메서드는 주어진 문자열에서 ASCII 부호 값이 127보다 큰 모든 문자를 16진 표현으로 바꾸어서 유효한 URL을 만들어 준다.
    • CheckHostName 메서드와 CheckSchemeName 메서드는 주어진 문자열의 호스트 이름과 스킴 이름이 유효한 형태인지 점검해 준다(단, 해당 호스트나 URI가 실제로 존재하는지를 점검하지 않는다)

클라이언트 쪽 클래스들

  • WebRequest와 WebResponse는 HTTP와 FTP 그리고 ‘file:’ 프로토콜의 클라이언트쪽 활동을 위한 공통의 기반 클래스이다. 이들은 이 프로토콜들이 공통으로 따르는 ‘요청/응답’ 모형, 즉 클라이언트가 서버에 요청을 보내고 서버의 응답을 기다리는 통신 방식을 캡슐화한다.
  • WebClient는 WebRequest와 WebResponse의 메서드들을 적절히 호출해 주는 편리한 퍼사드 클래스이다. 이 클래스를 이용하면 코딩을 어느 정도 줄일 수 있다.
    • WebClient는 문자열 뿐만 아니라 바이트 배열이나 파일, 스트림으로도 사용할 수 있다. 그러나 WebRequest/WebResponse는 스트림만 지원한다.
    • 안타깝게도 WebClient만으로 모든 문제가 해결되지는 않는다. 이 클래스에는 쿠키 같은 일부 기능이 빠져 있기 때문이다.
  • HttpClient도 WebRequest와 WebResponse에 (좀 더 구체적으로는 HttpWebRequest와 HttpWebResponse에) 기초한 클래스로 .NET Framework 4.5에서 도입되었다.
    • WebClient는 대체로 요청/응답 클래스들에 기초한 얇은 계층으로 작동하지만, HttpClient는 HTTP 기반 웹 API나 REST 기반 서비스, 커스텀 인증 스킴을 좀 더 사용하기 쉽게 해주는 추가적인 기능들도 제공한다.
  • 간단하게 파일이나 문자열, 바이트 배열을 주고받는 용도라면 WebClient와 HttpClient 둘 다 적합하다. 둘 다 비동기 메서드들을 제공한다. 단 진행 정도 보고 기능은 WebClient만 제공한다.
    • WinRT 응용 프로그램은 WebClient를 전혀 사용할 수 없다. 대신 WebRequest/WebResponse나 HttpClient(HTTP의 경우)를 사용해야 한다.
  • 기본적으로 CLR은 HTTP 동시성을 제한한다. 만일 비동기 메서드나 다중 스레드를 이용해서 한 번에 셋 이상의 요청을 보내야 한다면(WebRequest를 통해서든 WebClient나 HttpClient를 통해서든) 먼저 정적 속성 ServicePointManager.DefaultonnectionLimit를 통해서 동시성 한계를 증가해야 한다.

WebClient 클래스

  • 다음은 WebClient를 사용하는 단계들이다.
    1. WebClient 객체를 인스턴스화 한다.
    2. Proxy 속성을 설정한다.
    3. 인증이 필요한 경우에는 Credentials 속성도 설정한다.
    4. 원하는 URL을 지정해서 DownloadXXX 메서드나 UploadXXX 메서드를 호출한다.
  • 서버의 자원을 내려받는 메서드들은 다음과 같다.
public void DownloadFile(string address, string fileName);
public string DownloadString (string address);
public byte[] DownloadData (string address);
public Stream OpenRead (string address);
  • 이들은 문자열 주소 대신 Uri 객체를 받는 종복적재 버전들도 제공한다.
  • 이와 비슷하게 클라이언트에서 서버로 자원을 올리는 메서드들도 있다. 이 메서드들의 반환값에는 서버의 응답이 들어 있다.
public byte[] UploadFile(string address, string fileName);
public byte[] UploadFile(string address, string method, string fileName);
public string UploadString (string address, string data);
public string UploadString (string address, string method, string data);
public byte[] UploadData (string address, byte[] data);
public byte[] UploadData (string address, string method, byte[] data);
public byte[] UploadValues(string address, NameValueCollection data);
public byte[] UploadValues(string address, string method, NameValueCollection data);
public Stream OpenWrite (string address);
public Stream OpenWrite (string address, string method);
  • UploadValues 메서드들은 method 인수를 ‘POST’로 해서 HTTP 양식(form)을 제출하는 용도로 사용할 수 있다.
  • WebClient는 또한 BaseAddress라는 속성도 제공한다. 이 속성에 설정한 접두사(이를테면 http://www.mysite.com/data/)는 자동으로 이후의 모든 주소 앞에 붙는다.
  • 다음은 이 책의 예제 코드 페이지를 현재 폴더의 한 파일로 내려받아서 기본 웹브라우저로 표시하는 예이다.
WebClient wc = new WebClient { Proxy = null };
wc.DownloadFile("http://www.albahari.com/nutshell/code.aspx", "code.htm");
System.Diagnostics.Process.Start("code.htm");
  • WebClient는 IDisaposable을 구현하는데, 이는 단시 Component를 상속하다 보니 자동으로 그렇게 된 것일 뿐이다(Component를 상속한 덕분에 WebClient를 Visual Studio 디자이너의 구성요소 모음에 넣을 수 있게 되었다) 실행시점에서 WebClient의 Dispose 메서드는 그 어떤 쓸모 있는 일도 하지 않으므로, WebClient 인스턴스는 굳이 처분할 필요가 없다.
  • .NET Framework 4.5부터는 WebClient가 오래 실행되는 메서드들의 비동기 버전들을 제공한다. 비동기 버전들은 await로 기다릴 수 있는 작업 객체를 돌려준다.
await wc.DownloadFileTaskAsync("http://www.albahari.com/nutshell/code.aspx", "code.htm");
  • 메서드 이름의 접미사 TaskAsync는 접미사 Async를 사용하는 구식 EAP 기반 비동기 메서드들과의 혼동을 피하기 위한 것이다.
  • 안타깝게도 새 비동기 메서드들은 취소와 진행 보고에 표준적으로 쓰이는 “TAP” 패턴을 지원하지 않는다. 작업을 취소하려면 반드시 WebClient 객체에 대해 CancelAsync 메서드를 호출해야 하며, 진행 보고를 위해서는 DownloadProgressChanged/UploadProressChanged 이벤트를 처리해야 한다.
  • 다음 예제는 웹 페이지를 내려받으면서 진행 정도를 보고하되, 5처를 넘기면 다운로드를 취소한다.
var wc = new WebClient();
wc.DownloadProgressChanged += (sender, args) => Console.WriteLine(args.ProgressPercentage + "% complete");
Task.Delay(5000).ContinueWith(ant => wc.CancelAsync());
await wc.DownloadFileTaskAsync("http://oreilly.com", "webpage.htm");
  • 요청이 취소되면 Status 속성이 WebExceptionStatus.RequestCanceled인 WebException 예외가 발생한다. (역사적인 이유로 OperationCanceledException 예외는 발생하지 않는다)
  • 진행 보고 관련 이벤트들은 활성 동기화 문맥을 갈무리해서 전송하므로, 해당 이벤트 처리부에서 Dispatcher.BeginInvoke를 호출하지 않고도 UI 컨트롤을 갱신할 수 있다.
  • 취소나 진행 보고에 의존하는 경우, 하나의 WebClient 객체로 여러 연산을 연달아 수행하는 것은 피해야 한다. 경쟁 조건이 발생할 수도 있기 때문이다.

WebRequest와 WebResponse

  • WebRequest와 WebResponse는 WebClient보다 사용하기가 좀 더 복잡하지만, 대신 좀 더 유연하다. 다음은 이 클래스들의 기본적인 사용 방법이다.
    • 원하는 URI로 WebRequest.Create를 호출해서 웹 요청 객체를 얻는다.
    • 그 객체의 Proxy 속성을 설정한다.
    • 인증이 필요한 경우에는 Credentials 속성도 설정한다.
  • 자료 올리기
    • 요청 객체에 대해 GetRequestStream을 호출해서 요청 스트림을 얻고 그 스트림에 원하는 자료를 기록한다. 서버의 응답을 받아야 하면 아래 단계로 간다.
  • 자료 내려받기
    • 요청 객체에 대해 GetResponse를 호출해서 웹 응답 객체를 생성한다.
    • 응답 객체에 대해 GetResponseStream을 호출해서 응답 스트림을 얻고 그 스트림에서 자료를 읽어 들인다(이때 StreamReader가 도움이 된다)
  • 다음은 앞의 예제를 이 클래스들을 이용해서 다시 작성한 것이다.
WebRequest req = WebRequest.Create("http://www.albahari.com/nutshell/code.html");
req.Proxy = null;

using (WebResponse res = req.GetResponse())
using (Stream rs = res.GetResponseStream())
using (FileStream fs = File.Create("code.html"))
  rc.CopyTo(fs);
  • 다음은 비동기 메서드들을 사용하는 버전이다.
WebRequest req = WebRequest.Create("http://www.albahari.com/nutshell/code.html");
req.Proxy = null;

using (WebResponse res = await req.GetResponseAsync())
using (Stream rs = res.GetResponseStream())
using (FileStream fs = File.Create("code.html"))
  rc.CopyToAsync(fs);
  • 웹 응답 객체에는 ContentLength라는 속성이 있다. 이 속성은 응답 스트림의 바이트 단위 길이를 담고 있는데, 그 값은 서버가 응답 헤더를 통해서 알려준 값일 뿐 실제 기리와는 다를 수 있다(또한 응답 헤더에 이 정보를 아예 넣지 않은 서버도 있다) 특히 HTTP 서버가 큰 응답을 여러 조각으로 나누어서 보내는 경우(‘chunked’ 모드), ContentLength가 실제 보다 한 바이트 모자란 경우가 많다. 동적으로 생성된 페이지의 경우에도 마찬가지이다.
  • 정적 Create 메서드는 HttpWebRequest나 FtpWebRequest 같은 WebRequest 파생 클래스의 인스턴스를 생성한다. 구체적으로 어떤 파생 형식이 선택되는지는 URI의 스팀에 따라 다른데 아래 표에 선택 규칙이 나와 있다.
스킴 웹 요청 형식
http: 또는 https: HttpWebRequest
ftp: FtpWebRequest
file: FileWebRequest

 

  • 하나의 웹 요청 객체를 구체적인 형식(HttpWebRequest나 FtpWebRequest)으로 캐스팅하면 해당 프로토콜에 고유한 기능들에 접근할 수 있다.
  • 또한 WebRequest.RegisterPrefix를 호출해서 새로운 커스텀 스킴을 등록할 수도 있다. 이를 위해서는 호출 시 스킴뿐만 아니라 적절한 웹 요청 객체를 생성하는 Create 메서드가 있는 팩토리 객체도 지정해야 한다.
  • ‘https:’ 프로토콜은 SSL(Secure Sockets Layer; 보안 소켓 계층)을 통한 안전한(암호화된) HTTP 통신에 쓰인다. WebClient와 WebRequest 모두 이 스킴을 인식해서 자동으로 SSL을 활성화한다.
  • ‘file:’ 프로토콜은 그냥 요청을 FileStream 객체에 전다랗기만 한다. 이 프로토콜은 웹 페이지와 FTP 사이트뿐만 아니라 지역 파일에도 URI를 통해서 일관된 방식으로 접근할 수 있게 하기 위한 것이다.
  • WebRequest에는 Timeout 속성이 있다. 이 속성은 밀리초 단위의 만료시간이다. 만일 시간이 만료되면 WebRequest는 Status 속성이 WebExceptionStatus.Timeout인 WebException 예외를 던진다.
    • 기본 만료시간은 HTTP는 100초이고 FTP는 무제한이다.
  • 하나의 WebRequest 객체를 여러 요청에 재활용하면 안 된다. 한 인스턴스는 하나의 요청에만 사용하는 것이 좋다.

HttpClient 클라이언트

  • .NET Framework 4.5에 새로 추가된 HttpClient 클래스는 WebClient처럼 요청, 응답 클래스들(구체적으로는 HttpWebRequest와HttpWebResponse) 위에 놓인 하나의 얇은 계층이다. 이 클래스는 HTTP 기반 웹 API와 REST 서비스의 성장에 대응하기 위해 작성된 것으로 단순한 웹 페이지 가져오기 이상의 프로토콜들을 다루어야 할 때 WebClient보다 편하다. 특히 다음과 같은 장점이 있다.
    • 하나의 HttpClient 인스턴스로 다수의 동시적 요청들을 처리할 수 있다. WebClient로 동시성을 얻으려면 동시적 요청마다 새 인스턴스가 필요한데, 거기에 커스텀 헤더나 쿠키, 인증 스킴 같은 것들을 도입하면 코드가 다소 지저분해진다.
    • 커스텀 메시지 처리부를 작성해서 HttpClient에 등록할 수 있다. 이 능력은 단위 검사(unit test)를 위한 모의 객체(mock up)를 만들 때나 로깅, 압축, 암호화 등을 위한 커스텀 파이프라인을 만들 때 요긴하다. 반면 WebClient를 사용하는 코드에 대한 단위 검사는 작성하기가 괴롭다.
    • HttpClient는 HTTP 헤더와 내용을 위한 다양한 형식을 지원하며, 확장도 가능하다.
  • 그렇지만 HttpClient로 WebClient를 완전히 대체할 수는 없다. 특히 HttpClient는 진행 보고 기능을 지원하지 않는다. 또한 WebClient는 FTP, file://, 커스텀URI 스킴을 지원한다는 장점이 있으며, 버전 4.5이전의 .NET Framework에서도 사용할 수 있다.
  • WebClient를 사용하는 가장 간단한 방법은 인스턴스를 하나 생성한 후 원하는 URI로 Get* 메서드 중 하나를 호출하는 것이다.
string html = await new HttpClient().GetStringAsync("http:/linqpad.net");
  • GetStringAsync 외에 GetBYteArrayAsync와 GetStreamAsync도 있다.
  • HttpClient의 모든 I/O 한정 메서드들은 비동기적이다(동기적 버전들도 있다)
  • WebClient와 달리 HttpClient에서 최고의 성능을 얻으려면 같은 인스턴스를 반드시 재사용해야 한다(그렇지 않으면 DNS 조회 같은 연산을 쓸데 없이 여러 번 수행하게 된다)
  • HttpClient는 동시적 연산을 지원하므로, 다음과 같이 두 웹페이지를 동시에 내려받는 코드가 적법하다.
var client = new HttpClient();
var task1 = client.GetStringAsync("http://www.linqpad.net");
var task2 = client.GetStringAsync("http://www.albahari.com");
Console.WriteLine(await task1);
Console.WriteLine(await task2);
  • HttpClient에는 시간 만료 기능을 위한 Timeout 속성이 있으며, 모든 요청에 공통으로 적용되는 URI 접두사를 지정하는 BaseAddress 속성도 있다.
  • HttpClient는 다소 얇은 껍데기이다. 이 클래스가 제공하는 대부분의 속성은 사실 HttpClientHandler라는 다른 클래스에 정의되어 있다. 이 클래스에 접근하려면 다음처럼 먼저 인스턴스를 생성한 후 HttpClient의 생성자에 그 인스턴스를 넘겨 주어야 한다.
var handler = new HtpClientHandler { UseProxy = false };
var client = new HttpClient(handler);
...
  • 이는 HTTP 클라이언트 처리부에게 프록시를 사용하지 말라고 지정하는 예이다. 이 예에 나온 속성 외에도 쿠키, 자동 재지정, 인증 등을 제어하는 여러 속성이 있다.

GetAsync와 응답 메시지

  • 사실 GetStringAsync, GetByteArrayAsync, GetStreamAsync 메서드는 좀 더 일반 적인 GetAsync라는 메서드를 호출해주는 단축 버전이다. GetAsync는 하나의 응답 메시지를 나타내는 객체를 돌려준다.
var client = new HttpClient();
// GetAsync 메서드는 CancellationToken 객체도 받는다.
HttpResponseMessage response = await client.GetAsync("http://...");
response.EnsureSuccessStatusCode();
string html = await response.Content.ReadAsStringAsync();
  • HttpResponseMessage는 헤더들에 접근하기 위한 속성들과 HTTP 상태 코드를 담은 StatusCode 속성을 제공한다.
    • WebClient와는 달리 HttpClient는 서버가 404(Not Found) 같은 오류성 상태 코드를 돌려주어도 예외를 던지지 않는다. 예외는 명시적으로 EnsureSuccessStatusCode를 호출해야 던져진다.
    • 단 저수준의 통신이나 DNS 오류가 발생하면 예외를 던진다.
  • HttpContent 클래스에는 응답 내용을 다른 스트림에 기록하는 CopyToAsync라는 메서드가 있다. 이 메서드는 응답을 파일에 기록할 때 유용하다.
using (var fileStream = File.Create("linqpad.html"))
  await response.Content.CopyToAsync(fileStream);
  • GetAsync는 HTTP의 네 동사에 해당하는 네 가지 메서드 중 하나이다.(다른 메서드들은 PostAsync, PutAsync, DeleteAsync이다).

SendAsync와 요청 메시지

  • 지금까지 설명한 네 Get* 메서드들은 모두 하나의 저수준 메서드 SendAsync를 호출해서 실제 작업을 진행한다. 이 메서드를 사용하려면 먼저 요청 메시지를 나타내는 HttpRequestMessage 객체를 만들어야 한다.
var client = new HttpClient();
var request = new HttpRequestMEssage(HttpMethod.Get, "http://...");
HttpResponseMessage response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
...
  • 이처럼 HttpRequestMessage 객체를 직접 생성하면 요청의 여러 속성을 세세하게 커스텀화 할 수 있다. 이를테면 HTTP 헤더들과 요청의 내용 자체를 필요에 따라 설정해서 원하는 자료를 서버에 올릴 수 있다.

자료 올리기와 HttpContent

  • HttpRequestMessage 객체를 인스턴스화한 후에는 서버에 올리고자 하는 내용을 그 객체의 Content 속성에 설정한다. 이 속성의 형식은 HttpContent라는 추상 클래스이다.
  • .NET Framework에는 여러 내용 형식에 대응되는 다음과 같은 구체적인 구현 클래스들이 있다. (또한 독자가 커스텀 구현 클래스를 직접 작성해서 사용할 수도 있다)
    • ByteArrayContent
    • StringContent
    • FormUrlEncodedContent
    • StreamContent
  • 다음은 문자열 내용을 서버에 보내는 예이다.
var client = new HttpClient (new HttpClientHandler { UseProxy = false });
var request = new HttpRequestMessage(HttpMethod.Post, "http://www.albahari.com/EchoPost.aspx");
request.Content = new StringContent("시험용 텍스트");
HttpResponseMessage response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
Console.WriteLine(await response.Content.ReadAsStringAsync());

HttpMessagHandler 클래스

  • 앞에서 요청을 커스텀화 하는데 쓰이는 대부분의 속성이 HttpClient가 아니라 HttpClientHandler 클래스에 정의되어 있다고 말했다. HttpClientHandler는 추상 클래스 HttpMessageHandler의 한 파생 클래스이다. HttpMessagHandler의 정의는 다음과 같다.
public abstract class HttpMessageHandler : IDisposable
{
  protected internal abstract Task<HttpResponseMessage> SendAsync (HttpRequestMessage request, CancellationToken cancellationToken);

  public void DIspose();
  protected virtual void DIstpose(bool disposing);
}
  • HttpClient의 SendAsync 메서드가 호출되면 결국에는 이 클래스(의 구현 클래스)의 SendAsync 메서드가 호출된다.
  • HttpMessageHandler는 정의가 간단하기 때문에 쉽게 파생할 수 있다. 이 클래스는 HttpClient의 기능을 확장하는 수단으로 쓰인다.

단위 검사와 모의 처리부

  • HttpMessageHandler의 한 용도는 단위 검사를 위한 모의(mocking) 처리부 클래스를 파생하는 것이다. 다음이 그러한 예이다.
class MockHandler : HttpMessageHandler
{
  Func<HttpRequestMessage, HttpResponseMessage> _responseGenerator;
  
  public MockHandler (Func<HttpRequestMessage, HttpResponseMessage> responseGenerator)
  {
    _responseGenerator = responseGenerator;
  }

  protected override Task <HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
  {
    cancellationToken.THrowIfCancellationRequested();
    var response = _responseGenerator(request);
    response.RequestMessage = request;
    return Task.FromResult(response);
  }
}
  • 이 클래스의 생성자는 주어진 요청에 대한 응답을 생성해 주는 함수를 받는다. 같은 처리부로 여러 요청을 검사할 수 있다는 점에서 이것이 가장 융통성 있는 접근 방식이다.
  • 이 클래스의 SendAsync는 이름과 달리 동기적으로 작동한다. 끝에서 Task.FromResult를 호출하기 때문이다.
    • 응답 생성함수가 Task<HttpResponseMEssage>를 돌려주게 하면 SendAsync의 비동기성을 유지할 수 있지만, 어차피 단위 검사를 위한 모의 처리부는 대부분 짧게 실행된다고 가정하므로 비동기성이 꼭 필요하지는 않다.
    • 다음은 이 모의 처리부를 사용하는 예이다.
var mocker = new MockHandler (request =>
  new HttpResponseMessage(HttpStatusCode.OK)
  {
    Content = new StringContent("요청된 URI: " + request.RequestUri)
  });

var client = new HttpClient(mocker);
var response = await client.GetAsync("http://www.linqpad.net");
string result = await response.Content.ReadAsStringAsync();
Assert.AreEqual("요청된 URI: http://www.linqpad.net/", result);

DelegatingHandler를 이용한 처리부 연쇄

  • 한 메시지 처리부에서 다른 메시지 처리부를 호출하려면(그럼으로써 처리부들이 연쇄적으로 실행되게 하려면) DelegatingHandler라는 클래스를 파생해야 한다. 이를테면 커스텀 인증이나 압축, 암호화 프로토콜을 구현할 때 이러한 처리부 연쇄 기법이 유용하다.
    • 다음은 간단한 로깅 처리부의 예이다.
class LoggingHandler : DelegatingHandler
{
  public LoggingHandler(HttpMessageHandler nextHandler)
  {
    InnerHandler = nextHandler;
  }

  protected async override Task<HttpResponseMessage> SendAsync (HttpRequestMessage request, CancellationToken cancellationToken)
  {
    Console.WriteLine("요청 중 : " + request.RequestUri);
    var response = await base.SendAsync(request, cancellationToken);
    Console.WriteLine("받은 응답: " + response.StatusCode);
    return response;
  }
}
  • 비동기성을 유지하기 위해 SendAsync르 ㄹ재정의했음을 주목하기 바란다. 작업 객체를 돌려주는 메서드를 재정의할 때 async 수정자를 추가하는 것은 완벽하게 적법하다. 사실 이 경우는 그렇게 하는 것이 바람직하다.
  • 지금 예는 로그 메시지를 콘솔에 기록하지만, 실용적인 로깅 처리부라면 생성자에서 어떤 로깅 객체를 받아서 저장하고 그 객체에 메시지를 기록해야 할 것이다. 아마도 더 나은 방법은 요청 메시지의 기록과 응답 메시지의 기록을 담당하는 Action<T> 대리자들을 받아서 그 대리자들에게 처리를 맡기는 것이다.

프록시 활용

  • 프록시 서버는 실제 서버와 클라이언트 사이에서 HTTP와 FTP 요청/응답을 중계하는 서버이다. 일부 기업은 직원들이 오직 프록시를 통해서만 인터넷에 접속하게 한다. 그러면 보안이 간단해지기 때문이다. 프록시는 자신만의 주소를 가지며, LAN의 특정 사용자들만 인터넷에 접속할 수 있도록 인증을 요구할 수도 있다.
  • WebClient나 WebRequest 객체가 요청을 프록시 서버로 보내게 하려면 다음처럼 WebProxy 객체를 사용한다.
// 프록시 서버의 IP 주소와 포트를 지정해서 WebProxy 객체를 생성한다.
// 프록시에 사용자 이름/패스워드가 필요하면 Credentials 속성을 적절히 설정해 준다.
WebProxy p = new WebProxy("192.178.10.49", 8008);
p.Credentials = new NetworkCredential("사용자이름", "패스워드");
// 또는
p.Credentials = new NetworkCredential("사용자이름", "패스워드", "도메인");

WebClient wc = new WebClient();
wc.Proxy = p;
...

// WebRequest 객체도 마찬가지이다.
WebRequest req = WebRequest.Create("...");
req.Proxy = p;
  • HttpClient에서 프록시를 사용할 떄는 먼저 HttpClientHandler 객체를 생성해서 Proxy 속성을 적절히 설정하고 그 객체를 HttpClient의 생성자에 넘겨준다.
WebProxy p = new WebProxy("192.178.10.49", 808);
p.Credentials = new NetworkCredential("사용자이름", "패스워드", "도메인");

var handler = new HttpClientHandler { Proxy = p };
var client = new HttpClient(handler);
...
  • 프록시가 없다는 점이 확실한 경우에는 WebClient나 WebRequest 객체의 Proxy 속성을 null로 설정하는 것이 좋다. 그렇게 하지 않으면 .NET Framework가 프록시 설정들을 자동으로 검출하려 들 수 있기 때문이다.
    • 프록시 설정 자동 검출 때문에 요청 처리 시간이 많게는 30초나 길어질 수 있다. 웹 요청이 이상하게 느리게 진행된다면, 아마 이 프록시 검출 문제 때문일 것이다.
  • HttpClientHandler에는 UseProxy라는 속성이 있다. Proxy 속성을 널로 설정하는 대신 이 속성을 false로 설정해도 프록시 자동 검출이 비활성화 된다.
  • NetworkCredential 객체를 생성할 때 도메인을 지정하면 Windows 기반 인증 프로토콜을 통해서 사용자 인증이 일어난다. 현재 인증(로그인)된 Windows 사용자의 신원을 사용하고 싶으면 정적 속성 CredentialCache.DefaultNetworkCredentials의 값을 프록시 객체의 Credentials 속성에 배정하면 된다.
  • 매번 Proxy를 설정하는 대신 다음과 같이 전역 기본 프록시를 설정할 수도 있다.
WebRequest.DefaultWebProxy = myWebProxy;
  • 또는 다음처럼 프록시를 사용하지 않는 것을 기본으로 할 수도 있다.
WebRequest.DefaultWebProxy = null;
  • 어떻게 설정하든 응용 프로그램 도메인의 수명 전체에 적용된다.

인증

  • HTTP나 FTP 사이트에 사용자 이름과 패스워드를 제공해야 한다면, NetworkCredential 객체를 적절히 생성해서 WebClient나 WebRequest의 Credentials 속성에 설정하면 된다. 다음이 그러한 예이다.
WebClient wc = new WebClient { Proxy = null };
wc.BaseAddress = "ftp://ftp.albahari.com"l

// 사용자 인증 후 FTP 서버에서 파일을 하나 내려 받아서 수정 후 다시 올린다. HTTP나 HTTPS에서도 같은 방식으로 진행하면 된다.
string username = "nutshell";
string password = "oreilly";
wc.Credentials = new NetworkCredential (username, password);
wc.DownloadFile("guestbook.txt", "guestbook.txt");

string data = "Hello from " + Environment.UserName + "!\r\n";
File.AppendAllText("guestbook.txt", data);
wc.UploadFile("guestbook.txt", "guestbook.txt");
  • HttpClient를 사용할 떄는 클라이언트 처리부(HttpClientHandler)의 Credentials 속성을 설정해야 한다.
var handler = new HttpClientHandler();
handler.Credentials = new NetworkCredential (username, password);
var client = new HttpClient(handler);
...
  • 이 방법은 기본(Basic Authentication) 인증과 다이제스트 인증(Digest Authentication) 같은 대화상자 기반 인증 프로토콜들을 지원하며, AuthenticationManager 클래스를 통해서 더 확장하는 것도 가능하다.
    • 이 방법은 Windows의 NTLM과 Kerberos 프로토콜도 지원한다.(NetworkCredential 객체 생성 시 도메인 이름을 지정했다고 할 때)
    • 현재 인증된 Windows 사용자로 인증하려면 Credentials 속성은 null로 두고 UseDefaultCredentials 속성을 treu로 설정하면 된다.
  • 웹 페이지 자체의 HTML 양식(form)을 거치는 인증에는 Credentials 속성을 설정하는 방법이 통하지 않는다.
  • 이러한 인증을 실제로 처리하는 것은 WebRequest의 파생 형식(앞의 예제에서는 FtpWebRequest)이다. WebRequest 파생 형식은 자동으로 호환 프로토콜을 협상해서 인증을 진행한다.
    • HTTP의 경우 여러 인증 프로토콜 중 하나를 선택할 수 있는데, 예컨대 Microsoft Exchange 서버 웹 메일 페이지가 처음 보내는 응답을 살펴보면 다음과 같은 헤더들이 있다.
HTTP/1.1 401 Unauthorized
Content-Length: 83
Content-Type: text/html
Server: Microsoft-IIS/6.0
WWW-Authenticate: Negotiate
WWW-Authenticate: NTLM
WWW-Authenticate: Basic realm="exchange.somedomain.com"
X-Powered-By: ASP.NET
Date: Sat, 05 Aug 2006 12:37:23 GMT
  • 401이라는 HTTP 상태 부호는 해당 자원에 접근하려면 적절한 권한이 있어야 함을 뜻한다. ‘WWW-Authenticate’ 헤더들은 서버가 지원하는 프로토콜들을 알려주는 역할을 한다.
    • WebClient나 WebRequest 객체에 적절한 사용자 이름과 패스워드를 설정한 경우에는 이런 헤더들을 보지 못한다. .NET Framework가 자동으로 호환되는 인증 프로토콜을 선택하고, 원래의 요청에 관련 헤더들을 추가해서 다시 요청을 보내기 때문이다. 이를테면 다음과 같은 헤더를 추가한다.
Authorization: Negotiate TlRMTVNTUAAABAAA5II2gj....
  • 이러한 메커니즘은 인증을 투명하게 처리해주긴 하지만, 요청마다 추가적인 왕복 통신이 일어난다는 단점이 있다. PreAuthenticate 속성을 true로 설정하면 같은 URI에 대한 여러 요청에서 추가적인 왕복 통신이 일어나지 않는다. 이 속성은 WebRequest 클래스에 정의되어 있다.(그리고 HttpWebRequest에서만 실제로 작동한다) WebClient는 이 기능을 전혀 제공하지 않는다.

CredentialCache 클래스

  • 사용하고 싶은 또는 사용하고 싶지 않은 특정 인증 프로토콜들이 있다면, CredentialCache 객체로 대표되는 신원 정보 캐시를 활용하면 된다. 신원 정보 캐시는 하나 이상의 NetworkCredential 객체를 담는데, 각 객체를 가리키는 키는 특정 프로토콜과 URI 접두어의 조합이다.
    • 예컨대 기본 인증 프로토콜은 패스워드를 평문으로 전송하므로 중요한 서버에 로그인할 떄는 피하는 것이 바람직하다.
    • 다음은 Microsoft Exchange 서버에 로그인할 때 기본(Basic) 인증 프로토콜을 협상 대상에서 제외하는 예이다.
CredentialCache cache = new CredentialCache();
Uri prefix = new Uri ("http://exchange.somedoamin.com");
cache.Add(prefix, "Digest", new NetworkCredential("joe", "passwd"));
cache.Add(prefix, "Negotiate", new NetworkCredential("joe", "passwd"));

WebClient wc = new WebClient();
wc.Credentials = cache;
...
  • 인증 프로토콜은 문자열로 지정한다. 유효한 값은 다음 다섯가지이다.
    • Basic, Digest, NTLM, Kerberos, Negotiate
  • 앞에서 서버가 보낸 응답 헤더들을 보면 서버는 Digest를 지원하지 않으므로 지금 예에서 WebClient는 Negotiate 인증을 선택하게 된다. Negotiate는 Windows의 한 인증 프로토콜로, 서버의 능력에 따라 Kerberos나 NTLM 중 하나로 환원된다.
  • 정적 CredentialCache.DefaultNetworkCredentials 속성을 이용하면 현재 인증된 Windows 사용자를 패스워드를 지정하지 않고도 신원 정보 캐시에 추가할 수 있다. 다음과 같이 하면 된다.
cache.Add(prefix, "Negotiate", CredentialCache.DefaultNetworkCredentials);

HttpClient와 헤더 전송을 이용한 인증

  • HttpClient를 사용할 때는 다음 예처럼 인증 헤더를 직접 전송해서 인증을 진행할 수도 있다.
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("username:password")));
...
  • 이 전략은 OAuth 같은 커스텀 인증 시스템에도 적용할 수 있다.

예외 처리

  • WebRequest, WebResponse, WebClient와 해당 스트림들은 모두 네트워크 오류나 프로토콜 오류가 발생했을 때 WebException을 던진다. HttpClient는 WebException을 감싼 HttpRequestException을 던진다. 구체적인 오류는 WebException의 Status 속성을 보면 알 수 있다. 이 속성은 다음과 같은 멤버들이 있는 WebExceptionStatus 열거형의 값을 돌려준다.
CacheEntryNotFound RequestCanceled
ConnectFailure RequestProhibitedByCachePolicy
ConnectionClosed RequestProhibitedByProxy
KeepAliveFailure SecureChannelFailure
MessageLengthLimitExceeded SendFailure
NameResolutionFailure ServerProtocolViolation
Pending Success
PipelineFailure Timeout
ProtocolError TrustFailure
ProxyNameResolutionFailure UnkownError
ReceiveFailure

 

  • NameResolutionFailure는 도메인 이름이 유효하지 않은 경우이고, ConnectFailure는 네트워크 자체가 죽은 경우이다. Timeout은 WebRequest.Timeout에 설정된 밀리초 단위의 만료시간을 넘겼음을 뜻한다.
  • “Page not found”, “Moved Permanently”, “Not Logged In” 같은 오류 메시지는 HTTP나 FTP 프로토콜 자체에 정의된 문구이므로 프로토콜 오류를 뜻하는 ProtocolError 속성에서 볼 수 있다.
  • HttpClient는 이런 오류들이 발생해도 예외를 던지지 않는다. 응답 객체에 대해 EnsureSuccessStatusCode를 호출해야 비로소 예외가 발생한다. 그 전에라도 StatusCode 속성을 보면 구체적인 상태 코드를 알 수 있다.
var client = new HttpClient();
var response = await client.GetAsync("http://linqpad.net/foo");
HttpStatusCode responseStatus = response.StatusCode;
  • WebClient와 WebRequest/WebResponse에서는 실제로 WebException을 잡은 후 다음과 같은 과정을 거쳐야 한다.
    1. WebException의 Response 속성을 HttpWebResponse나 FtpWebResponse로 캐스팅한다.
    2. 그 응답 객체의 Status 속성(HttpStatusCode 또는 FtpStatusCode 열거형)이나 StatusDescription 속성(문자열)을 조사한다.
  • 예를들면 다음과 같다.
WebClient wc = new WebClient { Proxy = null };
try
{
  string s = wc.DownloadString("http://www.albahari.com/notthere");
}
catch (WebException ex)
{
  if (ex.Status == WebExceptionStatus.NameResolutionFailure)
    Console.WriteLine("잘못된 도메인 이름");
  else if (ex.Status == WebExceptionStatus.ProtocolError)
  {
    HttpWebResponse response = (HttpWebResponse) ex.Response;
    Console.WriteLine(response.StatusDescription);  // "Not Found"
    if (response.StatusCode == HttpStatusCode.NotFound)
      Console.WriteLine("찾을 수 없음!");    // "찾을 수 없음!"
  }
  else throw;
}
  • 401이나 404 같은 세 자리 상태 부호를 얻으려면 HttpStatusCode나 FtpStatusCode 열거형을 그냥 정수로 캐스팅하면 된다.
    • 기본적으로 재지정(redirection) 오류를 만나는 일은 없다. 왜냐면 WebClient와 WebRequest는 재지정 응답을 자동으로 따라가기 때문이다. 이러한 자동 재지정 처리를 원하지 않으면 WebRequest 객체의 AllowAutoRedirect 속성을 false로 설정하면 된다.
    • 재지정 오류는 세 가지로 301(Moved Permanently)과 302(Found/Redirect), 307(Temporary Redirect)이다.
  • 통신상의 문제가 아니라 코드에서 WebClient나 WebRequest 클래스를 잘못 사용해서 오류가 발생한다면 WebException이 아니라 InvalidOperationException이나 ProtocolViolationException 예외가 발생할 가능성이 크다.

HTTP 다루기

HTTP 헤더

  • WebClient와 WebRequest, HttpClient는 요청에 커스텀 HTTP 헤더를 추가하는 기능과 응답에 있는 헤더들을 열거하는 기능을 제공한다.
    • HTTP 헤더는 그냥 요청 또는 응답에 관한 메타자료를 담은 키/값 쌍이다.
    • 클라이언트와 서버는 이를테면 메시지 내용 형식이나 서버 소프트웨어 종류 같은 정보를 헤더를 통해서 주고 받는다.
    • 다음은 WebClient를 이용해서 통신할 때 요청에 커스텀 헤더를 추가하는 방법과 응답 메시지에 있는 모든 헤더를 나열하는 방법을 보여주는 예제이다.
WebClient wc = new WebClient { Proxy = null };
wc.Headers.Add("CustomHeader", "JustPlaying/1.0");
wc.DownloadString("http://www.oreilly.com");

foreach (string name in wc.ResponseHeaders.Keys)
  Console.WriteLine(name + "=" + wc.ResponseHeaders[name]);

// 출력
// Age=51
// X-Cache=HIT from oregano.bp
// X-Cache-Lookup=HIT from oregano.bp:3138
// Connection=keep-alive
// Accept-Ranges=bytes
// Content-Length=95433
// Content-Type=text/html
...
  • HttpClient에서는 구체적인 형식이 있는 컬렉션 형태의 속성들을 이용해서 표준 HTTP 헤더들에 접근해야 한다. 예컨대 DefaultRequestHeaders 속성은 모든 요청에 적용되는 헤더들을 담는다.
var client = new HttpClient(handler);
client.DefaultRequestHeaders.UserAgent.Add(new ProjectInfoHeaderValue("VisualStudio", "2015"));
client.DefaultRequestHeaders.Add("CustomHeader", "VisualStudio/2015");
  • 한편, HttpRequestMessage 클래스의 Headers 속성은 요청에 국한된 헤더들만 지원한다.

질의 문자열

  • 질의 문자열(query string)은 URI에 물음표와 함께 추가하는 문자열로 클라이언트가 간단한 자료를 서버에 보내는 용도로 쓰인다. 다음과 같이 하나의 질의 문자열에 여러 개의 키/값 쌍을 &로 구분해서 지정할 수 있다.
?키1=값1&키2=값2&키3=값3...
  • WebClient를 사용할 때는 사전 스타일의 속성을 통해서 질의 문자열을 손쉽게 추가할 수 있다. 다음은 Google에서 ‘WebClient’라는 단어를 찾되 결과 페이지를 프랑스어로 표시하는 예이다.
WebClient wc = new WebClient { Proxy = null };
wc.QueryString.Add("q", "WebClient");  // "WebClient"를 검색
wc.QueryString.Add("hl", "fr");  // 결과를 프랑스어로 표시
wc.DownloadFile("http://www.google.com/search", "results.html");
System.Diagnostics.Process.Start("results.html");
  • WebRequest나 HttpClient로 같은 결과를 얻으려면 적절한 형태의 질의 문자열을 요청 URI에 직접 추가해야 한다.
string requestURI = "http://www.google.com/search?q=WebClient&hl=fr";
  • 키나 값에 기호나 빈칸이 있는 질의 문자열을 그대로 추가하면 유효한 URI가 되지 않는다. 유효한 URI를 만드는 한 가지 방법은 Uri의 EscapeDataString 메서드를 이용하는 것이다.
string search = Uri.EscapeDataString("(WebClient OR HttpClient)");
string language = Uri.EscapeDataString("fr");
string requestURI = "http://www.google.com/search?q=" + search + "&hl=" + language;
  • 이 코드는 다음과 같은 URI를 만들어낸다.
http://www.google.com/search?q=(WebClient%20OR%20HttpClient)&hl=fr
  • EscapeDataString 메서드는 EscapeUriString 메서드와 비슷하되, &와 = 같은 문자도 부호화한다는 점이 다르다. 그런 문자가 키나 값 자체에 들어 있으면 질의 문자열에 담긴 키/값 쌍들의 구분이 엉망이 된다.
  • Microsoft의 Web Protection 라이브러리는 교차 사이트 스크립팅(cross-site scripting) 취약점까지 고려하는 또 다른 부호화/복호화 해법을 제공한다.

HTML 양식 자료 올리기

  • WebClient는 HTML 양식 자료를 서버에 전송해주는 UploadValues라는 메서드를 제공한다.
WebClient wc = new WebClient { Proxy = null };
var data = new System.Collections.Sepcialized.NameValueCollection();
data.Add("Name", "Joe Albahari");
data.Add("Company", "O'Reilly");

byte[] result = wc.UploadValues("http://www.albahari.com/EchoPost.aspx", "POST", data);
Console.WriteLine(Encoding.UTF8.GetString(result));
  • NameValueCollection은 키/값 쌍들을 담는다. 이 예제의 Name이나 Company 같은 키들은 HTML 양식의 입력 상자 이름과 대응된다.
  • WebRequest로 양식 자료를 제출하려면 수고가 더 든다. (쿠키 같은 기능을 사용하려면 WebRequest를 사용해야 한다.) 그 과정은 다음과 같다.
    1. 요청 객체의 ContentType 속성을 “application/x-www-form-urlencoded”로 Method 속성을 “POST”로 설정한다.
    2. 서버에 보낼 양식 자료를 다음처럼 “이름=값” 쌍들의 목록 형태로 부호화한 문자열을 만든다.
      • 키1=값1&키2=값2&키3=값3…
    3. 그 문자열을 Encoding.UTF8.GetBytes를 이용해서 바이트 배열로 변환한다.
    4. 웹 요청 객체의 ContentLength 속성에 그 바이트 배열의 길이를 설정한다.
    5. 웹 요청 객체의 GetRequestStream 메서드를 호출해서 그 바이트 배열을 전송한다.
    6. GetResponse를 호출해서 서버의 응답을 읽는다.
  • 다음의 앞의 예제를 WebRequest를 이용해서 다시 작성한 것이다.
var req = WebRequest.Create("http://ww.albahari.com/EchoPost.aspx");
req.Proxy = null;
req.Method = "POST";
req.ContentType = "application/x-www-form-rulencoded";

string reqString = "Name=Joe+Albahari&Company=OR'eilly";
byte[] reqData = Encoding.UTF8.GetBytes(reqString);
req.ContentLength = reqData.Length;

using (Stream reqStream = req.GetRequestStream())
  req.Stream.Write(reqData, 0, reqData.Length);

using (WebResponse res = req.GetResponse())
using (Stream resStream = res.GetResponseStream())
using (StreamReader st = new StreamReader(resStream))
  Console.WriteLine(sr.ReadToEnd());
  • HttpClient에서는 FromUrlEncodedContent 객체를 생성해서 양식 자료를 채운 후 PostAsync 메서드를 호출하거나 요청 객체의 Content 속성을 설정하면 된다.
string uri = "http://ww.albahari.com/EchoPost.aspx";
var client = new HttpClient();
var dict = new Dictionary<string, string>
{
  { "Name", "Joe Albahari" },
  { "Company", "O'Reilly" }
};
var values = new FroRulEncodedContent(dict);
var response = await client.PostAsync(uri, values);
Response.EnsureSuccessStatusCode();
Console.WriteLine(await response.Content.ReadAsStringAsync());

쿠키

  • 쿠키(cookie)는 HTTP 서버가 응답 헤더에 담아서 클라이언트에게 보내는 이름/값 쌍 문자열이다. 웹 브라우저 클라이언트는 서버가 보낸 쿠키들을 일정 기간 저장해 두었다가 나중에 같은 주소를 다시 방문할 때 그 쿠키들을 요청에 담아 보낸다.
    • 흔히 쿠키는 몇 분 또는 며칠 사이에 재방문한 클라이언트를 서버가 식별하는 용도로 쓰인다. 쿠키를 이용하면 URI에 지저분한 질의 문자열을 추가할 필요가 없다.
  • 기본적으로 HttpWebRequest는 서버가 보낸 쿠키를 모두 무시한다.
    • 쿠키를 받으려면 CookeContainer 객체를 생성해서 WebRequest에 배정해야 한다. 그러면 웹 응답 객체를 통해서 쿠키들을 열거할 수 있다.
var cc = new CookeContainer();

var request = (HttpWebRequest) WebRequest.Create("http://www.google.com");
request.Proxy = null;
request.CookieContainer = cc;

using (var response = (HttpWebResponse) request.GetResponse())
{
  foreach (Cookie c in response.Cookies)
  {
    Console.WriteLine(" 이름: " + c.Name);
    Console.WriteLine(" 값: " + c.Value);
    Console.WriteLine(" 경로: " + c.Path);
    Console.WriteLine(" 도메인: " + c.Domain);
  }
  // 응답 스트림을 읽어 들인다.
}

// 출력 예
// 이름: PREF
// 값: ID=6b10df1da493a9c4:TM=1179025496:LM=1179025486:S=EJCZri0aWEHlk4tt
// 경로: /
// 도메인: .google.com
  • HttpClient 에서는 다음처럼 HttpClientHandler 인스턴스에 쿠키 컨테이너를 배정해야 한다.
var cc = new CookeContainer();
var handler = new HttpClientHandler();
handler.CookeContainer = cc;
var client = new HttpClient(handler);
...
  • WebClient 퍼사드 클래스는 쿠키를 지원하지 않는다.
  • 받은 쿠키를 나중에 요청과 함꼐 보내려면 같은(이전에 쿠키를 ㅂ다았던) CookeContainer 객체를 각 WebRequest 객체에 배정하면 된다. HttpClient의 경우에는 그냥 쿠키를 받을 때 사용했던 것과 같은 HttpClient 객체로 요청을 보내면 그만이다.
    • CookieContainer는 직렬화를 지원하므로 쿠기들을 디스크에 기록하는 것도 가능하다.
Cookie c = new Cookie("PREF", "ID=6b10df1da493a9c4:TM=1179...", "/", ".google.com");
freshCookeContainer.Add(c);
  • Cookie 생성자의 셋째 인수와 넷째 인수는 쿠키 출처의 경로와 도메인이다. 클라인트 쪽의 한 CookieContainer 객체에 서로 다른 여러 출처의 쿠키들을 담을 수 있다. WebRequest는 경로와 도메인이 요청 대상 서버와 부합하는 쿠키들만 전송한다.

HTML 양식을 이용한 인증

  • 이전 절에서 NetworkCredentials 객체를 이용해서 기본 인증이나 NTLM 인증 같은 인증 체계(웹 브라우저가 대화상자를 띄우는 방식의)를 만족하는 방법을 이야기했다. 그런데 사용자 인증을 요구하는 대부분의 웹사이트는 웹 페이지 안의 HTML 양식을 이용해서 사용자 인증을 처리한다.
    • 적절한 기업 로고 이미지들로 장식된 HTML 양식에 있는 텍스트 상자들에 사용자가 사용자 이름과 패스워드를 입력한 후 버튼을 클릭하면 브라우저가 그 양식 자료를 서버에 보낸다.
    • 인증이 성공한 경우 서버는 적절한 쿠키를 응답에 담아 보낸다. 이후 그 쿠키는 사용자가 특정 수준 이상의 권한이 필요한 페이지들에 접근할 수 있는 열쇠 역할을 한다.
    • 앞에서 이야기한 WebRequest나 HttpClient의 여러 기능을 이용하면 이상의 모든 클라이언트 쪽 활동을 프로그램 안에서 수행할 수 있다.
    • 이러한 접근방식은 웹사이트를 검사하거나 적당한 API를 제공하지 않는 웹 서비스의 활용을 자동화하는데 유용하다.
  • 양식 기반 인증을 사용하는 웹사이트의 HTML 양식 코드는 대체로 다음과 같은 모습이다.
<form action="http://www.somesite.com/login" method="post">
  <input type="text" id="user" name="username">
  <input type="password" id="pass" name="password">
  <button type="submit" id"login-btn">Log In</button>
</form>
  • 다음 예제는 위와 같은 양식을 제공하는 사이트에 WebRequest/WebResponse를 이용해서 로그인하는 방법을 보여준다.
string loginUri = "http://www.somesite.com/login";
string username = "username";
string password = "password";
string reqString = "username=" + username + "&password=" + password;
byte[] requestData = Encoding.UTF8.GetBytes(reqString);

CookieContainer cc = new CookieContainer();
var request = (HttpWebRequest)WebRequest.Create(loginUri);
request.Proxy = null;
request.CookieContainer = cc;
request.Method = "POST";
request.ContentType = "application/x-www-form-urlencoded";
request.ContentLength = requestData.Length;

using (Stream s = reqeust.GetRequestStream())
  s.Write(requestData, 0, requestData.Length);

using (var response = (HttpWebResponse) request.GetResponse())
  foreach (Cookie c in response.Cookes)
    Console.WriteLine(c.Name + " = " + c.Value);

// 이제 로그인 되었다. 이후 cc를 WebRequest 객체에 배정하기만 하면 인증된 사용자의 자격으로 웹사이트의 자원에 접근할 수 있다.
  • 다음은 HttpClient의 경우이다.
string loginUri = "http://www.somesite.com/login";
string username = "username";
string password = "password";

CookieContainer cc = new CookeContainer();
var handler = new HttpClientHandler { CookieContainer = cc };

var request = new HttpRequestMessage(HttpMethod.Post, loginUri);
requst.Content = new FromUrlEncodedContent (new Dictionary<string, string>
{
  { "username", username },
  { "password", password }
});

var client = new HttpClient(handler);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
...

SSL

  • WebClient와 HttpClient, WebRequest는 URI에 “https:” 스킴이 있으면 자동으로 SSL을 사용한다. X.509 인증서에 문제가 있는 경우를 제외하면 SSL과 관련해서 문제가 발생할 일은 없다.
    • 어떤 이유로 (이를테면 시험용 인증서를 사용하는 등) 서버의 사이트 인증서가 유효하지 않으면, 그 서버와 통신을 시도할 때 예외가 발생한다. 이를 피하는 한 방법은 커스텀 인증서 유효성 검증 메서드를 만들어서 ServicePointManager 클래스의 한 정적 속성에 배정하는 것이다.
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
...

static void ConfigureSSL()
{
  ServicePointManager.ServerCertificateValidationCallback = CertChecker;
}
  • 대리자 형식의 속성인 ServerCertificateValidationCallbackdㅔ는 아래와 같은 서명에 부합하는 메서드를 배정해야 한다. 그 메서드가 true를 돌려주면 WebClient 등은 인증서가 승인된 것으로 간주하고 서버와의 통신을 진행한다.
static bool CertChecker(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
{
  // 인증서에 문제가 없으면 true를 돌려준다.
  ...
}

HTTP 서버 작성

  • HttpListener 클래스를 이용하면 나만의 .NET HTTP 서버를 만들 수 있다. 다음은 포트 51111을 청취하다가 클라이언트 요청이 들어오면 한 줄 짜리 응답을 돌려준 후 실행을 끝내는 아주 간단한 서버이다.
static void Main()
{
  ListenAsync();  // 서버를 시작한다.
  WebClient wc = new WebClient();  //클라이언트 요청을 만든다.
  Console.WriteLine(wc.DownloadString("http://localhost:51111/MyApp/Request.txt"));
}

async static void ListenAsync()
{
  HttpListener listener = new HttpListener();
  listener.Prefixes.Add("http://localhost:51111/MyApp/");  // 포트 5111을 청취한다.
  listener.Start();

  // 클라이언트의 요청을 기다린다.
  HttpListenerContext context = await listener.GetContextAsync();

  // 요청에 응답한다.
  string msg = "요청된 자원: " + context.Request.RawUrl;
  context.Response.ContentLength64 = Encoding.UTF8.GetByteCount(msg);
  context.Response.StatusCode = (int) HttpStatusCode.OK;

  using (Stream s = context.Response.OutputStream)
  using (StreamWriter writer = new StreamWriter(s))
    await writer.WriteAsync(msg);

  listener.Stop();  
}

// 출력
// 요청된 자원: /MyApp/Requst.txt
  • HttpListener는 내부적으로 .NET의 Socket 객체가 아니라 Windows의 HTTP 서버 API를 사용한다. 이 덕분에 한 컴퓨터의 여러 응용 프로그램이 동일한 IP 주소와 포트 번호 조합을 청취할 수 있다. 응용 프로그램들이 각자 다른 주소 접두사를 사용하기만 하면 된다.
    • 지금 예제는 http://localhost/myapp이라는 접두사를 등록한다. 따라서 이와는 다른, 이를테면 http://localhost/anotherapp 같은 접두사를 사용하는 다른 응용 프로그램ㅁ은 같은 IP 주소와 포트를 청취할 수 있다.
    • 기업 방화벽 환경에서는 여러 사내 보안 규칙 때문에 새 포트를 열기 어렵다는 점ㅇ르 생각하면 이러한 기능은 아주 소중하다.
  • GetContextAsync를 호출하면 HttpListener는 클라이언트의 요청을 기다리기 시작한다. 요청이 들어오면 Request라는 속성과 Response라는 속성이 있는 객체를 돌려준다. 이들은 각각 WebRequest 객체 및 WebResponse 객체의 서버 쪽 버전에 해당한다.
    • 이 요청 응답 객체들은 클라이언트 쪽 웹 요청/응답 객체들과 마찬가지 방식으로 사용할 수 있다. 이를테면 이들을 이용해서 HTTP 헤더들과 쿠키들을 읽고 쓸 수 있다.
  • 서버가 HTTP 프로토콜의 기능들을 어느 정도나 지원할 것인지는 서버에 접속할 클라이언트들의 요구에 따라 결정해야 할 것이다. 최소한의 요구사항으로 각 응답에 내용 길이(content length)와 샅애 부호(status code)만큼은 꼭 포함해야 한다.
  • 다음은 아주 간단한 웹 페이지 서버 클래스로 비동기적으로 작성한 것이다.
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;

class WebServer
{
  HttpListener _listener;
  string _baseFolder;

  public WebServer (string uriPrefix, string baseFolder)
  {
    _listener = new HttpListener();
    _listener.Prefixes.Add(uriPrefix);
    _baseFolder = baseFolder;
  }

  public async void Start()
  {
    _listener.Start();
    while (true)
    {
      try
      {
        var context = await _listener.GetContextAsync();
        Task.Run(() => ProcessRequestAsync(context);
      }
      catch (HttpListenerException) { break; }
      catch (InvalidOperationException) { break; }
    }
  }

  public void Stop() { _listener.Stop(); }

  async void ProcessRequestAsync (HttpListenerContext context)
  {
    try
    {
      string filename = Path.GetFileName(context.Request.RawUrl);
      string path = Path.Combine(_baseFolder, filename);
      byte[] msg;

      if (!File.Exist(path))
      {
        Console.WriteLine("자원을 찾지 못했음: " + path);
        context.Response.StatusCode = (int) HttpStatusCode.NotFound;
        msg = Encoding.UTF8.GetBytes("죄송합니다. 없는 페이지입니다.");
      }
      else
      {
        context.Response.StatusCode = (int) HttpStatusCode.OK;
        msg = File.ReadAllBytes(path);
      }

      context.Response.ContentLength64 = msg.Length;
      using (Stream s = context.Response.OutputStream)
        await s.WriteAsync(msg, 0, msg.Length);
    }
    catch (Exception ex) { Console.WriteLine("Request error: " + ex); }
  }
}
  • 다음은 이 서버 클래스를 사용하는 주 프로그램이다.
static void Main()
{
  // 포트 51111을 청취하면서 d:\webroot에 있는 파일들을 제공한다.
  var server = new WebServer("http://localhost:51111/", @"d:\webroot");

  try
  {
    server.Start();
    Console.WriteLine("서버 실행 중... Enter 키를 누르면 종료됩니다.");
    Console.ReadLine();
  }
  finally { server.Stop(); }
}
  • 임의의 웹 브라우저로 이 서버를 시험해 보기 바란다. http://localhost:51111/ 다음에 원하는 웹페이지 파일 이름을 붙인 URI를 요청하면 된다.
  • 만일 HttpListener가 청취하려는 포트를 다른 응용 프로그램이 Windows HTTP Server API 이외의 수단으로 이미 청취하고 있으면 HttpListener는 청취를 시작하지 않는다.
    • 예컨대 기본 포트 80은 다른 웹 서버나 P2P 프로그램(Skype 등)이 청취하고 있을 가능성이 있다.
  • 비동기 함수들을 사용한 덕분이 이 서버는 규모가변성과 효율성이 좋다. 그러나 이 서버를 UI 스레드에서 띄우면 규모가변성이 나빠진다. 요청이 들어올 때마다 await 이후에 실행이 UI 스레드로 복귀하기 때문이다.
    • 이 예제의 경우 서버와 UI 상에서 상태를 공유할 필요가 없다는 점을 생각해보면 그런 추가부담은 특히나 무의미하므로 UI가 있는 응용 프로그램에서는 다음처럼 서버를 UI 스레드에서 떼어 내는 것이 낫다.
Task.Run(Start);
  • 또는 GetContextAsync를 호출한 후 ConfigureAwait(false)를 호출해도 같은 결과가 난다.
  • ProcessRequestAsync 자체가 비동기 함수지만 그래도 Task.Run을 이용해서 ProcessRequest를 실행한다는 점도 주목하기 바란다. 이렇게 하면 호출자는 메서드의 동기적 부분이 완료될 때까지 기다리지 않고 (첫 await까지) 즉시 다른 요청을 실행할 수 있다.

FTP 활용

  • 간단하게 FTP 업로드/다운로드 연산은 이전에 나온 예처럼 그냥 WebClient를 사용하면 된다.
WebClient wc = new WebClient { Proxy = null };
wc.Credentials = new NetworkCredential ("nutshell", "oreilly");
wc.BaseAddress = "ftp://ftp.albahari.com";
wc.UploadString("tempfile.txt", "hello!");
Console.WriteLine(wc.DownloadString("tempfile.txt"));  // hello!
  • 그러나 파일을 보내고 받는 것이 FTP의 전부는 아니다. 이 프로토콜은 그 외에도 여러 명령 또는 ‘메서드’들을 지원하는데 WebRequestMethods.Ftp 클래스에 그 명령들에 대응되는 다음과 같은 문자열 상수들이 정의되어 있다.
AppendFile ListDirectoryDetails Rename
DeleteFile ListDirectory UploadFile
DownloadFile MakeDirectory UploadFileWithUniqueName
GetDateTimestamp PrintWorkingDirectory
GetFileSize RemoveDirectory

 

  • 이런 명령들을 실행하려면 원하는 명령의 문자열 상수를 웹 요청 객체의 Method 속성에 배정한 후 GetResponse를 호출하면 된다. 다음은 디렉터리 내용을 나열하는 예이다.
var req = (FtpWebRequest) WebRequest.Create("ftp://ftp.albahari.com");
req.Proxy = null;
req.Credentials = new NetworkCredential("nutshell", "oreilly");
req.Method = WebRequestMethods.Ftp.ListDirectory;

using (WebResponse resp = req.GetResponse())
using (StreamReader reader = new StreamReader(resp.GetResponseStream())
  Console.WriteLine(reader.ReadToEnd());

// 출력
// .
// ..
// guestbook.txt
// tempfile.txt
// test.doc
  • 이처럼 디렉터리를 나열할 때는 결과를 얻으려면 응답 스트림을 읽어야 한다. 그러나 다른 대부분의 명령에는 그러한 과정이 필요하지 않다. 예컨대 GetFileSize 명령의 결과는 그냥 응답 객체의 ContentLength 속성으로 알 수 있다.
var req = (FtpWebRequest) WebRequest.Create("ftp://ftp.albahari.com/tempfile.txt");
req.Proxy = null;
req.Credentials = new NetworkCredential("nutshell", "oreilly");
req.Method = WebRequestMethods.Ftp.GetFileSize;

using (WebResponse resp = req.GetResponse())
  Console.WriteLine(resp.ContentLength);  // 6
  • GetDateTimestamp 명령도 비슷한 방식으로 작동한다. 결과를 알려면 응답 객체의 LastModified 속성을 봐야 한다는 점이 다를 뿐이다. 이 속성에 접근하려면 응답 객체를 FtpWebResponse로 캐스팅 해야 한다.
...
req.Method = WebRequestMethods.Ftp.GetDateTimestamp;

using (WebResponse resp = req.GetResponse())
  Console.WriteLine(resp.LastModified);
  • Rename 명령을 사용하려면 요청 객체의 RenameTo 속성에 원하는 새 이름을 지정해야 한다(경로 접두사 없이 파일 이름만) 예컨대 다음은 incoming 디렉터리의 tempfile.txt 파일을 deleteme.txt로 바꾼다.
var req = (FtpWebRequest) WebRequest.Create("ftp://ftp.albahari.com/tempfile.txt");
req.Proxy = null;
req.Credentials = new NetworkCredential("nutshell", "oreilly");
req.Method = WebRequestMethods.Ftp.Rename;
req.RenameTo = "deleteme.txt";
req.GetResponse().Close();  // 이름 바꾸기를 수행한다.
  • 다음은 파일을 삭제하는 방법을 보여주는 예이다.
var req = (FtpWebRequest) WebRequest.Create("ftp://ftp.albahari.com/tempfile.txt");
req.Proxy = null;
req.Credentials = new NetworkCredential("nutshell", "oreilly");
req.Method = WebRequestMethods.Ftp.DeleteFile;
req.GetResponse().Close();  // 삭제를 수행한다.
  • 위 예제들에서는 예외처리 블록을 생략했지만 실제 응용에서는 네트워크 오류와 프로토콜 오류를 잡는 예외처리부를 두는 것이 바람직하다. 이를 위한 전형적인 catch 블록은 다음과 같은 모습이다.
catch (WebException ex)
{
  if (ex.Status == WebExceptionStatus.ProtocolError)
  {
    // 좀 더 상세한 오류 정보를 얻는다.
    var response = (FtpWebResponse) ex.Response;
    FtpStatusCode errorCode = response.StatusCode;
    string errorMessage = response.StatusDescription;
    ...
  }
  ...
}

DNS 사용

  • 정적 Dns 클래스는 66.135.192.87 같은 가공되지 않은 IP 주소와 ebay.com 같은 사람이 기억하기 쉬운 도메인 이름 사이의 변환을 위한 DNS(Domain Name Service) 기능을 캡슐화한다.
  • GetHostAddress 메서드는 도메인 이름을 IP 주소(또는 주소들)로 변환한다.
foreach (IPAddress a in Dns.GetHostAddresses("albahari.com"))
  Console.WriteLine(a.ToString());  // 205.210.42.167
  • GetHostEntry 메서드는 그 반대의 일을 한다. 즉, IP 주소를 도메인 이름으로 변환한다.
IPHostEntry entry = Dns.GetHostEntry("205.210.42.167");
Console.WriteLine(entry.HostName);  // albahari.com
  • 문자열 대신 IPAddress 객체를 받도록 중복적재된 GetHostEntry도 있다. 이를 이용해서 다음처럼 바이트 배열로 IP 주소를 지정할 수 있다.
IPAddress address = new IPAddress(new byte[] { 205, 210, 42, 167 });
IPHostEntry entry = Dns.GetHostEntry(address);
Console.WriteLine(entry.HostName);  // albahari.com
  • WebRequest나 TcpClient 같은 클래스를 사용할 때는 도메인 이름에서 IP 주소로의 변환이 자동으로 일어난다.
    • 그러나 만일 응용 프로그램이 같은 주소에 다수의 네트워크 요청을 보내야 한다면, 먼저 Dns를 이용해서 명시적으로 도메인 이름을 IP 주소로 한 번만 변환해 두고 이후의 통신에서는 그 주소를 재사용하는 것이 성능 향ㅅ아에 도움이 될 수 있다.
    • 그렇게 하면 같은 도메인 이름 조회를 위해 DNS 서버와의 왕복 통신을 거듭 되풀이하지 않아도 된다. TcpClient나 UdpClient, Socket으로 전송 계층을 직접 다룰 때에도 이런 방법이 성능 향상에 도움이 될 수 있다.
  • Dns 클래스는 대기 가능한 작업 객체 기반 비동기 메서드들도 제공한다.
foreach (IPAddress a in await Dns.GetHostAddressesAsync("albahari.com"))
  Console.WriteLine(a.ToString());

SmtpClient로 메일 보내기

  • System.Net.Mail 이름공간의 SmtpClient 클래스는 널리 쓰이는 SMTP(Simple Mail Transfer Protocol)를 이용해서 메일을 보내는 기능을 제공한다. 간단한 텍스트 메시지를 보내려면 SmtpClient 인스턴스를 하나 생성해서 Host 속성에 독자가 사용하는 SMTP 서버의 주소를 설정한 후 Send를 호출하면 된다.
SmtpClient client = new SmtpClient();
client.Host = "mail.myisp.net";
client.Send("from@adomain.com", "to@adomain.com", "subject", "body");
  • 스팸 방지를 위해 인터넷에 있는 대부분의 SMTP 서버는 ISP 가입자에서 온 연결만 받아들인다. 따라서 이 예제를 실제로 실행하려면 현재 인터넷 연결에 맞는 SMTP 주소를 지정해야 한다.
  • 첨부 파일 등의 고급 기능을 위해서는 MailMessage 객체를 만들어야 한다.
SmtpClient client = new SmtpClient();
client.Host = "mail.myisp.net";

MailMessage mm = new MailMessage();
mm.Sender = new MailAddress("kay@domain.com", "Kay");
mm.From = new MailAdress("kay@domain.com", "Kay");
mm.To.Add = new MailAdress("bob@domain.com", "Bob");
mm.CC.Add = new MailAdress("dan@domain.com", "Dan");
mm.Subject = "Hello!";
mm.Body = "Hi there. Here's the photo!";
mm.IsBodyHtml = false;
mm.Priority = MailPriority.High;

Attachment a = new Attachment("photo.jpg", System.Net.Mime.MediaTypeNames.Image.Jpeg);
me.Attachments.Add(a);
client.Send(mm);
  • SmtpClient 에는 다양한 속성이 있다. 예컨대 인증을 요구하는 서버를 위해 신원 정보를 제공하려면 Credentials 속성을, SSL을 활성화 하려면(서버가 지원하는 경우) EnableSsl 속성을, 표준 포트가 아닌 포트를 사용하려면 Port 속성을 사용하면 된다. 그리고 DeliveryMethod라는 속성도 있는데, 이 속성을 이용하면 SmtpClient가 SMTP 서버 대신 IIS를 이용해서 메일을 보내게 하거나 다음처럼 메일 메시지를 지정된 디렉터리에 .eml 파일로 기록하게 할 수 있다.
SmtpClient client = new SmtpClient();
client.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;
client.PickupDirectoryLocation = @"c:\mail";

TCP 사용

  • TCP와 UDP는 대부분의 인터넷 및 LAN 서비스들이 기반으로 삼는 전송 계층 프로토콜들이다. HTTP와 FTP, SMTP는 TCP를 사용하고 DNS는 UDP를 사용한다.
    • TCP는 연결 지향적이며 신뢰성 메커니즘을 포함하고 있다. UDP는 무연결 방식이고 부하가 적으며 방송(broadcasting) 즉 불특정 다수에 대한 일대다 통신을 지원한다. BitTorrent가 UDP를 사용하며 VoIP도 UDP를 사용한다.
  • 전송 계층을 직접 다루면 그 위의 계층들을 사용할 때보다 훨씬 더 유연한 처리가 가능하며, 더 나은 성능을 얻을 여지도 생긴다. 단, 인증이나 암호화 같은 작업을 프로그램이 직접 처리해야 한다.
  • .NET Framework에서 TCP를 활용할 떄는 두 가지 선택이 가능하다. 사용하기 쉬운 TcpClient와 TcpListener 퍼사드 클래스들을 선택할 수도 있고, 기능이 많은 Socket 클래스를 선택할 수도 있다. (TcpClient가 Client 속성을 통해서 바탕 Socket 객체를 노출하므로 사실 둘을 섞어 쓰는 것도 가능하다.)
    • Socket 클래스는 더 많은 구성 옵션들을 제공하며, 네트워크 계층(IP)과 Novell의 SPX/IPX처럼 인터넷 기반이 아닌 프로토콜들에도 직접 접근할 수 있다.
  • 다른 프로토콜들처럼 TCP는 클라이언트와 서버를 구분한다. 클라이언트는 요청을 제출하고, 서버는 클라이언트의 요청을 기다린다.
    • TCP 클라이언트가 동기적으로 요청을 보내는 코드는 대체로 다음과 같은 형태이다.
using (TcpClient client = new TcpClient())
{
  client.Connect("address", port);
  using (NetworkStream n = client.GetStream())
  {
    // 네트워크 스트림을 읽고 쓴다.
  }
}
  • TcpClient의 Connect 메서드는 연결이 확립될 때까지 차단된다(차단되지 않는 비동기 버전은 ConnectAsync이다). 일단 연결이 된 후에는 GetStream이 돌려준 네트워크 스트림(NetworkStream 객체)을 양방향 통신 수단으로 사용해서 서버에 바이트들을 보내거나 서버가 보낸 바이트들을 받으면 된다.
  • 이에 대응되는 간단한 TCP 서버는 다음과 같은 모습이다.
TcpListener listener = new TcpLIstener(<IP 주소>, <포트 번호>);
listener.Start();

while (keepProcessingRequests)
{
  using (TcpClient c= listener.AcceptTcpClient())
  using (NetworkStream n = c.GetStream())
  {
    // 네트워크 스트림을 읽고 쓴다.
  }
}

listener.Stop();
  • TcpListener의 생성자는 요청을 기다릴 지역 IP 주소와 포트 번호를 받는다. 예컨대 네트워크 카드가 두 개 설치된 컴퓨터는 IP 주소도 두 개 이므로, 이처럼 IP 주소를 지정해야 한다.
    • 모든 지역 IP 주소를(또는 지역 IP 주소들만) 청취하고 싶으면 IPAddress.Any를 지정하면 된다.
    • AcceptTcpClient는 클라이언트 요청이 들어올 떄까지 차단된다(이 메서드에도 비동기 버전이 존재한다.)
    • 일단 요청이 들어오면 클라이언트 쪽에서처럼 GetStream으로 얻은 네트워크 스트림으로 통신을 진행하면 된다.
  • 전송 계층을 다룰 때는 누가 언제 얼마나 오래 이야기할 것인지에 대한 규약, 즉 프로토콜을 정해야 한다. 이는 마치 무전기로 대화 하는 것과 비슷하다. 양쪽이 동시에 말하거나 들으려 하면 대화가 제대로 진행되지 않는다.
  • 그럼 클라이언트가 “Hello”라고 말하면 서버가 “Hello right back!”이라고 응답하는 간단한 프로토콜 예제를 보자.
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;

class TcpDemo
{
  static void Main()
  {
    new Thread(Server).Start();  // 서버 메서드도 동시에 실행한다.
    Thread.Sleep(500);  // 서버가 시동할 시간을 준다.
    Client();
  }

  static void Client()
  {
    using (TcpClient client = new TcpClient("localhost", 51111))
    using (NetworkStream n = client.GetStream())
    {
      BinaryWriter w = new BinaryWriter(n);
      w.Write("Hello");
      w.Flush();
      Console.WriteLine(new BinaryReader(n).ReadString());
    }
  }

  static void Server()  // 클라이언트 요청 하나만 처리한 후 종료한다.
  {
    TcpListener listener = new TcpListener(IPAddress.Any, 51111);
    listener.Start();

    using (TcpClient c = listener.AcceptTcpClient())
    using (NetworkStream n = c.GetStream())
    {
      string msg = new BinaryReader(n).ReadString();
      BinaryWriter w = new BinaryWriter(n);
      w.Write(msg + " right back!");
      w.Flush();  // 기록자를 처분하지 않으므로 반드시 Flush를 호출해야 한다.
    }
    listener.Stop();
  }
}
  • 이 예제는 루프백 주소인 localhost를 사용해서 클라이언트와 서버를 같은 컴퓨터에서 돌린다. 포트 번호 51111은 다른 용도로 할당되지 않은 범위(49152 이상)에서 임의로 고른 것이다.
    • 텍스트 메시지를 부호화하는데는 BinaryWriter와 BinaryReader를 사용한다.
    • 대화가 완료될 때까지 바탕 NetworkStream을 열어 두기 위해, 루프에서 이진 기록자와 판독자들을 닫거나 처분하지 않는다.
  • 문자열을 읽고 쓰는데 BinaryReader와 BinaryWriter를 사용한다는 것이 좀 이상할 수도 있겠다. 그러나 StreamReader와 StreamWriter보다 이들을 사용하는 것이 더 낫다.
    • 왜냐하면 BinaryWriter는 문자열을 보낼 때 먼저 문자열 길이에 해당하는 정수를 보내며, BinaryReader는 그 정수를 보고 정확한 개수의 문자들을 읽기 때문이다.
    • 사실 네트워크 스트림에 대해 StreamReader.ReadToEnd를 호출하면 호출이 무한정 차단될 수 있다. NetworkStream에는 스트림의 끝이라는 것이 정해져 있지 않기 때문이다. 연결이 열려 있는 한 언제라도 클라이언트가 또다시 자료를 보낼 수 있으므로 네트워크 스트림에 대해서는 ‘끝’이라는 것을 가정하지 말아야 한다.
  • 실제로 StreamReader와 NetworkStream의 조합은 단순히 ReadLine을 호출하는 경우에도 문제를 일으킨다.
    • StreamReader에는 미리 읽기 버퍼가 있는데, ReadLine 호출 시 StreamReader가 그 버퍼를 채우기 위해 요청된 것보다 더 많은 바이트를 미리 읽으려 할 수 있으며, 그러면 호출이 무한정(또는 소켓의 시간 만료가 발생할 때까지) 차단된다.
    • FileStream 같은 스트림에서는 StreamReader와 같은 문제가 발생하지 않는다. 그런 스트림은 끝이 명확하며, 스트림의 끝에 도달하면 Read가 0을 돌려주기 때문이다.

TCP와 동시성

  • 규모가변적인 동시성을 위해 TcpClient와 TcpListener는 작업 기반 비동기 메서드들을 제공한다.
    • 이 메서드들을 사용하는 것은 쉽다. 그냥 동기적 (차단되는) 메서드들을 해당 *Async 버전으로 대체하고, 반환된 작업 객체에 대해 await를 적용하면 그만이다.
    • 다음은 클라이언트가 보낸 5,000바이트 자료를 받아서 그 바이트들의 순서를 뒤집은 결과를 다시 돌려주는 TCP 서버의 예이다.
async void RunServerAsync()
{
  var listener = new TcpListener(IPAddress.Any, 51111);
  listener.Start();
  try
  {
    while (true)
      Accept(await listner.AcceptTcpClientAsync());
  }
  finally { listener.Stop(); }
}

async Task Accept (TcpClient client)
{
  await Task.Yield();
  try
  {
    using (client)
    using (NetworkStream n = client.GetStream())
    {
      byte[] data = new byte[5000];

      int bytesRead = 0; int chunkSize = 1;
      while (bytesRead < data.Length && chunkSize > 0)
        bytesRead += chunkSize = await n.ReadAsync(data, bytesRead, data.Length - bytesRead);
      
      Array.Reverse(data);  // 바이트 열을 뒤집는다.
      await n.WriteAsync(data, 0, data.Length);
    }
  }
  catch (Exception ex) { Console.WriteLine (ex.Message); }
}
  • 하나의 요청을 다 처리할 떄까지 스레드를 차단하지는 않는다는 점에서 이 서버는 규모가변성이 좋다고 할 수 있다. 예컨대 천 개의 클라이언트가 느린 네트워크 연결을 통해 동시에 연결해도(그리고 네트워크 연결이 느려서 하나의 요청을 완전히 처리하는데 몇 초가 걸린다고 해도) 그 요청들을 모두 처리하는 내내 1,000개의 스레드를 점유하지는 않는다(동기적 버전이라면 그래야 할 것이다.) 대신 이 서버는 await 표현식들 이전과 이후의 코드를 실행하는데 필요한 시간 동안만 스레드들을 사용한다.

TCP로 POP3 메일 수신

  • .NET Framework에는 POP3을 위한 응용 계층 클래스들이 없다. 따라서 POP3 메일서버에서 메일을 가져오려면 TCP 계층에서 직접 서버와 통신해야 한다. 다행히 POP3은 간단한 프로토콜이다. POP3을 통한 대화는 다음과 같이 진행된다.
클라이언트 메일 서버 참고
클라이언트가 연결한다… +OK Hello there. 환영 메시지
USER joe +OK Password required
PASS password +OK Logged in.
LIST +OK
1 1876
2 5412
3 845
.
서버에 있는 각 메시지의 ID와 파일 크기를 나열
RETR 1 +OK 1876 octets
1번 메시지 내용…
지정된 ID에 해당하는 메시지를 수신
DELE 1 +OK Deleted 서버에서 메시지를 삭제
QUIT +OK Bye-bye

 

  • 각각의 명령과 응답은 새 줄(CR+LF)로 끝난다. 단, 여러 줄로 이루어진 LIST와 RETR 명령의 응답은 마침표 하나로 된 줄로 끝난다. NetworkStream에는 StreamReader를 사용할 수 없으므로, 우선 버퍼링 없이 텍스트 한 줄을 읽는 보조 메서드를 작성하는 것으로 시작하자.
static string ReadLine(Stream s)
{
  List<byte> lineBuffer = new List<byte>();
  while (true)
  {
    int b = s.ReadByte();
    if (b == 10 || b < 0) break;
    if (b != 13) lineBuffer.Add((byte)b);
  }
  return Encoding.UTF8.GetString(lineBuffer.ToArray());
}
  • 또한 명령을 보내는데 사용할 보조 메서드도 하나 만들어 두기로 한다. 명령에 대한 응답은 항상 “+OK”로 시작하므로, 서버가 보낸 자료를 읽은 즉시 그것이 유효한 응답인지 확인할 수 있다.
static void SendCommnad(Stream stream, string line)
{
  byte[] data = Encoding.UTF8.GetBytes(line + "\r\n");
  stream.Write(data, 0, data.Length);
  string response = ReadLine(stream);
  if (!response.StartsWith("+OK"))
    throw new Exception("POP Error: " + response);
}
  • 이런 메서드들을 갖추고 나면, 메일을 가져오는 작업 자체는 간단하다. 다음의 예제 클라이언트는 110번 포트(POP3의 기본 포트)에 TCP로 연결해서 대화를 시작한다. 클라이언트는 서버로부터 각 메시지를 확장자가 .eml인 파일(이름은 무작위로 설정)에 저장한 후 서버에 있는 메시지를 삭제한다.
using (TcpCLient client = new TcpClient("mail.isp.com", 110))
using (NetworkStream n = client.GetStream())
{
  ReadLine(n);  // 환영 메시지를 읽는다.
  SendCommand(n, "USER username");
  SendCommand(n, "PASS password");
  SendCommand(n, "LIST");  // 메시지 ID들을 가져온다.

  List<int> messageIDs = new List<int>();
  while (true)
  {
    string line = ReadLine(n);  // 이를테면 "1 1876"
    if (line == ".") break;
    messageIDs.Add(int.Parse(line.Split(' ')[0]));  // 메시지 ID
  }

  foreach (int id in messagIDs)  // 각 메시지를 가져온다.
  {
    SendCommand(n, "RETR " + id);
    string randomFile = Guid.NewGuid().ToString() + ".eml";

    using (StreamWriter writer = File.CreateText(randomFile))
    {
      string line = ReadLine(n); // 메시지의 다음 줄을 읽는다.
      if (line == ".") break;  // 마침표 하나이면 메시지의 끝이다.
      if (line == "..") line = ".";  // 이중 마침표 탈출열을 복원한다.
      writer.WriteLine(line);  // 파일에 기록한다.
    }
    SendCommand(n, "DELE " + id);  // 서버에 있는 메시지를 삭제한다.
  }
  SendCommand(n, "QUIT");
}

WinRT의 TCP

  • (생략)
[ssba]

The author

지성을 추구하는 디자이너/ suyeongpark@abyne.com

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.