자바의 입출력은 코딩테스트를 준비하는데 있어 자주 사용 되지만 공부할 필요성을 그만큼 자주 느끼는 파트이기도 하다. 매번 입출력을 위해 많은 객체를 생성하지만 이유도 모르고 선언하는 할 때마다 답답함을 느껴 본 글을 쓰게 되었다.
오늘 포스팅에서는 자바의 입력, 그 중에서도 [Scanner, InputStream, BufferedReader]에 대해 확실히 정리하고자 한다.
본 글은 아래의 글을 인용하여 작성하였습니다.
출처: stranger's LAB, 자바- 입력 뜯어보기
먼저 알아볼 것은 자바(Java)의 인코딩 방식이다.
Java는 String을 처리할 때 내부(메모리 상)에서는 UTF-16 BE인코딩으로 문자열을 저장하고, 송수슨에서는 직렬화가 필요한 경우 변형된 UTF-8을 사용하여 문자열을 입/출력할 때에만 사용자가 지정한 인코딩 값 또는 운영체제의 기본 인코딩 값으로 문자열을 인코딩한다.
즉,
- 내부적으로 (메모리 상에서) 문자열이 UTF-16으로 인코딩되어 처리.
- 문자열 송/수신을 위해 직렬화가 필요로 할 때에는 변형된 UTF-8을 사용.
- [문자열 입출력시] 운영체제 기본 인코딩 값, 또는 사용자가 지정한 인코딩 값으로 문자열을 인코딩.
필자가 사용하고 있는 인텔리제이의 경우 다음과 같은 인코딩 방식이 확인되었다. [Setting > Editor > File Editor]
기본적으로 대부분의 인코딩 형식들은 해당 값과 아스키 코드 값이 10진수로 1~127번까지는 대응되는 문자가 같다.
Scanner scan = new Scanner(System.in);
BufferedReader br = BufferedReader(new InputStreamReader(System.in));
본 글은 자바로 코딩테스트를 준비하면서 반드시 알아야 하는 이 두가지 의문을 해소하고자 한다.
계속 미뤄오다가 이제야 정리하게되네.. (귀찮..)
최근들어 chatgpt의 신뢰성 문제가 대두되고 있지만 정답이 있는 문제의 경우 정확도는 상당하기에 chatgpt를 인용해 본다.
What is Stream in java?
In Java, a stream is a sequence of data elements that can be processed in a pipeline of operations.
It's the part of the Java Collections Framework and provide a powerful and concise way to manipulate collection of data
직역하자면, 계산 파이프라인에 있어서 진행되는 데이터 요소들의 연속이라고 할 수 있다.
예시를 들자면, 여러가지 수도관으로 연결되어 있는 수도꼭지 중에서도, 스트림은 물의 흐름 이라고 할수 있다.
[출처: Stranger's LAB의 자바 입출력 뜯어보기]
의 그림에서 보듯이 한 곳에서 다른 곳으로의 데이터 흐름을 스트림이라고 한다. 스트림은 다음과 같은 특징을 가진다.
System.in 과 InputStream의 차이(reference to chatgpt..)
코드예시까지 알려주고.. 세상 마이 좋아졌다...
그렇다면 위의 System.in과 InputStream은 어떤 관계일까?
System.in은 InputStream클래스의 객체이며, 이것은 "System.in" 을 다른 어떤 "InputStream"의 객체 로 사용할 수 있다는 것을 의미한다.
위의 코드예시를 살펴본다. 상황은 다음과 같다.
정리하자면 in이라는 변수는 InputStream의 변수로 결국 InputStream타입의 새 변수, 즉, System.in을 선언 할 수 있다.
import java.io.IOException;
import java.io.InputStream;
public class Input{
public void solve() thorws IOException{
InputStream inputstream = System.in;
int a = inputstream.read();
System.out.println(a);
}
public static void main(String[]args) throws IOException{
new Input().solve();
}
}
예시는 다음과 같다.
주의할 점
바이트 스트림 활용 예시 1
입력: 100000001 00001111값의 2바이트 문자
1바이트로 각각 나뉘어 스트림을 통해 10000001 과 00001111의 데이터가 흐르게 된다. 즉, 스트림에서는 1바이트의 데이터가 2개가 있다.
하지만 read()를 한 번만 쓰면 먼저 입력된 10000001을 읽지만, 00001111은 스트림에 계속 남아있게 된다.
따라서 나머지 바이트도 읽고 싶다면 다음과 같이 코드를 짤 수 있겠다.
try (FileInputStream input = new FileInputStream("file.bin")) {
int data;
while ((data = input.read()) != -1) {
// process the byte data here
}
} catch (IOException ex) {
ex.printStackTrace();
}
위는 FileInputStream 객체를 "file.bin"이라는 바이너리 파일을 읽기 위해 생성한 예시이다.
앞서 말한 것처럼 바이트 스트림으로부터 데이터를 읽을 때와 쓸 때는 byte-by-byte, 즉, 1바이트 단위로 읽고 쓴다.
따라서 루프문을 활용하여 한번에 하나의 바이트를 계속 읽는다.
그리고 read()메소드는 읽을 바이트가 소진되면,(정확히는 스트림의 끝에 도달하면) -1을 리턴한다.
바이트 스트림 활용 예시 2
이제는 10개의 문자를 입력받고자 한다.
10개의 변수를 선언할 수도 있겠지만 비효율적이다.
따라서 바이트 타입 배열을 선언하고 read()메소드에 넣어서 입력한다.
import java.io.IOException;
import java.io.InputStream;
public class Main{
public static void main(String[]args) throws IOException{
InputStream inputstream = System.in; // 객체생성 후 데이터를 System.in 스트림으로부터 데이터를 읽는다.
byte[] a = new byte[10]; // 10 바이트 길이의 byte 배열 "a"를 생성한다.
inputstream.read(a); // InputSteram객체의 read()메소드가 콘솔 인풋으로부터 10바이트 길이의 데이터를 입력받기 위해 호출된다.
// 그리고 바이트 배열 a에 저장된다.
for(byte val : a){ // a for-each loop is used to iterate over the byte array "a"
// and print out each value using System.out.println() method.
System.out.println(val);
}
}
}
위 코드를 요약하자면 다음과 같다.
10 바이트 길이의 byte 배열 "a"를 생성한다.
InputStream 객체의 read()메소드가 콘솔 인풋으로부터 10바이트 길이의 데이터를 입력받기 위해 호출된다.
그리고 바이트 배열 a에 저장된다.
애초에 byte[] 배열 말고는 다른 타입(int, char)은 read 메소드에 넣을 수 없다. => 바이트 단위로 읽어들이므로...
chatgpt가 코드분석까지 해주네.... 캬...
문제점
한글로 콘솔을 입력받을 때
먼저 Scanner 클래스에 대해 전격 해부해보자.
정확히는 "어떤 경로를 통해 입력을 받게 되는지"의 기본 골자에 대해 알아본다.
Scanner(System.in)은 입력 바이트 스트림인 InputStream을 통해 표준 입력을 받으려고 하는 구나
평소에 사용하는 Scanner는 이렇게 풀어쓸 수 있다.
InputStream inputstream = System.in;
Scanner scan = new Scanner(inputstream);
int a = scan.nextInt();
System.out.println(a);
그러면 Scanner()에 InputStream이 들어가는 이유는 무엇일까?
Scanner.class 파일을 보면, Scanner라는 생성자(constructor)가 오버로딩(Overloading) 되어 있는 것을 볼 수 있다.
즉, 아래의 코드를 통해 Scanner(System.in)의 경로를 확인할 수 있다.
public Scanner(InputStream source){
this(new InputStreamReader(source), WHITESPACE_PATTERN);
}
자세히보면, Scanner()생성자들은 결국, private Scanner(Readable source, Pattern pattern) 으로 넘겨진다.
여기서 바로! InputStreamReader가 등장한다.
여기서 InputStream의 특징을 정리해본다.
1. 입력받은 데이터는 int형으로 저장되고, 이는 10진수의 UTF-16값으로 지정된다.
2. 1byte만 읽는다.
이에 반해, InputStreamReader는 다음과 같은 특징을 지닌다.
참고사항
자바는 내부적으로 UTF-16을 사용하므로 입력자가 EUC-KR 혹은 UTF-8을 사용하더라도 charset을 통해 TUTF-16으로 변환되어 메모리에 올라간다.
아래의 코드는 InputStream이 문자를 그대로 읽지 못하는 이슈로 인해 InputStreamReader를 사용하는 예시이다.
import java.io.IOException
import java.io.InputStream;
import java.io.InputStreamReader;
public class Input_test{
public static void main(String[]args){
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){
INputStreamReader sr = new InputStreamReader(System.in);
int c = sr.read();
System.out.println((char) c); // '가'출력
Systemout.prinln(c); // '44032'출력
}
}
문자가 아닌 문자열로 받고 싶으면 다음과 같이 코드를 작성한다.
즉, InputStreamReader의 특징을 다음과 같이 정리할 수 있다.
1. 바이트 단위 데이터를 문자(character)단위 데이터로 처리할 수 있도록 변환
2. char 배열로 데이터를 받는다.
그렇다면, 실질적으로 우리가 입력받는 방식은?
흔히 우리가 next(), nextInt(), nextDouble(),nextFloat()등 입력을 통한 메소드는 .class()파일을 보면 알 수 있다.
일단 Scanner.nextInt()를 쓰면 nextInt()메소드에서 오버로딩된 아래의 nextint(int radix)메소드로 보내진다.
중요하게 봐야 될 것은 try-catch문의 Stirng s = next(integerPattern()); 이다.
해당 메소드로 가면 다음과 같이 구성이 되어있다.
private Pattern intergerPattern(){
if(integerPattern == null){
integerPattern = patternCache.forName(buildIntegerPatternString());
}
return integerPattern;
}
또 다른 메소드, 즉, integerPattern이 호출된다. 기본적으로 입력받기 직전에는 초기화된 값이 null이기 때문에 if문이 true가 되어 해당 조건문을 실행시킨다.
중요한 것은 buildIntegerPatternString() 이다.
// Scanner.class
...
private String buildIntegerPatternString(){
String radixDigits = digits.substring(0,radix);
String digit = "...."
}
이곳에서 입력받은 문자를 해당 메소드로 보내서 정규식들을 검사하고 검사 된 문자열을 변환한다.
Scanner는 속도가 느리다. 성능이 좋지 않은 이유는 바로 다음과 같은 정규식을 계속 검사하는 메소드를 호출하기 때문이다.
물론, 강력한 정규식 검사로 인해 여러 예외적인 입력 값에 대해서도 입력받은 값이 특정 타입으로 변환 할 수 있는지를 정확하게 파악할 수 있다. 즉, 타입 변환의 안전성이 뛰어나다.
이렇게 많은 정규식을 통과하여 return된 정규식의 문자열을 patternCache.forName() 으로 보낸다.
해당 메소드를 통해 Pattern이라는 타입으로 compile을 호출하여 String 정규식 문자열을 Pattern 이라는 객체로 반환시켜준다.
참고로 Pattern은 java.util.regex패키지에 있는 클래스이며 정규식의 컴파일된 표현이기도 하다.
Pattern forName(String name){
if (oa==null){
Pattern[] temp = new Pattern[size];
oa = temp;
} else {
for(int i = 0; i< oa.length; i++){
Pattern ob =oa[i];
if(ob == null)
continue;
if (hasName(ob, name)){
if(i>0)
moveToFront(oa, i);
return ob;
}
}
}
//Create a new object
Pattern ob = Pattern.compile(name);
oa[oa.length - 1] = ob;
moveToFront(oa, oa.length -1);
return ob;
}
...
그리고 Pattern 이라는 객체가 반환되면 Pattern integerPattern에 저장되고 이를 반환시킨다.
//Scanner class
private Pattern integerPattern(){
if(integerPattern == null){
integerPattern = pattern
}
}
그리고 다시 nextInt()로 돌아간다.
결국 Patttern 객체를 String 타입으로 변환시키고 최종적으로 return되는 것은 Integer.parseInt(s, radix)로 인해 int형으로 리턴된다.
정리하자면,
1. InputStream을 통해 입력 받음
2. 문자로 온전하게 받기 위해 중개자 역할을 하는 InputStreamReader(문자스트림) 을 통해 char타입 으로 데이터를 처리함
3. 입력받은 문자는 입역 메소드( next(),nextInt()등등..) 의 타입에 맞게 정규식을 검사
4. 정규식 문자열을 Pattern.compile()이라는 메소드를 통해 Pattern 타입으로 변환
5. String은 입력메소드의 타입에 맞게 반환
느낀점 및 다짐
처음 포스팅을 작성할 때는 이렇게 길게 작성하게 될줄 몰랐다.
몇가지 느낀점은,
1. "긴글을 가독성 있게 읽히려고 노력하는 것이 중요하다." 이다.
Scanner로 입력을 받는 것과 InputStream(바이트 스트림)을 통해 입력 받는 것의 컴파일 속도 차이가 이렇게해서 나는구나라는 걸 직접 코딩도 해보고 문제도 풀면서 느낄수 있었다.
또한 컴파일 속도는 느리지만 정규식을 검사하는 측면에서 Scanner가 기본적이면서도 안전한 입출력 방식이라는 것을 깨달았다.
앞으로도 애매하게 모르는 부분을 넘어가지 말고 직접 포스팅 해보면서 나의 것으로 만들자!