수 많은 라이브러리의 http통신 부분을 볼 때마다 왜 이렇게 불편하게 만들까? 라는 생각이 듭니다. 플래시, 자바스크립트, 컬, 파이선, 루비 등도 기본 인터페이스는 매우 가혹합니다.
당연히 아이폰과 안드로이드는 더욱 복잡하고 어렵게 되어 있습니다. 개발한 거룩한 분들이 http명세를 (여러분들이) 다 외우길 원했기 때문일지도 모릅니다….게다가 스펙을 추상화한 센스도 개발자마다 제 각각일 뿐더러 같은 라이브러리마저도 버전 별로 매우 상이하여 어느 장단에 맞출지 알 수 없을 지경입니다.
이는 과거의 개발자들이 갖고 있는 센스가 인간 중심이나 목적지향적이지 않고 이른바 엔지니어링으로 대표되는 스펙과 기술 중심의 사고를 갖고 있어서가 아닐까 싶습니다.
뼈대 만들기
http통신에서 모든 기술적인 측면을 잠시 잊고 목적지향적으로 생각을 바꿔보죠.
결국 http는 요청하면 응답을 받는 단순한 통신입니다. 요청 시 서버에 추가적인 데이터를 전송할 수도 있다는 정도죠.
그 추가 데이터의 정체를 살펴보면
- url에 직접 키, 값을 추가하는 get방식의 데이터와
- 요청 몸체에 직접 데이터를 추가하는 post방식의 데이터로 나눌 수 있습니다.
그 중 post방식은- 이미지 등을 업로드 할 때 사용하는 멀티파트 방식과
- 단순 텍스트 형태의 데이터만 포함하는 폼 방식으로 나눌 수 있습니다.
- 그 외로 헤더에 기술하는 추가 정보와 쿠키, 보안 등이 있습니다.
하지만 유저 입장에서는
- 파일을 추가하면 알아서 멀티파트로 작동하고,
- 파일이 없는데 post가 있으면 폼으로 작동하며
- 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; } }
코드는 매우 간단합니다.
- get은 잘 생각해보면 그저 url에 문자열을 추가하는 것이니 미리 url 문자열 수준에서 처리할 수 있습니다.
- file이 일단 들어오면 멀티파트 프로토콜에 맞게 file과 post를 처리해야 합니다.
- file이 없는데 post가 있다면 폼 프로토콜에 맞게 post를 처리합니다.
- 최종적으로 연결하여 결과가 200대나 300대라면 스트림을 돌려줍니다.
Get 처리
일단 뼈대는 세웠습니다. 위의 함수 선언을 보면 get이나 post를 HashMap이 아니라 문자열 하나로 받고 있습니다.
따라서 문자열을 파싱하여 키, 값으로 정리하겠다는 뜻인데 다양한 값이 들어올 걸 예상하면 구분자를 좀 까다롭게 고를 필요가 있습니다.
하지만 단순한 예제니 여기서는 컴마로 처리하겠습니다. 파싱은 매우 간단합니다.
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(); }
파일이 있는 경우
파일은 문자열로 받을 재간은 없죠. 키와 스트림으로 구성된 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하게 됩니다. 대략 만드시면 됩니다. 데이터와 중복만 안되면 되거든요.
다음은 conn의 outStream을 가져와야 합니다.
이게 약간 혼동을 일으킬 수 있는데,
- 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만 있는 경우
이제 마지막으로 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();
이미 익숙해지셨을 테니 자세한 설명은 생략합니다. 멀티파트에 비하면 껌이죠.
응답요청 및 응답코드
이제 요청에 대한 내용을 완전히 작성했으니 서버에 연결하여 데이터를 받아올 차례입니다. 이 때 서버는 크게 보면 세 가지로 응답합니다.
- 정상적으로 응답한다.
- 열라 늦게 응답하거나 안한다.
- 니 요청에 응답을 못하겠다고 한다.
우선 3번을 집중적으로 생각해보죠. 3번은 사실 서버에게 응답을 받은 것입니다.
- 요청이 잘못되어서 그런 자원이 없다(404)고 답하든,
- 서버가 바빠서 니 건 처리 못하겠다(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압축
http프로토콜은 자체적으로 압축을 지원합니다. 따라서 요청헤더에 압축지원을 기술하면 서버가 압축을 해서 보내줄 수도 있습니다(서버가 안 해 줄 수도 있습니다 ^^)
이를 위해서는 두 군데를 수정해야 합니다.
- 우선 최초 conn셋팅에서 압축된 응답을 받을 수 있다고 추가로 셋팅합니다.
- 응답받은 스트림을 조사하여 압축된 경우 압축을 풀고 처리합니다.
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; }
예외처리
전체적으로 예외처리를 생략했는데 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로 감싸야 합니다.
위의 예외 처리는 순서도 매우 중요합니다. 서로 상속관계가 있기 때문에 반드시 저 순서로 해야 개별로 에러를 인식할 수 있습니다.
결론
http 처리를 간단히(^^) 알아봤습니다.
conn.connect는 블록킹을 일으키는 동기화 함수 입니다. 따라서 비동기화하여 응답성을 확보하려면 반드시 쓰레드로 돌려야 합니다.
웹 서핑중 귀하의 글을 보고 메일 드립니다.
http 통신일 경우 FTP 특정 사이트의 특정 폴더에 이미 저장되어 있는 데이터 파일
(예) http://www.xxxxx.com/client_data/aaaa.dat(텍스트파일) 이 있다고 가정할 때
이 데이터 파일을 읽어 드린 다음
이 데이터를 참조하여 안드로이트 앱에 볼러오고
이를 이용하여 가공된 별도의 텍스트형식의 데이터파일을 bbbb.dat 라는 이름으로
서버로 전송해 주는 앱 제작이 가능할까요?
안드로이드 앱을 공부하고 있는데 이제 막 시작한 초짜라서 모르는게 너무 많아 여쭤봅니다.
이 글을 보실 수 있을라 모르겠네요.
만약 보셨다면 메일로 가르침 부탁합니다.
1
http통신일 경우 ftp특정 사이트의 폴더는 접근할 수 없습니다. 왜냐면 http통신이니까요.
2
http의 어떤 위치에 있는 자원을 내려받아서 가공한뒤 어떤 서버에게 전송해주는게 가능한가에 대해서라면 그 서버가 준비되면 가능할 것 같습니다.
(1) ftp 특정사이트의 소유및 관리자가 본인일 경우도 불가능할까요?
사이트의 아이디와 Password 를 알 수 있으니까요…
(2) 해당 파일의 구조가 단순한 텍스트 형식의 랜덤파일일 경우는
안드로이드상에서 해당 사이트로 Read, Write에 문제가 되지 않는지요?
그 파일의 구조가 안드로이드상에서 별도로 읽을 수 있게
다르게 가공되어야 하는지요…
본인은 올드버전의 (Visual) 계열의 언어 프로그램을 사용하여
다년간의 작업으로 어떤 프로젝트를 이미 완성하였고
단지 안드로이드 앱을 이용하여 클라이언트가 보내는 자료를 받아보고자 합니다.
앱 제작과 관련된(Java,xml,sql 등 앱 제작과 관련된 공부는 하고 있습니다만
앱세대가 아니라서 그 언어 구조체가 너무 생소하고 접근하기 어려워
어려움을 겪고 있습니다.
가급적 답변은 귀찮으시더라도 이메일로 부탁드려도 될런지요?
저의 이메일은 ebb_tide@naver.com 입니다.
1. ftp클라이언트를 만들어서 해결해야 합니다. 당연하게도 http클라이언트는 http만 처리합니다 ^^;
2. 파일구조가 어떻든 바이트스트림으로 읽으면 그만입니다. 자바는 바이트레벨의 스트림을 지원하므로 쨌든 읽는건 문제없습니다.
3. 앱개발이라고는 하나 지금 저와 대화하고 있는 주제는 그저 자바입니다. 자바는 배우기 쉬운 언어입니다 ^^ 다양한 커뮤니티와 초보용 강좌가 무료로 즐비합니다.
4. 댓글에 쓰신 이메일 지우세용 ^^; 각종 스팸수집기들의 타겟이 됩니다. 제 블로그는 안그래도 스팸봇들이 많이 방문하고 스팸성 댓글도 많이 달려서 필터가 엄청 잡아내더라구요. 앱타이드님의 개인정보보호를 위해 댓글에서 이메일 부분은 삭제해주세요 ^^
성실한 답변 고맙습니다.
공부에 많은 도움이 되겠습니다.
건승을 기원합니다.