1.0 파일 다운로드 후 로컬에 저장하기

브라우저가 파일을 어떻게 처리할지 결정하는 것은 확장자가 아니라 서버가 보낸 MIME 타입이었다. 이미지 파일 링크를 브라우저에서 열었을 떄, image/png라면 이미지 파일로 보고 브라우저에서 표시한다. PDF 파일도 인라인 표시가 된다. 이것이 기본 동작이다.

 

예를 들어, 서버 응답에 Content-Disposition 헤더가 있으면, 브라우저는 다운로드 대화상자를 표시하고 파일을 저장한다. filename으로 지정된 파일명이 다운로드 대화 상자에 기본값으로 표시된다.

Content-Disposition: attachment; filename=filename.xlsx

 

Content-Disposition 헤더를 이용한 다운로드 기능은 HTTP를 위해 만들어진 것은 아니다. 이메일의 첨부 파일을 위해 RFC 1806에서 정의된 규격이다. HTTP/1.0과 초기 버전 HTTP/1.1(RFC 2068)에는 없었고, 2년 후 개정된 HTTP/1.1 (RFC 2616)에서 언급되었다. HTTP의 RFC 규격에는 없었지만, 몇몇 브라우저에서 이미 구현된 이 기능은 첫 HTTP/1.0이 나오고 15년 후인 2011년에 RFC 6266에서 정식 사양으로 정해졌다.

 

 

2.0 다운로드 중단과 재시작

파일 크기가 크면 클 수록 다운로드 시간이 오래 걸리고, 다운로드 시간이 길어지면 통신이 불안정해지거나 다운로드 도중에 실패할 확률이 높아진다. 요즘 회선 환경에서는 실패하지 않고 다운로드를 마치는 경우가 많겠지만, 중단된다면 중단된 지점부터 다시 시작하는 방법을 제공한다. 오리지널 사양은 RFC 2616, 최신 사양은 RFC 7233이다. 

 

재시작은 지정한 범위를 잘라내 다운로드 한다는 뜻이다. 서버가 범위 지정 다운로드를 지원하는 경우는 Accept-Ranges 헤더를 응답에 부여한다.

Accept-Ranges: bytes

위는 bytes 단위로 범위 지정 다운로드를 하는 것이다. bytes와 none만이 존재하는데, none은 범위 지정을 하지 않는다. 범위 지정을 하는 경우

HTTP/1.1 206 Partial Content

Content-Length: 1000
Content-Ranges: 1000-1999/5000

위와 같은 형식으로 원하는 범위를 받을 수 있다. 위는 응답헤더이고 요청 헤더는 아래와 같다.

Range: bytes=1000-1999

 

 

2.2 병렬 다운로드

서버가 세션마다 대역을 제한할 경우, 영역을 나눠 세션마다 Range 헤더를 이용해 HTTP 접속을 하는 것으로 병렬로 다운로드할 수 있다. 다운로드한 데이터 조각을 이후에 결합하면, 전체 다운로드 시간이 줄어든다. 

 

다만 병렬 다운로드는 서버에 지나치게 부단을 주기 때문에 별로 권장되진 않는다. 브라우저의 경우는 같읍 웹사이트에 대한 동시 접속 수를 2~6으로 제한하고 있다. 요즘은 정적 파일을 캐시해 서버에 부단을 주지 않고 배포하는 CDN이 보급 돼, 다운로드가 몰려 속도가 떨어지는 일도 줄어들었다. 비교적 파일 크기가 큰 파일로는 동영상이 있지만, 동영상은 일부만 미리 읽어와도 재생이 시작되므로 모든 콘텐츠가 다운로드 될 때까지 기다릴 필요가 없다.

 

 

3.0 XMLHttpRequest

지금까지 소개한 다운로드 기능을 자바스크립트로 사용할 수 있게 해주는 기능이 XMLHttpRequest이다. XMLHttpRequest는 HTTP 통신과 마찬가지로 클라이언트가 서버에 요청을 보내고, 응답으로 서버가 클라이언트에 데이터를 보낼 수 있는 것이다. 헤더를 송수신할 수 있고, 캐시 제어나 쿠키 송수신 등 변함이 없다. FormData 클래스를 사용하면, multipart/form-data 형식으로 파일 등을 보낼 수도 있다. 단, HTTP처럼 서버 측에서 클라이언트에 요청을 보낼 수는 없다. 

var xhr = new XMLHttpRequest();
xht.open("GET", "/json", true);
xht.onload = function() {
	// 응답이 들어왔을 때 호출되는 메서드
    if (xhr.status == 200) {
		// JSON 파싱해서 표시
        console.log(JSON.parse(xhr.responseText));
    }
};
xhr.setRequestHeader("MyHeader", "HeaderValue");
xhr.send();
curl -H "MyHeader=HeaderValue" /json

과 같은 의미이다. open에서 세번 째 파라미터를 true로 주면 비동기로 처리가 된다. 동기 실행일 경우 응답이 돌아올 때까지 send() 메서드가 끝나지 않게된다.

 

 

3.1 XMLHttpRequest와 브라우저의 HTTP 요청 차이

  • 송수신할 때 HTML 화면이 새로고침되지 않는다. (HTTP의 경우 응답이 올 때, 새로운 페이지가 렌더링 됌)
  • GET과 POST 이외의 메서드도 전송할 수 있다.
  • 폼의 경우 키와 값이 일대일이 되는 형식의 데이터만 전송할 수 있고, 응답은 브라우저로 표시되어 버리지만, 플레인 텍스트, JSON, 바이너리 데이터, XML 등 다양한 형식을 송수신할 수 있다.
  • 몇 가지 보안상 제약이 있다. (이후 설명)

 

 

4.0 지오로케이션

클라이언트의 물리적 위치에 기반한 서비스를 접해본 경험이 있을 것이다. 물리적 위치를 측정하는 데는 2가지가 있다.

1. 클라이언트 자신이 측정해서 서버에 보냄

2. 서버가 클라이언트 위치를 추측

 

4.1 클라이언트 자신이 위치를 구하는 방법

최근 모던 브라우저는 지오로케이션 API를 제공한다. 예를들어, 스마트폰이라면 내장된 GPS나 기지국 정보를 활용해 위치 정보를 알려줄 수 있다. GPS가 없는 컴퓨터 경우 와이파이 등을 이용한 위치 측정으로 대략적인 위치를 추측해서, 위도와 경도를 알려줄 수 있다. 하지만, 위치 정보는 사생활과 직결되므로, 사용자가 허락한 경우에만 위치 정보를 사용할 수 있다. (App에서 위치 정보 제공 여부를 묻는 이유) 

 

와이파이 자체에는 GPS가 없으므로 와이파이에서 위치 정보를 알아내는 것은 조금 교묘하고 대규모 방식으로 이루어진다. 우선 와이파이 엑세스 포인트의 고유 ID(BBSID)와 위도 경도 정보를 데이터베이스로 사전에 구축해둔다. 클라이언트는 OS의 API를 이용해 현재 자신이 액세스할 수 있는 액세스 포인트의 BSSID를 가져와 서버에 문의해 위도와 경도를 조회한다.

 

BSSID는 와이파이 기기 식별자의 48비트 수치로, 기기마다 독특한 수치로 되고 있다. 맥 주소와 같은 것이라고 한다.

 

스마트폰은 스스로 위도와 경도를 측정하고 와이파이에 접속할 수 있으므로, 차로 이동하지 않아도 스마트폰을 이용해 위치 정보를 수집할 수 있다. 스마트폰을 이용하여 위치 정보를 수집하는 방법은 클라우드소싱이라고 불리며, 2008년경 W3C 지오로케이션 API로서 규격화 됐다.

 

 

4.2 서버가 클라이언트 위치를 추측하는 방법

지오 IP라고 불리는 IP 주소로 추측하는 방법이다. IP 주소는 지역마다 등록 관리 기관이 있어, 기업이나 프로바이더 등에 IP 주소를 할당한다. 그렇다고 등록 기관에 문의하면 되는가? 라고 묻는다면 등록 기관은 정확한 장소까지는 관리하지 않는다. 이 방식도 지오로케이션처럼 꾸준히 모은 데이터를 바탕으로 위치 정보를 알려주는 서비스로 맥스마인드, ip2location, ipligence, 도코도코JP가 있다.

 

 

6.0 원격 프로시저 호출

프로시저는 각 언어가 제공하는 함수, 클래스 메서드와 같은 것 을 뜻한다. 

 

RPC (원격 프로시저 호출)이란 것은 다른 컴퓨터에 있는 기능을 마치 자신의 컴퓨터 안에 있는 것처럼 호출하고, 필요에 따라 반환값을 받는 구조이다. 원격 메서드 호출(RMI)이라고 불리는 경우도 있다.

 

RPC의 역사는 1980년대까지 거슬러 올라간다. RPC에는 다양한 방식이 있다. 인터넷의 확산과 함께 HTTP를 기반으로 하는 RPC가 몇 종류 등장했다.

 

 

6.1 XML-RPC

최초로 규격화된 RPC는 XML-RPC이다. 유저랜드 소프트웨어와 마이크로소프트가 1998년에 개발했다. XML-RPC의 규격은 xmlrpc.com에 있다. XML-RPC 자체는 RFC화되지 않았지만, XML-RPC를 바탕으로 한 RFC가 있다.(RFC 3529)

 

XML-RPC가 만들어진 무렵 HTTP/1.1이 있었지만, 사양 요청 예제에는 HTTP/1.0으로 적혀있었다. Content-Length를 명시해야 했기때문에, HTTP/1.1의 청크 방식은 지원하지 않고 단순한 프로토콜 위에 구축됐다. 전송에 사용하는 method는 POST이고 인수, 반환값 모두 XML으로 표현하기 때문에, Content-Type은 항상 text/xml 이다. GET은 캐시될 가능성이 있기때문에 RPC 통신에는 적합하지 않다.

 

 

6.2 SOAP

SOAP는 XML-RPC를 확장해서 만들어진 규격이다. 2016년 웹의 아키텍처로서 마이크로 서비스가 자주 화제로 오르지만, SOAP는 10년 전쯤 자주 화제가 됐고, 서비스 지향 아키텍처 안에서 큰 역할을 했었다.

 

SOAP는 W3C에서 규격화됐다. W3C 사이트에 1.1 사양이 게재된 것은 XML-RPC에서 불과 2년 만인 2000년 이다. 이때 Java의 J2EE가 발흥하기도 했고, 업계 전체가 웹 애플리케이션으로 방향을 바꾼 시기이다. 

SOAP 메시지 구조

SOAP는 단순한 RPC였던 XML-RPC보다는 복잡하게 되어있다. SOAP 자체는 데이터 표현 형식으로, SOAP 규격 안에 SOAP를 사용한 RPC인 SOAP-RPC도 정의되어 있다. 메시지 구조를 보면 HTTP와 같다. HTTP 안에 미니 HTTP와 같은 구조로 되어있다. 이로써 HTTP 이외에도 메일 전송 프로토콜(SMTP)를 써서 SOAP 메시지를 주고받을 수도 있다. 헤더에는 요청의 메서드나 트랜잭션 정보를 기술하고, 엔벨로프에는 데이터가 들어간다. 

POST /Reservations HTTP/1.1
Host: travelcompany.example.org
Content-Type: application/soap+xml; charset="utf-8"
Content-Length: nnnn

<?xml version='1.0' ?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope" >
 <env:Header>
   <t:transaction
           xmlns:t="http://thirdparty.example.org/transaction"
           env:encodingStyle="http://example.com/encoding"
           env:mustUnderstand="true" >5</t:transaction>
 </env:Header>  
 <env:Body>
  <m:chargeReservation
     env:encodingStyle="http://www.w3.org/2003/05/soap-encoding"
          xmlns:m="http://travelcompany.example.org/">
   <m:reservation xmlns:m="http://travelcompany.example.org/reservation">
    <m:code>FT35ZBQ</m:code>
   </m:reservation>
    <o:creditCard xmlns:o="http://mycompany.example.com/financial">
     <n:name xmlns:n="http://mycompany.example.com/employees">
           Åke Jógvan Øyvind
     </n:name>
     <o:number>123456789099999</o:number>
     <o:expiration>2005-02</o:expiration>
    </o:creditCard>
   </m:chargeReservation
  </env:Body>
</env:Envelope>

샘플은 W3C 사이트에서 인용했다. 딱 봐도 복잡해 보인다... 이후에 공부할 일이 있다면 읽어보면 좋을 듯 하다.

 

 

6.3 JSON-RPC

JSON-RPC는 XML-RPC의 XML 대신 JSON을 이용한 RPC이다. 복잡한 SOAP와 대조적으로 단순하게라는 방침으로 내세웠다. XML-RPC처럼 IETF와 W3C가 아닌 jsonrpc.org라는 자체 사이트에 사양을 게재하고 있다. JSON-RPC는 HTTP 이외에 TCP/IP 소켓 등을 사용하는 것도 가정하고 있어, 사양이 최대공약수적으로 기술되어 있다. 단순함을 추구하면서 몇 가지 XML-RPC와 다른 기능도 갖추고 있다.

 

기본 응답은 아래와 같다. --> 부분이 request, <--부분이 response이다. 링크를 참고했다.

--> {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 3}
<-- {"jsonrpc": "2.0", "result": 19, "id": 3}

request와 response를 보면, json형식으로 간결하게 표현하는 것을 볼 수 있다. 책에서는 request 시 필요한 헤더로 Content-Type, Content-Length, Accept 헤더가 필요하다고 명시되어 있지만, jsonrpc.org에는 명시되어 있지 않다. 다만, json obejct의 경우는 지켜야 한다.

---gRPC는 소개하지 않는듯하다.

 

 

7.0 WebDAV

WebDav는 HTTP/1.1에 포함되진 않지만, 이 시기에 만들어졌고 수많은 환경에서 지원되고 있다.

 

WebDAV는 HTTP를 확장해 분산 파일 시스템으로 사용할 수 있게 한 기술로, 마이크로소프트가 개발해 1999년에 RFC 2518로 책정됐다. 현재는 RFC 4918로 갱신돼, 관련된 RFC 3253(버전 관리), RFC 3744(액세스 제어)도 추가로 정의됐다. 

 

WebDAV의 용어는 아래와 같다.

  • 리소스
    • 일반 파일 시스템에서는 데이터를 저장하는 아토믹 요소를 '파일'로 부르지만, WebDAV에서는 HTTP 용어를 이어받아 리소스로 부른다.
  • 컬렉션
    • 폴더와 디렉터리에 해당하는 요소
  • 프로퍼티
    • 리소스와 컬렉션이 가질 수 있는 추가 속성이다. 작성일시, 갱신일시, 최종 갱신자와 같은 정보가 해당한다.
    • 분산 파일 시스템은 같은 폴더를 여러 사람이 동시에 보고 데이터를 공유할 수 있지만, 같은 파일을 동시에 편집하게 될 경우가 있다. 같은 파일에 여러 사람이 동시에 기록하면 마지막에 전송된 내용 이외에는 지워져버린다. 이를 피하기 위해 먼저 선언한 사람 이외의 변경을 거절하는 시스템이다.

HTTP/1.1의 POST, GET, PUT, DELETE 메서드를 그대로 이용하지만 파일 시스템으로서는 기능이 부족해 몇 가지 메서드가 추가됐다.

 

기본 조작으로서 COPY와 MOVE가 추가됐다. 모두 GET하고나서 POST(MOVE의 경우 그다음에 DELETE)하면 에뮬레이션할 수 있지만, 동영상 소재로 10GB 분량의 콘텐츠가 있을 때 전체를 일단 로컬에 저장했다가 다시 업로드하는 것은 비효율적이다. 그런 경우 처음부터 원격 웹 서버에서만 이동하거나 삭제한 후 결과만 알려주는게 효율적이다. POST 메서드는 리소스만 작성할 수 있으므로, 컬렉션을 작성하는 MKCOL 메서드가 추가됐다. 콜렉션 내의 요소 목록은 프로퍼티로 취득하므로 PROPFIND 메서드를 사용한다. LOCK/UNLOCK 메서드로 파일 잠금 여부를 제어한다.

 

현재 오픈 소스 개발에서 가장 많이 사용되는 버전 관리 시스템인 Git에서는 전송용 프로토콜로 SSH와 HTTPS 두 가지를 지원한다. 사실은 이 HTTPS 안에서는 WebDAV를 사용한다. SSH는 암호화된 통신 경로를 사용하지만, 그 안의 통신은 오리지널 Git 프로토콜을 사용한다. HTTPS라면 어느 WebDAV 서버를 사용해도 호스트할 수 있으므로 설정이 간단하다는 장점이 있지만, 차분한 전송할 수 있는 Git 프로토콜 쪽이 통신 속도는 뛰어나다.

 

 

8.0 웹 사이트 간 공통 인증 및 허가 플랫폼

인터넷 보급과 함께 여러 웹 서비스가 등장했다. 사용자는 웹 서비스마다 메일 주소, 사용자 아이디, 패스워드를 입력해서 계정을 만들어야 했다. 인간의 기억력에는 한계가 있기때문에 모든 사이트에 같은 아이디와 패스워드를 이용하는 사람이 많을 것이다. 만약, 어떤 웹 서비스가 해킹됐다면 유출된 패스워드를 바탕으로 다른 사이트로 침입이 가능하게 된다. 이런 공격을 리스트형 공격이라고 부른다.

 

외부 서비스가 제공하는 인증 기반에 합승하는 기술도 몇 가지 개발됐다. 직접 사용자 ID와 패스워드를 관리하는 기반 시스템을 구축하려면 많은 시간과 노력이 필요하다. 흔히 있는 '비밀 질문'과 같은 것을 구현하거나, 2단계 인증을 구현해야 하기도 하다. 공격에 노출되기도 하고, 정보 유출 시엔 보상하는 경우도 있다. 플랫폼을 운영 관리하는 게 아니라면, 자사 내에서 사용자 ID와 패스워드 관리를 그만 둘 경우 방대한 작업으로부터 해방돼 서비스 개발에 집중할 수 있고 보안 리스크도 줄어든다.

 

해당 책에서는 아래와 같은 항목을 설명한다.

  • 싱글 사이온
  • 커베로스 인증
  • SAML
  • 오픈아이디
  • 오픈소셜
  • OAuth (이 중 현재 가장 많이 사용되는 듯?하다)
  • 오픈아이디 커넥트

우선 인증(Authentication)과 권한 부여(Authorization)의 차이를 확인하자.

 

Authentication(인증)
로그인하려는 사용자가 누구인지 확인한다. 브라우저를 조작하는 사람이 서비스에 등록된 어느 사용자 ID의 소유자인지 확인
Autorization(권한 부여)
인증된 사용자가 누구인지 파악한 후, 그 사용자에게 어디까지 권한을 부여할지 결정한다.

실제로 이와 같은 기술을 사용할 때는 OAuth 2.0 혹은 오픈아이디 커넥트 중 하나가 될 것이라고 생각하지만, 구현할 때는 각각의 기술을 제대로 배워둘 필요가 있다고 한다. 다행히 OpenID 파운데이션 제팬 홈페이지에 일본어로 된 RFC와 사양서가 있다. 사용할 언어에 라이브러리와 샘플 코드가 있어 구현하기 쉽다고 해도 관련 문서를 훑어보길 추천한다고 한다. 

 

 

8.1 싱글 사인온

기업 내에서 사용하는 웹 서비스나 시스템이 많아지면, 싱글 사인온(SSO)이 검토된다. 싱글 사인온은 시스템 간 계정 관리를 따로따로 하지 않고, 한 번의 로그인을 전 시스템에 유효하게 하는 기술이다. 현재도 많이 사용되는 듯 하다. (네이버, 쿠팡 등)

 

싱글 사인온은 다른 기술과 달리 프로토콜이나 정해진 규칙이 아니고, 이런 용도로 사용되는 시스템을 가리키는 명칭이다. 구현 방식도 웹에만 한정되지 않다. 싱글 사인온을 실현하는 데는 몇 가지 방법이 있다.

 

각 서비스가 인증 서버에 직접 액세스하러 가는 방법이 가장 이해하기 쉬울 것이다. 사용자 ID와 패스워드를 서비스마다 입력해야 하므로 싱글 사인온은 아니지만, 사용자 ID를 일원화해 관리할 수 있게 된다. 각 애플리케이션은 인증 시스템에 로그인하는 과정을 대행한다. 그 밖에도 다음에 소개하는 티켓을 이용한 방법이 있다.

 

웹 서비스로 한정되지만, 각 서비스의 앞단에 HTTP 프로시 서버를 두고 인증을 대행하는 방법과 각 서비스에 인증을 대행하는 에이전트를 넣고 로그인 시 중앙 서버에 액세스해 로그인 됐는지 확인하는 방법도 있다.

 

8.2 커베로스 인증

인터넷보다도 이전 시대부터 내려온 방법으로는 본래의 사용자 관리 구조를 하나로 정리해, 모든 시스템에서 이용하는 방법이 있다. 공통 규격으로서 기업 내에서도 많이 이용되는 것이 RFC 2251에 정의된 LDAP이다. OpenLDAP, 액티브 디렉터리(AD) 같은 구현이 있다. LDAP는 원래 싱글 사인온을 위한 시스템이 아니라 이용자, 조직, 서버 등 기업 내 정보를 일원화해 관리하는 데이터베이스이다. v3에서 추가된 SASL(Simple Authentication and Security Layer)이라는 인증 기능과 세트로 기업 내 마스터 인증 시스템으로 사용된다. RFC 1510(최신은 RFC 4120)에 정의된 커베로스 인증이 널리 사용된다.

 

커베로스 인증을 하면, 티켓 보증 서버로의 액세스 토큰인 티켓 보증 티켓(TGT)과 세션 키를 얻을 수 있다. 서비스와 시스템을 사용할 때는 TGT와 세션 키를 티켓 보증 서버(TGSA)에 보내고, 클라이언트에서 서버로 액세스하기 위한 티켓과 세션 키를 받는다. 이들은 서비스가 가진 비밀 키로 암호화 되어있다. 사용자가 서비스를 사용할 때는 이 티켓과 세션 키를 서비스에 보냄으로서 싱글 사인온이 실현된다.

 

8.3 SAML

최근에는 많은 사내 시스템이 웹 서비스로서 구현됐다. SAML(Security Assertion Markup Language)은 웹 계통의 기술(HTTP/SOAP)을 전제로 한 싱글 사인온 구조이다. 옥타, 원로그인 같은 SaaS(Software as a service)형태로 제공되는 서비스도 있다. SAML은 XML 기반의 표준을 많이 다루는 OASIS(Organization for the Advancement of Structured Information Standards)에서 책정된 규격이다.

 

SAML은 쿠키로 세션을 관리하는 웹의 구조를 따르고, 도메인을 넘어선 서비스 간 통합 인증을 할 수 있다. SAML을 지원하는 싱글 사인온을 조사해보면 수많은 SaaS의 웹 서비스가 나온다. 이 서비스들은 외부 도메인에 있는 구글의 G 스위트, 사이보우즈의 킨톤, 마이크로소프트의 오피스 365, 온라인 스토리지인 드롭박스 등과 연계할 수도 있다.

 

서비스 간 정보 교환 메타데이터도 공통화됐다. 우선은 아래 용어를 기억하자.

사용자
브라우저를 조작하는 사람
인증 프로바이더
ID를 관리하는 서비스
서비스 프로바이더
로그인이 필요한 서비스

 

구현 방법은 아래 여섯가지가 있다. 해당 책에서는 HTTP POST 바인딩 구현을 소개한다.

  • SAML SOAP 바인딩
  • 리소스 SOAP(PAOS) 바인딩
  • HTTP 리디렉트 바인딩
  • HTTP POST 바인딩
  • HTTP 아티팩트 바인딩
  • SAML URI 바인딩

우선은 사전 준비로 인증 프로바이더에 서비스 정보를 등록한다. 등록할 때는 메타데이터로 불리는 XML 파일을 사용한다. 여기에 XML 파일을 인용하진 않지만 아래와 같은 항목이 포함된다.

  • 서비스 ID (인증 프로바이더가 서비스를 식별하기 위한 것)
  • 인증 프로바이더가 HTTP-POST할 엔드포인트 URL
  • 바인딩(urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST 등)
  • 경우에 따라서는 X.509 형식의 공개 키

서비스 프로바이더 쪽에도 인증 프로바이더 정보를 등록한다. 등록할 정보는 인증 프로바이더가 XML 파일로 제공한다. 여기에도 일련의 통신에서 사용할 엔드포인트 URL 목록과 인증서가 포함된다. 

 

실제 통신은 단순하다. 사용자가 서비스를 이용하려고 접속했다고 가정해보자. 아직 로그인하지 않았다면, 서비스 프로바이더는 인증 프로바이더로 리디렉트 한다. 폼을 이용한 리디렉트로 HTTP의 POST를 사용해 리디렉트 한다. HTTP 리디렉트 바인딩에는 일반적인 302 스테이터스 코드를 사용한다.

 

브라우저는 인증 프로바이더 화면을 표시한다. 그 화면에서 로그인에 성공하면 인증 프로바이더는 로그인 정보를 서비스 프로바이더로 POST한다. 이때도 자동으로 POST하는 HTML을 반환한다. 이제 서비스 프로바이더는 사용자가 로그인에 성공한 것을 알 수 있으므로, 처음에 사용자가 요청한 페이지의 콘텐츠를 보여준다.

 

 

8.4 오픈아이디

오픈아이디는 중앙형 ID 관리를 하지 않고, 이미 등록된 웹 서비스의 사용자 정보로 다른 서비스에 로그인할 수 있는 시스템입니다. (OAuth와 같은 것 같은데..?) 2005년에는 1.0 사양이, 2007년에 2.0 사양이 정해졌지만, 현재는 후속 기술인 오픈아이디 커넥트를 이용하는 웹사이트는 늘고 있고, 오픈 아이디로 이용할 수 있는 서비스는 줄고 있다. 오픈아이디의 고유 용어로는 몇가지가 있는데 아래와 같다.

오픈아이디 프로바이더
사용자 정보를 가진 웹 서비스, 사용자는 이미 이 서비스의 ID가 있다. 2017년 현재 야후!재팬과 하테나가 지원한다.
릴레잉 파티
사용자가 새로 이용하고 싶은 웹 서비스, 2017년 이용할 수 있는 서비스는 이벤트 등록과 참가를 관리하는 웹 서비스인 ATND가 있다.
사용자 입력 식별자
사용자가 입력할 URL 형식으로 된 문자열, 오픈아이디 프로바이더가 제공하며, 오픈아이디 프로바이더의 사용자 프로필 화면 등에 표시된다. 사용자를 판별하는 ID가 아니라 서비스명 수준의 식별자가 사용되기도 한다.

 

야후!재팬과 하테나 계정이 있는 사용자라면 인증 정보로 ATND를 이용할 수 있다. 

 

 

8.5 오픈소셜

오픈소셜은 플랫폼으로서 승승장구를 이어가고 있는 페이스북에 맞서고자 SNS 분야에서 늦게 출발한 구글과 페이스북에게 바짝 추격당한 마이스페이스가 손잡고 소셜 네트워크 공통 API로서 개발됐다. 2007년 공개 이후, 현재는 W3C에 이관돼 소셜 웹 프로토콜로서 사양이 책정되고 있다. 

 

오픈소셜은 회원 정보나 친구 관계를 가져오는 Person&Friend API, 액티비티를 작성하는 Activities API, 정보를 저장하거나 공유하는 Persistence API, 다른 멤버에게 메시지를 보내는 requestSendMessage 등 다양한 기능을 지원하며 인증에만 머무르지 않았고 플랫폼을 지향했다.

 

오픈아이디의 릴레잉 파티의 경우, 서비스 쪽 제약은 거의 없고 오픈아이디 프로바이더와 릴레잉 파티를 사전에 등록할 필요도 없다. 오픈소셜의 경우, 오픈아이디 프로바이더에 해당하는 부분이 소셜 네트워크 서비스 제공자이다. 인증과 권한 부여를 모두 이쪽에서 한다. 릴레잉 파티인 서드 파티 애플리케이션은 서버를 SNS 외부에 준비하지만, UI 부분은 가젯으로 불리며 미리 정해진 규칙에 따라 XML 파일을 만들고, 친구 목록을 가져오거나 하는 각종 API를 이용해 자바스크립트와 AJAX로 애플리케이션을 개발한다. 오픈아이디와 비교하면 여러가지가 밀겹화돼 있다. 

 

브라우저의 관점, 즉 HTTP 차원에서 보면 오픈소셜을 사용하는 소셜 인터넷 워크 서비스에 평소처럼 로그인할 뿐 특별한 기능은 사용하지 않는다. HTML의 IFRAME을 사용해 가젯이라는 서드파티 앱에 액세스하지만, 브라우저에서 사용하는 자바스크립트의 AJAX API나 도메인 외부 서버에 대한 요청 등은 이 가젯 서버에서 대응한다.

 

가젯 제공자와 플랫폼 제공자 사이의 통신은 책에서 설명해온 HTTP 그 자체이다. 가젯 설정 파일인 XML은 외부 서버에 두고, 가젯 서버가 HTTP로 가져온다. 클라이언트 브라우저의 요청도 가젯 서버가 중계해서 HTTP로 외부 서버에 도달한다. 외부 서버가 사용자 정보를 취득하려면 RESTful API에 HTTP로 액세스 한다.

 

 

8.6 OAuth

OAuth는 인증이 아니라 권한을 부여하는 시스템으로서 개발됐고, 현재도 활발하게 이용되고 있다. OAuth는 2006년 말부터 검토되기 시작됐다. 그러다가 2008년에 RFC 5849로 OAuth 1.0이 공개됐고, 2012년에 최신인 2.0이 RFC 6749, RFC 6750으로 공개됐다. 해당 책에서는 2.0에 관해서 소개한다.

 

OAuth 이전까지는 사용자가 누구인가를 판정하는 인증 프로세스였다. OAuth는 인증이 아니라 권한 부여에 특화된 시스템이다. 

권한 부여 서버
오픈아이디에서 말하는 오픈아이디 프로바이더, 사용자는 이 권한 부여 서버의 계정이 있다.
리소스 서버
사용자가 허가한 권한으로 자유롭게 액세스할 수 있는 대상, 트위터나 페이스북의 경우는 권한 부여 서버와 같다.
클라이언트
오픈아이디에서 말하는 릴레잉 파티, 사용자가 이제부터 사용할 서비스나 애플리케이션, 오픈아이디와 달리 권한 부여 서버에 애플리케이션 정보를 등록하고, 전용 ID를 가져와야 한다. 이 ID를 credential이라고 부르기도 한다.

 

오픈 아이디와 OAuth 모두 비슷하게 화면을 전환한다. 사용자가 새로운 웹 서비스를 이용하려고 할 때, 이미 계정이 있는 서비스의 웹사이트로 전환된다. 바뀐 화면에서 사용자가 승인 버튼을 누르면, 처음에 연 웹 서비스 화면으로 다시 돌아와 정상적으로 서비스를 이용할 수 있게 된다. (단, 예외도 존재한다.)

 

화면 전환이 비슷하다고 해도, 인증보다 권한 부여는 큰 영향을 미친다. OAuth로 하는 일은 비유하면 전기요금이나 가스요금을 내기 위해 신용카드 번호를 맡기는 것과 같다. 오픈 아이디는 사용자가 이용하려는 웹 서비스가 신용카드 회사에 "이 사람이 회원이 맞나요?"라고 묻고, YES/NO로 응답받는 정도에 불과하지만, OAuth는 최종적으로 회원의 신용카드 번호를 받아온다. 웹 서비스는 그 카드번호가 유효하면 요금을 청구할 수 있다.

 

위 사례는 조금 극단적이긴 하지만, 실제로 클라이언트=외부 웹 서비스에 무엇을 허가할지는 '범위'로 결정된다. OAuth도 오픈아이디처럼 나중에 권한 부여 서버의 설정 화면에서 허가를 취소하거나 허가 범위를 변겨할 수 있다.

 

OAuth 2.0은 4가지 플로를 제공하며, 웹 서비스 이외에도 사용할 수 있다.

Authorization Code
자주 사용되는 일반적인 방법이다. 웹 서비스의 서버 내에 client_secret을 감출 수 있고, 외부에서 볼 가능성이 없는 경우에 사용한다.
Implicit Grant
client_secret없이 액세스할 수 있는 패턴, client_secret이 없으므로 클라이언트의 신원을 보증하지 않는다. 안전하게 client_secret을 유지할 수 없는 자바스크립트, 사용자 단말에 애플리케이션 코드를 다운로드하는 스마트폰 앱 등을 위한 방법이다.
Resource Owner Password Credentials Grant
이제까지는 클라이언트 자신이 사용자의 ID와 패스워드에 접근하지 않았지만, 이 방법은 예외다. 허가 서버가 신뢰하는 클라이언트에서 사용한다. iOS에 내장된 페이스북, 트위터 연계 등 특수한 경우다.
Client Credentials Grant
사용자 동의 없이 client_id와 client_secret만으로 액세스하는 방법이다. client_secret을 이용한 클라이언트 인증만 하므로, 서버 사이드 등 client_secret을 외부로부터 감출 수 있는 환경에서 이용할 수 있다.

 

 

8.7 오픈아이디 커넥트

오픈아이디 커넥트는 OAuth 2.0을 기반으로 한 권한 부여뿐만 아니라 인증으로 사용해도 문제가 없게 확장한 규격이다. 2014년 2월에 출시됐다. 인증과 허가에 모두 사용할 수 있고, 서비스 제공자(릴레잉 파티, 클라이언트)에서 구현하기도 어렵지 않아 앞으로 사실상의 표준이 될 것이다. 현 시점에서는 구글, 야후!재팬, 믹시 등이 오픈아이디 커넥트 프로바이더로서 서비스를 제공하고 있다.

 

클라이언트 입장에서 볼 때 OAuth 2.0과의 차이는 사용자 프로필에 액세스하는 방법을 규격화한 점이다. 일반 액세스 토클과 별개로 ID 토큰이 발행되는데, 이 토큰을 사용해 액세스할 수 있다. 사용자 관점에서의 절차는 오픈아이디, OAuth와 같다.

 

오픈아이디 커넥트에서는 액세스 토큰과 ID 토큰을 가져오기 위해 두 개의 엔드포인트와 세가지 플로를 정의했다.

권한 부여 엔드포인트
클라이언트가 권한 부여 요청을 보낼 서비스 창구, 클라이언트 인증을 하는 플로에서는 토큰 엔드포인트에 액세스하기 위한 키(권한 부여 코드)를 반환한다. 인증하지 않는 플로에서는 액세스 토큰과 ID 토큰은 이 엔드포인트가 반환한다.
토큰 엔드포인트
액세스 토큰과 ID 토큰을 반환하는 창구. 클라이언트를 인증해 강한 권한을 가진 토큰을 반환한다.

 

세 개의 플로는 아래와 같다.

Authorization Code Flow
OAuth의 Authorization Code와 같다. Client_secret을 은닉할 수 있는 서버 환경용, 권한 부여 엔드 포인트에 액세스해서 권한 부여 코드를 가져온 후, 토큰 엔드포인트에 액세스해서 토큰을 얻는다. 클라이언트 인증을 사용할 수 있으므로 가장 강한 권한을 허용할 수도 있다.
Implicit Flow
OAuth의 Implicit Grant와 같다. HTML 상의 자바스크립트 등 client_secret을 은닉할 수 없는 클라이언트 환경용. 권한 부여 엔드포인트에 액세스해 코드와 토큰을 한 번에 가져온다.
Hybrid Flow
Implicit Flow와 비슷하지만 권한 부여 엔드포인트에서 통신에 필요한 토큰과 추가 정보를 얻기 위한 권한 부여 코드를 얻을 수 있다. 이 권한 부여 코드를 사용해 토큰 엔드포인트에 액세스한다.

 

OAuth 2.0에서는 Implicit Grant로 사용자 인증을 하려고 하면, 차가 지나갈 정도로 보안에 큰 구멍이 뚫린다고 한다. 이에 대해서도 오픈아이디 커넥트에서는 해시 코드를 사용해 각종 토큰을 검증할 수 있게 개량했다.


OAuth 2.0과의 차이는 Hybrid Flow가 가장 크다. OAuth가 목표로 한 것은 다음 3자간의 워크 플로이다.

  • 사용자
  • 클라이언트
  • 권한 부여 서버

그러나 모바일 애플리케이션이 보급되면서 클라이언트가 다시 둘로 나뉘어 4자간의 워크 플로가 필요해졌다.

  • 사용자
  • 클라이언트 1 : 스마트폰 단말의 애플리케이션
  • 클라이언트 2: 백엔드 웹 서비스
  • 권한 부여 서버

Hybrid Flow는 클라이언트 1에서 client_secret을 은닉할 수 없는 환경을 위한 Implicit Flow로 권한을 부여한 후에, 클라이언트 2가 클라이언트 인증을 하면서 토큰의 엔드포인트에 요청을 보내 더 강력한 권한을 가진 토큰을 얻을 수 있게 됐다.

 

 

 

 

Reference

  • 리얼월드 HTTP : 역사와 코드로 배우는 인터넷과 웹 기술

 

HTTP/1.1의 변경사항은 아래와 같다.

  • 통신 고속화
    • Keep-Alive가 기본적으로 유효
    • 파이프라이닝
  • TLS에 의한 암호화 통신 지원
  • 새 메서드 추가
    • PUT, DELETE가 필수 메서드
    • OPTION, TRACE, CONNECT 메서드 추가
  • 프로토콜 업그레이드
  • 이름을 사용한 가상 호스트 지원
  • 크기를 사전에 알 수 없는 콘텐츠의 청크 전송 인코딩 지원

 

1. 통신 고속화

이전 캐시에서 설명한 ETag와 Cache-Control은 HTTP/1.1의 기능이다. 캐시는 콘텐츠 리소스마다 통신을 최적화하는 기술이지만, Keep-Alive와 파이프 라이닝은 좀 더 범용적으로 모든 HTTP 통신을 고속화하는 기능이다.

 

브라우저에서 서버로 동시에 접속할 때 HTTP/1.0에서 권장하는 값은 4였다. 병렬로 동시에 접속하는 이 값이 HTTP/1.1에서는 2로 내려갔는데, Keep-Alive나 파이프라이닝의 효과를 고려한 결과라고 한다. 또한, 프로토콜 버전 업으로 속도가 개선되고 서버의 부하도 내려간다.

 

 

1.1 Keep-Alive

HTTP의 아래층인 TCP/IP 통신을 효율화하는 구조로, 요청마다 통신을 닫지않고 연속된 요청에는 연결된 connection을 재사용함
Why>
HTTP는 TCP를 사용하기 때문에 3-way-handshake를 통해 연결을 성립한다. 이후 request와 적절한 response를 받으며 통신을 하고 4-way-handshake를 통해 연결을 닫는다. 하지만, 1번의 request를 보내는 것이 아닌 연속적인 request를 보내야 할 경우 요청마다 3-way, 4-way handshake를 해야한다. 빛의 속도가 아무리 빠르다고 하지만, 지구 반대편까지 통신을 보내는 데 0.2초가 걸린다고 한다. 연속적인 request를 보낼 경우 이전에 사용한 3-way-handshake를 통해 연결된 connection을 재사용한다면 효율적인 통신이 오고 갈 수 있다. 이때 사용하는 것이 Keep-Alive이다.

Keep-Alive

이 기능은 HTTP/1.0에서 지원하지 않았지만, 몇몇 브라우저에서 이미 지원하고 있었다. HTTP/1.0에서 요청 헤더에 아래 헤더를 추가함으로써 Keep-Alive를 이용할 수 있었다.

Connection: Keep-Alive

 

이 헤더를 받아들인 서버가 Keep-Alive를 지원하면, 같은 헤더를 응답 헤더에 추가해서 반환한다.

HTTP/1.1에서는 이 동작이 기본으로 되어 있다. TLS 통신을 이용할 경우, 특히 통신 시간을 많이 줄여준다. (인증하는 시간이 추가되지 않아서 그런 것 같다.) HTTP 아래 계층의 프로토콜인 TCP/IP도 접속할 때는 1.5회 왕복의 통신을 필요로 한다. 패킷이 1회 왕복하는 시간을 1RTT(Round-Trip Time)로 부르며, TLS에서는 서버/클라이언트가 통신을 시작하기 전에 정보를 교환하는 핸드세이크과정에서 2RTT만큼 시간이 걸린다. 

 

Keep-Alive를 이용한 통신은 클라이언트나 서버 중 한 쪽이 아래 헤더를 부여해 접속을 끊거나 타임아웃될 때까지 연결이 유지된다.

Connection: Close

 

Keep-Alive 지속 시간은 클라이언트와 서버 모두 가지고 있다. 한 쪽이 TCP/IP 연결을 끊는 순간에 통신은 완료되므로, 어느 쪽이든 짧은 쪽이 사용된다. Chrome 300초, firefox 115초, edge 120초, safari 60초로 기본 타임아웃 시간을 가지고 있다. 

 

통신이 지속되는 동안 os의 자원을 계속 소비하므로, 실제로 통신이 전혀 이루어지지 않는데 접속을 유지하는 것은 바람직하지 않다. 짧은 시간에 접속을 끊는 것에 의미가 있다.

 

 

1.2. 파이프라이닝

최초의 요청이 완료되기 전에 다음 요청을 보내는 기술. 다음 요청까지의 대기 시간을 없앰으로써, 네트워크 가동률을 높이고 성능을 향상 시킨다. Keep-Alive 이용을 전제로, 서버는 요청이 들어온 순서대로 응답을 반환

파이프라이닝

그대로 동작한다면, 왕복 시간이 걸리는 모바일 통신에서 큰 효과를 기대할 수 있다. 하지만, 실제로는 파이프라이닝 기능을 구현하지 않았거나 구현했어도 기본 설정에서 꺼둔 브라우저도 있다. 크롬 또한 버전 18에서 지원했지만, 버전 26에선 파이프라이닝을 삭제했다. 현재 기본으로 활성화한 브라우저는 오페라와 iOS 5 이후의 사파리 정도이다.

호환성, 보안, 성능 이슈 등 파이프라이닝의 이점이 크지 않았다는 것이 이유인 것 같다. 최근에는 HTTP/2와 같은 새로운 프로토콜이 등장하며 파이프라이닝의 필요성이 더 낮아졌다고 한다. 
NOTE_스트림
그렇다고 파이프라이닝이 쓸모없는 사양이었다는 것은 아니다. 파이프라이닝은 여러 문제를 해결하고, HTTP/2에서 스트림이라는 새로운 구조로 다시 나왔다.
- HTTP/2 에서는 HTTPS 통신이 전제가 된다. HTTPS 이므로 기본적으로 프록시가 송수신되는 데이터 내부를 볼 수 없다. 프록시는 통신을 중계만 하게 됐으므로 최신 프로토콜을 해석할 수 없는 프록시가 도중에 방해할 일이 사라졌다.
- 통신 순서를 유지해야 한다는 제약이 HTTP/2에서는 사라졌다. (응답 순서가 파이프라이닝의 성능 이슈를 만들었었음.) HTTP/2에서는 하나의 세션 안에 복수의 스트림이 공존한다. 시분할로 각 스트림의 통신이 이루어지므로, 서버 측의 준비가 된 순서로 응답을 반환할 수 있다. 또한, 우선순위를 부여해 순서를 바꾸는 것도 가능하다.

 

 

2. 전송 계층 보안(TLS)

컴퓨터 네트워크에서 보안 통신을 제공하는 프로토콜로 기밀성과 데이터 무결성을 보장하여 민감한 정보의 안전한 전송을 도와준다.

 

HTTP/1.1과 함께 이용되는 것을 강하게 의식해서 만들어졌지만, TLS 암호화 자체는 HTTP 뿐만 아니라 다양한 형식의 데이터를 양방향으로 흘려보낼 수 있다. TLS는 기존 프로토콜에 통신 경로의 안정성을 추가해 새로운 프로토콜을 만들어낼 수 있는 범용적인 구조로 되어 있다. HTTP는 80번 port를 사용하고, HTTPS는 443번을 사용해 다른 서비스로 취급된다. HTTPS 이외에 메일 전송 프로토콜 SMTP(25번)의 TLS 버전인 SMTPS(465번) 등 기존 프로토콜의 버전업에도 이용된다.

 

HTTP/1.0과 1.1에서는 프록시 서버 등이 통신을 해석해 캐시함으로써 고속화 기능을 제공할 수 있었지만, 자신이 해석할 수 없는 프로토콜을 멈춰버리는 경우가 있었다. TLS를 사용하면 조작할 수 없는 안정된 통신로가 생기므로, HTML5에서 새로 도입된 웹소켓 같은 통신 프로토콜이나 HTTP/2 등 이전과 상위 호환성이 없는 수많은 새로운 시스템을 원만하게 도입하는 인프라가 됐다.

 

TLS는 몇 가지 버전이 있다. SSL 이라고 불리던 시절도 있었으며, 현재에도 TLS 부분을 많은 라이브러리로서 높은 점유율을 자랑하는 소프트웨어 이름이 OpenSSL이라는 등 TLS와 관련된 이름에 SSL이라는 글자가 들어간 경우가 있다. 다만 실제 SSL은 여러가지 취약성이 알려져 있어, RFC에서도 권장하지 않는다. 인터넷 서비스에서도 무효화된 것이 많고, 실제로 사용되는 것은 대부분 TLS 이다.

SSL/TLS 역사

 

실제 통신 내용은 암호화된 이진 데이터이므로 내부를 보는 것은 어렵지만, curl 커맨드로 액세스할 때는 URL을 https://로만 하면 된다. 아래 소개한 옵션으로 세밀하게 동작을 설정할 수 있다. 서버 설정이 맞는지, 각 버전에서 예상한 대로 동작하는지 확인하는 데 편리하다.

  • -1, --tlsv1
    • TLS로 접속
  • --tlsv1.0, --tlsv1.1, --tlsv1.2
    • TLS 니고에이션 시 지정한 버전으로 연결하도록 강제
  • --cert-status
    • 인증서를 확인
  • -k, --insecure
    • 자가 서명 인증서라도 오류가 되지 않는다.

--cert-status와 -v를 붙여 실행하면, 인증서 상태를 아래처럼 표시한다.

$ curl --cert-status -v https://example.com 
(생략)
* SSL certificate status: good (0)



 

2.1 해시 함수

입력 데이터를 규칙에 따라 집약해감으로써 해시 값으로 불리는 짧은 데이터를 만들어 내는 함수

해시는 '잘게 저민 조각'이라는 뜻으로, 해시드 포테이토나 해시드 비프의 해시와 같은 말이다. 

 

해시 함수에는 암호화 통신을 사용하는 데 편리한 수학적 특성이 있다. 해시 함수를 h(). 입력 데이터를 A,B...., 산출된 해시 값을 X, Y ...라고 하자. 길이는 len() 이라고 하자.

  • 같은 알고리즘과 같은 입력 데이터라면, 결과로서 생성되는 값은 같다. h(A)=X가 항상 성립한다.
  • 해시 값은 알고리즘이 같으면 길이가 고정된다. SHA-256 알고리즘에선 256비트(32바이트)다. 따라서 입력 데이터가 너무 작을 경우 해시 값이 더 커지지만, 기본적으로는 len(X)<len(A)가 된다.
  • 해시 값에서 원래 데이터를 유추하기 어렵다. h(A)=X의 X에서 A를 찾기 힘들다.
  • 같은 해시 값을 생성하는 다른 두 개의 데이터를 찾기 어렵다. h(A)=h(B)가 되는 임의의 데이터 A,B를 찾기가 힘들다.

해시 함수는 다양한 용도로 쓰인다. 예를 들어, 다운로드한 파일이 깨지지 않았는지 확인하는 방법으로 이용된다. 1바이트라도 데이터에 차이가 있으면 해시 값이 바뀌기 때문이다. 이런 용도로 사용될 때 해시 값을 checkSum 혹은 핑거프린트라고 불리기도 한다. 또한, Git에서는 파일을 관리할 때 파일명이 아니라 파일 내용을 바탕으로 한 해시 값을 사용하고, 이 해시 값을 키로 해서 데이터베이스에 파일을 저장한다. 같은 내용의 파일이 여러 개 있을 때, 데이터의 실체는 하나다. 커다란 데이터 파일을 모두 비교하지 않고 해시 값만 비교함으로써 재빨리 같은 파일인지 판정할 수 있다. 해시 값 충돌은 매우 드물게 일어나지만, 데이터양이 적으면 거의 일어날 수 없다.

 

유명한 해시 함수로는 MD5(128bit), SHA-1(160bit), SHA-2(SHA-224, 256, 384, 512, 512/224, 512/256) 등 이있다. SHA-1은 서버 인증서 서명에 사용하는 것을 추천하지 않는 등 다양한 이슈가 있다. 

 

해시 값이 어떤지 시험하는 데는 커맨드라인 툴을 사용하면 좋다. 보안 용도로 부적당한 MD5도 체크섬 용도로는 여전히 이요되고 있으며, 각 OS에 해당되는 유틸리티가 준비되어 있다. mac OS나 BSD 계열 OS에서는 md5 커맨드가 있고, 리눅스에서는 md5sum 커맨드가 있다. 파일을 1바이트만 수정해도 해시 값이 크게 달라지는 것을 알 수 있다.

md5 test

 

 

2.2 공통 키 암호와 공개 키 암호 그리고 디지털 서명

Why>
암호화는 비밀스러운 방법으로 문장을 해독할 수 없는 형식으로 바꿔(인코딩) 보내고, 받아보는 쪽에서 원래 문장으로 복원(디코딩)한다. 하지만, 인코딩/디코딩하는 변환 알고리즘을 알게된다면 모든 통신이 그대로 노출된다. 보다 안전한 통신 방법으로 공동키, 공개키가 만들어졌다.

암호화에서 중요한 것은 변환 알고리즘을 비밀로 하는 것이 아닌, 알고리즘이 알려져도 안전하게 통신할 수 있는 것이다. 현재 일반적으로 사용하는 방식은 암호화 알고리즘은 공개하고, 그 암호화에 사용하는 데이터(키)를 따로 준비하는 방식이다. TLS에서 사용되는 방식으로는 공통 키와 공개 키 방식 두 종류가 있다.

 

공통 키 방식은 자물쇠를 잠글 때와 열 때 모두 같은 열쇠를 사용하는 방식이다. 따라서 통신하는 사람끼리는 이 번호를 공유할 필요가 있다.  공통 키 방식은 대칭 암호라고도 불린다. TLS에서는 일반 통신의 암호화에 사용한다.

 

공개 키 방식은 비대칭 암호라고도 불린다. 공개 키 방식에서 필요한 것은 공개 키와 비밀 키이다. 공개 키는 이름 그대로 전 세계에 공개해도 문제 없지만, 비밀 키는 다른 사람에게 알려져선 안 된다. 공개 키 방식에선 집 열쇠와 달리 암호화 키와 암호해독 키가 따로 있다. 암호화하는 것이 공개 키고 해독하는 것은 비밀키다. 사물에 비유하면 공개 키는 자물쇠, 비밀 키는 열쇠이다. 

 

디지털 서명은 공개 키 방식을 응용한 예이다. 거꾸로 열쇠를 나누어주고 자물쇠를 비밀로 해두는 것과 같은 이미지이다. 편지 본문에 자물쇠로 잠근 데이터도 함께 첨부해 보낸다. 받는 사람이 공개된 키를 사용해 자물쇠를 열었을 때 본문과 동일한 것이 나오면 메시지가 조작되지 않은 것을 알 수 있다. 실제 디지털 서명은 본문 자체를 암호화하는 것이 아닌 해시화하고 그 결과를 암호화한다. 

 

암호화는 완벽하지 않다. 자물쇠에 비유하면 암호의 안정성은 알고리즘과 비트 수로 강도가 정해진다. 시간을 들여 열쇠의 울퉁불퉁한 패턴을 반복해서 테스트하면 진짜 열쇠를 만들어낼 수도 있다. 실제로는 상당한 계산량이 필요하지만, 현실적인 시간 내에서 해석이 가능한 경우 보안에 취약하다고 간주된다. 이미 몇몇 알고리즘은 CPU의 발달과 함께 권장하지 않게 되었다.

 

 

2.3 키 교환

키 교환은 클라이언트와 서버 사이에 키를 교환하는 것이다. 간단한 방법으로는 클라이언트에서 공통 키를 생성한 다음 전술한 서버 인증서의 공개 키로 암호화해 보내는 방법이 있고, 키 교환 전용 알고리즘도 있다. 책에서는 RFC 2631에 정의된 디피-헬먼(DH) 키 교환 알고리즘을 소개한다. 실제로는 해당 알고리즘에서 파생된 일시 디피-헬먼(DHE)를 사용한다.

 

알고리즘의 핵심은 키 자체를 교환하는게 아니라, 클라이언트와 서버에서 각각 키 재료를 만들어 서로 교환하고 각자 계산해서 같은 키를 얻는 것이다.

키 교환 모식도

TLS 상에서 서버가 계산에 사용할 값 p, g를 준비한다. 이 값들은 공개 정보로서 그대로 클라이언트어 넘겨줍니다. g는 법 p에 대한 원시근이라고 한다. 

원시근?
g^1, g^2 ..., g^(p-2)의 어느 수치도 q로 나눈 나머지가 1이 아니다

속도를 높이고자 p와 g의 조합을 미리 계산해서 TLS 라이브러리 내부에 목록으로 만들어두는 경우가 많다고 함.

 

또, 한 가지 값을 계산한다. 이 값을 Y라고 부르는데, 비밀이라고한다.(서버 내부에만 저장되는 값인 듯 하다.) 클라이언트에 보내는 Ys 값은 아래와 같이 계산한다.

// g=5, p=23, Y=6
Ys = (g^Y) % p = (5^6)%23 = 23

 

Server Key Exchange(서버 키 교환) 메시지의 인수로서 p, g, Ys를 클라이언트로 보낸다. 전송이 끝난 후 상태는 아래와 같다.

서버 키 교환 메시지 전송 후 상태 변화

클라이언트도 랜덤하게 값 X를 생성하고 Xs를 계산한다. X를 15라고 가정하면 아래와 같다.

Xs = (g^X)%p = (5^15)%23 = 19

Client key Exchange(클라이언트 키 교환) 메시지의 인수로서 Xs를 서버로 보낸다. 전송이 끝난 후 상태는 아래와 같다.

클라이언트 키 교환 메시지 전송 후 상태 변화

클라이언트가 직접 생성한 값 X와 서버가 보내준 Ys로 공통 키의 시드를 생성한다.

ZZ = (Ys^X)%p = (8^15)%23 = 2

서버도 직접 생성한 값 Y와 클라이언트가 보내준 값 Xs로 공통 키의 시드를 생성한다.

ZZ = (Xs^Y)%p = (19^6)%23 = 2

 

ZZ는 p로 나머지를 구하기 때문에 p를 넘어가지 않는다. 현재는 예시로 5비트 안에 들어가지만, 실제로는 1024비트, 2048비트와 같은 큰 길이를 사용한다. 이유는 생성되는 키가 작으면 취약성으로 이어지기 때문이다. 실제로 이 성질을 이용해, 생성되는 키의 비트 수를 작게 해서 보안 강도를 약화하는 로그 잼이라는 공격이 있다고 한다. 현재는 2048 bit를 권장한다고 함. 

 

 

2.4 공통 키 방식과 공개 키 방식을 구분해서 사용하는 이유

공개 키 방식이 복잡한만큼 안정성이 공통 키 방식에 비해 안정성이 높다. 안정성이 높은 방식을 계속 사용하면 안정적이지만 TLS는 양쪽 방식으 조합했다. TLS에서는 통신마다 한 번만 사용되는 공통 키를 만들어내고, 공개 키 방식을 사용해 통신 상대에게 신중히 키를 전달한 이후는 공통 키를 통해 고속으로 암호화하는 2단계 방식을 이용한다. 이유는 공개 키 방식이 안정성이 높지만, 키를 가지고 있어도 암호화와 복호화에 필요한 계산량이 공통 키 방식보다 너무 많기 때문이다.

 

"계산량이 많지만, 안전하게 좋다" 라는 사람도 있을 것이다. 한 번 두개의 키 방식의 성능을 측정해보자.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/md5"
	"crypto/rand"
	"crypto/rsa"
	"io"
	"testing"
)

func prepareRSA() (sourceData, label []byte, privateKey *rsa.PrivateKey) {
	sourceData = make([]byte, 128)
	label = []byte("")
	io.ReadFull(rand.Reader, sourceData)
	privateKey, _ = rsa.GenerateKey(rand.Reader, 2048)
	return
}

func BenchmarkRSAEncryption(b *testing.B) {
	sourceData, label, privateKey := prepareRSA()
	publicKey := &privateKey.PublicKey
	md5hash := md5.New()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		rsa.EncryptOAEP(md5hash, rand.Reader, publicKey, sourceData, label)
	}
}

func BenchmarkRSADecrytion(b *testing.B) {
	sourceData, label, privateKey := prepareRSA()
	publicKey := &privateKey.PublicKey
	md5hash := md5.New()
	encrypted, _ := rsa.EncryptOAEP(md5hash, rand.Reader, publicKey, sourceData, label)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		rsa.DecryptOAEP(md5hash, rand.Reader, privateKey, encrypted, label)
	}
}

func prepareAES() (sourceData, nonce []byte, gcm cipher.AEAD) {
	sourceData = make([]byte, 128)
	io.ReadFull(rand.Reader, sourceData)
	key := make([]byte, 32)
	io.ReadFull(rand.Reader, key)
	nonce = make([]byte, 12)
	io.ReadFull(rand.Reader, nonce)
	block, _ := aes.NewCipher(key)
	gcm, _ = cipher.NewGCM(block)
	return
}

func BenchmarkAESEncryption(b *testing.B) {
	sourceData, nonce, gcm := prepareAES()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		gcm.Seal(nil, nonce, sourceData, nil)
	}
}

func BenchmarkASEDecryption(b *testing.B) {
	sourceData, nonce, gcm := prepareAES()
	encrypted := gcm.Seal(nil, nonce, sourceData, nil)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		gcm.Open(nil, nonce, encrypted, nil)
	}
}

 

공통 키는 AES, 공개 키는 RSA를 사용했고, 테스트를 하려면 cmd 창에서 아래 명령어를 입력하면 된다.

$ go test -bench .

결과는 아래와 같다.

시간 결과

128바이트 데이터를 암호화하고 복호화할 때 걸리는 처리 시간은 위의 결과 그댈외다. 암호화와 복호화가 다른 컴퓨터에서 진행되는 점을 감안하면 실제 산출량은 더 느린 수치가 될 수 있다. 공개 키 암호에서는 비교적 성능이 좋은 컴퓨터를 사용해도 PHS 회선 정도의 속도밖에 안나온다고 한다. AES로는 기가비트 광회선에서도 병목이 일어나지 않는 속도로 그 차이는 1만 5천배이다. AES는 Go 언어의 64비트 인텔 아키텍처용 구현으로 하드웨어 처리가 이루어져, 3~10배의 속도가 되는 것을 빼도 상당한 속도 차이가 있다.

 

 

2.5 TLS 통신 절차

TLS 통신은 크게 3개로 나눌 수 있다.

  1. handshake를 통해 통신 확립
  2. 레코드 프로토콜로 불리는 통신 단계
  3. SessionTicket 구조를 이용한 재접속 시의 고속 handshake

TLS 통신 순서

 

서버의 신뢰성 확인

서버의 신뢰성을 보증하는 구조는 공개 키를 보증하는 구조이기도 해서, 공개 키 기반구조(PKI)라고 불립니다. 브라우저는 서버에서 그 서버의 SSL 서버 인증서를 가져오는 것부터 시작합니다. 

 

인증서는 X.509 형식으로 기술된 파일입니다. 이 인증서에는 사이트 주체, 발행자, 소유자 서버의 공개 키, 유효 기한 등의 항목이 있습니다. 발행자는 인증기관(CA)이라고도 불립니다.

 

외부에 공개된 서비스라면 인증서는 누구나 취득할 수 있습니다. openssl 커맨드로 구글의 인증서를 가져와 내용을 표시해봅시다.

$ openssl x509 -in google.crt -noout -text  

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            90:76:89:18:e9:33:93:a0
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: OU=No SNI provided; please fix your client., CN=invalid2.invalid
        Validity
            Not Before: Jan  1 00:00:00 2015 GMT
            Not After : Jan  1 00:00:00 2030 GMT
        Subject: OU=No SNI provided; please fix your client., CN=invalid2.invalid
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (2048 bit)
                Modulus:
                    00:cd:62:4f:e5:c3:13:84:98:0c:05:e4:ef:44:a2:
                    a5:ec:de:99:71:90:1b:28:35:40:b4:d0:4d:9d:18:
...

위는 실제 서비스의 인증서입니다.

 

 

키 교환과 통신 시작

공개 키 암호를 사용하는 방법과 키 교환 전용 알고리즘을 사용하는 방법이 있다. 어느 쪽을 쓸 것인지는 최초의 Client Hello, Server Hello 니고시에이션에서 결정된다. 

 

클라이언트는 먼저 난수를 사용해 통신용 공통 키를 만든다. 난수도 패턴이 쉽게 읽히는 알고리즘으로는 아무리 암호해봐야 애초에 생성될 공통 키가 예측되거나 암호를 결정하는 알고리즘의 중간 경과가 추측될 우려가 있다. 

 

공개 키를 사용하는 방법은 간단하다. 서버 인증서에 첨부된 공개 키로 통신용 공통 키를 암호화해 그 키를 서버에 보낸다. 서버는 인증서의 공개 키에 대응하는 비밀 키를 갖고 있으므로 건네받은 데이터를 복호화해 공통 키를 꺼낼 수 있다.

 

순방향 비밀성이 우수하므로, 키 교환에서 주류가 되는 것은 키 교환 전용 알고리즘 방식이다. 

 

 

통신

통신을 할 때도 기밀성과 무결성을 위해 암호화를 한다. 암호화에는 클라이언트와 서버 모두 가지고 있는 공통 키 암호 방식 알고리즘 이용한다.

 

TLS 1.2 이전 버전에서는 통신 내용의 해시 값을 계산한 다음, 공통 키 암호로 암호화 하는 방법을 지원했다. 하지만, 이 기법에 대한 공격이 발견됐으므로 TLS 1.3 이후에는 AES+GCM, AES+CCM, ChaCha20-Poly1305 등의 인증 암호(AEAD)로 제한될 예정이다.

 

 

통신의 고속화

지금까지 설명한 절차는 가장 긴 경우인 신규 접속의 흐름이다. 일반적인 접속에서는 우선 HTTP로 연결하기 전 TCP/IP 단계에서 1.5RTT가 걸린다. 그 후 TLS 핸드세이크에서 2RTT, 그리고 HTTP의 요청에서 1RTT의 통신 시간이 걸린다. 단 TCP/IP 통신 마지막의 0.5RTT와 그 후 TLS의 최초 통신은 함께 이루어지므로 합계는 4RTT이다. TLS를 사용하지 않는다면 2RTT로 종료된다. 

 

4RTT라는 단위는 통신에서 전기 신호가 서버에 도달하고 응답이 되돌아오기까지 매우 긴 시간이다. 그래서 인터넷을 더 빠르게 하려면 왕복 시간을 줄이는 것이 중요하다. TLS와 HTTP에는 이를 위한 장치가 몇 가지 구현되어 있다.

 

우선 이 장 처음에 소개한 Keep-Alive입니다. Keep-Alive를 이용하면 세션이 지속되므로, 최초 요청 이후의 통신에서는 RTT가 1이 된다.

 

TLS 1.2에는 세션 재개 기능이 있어 최초의 핸드셰이크에서 전에 사용하던 세션ID(32bit)를 보내면 이후의 키 교환이 생략되므로 1RTT로 세션이 재개된다. 1.3에서는 사전에 키를 공유해 둠으로써 0RTT로 최초 요청부터 정보를 전송할 수 있게 된다.

 

TLS 1.3에서는 키 교환과 비밀 키 암호가 분리되어 암호화 스위트로 비밀 키 암호를 니고시에이션한 결과를 기다리지 않고, 최초의 Client Hello로 클라이언트 쪽에서 키를 교환할 수 있게 됩니다. 통신이 1왕복 줄어 1RTT로 인증이 완료된다.

 

TLS 아래 계층을 핸드셰이크가 필요한 세션형 TCP에서 재전송도 및 흐름 제어도 하지 않는 간이 데이터그램형 UDP로 대체해, 애플리케이션 계층에서 재전송하는 QUIC(Quic UDP Internet Connections)라는 통신 방식의 RFC화가 IETF에 제안됐다. 이미 구글의 서버가 QUIC을 지원하고, 크롬 브라우저에서도 이용된다. HTTP나 TLS 통신 이전에 전송 계층인 TCP 시점에서 핸드셰이크에 1RTT를 소비했지만, UDP는 핸드셰이크를 하지 않으므로 0RTT로 연결 할 수 있다. 현재 구현된 QUIC은 TLS에 해당하는 것을 자신이 갖는 등 거대해졌지만, 앞으로는 TLS 1.3으로 대체된다.

 

 

2.8 프로토콜 선택

TLS가 제공하는 기능 중 차세대 통신에 없어선 안 될 것이 애플리케이션 계층 프로토콜을 선택하는 확장 기능이다. 

 

처음에 구글이 NPN(Next Protocol negotiation) 확장을 제안해서 RFC화를 목표로 초안이 만들어졌다. 그러나 니고시에이션 흐름이 크게 달라져버렸고, 다른 방식인 ALPN(Applcation-Layer Protocol Negotiation) 확장 방식이 선택돼 RFC 7301이 됐다.

 

ALPN에서는 TLS의 최초 핸드셰이크 시 (Client Hello) 클라이언트가 서버에 '클라이언트가 이용할 수 있는 프로토콜 목록'을 첨부해서 보낸다. 서버는 그에 대한 응답 (Server Hello)으로 키 교환을 하고 인증서와 함께 선택한 프로토콜을 보낸다. 클라이언트가 보낸 목록에서 서버가 사용할 프로토콜을 하나 골라 반환하는 방법은 콘텐트 니고시에이션과 같다.

 

선택할 수 있는 프로토콜 목록은 IANA에서 관리한다. 현재 등록된 이름은 아래와 같다. 주로 HTTP 계열과 WebRTC 계열 프로토콜이 있다.

 

 

2.9 TLS가 지키는 것

TLS 1.3의 인증된 암호 모드 알고리즘은 통신 내부가 보이지 않게 하고, 조작도 사칭도 되지 않도록 보호한다. 여기서 중요한 것은 공통 키의 안전한 교환이다. 그렇기때문에 DHE, ECDHE 같은 키 교환 알고리즘을 이용하여 키를 찾기 힘들도록 한다. 다만 이 방법은 도중에 통신 내용을 바꿀 수 있는 중간자 공격에 약하기 때문에, 인증서 인증을 함께 사용하여 조작 위험성을 줄인다.

 

 

3.0 PUT 메서드와 DELETE 메서드와 표준화

HTTP/1.0에서 옵션이었던 PUT과 DELETE 메서드도 HTTP/1.1에서는 필수 메서드로 추가됐다. 이로써 DB에서 데이터를 다룰 때 사용하는 기본적인 CRUD가 갖추어져, HTTP는 데이터를 취급하는 프로토콜로도 이용할 수 있게 됐다.

데이터를 다루는 기본 메서드

 

데이터베이스의 경우 트랜잭션이라는 큰 테두리 안에서 데이터의 불일치가 일어나지 않도록 CRUD를 사용한 1회 액션으로 데이터를 갱신한다. HTTP에는 트랜재개션이 없고, 1회 액션에 해당하는 조작이 HTTP의 1 request이다. 

 

 

 

4.0 OPTIONS, TRACE, CONNECT 메서드 추가

HTTP/1.1에서는 OPTIONS, TRACE, CONNECT라는 새로운 메서드가 추가됐다. 이 중에서 CONNECT가 가장 자주 사용되는 새 메서드다.

 

4.1 OPTIONS

서버가 받아들일 수 있는 메서드 목록을 반환

command 창에서 curl을 이용해서 불러보자. google에 테스트를 해봤고 아래 결과를 받았다.

$ curl -X OPTIONS -v https://www.google.com
....


< HTTP/2 405 
< allow: GET, HEAD
< date: Thu, 22 Feb 2024 11:05:58 GMT
< content-type: text/html; charset=UTF-8

allow 헤더에 GET, HEAD만 받아들일 수 있다고 응답을 보낸 것을 확인할 수 있다. 참고로 405 Method Not Allowed를 반환했는데, google에서 OPTIONS라는 메서드는 받아들일 수 없기때문에 405를 반환한 것이다. 추가적으로 네이버는 404 Not Found를 반환한다. OPTIONS 헤더로 요청이 오면 404 Not Found를 반환하도록 되어있는 것 같다.

 

4.3 CONNECT

HTTP 프로토콜상에 다른 프로토콜의 패킷을 흘릴 수 있게 한다. 프록시 서버를 거쳐, 대상 서버에 접속하는 것을 목적으로 한다.

주로 https 통신을 중계하는 용도로 사용된다. (왜?) Squid의 CONNECT 설정에 관한 웹 문서를 보더라도 https 이외의 CONNECT 접속을 거부한다라는 설정을 소개한 페이지가 대부분이다. 

 

CONNECT 메서드를 이용하고 싶은 클라이언트는 다음과 같은 내용을 프록시 서버에 전송한다.

CONNECT example.con:8889 HTTP 1.1

CONNECT 메서드를 무조건 받아들이는 프록시는 아무 프로토콜이나 통과시켜버리므로, 맬웨어가 메일을 보내거나 하는 통신 경로로 사용될 위험이 있다. 

 

실제 프록시 서버인 squid를 사용해, 외부 사이트에 연결해보자. 로컬 3128 포트에서 squid를 시작한다.

$ docker run -d -p 3128:3128 --name squid poklet/squid

다음으로 squid를 프록시로 사용해 외부 https 서버에 접속해보자. 

$ curl -x http://localhost:3128 -v https://yahoo.com
*   Trying 127.0.0.1:3128...
* Connected to (nil) (127.0.0.1) port 3128 (#0)
* allocate connect buffer
* Establish HTTP proxy tunnel to yahoo.com:443
> CONNECT yahoo.com:443 HTTP/1.1
> Host: yahoo.com:443
> User-Agent: curl/7.86.0
> Proxy-Connection: Keep-Alive

< HTTP/1.0 200 Connection established

* Proxy replied 200 to CONNECT request
* CONNECT phase completed
* ALPN: offers h2
* ALPN: offers http/1.1
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES128-GCM-SHA256
* ALPN: server accepted h2
* Server certificate:
*  subject: C=US; ST=California; L=Sunnyvale; O=Oath Holdings Inc.; CN=yahoo.com
*  start date: Feb 20 00:00:00 2024 GMT
*  expire date: Aug 14 23:59:59 2024 GMT
*  subjectAltName: host "yahoo.com" matched cert's "yahoo.com"
*  issuer: C=US; O=DigiCert Inc; OU=www.digicert.com; CN=DigiCert SHA2 High Assurance Server CA
*  SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: yahoo.com]
* h2h3 [user-agent: curl/7.86.0]
* h2h3 [accept: */*]
* Using Stream ID: 1 (easy handle 0x7fe53500d000)
> GET / HTTP/2
> Host: yahoo.com
> user-agent: curl/7.86.0
> accept: */*

< HTTP/2 301 
< date: Thu, 22 Feb 2024 15:53:06 GMT
< strict-transport-security: max-age=31536000
< server: ATS
< cache-control: no-store, no-cache
< content-type: text/html
< content-language: en
< x-frame-options: SAMEORIGIN
< referrer-policy: no-referrer-when-downgrade
< x-content-type-options: nosniff
< x-xss-protection: 1; mode=block
< location: https://www.yahoo.com/
< content-length: 8

* Connection #0 to host (nil) left intact

먼저 로컬 호스트의 3128 포트에 접속했지만, CONNECT 메서드로 yahoo.com의 https용 포트인 443 포트에 연결하러 가고 있는 것을 볼 수 있다.  HTTP/1.0 200 Connection established를 반환한 것은 프록시 서버이다. 실제 yahoo.com 서버는 www.yahoo.com으로 연결되길 원하므로, 301 Redirect를 반환했다. 이쪽은 프록시가 아닌 프록시 끝의 서버가 반환하는 내용이다.

 

확인을 다 했으면, 아래 명령어로 Squid 프록시 서버를 멈춰주자.

$ docker stop squid

 

 

 

5.0 프로토콜 업그레이드

HTTP/1.1 부터는 HTTP 이외의 프로토콜로 업그레이드가 가능하다. HTTP/1.0과 HTTP/1.1은 text 기반의 알기 쉬운 프로토콜이지만, 이 기능을 사용해 이진 프로토콜로 교체할 수 있다. 업그레이드는 클라이언트, 서버 측 모두 요청할 수 있다.

  • HTTP에서 TLS를 사용한 안전한 통신으로 업그레이드 (TLS/1.0, TLS/1.1, TLS/1.2)
  • HTTP에서 웹소켓을 사용한 양방향 통신으로 업그레이드(websocket)
  • HTTP에서 HTTP/2로 업그레이드(h2c)

HTTP에서 TLS로의 업그레이드는 RFC 2817에 설명되어 있다. 다만 이 방법으로 업그레이드해도 보안이 지켜지지 않는 문제가 있다. 현재는 모든 통신이 TLS화 되고 있으며, TLS 자체가 갖는 핸드세이크 시 프로토콜 선택 기능(ALPN)을 사용하도록 권장하고 있다. HTTP/2에서는 프로토콜 업그레이드 기능이 삭제 됐다.

 

HTTP/2 통신도 TLS를 전제로 하고, TLS의 ALPN 사용을 권장한다. 현재 프로토콜 업그레이드는 거의 웹소켓용 이다. 

요청 하는 방법은 167page 참고. 보통 클라이언트, 서버 측 모두 Upgrade와 Connection 헤더를 포함해서 요청한다.

 

 

6.0 가상 호스트 지원

HTTP/1.0은 한 대의 웹 서버로 하나의 도메인만 다루는 것이 전제였다. 하지만 웹사이트마다 서버를 따로 준비하는 것은 매우 힘든 일이다. 그렇기때문에 하나의 웹 서버로 여러 웹 서비스를 운영하는 방법이 HTTP/1.1에서 지원되기 시작했다.

 

http://example.com/hello라는 url에 접속하고 싶다고 가정해보자. example.com 부분을 꺼내서 도메인 네임 서버에 문의하면 도메인을 갖는 서버의 IP 주소를 알 수 있다. 다음에 http 부분 또는 도메인 이름 뒤에 포트 번호(8080)를 정해보자. HTTP/1.0까지는 실제의 서버가 받는 정보는 마지막 경로인 /hello 뿐이었다. 

 

HTTP/1.1에서는 클라이언트가 Host 헤더에 요청을 보내고자 하는 서버 이름을 기술할 의무가 생겼다. curl 커맨드도 아무런 설정을 하지 않아도 이 헤더를 부여한다. 같은 서버 같은 포트로 tokyo.example.com과 osaka.example.com이라는 두 개의 서비스가 호스트되고 있다고 가정하자. 요청 헤더의 Host 헤더를 보면, 서버는 어떤 서비스를 요청하는지를 판정할 수 있다.

 

아파치 웹 서버를 사용하면 호스트 이름에 따라서 해당하는 서비스의 콘텐츠를 가져와 반환할 수 있다.

NameVirtualHost *:80

<VirtualHost *:80>
    ServerName tokyo.example.com
    DocumentRoot /www/tokyo
</VirtualHost>

<VirtualHost *: 80>
    ServerName osaka.example.com
    DocumentRoot /www/osaka
</VirtualHost>

클라이언트에서는 Host를 붙이는 것 뿐이지만, 서버에서는 그 정보를 바탕으로 같은 서버에서 콘텐츠를 구분해 보낼 수 있게 된다.

 

 

7.0 청크

HTTP/1.1에서 지원되는 새로운 데이터 표현으로, 전체를 한꺼번에 전송하지 않고 작게 나눠 전송하는 청크방식이 있다. 청크를 사용하면 시간이 오래 걸리는 데이터 전송을 조금씩 앞당겨 시행할 수 있다. 청크 방식을 스트리밍 다운로드/업로드라고 부르는 경우도 있다. 

 

예를 들면 라이브 동영상을 배포하거나 시간이 걸리는 검색 결과를 전송할 때, 동영상의 앞부분부터 혹은 검색 엔진이 찾아낸 순서대로 반환할 수 있다. 클라이언트 측에서 처리할 때는 청크를 통합한 후 처리하지만, 서버 측에서는 전송에 필요한 블록만 메모리에 로드해 TCP 소켓에 데이터를 실어 보낼 수 있다. 따라서 1GB짜리 동영상 파일을 보내는 경우라도 메모리를 1GB 소비하는 일은 없다. 클라이언트 측의 장점으로는 서버 측에서 마지막 데이터 준비가 됐을 무렵엔 그 전까지의 데이터는 이미 전송이 끝났으므로 리드 타임을 짧게 할 수 있다. JPEG, GIF, PNG라면 다운로드된 부분만 표시하거나 인터레이스 방식 표시도 할 수 있으므로 사용자에 대한 응답속도도 빨라진다.

 

청크의 구조는 아래와 같다.

HTTP/1.1 200 OK
Date: Sun, 3 Mar 2024 00:50:21 GMT
Content-Type: video/webm
Transfer-Encoding: chunked

186a0
(100KB분의 데이터)
186a0
(100KB분의 데이터)
186a0
(100KB분의 데이터)
0

 

바디는 몇 개의 데이터 덩어리로 나뉘어 있다. 우선 16진수로 표시된 파일 크기가 표시되어 있고, 그 뒤로 지정한 크기만큼 데이터가 이어진다. Transfer-Encoding: chunked가 설정됐을 때는 Content-Length 헤더를 포함해선 안 된다고 RFC에 정의되어 있다. 데이터 크기는 지정된 크기의 합계가 된다. 마지막으로 0을 보내면 청크 전송이 모두 끝났다는 신호가 된다.

 

청크는 다운로드뿐만이 아니라 업로드에서도 사용할 수 있다. 업로드할 때도 형식은 똑같다.

 

 

7.1 메시지 끝에 헤더 추가

청크 형식으로 전송하는 경우에 청크된 메시지 끝에 헤더를 추가할 수 있게 됐다.

Trailer: Content-Type

'여기서 부여한 헤더는 바디를 보낸 후 전송된다'라고 알려준다. 청크 형식으로만 사용할 수 있다는 것은 청크 형식임을 사전에 알 수 있게 해야 하므로, 이를 위해 필요한 헤더는지정할 수 없다. 또한 Trailer 자신을 나중에 보낼 수 없다. 따라서 다음의 헤더는 지정할 수 없다.

  • Transfer-Encoding
  • Content-Length
  • Trailer

 

8.0 바디 전송 확인

클라이언트에서 서버로 한 번에 데이터를 보내는 게 아니라, 일단 받아들일 수 있는지 물어보고 나서 데이터를 보내는 2단계 전송을 할 수 있게 됐다.

 

우선 클라이언트는 다음 헤더와 바디를 제외한 모든 헤더를 지정해 문의한다. 파일이 없어도 Content-Length 헤더를 함께 보낸다.

Expect: 100-continue

만약 서버로부터 아래와 같은 응답이 돌아왔다면, 서버가 처리할 수 있다는 말이므로 바디를 붙여 다시 전송한다.

100 Continue

서버가 지원하지 않으면 417 EXPECTATION FAILED가 돌아오기도 한다.

 

curl 커맨드는 기본적으로 이 헤더를 전송해 2단계로 포스트한다. 전송할 콘텐츠의 크기가 1025 바이트 이상이면 이렇게 동작한다. 이를 억제하려면 아래와 같이 Expect 헤더를 비워서 보낸다.

curl -H "Expect:" --data-binary @bigfile.txt http://localhost:18888       

 

 

 

 

Reference

  • 리얼월드 HTTP : 역사와 코드로 배우는 인터넷과 웹 기술

 

Why>
무분별한 크롤링을 막기위한 방식이 필요하다.

인터넷은 브라우저를 이용해 문서를 열람하는 구조로 출발했지만, 점차 검색 엔진이 정보를 수집하는 자동 순회 프로그램이 많이 운용되게 됐다. 자동 순회 프로그램은 '크롤러', '로봇', '봇' 과 같은 이름으로 불린다. 정확히 자동 순회 프로그램은 봇이지만, 대부분 검색 엔진에서 정보를 수집(크롤)하는 용도로 운용되므로 거의 같은 뜻으로 사용된다. 

 

크롤러의 접근을 제어하는 방법으로 주로 아래와 같이 두 가지가 사용된다.

  • robots.txt
  • 사이트맵

1. robots.txt

서버 콘텐츠 제공자가 크롤러에게 접근 허가 여부를 전하기 위한 프로토콜

크롤러 개발자가 멤버로 참여한 메일링 리스트 내에서 논의되어 1994년 무렵네 정해졌다. 이 규칙을 읽고 해석해 실제로 접근을 중단하는 것은 크롤러 쪽이므로, 크롤러 개발자들의 신사협정이라 할 수 있다. 현재는 구글, 네이버, 빙 등 많은 검색 엔진의 크롤러가 이 텍스트를 해석할 수 있다.

 

robots.txt는 아래와 같은 형식으로 읽기를 금지할 크롤러의 이름과 장소를 지정한다.

User-agent: *
Disallow: /cgi-bin/
Disallow: /tmp/

여기서 모든 크롤러에 대해 /cgi-bin 폴더와 /tmp 폴더 접근을 금지했다. User-agent에 구글 봇처럼 개별적으로 지정할 수도 있다.

 

robots.txt와 비슷한 내용을 HTML의 메타 태그로도 기술할 수 있다. robots.txt가 우선하지만, 메타 태그로 더 자세히 지정할 수 있다. 아래는 메타 태그로 크롤러를 거부한다는 의미이다.

<meta name="robots" content="noindex" />

 

content 속성에는 다양한 디렉티브를 기술할 수 있다. 구글 봇이 해석하는 디렉티브에 관한 자세한 내용은 구글 사이트(https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag)에 기재되어 있다. 대표적으로는 아래와 같다.

구글 디렉티브

같은 디렉티브는 HTTP의 X-Robots-Tag 헤더에도 쓸 수 있다. 아래는 그 예시이다.

X-Robots-Tag: noindex, nofollow

 

 

2. robots.txt와 재판 결과

robots.txt는 1997년 RFC 드래프트 단계까지 도달했지만, 현 시즘에서 정식 RFC는 아니다. 그러나 사실상 표준으로 널리 인지되고 있고 HTML4 사양에서도 설명되고 있다. 게다가 법적으로 효력이 있는 판례가 나오고 있고, 2014년 개정된 일본 저작권법 시행 규칙에도 'robots.txt'라고 명시되어 있다.

 

2006년에는 구글의 캐시가 저작권을 침해한다는 이유로 열린 유명한 소송 '필드 대 구글 사건'이 있다. 이는 작가이자 변호사였던 블레이크 필드가 구글에 낸 소송이었는데, 결과적으로 구글의 주장이 인정되었다. 판결에 결정적인 것이 robots.txt였다. 원고인 필드는 robots.txt로 크롤러의 접속을 금지하는 방법을 알고 있었지만 그 방법을 쓰지 않았기 때문에 재판에서 저작권 침해를 주장할 수 없었다. 

 

robots.txt 웹사이트에서는 관련 판례를 몇 가지 소개하고 있다. 웹 서비스 제공자와 크롤러 제작자 사이에 계약서를 쓰지 않지만, robots.txt를 설치하면 웹 서비스 제공자가 명확히 의사를 표명한 것으로 봐야 하므로 크롤러는 이를 지켜야 한다. 또한, robots.txt를 설치해 거부하지 않는 콘텐츠를 크롤러가 이용하는 것은 '금반언원칙'을 어기는 것으로 필드 대 구글사건처럼 소송이 기각된다.

 

찾아보니 웹 서비스를 제공하는 유명한 회사들은 robots.txt를 제공한다.

네이버 robots.txt

위 robots.txt 파일의 의미는 사이트의 루트 페이지만 수집 허용으로 설정한다는 것이다. 이전 프로젝트를 진행하며 Indeed라는 공고 사이트에서 크롤링을 하려한 적이 있었는데, 크롤링을 하지 못하도록 막혀있었던 경험이 있다. http://www.indeed.com/robots.txt를 확인해보니 경로를 Disallow 해놓은 것을 확인할 수 있다.

 

3. 사이트맵

사이트맵은 웹사이트에 포함된 페이지 목록과 메타데이터를 제공하는 XML 파일로, 2005년에 구글이 개발해 야후나 마이크로소프트에서도 이용하게 됐다. robots.txt가 블랙리스트처럼 사용된다면, 사이트맵은 화이트리스트처럼 사용된다. 사이트맵은 구글, 야후, 마이크로소프트가 공동으로 운용하는 다음 사이트에 사양이 공개되어 있다.

사이트맵 사이트에는 기본 설정 항목이 정의되어 있지만, 해석하는 검색 엔진마다 다른 기능이 추가되기도 한다. 아래 예제처럼 XML 형식으로 기술한다.

<?xml version="1.0" encoding="UTF-8"?>

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">

   <url>

      <loc>http://www.example.com/</loc>

      <lastmod>2005-01-01</lastmod>

      <changefreq>monthly</changefreq>

      <priority>0.8</priority>

   </url>

</urlset>

 

이 <url> 태그를 등록하고 싶은 페이지 수만큼 작성한다. <loc>는 절대 URL이다. xml 형식이 가장 많이 사용되지만 단순히 URL이 나열된 txt 파일이나 Rss 등의 형식도 사이트맵으로 사용할 수 있다.

 

사이트맵은 robots.txt에 쓸 수도 있다. 또한, 각 검색 엔진에 XML 파일을 업로드하는 방법도 있다. 아래는 robots.txt에 작성한 예시이다.

Sitemap: http://www.example.org/sitemap.xml

 

구글의 경우는 사이트맵을 사용해 웹 사이트의 메타데이터를 검색 엔진에 전달할 수 있다.

 

 

 

 

Reference

  • 리얼월드 HTTP : 역사와 코드로 배우는 인터넷과 웹 기술

 

Why>
클라이언트가 해당 사이트에 어떠한 경로로 접근하는지 알 수 있도록 해주는 헤더가 필요해서 만들어졌다.

 

사용자가 어느 경로로 웹사이트에 도달했는지 서버가 파악할 수 있도록 클라이언트가 서버에 보내는 헤더다. 추측이지만, Tistory 블로그 관리탭에 보면 유입 경로, 방문 통계에서 사용되는 헤더인 것 같다. 

 

클라이언트가 http://www.example.com/link.html의 링크를 클릭해서 다른 사이트로 이동할 때, 링크가 있는 페이지의 URL을 목적지 사이트의 서버에 아래와 같은 형식으로 전송한다.

Referer: http://www.example.com/link.html

철자가 referrer가 아닌 referer은 이유는 RFC 1945 제안 당시의 오자가 그대로 남아있기 때문이다.

 

만약 북마크에서 선택하거나 주소창에서 키보드로 직접 입력했을 때는 Referer 태그를 전송하지 않거나 Referer:about:blank를 전송한다.

 

 

 

 

Reference

  • 리얼월드 HTTP : 역사와 코드로 배우는 인터넷과 웹 기술

 

Cache

Why>
변경되지 않은 파일, 데이터, 값들을 다시 통신을 통해 가져올 필요가 있는가에 대한 고민으로 나온 기술이다. 자주 사용하는 데이터, 파일, 값을 미리 복사하여 사용하는 방식이다.

 

 

갱신 일자에 따른 캐시

What>
웹서버가 Last-Modified라는 헤더를 Response에 포함하여 클라이언트로 전송한다. 날짜는 RFC 1123이라는 형식으로 기술되며 Timezone은 GMT를 설정.

 

웹서버에서 보낸 헤더

Last-Modified: Web, 03 Jun 2023 15:23:45 GMT

웹 브라우저가 캐시된 URL을 다시 읽을 때는 서버에서 반환된 일시를 그대로 If-Modified-Since 헤더에 넣어서 요청

If-Modified-Since: Web, 03 Jun 2023 15:23:45 GMT

웹서버는 클라이언트의 요청을 받고 If-Modified-Since 헤더를 통해 일시를 확인한다. 만약, 웹 서버에 데이터가 변경되었다면 200 OK Status Code와 데이터를 Body에 넣어 보낸다. 그대로라면 304 Not Modified Status Code를 반환한다.

 

갱신 일자

 

 

Expires

Why>
갱신 일시를 이용하는 캐시의 경우 캐시의 유효성을 확인하기 위한 통신이 발생한다. 확인하기 위한 통신을 하지 않고, 캐시를 사용할 수 있도록 해주는 헤더

 

HTTP/1.0에 도입되었고, Expires Header를 이용하는 방법이다. Expires header에는 날짜와 시간이 들어간다. 클라이언트는 지정한 기한 내라면 캐시가 신선하다고 판단해 강제로 캐시를 이용한다. 

Expires: Web, 03 Jun 2023 15:23:45 GMT

Expires

 

 

Pragma: no-cache

Why>
요청을 보낼 때 저장된 캐시를 사용하지 않고 싶을 수도 있다. 그러기 위해서는 헤더에 저장된 캐시를 사용하지 않겠다는 것을 명시해야 한다. 이를위해 사용하는 것이 Pragma이다.

 

no-cache는 '요청한 컨텐츠가 이미 저장돼 있어도, 원래 서버에서 가져오라'고 프록시 서버에 지시하는 것이다. 참고로, no-cache는 HTTP/1.1에서 Cache-Control로 통합되었다. 

 

 

ETag 추가

Why>
날짜와 시간을 이용한 캐시 비교만으로 해결할 수 없을 때도 있다. 클라이언트마다 같은 사이트이지만, 각각 다른 화면을 동적으로 보여줘야한다면 말이다. 하지만, 동적으로 바뀌는 요소가 늘어날수록 어떤 날짜를 근거로 캐시의 유효성을 판단해야 하는지는 정말 어렵다. 따라서 하나의 수치로 귀착시키는 방법이 ETag이다.

 

RFC 2068의 HTTP/1.1에서 추가된 ETag는 순차적인 갱신 일시가 아니라 파일의 해시 값으로 비교한다. 웹서버에서 응답에 ETag 헤더를 부여한다. 클라이언트는 If-None-Match 헤더에 다운로드된 캐시에 들어있던 ETag 값을 추가해 요청한다. 서버는 보내려는 파일의 ETag와 비교해서 같으면 304 Not Modified Status Code를 반환한다. 

ETag

 

 

 

Cache-Control

Why>
보다 자세한 Cache Control을 위해 만들어졌다.

 

ETag와 같은 시기에 HTTP/1.1에서 추가된 것이 Cache-Control이다. Expires보다 우선해서 처리된다. 서버가 response로 보내는 헤더는 아래와 같다.

  • public
    • 같은 컴퓨터를 사용하는 복수의 사용자간 캐시 재사용을 허가한다. 
  • private
    • 같은 컴퓨터를 사용하는 다른 사용자 간 캐시를 재사용하지 않는다. 같은 URL에서 사용자마다 다른 콘텐츠가 들어오는 경우
  • max-age-n
    • 캐시의 신선도를 초단위로 설정한다. 86400을 지정하면 하루동안 캐시가 유효하고 서버에 요청하지 않고 캐시를 이용한다. 그 이후는 서버에 요청하고 304 Not Modified가 반환됐을 때만 캐시를 이용한다.
  • s-maxage-n
    • max-age-n과 같으나 공유 캐시에 대한 설정값이다.
  • no-cache
    • 캐시가 유효한지 매번 요청한다. max-age-0과 거의 같다.
    • Pragma: no-cache와 똑같이 캐시하지 않는 것은 아니다. 시간을 보고 서버에 접속하지 않은 채 콘텐츠를 재이용하는 것을 그만둘 뿐이다. 갱신 일자와 ETag를 사용하며, 서버가 304를 반환했을 때 이용하는 캐시는 유효하다.
  • no-store
    • 캐시하지 않는다.

위 헤더를 콤마로 구분해서 복수로 지정이 가능하지만, 내용면에서 아래와 같이 조합한다.

  • private, public 중 하나 혹은 설정하지 않는다. (default는 private)
  • max-age, s-maxge, no-cache, no-store 중 하나

cache-control

 

클라이언트 측에서 요청 헤더에 사용할 수 있는 설정 값은 아래와 같다.

  • no-cache
    • Pragma: no-cache와 같다.
  • no-store
    • 응답의 no-store와 같고, 프록시 서버에 캐시를 삭제하도록 요청
  • max-stale
    • 지정한 시간만큼 유지 기간이 지났어도 클라이언트는 지정한 시간 동안 저장된 캐시를 재사용하라고 프록시에 요청. 연장 시간은 생략할 수 있고, 그런 경우 영원히 유효하다는 의미 
  • min-fresh
    • 캐시의 수명이 지정된 시간 이상 남아있을 때, 캐시를 보내도 좋다고 프록시에 요청. 즉, 적어도 지정된 시간만큼은 신선해야한다.
  • no-transform
    • 프록시가 콘텐츠를 변형하지 않도록 요청 (게이트웨이와 같이 동작하라는 의미?)
  • only-if-cached
    • 캐시된 경우에만 응답을 반호나하고, 캐시된 콘텐츠가 없을 땐 504 Gateway Timeout status code를 반환하도록 프록시에 요청. 이 헤더가 설정되면 처음을 제외하고 오리진 서버에 전혀 액세스 하지 않음.

 

응답 헤더에서 서버가 프록시에 보내는 캐시 컨트롤 지시는 아래와 같다. 

  • no-transform
    • 프록시가 콘텐츠를 변경하는 것을 제어한다.
  • must-revalidate
    • no-cache와 비슷하지만 프록시 서버에 보내는 지시. 프록시 서버가 서버에 문의했을 때 서버의 응답이 없으면, 프록시 서버가 클라이언트에 504 Gateway Timeout이 반환되기를 기대.
  • proxy-revalidate
    • must-revalidate와 같지만, 공유 캐시에 요청

 

Vary

Why>
ETag는 같은 URL이라도 개인마다 달라지는 경우에 사용한다. 하지만, 같은 URL이라도 클라이언트에 따라 반환 결과가 달라야할 경우도 존재함. 이 경우에 사용하는 것이 Vary다.

 

예를들어, 사용자의 브라우저가 스마트폰용 일 때는 모바일용 페이지를 보여주는 등 언어에 내용이 바뀌는 경우를 들 수 있다. 이처럼 표시가 바뀌는 이유에 해당하는 헤더명을 Vary에 나열함으로써 잘못된 콘텐츠의 캐시로 사용되지 않게 합니다.

Vary: User-Agent, Accept-Language

 

로그인이 필요한 사이트라면 쿠키도 지시할 것이다.

 

Vary header는 검색 엔진용 힌트로도 사용된다. 브라우저 종류에 따라 콘텐츠가 바뀔 수 있다는 것은 모바일 버전은 다르게 보일수도 있다고 판단할 수 있는 재료가 된다. 그리고 영어 버전, 한국어 버전 등 언어별로 바르게 인덱스를 만드는 힌트도 된다.

 

모바일 브라우저인지 판정하는 방법은 User-Agent이다. 아래는 안드로이드 user-agent의 예시이다.

User-Agent: "Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36"

아래는 윈도우 user-agent의 예시이다.

User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246"

이처럼 User-Agent를 통해 판정할 수 있다. 서버에서는 이 정보를 바탕으로 콘텐츠를 나눠 내보낼 수 있다. 2017년 구글 가이드라인에서는 같은 콘텐츠를 모든 브라우저에 배포하고, 브라우저가 필요한 설정을 선택하는 반응형 웹 디자인을 권장한다. 

 

 

 

 

 

Reference

  • 리얼월드 HTTP : 역사와 코드로 배우는 인터넷과 웹 기술

 

 

 

 

1939번: 중량제한

첫째 줄에 N, M(1 ≤ M ≤ 100,000)이 주어진다. 다음 M개의 줄에는 다리에 대한 정보를 나타내는 세 정수 A, B(1 ≤ A, B ≤ N), C(1 ≤ C ≤ 1,000,000,000)가 주어진다. 이는 A번 섬과 B번 섬 사이에 중량제한이

www.acmicpc.net

 

 


 

문제

N개의 섬으로 이루어진 섬들 사이에서 다리가 M개 설치되어 있다. 다리는 양방향으로 연결되어있고, c라는 최대 용량이 존재한다.

A섬에서 B섬으로 갈 때, 이동할 수 있는 다리 중 최대 용량을 옮길 수 있는 값을 출력해야 한다.

 

입력

N M

M개의 다리 (a, b, c) - 양방향

A섬 B섬

 

풀이

가장 먼저 생각한 방법은 다익스트라 였다. (무게를 내림차순으로 bfs를 도는 방식)

distance라는 int 배열을 통해 해당 섬으로 갔을 때의 최대 용량을 업데이트 하면서 bfs를 도는 방식이다. (최대 용량보다 낮다면 굳이 이후 작업을 할 필요가 없기 때문)

Add
문제를 풀고 다른 사람들의 코드를 보며 안 방법인데, 이분탐색 방식도 가능하다. 아래 코드추가 해놓음

 


 

코드 (메모리 초과)

import java.io.*;
import java.math.BigInteger;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Main {
    static int N, M, start, end, answer;
    static int[] distance;
    static List<Node>[] lists;

    public static void main(String[] args) throws Exception {
        InputReader in = new InputReader(System.in);

        N = in.nextInt();
        M = in.nextInt();
        distance = new int[N+1];
        lists = new ArrayList[N+1];
        answer = 0;
        for(int i = 1; i <= N; i++) lists[i] = new ArrayList<Node>();
        // A, B, C
        // 1 <= A,B <= N
        // 1 <= C <= 10억
        for(int i = 0; i < M; i++) {
            int a = in.nextInt();
            int b = in.nextInt();
            int c = in.nextInt();

            lists[a].add(new Node(b,c));
            lists[b].add(new Node(a,c));
        }

        start = in.nextInt();
        end = in.nextInt();

        bfs();
        System.out.println(distance[end]);
    }

    public static void bfs() {
        PriorityQueue<Node> pq = new PriorityQueue<>();
        distance[start] = 1000000001;
        pq.add(new Node(start, 1000000001));
        while(!pq.isEmpty()) {
            Node node = pq.poll();

            if(distance[node.next] > node.weight) continue;

            for(int i = 0; i < lists[node.next].size(); i++) {
                Node next = lists[node.next].get(i);

                if(distance[next.next] > Math.min(node.weight, next.weight) ||
                        distance[end] > Math.min(node.weight, next.weight)) continue;
                distance[next.next] = Math.min(node.weight, next.weight);
                pq.add(new Node(next.next, Math.min(node.weight, next.weight)));
            }
        }
    }
}

class Node implements Comparable<Node>{
    int next;
    int weight;

    public Node(int next,int weight) {
        this.next = next;
        this.weight = weight;
    }

    @Override
    public int compareTo(Node o) {
        return o.weight - this.weight;
    }
}

class InputReader {
    private final InputStream stream;
    private final byte[] buf = new byte[8192];
    private int curChar, snumChars;

    public InputReader(InputStream st) {
        this.stream = st;
    }

    public int read() {
        if (snumChars == -1)
            throw new InputMismatchException();
        if (curChar >= snumChars) {
            curChar = 0;
            try {
                snumChars = stream.read(buf);
            } catch (IOException e) {
                throw new InputMismatchException();
            }
            if (snumChars <= 0)
                return -1;
        }
        return buf[curChar++];
    }

    public int nextInt() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        int res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public long nextLong() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        long res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public int[] nextIntArray(int n) {
        int a[] = new int[n];
        for (int i = 0; i < n; i++) {
            a[i] = nextInt();
        }
        return a;
    }

    public String nextLine() {
        int c = read();
        while (isSpaceChar(c))
            c = read();
        StringBuilder res = new StringBuilder();
        do {
            res.appendCodePoint(c);
            c = read();
        } while (!isEndOfLine(c));
        return res.toString();
    }

    public boolean isSpaceChar(int c) {
        return c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == -1;
    }

    private boolean isEndOfLine(int c) {
        return c == '\n' || c == '\r' || c == -1;
    }
}

class가 8바이트로 10만개를 추가해도 80만 바이트이기 때문에 128MB를 넘지 않으리라고 생각했다. 어디서 무한루프를 빠진다고 생각했고 문제가 되는 부분은 if(distance[next.next] > Math.min(node.weight, next.weight)) 였다. >=를 추가하지 않았다. 업데이트하면서 bfs를 돌지만 distance값과 Math.min 값이 같기 때문에 무한루프에 빠지는 것이였다.

 

코드 (정답)

import java.io.*;
import java.math.BigInteger;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Main {
    static int N, M, start, end, answer;
    static int[] distance;
    static List<Node>[] lists;

    public static void main(String[] args) throws Exception {
        InputReader in = new InputReader(System.in);

        N = in.nextInt();
        M = in.nextInt();
        distance = new int[N+1];
        lists = new ArrayList[N+1];
        answer = 0;
        for(int i = 1; i <= N; i++) lists[i] = new ArrayList<Node>();
        // A, B, C
        // 1 <= A,B <= N
        // 1 <= C <= 10억
        for(int i = 0; i < M; i++) {
            int a = in.nextInt();
            int b = in.nextInt();
            int c = in.nextInt();

            lists[a].add(new Node(b,c));
            lists[b].add(new Node(a,c));
        }

        start = in.nextInt();
        end = in.nextInt();

        bfs();
        System.out.println(distance[end]);
    }

    public static void bfs() {
        PriorityQueue<Node> pq = new PriorityQueue<>();
        distance[start] = 1000000001;
        pq.add(new Node(start, 1000000001));
        while(!pq.isEmpty()) {
            Node node = pq.poll();

            if(distance[node.next] > node.weight) continue;

            for(int i = 0; i < lists[node.next].size(); i++) {
                Node next = lists[node.next].get(i);

                if(distance[next.next] >= Math.min(node.weight, next.weight) ||
                        distance[end] >= Math.min(node.weight, next.weight)) continue;
                distance[next.next] = Math.min(node.weight, next.weight);
                pq.add(new Node(next.next, Math.min(node.weight, next.weight)));
            }
        }
    }
}

class Node implements Comparable<Node>{
    int next;
    int weight;

    public Node(int next,int weight) {
        this.next = next;
        this.weight = weight;
    }

    @Override
    public int compareTo(Node o) {
        return o.weight - this.weight;
    }
}

class InputReader {
    private final InputStream stream;
    private final byte[] buf = new byte[8192];
    private int curChar, snumChars;

    public InputReader(InputStream st) {
        this.stream = st;
    }

    public int read() {
        if (snumChars == -1)
            throw new InputMismatchException();
        if (curChar >= snumChars) {
            curChar = 0;
            try {
                snumChars = stream.read(buf);
            } catch (IOException e) {
                throw new InputMismatchException();
            }
            if (snumChars <= 0)
                return -1;
        }
        return buf[curChar++];
    }

    public int nextInt() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        int res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public long nextLong() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        long res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public int[] nextIntArray(int n) {
        int a[] = new int[n];
        for (int i = 0; i < n; i++) {
            a[i] = nextInt();
        }
        return a;
    }

    public String nextLine() {
        int c = read();
        while (isSpaceChar(c))
            c = read();
        StringBuilder res = new StringBuilder();
        do {
            res.appendCodePoint(c);
            c = read();
        } while (!isEndOfLine(c));
        return res.toString();
    }

    public boolean isSpaceChar(int c) {
        return c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == -1;
    }

    private boolean isEndOfLine(int c) {
        return c == '\n' || c == '\r' || c == -1;
    }
}

 

 

이분탐색 코드 (정답)

ㄴimport java.io.*;
import java.math.BigInteger;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Main {
    static int N, M, start, end, answer;
    static List<Node>[] lists;

    public static void main(String[] args) throws Exception {
        InputReader in = new InputReader(System.in);

        N = in.nextInt();
        M = in.nextInt();
        lists = new ArrayList[N+1];
        answer = 0;
        for(int i = 1; i <= N; i++) lists[i] = new ArrayList<Node>();

        int left = Integer.MAX_VALUE;
        int right = Integer.MIN_VALUE;
        for(int i = 0; i < M; i++) {
            int a = in.nextInt();
            int b = in.nextInt();
            int c = in.nextInt();

            lists[a].add(new Node(b,c));
            lists[b].add(new Node(a,c));
            left = Math.min(left, c);
            right = Math.max(right, c);
        }

        start = in.nextInt();
        end = in.nextInt();

        while(left <= right) {
            int middle = (left + right) / 2;
            if(bfs(middle)) {
                left = middle + 1;
                answer = middle;
            }
            else right = middle - 1;
        }
        System.out.println(answer);
    }

    public static boolean bfs(int weight) {
        Queue<Node> q = new LinkedList<>();
        boolean[] visited = new boolean[N+1];
        q.offer(new Node(start, 0));
        while(!q.isEmpty()) {
            Node node = q.poll();
            if(node.next == end) return true;
            for(int i = 0; i < lists[node.next].size(); i++) {
                Node next = lists[node.next].get(i);
                if(weight <= next.weight && !visited[next.next]) {
                    visited[next.next] = true;
                    q.add(next);
                }
            }
        }
        return false;
    }
}

class Node{
    int next;
    int weight;

    public Node(int next,int weight) {
        this.next = next;
        this.weight = weight;
    }
}

class InputReader {
    private final InputStream stream;
    private final byte[] buf = new byte[8192];
    private int curChar, snumChars;

    public InputReader(InputStream st) {
        this.stream = st;
    }

    public int read() {
        if (snumChars == -1)
            throw new InputMismatchException();
        if (curChar >= snumChars) {
            curChar = 0;
            try {
                snumChars = stream.read(buf);
            } catch (IOException e) {
                throw new InputMismatchException();
            }
            if (snumChars <= 0)
                return -1;
        }
        return buf[curChar++];
    }

    public int nextInt() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        int res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public long nextLong() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        long res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public int[] nextIntArray(int n) {
        int a[] = new int[n];
        for (int i = 0; i < n; i++) {
            a[i] = nextInt();
        }
        return a;
    }

    public String nextLine() {
        int c = read();
        while (isSpaceChar(c))
            c = read();
        StringBuilder res = new StringBuilder();
        do {
            res.appendCodePoint(c);
            c = read();
        } while (!isEndOfLine(c));
        return res.toString();
    }

    public boolean isSpaceChar(int c) {
        return c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == -1;
    }

    private boolean isEndOfLine(int c) {
        return c == '\n' || c == '\r' || c == -1;
    }
}

사람인에 있는 공고들을 크롤링 해오려고 한다.

 

jobscrapper라는 폴더를 하나 만들고 main.go 파일을 하나 만들었다.

 

Add baseURL

var baseURL string = "https://www.saramin.co.kr/zf_user/search/recruit?&searchword=python"

Add getPages()

func main() {
	getPages()
}

func getPages() int {
	res, err := http.Get(baseURL)
	checkErr(err)
	checkStatusCode(res)

	return 0
}

func checkErr(err error) {
	if err != nil {
		log.Fatalln(err)
	}
}

func checkStatusCode(res *http.Response) {
	if res.StatusCode != 200 {
		log.Fatalln("Request failed with Status:", res.StatusCode, res.Status)
	}
}

처음에 indeed에서 크롤링을 막아놓아서 그런지 http.Get(url) 방법으로는 403 에러가 발생했다. User-Agent와 프록시로 해봤지만, 해결되지 않았고 사람인 페이지로 대체했다.

ADD>
이후에 찾아보니 indeed에서 크롤링하는 것을 indeed 측에서 막았다. http://www.indeed.com/robots.txt를 확인해보면, Disallow했다. robots.txt를 알고싶은 사람은 링크를 통해 글을 읽어보면 좋겠다.

 

go query라는 별도의 라이브러리를 추가해주자. 참고로 위의 error 처리는 go query에 적혀있는 방식을 보고 따라했다.

터미널에서 아래 명령어를 실행해주자.

go get github.com/PuerkitoBio/goquery

Add go query

func getPages() int {
	...

	defer res.Body.Close()

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)
    
	fmt.Println(doc)
	...
}

출력

별도의 문제없이 성공이 되고 응답이 오는 모습이다.

참고!

checkErr()나 checkStatusCode()라는 함수를 통해 에러를 처리했다. 이와같은 방식은 함수 명을 통하여 의미를 부여할 수 있지만 코드가 길어지는 경우 어떻게 에러를 처리하는지 확인하고 싶을 때, depth가 한번 더 들어가있기 때문에 가독성이 떨어질 수 있다. 상황에 따라서 똑똑하게 사용하자!

 

go query는 github에서 잘 읽어보면 사용방법이 나와있다.

  // Find the review items
  doc.Find(".left-content article .post-title").Each(func(i int, s *goquery.Selection) {
		// For each item found, get the title
		title := s.Find("a").Text()
		fmt.Printf("Review %d: %s\n", i, title)
	})

div class 이름을 Find 안에 적어주고 적혀있는 내용을 가져올 수 있다.

페이지의 개수를 알고싶기 때문에 페이지를 나타내는 div의 class네임인 pagination을 개발자 도구탭에서 찾아냈다.

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	doc.Find(".pagination").Each(func(i int, s *goquery.Selection) {
		fmt.Println(s.Html())
	})

출력

이렇게 안에 적힌 페이지의 개수를 가져올 수 있다.

a href에 적힌 #recruit_info는 현재 페이지를 나타내는 듯 하다. 

	doc.Find(".pagination").Each(func(i int, s *goquery.Selection) {
		fmt.Println(s.Find("a").Length())
	})

a의 개수를 출력해 본 결과 10개로 정확하게 나온다.

https://www.saramin.co.kr/zf_user/search/recruit?=&searchword=python&recruitPage=2&recruitPageCount=50

크롤링 하는 방법은 아래와 같다.

  • 원하는 검색 키워드를 searchword에 넣어준다.
  • 원하는 페이지 번호를 recruitPage에 넣어준다. (공고를 가져오기 위함)
  • 원하는 페이지 공고 개수를 recruitCount에 넣어준다.

일단, getPages는 페이지 개수를 구하기 위한 함수였기 때문에 아래와 같이 수정해준다.

func getPages() int {
	pages := 0
	res, err := http.Get(baseURL)
	checkErr(err)
	checkStatusCode(res)

	defer res.Body.Close()

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	doc.Find(".pagination").Each(func(i int, s *goquery.Selection) {
		pages = s.Find("a").Length()
	})

	return pages
}

페이지들의 개수를 가져오싸으니, 각각 페이지의 공고들을 가져와 보자.

func main() {
	total := getPages()
	fmt.Println(total)

	for i := 1; i <= total; i++ {
		getPage(i)
	}
}

main에서 페이지의 개수들을 getPages()를 통해 가져오고 각각 페이지를 반복문을 통해서 가져온다. 페이지 숫자는 1~total까지 있기때문에 1부터 tatal까지 반복문을 돌려준 모습이다.

func getPage(page int) {
	pageURL := baseURL + "&recruitPage=" + strconv.Itoa(page)
	fmt.Println("Requesting :", pageURL)
	res, err := http.Get(pageURL)
	checkErr(err)
	checkStatusCode(res)
	
}
https://www.saramin.co.kr/zf_user/search/recruit?&searchword=python&recruitCount=50&recruitPage=10

페이지를 넘기는 방법은 위처럼 recruitPage를 사용하여 페이지 번호를 넘길 수 있다. 기본 URL에 recruitPage 번호를 통해 각각의 모든 공고들을 페이지 번호를 넘겨가며 크롤링할 수 있다.

 

각 공고 div

이제 페이지별로 각 공고들을 크롤링해오기 위해 공고가 해당되는 div class 이름을 가져오자. 사람인의 경우 item_recruit으로 되어있다.

 

func getPage(page int) {
	...
    
	defer res.Body.Close()

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	doc.Find(".item_recruit").Each(func(i int, s *goquery.Selection) {
		fmt.Println(s.Html())
	})
}

getPage 함수에 goquery를 통해 item_recruit에 해당하는 데이터들을 가져오자.

출력

아주 많은 html 코드들이 출력된다. (잘 되고 있군)

 

	doc.Find(".item_recruit").Each(func(i int, s *goquery.Selection) {
		id, _ := s.Attr("value")
		title := s.Find(".job_tit>a").Text()
		condition := s.Find(".job_condition").Text()
		fmt.Println(id, title, condition)
	})

출력

필요한 정보들만 찾아서 출력해보면 위와 같다. 

 

이제 가져올 수 있는 필요한 정보들을 묶어 struct로 만들자.

type extractedJob struct {
	id string
	title string
	location string
	summary string
	company string
}

그 후, extractJob 함수를 추가해서 struct에 넣어주자.

func getPage(page int) {
	...

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	doc.Find(".item_recruit").Each(func(i int, card *goquery.Selection) {
		extractJob(card)
	})
}
func claanString(str string) string {
	return strings.Join(strings.Fields(strings.TrimSpace(str)), "")
}

func extractJob(card *goquery.Selection ) {
	id, _ := card.Attr("value")
	title := claanString(card.Find(".job_tit>a").Text())
	location := claanString(card.Find(".job_condition>span>a").Text())
	summary := claanString(card.Find(".job_sector").Clone().ChildrenFiltered(".job_day").Remove().End().Text())
	company := claanString(card.Find(".area_corp>strong>a").Text())
	fmt.Println(id, title, location, summary, company)
}

출력

잘 가져오는 모습이다.

func extractJob(card *goquery.Selection ) extractedJob {
	id, _ := card.Attr("value")
	title := claanString(card.Find(".job_tit>a").Text())
	location := claanString(card.Find(".job_condition>span>a").Text())
	summary := claanString(card.Find(".job_sector").Clone().ChildrenFiltered(".job_day").Remove().End().Text())
	company := claanString(card.Find(".area_corp>strong>a").Text())
	return extractedJob {
		id : id,
		title : title,
		location : location,
		summary : summary,
		company : company,
	}
}

extractJob을 return 해주도록 수정하자.

func getPage(page int) []extractedJob{
	...

	var jobs []extractedJob
	doc.Find(".item_recruit").Each(func(i int, card *goquery.Selection) {
		job := extractJob(card)
		jobs = append(jobs, job)
	})
	return jobs
}

getPage도 수정해주자.

func main() {
	...
	
	var jobs []extractedJob
	for i := 1; i <= total; i++ {
		extractedJobs := getPage(i)
		jobs = append(jobs, extractedJobs...)
	}
}

main도 수정해주자.

출력

총 10페이지 모두 가져오는 것을 볼 수 있다.

 

이제 가져온 데이터를 csv파일에 쓸 것이다. 아래 Go Package에서 지원하는 csv package를 사용할 것이다.

 

csv package - encoding/csv - Go Packages

Discover Packages Standard library encoding csv Version: go1.21.6 Opens a new window with list of versions in this module. Published: Jan 9, 2024 License: BSD-3-Clause Opens a new window with license information. Imports: 8 Opens a new window with list of

pkg.go.dev

사용 예시나 설명은 해당 링크에 자세하게 나와있다. 

 

func writeJobs(jobs []extractedJob) {
	file, err := os.Create("jobs.csv")
	checkErr(err)

	w := csv.NewWriter(file)
	defer w.Flush()		// must

	headers := []string{"Id", "Title", "Location", "Summary", "Company"}
	
	Werr := w.Write(headers)
	checkErr(Werr)
	
	for _, job := range(jobs) {
		jobSlice := []string{job.id, job.title, job.location, job.summary, job.company}
		jobErr := w.Write(jobSlice)
		checkErr(jobErr)
	}
}

해당 라이브러리를 근거로 위와 같이 작성해주었다. 함수가 종료될 때, csv를 쓰도록 defer를 사용했다. 먼저, headers를 작성하여 쓰기 작업을 한 후 jobs의 데이터들을 하나씩 쓰도록 코드를 구현했다. w.Write의 경우 error를 return하기 때문에 error체크도 병행해주었다.

코드를 돌려보면 jobs.csv 파일이 생성되고 쓰기 작업을 한 작업들이 담겨진 모습이다.

 

CSV Viewer and Editor

Save Your result: .csv or .xlsx EOL: CRLFLF Include Header

www.convertcsv.com

위 페이지에 csv파일에 담겨진 문자열을 전체 복사한 후 붙여넣으면 아래와 같이 보기 좋게 볼 수 있다.

 

id에 해당하는 값은 해당 공고의 고유 번호이다. 실제로 공고를 눌러서 확인하면 ID값이 연결된 것을 볼 수 있다.

https://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx=47446357

위는 1번 공고의 id 값으로 연결된 공고 url이다. 여기서 rec_idx가 바로 id 값이다. id를 link로 이어지도록 고쳐보자.

func writeJobs(jobs []extractedJob) {
	...
	
	for _, job := range(jobs) {
		jobSlice := []string{"https://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx="+job.id, job.title, job.location, job.summary, job.company}
		jobErr := w.Write(jobSlice)
		checkErr(jobErr)
	}
}

writeJobs 함수를 위와 같이 수정해주자.

id값이 위와같이 수정되고, id를 복사해서 웹에 넣어주면 아래와 같이 공고를 확인할 수 있다.

Add
Change id -> link 

 

 

goroutine 적용

함수들을 보면 goroutine을 사용하면 더 빠르게 동작할 수 있는 함수들이 있음을 느꼈을 수 있다. getPage()와 extractJob() 함수의 경우 goroutine을 사용하면 병렬적으로 보다 빠르게 데이터를 가져올 수 있다. 

먼저, getPage()를 고쳐보자. 데이터를 가져와야하기 때문에 channel을 사용한다.

func main() {
	total := getPages()
	
	var jobs []extractedJob
	c := make(chan []extractedJob)
	for i := 1; i <= total; i++ {
		go getPage(i, c)
	}

	for i := 1; i <= total; i++ {
		job := <- c
		jobs = append(jobs, job...)
		// same
		// jobs = append(jobs, <- c...)
	}
	writeJobs(jobs)
}

getPage()를 부르는 main()함수에 channel을 생성하고 getPage() 파라미터에 추가해주자. 

func getPage(page int, mainC chan <-[]extractedJob) {
	pageURL := baseURL + "&recruitPage=" + strconv.Itoa(page) 
	fmt.Println("Requesting :", pageURL)
	res, err := http.Get(pageURL)
	checkErr(err)
	checkStatusCode(res)
	
	defer res.Body.Close()

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	var jobs []extractedJob
	c := make(chan extractedJob)

	cards := doc.Find(".item_recruit")
	cards.Each(func(i int, card *goquery.Selection) {
		job := extractJob(card)
		jobs = append(jobs, job)
	})
	mainC <- jobs
}

파라미터를  추가하고, return을 없앤 뒤 jobs를 채널에 전송하자.

이제 extractJob() 함수에 goroutine을 추가해보자. 위 getPage() 함수에서 채널을 만든 뒤, extractJob 파라미터에 추가하자.

func getPage(page int, mainC chan <-[]extractedJob) {
	pageURL := baseURL + "&recruitPage=" + strconv.Itoa(page) 
	fmt.Println("Requesting :", pageURL)
	res, err := http.Get(pageURL)
	checkErr(err)
	checkStatusCode(res)
	
	defer res.Body.Close()

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	var jobs []extractedJob
	c := make(chan extractedJob)

	cards := doc.Find(".item_recruit")
	cards.Each(func(i int, card *goquery.Selection) {
		go extractJob(card, c)
	})

	for i:=0; i< cards.Length(); i++ {
		job := <- c
		jobs = append(jobs, job)
	}
	mainC <- jobs
}
func extractJob(card *goquery.Selection, c chan<- extractedJob) {
	link, _ := card.Attr("value")
	title := claanString(card.Find(".job_tit>a").Text())
	location := claanString(card.Find(".job_condition>span>a").Text())
	summary := claanString(card.Find(".job_sector").Clone().ChildrenFiltered(".job_day").Remove().End().Text())
	company := claanString(card.Find(".area_corp>strong>a").Text())
	c <- extractedJob {
		link : link,
		title : title,
		location : location,
		summary : summary,
		company : company,
	}
}

extractJob에서 파라미터를 추가하고, return을 없앤 뒤 채널에 추출한 extractJob을 전송하면 된다.

goroutine 사용 전
goroutine 사용 후

goroutine 사용 전 후를 비교해보면 recruitPage의 순서가 다르다는 것을 볼 수 있다. goroutine을 사용하여 병렬적으로 수행했기 때문이다. 프로그램을 돌려보면 이전보다 확실히 빠르다는 것을 느낄 수 있다. 체감상 2~3배 정도 빨라진 것 같다.

 

 

Add echo server

현재는 python으로만 검색하여 공고들을 스크랩했지만, python을 동적으로 변경하여 입력한 기술스택으로 공고를 수집할 수 있도록 만들어보자. go에서는 다양한 웹 프레임워크가 존재한다. echo는 그 중 성능이 뛰어난 것으로 알고 있다. echo web framework를 통해 동적으로 스크랩할 수 있도록 구현해보려한다.

일단, 기존 main.go에 있던 코드들을 scrapper directory안 scrapper.go로 이동시켜주었다. main() 함수를 Scrape()이라는 함수로 변경하고 string 파라미터를 추가하여 python을 대체할 문자열을 받을 수 있도록 했다. 첫 문자를 대문자로 한 이유는 export하기 위함이다. main()에서 접근하기 위함! 변경된 코드는 아래와 같다.

package scrapper

import (
	"encoding/csv"
	"fmt"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"

	"github.com/PuerkitoBio/goquery"
)

type extractedJob struct {
	link string
	title string
	location string
	summary string
	company string
}

// Scrape saramin by a term
func Scrape(term string) {
	var baseURL string = "https://www.saramin.co.kr/zf_user/search/recruit?&searchword="+term+"&recruitCount=50"
	total := getPages(baseURL)
	
	var jobs []extractedJob
	c := make(chan []extractedJob)
	for i := 1; i <= total; i++ {
		go getPage(i, baseURL, c)
	}

	for i := 1; i <= total; i++ {
		job := <- c
		jobs = append(jobs, job...)
		// same
		// jobs = append(jobs, <- c...)
	}
	writeJobs(jobs)
}

func writeJobs(jobs []extractedJob) {
	file, err := os.Create("jobs.csv")
	checkErr(err)

	w := csv.NewWriter(file)
	defer w.Flush()		// must

	headers := []string{"Link", "Title", "Location", "Summary", "Company"}
	
	Werr := w.Write(headers)
	checkErr(Werr)
	
	for _, job := range(jobs) {
		jobSlice := []string{"https://www.saramin.co.kr/zf_user/jobs/relay/view?rec_idx="+job.link, job.title, job.location, job.summary, job.company}
		jobErr := w.Write(jobSlice)
		checkErr(jobErr)
	}
}

func getPage(page int, baseURL string, mainC chan <-[]extractedJob) {
	pageURL := baseURL + "&recruitPage=" + strconv.Itoa(page) 
	fmt.Println("Requesting :", pageURL)
	res, err := http.Get(pageURL)
	checkErr(err)
	checkStatusCode(res)
	
	defer res.Body.Close()

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	var jobs []extractedJob
	c := make(chan extractedJob)

	cards := doc.Find(".item_recruit")
	cards.Each(func(i int, card *goquery.Selection) {
		go extractJob(card, baseURL, c)
	})

	for i:=0; i< cards.Length(); i++ {
		job := <- c
		jobs = append(jobs, job)
	}
	mainC <- jobs
}

func claanString(str string) string {
	return strings.Join(strings.Fields(strings.TrimSpace(str)), "")
}

func extractJob(card *goquery.Selection, baseURL string, c chan<- extractedJob) {
	link, _ := card.Attr("value")
	title := claanString(card.Find(".job_tit>a").Text())
	location := claanString(card.Find(".job_condition>span>a").Text())
	summary := claanString(card.Find(".job_sector").Clone().ChildrenFiltered(".job_day").Remove().End().Text())
	company := claanString(card.Find(".area_corp>strong>a").Text())
	c <- extractedJob {
		link : link,
		title : title,
		location : location,
		summary : summary,
		company : company,
	}
}

func getPages(baseURL string) int {
	pages := 0
	res, err := http.Get(baseURL)
	checkErr(err)
	checkStatusCode(res)

	defer res.Body.Close()

	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	doc.Find(".pagination").Each(func(i int, s *goquery.Selection) {
		pages = s.Find("a").Length()
	})

	return pages
}

func checkErr(err error) {
	if err != nil {
		log.Fatalln(err)
	}
}

func checkStatusCode(res *http.Response) {
	if res.StatusCode != 200 {
		log.Fatalln("Request failed with Status:", res.StatusCode, res.Status)
	}
}

 

main을 추가하고 python string을 보내 테스트해보자.

package main

import "github.com/qazyj/jobscrapper/scrapper"

func main() {
	scrapper.Scrape("python")
}

(잘 돌아간다!)

 

이제 main에 echo를 사용할 코드를 추가해보자! 아래 링크를 참고했다.

 

GitHub - labstack/echo: High performance, minimalist Go web framework

High performance, minimalist Go web framework. Contribute to labstack/echo development by creating an account on GitHub.

github.com

go get github.com/labstack/echo

명령어를 터미널에 쳐주자.

 

예제대로 main에 코드를 추가 했다. (middleware는 사용하지 않을 것이기때문에 추가해주지 않았다.)

package main

import (
	"net/http"

	"github.com/labstack/echo"
)

func main() {
	// Echo instance
	e := echo.New()
  
	// Routes
	e.GET("/", hello)
  
	// Start server
	e.Logger.Fatal(e.Start(":1323"))
}

// Handler
func hello(c echo.Context) error {
	return c.String(http.StatusOK, "Hello, World!")
}

1323포트 번호로 서버를 열었다.

테스트

테스트해보면 위와같이 문제없이 돌아가는 것을 확인할 수 있다.

참고!
server를 종료하고자 한다면 mac기준 control+c를 누르면 된다.

 

이제 검색어를 입력할 수 있는 input box와 button이 있는 페이지를 만들어보자.

html 추가

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Go Jobs</title>
  </head>
  <body>
    <h1>Go Jobs</h1>
    <h3>www.saramin.co.kr scrapper</h3>
    <form method="POST" action="/scrape">
      <input placeholder="what job do you want" name="term" />
      <button>Search</button>
    </form>
  </body>
</html>

이제 메인에서 handler를 통해 localhost:1323으로 주소를 입력할 때 home.html이 나올 수 있도록 만들어주자.

func main() {
	// Echo instance
	e := echo.New()
  
	// Routes
	e.GET("/", handleHome) //Add
  
	// Start server
	e.Logger.Fatal(e.Start(":1323"))
}

// Add
func handleHome(c echo.Context) error {
	return c.File("home.html")
}

 

잘 나온다.

 

이제 Search 버튼을 누르면 동작할 POST method를 추가해보자.

func main() {
	...
	e.POST("/scrape", handlerScrape)
  
	...
}

func handlerScrape(c echo.Context) error {
	fmt.Println(c.FormValue("term"))
	return nil
}

그 후 돌려보면 아래와 같이 검색한 검색어가 정상적으로 들어오는 것을 확인할 수 있다.

 

이제 csv를 다운로드할 수 있도록 구현해볼 것이다.

const fileName string = "jobs.csv"

func handlerScrape(c echo.Context) error {
	defer os.Remove(fileName)
	term := strings.ToLower(scrapper.CleanString(c.FormValue("term")))
	scrapper.Scrape(term)
	return c.Attachment(fileName, fileName)
}

handlerScrape 함수를 위와 같이 작성해주었따. scrapper에 term을 파라미터로 보내 jobs.csv를 만들고 해당 csv 파일이 바로 다운로드 되도록 구현했다. 해당 함수가 종료되면 jobs.csv는 삭제되도록 defer를 사용했다.

 

 

Reference

'Go' 카테고리의 다른 글

URL Checker & Go Routine  (0) 2024.01.14
method를 활용한 map 구현(Search, Add, Delete, Update)  (0) 2024.01.10
struct/public,private/String()/error handling  (0) 2024.01.07
Struct  (0) 2024.01.04
Maps  (0) 2023.12.30
 

1753번: 최단경로

첫째 줄에 정점의 개수 V와 간선의 개수 E가 주어진다. (1 ≤ V ≤ 20,000, 1 ≤ E ≤ 300,000) 모든 정점에는 1부터 V까지 번호가 매겨져 있다고 가정한다. 둘째 줄에는 시작 정점의 번호 K(1 ≤ K ≤ V)가

www.acmicpc.net

 


 

문제

정점 v <= 20,000과 간선 e <= 300,000개수가 주어진다.

시작 정점 s가 주어졌을 때, 시작정점에서부터 각 정점까지 최단거리를 출력하자. 단, 시작정점부터 못 가는 정점은 INF를 출력하자.

 

 

풀이

각 정점마다 가장 빠른 거리만 최신화하며 탐색하면 된다고 판단했다. -> 다익스트라

정점의 개수만큼 distance라는 int형 배열을 만들고, 간선 w의 최대값인 10과 정점의 최대값인 20,000을 곱한 200,000보다 1큰  200,001로 초기화했다. 그래프를 탐색하며 distance 배열을 최신화시킬 수 있는 탐색만 진행했다.

 

 

코드

import java.io.*;
import java.math.BigInteger;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;

public class Main {
    static int v,e,s;
    static List<Node>[] list;
    static int[] distance;

    public static void main(String[] args) throws Exception {
        InputReader in = new InputReader(System.in);

        v = in.nextInt();
        e = in.nextInt();
        s = in.nextInt();
        list = new ArrayList[v+1];
        distance = new int[v+1];
        for(int i = 1; i <= v; i++) {
            list[i] = new ArrayList<Node>();
            distance[i] = 200001;
        }

        for(int i = 0; i < e; i++) {
            int s = in.nextInt();
            int e = in.nextInt();
            int w = in.nextInt();

            list[s].add(new Node(e, w));
        }

        bfs();
        StringBuilder sb = new StringBuilder();
        for(int i = 1; i <= v; i++) {
            if(i==s) sb.append("0");
            else if(distance[i] != 200001) sb.append(distance[i]);
            else sb.append("INF");
            sb.append("\n");
        }

        System.out.println(sb);
    }

    public static void bfs() {
        PriorityQueue<Node> pq = new PriorityQueue<>();
        pq.add(new Node(s, 0));
        distance[s] = 0;
        while(!pq.isEmpty()) {
            Node node = pq.poll();
            for(int i = 0; i < list[node.next].size(); i++) {
                Node next = list[node.next].get(i);

                if(distance[next.next] <= distance[node.next] + next.distance) continue;

                distance[next.next] = distance[node.next] + next.distance;
                pq.add(new Node(next.next, node.distance+next.distance));
            }
        }
    }
}

class Node implements Comparable<Node> {
    int next;
    int distance;

    public Node(int next, int distance) {
        this.next = next;
        this.distance = distance;
    }

    @Override
    public int compareTo(Node o) {
        return this.distance - o.distance;
    }
}

class InputReader {
    private final InputStream stream;
    private final byte[] buf = new byte[8192];
    private int curChar, snumChars;

    public InputReader(InputStream st) {
        this.stream = st;
    }

    public int read() {
        if (snumChars == -1)
            throw new InputMismatchException();
        if (curChar >= snumChars) {
            curChar = 0;
            try {
                snumChars = stream.read(buf);
            } catch (IOException e) {
                throw new InputMismatchException();
            }
            if (snumChars <= 0)
                return -1;
        }
        return buf[curChar++];
    }

    public int nextInt() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        int res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public long nextLong() {
        int c = read();
        while (isSpaceChar(c)) {
            c = read();
        }
        int sgn = 1;
        if (c == '-') {
            sgn = -1;
            c = read();
        }
        long res = 0;
        do {
            res *= 10;
            res += c - '0';
            c = read();
        } while (!isSpaceChar(c));
        return res * sgn;
    }

    public int[] nextIntArray(int n) {
        int a[] = new int[n];
        for (int i = 0; i < n; i++) {
            a[i] = nextInt();
        }
        return a;
    }

    public String nextLine() {
        int c = read();
        while (isSpaceChar(c))
            c = read();
        StringBuilder res = new StringBuilder();
        do {
            res.appendCodePoint(c);
            c = read();
        } while (!isEndOfLine(c));
        return res.toString();
    }

    public boolean isSpaceChar(int c) {
        return c == ' ' || c == '\n' || c == '\r' || c == '\t' || c == -1;
    }

    private boolean isEndOfLine(int c) {
        return c == '\n' || c == '\r' || c == -1;
    }
}

 

+ Recent posts