JAVA [자바] - 입력 뜯어보기 [Scanner, InputStream, BufferedReader]
이 글을 지금 이 시점에 써야 할까 고민을 많이 했다.
사실 자바를 그냥 다룰 줄만 아는 것에 목표를 둔다면 이 글이 무의미할 수도 있다.
그러나 자바에 대해 조금이라도 관심이 있고 더 배우고 싶은 분들도 있겠다 싶어 작성해보자 마음먹고 쓰기로 했다.
(사실 고민하다간 똥 된단 소리를 워낙 많이 들어와서..)
그래서 입력에 대한 구조를 살펴보면서 자바에 대한 지식을 하나 더 추가해보자.
그리고 이 글을 보기 전에 여러분들이 기본적으로 알고 가야할 것이 있다.
바로 JAVA 의 인코딩에 대한 것이다.
Java는 String 을 처리할 때 내부(메모리 상에서)에서는 UTF-16 BE 인코딩으로 문자열을 저장하고, 송수신에서는 직렬화가 필요한 경우 변형된 UTF-8 (modified UTF-8) 을 사용하며 문자열을 입/출력할 때에만 사용자가 지정한 인코딩 값 또는 운영체제의 기본 인코딩 값으로 문자열을 인코딩한다.
- 자바는 내부적으로 (메모리 상에서) 문자열이 UTF-16 으로 인코딩되어 처리된다.
- 문자열 송/수신을 위해 직렬화가 필요할 때에는 변형된 UTF-8 을 사용한다.
- 문자열을 입출력 할 때는 운영체제 기본 인코딩값, 또는 사용자가 지정한 인코딩 값으로 문자열을 인코딩한다. (내부 메모리 상에서 처리되는 것과는 다르다.)
- 1 ~ 127 까지는 Ascii 코드 값과 유니코드(UTF-8, UTF-16 등..), MS계열 코드(CP949, MS949 등..) 의 값이 같다. ( ms 랑 유니코드는 해당 범위에서 92 번만 다른데 이는 역슬래시로 윈도우에서는 대부분 ₩ 으로 표현되고 맥북, 리눅스 계열에서는 \ 으로 표현된다. )
UTF-8 과 UTF-16 의 차이는 Byte 구성 방식에서 차이가 있다.
UTF-8 의 경우 문자의 영역에 따라 Byte 사용 개수가 다른데, 영어의 경우 1Byte, 한글의 경우 3Byte 를 사용한다.
반대로 UTF-16 은 거의 모든 문자가 2Byte 로 구성된다. (특이케이스로 4Byte 로 구성되어있는 경우인데 쓸 일은 거의 없을 것이다. 만약 궁금하다면 써로게이트(surrogate) 를 검색해보면 된다.)
인코딩 처리 방식은 아래와 같다.
일단 UTF-8 의 형식을 보아야한다.
xxxx 로 표시된 부분은 원래의 비트 값을 순서대로 적으면 된다.
즉 어떤 문자가 128이라는 값에 대응된다면, 128은 16진수로는 x0080, 2진수로는 00000000 10000000는데, 이 때 인코딩 될 때에는 00000000 10000000 중 '파란색 부분'만 떼어서 110xxxxx 10xxxxxx에 차례대로 채워넣어주면 된다.
즉, 11000010 10000000 으로 저장된다는 것이다.
그런데 앞서 말했지만 자바에서는 UTF-16 을 쓴다고 했다.
그 이유는 인코딩 할 때 널(NULL) 문자가 나타나지 않기 위해서다. 그래서 U+0000 을 2Byte 로 구성한다.
즉, 인코딩 방법에서 약간의 차이가 있는데 다음과 같다.
결과적으로 자바 메모리에 올라갈 때의 과정을 간단히 설명하자면 다음과 같다.
이클립스의 File encoding 이 UTF-8 이라면
입력(UTF-8) -> 송수신(modified UTF-8) -> 자바 메모리 (UTF-16) -> 송수신(modified UTF-8) -> 출력(UTF-8)
즉, 운영체제 혹은 시스템에 설정되어있는 인코딩 형식으로 입력받으면 UTF-16 의 인코딩 규칙에 의해 인코딩되어 메모리에 올라가고,
출력하게 될 경우 메모리에 UTF-16 인코딩 규칙에 의해 저장되있는 값을 다시 운영체제 혹은 시스템에서 설정한 인코딩 형식으로 대응되는 문자를 출력하는 것이다.
참고로 직렬화 할 경우 UTF-8로 변환 될 수도 있긴 하지만, 이 것까지 설명하려면 일단 직렬화에 대해 알아야 하기 때문에 결국 너무 글이 세어나가므로 이 부분은 고려하지 않겠다. 직렬화 부분은 다른 여러 블로그들을 통해 찾아보시면 좋을 것 같다. 혹은 시간이 된다면 나중에 따로 다뤄보기로 하겠다.
일단, 기본적으로 직렬화, 혹은 소수의 특수 케이스들을 제외하고는 UTF-16으로 표현된다고 보시면 된다.
필자는 MACOS 로 기본 운영체제 인코딩 값이 UTF-8 로 설정되어있다. 윈도우의 경우 대개 MS949 로 기본 설정 되어 있을 것이다.
이를 설정 또는 확인하는 방법은 아래와 같다.
해당 이클립스에서 클래스 파일(.java)을 우클릭 하여 Properties 을 누르면 볼 수 있다.
가끔 문자열에 대한 글을 읽다보면 아스키코드 값과 UTF-8, MS949 등 다른 인코딩들을 혼동하는 분들이 많다.
아래 글에서 보면 알겠지만 기본적으로 대부분의 인코딩 형식들은 해당 값과 아스키 코드 값이 10진수로 1~127 번까지는 대응되는 문자가 같다.
특히나 코딩할 때 대부분 한글은 안쓰고 영문자와 기본 연산기호를 많이 쓰다보니 char 에 문자를 저장하여 int 값을 반환할 때 "아스키 코드 값" 라고 하는 분들이 많다.
물론 아예 틀린 말은 아니나.. 그건 어디까지나 127 번째 문자까지만 해당하는 말이고 정확히는 파일 인코딩 형식의 10진수 값이 나온다고 하는 것이 올바르다.
자세한 설명은 차근차근 글을 읽어보면서 이해해보자.
필자가 자바를 처음 접했을 때 였을 것이다. 콘솔 창에서 키보드로 입력하기 위한 방법을 배우고 있었다.
[교수님]
Scanner 객체명 = new Scanner(System.in); 이게 입력을 하기 위해 생성한 객체다. Scanner 을 사용하기 위해서는 Scanner 패키지를 import 해라!
객체명.nextLine() 은 문자열을, nextInt()는 정수를, nextDouble() 은 더블형을 입력받는다!
[학생]
외워야 할 것.. S는 반드시 대문자로....
필자는 이렇게 배웠다.
물론 필자 뿐만 아니라 주변 지인들에게도 물어봤는데 100이면 100 다 이렇게 가르쳤다.
사실 당연 할 수밖에 없는 것이 자바를 처음 접하는데 객체니 스트림이니 하면서 가르치는 것도... 어찌 보면 말이 안 되긴 한다. 보통 중후반 가서야 파일 입출력 때에나 가르치기는 하지만.. 대개 이론적으로 이렇다고만 할 뿐 어떻게 연결되고 성능은 어떤지에 대해 정확히 알아 볼 수 있는 기회가 얼마나 있는지는 모르겠다.
본론으로 가서..
Scanner scan = new Scanner(System.in);
여기서 System.in 은 무엇일까?
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
여기도 System.in 이 있네..? 근데 InputStreamReader 은 또 뭐야?
이러한 궁금증을 약간이나마 해소하고자 한다.
글 목차는 가장 원시 입력부터 이 입력방법을 이용하여 구현된 클래스들로 점점 넓혀가며 말하려고 한다.
즉, 순서는 이렇게 가려고 한다.
- InputStream 과 System.in
- Sanner(System.in) 과 InputStreamReader()
- BufferedReader()
- System.in 그리고 InputStream
일단 위 두 개를 설명하기 전에 스트림(Stream) 에 대한 간단한 이해가 필요하다.
참고자료가 있나 싶어 찾아봤더니 점프투자바에서는 다음과 같이 설명한다.
음... 뭐 이해가 된다면 넘어가도 좋다만 필자는 설명이 잘 이해가 안 된다.
그냥 더 쉽게 말하자면 다음과 같다.
" 출발지와 도착지를 이어주는 빨대 "
이미지로 보면 아래와 같다.
위 그림에서 보듯이 한 곳에서 다른 곳으로의 데이터 흐름을 스트림이라고 한다.
그리고 스트림은 단방향이기 때문에 입력과 출력이 동시에 발생할 수 없다. 그렇기 때문에 용도에 따라 입력스트림, 출력스트림이 나뉜다.
(물론 꼭 입력장치나 출력장치가 하드웨어일 필요는 없다. 다만, 이해를 돕기 위해 가장 보편적인 흐름을 보여준 것이다.)
또 다른 비유를 하자면 고속도로라고 보면 된다. 고속도로에서 역주행을 할 수 없듯 스트림은 단방향으로만 흐르며 고속도로에서는 중앙분리대 혹은 도로 자체가 분리되어 상향행 하향행이 존재하듯 입력스트림과 출력스트림 또한 분리되어있다고 보면 된다.
그리고 자바에서 가장 기본이 되는 입력 스트림은 InputStream 이다.
(반대로 출력 스트림은 OutputStream 이다.)
그러면 이쯤에 하나 질문이 생길 것이다.
그래서 도대체 System.in 과 InputStream 의 관계가 뭐길래 이렇게 장황하게 설명해???
이에 대한 대답은 System.in 이 InputStream 타입의 필드라는 것이다!
더 정확히 보면 System 클래스의 in 이라는 필드는 InputStream 의 정적 필드라는 것!
아래 사진을 보면 알 수 있다.
(참고로 System 클래스 in 변수는 '표준 입력 스트림'이며 일반적으로 콘솔, 명령줄 인수 등을 통해 입력을 받을 수 있다. 매우 간략하게 말하자면 여러분이 키보드로 치거나 터미널 등에서 입력을 넣어주는 것들은 System.in 을 통해 연결된다는 의미다.)
정리하자면 in 이라는 변수는 InputStream의 변수로 결국 InputStream 타입의 새 변수를 선언하고 그 변수에 System.in 을 할당시킬 수도 있다는 뜻이다.
이렇기 때문에 System.in 과 InputStream 을 같이 묶어서 설명하게 되는 것이다.
그럼 저 것만으로도 입력이 가능할까? 물론 당연하다.
import java.io.IOException;
import java.io.InputStream;
public class Input_Test {
public static void main(String[] args) throws IOException {
InputStream inputstream = System.in;
int a = inputstream.read();
System.out.println(a);
}
}
위와 같이 InputStream 타입의 변수를 생성하고 입력을 받는 메소드인 read()를 통해 입력할 수 있다.
※ 주의할 점
기본적으로 입출력 클래스는 java.io 라는 패키지에 있다. 그리고 반드시 예외처리를 해주어야한다.
try-catch 문으로 예외를 처리해주어도 되고 위 처럼 메소드에 throws IOException 으로 처리해줘도 된다.
Scanner 나 System.out.print 메소드의 경우 해당 메소드 안에서 자체적으로 예외처리를 하기 때문에 예외처리를 해줄 필요가 없었지만 기본적으로 io 패키지는 IOException 이라는 예외를 던지기 때문에 이를 처리해주어야 정상적으로 컴파일된다.
근데 여러분들이 위 코드를 실행해보면 알겠지만 우리가 입력한 값과 전혀 다른 값이 나온다는 것을 볼 수 있다.
심지어 아예 다른 수를 넣어도 이와 같이 나온다.
왜 이런 현상이...?
눈치 빠른 분들은 알겠지만 InputStream.read() 는 두 가지 특징이 있다.
- 입력받은 데이터는 int 형으로 저장되는데 이는 해당 문자의 시스템 또는 운영체제의 인코딩 형식의(필자의 경우 UTF-8) 10진수로 변수에 저장된다.
- 1 byte 만 읽는다.
이러한 이유는 일단 앞서 언급했듯이 InputStream 은 가장 기본적인 입력스트림이라고 했다.
컴퓨터의 모든 데이터는 어떻게 구성되어있을까? 바로 바이트 단위 데이터로 구성되어있다.
즉, 데이터를 저장하던, 전달하던 컴퓨터에는 바이트 단위로 데이터가 저장된다.
InputStream 은 바이트 단위로 데이터를 보내며 이 InputStream 의 입력 메소드인 read()는 1 바이트 단위로 읽어 들인다.. 또한 바이트 단위로 데이터를 입력받으면 입력받은 문자가 2byte 이상으로 구성되어있는 인코딩을 사용할 경우 1byte 값만 읽어들이고 나머지는 읽지 않고 스트림에만 남아있기 때문에 출력할 때는 해당 데이터의 1 Byte 에 대한 인코딩 값을 10진수로 변환한 값이 출력되는 것이다.
예로들어보겠다.
10000001 00001111 의 값을 갖고있는 2Byte 문자 하나를 입력했다고 가정해보자.
1Byte 로 각각 나뉘어 스트림을 통해 10000001 과 00001111 의 데이터가 흐르게 된다. 즉, 스트림에서는 1Byte 데이터가 2개가 있다는 의미다.
하지만 프로그램에서 read() 를 한 번만 쓰면 먼저 입력된 10000001 을 읽지만, 00001111 은 스트림에 계속 남아있게 된다.
만약에 나머지 1Byte 도 읽고싶다면 read() 메소드를 두 번 써야된다.
그리고 이러한 바이트 단위로 주고받는 스트림을 바이트 스트림이라고도 한다.
그러면 만약 10개의 문자를 입력받고 싶으면 10개의 변수를 선언해야 해??
물론 그럴 수도 있지만 다행히도 바이트 타입 배열을 선언하고 read() 메소드에 넣어서 입력받는 방법도 있다.
import java.io.IOException;
import java.io.InputStream;
public class Input_Test {
public static void main(String[] args) throws IOException {
InputStream inputstream = System.in;
byte[] a = new byte[10];
inputstream.read(a);
for(byte val : a){
System.out.println(val);
}
}
}
이렇게 byte 배열을 선언한 뒤 read() 메소드에 배열을 넣으면 입력하면서 바이트 값으로 a 배열에 저장된다.
애초에 byte[] 배열 말고는 다른 타입(int, char 등등..)은 read 메소드에 넣을 수 없다. 당연히 바이트 단위로 읽어 들이니깐.
물론 출력해도 해당 문자가 아닌 운영체제의 인코딩 방식의 10진수 값이 나온다.
물론 문자로 치환하고 싶으면 char 으로 캐스팅을 해주어야 한다.. (너무 불편하게도..)
그리고 가장 큰 문제점이 있다.
바로 한글을 제대로 인식하지 못한다.
아스키코드 확장판을 보면 총 255개의 문자가 있는데 보면 알겠지만 한글이 없다. ( 1 Byte 의 범위는 -128~127 이며 마지막 1bit 가 남아있는 것을 활용하여 확장한 것이 아래 128~255 범위의 문자들이다. )
이러한 결과로 한글을 입력하게 되면 아무리 캐스팅을 해주어도 엉뚱한 문자가 나온다.
한 번 아래 사진을 보자.
(File Encoding : UTF-8)
(File Encoding : MS949)
( 일단 필자가 UTF-8 형식으로 설정되어있으므로 UTF-8 기준으로 설명하겠다.)
'가' 라는 문자를 입력하자 e 랑 비슷하지만 다른 ê, 10진수로는 234 값이 저장되어있는 것을 볼 수 있다.
필자의 경우 앞서 말했듯이 이클립스 file 인코딩을 UTF-8 인코딩을 쓰고 있는데 UTF-8 의 경우 한글은 3Byte 를 사용한다.
만약 EUC-KR 인코딩을 쓰면 2Byte 를 쓸 것이다.
아래가 UTF-8 인코딩 테이블이다.
보면 3Byte 로 구성되어있고 '가' 라는 문자는 각 1Byte 씩 234, 176, 128 의 구성으로 합쳐져 '가'라고 표현된다는 것을 볼 수 있다.
즉 InputStream.read() 는 1 Byte 밖에 못 읽기 때문에 가장 앞의 1 Byte인 234 까지만 읽고 나머지는 바이트스트림에 남아있게 되는 것이다.
(옆에 있는 UNICODE 가 유니코드에서 코드 포인트(code point)라고 하는 값이다. 즉, '가'라는 문자가 유니코드에서는 16진수로 AC00 이라는 의미다.)
그리고 자바는 UTF-16 을 쓴다고 했다.
234 를 16진수로 변환하면 EA 이고 자바의 인코딩 방식에 따라 0xc3 0xaa 로 변환되어 이에 대응되는 유니코드 테이블의 문자는 다음과 같다.
(자바 내부 메모리상으로는 유니코드 인코딩 규칙에 의해 2byte 길이의 이진법으로 인코딩되므로 11000011 10101010 이 저장된다.)
그래서 출력하려고 하면 11000011 110101010 에 대응되는, 즉 0xc3 0xaa 에 대응되는 문자인 ê 라는 문자가 출력되는 것이다.
정리하자면
1. UTF-8 로 입력을 받는다
2. read() 메소드는 1 byte 만 읽기 때문에 나머지 byte 는 스트림에 잔존하게 된다.
3. 읽어들인 byte 값은 메모리에 UTF-16 에 대응되는 문자의 인코딩방식으로 2진수 값이 저장한다.
4. 출력시 메모리에 저장되어있던 2진수에 대응되는 문자가 UTF-8 로 변환되어 출력된다.
[MS949 - window 계열의 처리과정을 보고 싶다면 아래 더 보기를 누르면 된다.]
대부분의 윈도우 사용자는 MS949 가 디폴트로 설정되어있다. 그래서 아까 사진에서 봤듯이 같은 '가' 를 입력하더라도 '째'가 출력되어 나온다.
그럼 과정을 한 번 보자.
일단 MS949 에서 '가' 라는 문자는 다음과 같다.
즉 16진수로 0xB0A1 이다.
B0A1 은 십진수로 176 161 이고 아까 언급했듯이 read 메소드는 1byte 만 읽기 때문에, 즉 B0 만 읽기 때문에 176 만 남게된다.
자바에서는 UTF-16 으로 변환되어 저장된다고 했으니 유니코드의 코드 포인트에서 B0 에 대응되는 값은 무엇일까?
U+00B0 의 값에 대응하여 인코딩 되는 값은 0xc2 0xb0, 즉 11000010 10110000 가 메모리에 저장되는 것이다.
그리고 출력한다고 하면 11000010 10110000 에 대응되는, 즉 0xc2 0xb0 에 대응하는 값을 MS949 코드표에서 0xc2b0 를 찾아보면 된다.
MS949 코드 테이블을 보면 다음과 같다.
0xC2B0 는 '째'라는 문자다.
아까 출력에서 '째'라는 문자가 나온 것과 일치한다.
- Scanner(System.in) 그리고 InputStreamReader(System.in)
먼저 Scanner 클래스에 대해 뜯어보고자 한다.
( 설명하기 전에 Scanner 의 모든 메소드를 다 뜯어볼 수 없다. 워낙 여기 저기서 메소드를 호출하고 정규식 검사하는 등 모든 변수, 메소드를 설명하기에는 주제를 많이 벗어난다. 우리가 볼 것은 어느 "경로를 통해 입력을 받게 되는지" 의 기본 골자를 보는 것이 목표다. )
여기까지 설명했으면 다음은 이해할 수 있을 것이다.
"아.. Scanner(System.in) 은 입력 바이트 스트림인 InputStream 을 통해 표준 입력받으려고 하는 것이구나!"
우리가 평소에 쓰는 Scanner 는 다음과 같이 풀어쓸 수 있다.
그러면 Scanner() 에 왜 InputStream 이 들어가는 걸까??
이는 Scanner 클래스를 자세히 보면 알 수 있다.
아래 사진을 보자.
보다시피 Scanner 클래스의 파일에는 Scanner 라는 생성자가 오버로딩(Overloading) 되어있는 걸 볼 수 있다.
우리가 흔하게 쓰는 Scanner(System.in) 은 System.in 이 InputStream 타입이고 System.in 하나만 넣었으니 어디로 가는지 볼 수 있겠다.
바로 public Scanner(InputStream source) 로 보내지는 것이다.
즉 아래 코드가 우리가 흔히 쓰는 Scanner(System.in) 의 경로이다.
public Scanner(InputStream source) {
this(new InputStreamReader(source), WHITESPACE_PATTERN);
}
근데, 사진을 보면 여러 Scanner() 생성자들이 다 어디로 가는지 보이는가?
바로 사진에서 가장 상위에 있는 Scanner 생성자.
즉, private Scanner(Readable source, Pattern pattern) 으로 넘겨진다.
어?? 근데 new InputStreamReader(source) 를 보내는데??
여기서 바로! InputStreamReader 가 나오는 거다.
InputStreamReader 라니.. InputStream 이랑 뭐가 다른 거지?
다시 한번 복기해보자. InputStream 의 가장 큰 특징 두 가지가 있었다.
- 입력받은 데이터는 int 형으로 저장되는데 이는 10진수의 UTF-16 값으로 저장된다.
- 1 byte 만 읽는다.
InputStream 은 우리가 InputStream.read() 를 통해 입력을 받으려고 해도 1Byte 만 인식하니 한글은 입력해봤자 읽지도 못하고 엉뚱한 문자만 나온다. 이를 해결하기 위해 우리가 필요한 건 '문자를 온전하게 읽어들이기' 이다. 그리고 이를 위해 확장시킨 것이 InputStreamReader다.
자바 API 에서 보면 아래와 같이 설명하고 있다.
즉, InputStream 의 바이트 단위로 읽어 들이는 형식을 문자단위(character)로 데이터로 변환시키는 중개자 역할을 한다고 보면 좋다.
( 바이트 데이터를 문자 단위로 변환하는 것은 Charset 이라는 클래스에서 담당한다. 이 클래스 덕분에 UTF-8 에서 한글이 3Byte 여도 2Byte 로 변환되어 자바 메모리상에서 char 타입에 한글이 대입될 수 있는 것이다.)
잘 생각해보자. 우리가 Scanner 로 입력받을 때 한글을 String이나 char 에 저장하고 출력할 때 한글이 깨지던가? 아니다.
이는 Scanner 의 생성자 사진에서 보듯이 InputStream 으로 키보드 입력을 받아들인다 하더라도 중개자 역할을 하는 InputStreamReader 가 온전한 문자형태로 변환 및 처리해주기 때문이다.
그리고 이러한 InputStreamReader 을 문자스트림이라고 한다.
다시 한번 강조하지만 자바는 내부적으로 UTF-16 를 쓴다. 즉 여러분이 EUC-KR을 쓰던, UTF-8 을 쓰던 charset 을 통해 UTF-16 으로 변환되어 메모리에 올라가는 것이다.
또한 UTF-8 인코딩은 유니코드 한 문자를 나타내기 위해 1바이트에서 4바이트까지를 사용한다. 그래서 한글의 경우 3Byte 를 쓰게 되어 있는데 자바에서 char = '가'; 이렇게 해도 문제가 발생하지 않는 이유가 UTF-16 는 기본적으로 16bit, 즉 2Byte 로 내부적으로 변환되어 올라가기 때문이다.
(물론 UTF-16 에서도 32bit 까지 쓰는 경우가 있지만.. 이는 설명이 너무 복잡해지니 pass.. 궁금하면 surrogate pair 을 검색해보시면 된다. )
그럼 한 번 정리해보자.
InputStreamReader 는 InputStream 가 문자를 그대로 읽지 못하는 경우가 발생하기 때문에 좀 더 효율적인 입력을 위해 중개자 역할인 InputStreamReader 를 쓴다고 했다.
즉 아래와 같이 쓸 수 있다는 것이다.
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
public class Input_Test {
public static void main(String[] args) throws IOException {
InputStream inputstream = System.in;
InputStreamReader sr = new InputStreamReader(inputstream);
}
}
더 간단하게 요약하면 아래와 같이도 쓸 수 있다.
InputStreamReader sr = new InputStreamReader(System.in);
그럼 한 번 테스트를 해보자.
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
public class Input_Test {
public static void main(String[] args) throws IOException {
InputStream inputstream = System.in;
InputStreamReader sr = new InputStreamReader(inputstream);
int c = sr.read();
System.out.println((char)c);
System.out.println(c);
}
}
위 코드에 한글 문자를 입력하면 이상한 값이 안 나오고 정확하게 잘 뜬다.
44032 는 위에서 언급했듯이 자바 내부적으로 처리된 UTF-16 의 16진수 값 AC00 이다.
InputStreamReader 자체가 character(문자) 데이터로 변환하면서 메모리에 UTF-16 포맷으로 올라가기 때문에 위와 같은 값이 나오는 것이다.
물론 문자가 아닌 문자열을 받고 싶다면 char 배열로 아래와 같이 받을 수도 있다.
정리해보자
바이트스트림인 InputStream 을 통해 입력을 받으면 문자스트림인 InputStreamReader 을 통해 바이트 단위 데이터를 문자(character) 데이터로 처리할 수 있게 만들어준다는 것이다. 그리고 앞에서 보았듯이 Scanner 를 생성할 때 문자스트림으로 변환시켜 읽는다는 것을 알 수 있다.
즉, InputStreamReader 의 가장 큰 특징은 다음과 같다.
- 바이트 단위 데이터를 문자(character) 단위 데이터로 처리할 수 있도록 변환해준다.
- char 배열로 데이터를 받을 수 있다.
그럼 InputStreamReader 을 이해했으니 다시 Scanner 로 돌아가 보자.
그렇다면 실질적으로 어떻게 입력을 받을까?
그리고 흔히 우리가 next(), nextInt(), nextDouble(), nextFloat() 등 입력을 통한 메소드는 다음과 같이 구성되어 있는데, 대표적으로 nextInt() 메소드를 뜯어보자.
일단 Scanner.nextInt() 를 쓰면 nextInt() 메소드에서 오버로딩된 아래의 nextInt(int radix) 메소드로 보내진다.
다른 변수들을 모두 설명하기에는 주제 범위를 벗어나니...
일단 우리가 중요하게 봐야 할 것은 try - catch 문의 String s = next(integerPattern()); 이다.
이 메소드로 가면 다음과 같이 구성되어 있다.
그러면 또 다른 메소드로 우리들을 안내하는데... integerPattern() 메소드로 가면 기본적으로는 입력받기 직전에는 초기화된 값이 null 값이기 때문에 if 문이 true 가 되어 해당 조건문을 실행시킨다.
그리고 여기서 중요한 것이 바로 buildIntegerPatternString() 이다.
여기서 우리는 입력받은 문자를 해당 메소드로 보내서 다음의 정규식들을 검사하고 검사 된 문자열을 반환한다.
필자가 포스팅하면서 Scanner 는 속도가 느리다. 성능이 좋지 않다고 한 이유가 바로 다음과 같은 정규식을 불필요할 정도(?)로 많이 검사하기 때문이다. (물론 장점도 있다. 강력한 정규식 검사로 인해 여러 예외적인 입력 값에 대해서도 입력받은 값이 특정 타입으로 변환 할 수 있는지를 정확하게 파악할 수 있다. 즉, 타입 변환의 안정성이 매우 뛰어나다는 점이다.)
정규식을 적당히 풀어쓴 것을 보고 싶다면 아래 더보기를 클릭하여 보면 된다.
"("+ "([-+]?(" + "(("((?i)
["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"++)|"+
"("+"[\\p{javaDigit}&&[^0]]"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+"?"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+"?("+ "\\,"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+")+)"+")" + "))" + ")|(" +
"" + "(("((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"++)|"+
"("+"[\\p{javaDigit}&&[^0]]"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+"?"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|\\p{javaDigit})"+
"?("+"\\,"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+")+)"+")" + "" + ")|(" +
"\\-" + "(("((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"++)|"+
"("+"[\\p{javaDigit}&&[^0]]"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+"?"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+"?("+"\\,"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+
"((?i)["+"0123456789abcdefghijklmnopqrstuvwxyz".substring(0,radix)+"]|
\\p{javaDigit})"+")+)"+")" + "" + ")"
이렇게 많은 정규식을 통과하여 return 된 정규식의 문자열을 patternCache.forName()으로 보낸다.
이 메소드를 통해 Pattern 이라는 타입으로 compile 을 호출하여 String 정규식 문자열을 Pattern 이라는 객체로 반환시켜준다.
참고로 Pattern 은 java.util.regex 패키지에 있는 클래스이며 정규식의 컴파일된 표현이다.
그리고 Pattern 이라는 객체가 반환되면 Pattern integerPattern 에 저장되고 이를 반환시킨다.
우리가 처음 봤던 nextInt() 로 다시 돌아가게 된다.
보면 결국에 Pattern 객체를 String 타입으로 변환시키고 최종적으로 return 되는 것은 Integer.parseInt(s, radix) 로 인해 int 형으로 리턴된다.
이쯤 되면 약간 어리둥절할 수도 있다.
한 번 정리해보자.
그동안 Scanner의 생성자와 메소드 nextInt() 의 과정을 보면 아래와 같이 해석할 수 있다.
- InputStream (바이트스트림) 을 통해 입력 받음
- 문자로 온전하게 받기 위해 중개자 역할을 하는 InputStreamReader(문자스트림) 을 통해 char 타입으로 데이터를 처리함
- 입력받은 문자는 입력 메소드( next(), nextInt() 등등.. ) 의 타입에 맞게 정규식을 검사함
- 정규식 문자열을 Pattern.compile() 이라는 메소드를 통해 Pattern 타입으로 변환함
- 반환된 Pattern 타입을 String으로 변환함
- String 은 입력 메소드의 타입에 맞게 반환함 ( nextInt() - Integer.parseInt() / nextDouble() - Double.parseDouble() 등등.. )
- BufferedReader 그리고 InputStreamReader(System.in)
여기까지 과정을 이해했다면 아마 BufferedReader 에 대한 이야기 또한 금방 이해될 것이다.
System.in 은 바이트스트림인 InputStream 타입이고 이 입력방법만으로는 문자를 온전하게 받기 힘드니 InputStreamReader 로 감싸주면서 바이트 단위 데이터를 문자 단위로 처리할 수 있도록 한다고 했다.
BufferedReader 도 비슷한 원리다.
BufferedReader 객체를 생성, 선언을 할 때 보통 우리는 다음과 같이 쓴다.
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
위 코드를 풀어쓰면 다음과 같을 것이다.
InputStream inputstream = System.in;
InputStreamReader sr = new InputStreamReader(inputstream);
BufferedReader br = new BufferedReader(sr);
그러면 우리는 두 가지 사실을 바로 알 수 있다.
- 기본적으로 바이트 스트림인 InputStream 을 통해 바이트 단위로 데이터를 입력을 받는구나!
- 입력 데이터를 char 형태로 처리하기 위해 중개자 역할인 문자스트림 InputStreamReader 로 감싸주는구나!
근데 BufferedReader 는 왜 필요해??
이 질문에 대한 답이 필요하다.
앞서 Scanner 에서 InputStreamReader 을 설명할 때 '문자'를 처리한다고 했다. '문자열'이 아니다.
(그래서 Scanner 에서도 내부에서 임시 배열을 두어 문자열처럼 사용하고 있다.)
InputStreamReader 로 char type 으로 처리할 수 있는 장점은 개선되었는데...
우리가 만약 문자열을 입력하고 싶다면 매번 배열을 선언해야 한다는 단점은 그대로 남아있다. 심지어 입력받을 문자열의 길이가 가변적이라면 더욱 불편하다.
그래서 쓰는 것이 Buffer(버퍼)를 통해 입력받은 문자를 쌓아둔 뒤 한 번에 문자열처럼 보내버리는 것이다.
BufferedReader 를 쓸 때 우리는 입력 메소드로 readLine() 을 많이 쓴다. 이 메소드는 한 줄 전체를(공백 포함) 읽기 때문에 char 배열을 하나하나 생성할 필요 없이 String 으로 리턴하여 바로 받을 수 있다는 장점이 있다.
Byte Type = InputStream
Char Type = InputStreamReader
Char Type 의 직렬화 = BufferedReader
(String 도 따지고 보면 char의 배열 형태이니.. 직렬화라는 말이 어렵다면 String이라고 봐도 큰 문제는 없을 것 같다.)
이런 식으로 업그레이드가 되는 느낌이 안 드는가?
다시 한번 돌아가서 보자.
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
System.in = InputStream -> InputStreamReader -> BufferedReader
byte 타입으로 읽어들이는 in을 char 타입으로 처리한 뒤 String, 즉 문자열로 저장할 수 있게 한다는 의미로 해석할 수 있다.
그렇기 때문에 BufferedReader 을 쓸 때 위와 같이 코드가 길어지게 되는 것이다.
BufferedReader 의 특징은 크게 두 가지가 있다.
- 버퍼가 있는 스트림이다.
- 별다른 정규식을 검사하지 않는다.
위와 같이 두 개의 특징 덕분에 입력 과정에서 Scanner에 비해 성능이 우수할 수밖에 없다.
그리고 BufferedReader 의 경우 Scanner 와 다르게 문자열 그대로 읽어 들이기 때문에 별다른 정규식을 검사하지 않는다.
그렇기 때문에 Scanner 에 비해 성능이 좋을 수밖에 없다는 것.
즉 스트림 이러한 구성이라고 보면 된다.
물론 버퍼가 저렇게 작진 않고 따로 설정하지 않으면 디폴트로 8192개의 문자를 저장할 수 있다.
기본적으로 개행이 입력되거나 버퍼가 꽉 차게 되면 버퍼를 비우면서 프로그램으로 데이터를 보낸다.
즉 하나하나 문자를 보내는 것이 아닌 한 번에 모아둔 다음 보내니 훨씬 속도가 빠르고 별다른 정규식을 검사하지 않으니 더더욱 속도는 빠를 수밖에 없다.
즉, 정리하자면 바이트 단위 [InputStream]로 문자를 입력받아 문자(character) [InputStreamReader]로 처리한 뒤 버퍼(buffer) [BufferedReader]에 담아두었다가 일정 조건이 되면 버퍼를 비우면서 데이터를 보내는 것이다.
- 마무리 정리
길고 긴 포스팅이 드디어 마무리되었다.
글로 설명하자니 어려운 부분도 있을 것 같다.. 한눈에 보기 쉽게 이해할 수 있도록 이미지를 첨부하면서 정리하려 한다.
- InputStream 은 바이트 단위로 데이터를 처리한다. 또한 System.in 의 타입도 InputStream 이다.
- InputStreamReader 은 문자(character) 단위로 데이터를 처리할 수 있도록 돕는다. InputStream 의 데이터를 문자로 변환하는 중개 역할을 한다.
- BufferedReader 은 스트림에 버퍼를 두어 문자를 버퍼에 일정 정도 저장해둔 뒤 한 번에 보낸다.
이를 마지막으로 드디어 포스팅이 끝났다.
여담으로 대개 자바로 알고리즘을 푸는 경우에는 Scanner를 굳이 써야 할 의미는 없다. 일단 문제에서 주어지는 input은 대부분 예외적인 입력이 아닌 정확이 주어지는 조건에 맞는 입력만 주어지기 때문이다.
그래서 굳이 저 수많은 정규식 과정을 거칠 필요 없이 BufferedReader 을 통해 문자열을 받아온 뒤, Integer.parseInt() 같은 파싱 함수들을 통해 타입 변환해주는 것이 특히나 성능(시간) 경쟁인 알고리즘에서는 훨씬 선호 될 수밖에 없다.
(물론 알고리즘 뿐만 아니라, nio 에서 File 클래스 중 readAllLines() 메소드 같은 다른 입출력 클래스에서도 빠른 입력을 위해 BufferedReader가 사용되곤 한다.)
물론 자바를 잠깐 스쳐 지나가는 경우 그렇게 중요한 내용이 아닐 수도 있겠지만, 적어도 우리가 어떻게 콘솔 입력을 받는지, 왜 이렇게 구성이 되어있는지, 혹은 왜 알고리즘 문제에서 두 클래스가 많이 쓰이는지 정도는 알고 갔으면 좋겠다는 마음에서 시작했다.
물론 필자가 명필가가 아니기 때문에 이해가 잘 안 될 수도 있다. 그런 건 댓글 남겨주신다면 최대한 빠르게 답변드리도록 약속하겠다.
스트림부터 해서 Scanner 와 BufferedReader 까지, 긴 포스팅을 읽느라 고생했을 분들,
이상 허접한 설명을 읽어주어서 감사하다.
'Java' 카테고리의 다른 글
자바 [JAVA] - Comparable 과 Comparator의 이해 (261) | 2021.04.29 |
---|---|
자바 [JAVA] - 제네릭(Generic)의 이해 (112) | 2020.10.19 |
패스워드의 암호화와 저장 - Hash(해시)와 Salt(솔트) (69) | 2020.05.23 |
자바 [JAVA] - 스캐너(Scanner) 클래스와 입력 (28) | 2020.05.07 |