[Java 으깨기 (1)] Buffer 입출력과 StringTokenizer

윤유상·2022년 11월 2일

Java

목록 보기
1/1

0. 표준 입출력만을 이용한 문제풀이의 한계

오로지 Java의 표준 입출력만으로 백준 문제를 풀어나가던 중, 나는 난관에 봉착했다.

https://www.acmicpc.net/problem/2693
(백준 2693번 - N번째 큰 수)

정수 n이 주어지고, n번 동안 10개의 숫자를 입력받을 때마다 그 숫자들 중 3번째로 큰 수를 출력하는 문제로, 문제 자체를 해결하는 방법을 찾는 데엔 큰 어려움이 없었다.

  1. 배열에 숫자 10개를 저장한다.
  2. 배열을 오름차순으로 정렬한다.
  3. 8번째로 큰 수(배열의 7번 인덱스의 숫자)를 출력한다.
  4. 이를 n번 반복한다.

배열의 정렬은 Arrays 클래스의 sort 메소드를 이용하면 되니 문제 없이 바로 풀 수 있을 것이라 생각했다.

처음 작성한 코드.
이 코드로 스무스하게 다음 문제로 넘어갈 수 있을 것이라 생각했는데 예상과 달리 이 문제만 며칠 동안 붙잡고 있었다.

import java.util.*;
public class Main {
	public static void main(String[] args) {
		Scanner s=new Scanner(System.in);
		int t=s.nextInt();
		int[] nlist=new int[10];
		for(int i=0; i<t; i++) {
			for(int j=0; j<10; j++) {
				nlist[j]=s.nextInt();
			}
			Arrays.sort(nlist);
			System.out.println(nlist[7]);
		}
	}
}
<출력결과>
4
1 2 3 4 5 6 7 8 9 1000
338 304 619 95 343 496 489 116 98 127
931 240 986 894 826 640 965 833 136 138
940 955 364 188 133 254 501 122 768 4088
489
931

768

첫번째 결과값 8이 줄바꿈없이 출력되는 것도 문제였고, 마지막 결과값 768과 그 전 결과값 931 사이에 줄바꿈이 뜬금없이 들어가는 것도 문제였다.

로직에 특별한 문제는 없어보였기에, 입출력의 문제일 수밖에 없다고 생각했다.
사실 구체적으로 왜 이런 문제가 발생하는지는 아직도 정확히 모르겠다;
아시는 분은 댓글로 가르쳐주시면 정말 감사드립니다... 진짜 너무 궁금해요.

아무튼, 그 전부터 백준 문제를 풀면서 문제를 푼 다른 사람들의 코드를 볼 때마다 느꼈던, 자바 입출력에 대한 나의 부족한 이해를 으깨버릴 때가 왔음을 느꼈다.

1. 입출력 스트림

스트림은 데이터가 이동하는 통로이다.
Stream이라는 단어의 의미와 같이, 물이 흐르듯 데이터가 흐르는 길을 상상하면 된다.
스트림을 통해 이동하는 데이터의 형식에 따라, 스트림의 목적에 따라 각각 2가지로 나뉜다.

1-(1) 바이트 기반 스트림 vs 문자 기반 스트림

바이트 기반 스트림은 모든 종류의 데이터를 받고 보낼 수 있다.
문자 기반 스트림은 문자 형식의 데이터를 받고 보내는 데 특화되어있다.

그러면 굳이 문자 기반 스트림을 쓸 필요 없이 바이트 기반 스트림만 쓰면 되지 않나 싶은 생각도 들지만,
바이트 기반 스트림은 바이트 단위로 데이터를 운반하기 때문에 단 하나의 값만 입출력을 할 수 있다. (배열을 통해 여러 값을 받을 수가 있는 메소드가 있긴 하지만, 그 배열이 바이트 배열이기도 하고 읽고 쓰는 바이트 갯수를 지정해주어야 하기 때문에 여러모로 써먹기가 어렵다.)

하지만 이제 언급할 문자 기반 스트림 BufferedReader 클래스의 readLine() 메소드를 이용하면 한 줄을 통째로 입력받아 String으로 저장할 수 있다.

1-(2) 입력 스트림 vs 출력 스트림

프로그램이 데이터를 입력받을 때 이동하는 경로는 입력 스트림, 프로그램이 데이터를 출력할 때 이동하는 경로는 출력 스트림이라고 한다.

입력 스트림은 입력장치(키보드, 파일 등) -> 프로그램
출력 스트림은 프로그램 -> 출력장치(모니터, 파일 등)
의 경로로 이어주는 통로라고 생각하면 된다.

이 때, 물의 흐름이 역행하지 않듯이 스트림에서는 한 쪽 방향으로만 데이터가 이동하기 때문에 프로그램에서 입력도 받고 출력도 해내야 하는 경우엔 입력 스트림과 출력 스트림을 각각 만들어주어야한다.

1-(3) 입출력 스트림의 최상위 클래스들

(1) 바이트 기반

  • 입력 스트림 : InputStream
  • 출력 스트림 : OutputStream

(2) 문자 기반

  • 입력 스트림 : Reader
  • 출력 스트림 : Writer

이 클래스들은 모두 추상 클래스들이라 new 연산자로 생성을 못하기 때문에, 하위 클래스를 통해서 메소드에 접근해야 한다.

2. 버퍼

BufferedReader 클래스와 BufferedWriter 클래스는 각각 문자 기반 입출력 스트림 클래스 Reader 클래스와 Writer 클래스의 하위 클래스이다.
특징은 버퍼를 사용한다는 점인데, 아니 버퍼는 또 뭐지?

버퍼는 스트림이라는 수로를 가로막고 있는 중개상이라고 보면 된다.
이 중개상이 없으면 데이터가 흘러드는 족족 바로 목적지에 도달할 수 있다. (버퍼를 이용하지 않는 입출력)
하지만 버퍼가 가로막고 있으면 버퍼는 그 데이터들을 건져올려서 화물칸에 쟁여두었다가 단속이 뜨거나 화물칸이 가득 차면 목적지에 돌려준다. (버퍼를 이용한 입출력)

안까먹으려고 저세상 비유를 들었는데, 정리하면 다음과 같다.

2-(1) 버퍼를 이용하지 않는 입출력

  • 데이터가 입출력 스트림으로 흘러들면 바로 목적지 (입력스트림의 경우엔 프로그램, 출력스트림의 경우엔 출력장치)에 도달한다.

2-(2) 버퍼를 이용하는 입출력

  • 데이터가 입출력 스트림으로 흘러들고 목적지에 도달하지 않고 일단 버퍼에 저장된다.
  • 개행 문자(\n)이나 flush()를 만났을 때,
    혹은 버퍼가 가득 찼을 때 데이터가 버퍼에서 목적지에 도달한다.

버퍼를 이용해서 입출력하면 데이터를 한꺼번에 옮겨주기 때문에, 스트림으로 흘러드는 데이터의 양이 많을수록 버퍼를 이용하는 것이 더 속도가 빠르다는 장점이 있다.

3. BufferedReader와 BufferedWriter

이제 버퍼의 개념도 알았으니 BufferedReader와 BufferedWriter 클래스를 사용해볼 시간.

3-(1) java.io 패키지 import해주기

먼저, java.io 패키지 아래에 있는 BufferedReader 메소드와 BufferedWriter 메소드를 이용하기 위해 import해준다.

import java.io.BufferedReader
import java.io.BufferedWriter

귀찮으면,

import java.io.*;

로 퉁쳐줘도 된다.

3-(2) IOException 예외처리해주기

public static void main(String[] args) throws IOException

io 패키지의 클래스의 메소드들은 IOException이라는 예외를 발생시키므로, 예외처리를 해주어야 한다.
물론, try catch문을 사용해 예외처리를 해도 된다.

3-(3) BufferedReader와 BufferedWriter 객체 생성

InputStream in = System.in;
InputStreamReader reader = new InputStreamReader(in);
BufferedReader buffered_reader = new BufferedReader(reader);

BufferedReader 객체를 생성하는 과정이다.

BufferedReader은 생성자의 인자로 Reader 객체를 받는다.
근데 Reader는 최상위 클래스이기 때문에 new 연산자로 만들 수 없으므로 하위 클래스인 InputStreamReader와 InputStreamWriter로 전달해준다.

InputStreamReader는 생성자의 인자로 InputStream 객체를 받는다.
System.in이 InputStream 객체를 뱉기 때문에 인자로 System.in을 전달해준다.

이를 한 줄로 줄이면 다음과 같다.

BufferedReader buffered_reader = new BufferedReader(new InputStreamReader(System.in));

BufferedWriter의 경우도 마찬가지.

BufferedWriter buffered_writer = new BufferedWriter(new OutputStreamWriter(System.out));

3-(4) 메소드 사용하기 - readLine(), write()

다른 메소드들도 있지만 이번에 쓸 (아마 앞으로도 주로 쓰게 될) 메소드는
BufferedReader 클래스의 readLine() 와
BufferedWriter 클래스의 write() 이다.
각각의 메소드에 대해 살펴보면,

BufferedReader.readLine()
: 개행 문자(\n)를 만날 때까지 입력을 저장해두었다가 프로그램에 전달한다.
즉, 입력된 한 줄을 통째로 읽어 String으로 반환한다.
만약 띄어쓰기로 구분된 정수들을 입력받는 경우에는 띄어쓰기를 기준으로 나누어주는 가공을 거친 뒤(이 때 StringTokenizer를 사용한다.),
int형으로 형변환을 시켜주어야 한다.

BufferedWriter.write(String str)
: 인자로 전달된 String을 화면에 출력한다.
출력할 데이터가 String이 아니라면 +"" 등을 통해 인자를 String으로 바꾸어주어야 한다.

3-(5) 마무리 - flush() 와 close()

BufferedWriter.flush() 는 버퍼에 남은 데이터를 모두 출력해 버퍼를 비워주는 메소드이고,
BufferedReader.close(), BufferedWriter.close() 는 사용한 시스템 자원을 반납하고 각각의 스트림을 닫아주는 메소드이다.
입출력 스트림을 사용한 후엔 이 메소드들을 호출해주는 것이 좋다.

4. StringTokenizer

BufferedReader.readLine() 을 통해 데이터를 읽어들일 때, 한 줄을 통째로 읽어들이기 때문에 여러 개의 데이터가 띄어쓰기나 다른 구분자로 구분되어있을 경우엔 이를 나누어주어야 한다.
StringTokenizer 라이브러리는 문자열을 어떤 기준자를 통해 작은 단위의 토큰으로 나누어주는 역할을 해줄 수 있다.

StringTokenizer를 이용하는 방법도 알아봐야겠다.

4-(1) StringTokenizer 라이브러리 import해주기

java.util.StringTokenizer;

이하 생략.

4-(2) StringTokenizer 객체 생성하기

StringTokenizer st = new StringTokenizer(String str);

구분자로 나누어줄 String 객체를 인자로 전달해 StringTokenizer 객체를 생성해준다.
위와 같이 생성해주면 주어진 문자열을 띄어쓰기를 기준으로 나누어주지만, StringTokenizer의 생성자는 다음과 같이 오버로딩되어있어 필요에 따라 인자를 전달해주면 된다.

StringTokenizer st = new StringTokenizer(String str, String str2);
// str2를 기준으로 나누어준다.
StringTokenizer st = new StringTokenizer(String str, String str2, boolean b);
// b가 false면 str2를 토큰에서 제외해 나누어주고, b가 true면 str2를 토큰에 포함시켜서 나누어준다.

이렇게 나누어진 문자열 str은 토큰 단위로 큐에 저장되어서, 하나씩 빼서 쓸 수 있다.

4-(3) 메소드 사용하기 - nextToken()

StringTokenizer.nextToken()
: 토큰을 하나씩 빼서 반환한다.

이 문제에선 이 메소드를 사용하지만, StringTokenizer 라이브러리에 다른 유용한 메소드도 있어보인다.
특히 토큰의 개수를 세는 countTokens 메소드는 언젠가 만드시 쓸 것 같으니 기억해두어야겠다.

5. 문제에 활용해보기

import java.io.*;
import java.util.Arrays;
import java.util.StringTokenizer;

public class Main {
	public static void main(String[] args) throws Exception{
		BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
		BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
        // 스트림 객체 생성
        
		int t=Integer.parseInt(br.readLine());
        // 입력받을 테스트케이스 갯수 
        
		int[] nlist=new int[10];
        // 테스트케이스의 정수들을 저장할 배열 생성
        
		for(int i=0; i<t; i++) {
			StringTokenizer st=new StringTokenizer(br.readLine());
            
			for(int j=0; j<10; j++) {
				nlist[j]=Integer.parseInt(st.nextToken());
                // 띄어쓰기로 구분된 정수들을 배열에 집어넣기
			}
            
			Arrays.sort(nlist);
			bw.write(nlist[7]+"\n");
		}
        bw.flush();
		br.close();
		bw.close();
        // 버퍼 비우고 자원 반납 후 스트림 닫기.
	}
}

사실 지금껏 어떤 언어를 다루든 입출력이나 문자열 처리에 관련된 부분이 약해서 문제 풀 때마다 애를 먹었는데, 돌고돌아 자바에서야 비로소 어느 정도 숨통이 트인 듯한 기분이다.

다음 으깨기 시간에 만나요!

<참고한 글 & 영상>
'이것이 자바다 - 18.2 입력 스트림과 출력 스트림'
, 한빛미디어, 유튜브
'Java 입출력(I/O), 스트림(Stream), 버퍼(Buffer) 개념 및 사용법'
, TerianP 님, 티스토리
'[JAVA 자바] StringTokenizer 클래스로 문자열 분리하기! split 비교.'
, 양햄찌 님, 티스토리

profile
안녕하세요. 일본 유학 중인 개발자 지망생입니다.

0개의 댓글