http로 통신하기

수 많은 라이브러리의 http통신 부분을 볼 때마다 왜 이렇게 불편하게 만들까? 라는 생각이 듭니다. 플래시, 자바스크립트, 컬, 파이선, 루비 등도 기본 인터페이스는 매우 가혹합니다.

당연히 아이폰과 안드로이드는 더욱 복잡하고 어렵게 되어 있습니다. 개발한 거룩한 분들이 http명세를 (여러분들이) 다 외우길 원했기 때문일지도 모릅니다….게다가 스펙을 추상화한 센스도 개발자마다 제 각각일 뿐더러 같은 라이브러리마저도 버전 별로 매우 상이하여 어느 장단에 맞출지 알 수 없을 지경입니다.

이는 과거의 개발자들이 갖고 있는 센스가 인간 중심이나 목적지향적이지 않고 이른바 엔지니어링으로 대표되는 스펙과 기술 중심의 사고를 갖고 있어서가 아닐까 싶습니다.

뼈대 만들기top

http통신에서 모든 기술적인 측면을 잠시 잊고 목적지향적으로 생각을 바꿔보죠.

결국 http는 요청하면 응답을 받는 단순한 통신입니다. 요청 시 서버에 추가적인 데이터를 전송할 수도 있다는 정도죠.

그 추가 데이터의 정체를 살펴보면

  • url에 직접 키, 값을 추가하는 get방식의 데이터와
  • 요청 몸체에 직접 데이터를 추가하는 post방식의 데이터로 나눌 수 있습니다.
    그 중 post방식은

    • 이미지 등을 업로드 할 때 사용하는 멀티파트 방식과
    • 단순 텍스트 형태의 데이터만 포함하는 폼 방식으로 나눌 수 있습니다.
  • 그 외로 헤더에 기술하는 추가 정보와 쿠키, 보안 등이 있습니다.

하지만 유저 입장에서는

  1. 파일을 추가하면 알아서 멀티파트로 작동하고,
  2. 파일이 없는데 post가 있으면 폼으로 작동하며
  3. get만 있으면 알아서 url을 갱신해주는 식으로 작동하면 그만입니다.

이를 안드로이드 버전으로 간단한 인터페이스를 구성해보죠.

이 구현물은 일부러 아파치 라이브러리를 쓰지 않습니다. 기저 자바 수준에서 원리에 맞게 차근차근 구현합니다.

static InputStream send(
	String $url,
	final String $get,
	final String $post,
	final HashMap<String, InputStream> $file
){
	if( $get != null ){
		// url에 get처리
	}

	HttpURLConnection conn;
	conn = (HttpURLConnection)new URL( $url ).openConnection();

	if( $file != null ){
		conn.setRequestProperty(
			"Content-Type",
			"multipart/form-data, boundary=--------123--"
		);
		// file, post 처리
	}else  if( $post != null ){
		conn.setRequestProperty(
			"Content-Type",
			"application/x-www-form-urlencoded"
		);
		// post 처리
	}
	conn.connect();
	if( conn.getResponseCode() == HttpURLConnection.HTTP_OK ){
		return conn.getInputStream();
	}else{
		return null;
	}
}

코드는 매우 간단합니다.

  1. get은 잘 생각해보면 그저 url에 문자열을 추가하는 것이니 미리 url 문자열 수준에서 처리할 수 있습니다.
  2. file이 일단 들어오면 멀티파트 프로토콜에 맞게 filepost를 처리해야 합니다.
  3. file이 없는데 post가 있다면 폼 프로토콜에 맞게 post를 처리합니다.
  4. 최종적으로 연결하여 결과가 200대나 300대라면 스트림을 돌려줍니다.

Get 처리top

일단 뼈대는 세웠습니다. 위의 함수 선언을 보면 get이나 postHashMap이 아니라 문자열 하나로 받고 있습니다.

따라서 문자열을 파싱하여 키, 값으로 정리하겠다는 뜻인데 다양한 값이 들어올 걸 예상하면 구분자를 좀 까다롭게 고를 필요가 있습니다.

하지만 단순한 예제니 여기서는 컴마로 처리하겠습니다. 파싱은 매우 간단합니다.

String[] temp = $get.split( "," );
for( int i = 0, j = temp.length ; i < j ; ){
	// todo
}

하지만 Get을 처리하기 전에 인자로 넘어온 $url에 대해 생각해봅시다.

get을 인자로 받는다는 점은 암묵적으로 $url안에 get과 관련된 데이터가 없기를 가정하고 있습니다.

따라서 http://domain/src?a=3&b=5 처럼 get문자열이 포함되면 안됩니다.

하지만 북마크는 어떨까요? 인자로 북마크를 따로 받지는 않기 때문에 $url에 북마크를 넣는 경우는 있을 수 있습니다.

http://domain/src#book 처럼 북마크는 허용해야 하죠. 북마크는 URI에서 가장 뒤에 와야 하기 때문에 $url을 변경시키기 전에 반드시 미리 분리시켜야 합니다.

이를 모두 반영하여 Get을 정리하면 다음과 같습니다.

if( $get != null ){
	StringBuffer sb = new StringBuffer();

	// 1. 북마크가 있다면 미리 분리해두자
	if( $url.indexOf( "#" ) > -1 ){
		String[] urlmark = $url.split( "#" );
		$url = urlmark[0];
		book = urlmark[1];
	}

	// 2. cgi문자열 생성
	sb.append( $url ).append( "?" );
	String[] get = $get.split( "," );
	for( int i = 0, j = get.length ; i < j ; ){
		sb
		.append( URLEncoder.encode( get[i++], UTF8 ) )
		.append( "=" )
		.append( URLEncoder.encode( get[i++], UTF8 ) );
		if( i < j - 1 ) sb.append( "&" );
	}

	// 3. 북마크가 있었으면 다시 붙여주고
	if( book != null ) sb.append( "#" ).append( book );

	// 4. $url을 대체한다.
	$url = sb.toString();
}

파일이 있는 경우top

파일은 문자열로 받을 재간은 없죠. 키와 스트림으로 구성된 HashMap을 받습니다. 따라서 파싱은 필요 없습니다만 문제는 멀티파트 프로토콜로 이를 기술하는 게 귀찮다는 점입니다.

post데이터 또한 멀티파트에서 한꺼번에 처리해야 하기 때문에 큰 뼈대를 먼저 세워 보면 다음과 같습니다.

if( $file != null ){
	// 1. 멀티파트 헤더처리
	if( $post != null ){
		// 2. post 우선처리
	}
	// 3. 파일부분 처리
}

위의 번호대로 하나씩 살펴보죠.

멀티파트 헤더처리

conn.setRequestProperty(
	"Content-Type",
	"multipart/form-data, boundary=-----------123"
);

DataOutputStream dos;
dos = new DataOutputStream( conn.getOutputStream() );

우선 헤더에 이 요청이 멀티파트를 따르고 있음을 기술하고 바운더리를 설정합니다. 바운더리는 요청 헤더의 본문에서 각 키, 값을 구분하기 위한 구분자입니다. 이 문자열을 이용하여 서버 측에서는 요청에 있는 내용을 split하게 됩니다. 대략 만드시면 됩니다. 데이터와 중복만 안되면 되거든요.

다음은 connoutStream을 가져와야 합니다.

이게 약간 혼동을 일으킬 수 있는데,

  • conn입장에서 outSteam은 내보내는 것이니까 요청에 기술하게 될 내용이 되고
  • inputStream은 들어온 것이니 서버에서 받은 응답내용이 됩니다.

요청에 파일과 post를 써야 하니 outStream을 얻어온 거죠.

post 처리

if( $post != null ){
	String[] post = $post.split( "," );
	for( int i = 0, j = post.length ; i < j ; ){
		dos.writeBytes( "-----------123" );
		dos.writeShort( 0x0d0a );
		dos.writeBytes( "Content-Disposition: form-data; name=" );
		dos.writeBytes( URLEncoder.encode( post[i++], UTF8 ) );
		dos.writeBytes( """ );
		dos.writeShort( 0x0d0a );
		dos.writeShort( 0x0d0a );
		dos.writeBytes( URLEncoder.encode( post[i++], UTF8 ) );
		dos.writeShort( 0x0d0a );
	}
}

Get과 마찬가지 요령으로 Post를 파싱한 뒤 루프 내에서는 outStream에 차근차근 적어줍니다. 여기엔 로직이 없습니다. 멀티파트의 프로토콜 규정이 원하는 대로 적어줄 뿐입니다.

파일 처리

for(String key : $file.keySet() ){
	String[] file = URLEncoder.encode( key, UTF8 ).split( "," );
	dos.writeBytes( "-----------123" );
	dos.writeShort( 0x0d0a );
	dos.writeBytes( "Content-Disposition: form-data; name="" );
	dos.writeBytes( file[0].trim() );
	dos.writeBytes( ""; filename="" );
	dos.writeBytes( file[1].trim() );
	dos.writeBytes( """ );
	dos.writeShort( 0x0d0a );
	dos.writeBytes( "Content-Type: application/octet-stream" );
	dos.writeShort( 0x0d0a );
	dos.writeShort( 0x0d0a );

	InputStream is = $file.get( key );
	int bytesAvailable = is.available();
	int bufferSize = Math.min( bytesAvailable, 1024 );
	byte[] buffer = new byte[bufferSize];
	int bytesRead = is.read(buffer, 0, bufferSize);
	while (bytesRead > 0){
		dos.write(buffer, 0, bufferSize);
		bytesAvailable = is.available();
		bufferSize = Math.min(bytesAvailable, 1024);
		bytesRead = is.read(buffer, 0, bufferSize);
	}
	is.close();
	dos.writeShort( 0x0d0a );
}

거듭 말씀 드리지만 여기엔 로직이 없습니다. 멀티파트 규격대로 적어주고 있을 뿐입니다 ^^;

이렇게 하여 file이 들어온 경우 멀티파트로 요청을 처리하는데 성공하였습니다.

Post만 있는 경우top

이제 마지막으로 Post만 들어온 경우를 보겠습니다.

// 폼으로 헤더설정
conn.setRequestProperty(
	"Content-Type",
	"application/x-www-form-urlencoded"
);

// outStream을 얻고
OutputStreamWriter out;
out = new OutputStreamWriter( conn.getOutputStream() );

// $post를 파싱하여 적어준다.
String[] post = $post.split( "," );
for( int i = 0, j = post.length ; i < j ; ){
	out.write( URLEncoder.encode( post[i++], UTF8 ) );
	out.write( "=" );
	out.write( URLEncoder.encode( post[i++], UTF8 ) );
}
out.flush();
out.close();

이미 익숙해지셨을 테니 자세한 설명은 생략합니다. 멀티파트에 비하면 껌이죠.

응답요청 및 응답코드top

이제 요청에 대한 내용을 완전히 작성했으니 서버에 연결하여 데이터를 받아올 차례입니다. 이 때 서버는 크게 보면 세 가지로 응답합니다.

  1. 정상적으로 응답한다.
  2. 열라 늦게 응답하거나 안한다.
  3. 니 요청에 응답을 못하겠다고 한다.

우선 3번을 집중적으로 생각해보죠. 3번은 사실 서버에게 응답을 받은 것입니다.

  1. 요청이 잘못되어서 그런 자원이 없다(404)고 답하든,
  2. 서버가 바빠서 니 건 처리 못하겠다(500대)고 하던

쨌든 정상적으로 응답자체를 받아온 경우입니다. 따라서 넓게 보면 요청하고 응답을 받은 범주입니다. 따라서 1번과 3번의 경우엔 서버의 응답코드를 체크하여 처리할 수 있습니다.

// 서버에게 응답요청
conn.connect();

// 받아온 응답코드가 200, 300대인지 확인
if( conn.getResponseCode() == HttpURLConnection.HTTP_OK ){
	return conn.getInputStream();
}else{
	return null;
}

하지만 2번은 서버가 응답을 안하고 침묵하고 있는 경우입니다.

이 건 응답요청하고 그 결과를 통해 확인할 수 없습니다. 아예 conn에게 타임아웃을 걸어두는 게 상책입니다.

최초 conn을 생성하는 시점에 타임아웃을 걸어두겠습니다. 또한 그 외에 자잘한 셋팅도 추가로 하죠 ^^

HttpURLConnection conn;
conn = (HttpURLConnection) new URL( $url ).openConnection();

// 1. 타임아웃 설정
conn.setConnectTimeout( TIMEOUT );
conn.setReadTimeout( TIMEOUT );

// 2. 요청편집허가
conn.setDoInput(true);
conn.setDoOutput(true);

// 3. 캐쉬를 꺼두자
conn.setUseCaches(false);

// 4. keepalive는 도움이 된다
conn.setRequestProperty(
	"Connection",
	"Keep-Alive"
);

// 5. 요청 메서드를 판별하자.
if( $post != null || $file != null ){
	conn.setRequestMethod( "POST" );
}else{
	conn.setRequestMethod( "GET" );
}

사실은 최초 할 일이 좀 많습니다 ^^; 특히 5번에 있는 메서드 기술은 반드시 해야 합니다.

멀티파트나, 폼 형식은 넓게 보면 POST요청입니다. 기본은 GET요청이죠.

http압축top

http프로토콜은 자체적으로 압축을 지원합니다. 따라서 요청헤더에 압축지원을 기술하면 서버가 압축을 해서 보내줄 수도 있습니다(서버가 안 해 줄 수도 있습니다 ^^)

이를 위해서는 두 군데를 수정해야 합니다.

  1. 우선 최초 conn셋팅에서 압축된 응답을 받을 수 있다고 추가로 셋팅합니다.
  2. 응답받은 스트림을 조사하여 압축된 경우 압축을 풀고 처리합니다.

1. 헤더 셋팅

표준적인 http압축은 gzip입니다. 따라서 이를 수락할 수 있다고 헤더에 써주면 끝입니다.

conn.setRequestProperty(
	"Accept-Encoding",
	"gzip"
);

응답 시 처리는 응답 헤더를 확인하고 gzip으로 압축되어 있다면 풀어주는 정도입니다.

conn.connect();
if( conn.getResponseCode() == HttpURLConnection.HTTP_OK ){

	InputStream is = conn.getInputStream();

	String encode = conn.getHeaderField( "Content-Encoding" );
	if( encode.equalsIgnoreCase( "gzip" ) ){
		is = new GZIPInputStream( is );
	}

	return is;
}else{
	return null;
}

예외처리top

전체적으로 예외처리를 생략했는데 HttpURLConnection 객체는 예외를 처리하지 않으면 연결할 수 없는 객체입니다. 또한 세심하게 catch의 순서를 정할 필요도 있습니다.

다음과 같은 뼈대를 생각해 볼 수 있습니다.

static InputStream send( ... ){

	try{

		// get처리

		try{

			// conn생성
			// file, post 처리

			conn.connect();
			return conn.getInputStream();

		}catch( MalformedURLException $e ){
			// 1. 잘못된 url
		}catch( SocketTimeoutException $e){
			// 2. 타임아웃
		}catch( IOException $e){
			// 3. 네트웍 문제
		}catch( Exception $e){
			// 4. 기타 문제
		}

	}catch( Exception $e ){
		// 5. 기타 문제
	}

}

가장 외곽에 있는 try의 경우 왜 예외처리가 필요한지 의문이 들 수 있습니다.

5번과 4번에 자주 걸리는 예외는 URLEncoder.encode 때문입니다.

URLEncoder.encode는 문자열을 url에 맞게 인코딩하는데 이때  여러 이유로 인코딩에 실패하면 예외를 보냅니다.
저 메소드 자체가 throw를 하기 때문에 반드시 try로 감싸야 합니다.

위의 예외 처리는 순서도 매우 중요합니다. 서로 상속관계가 있기 때문에 반드시 저 순서로 해야 개별로 에러를 인식할 수 있습니다.

결론top

http 처리를 간단히(^^) 알아봤습니다.

conn.connect는 블록킹을 일으키는 동기화 함수 입니다. 따라서 비동기화하여 응답성을 확보하려면 반드시 쓰레드로 돌려야 합니다.