Programing
5장 I/O Streams - Java Performance and Scalability
원문 : http://www.mobilejava.co.kr/bbs/temp/lecture/j2me/perf5.html
※이 문서는 『Java Performance and Scalability Volume1』(by Dov Bulka)라는 책의
5장:I/O Streams 를 읽고 나름대로 정리한 것입니다.
Preface |
자바의 I/O stream은 socket, file, string, character array 등등을 끝단으로 해서 데이터를 읽고 쓸 수 있다. 이러한 I/O 들의 성능 문제를 언급하기 위해서 전체를 다 다룰 필요는 없을것 같다.
우리가 원하는 것은 실제 I/O 장치와 stream 클래스와의 상호작용의 기본적인 성능 원리를 이해하는 것이므로 file I/O에 관한 stream을 살펴보는 것만으로도 충분할 것이다. Java에서 I/O stream을 이용하여 데이터를 읽고 쓰는데 있어서 가장 중요한 문제는 버퍼링과 유니코드 변환문제이다. 이번 장에서 다루는 모든 최적화는 둘중의 하나이다.
Example I/O Code |
이번장에서 사용하는 예제들은 I/O stream을 통해 주로 데이타를 읽기만 하거나 쓰기만 할 것이므로 쓰여질 데이터가 될 코드를 미리 작성해 둔다. 간단한 주식거래에 대한 클래스로 거래 날짜, 매도(매수), 주식 심볼, 거래 주식수, 주식 가격만을 정보로 가진다고 하자. 그러면 대충 필요한 겉모양은 나온다.
class Trade{
String date;
boolean buy;
int numShares;
String symbol;
float price;
...
}
Trade 객체를 파일에 기록한다면 (1)이나 (2)처럼 사용할 것이다.
(1) Trade buy = new Trade(“01-01-99”, true, 100, “IBM”, 140);
fw.println(buy); //fw is a FileWriter
(2) fos.write(buy.toString().getBytes()); //fos is FileOutputStream
println() 메소드는 Trade.toString()을 호출할텐데 이는 다소 비싼 작업이다. 여기에서는I/O 의 성능에 관해서 이야기를 할 것이므로 이러한 부분들이 성능 비교에 별 영향을 미치지 못하도록 클래스를 적절히 수정하도록 하겠다.
생성자에서 String이나 byte로 미리 변환을 시켜놓고toString()이나 toBytes() 메소드는 단지 이에 대한 레퍼런스 값만을 넘기게 함으로써 I/O 작업을 위해 String값이나 byte값을 매번 요구하더라도 값을 넘겨주는 작업은 성능에 거의 영향을 미치지 않게 된다. 코드는 아래와 같다.
class Trade{
String date;
boolean buy;
int numShares;
String symbol;
float price;
String stringRep;
byte[] byteRep;
public Trade(String d, boolean b, int n, String s, float p){
symbol = s;
numShares = n;
buy = b;
date = d;
price = p;
StringRep = privateToString(); //String으로 미리 변환
byteRep = stringRep.getBytes(); //byte로 미리 변환
}
private String privateToString(){
StringBuffer sb = new StringBuffer(128);
sb.append(date).append(“ ”);
if(buy) sb.append(“Buy “);
else sb.append(“Sell “);
sb.append(numShared).append(“ ”);
sb.append(symbol).append(“ ”);
sb.append(price).append(“ ”);
return sb.toString();
}
public String toString(){
return stringRep;
}
public byte[] toBytes(){
return byteRep;
}
}
1. Output Buffering |
버퍼링은 바이트당 오버헤드를 최소화 할 수 있는 기법이다. 데이터를 보내기 위해서 자바에서는 OS자체의 함수를 콜해야 하는데, OS 함수 자체는 한번 콜하는데 드는 비용이 한 바이트를 보내나 여러바이트를 보내나 비슷하다(그 비용 또한 만만치도 않다).
그러므로 한번에 한 바이트씩만 보낸다면여러 바이트를 묶어서 한번에 보내는 것보다 엄청나게 비싼 댓가를 치러야 되는 것은 자명한 사실이다. 자바에서 I/O stream을 이용해서 버퍼링을 하는 것은 아주 쉽다. 단지 원래의 output stream을 buffered stream으로 감싸기만 하면 된다.
여기에서는 Trade 객체를 FileOutputStream을 이용해서 파일에 기록할 때 버퍼링이 얼마나 나은결과를 가져 오는지 보여주고자 한다. 첫번째 예제는 버퍼링 없이 FileOutputStream을 사용하였고두번째 예제는 버퍼링을 사용하였다.
[예제1] 수행시간 : 33초
Trade buy = new Trade(“01-01-88”,true,100,”IBM”,140);
Trade sel = new Trade(“01-08-88”,false,100,”IBM”,150);
FileOutputStream fos = new FileOutputStream(“stock.dat”);
for(int i=0; i<1000000; i++){
fos.write(buy.toBytes());
fos.write(sell.toBytes());
}
fos.close();
[예제2] 수행시간 : 19초
Trade buy = new Trade(“01-01-88”,true,100,”IBM”,140);
Trade sel = new Trade(“01-08-88”,false,100,”IBM”,150);
BufferedOutputStream fos =
new BufferedOutputStream(new FileOutputStream(“stock.dat”));
for(int i=0; i<1000000; i++){
fos.write(buy.toBytes());
fos.write(sell.toBytes());
}
fos.close();
버퍼링을 하지 않았을 때는 33초, 했을 때는 19초가 걸렸다. 버퍼링을 하지 않았을 때는 모든Trade 객체가 약 50 바이트마다 파일에 기록된다. BufferedOutputStream의 디폴트 버퍼 사이즈는 512 바이트이므로 스트림 객체는 버퍼에 그만큼의 데이터가 찰 때까지 기다렸다가 한번에 복수의Trade 객체를 파일에 기록함으로써 성능을 향상시켰다. 대개의 경우 버퍼 사이즈를 늘릴 수록 성능은 향상된다. 버퍼 사이즈를 변경했을 때 미치는 영향을 알아보자. 예제3과 4는 버퍼사이즈만 변경해서 테스트를 해 본 것이다.
[예제3] 수행시간 : 19초
BufferedOutputStream fos =
new BufferedOutputStream(new FileOutputStream(“stock.dat”),4096);
...
[예제4] 수행시간 : 27초
BufferedOutputStream fos =
new BufferedOutputStream(new FileOutputStream(“stock.dat”),64);
...
충분한 버퍼링을 했을 때는 서로 성능 차이가 별로 없었다. 그러나 예제4와 같이 버퍼 사이즈가 너무 작으면 버퍼링을 하지 않았을 때와 비슷한 빈도로 실제 I/O가 일어나므로 별 이득이 없다.
2. Don’t Flush Prematurely |
버퍼에 담겨 있는 데이터를 내보내는 flushing은 버퍼가 다 차면 자동으로 내보내는 식으로만은사용할 수 없다.
예를 들어 일단 클라이언트 쪽에서 요청 명령을 보낸다음 서버쪽으로부터 어떤 데이터를 받는다고 했을 때 요청이 버퍼링이 되어있다면 서버쪽에 전달이 되질 않을테니 그러한 경우는버퍼가 다 차지 않아도 즉각 데이터를 내보내야 한다.
반면에 그 후로 이어지는 일련의 데이터가 있다면 버퍼가 찰 때까지 기다렸다나 한꺼번에 보내주는게 효율적이다. 이렇듯 플러슁은 그 시점이 나름대로 중요하므로 수동으로 적절히 조절할 필요가 있다. 그러나 비적절한 시점에 자주 플러슁을 하면 성능은 떨어진다.
우리의 예제를 살펴보자. 주기적으로 플러슁을 해야 한다면 그 시점은 Trade 객체 하나를 모두 보냈을 때 뿐일 것이다.
마지막 기록에서는 close() 메소드가 자동적으로 flush() 메소드를 호출하므로 플러슁을 할 필요가 없다. 수동적으로 해주지 않으면 bufferd stream이 버퍼가 차면 자동으로 플러슁을 해준다. 너무 성급하게 플러슁을 했을 때 어떤 일이 벌어지는지 한번 살펴보겠다.
예제1은 BufferedOutputStream을 사용하지만 매번 Trade 객체를 기록할 때마다 플러슁을 하였다.
[예제1] 수행시간 38 초
Trade buy = new Trade(“01-01-88”,true,100,”IBM”,140);
Trade sel = new Trade(“01-08-88”,false,100,”IBM”,150);
BufferedOutputStream fos =
new BufferedOutputStream(new FileOutputStream(“stock.dat”));
for(int i=0; i<1000000; i++){
fos.write(buy.toBytes());
fos.flush();
fos.write(sell.toBytes());
fos.flush();
}
fos.close();
너무 잦은 플러슁은 버퍼링 기법을 오히려 더 안좋게 만들어 준다. 버퍼링을 하기 위해서는 데이터를 일단 버퍼에 카피를 해야 하는 수고를 들여야 하는데 버퍼에 카피하자마자 데이터를 내보냈으니앞절에서 행한 결과와 비교해보면 카피하는데 드는 비용으로 인해 버퍼링을 하지 않았을 때 보다 더비효율적이 되어버렸다.
3. Prefer Byte Stream to Unicode |
Output stream은 대략 유니코드 문자열을 다루는 writer와 바이트 배열을 다루는 것으로 나눌수 있다. 유니코드 문자열을 writer를 이용하여 내보낼 때 그 끝단은 아마 socket이나 file쯤이될 것이다.
그런데 여기서의 문제점은 soket이나 file은 유니코드가 아니라 단지 바이트로만 처리할 수 있다는데 있다. 그러므로 어딘가에서는 유니코드를 바이트로 변환을 해주어야 하고 1장에서도살펴봤듯이 이는 매우 비싼 작업이다.
유니코드 스트림를 바이트 스트림으로 바꾸어 주는 데는OutputStreamWriter라는 것이 있다. 만약에 (1)과 같이 FileWriter를 정의한다면 이는 실제로는 (2)와 동일하다.
(1) FileWriter fw = new FileWriter(...);
(2) OutputStreamWriter osw =
new OutputStreamWriter(new FileOutputStream(...));
Writer stream은 더 편한 인터페이스를 제공하기는 하지만 하나가 편하면 어디선가 불편한 누군가가 있기 마련이다. 이는 곧 성능 저하라는 결과를 가져온다. Buffered FileWriter와 bufferd FileOutputStream을 비교해보자.
[예제1] 수행시간 : 19초
Trade buy = new Trade(“01-01-88”,true,100,”IBM”,140);
Trade sel = new Trade(“01-08-88”,false,100,”IBM”,150);
BufferedOutputStream fos =
new BufferedOutputStream(new FileOutputStream(“stock.dat”));
for(int i=0; i<1000000; i++){
fos.write(buy.toBytes());
fos.write(sell.toBytes());
}
fos.close();
[예제2] 수행시간 : 34초
Trade buy = new Trade(“01-01-88”,true,100,”IBM”,140);
Trade sel = new Trade(“01-08-88”,false,100,”IBM”,150);
BufferedWriter fw =
new BufferedWriter(new FileWriter(“stock.dat”));
for(int i=0; i<1000000; i++){
fw.write(buy.toString, 0, buy.toString().length());
fw.write(sell.toString, 0, buy.toString().length()(;
}
fos.close();
이러한 현저한 차이는 Writer에서 행해야 하는 char-to-byte 변환 때문에 발생한다. 그러므로실제로 유니코드로 전송해야 할 일이 생긴다면 성능에 별로 영향을 미치지 않는 곳에서 char를byte로 변환한 다음 stream으로 바이트 배열만을 보내야 할 것이다.
4. Input Buffering |
출력에서 입력으로 방향을 바꿔보자. 반대이긴 하지만 출력의 경우와 거의 비슷하고 여전히 가장 중요한 문제는 버퍼링이다. stock.dat라는 파일에는 100,000개의 Trade 객체가 들어있다.
버퍼링을 했을때와 하지 않았을 때를 비교해보자.
[예제1] 수행시간: 40초
Trade buy = null;
Trade sell = null;
String line =null;
DataInputStream dis =
new DataInputStream( new FileInputStream(“stock.dat”));
while((line = dis.readLine()) != null){
}
dis.close();
[예제2] 수행시간: 4.7초
...
DataInputStream dis =
new DataInputStream(
new BufferdInputStream(new FileInputStream(“stock.dat”)));
...
일단 버퍼링을 하지 않은 예제1은 매우 느리다. 같은 수의 객체를 기록할 때보다도 약 10배나 느리다. 이러한 현상이 발생하는 이유는 DataInputStream이 버퍼링을 사용하지 않는FileInputStream을 사용했기에 파일에서 한번에 한 바이트씩만을 읽어왔기 때문이다.
FileInputStream은 매우 단순하고 바보같은 스트림으로서 한 바이트만 요구하면 한 바이트만 물리적으로 읽어온다. 우리가 원하는 것은 바이트당의 오버헤드를 최소화 하기 위해 한번의 물리적 읽기로 여러 바이트를 읽어오는 것이다. 그러기 위해서는 예제2처럼 BufferedInputStream을FileInputStram과 DataInputStream 사이게 넣기만 하면 된다. 수행 시간을 보면 9배나 성능이향상된다는 것을 알 수 있다.
Reader의 경우도 똑같이 적용된다. (1)보다는 (2)가 낫다.
(1) FileReader fr = new FileReader(“stock.dat”);
(2) BufferedReader fr =
new BufferedReader( new FileReader(“stock.dat”));
일반적으로 byte stream이나 reader 또는 writer를 사용할때는 반드시 buffered stream으로감싸서 사용할 것을 고려해 봐야 한다.
5. Byte-to-Char Conversions |
앞에서 살펴봤듯이 유니코드 문자열을 바이트 스트림으로 변환하면 성능이 저하된다.
반대로 바이트스트림을 유니코드 문자열로 변환시키는 것도 마찬가지이다. FileReader를 사용하여 앞절의 예제2와 같이 실행해보면 FileInputStream을 사용했을 때는 4.7초가 걸리는 대신 5.3초가 소요된다.
유니코드로 저장된 문자들를 읽어들일려면 어쩔 수 없지만 ASCII로만 되어있다면 굳이 Reader를 사용할 필요가 없다.
'Programing'의 다른글
- 현재글5장 I/O Streams - Java Performance and Scalability