[Java] I/O
Published:
In this post, basic grammar of input/output stream is introduced.
Stream
스트림 자체는 물리적인 ‘장치’가 아니라, 운영체제가 제공하는 추상적인 통로이다.
하지만 실제 데이터는 운영체제가 관리하는 메모리 영역(입출력 버퍼)에 존재한다.
예를 들어, 사용자가 키보드에서 1 2 3 을 입력했다면, 하드웨어가 인터럽트(신호)를 CPU(pin)/OS에 보낸다. (인터럽트에 관한 내용은 여기 를 참고하기 바람.)
[키보드 하드웨어] → [운영체제의 입력 버퍼(RAM 일부)] → [System.in (스트림)] → [Scanner]
- 프로그램 실행 중, 사용자가 키보드에서
1 2 3입력을 하면 초기에는 값들이 키보드 컨트롤러에 의해 키보드 장치 자체 메모리/레지스터 안에 저장된다. - 키보드 컨트롤러는 “키 입력 발생” 신호(하드웨어 인터럽트)를 CPU(pin)에 보낸다. (인터럽트에 관한 내용은 여기 를 참고하기 바람.)
- 인터럽트 핸들러는 키보드 컨트롤러에서 값을 읽어와 운영체제 커널 메모리의 입력 버퍼(Queue) 로 복사한다.
System.in스트림은 이 버퍼에 연결돼 있고,Scanner는 여기서 토큰을 꺼내는 것이다.- 즉,
System.in은 운영체제의 표준 입력 버퍼(stdin) 를 읽어올 수 있게 열어둔 자바의 입력 스트림 객체로 이해할 수 있다.
- 즉,
자바의 I/O(Input/Output) 스트림은 크게 두 가지로 나뉜다.
바이트 스트림 (Byte Stream)
데이터 단위를 1 byte (8bit) 씩 다룸
클래스 예:
InputStream,OutputStream계열예:
System.in(표준 입력),FileInputStream(파일 입력)
문자 스트림 (Character Stream)
데이터를 문자 단위(2 byte, UTF-16) 로 다룸
클래스 예:
Reader,Writer계열예:
FileReader,BufferedReader
System.in
System 은 Java에서 제공하는 유틸리티 클래스 (java.lang.System) 이다. java.lang 패키지에 속해있는 클래스들은 자동으로 import 해주므로 별도의 import 없이 사용할 수 있다.
System 클래스 내부에는 여러 static 필드와 메서드가 정의되어 있다.
System.out : 표준 출력 스트림
System.err : 표준 에러 스트림
System.in : 표준 입력 스트림
public final class System { public static final InputStream in; ... }즉, System.in은 InputStream 타입의 정적 필드이다. 실행 시점에 표준 입력 (키보드) 에 연결된 InputStream 객체가 들어 있다.
위에서 본 것처럼 InputStraem 은 바이트 기반 스트림이므로 키보드 입력을 받아올 때 그냥 문자나 숫자가 아니라, 0~255 범위의 바이트 값으로 받아온다.
사용자가 키보드로 'A' 입력 → 아스키 코드 65 → 0x41 바이트로 전달System.in 은 바이트 단위로만 읽기 때문에, 문자·정수·실수 같은 의미 있는 데이터로 변환하기 불편하다.
int ch = System.in.read(); System.out.println(ch); // 65 출력만약 여기서, 사용자가
A를 입력했다면,read()함수의 반환형이 int 이므로 아스키 코드 65가 저장되고 출력된다. 우리가 원하는대로A를 출력하게 하려면 다음과 같은 과정을 거쳐야 한다. 물론 여기서 char 형c를 출력해도 문자 A가 출력된다.int ch = System.in.read(); // 'A' 입력 → 65 char c = (char) ch; // 코드값 → 문자 String s = String.valueOf(c);// 문자 → 문자열char는 정수형에 더 가깝다. 단지 출력할 때는 문자로 보이는 것뿐. 스택에 직접 값을 저장한다.String은char들의 배열(char[])을 객체로 묶어놓은 것 으로, 객체로 힙에 저장, 참조값만 스택에 저장된다.
그래서
Scanner나InputStreamReader같은 보조 클래스를 붙여서 바이트 → 문자 → 토큰 단위 데이터로 변환한다.
Scanner
이제 위의 System.in 을 단독으로 사용하는 경우, InputStreamReader와 함께 사용하는 경우, 여기에 Scanner까지 함께 사용하는 경우를 알아보자.
Unicode Code point, UTF-8, UTF-16
그 전에 다음 개념을 정리하자.
유니코드 코드 포인트 : 전 세계 모든 문자를 고유한 숫자로 표현한 값이다. 컴퓨터에서의 저장 방식과 무관한 추상적 개념으로
가는 0xAC00 (10 진수 44032) 의 값을 가진다.- UTF-8 : 이 코드 포인트를 바이트 단위로 저장/전송하는 방법이다.
'가'(U+AC00) →0xEA 0xB0 0x80→ 3바이트로 저장된다.- 키보드로
가를 입력하면 UTF-8로 인코딩되어 OS 입력 버퍼에 들어간다. - 이 상태에서
System.in으로 읽으면 → 바이트 234, 176, 128 순서로 나온다.
- UTF-16
InputStreamReader는 내부적으로 인코딩(UTF-8)을 참고해서 바이트를 묶어 해석한다. 0xEA 0xB0 0x80 → 유니코드 코드 포인트 U+AC00로 복원한다.- 자바는 내부적으로 문자열을 메모리에 저장할 때, UTF-16 코드 유닛(char, 2바이트)으로 저장한다. 이 때, BMP 영역에서는 UTF-16 의 값과 유니코드 코드 포인트의 값이 일치하고 char 한 개(2바이트) 로 충분히 표현 가능하므로
0xAC00으로 메모리에 저장된다.
키보드 입력: '가'
↓ (UTF-8 인코딩)
운영체제 버퍼: EA B0 80 [3바이트]
↓ (System.in.read()) → 234, 176, 128
↓ (InputStreamReader UTF-8 해석)
유니코드 코드 포인트: U+AC00 (10진 44032)
↓ (자바 내부 저장, UTF-16)
char: 0xAC00 [2바이트]
System.in단독 사용import java.io.IOException; public class TestSystemIn { public static void main(String[] args) throws IOException { System.out.print("한 글자 입력: "); int b1 = System.in.read(); // '가' 입력 int b2 = System.in.read(); int b3 = System.in.read(); System.out.println("읽은 바이트 값:"); System.out.println(b1); // 234 System.out.println(b2); // 176 System.out.println(b3); // 128 } }InputStreamReader사용 (문자 단위)InputStreamReader는read()가 호출될 때 필요한 만큼만 바이트를 읽어서 문자로 변환한다. 즉, “버퍼가 다 비워질 때까지 무조건 싹 다 읽는 것”이 아니라, 문자 하나(혹은 요청한 배열 길이)만큼 해석하기에 필요한 최소한의 바이트만 읽는다. (이 때, 내부적으로system.in.read를 여러번 수행한다.)여기서 “UTF-8” 인자는 아래 주석 과정에서 쓰인다.
키보드 입력: '가' ↓ (UTF-8 인코딩) 운영체제 버퍼: EA B0 80 [3바이트] ↓ (System.in.read()) → 234, 176, 128 ↓ (InputStreamReader UTF-8 해석) // 이 부분의 정보를 전달하기 위함! 유니코드 코드 포인트: U+AC00 (10진 44032) ↓ (자바 내부 저장, UTF-16) char: 0xAC00 [2바이트]import java.io.*; public class TestInputStreamReader { public static void main(String[] args) throws IOException { InputStreamReader reader = new InputStreamReader(System.in, "UTF-8"); System.out.print("한 글자 입력: "); int ch = reader.read(); // '가' 입력 System.out.println("읽은 int 값: " + ch); // 44032 System.out.println("읽은 char 값: " + (char) ch); // '가' } }Scanner는 Java에서 입력을 처리할 때 많이 사용하는 클래스이다.
java.util.Scanner 패키지에 속해 있고, 키보드 입력뿐만 아니라 파일, 문자열 같은 다양한 입력 소스에서 데이터를 읽어올 수 있다.
Input Stream 을 토큰 단위로 잘라서 읽는 역할을 한다.
- Token 이란, 입력을 잘라낸 조각으로 보통 공백(띄어쓰기, 탭, 줄바꿈) 을 기준으로 문자열을 나눴을 때 생기는 단위이다.
Scanner sc = new Scanner(System.in); int a = sc.nextInt(); // 입력: 10 20 hello (사용자가 Enter 누름) "10" 꺼내서 int 10으로 변환 int b = sc.nextInt(); // 남아있던 "20" 꺼내서 int 20으로 변환 String s = sc.next(); // 남아있던 "hello" 꺼내서 String "hello" 반환 String a = sc.next(); // "Hello World !!" 입력 시 → "Hello" String b = sc.nextLine(); // 남은 " World !!"까지 포함한 전체 한 줄 읽음nextInt(),nextDouble(),nextLong(),nextFloat()같은 숫자 계열 입력 메서드는 **숫자까지만 읽고 뒤에 있는 공백/엔터는 버퍼에 남겨둔다. ** 따라서,nextLine()을 수행하여 엔터를 제거하고 새로운 입력을 받는다.
int n = sc.nextInt(); sc.nextLine(); // 엔터 제거 String s = sc.nextLine(); sc.close(); // 사용을 마친 후, 자원을 해제해야 memory leak이 없다.
BufferedReader사용import java.io.*; public class Test3 { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); System.out.print("문자열 입력: "); String line = br.readLine(); // 한 줄 전체 읽기 System.out.println("입력된 문자열: " + line); } }BufferReader는 바이트들을 한 글자씩 읽는 게 아니라,큰 덩어리(예: 8192자 정도)를 한 번에 읽어와서 내부 메모리(버퍼)에 저장. 프로그램이read()나readLine()을 호출하면, 이미 버퍼에 쌓아둔 데이터를 꺼내오기 때문에 훨씬 빠름[System.in] : OS 입력 버퍼 (바이트 저장) ↓ 바이트 읽음 [InputStreamReader] - byte[] 버퍼 - char[] 버퍼 ← (여기서 바이트→문자 변환 결과 임시 저장) ↓ [BufferedReader] - char[] 버퍼 (기본 8192 크기, 여기 모아둠 → readLine으로 꺼냄) ↓ 사용자 변수(String line 등)
StringBuilder
StringBuilder는 문자열을 효율적으로 연결(append)하거나 수정할 수 있게 해주는 클래스 이다.- 자바에서
String은 불변(immutable) 이라서, 문자열을 한 번 만들면 내용을 바꿀 수 없다. 예를 들어:
String s = "Hello";
s += " World";
이렇게 쓰면 "Hello World"라는 새로운 String 객체를 다시 만드는 것이다. 따라서 반복적으로 문자열을 붙이는 작업에서는 메모리 낭비와 성능 저하가 생긴다. 반면에, StringBuilder는 가변(mutable) 문자열 버퍼를 사용한다. 즉, 새로운 객체를 만들지 않고 기존 버퍼에 문자열을 덧붙여 성능이 훨씬 좋다.
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 10000; i++) {
while (count[i]-- > 0) {
sb.append(i).append('\n');
}
}
StringBuilder 에선, append 로 정수, 문자열 모두 들어갈 수 있다.(오버로딩이 되어 있다) Java에서 \n을 작은 따옴표로 감싸면, char 취급되므로 sb.append(i + '\n') 처럼 적으면 정수 연산이 내부에서 되므로 안되고 위 처럼 두 번 append 하여 적어야 한다. \n을 큰 따옴표로 감싸면 String 취급되므로 sb.append(i + "\n") 처럼 적어도 된다.
같은 일을 하는 다음 두 코드로부터 System.out.prlinln 보다 StringBuilder 의 성능이 좋은 이유를 알아보자.
for (int i = 0; i < 10000; i++) {
System.out.println(i);
}
System.out.prlinln 은 호출될 때마다 운영체제의 출력 버퍼로 데이터를 보낸다. 내부적으로 I/O(입출력) 작업이 일어나는데, 이건 메모리 연산보다 훨씬 느리다.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i).append('\n');
}
System.out.print(sb);
StringBuilder 는여러 문자열을 .append()로 모아뒀다가 (이 작업을 immutable한 String 으로 하면 메모리 낭비가 심해짐), 마지막에 System.out.println(sb.toString())으로 한 번만 출력한다.
따라서, 출력하고자 하는 데이터 양이 많을 때, StringBuilder 를 이용하는 것이, 시간적, 공간적 관점에서 모두 이득이다.
BufferedWriter
위 예제에서 출력을 위해 System.out.print 를 사용할 수도 있지만, 다음과 같이 BufferedWriter 를 사용할 수도 있다.
StringBuilder sb = new StringBuilder();
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
for (int i = 0; i < 10000; i++) {
sb.append(i).append('\n');
}
// System.out.print(sb);
bw.write(sb.toString());
bw.flush();
bw.close();
br.close();
자세한 설명 이전에, flush와 close 가 무엇인지 알아보자.
flush
버퍼(임시 저장 공간)에 있는 데이터를 강제로 내보내는 것이다. 출력 스트림(BufferedWriter, PrintWriter 등)은 성능 때문에 데이터를 바로 OS/파일에 쓰지 않고 버퍼에 모아둔다. 그런데 버퍼가 다 차지 않아도 지금 당장 결과를 보여줘야 할 때가 있을 수 있다. 그럴 때 flush()를 호출해서 지금까지 모아둔 내용을 모두 내보낸다.
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
bw.write("Hello");
bw.flush(); // 이제서야 "Hello"가 화면에 출력됨
flush() 없으면 버퍼에만 쌓여 있고, 아직 화면에는 안 보일 수 있다.
close
스트림을 완전히 닫는 것. 내부적으로는 아직 버퍼에 남아있는 데이터를 자동으로 flush() 하고, 파일/네트워크/콘솔과 연결된 리소스를 해제 를 해 준다.close()를 안 하면 데이터가 끝까지 안 써지고 프로그램이 끝날 수도 있고 파일 핸들이나 메모리가 해제되지 않아 자원 누수(leak) 가 생길 수 있다.
BufferedWriter bw = new BufferedWriter(new FileWriter("out.txt"));
bw.write("Hello");
bw.close(); // flush도 하고 파일도 닫음
만약 close()를 안 하면 "Hello"가 실제 파일에 안 써질 수도 있다.
다시 System.out.print 와 BufferedWriter 의 비교로 돌아오자.
println()은 내부적으로도 버퍼를 사용하지만, 호출할 때마다 자동 flush 동작이 섞여 있다. 즉, 반복적으로 호출하면 성능이 떨어집니다. (많은 양의 출력이 필요한 ps 문제 같은 경우 시간 초과 위험이 있다.)BufferedWriter는 큰 버퍼(메모리 공간) 를 가지고 있어, 데이터를 버퍼에 쌓아두었다가flush()나close()시점에 한 번에 출력한다. 즉, 불필요한 flush 없이 I/O 호출을 최소화한다.
Summary
이제 앞으로 우리는 PS 와 같이 많은 입력과 출력을 해야 하는 경우에 다음의 것들을 이용해서 보다 효율적인 코드를 작성할 수 있다.
- BufferedReader : 내부 버퍼로 한 번에 많은 데이터를 읽어오기 때문에 빠르다.
- BufferedWriter : 내부 버퍼에 많은 양의 데이터를 저장하다가 한 번에 flush 하여 I/O를 최소화할 수 있기 때문에 빠르다.
- StringBuilder : String을 자주 조작해야하는 경우 mutable 한 StringBuilder를 이용하면 메모리 효율적이고, String을 조작하지 않고 여러번 출력하려는 방법에 비해선 I/O 를 최소화할 수 있어 빠르다.

Leave a Comment