[자바] 입출력 완전 정리

몰입을 즐기는 개발자·2023년 5월 10일
1

코딩테스트

목록 보기
2/6
post-thumbnail

자바의 입출력은 코딩테스트를 준비하는데 있어 자주 사용 되지만 공부할 필요성을 그만큼 자주 느끼는 파트이기도 하다. 매번 입출력을 위해 많은 객체를 생성하지만 이유도 모르고 선언하는 할 때마다 답답함을 느껴 본 글을 쓰게 되었다.
오늘 포스팅에서는 자바의 입력, 그 중에서도 [Scanner, InputStream, BufferedReader]에 대해 확실히 정리하고자 한다.

본 글은 아래의 글을 인용하여 작성하였습니다.
출처: stranger's LAB, 자바- 입력 뜯어보기

먼저 알아볼 것은 자바(Java)의 인코딩 방식이다.
Java는 String을 처리할 때 내부(메모리 상)에서는 UTF-16 BE인코딩으로 문자열을 저장하고, 송수슨에서는 직렬화가 필요한 경우 변형된 UTF-8을 사용하여 문자열을 입/출력할 때에만 사용자가 지정한 인코딩 값 또는 운영체제의 기본 인코딩 값으로 문자열을 인코딩한다.

즉,

  1. 내부적으로 (메모리 상에서) 문자열이 UTF-16으로 인코딩되어 처리.
  2. 문자열 송/수신을 위해 직렬화가 필요로 할 때에는 변형된 UTF-8을 사용.
  3. [문자열 입출력시] 운영체제 기본 인코딩 값, 또는 사용자가 지정한 인코딩 값으로 문자열을 인코딩.

필자가 사용하고 있는 인텔리제이의 경우 다음과 같은 인코딩 방식이 확인되었다. [Setting > Editor > File Editor]

기본적으로 대부분의 인코딩 형식들은 해당 값과 아스키 코드 값이 10진수로 1~127번까지는 대응되는 문자가 같다.

Scanner scan = new Scanner(System.in);
  • 여기서 System.in은 무엇일까?
BufferedReader br = BufferedReader(new InputStreamReader(System.in));
  • [BufferedReader로 문자열을 입력받을 시] 또 한번 System.in이 등장한다.
  • InputStreamReader는 또 뭘까..?

본 글은 자바로 코딩테스트를 준비하면서 반드시 알아야 하는 이 두가지 의문을 해소하고자 한다.

계속 미뤄오다가 이제야 정리하게되네.. (귀찮..)

목차

  1. InputStream과 System.in
  2. Scanner(System.in)과 InputStreamReader()
  3. BufferedReader()

1. InputStream과 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의 자바 입출력 뜯어보기]

의 그림에서 보듯이 한 곳에서 다른 곳으로의 데이터 흐름을 스트림이라고 한다. 스트림은 다음과 같은 특징을 가진다.

  • 단방향이기 때문에 입출력이 동시에 발생할 수 없다.
  • 입력/출력 스트림 2개가 존재한다.
  • 자바에서 가장 기본적인 입력스트림은 InputStream이다.

System.in 과 InputStream의 차이(reference to chatgpt..)


코드예시까지 알려주고.. 세상 마이 좋아졌다...

그렇다면 위의 System.in과 InputStream은 어떤 관계일까?
System.in은 InputStream클래스의 객체이며, 이것은 "System.in" 을 다른 어떤 "InputStream"의 객체 로 사용할 수 있다는 것을 의미한다.

위의 코드예시를 살펴본다. 상황은 다음과 같다.

  • "System.in" 으로부터 바이트를 읽어 들이기 위해 "read()""available()" 과 같은 메소드를 사용하고자 한다.
  • InputStream 객체, 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. 예외처리
    • java.io 패키지(입출력 다룸) 사용시 반드시 해주어야 하며, try-catch문이 있지만 필자는 method에 throws IOException으로 하는 것이 가장 편리하다.
      -Scanner나 System.out.print 메소드의 경우 해당 메소드 안에서 자체적으로 예외처리를 한다.
  2. InputStream.read() 는 두가지 특징이 있다.
    • 입력받은 데이터는 int형으로 저장됨, 해당 문자의 시스템 또는 OS의 인코딩 형식의 10진수로 변수에 저장된다.
    • 1byte만 읽는다.
  3. InputStream은 바이트 단위로 데이터를 보낸다.
    • inputStream의 입력 메소드, read() 는 1바이트 단위로 읽어들인다.
    • [입력받은 문자가 2byte이상으로 구성되어있는 인코딩을 사용할 경우]
      => 바이트 단위로 데이터 입력을 받으면, 1byte값만 읽어들이고 나머지는 읽지 않고 스트림에만 남아있기 때문에 출력할 떄는 해당 데이터의 1byte에 대한 인코딩 값을 10진수로 변환한 값을 출력할 수도 있다.

바이트 스트림 활용 예시 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가 코드분석까지 해주네.... 캬...

문제점

  • 한글을 제대로 인식하지 못한다.
  • 아스키 확장편을 보면 255개의 문자 중 한글이 없다.
    [참고] 1바이트의 범위는 -128~127이며 1bit가 남아있는 것을 활용하여 확장한 것이 아래 128~255범위의 문자들이다.

한글로 콘솔을 입력받을 때

  1. UTF-8로 입력을 받는다.
  2. read()메소드는 1바이트만 읽기 때문에 나머지 바이트는 스트림에 잔존한다.
  3. 읽어들인 byte값은 메모리에 UTF-16에 대응되는 문자의 인코딩 방식으로 2진수 값이 저장한다.
  4. 출력시 메모리에 저장되어 있던 2진수에 대응되는 문자가 UTF-8로 변환되어 출력된다.

2. Scanner(System.in) 그리고 InputStreamReader(System.in)

먼저 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는 다음과 같은 특징을 지닌다.

  • InputStream의 바이트 단위를 읽어들이는 형식을 문자단위(character)의 데이터로 변환시키는 중개자 역할 인것이다.

참고사항

자바는 내부적으로 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 배열로 데이터를 받는다.

InputStreamReader와 Scanner의 본격적 비교

그렇다면, 실질적으로 우리가 입력받는 방식은?

흔히 우리가 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. "긴글을 가독성 있게 읽히려고 노력하는 것이 중요하다." 이다.

  • 본 포스팅을 쓰기 전 참고한 글은 좋은 글이었지만 너무 길었다. 해당 글도 정리가 어느정도 되어었기는 하지만 좀더 가독성있는 글을 위해 노력했고 이는 중요함을 느꼈다.
  1. Scanner로 입력을 받는 것과 InputStream(바이트 스트림)을 통해 입력 받는 것의 컴파일 속도 차이가 이렇게해서 나는구나라는 걸 직접 코딩도 해보고 문제도 풀면서 느낄수 있었다.

  2. 또한 컴파일 속도는 느리지만 정규식을 검사하는 측면에서 Scanner가 기본적이면서도 안전한 입출력 방식이라는 것을 깨달았다.

앞으로도 애매하게 모르는 부분을 넘어가지 말고 직접 포스팅 해보면서 나의 것으로 만들자!

profile
성장에 목마른 소프트웨어 엔지니어

0개의 댓글