Scanner로 키보드 입력 받기
Scanner로 키보드 입력을 받고, nextInt 다음의 nextLine이 빈 문자열을 읽는 이유와 줄 단위로 읽어 숫자로 바꾸는 방법을 익힙니다.
실행 인자와 첫 회원가입에서는 이메일과 나이를 실행 인자로 받았습니다. 이 방식은 간단하지만 값을 실행하기 전에 모두 정해 두어야 하고, 프로그램이 실행 도중에 사용자에게 무언가를 물어볼 수 없습니다. 회원가입 화면처럼 "이메일을 입력하세요"라고 묻고, 답을 기다렸다가 다음 질문으로 넘어가려면 실행 중에 키보드 입력을 읽어야 합니다.
자바에서 키보드 입력은 표준 입력(standard input)이라는 통로로 들어오고, 프로그램에서는 System.in이라는 이름으로 접근합니다. 그런데 System.in은 입력을 바이트 단위로만 건네주기 때문에, 보통은 그 위에 Scanner를 연결해 한 줄이나 한 단어 단위로 읽습니다. 이 글에서는 Scanner로 한 줄을 읽는 방법에서 시작해, 처음 배우는 사람이 가장 자주 겪는 'nextInt 다음 입력이 건너뛰어지는' 문제의 원인을 따라가고, 읽은 문자열을 숫자로 바꾸는 방법까지 다룹니다.
키보드에서 한 줄 읽기
다음 프로그램은 이메일을 묻고, 사용자가 입력한 한 줄을 그대로 다시 출력합니다. main 안은 Scanner를 만들고, print로 질문을 띄우고, nextLine()으로 답을 읽는 세 단계로 나뉩니다. 각 줄의 자세한 뜻은 실행해 본 다음에 하나씩 살펴봅니다.
import java.util.Scanner;
public class ReadOneLine {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("email> ");
String email = scanner.nextLine();
System.out.println("입력한 이메일=" + email);
}
}컴파일과 실행 명령은 지금까지와 같습니다. Scanner는 JDK의 표준 라이브러리에 들어 있으므로, 따로 내려받거나 클래스 경로에 추가할 것이 없습니다.
javac -d out src/ReadOneLine.java
java -cp out ReadOneLine실행하면 email> 이 출력된 뒤 프로그램이 멈춘 것처럼 기다립니다. 이때 [email protected]을 입력하고 Enter를 누르면 화면이 다음과 같이 됩니다.
email> [email protected]
입력한 이메일[email protected]첫 줄의 [email protected]은 프로그램이 출력한 것이 아니라, 사용자가 친 글자를 터미널이 화면에 보여 준 것입니다. 프로그램이 출력한 것은 email> 과 둘째 줄뿐입니다. 프롬프트를 println이 아니라 print로 출력했기 때문에 줄이 바뀌지 않았고, 그래서 입력한 글자가 프롬프트 바로 뒤에 이어서 표시됩니다.
이제 코드를 한 줄씩 보겠습니다. Scanner는 java.util 패키지에 들어 있는 클래스라서, 파일 맨 위에 import java.util.Scanner;가 있어야 Scanner라는 짧은 이름으로 쓸 수 있습니다. 이 줄을 빼면 컴파일러가 cannot find symbol 오류를 냅니다. new Scanner(System.in)은 표준 입력을 읽는 Scanner를 하나 만듭니다. new로 무언가를 만드는 문법은 4장의 클래스로 관련 데이터 묶기에서 자세히 배우므로, 여기서는 '키보드를 읽는 도구를 하나 준비한다' 정도로 이해하면 충분합니다.
scanner.nextLine()은 다음 한 줄을 읽어 문자열로 돌려줍니다. 사용자가 아직 Enter를 누르지 않았다면 한 줄이 완성될 때까지 기다리고, 돌려주는 문자열에는 Enter로 생긴 줄바꿈 문자가 들어 있지 않습니다. 그래서 email에는 정확히 [email protected]만 담깁니다.
입력을 코드 안에 고정해 두는 문자열 Scanner
키보드 입력을 쓰는 프로그램은 실행할 때마다 직접 입력해야 해서, 같은 상황을 여러 번 재현하거나 결과를 비교하기 번거롭습니다. 그래서 이 글의 나머지 예제는 대부분 Scanner에 키보드 대신 문자열을 연결합니다.
Scanner scanner = new Scanner("45\n[email protected]\n");문자열 안의 \n은 줄바꿈 문자 하나를 뜻합니다. 키보드에서 Enter를 눌렀을 때 입력에 들어가는 문자입니다. 따라서 위 Scanner는 사용자가 45를 치고 Enter, [email protected]을 치고 Enter를 누른 것과 같은 입력을 읽습니다. Windows에서는 Enter가 \r\n 두 문자를 만들지만, Scanner는 \r\n도 줄바꿈 하나로 처리하므로 결과는 같습니다.
입력이 어디서 오든 nextLine(), nextInt() 같은 읽기 메서드는 똑같이 동작합니다. 그러니 문자열로 확인한 동작은 키보드에서도 그대로 나타나고, 직접 입력해 보고 싶다면 new Scanner(System.in)으로 바꾸기만 하면 됩니다.
nextInt 다음의 nextLine이 입력을 건너뛰는 이유
Scanner에는 한 줄 전체를 읽는 nextLine() 말고도, 공백으로 구분된 덩어리 하나를 읽는 메서드들이 있습니다. 이 덩어리를 토큰(token)이라고 하며, 여기서 공백에는 스페이스뿐 아니라 탭과 줄바꿈도 포함됩니다. 그중 nextInt()는 다음 토큰을 읽어 int로 바꿔 주므로 나이를 받을 때 편해 보입니다. 그런데 나이를 nextInt()로, 이메일을 nextLine()으로 읽으면 이상한 일이 생깁니다. 앞의 ReadOneLine에서 main의 중괄호 안을 다음 코드로 바꿔 실행해 보세요.
Scanner scanner = new Scanner(System.in);
System.out.print("age> ");
int age = scanner.nextInt();
System.out.print("email> ");
String email = scanner.nextLine();
System.out.println("age=" + age);
System.out.println("email=[" + email + "]");45를 입력하고 Enter를 누르면, 이메일을 입력할 틈도 없이 프로그램이 끝나 버립니다. 둘째 줄에서 email> 프롬프트 뒤에 사용자 입력 대신 age=45가 바로 붙어 있는 것을 보세요.
age> 45
email> age=45
email=[]email> 프롬프트는 출력됐지만 nextLine()이 기다리지 않고 곧바로 빈 문자열을 돌려주었습니다. 예외도 오류 메시지도 없이 잘못된 값으로 계속 진행하기 때문에, 이런 버그는 결과를 꼼꼼히 보지 않으면 놓치기 쉽습니다. 원인을 차근차근 보기 위해 입력을 문자열로 고정한 같은 코드를 준비했습니다.
import java.util.Scanner;
public final class MixedScannerBug {
public static void main(String[] args) {
Scanner scanner = new Scanner("45\n[email protected]\n");
int age = scanner.nextInt();
String email = scanner.nextLine();
System.out.println("age=" + age);
System.out.println("email=[" + email + "]");
}
}age=45
email=[]Scanner는 입력을 어디까지 읽었는지 위치를 기억합니다. 읽기 메서드를 부를 때마다 이 위치가 앞으로 이동하고, 한 번 지나간 글자는 다시 읽지 않습니다. 이렇게 읽고 지나가는 것을 입력을 소비한다고 합니다. 호출마다 무엇을 돌려주고 무엇이 남는지 따라가 보면 다음과 같습니다.
| 단계 | 호출 | 돌려준 값 | 호출 뒤 남은 입력 |
|---|---|---|---|
| 시작 | 없음 | 없음 | 45\[email protected]\n |
| 1 | nextInt() | 45 | \[email protected]\n |
| 2 | nextLine() | 빈 문자열 "" | [email protected]\n |
nextInt()는 토큰 45만 읽고 바로 그 뒤에서 멈춥니다. 숫자 뒤의 줄바꿈은 토큰이 아니라 토큰 사이를 구분하는 공백이므로 소비하지 않고 남겨 둡니다. 이어서 호출한 nextLine()은 '지금 위치부터 줄바꿈까지'를 읽는 메서드입니다. 지금 위치 바로 다음이 줄바꿈이니 그 사이에 있는 내용, 즉 빈 문자열을 돌려주고 줄바꿈만 소비합니다. 정작 이메일 [email protected]은 읽히지 않은 채 입력에 남아 있습니다.
키보드 버전에서 프로그램이 기다리지 않은 이유도 똑같습니다. 사용자가 45 다음에 누른 Enter의 줄바꿈이 입력에 남아 있었고, nextLine()은 새 입력을 기다릴 필요 없이 그 줄바꿈에서 한 줄을 끝냈습니다.
줄 단위로 읽고 따로 변환하기
해결 방법은 읽는 방식을 하나로 통일하는 것입니다. 모든 입력을 nextLine()으로 한 줄씩 읽고, 숫자가 필요하면 읽은 문자열을 Integer.parseInt로 바꿉니다.
import java.util.Scanner;
public final class LineBasedInput {
public static void main(String[] args) {
Scanner scanner = new Scanner("45\n[email protected]\n");
String ageLine = scanner.nextLine();
int age = Integer.parseInt(ageLine.strip());
String email = scanner.nextLine().strip();
System.out.println("age=" + age);
System.out.println("email=[" + email + "]");
}
}age=45
email=[[email protected]]| 단계 | 호출 | 돌려준 값 | 호출 뒤 남은 입력 |
|---|---|---|---|
| 시작 | 없음 | 없음 | 45\[email protected]\n |
| 1 | nextLine() | "45" | [email protected]\n |
| 2 | nextLine() | "[email protected]" | 없음 |
nextLine()은 줄바꿈까지 소비하므로, 다음 호출은 언제나 새 줄의 맨 앞에서 시작합니다. 남는 줄바꿈이 없으니 앞의 문제도 생기지 않습니다. 읽기와 변환을 나눈 데는 이점이 하나 더 있습니다. 변환하기 전의 원래 문자열 ageLine이 변수에 남아 있으므로, 변환에 실패하면 사용자가 무엇을 입력했는지 그대로 보여 줄 수 있습니다. 이 점은 다음 글에서 오류 메시지를 만들 때 활용합니다.
strip()은 문자열 앞뒤의 공백 문자를 지운 새 문자열을 돌려줍니다. 사용자가 실수로 45 처럼 숫자 뒤에 공백을 넣으면 Integer.parseInt("45 ")는 NumberFormatException으로 실패하지만, strip()을 먼저 거치면 45가 되어 정상적으로 바뀝니다. 공백을 지우는 메서드로 trim()도 자주 보게 될 텐데, trim()은 코드값이 U+0020 이하인 문자만 지우고 strip()은 Character.isWhitespace가 공백으로 판단하는 문자를 지웁니다. 그래서 전각 공백(U+3000) 같은 문자는 strip()만 지웁니다.
nextInt()를 꼭 써야 한다면 그 뒤에 scanner.nextLine()을 한 번 더 호출해 남은 줄을 버리는 방법도 있습니다. 하지만 이 방법은 같은 줄에서 숫자 뒤에 입력된 내용까지 말없이 버립니다. 예를 들어 45 kim을 입력하면, 줄을 버리려고 호출한 nextLine()이 숫자 뒤의 kim까지 가져가 버립니다. 또 nextInt()를 쓸 때마다 잊지 않고 짝을 맞춰야 하므로, 처음부터 줄 단위로 읽는 편이 실수할 여지가 적습니다.
같은 입력에서 토큰과 줄 읽기의 소비 경계가 갈라진다
같은 45 줄과 이메일 입력을 토큰 읽기 또는 줄 읽기로 소비할 때 남는 줄바꿈이 달라집니다. 커서와 반환 문자열을 나란히 추적하면 빈 이메일의 원인이 드러납니다.
45 뒤 줄바꿈을 nextInt가 남기는 실행과 nextLine이 소비하는 실행에서 반환값과 남은 입력을 함께 비교한다.
INPUT · STATE BOUNDARY
같은 입력에서 달라지는 반환값과 남은 줄
nextInt는 숫자 뒤 줄바꿈을 남깁니다. 줄 단위 입력은 줄바꿈까지 소비하고, 읽어 둔 문자열을 별도로 정수로 변환합니다.
45\[email protected]\n
MixedScannerBug
nextInt()
age=45. 남은 입력은\[email protected]\n입니다.nextLine()
email=""을 정상 반환합니다.[email protected]\n은 아직 남아 있습니다.
LineBasedInput
한 줄 읽고 변환
nextLine()이"45"와 줄바꿈을 소비합니다.Integer.parseInt(ageLine.strip())은45를 만듭니다. 남은 입력은[email protected]\n입니다.다음 한 줄 읽기
nextLine().strip()은[email protected]을 반환합니다. 입력은 모두 소비되어hasNextLine()==false입니다.
\n은 줄바꿈을 나타냅니다. nextLine()은 줄바꿈까지 소비하지만 반환 문자열에는 넣지 않습니다. parseInt(ageLine.strip())은 읽어 둔 문자열만 변환합니다.
이 예의 조건
- 두 프로그램은 같은 입력으로 각각 시작한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 같은 입력에서 토큰과 줄 읽기의 소비 경계가 갈라진다 같은 Scanner 입력을 토큰 혼합과 줄 단위 방식으로 소비한 두 실행 45 뒤 줄바꿈을 nextInt가 남기는 실행과 nextLine이 소비하는 실행에서 반환값과 남은 입력을 함께 비교한다. INPUT · STATE BOUNDARY 같은 입력에서 달라지는 반환값과 남은 줄 nextInt는 숫자 뒤 줄바꿈을 남깁니다. 줄 단위 입력은 줄바꿈까지 소비하고, 읽어 둔 문자열을 별도로 정수로 변환합니다. 45\[email protected]\n MixedScannerBug nextInt() age=45 . 남은 입력은 \[email protected]\n 입니다. nextLine() email="" 을 정상 반환합니다. [email protected]\n 은 아직 남아 있습니다. LineBasedInput 한 줄 읽고 변환 nextLine() 이 "45" 와 줄바꿈을 소비합니다. Integer.parseInt(ageLine.strip()) 은 45 를 만듭니다. 남은 입력은 [email protected]\n 입니다. 다음 한 줄 읽기 nextLine().strip() 은 [email protected] 을 반환합니다. 입력은 모두 소비되어 hasNextLine()==false 입니다. \n 은 줄바꿈을 나타냅니다. nextLine() 은 줄바꿈까지 소비하지만 반환 문자열에는 넣지 않습니다. parseInt(ageLine.strip()) 은 읽어 둔 문자열만 변환합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 시작 두 reader에 45 줄과 이메일 줄을 제공합니다. |
|
|
| 나이 읽기 nextInt는 줄바꿈을 남기고 nextLine은 줄 전체를 소비합니다. |
|
|
| 이메일 읽기 다음 nextLine이 서로 다른 문자열을 반환합니다. |
|
|
같은 입력에서 토큰과 줄 읽기의 소비 경계가 갈라진다
같은 45 줄과 이메일 입력을 토큰 읽기 또는 줄 읽기로 소비할 때 남는 줄바꿈이 달라집니다. 커서와 반환 문자열을 나란히 추적하면 빈 이메일의 원인이 드러납니다.
45 뒤 줄바꿈을 nextInt가 남기는 실행과 nextLine이 소비하는 실행에서 반환값과 남은 입력을 함께 비교한다.
INPUT · STATE BOUNDARY
같은 입력에서 달라지는 반환값과 남은 줄
nextInt는 숫자 뒤 줄바꿈을 남깁니다. 줄 단위 입력은 줄바꿈까지 소비하고, 읽어 둔 문자열을 별도로 정수로 변환합니다.
45\[email protected]\n
MixedScannerBug
nextInt()
age=45. 남은 입력은\[email protected]\n입니다.nextLine()
email=""을 정상 반환합니다.[email protected]\n은 아직 남아 있습니다.
LineBasedInput
한 줄 읽고 변환
nextLine()이"45"와 줄바꿈을 소비합니다.Integer.parseInt(ageLine.strip())은45를 만듭니다. 남은 입력은[email protected]\n입니다.다음 한 줄 읽기
nextLine().strip()은[email protected]을 반환합니다. 입력은 모두 소비되어hasNextLine()==false입니다.
\n은 줄바꿈을 나타냅니다. nextLine()은 줄바꿈까지 소비하지만 반환 문자열에는 넣지 않습니다. parseInt(ageLine.strip())은 읽어 둔 문자열만 변환합니다.
이 예의 조건
- 두 프로그램은 같은 입력으로 각각 시작한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 같은 입력에서 토큰과 줄 읽기의 소비 경계가 갈라진다 같은 Scanner 입력을 토큰 혼합과 줄 단위 방식으로 소비한 두 실행 45 뒤 줄바꿈을 nextInt가 남기는 실행과 nextLine이 소비하는 실행에서 반환값과 남은 입력을 함께 비교한다. INPUT · STATE BOUNDARY 같은 입력에서 달라지는 반환값과 남은 줄 nextInt는 숫자 뒤 줄바꿈을 남깁니다. 줄 단위 입력은 줄바꿈까지 소비하고, 읽어 둔 문자열을 별도로 정수로 변환합니다. 45\[email protected]\n MixedScannerBug nextInt() age=45 . 남은 입력은 \[email protected]\n 입니다. nextLine() email="" 을 정상 반환합니다. [email protected]\n 은 아직 남아 있습니다. LineBasedInput 한 줄 읽고 변환 nextLine() 이 "45" 와 줄바꿈을 소비합니다. Integer.parseInt(ageLine.strip()) 은 45 를 만듭니다. 남은 입력은 [email protected]\n 입니다. 다음 한 줄 읽기 nextLine().strip() 은 [email protected] 을 반환합니다. 입력은 모두 소비되어 hasNextLine()==false 입니다. \n 은 줄바꿈을 나타냅니다. nextLine() 은 줄바꿈까지 소비하지만 반환 문자열에는 넣지 않습니다. parseInt(ageLine.strip()) 은 읽어 둔 문자열만 변환합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 시작 두 reader에 45 줄과 이메일 줄을 제공합니다. |
|
|
| 나이 읽기 nextInt는 줄바꿈을 남기고 nextLine은 줄 전체를 소비합니다. |
|
|
| 이메일 읽기 다음 nextLine이 서로 다른 문자열을 반환합니다. |
|
|
더 깊이: nextInt가 실패하면 토큰이 그대로 남는다
nextInt()는 다음 토큰이 정수 모양이 아니면 InputMismatchException을 던지는데, 이때 문제의 토큰을 소비하지 않습니다. 그래서 forty를 받고 예외를 잡은 뒤 다시 nextInt()를 부르면 같은 forty를 또 읽고 또 실패합니다. 반복문 안에서 재입력을 받으려 했다면 같은 오류가 끝없이 반복됩니다. hasNextInt()로 먼저 확인하는 방법도 있지만, 그 경우에도 잘못된 토큰은 next()로 직접 꺼내 버려야 합니다.
한 줄을 통째로 읽은 뒤 변환하는 방식은 변환에 실패해도 그 줄이 이미 소비되어 있으므로 이런 문제가 없습니다. 이 글과 다음 글이 줄 단위 입력을 기본으로 삼는 이유입니다.
Scanner는 하나만 만들어 끝까지 쓴다
질문이 여러 개라도 Scanner는 하나면 충분합니다. 다음 프로그램은 하나의 Scanner로 이메일과 나이를 차례로 묻습니다.
import java.util.Scanner;
public final class PromptOnce {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("email> ");
String email = scanner.nextLine().strip();
System.out.print("age> ");
String rawAge = scanner.nextLine().strip();
int age = Integer.parseInt(rawAge);
System.out.println("preview=" + email + ":" + age);
}
}[email protected]과 35를 차례로 입력하면 다음과 같습니다. 두 질문을 같은 scanner로 읽고 둘 다 nextLine()을 쓰므로, 앞에서 본 줄바꿈이 남는 문제도 생기지 않습니다.
email> [email protected]
age> 35
[email protected]:35이메일을 다 읽었으니 Scanner를 닫고, 나이를 읽을 때 새로 만들면 어떨까요? 문제는 Scanner를 닫으면 Scanner가 읽던 원천, 여기서는 System.in까지 함께 닫힌다는 점입니다. 한 번 닫힌 표준 입력은 프로그램이 끝날 때까지 다시 열 수 없습니다. PromptOnce의 main 안을 다음처럼 바꿔 확인해 보겠습니다.
Scanner first = new Scanner(System.in);
System.out.print("email> ");
String email = first.nextLine();
first.close(); // System.in도 함께 닫힌다
Scanner second = new Scanner(System.in);
System.out.print("age> ");
String rawAge = second.nextLine(); // 여기서 예외가 발생한다
System.out.println(email + ":" + rawAge);email> [email protected]
age> Exception in thread "main" java.util.NoSuchElementException: No line found이메일은 정상적으로 읽혔지만, age> 를 출력한 직후 사용자가 무언가를 입력하기도 전에 NoSuchElementException이 발생합니다. 새로 만든 second가 읽으려는 System.in이 이미 닫혀 있어서, 더 읽을 줄이 없다고 판단한 것입니다. 메시지 아래에는 at으로 시작하는 줄이 몇 줄 더 이어지며, 그중 내 클래스 이름이 들어 있는 줄이 실패한 second.nextLine()의 위치를 알려 줍니다.
실제 프로그램에서 이 실수는 보통 '입력을 받는 작은 메서드 안에서 Scanner를 만들고 닫는' 모양으로 나타납니다. 메서드는 이 장의 뒤쪽에서 배우지만 원칙은 같습니다. System.in에 연결한 Scanner는 프로그램에서 하나만 만들어 필요한 곳에서 함께 쓰고, 닫는다면 모든 입력이 끝난 맨 마지막에 한 번만 닫습니다. 프로그램이 끝나면 표준 입력은 저절로 정리되므로, 짧은 콘솔 프로그램에서는 닫지 않아도 괜찮습니다. 파일처럼 반드시 닫아야 하는 자원과 이를 안전하게 닫는 try-with-resources 문법은 12장의 try-with-resources와 공통 예외 처리에서 다룹니다.
더 깊이: Scanner를 여러 개 만들면 입력이 사라지는 이유
Scanner는 원천에서 글자를 하나씩 꺼내 쓰지 않습니다. 한 번에 여러 글자를 읽어 내부 버퍼에 담아 두고, 그 버퍼에서 토큰과 줄을 잘라 냅니다. 그래서 닫지 않더라도 같은 System.in에 Scanner를 두 개 만들면, 먼저 만든 Scanner가 버퍼에 미리 담아 둔 입력은 두 번째 Scanner가 볼 수 없습니다.
키보드로 한 줄씩 입력할 때는 버퍼에 그 한 줄만 들어오는 경우가 많아 문제가 드러나지 않을 수 있습니다. 하지만 입력을 파일이나 파이프로 한꺼번에 넣으면 첫 Scanner가 여러 줄을 미리 가져가 버리고, 두 번째 Scanner는 남은 입력이 없어 NoSuchElementException을 만납니다. 개발할 때는 잘 되다가 자동 테스트에서만 실패하는 버그가 되기 쉬우므로, 처음부터 Scanner를 하나만 두는 습관을 들이는 것이 좋습니다.
읽기 실패와 숫자 변환 실패는 다르다
줄 단위로 읽으면 Scanner는 숫자든 글자든 일단 문자열로 받아 옵니다. 숫자가 아닌 입력이 문제가 되는 곳은 그 다음 단계입니다.
import java.util.Scanner;
public final class InvalidNumberLine {
public static void main(String[] args) {
Scanner scanner = new Scanner("forty\n");
String raw = scanner.nextLine();
int age = Integer.parseInt(raw);
System.out.println(age);
}
}Exception in thread "main" java.lang.NumberFormatException: For input string: "forty"이 줄 아래로 at으로 시작하는 줄이 몇 줄 이어지는데, 그중 at InvalidNumberLine.main(InvalidNumberLine.java:7)이 예외가 난 내 코드의 위치입니다. 7번째 줄은 Integer.parseInt(raw)가 있는 줄입니다. 즉 Scanner는 forty를 아무 문제 없이 문자열로 읽었고, 실패한 곳은 그 문자열을 정수로 바꾸는 parseInt입니다.
입력과 관련된 실패는 이렇게 두 종류로 나눌 수 있습니다. 하나는 읽을 입력 자체가 없는 경우로, 입력이 이미 끝난 상태에서 nextLine()을 호출하면 NoSuchElementException이 발생합니다. 다른 하나는 입력은 있지만 원하는 형식이 아닌 경우로, 위의 NumberFormatException이 여기에 해당합니다. 둘을 구분해야 사용자에게 '입력이 없습니다'와 '숫자를 입력하세요'를 다르게 안내할 수 있습니다.
입력이 없는 경우는 hasNextLine()으로 미리 막을 수 있습니다. hasNextLine()은 읽을 줄이 남아 있으면 true, 입력이 끝났으면 false를 돌려줄 뿐 입력을 소비하지는 않습니다. 키보드에서 아직 아무것도 입력하지 않았다면 hasNextLine()도 nextLine()처럼 입력을 기다립니다. 키보드로 '입력이 끝났다'는 신호를 보내려면 Windows에서는 Ctrl+Z를 누른 뒤 Enter를, macOS와 Linux에서는 Ctrl+D를 누릅니다. 문자열 Scanner는 문자열의 끝에 도달하면 입력이 끝난 것으로 봅니다.
줄 읽기 실패와 숫자 변환 실패는 다른 경계다
줄 존재 검사 뒤 nextLine은 forty를 정상적으로 읽고 별도 parseInt에서 실패합니다. 입력 없음·문자열 읽기·변환 실패를 분리하여 실패 지점을 보여 줄 수 있습니다.
입력이 없는 경우와 forty를 정상적으로 읽은 뒤 숫자 변환에 실패하는 경우를 구분한다.
INPUT · STATE BOUNDARY
줄 읽기와 숫자 변환의 실패 경계
줄이 없으면 읽기를 호출하지 않습니다. forty는 정상적으로 읽힌 문자열이며, 그 다음 정수 변환에서 실패합니다.
- 줄 없음
hasNextLine()==false이면 읽기를 호출하지 않고 끝냅니다.- 줄 있음 · "forty"
nextLine()은"forty"를 정상 반환합니다. 다음parseInt가NumberFormatException으로 실패합니다.- 줄 있음 · "45"
nextLine()으로 문자열을 읽고parseInt로int 45를 얻습니다.
EOF에서 검사 없이 nextLine()을 호출하면 NoSuchElementException입니다. InvalidNumberLine의 forty는 읽힌 문자열이며 실패 지점은 parseInt입니다.
이 예의 조건
- 줄 존재와 정수 변환은 서로 다른 검사입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 줄 읽기 실패와 숫자 변환 실패는 다른 경계다 Scanner 줄 존재 검사와 문자열의 정수 변환에서 갈라지는 실패 경계 입력이 없는 경우와 forty를 정상적으로 읽은 뒤 숫자 변환에 실패하는 경우를 구분한다. INPUT · STATE BOUNDARY 줄 읽기와 숫자 변환의 실패 경계 줄이 없으면 읽기를 호출하지 않습니다. forty는 정상적으로 읽힌 문자열이며, 그다음 정수 변환에서 실패합니다. 줄 없음 hasNextLine()==false 이면 읽기를 호출하지 않고 끝냅니다. 줄 있음 · "forty" nextLine() 은 "forty" 를 정상 반환합니다. 다음 parseInt 가 NumberFormatException 으로 실패합니다. 줄 있음 · "45" nextLine() 으로 문자열을 읽고 parseInt 로 int 45 를 얻습니다. EOF에서 검사 없이 nextLine() 을 호출하면 NoSuchElementException 입니다. InvalidNumberLine 의 forty 는 읽힌 문자열이며 실패 지점은 parseInt 입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| EOF 가드 hasNextLine=false이면 읽기 호출 없이 끝냅니다. |
|
|
| 문자열 읽기 forty와 45 모두 nextLine으로 읽힙니다. |
|
|
| 숫자 변환 parseInt에서 두 값이 갈립니다. |
|
|
줄 읽기 실패와 숫자 변환 실패는 다른 경계다
줄 존재 검사 뒤 nextLine은 forty를 정상적으로 읽고 별도 parseInt에서 실패합니다. 입력 없음·문자열 읽기·변환 실패를 분리하여 실패 지점을 보여 줄 수 있습니다.
입력이 없는 경우와 forty를 정상적으로 읽은 뒤 숫자 변환에 실패하는 경우를 구분한다.
INPUT · STATE BOUNDARY
줄 읽기와 숫자 변환의 실패 경계
줄이 없으면 읽기를 호출하지 않습니다. forty는 정상적으로 읽힌 문자열이며, 그 다음 정수 변환에서 실패합니다.
- 줄 없음
hasNextLine()==false이면 읽기를 호출하지 않고 끝냅니다.- 줄 있음 · "forty"
nextLine()은"forty"를 정상 반환합니다. 다음parseInt가NumberFormatException으로 실패합니다.- 줄 있음 · "45"
nextLine()으로 문자열을 읽고parseInt로int 45를 얻습니다.
EOF에서 검사 없이 nextLine()을 호출하면 NoSuchElementException입니다. InvalidNumberLine의 forty는 읽힌 문자열이며 실패 지점은 parseInt입니다.
이 예의 조건
- 줄 존재와 정수 변환은 서로 다른 검사입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 줄 읽기 실패와 숫자 변환 실패는 다른 경계다 Scanner 줄 존재 검사와 문자열의 정수 변환에서 갈라지는 실패 경계 입력이 없는 경우와 forty를 정상적으로 읽은 뒤 숫자 변환에 실패하는 경우를 구분한다. INPUT · STATE BOUNDARY 줄 읽기와 숫자 변환의 실패 경계 줄이 없으면 읽기를 호출하지 않습니다. forty는 정상적으로 읽힌 문자열이며, 그다음 정수 변환에서 실패합니다. 줄 없음 hasNextLine()==false 이면 읽기를 호출하지 않고 끝냅니다. 줄 있음 · "forty" nextLine() 은 "forty" 를 정상 반환합니다. 다음 parseInt 가 NumberFormatException 으로 실패합니다. 줄 있음 · "45" nextLine() 으로 문자열을 읽고 parseInt 로 int 45 를 얻습니다. EOF에서 검사 없이 nextLine() 을 호출하면 NoSuchElementException 입니다. InvalidNumberLine 의 forty 는 읽힌 문자열이며 실패 지점은 parseInt 입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| EOF 가드 hasNextLine=false이면 읽기 호출 없이 끝냅니다. |
|
|
| 문자열 읽기 forty와 45 모두 nextLine으로 읽힙니다. |
|
|
| 숫자 변환 parseInt에서 두 값이 갈립니다. |
|
|
입력이 끝날 때까지 여러 줄 읽기
hasNextLine()을 반복 조건으로 쓰면 줄이 있는 동안 계속 읽는 반복문이 됩니다. 다음 예제는 이메일을 한 줄에 하나씩 읽고, 빈 줄은 건너뛰며, 받아들인 이메일 수를 셉니다.
import java.util.Scanner;
public final class ReadUntilEnd {
public static void main(String[] args) {
Scanner scanner = new Scanner("[email protected]\n\n[email protected]\n");
int accepted = 0;
while (scanner.hasNextLine()) {
String email = scanner.nextLine().strip();
if (email.isEmpty()) {
System.out.println("skip.empty");
continue;
}
accepted++;
System.out.println("email=" + email);
}
System.out.println("accepted=" + accepted);
}
}[email protected]
skip.empty
[email protected]
accepted=2입력 문자열의 \n\n은 줄바꿈이 두 번 연달아 있다는 뜻이므로, 그 사이에 빈 줄이 하나 있습니다. 반복이 한 바퀴 돌 때마다 무슨 일이 일어나는지 표로 보면 다음과 같습니다.
| 회차 | hasNextLine() | strip() 뒤의 줄 | 출력 | accepted |
|---|---|---|---|---|
| 1 | true | [email protected] | [email protected] | 1 |
| 2 | true | 빈 문자열 | skip.empty | 1 |
| 3 | true | [email protected] | [email protected] | 2 |
| 4 | false | 읽지 않음 | 반복 종료 | 2 |
isEmpty()는 문자열의 길이가 0인지 확인하는 메서드입니다. 2회차에서 빈 줄을 만나 continue가 실행되면 그 아래의 accepted++를 건너뛰고 바로 반복 조건으로 돌아갑니다. 이때 빈 줄은 이미 nextLine()으로 소비된 뒤이므로, 다음 회차는 그다음 줄을 읽고 반복은 앞으로 나아갑니다. 만약 줄을 읽기 전에 continue하는 코드였다면 같은 위치에 계속 머물러 반복이 끝나지 않았을 것입니다. 4회차에서 더 읽을 줄이 없으면 hasNextLine()이 false가 되어 반복이 끝나고, 마지막 줄 accepted=2가 출력됩니다.
한 줄에 담긴 명령 나누기
이제 한 줄에 명령과 값을 함께 받는 형식을 만들어 보겠습니다. 사용자가 register 이메일 나이처럼 입력하면, 프로그램은 이 줄을 공백 기준으로 나눠 명령 이름, 이메일, 나이를 꺼냅니다. 이메일에 공백을 넣을 수 없다는 제한이 생기지만, 대신 한 번의 입력으로 회원 한 명의 정보를 모두 받을 수 있습니다.
문자열을 나누는 데는 split을 씁니다. line.split("\\s+")는 하나 이상 이어진 공백 문자를 구분자로 삼아 문자열을 여러 조각으로 나누고, 그 조각들을 String[] 배열로 돌려줍니다. 이 배열은 실행 인자 args와 같은 방식으로 씁니다. parts.length로 조각 수를 알 수 있고, parts[0], parts[1]처럼 0부터 시작하는 번호로 각 조각을 읽습니다. 예를 들어 register [email protected] 45를 나누면 parts[0]은 register, parts[1]은 이메일, parts[2]는 나이 문자열 45입니다. 배열 자체는 배열과 인덱스에서 자세히 배웁니다.
"\\s+"는 정규 표현식(regular expression)이라는 패턴 문법으로 쓴 구분자입니다. \s는 스페이스나 탭 같은 공백 문자 하나를, +는 '바로 앞의 것이 하나 이상'을 뜻합니다. 자바 문자열 안에서 역슬래시 자체를 쓰려면 두 번 적어야 하므로 코드에는 "\\s+"로 씁니다. 그냥 " "(공백 한 칸)으로 나누면, 사용자가 공백을 두 번 친 곳에서 빈 조각이 생깁니다. 예를 들어 "register [email protected]".split(" ")는 register, 빈 문자열, [email protected]의 세 조각이 되어, 조각이 세 개인지 확인하는 검사를 엉뚱하게 통과해 버립니다.
import java.util.Scanner;
public final class MemberRegistryInput {
public static void main(String[] args) {
Scanner scanner = new Scanner("register [email protected] 45\nquit\n");
while (scanner.hasNextLine()) {
String line = scanner.nextLine().strip();
if (line.equals("quit")) {
System.out.println("bye");
break;
}
String[] parts = line.split("\\s+");
if (parts.length != 3 || !parts[0].equals("register")) {
System.out.println("usage: register <email> <age>");
continue;
}
int age = Integer.parseInt(parts[2]);
System.out.println("preview=" + parts[1] + ":" + age);
}
}
}[email protected]:45
bye반복문은 앞 절과 같은 모양으로 줄이 있는 동안 한 줄씩 읽습니다. 첫 줄은 quit이 아니므로 split으로 나뉘고, 조각이 세 개이며 첫 조각이 register이므로 검사를 통과해 [email protected]:45를 출력합니다. 둘째 줄 quit에서는 bye를 출력하고 break로 반복을 끝냅니다.
검사 순서에는 이유가 있습니다. 조각 수를 확인하기 전에 parts[2]를 읽으면, register [email protected]처럼 조각이 두 개뿐인 줄에서 존재하지 않는 칸을 읽다가 실행 오류가 납니다. 그래서 개수와 명령 이름을 먼저 확인하고, 맞지 않으면 사용법을 출력한 뒤 continue로 다음 줄로 넘어갑니다. split 전에 strip()을 하는 것도 같은 맥락입니다. 줄 앞에 공백이 있으면 split("\\s+")의 첫 조각이 빈 문자열이 되어, 올바른 명령도 사용법 오류로 거부되기 때문입니다.
아직 남은 문제가 하나 있습니다. 나이 자리에 forty가 들어오면 parseInt가 NumberFormatException을 던지고, 프로그램 전체가 그 자리에서 끝납니다. 잘못 입력한 한 줄 때문에 앞에서 받은 입력까지 모두 잃는 셈입니다.
정리
- 키보드 입력은
System.in으로 들어오고,new Scanner(System.in)을 연결해 읽습니다.Scanner는 프로그램에서 하나만 만들고, 중간에 닫지 않습니다. nextLine()은 줄바꿈까지 소비하고 줄바꿈을 뺀 문자열을 돌려줍니다.nextInt()같은 토큰 메서드는 토큰 뒤의 줄바꿈을 남기므로, 뒤이은nextLine()이 빈 문자열을 읽습니다.- 두 방식을 섞지 말고 줄 단위로 읽은 뒤,
strip()으로 앞뒤 공백을 지우고Integer.parseInt로 변환합니다. - 입력이 없는 경우(
hasNextLine()이false, 무시하고 읽으면NoSuchElementException)와 숫자가 아닌 경우(NumberFormatException)는 서로 다른 실패입니다.
연습 문제
- 첫 줄은 이메일, 둘째 줄은 나이로 읽는
TwoLineMember를 작성하세요. 이메일은strip()으로 앞뒤 공백을 지운 뒤 출력하고, 나이는 줄 전체를 읽은 뒤 정수로 바꿉니다. 문자열Scanner에" [email protected] \n50\n"을 넣어 결과를 확인하세요. new Scanner("7\nkim\n")에서nextInt(),nextLine(),nextLine()을 차례로 호출하면 각 호출은 무엇을 돌려줄까요? 실행하기 전에 먼저 예상해 보세요.MemberRegistryInput의 입력을"register [email protected] 50\nregister [email protected]\nquit\n"으로 바꾸면 무엇이 출력될까요? 첫 줄에는 공백이 여러 칸 들어 있습니다.
해설 보기
- 두 줄을 모두
nextLine()으로 읽고, 나이 줄만Integer.parseInt로 바꿉니다.
import java.util.Scanner;
public final class TwoLineMember {
public static void main(String[] args) {
Scanner scanner = new Scanner(" [email protected] \n50\n");
String email = scanner.nextLine().strip();
int age = Integer.parseInt(scanner.nextLine().strip());
System.out.println("email=" + email);
System.out.println("age=" + age);
}
}[email protected]
age=50첫 nextLine()은 " [email protected] "을 읽고 줄바꿈을 소비하며, strip()이 앞뒤 공백을 지웁니다. 둘째 nextLine()은 새 줄의 맨 앞에서 시작해 "50"을 읽습니다. 토큰 메서드를 섞지 않았으므로 남는 줄바꿈이 없습니다.
-
nextInt()는7, 첫nextLine()은 빈 문자열, 둘째nextLine()은kim을 돌려줍니다.nextInt()가7만 읽고 그 뒤의 줄바꿈을 남겼기 때문에, 첫nextLine()은 그 줄바꿈 앞의 빈 내용을 읽고 줄바꿈을 소비합니다. 그제야 둘째nextLine()이kim줄을 읽습니다. 이메일을 읽으려던 코드라면 이메일 자리에 빈 문자열이 들어갔을 것입니다. -
출력은 다음과 같습니다.
[email protected]:50
usage: register <email> <age>
bye첫 줄은 공백이 여러 칸이어도 "\\s+"가 이어진 공백을 하나의 구분자로 보므로 register, [email protected], 50 세 조각으로 나뉘어 정상 처리됩니다. 둘째 줄은 조각이 두 개뿐이라 parts.length != 3이 참이 되고, parts[2]를 읽기 전에 사용법을 출력한 뒤 다음 줄로 넘어갑니다. 셋째 줄 quit에서 bye를 출력하고 끝납니다.
다음 글
입력 검증과 재입력 반복에서는 forty 같은 잘못된 숫자를 try/catch로 붙잡아 프로그램을 끝내지 않고 다시 입력받게 만들고, 범위 검사와 종료 명령, 메뉴 반복을 하나의 대화형 회원가입으로 묶습니다.