예외 처리(Exception Handling)
프로그램을 만들거나 실행하다 보면 우리는 수없이 많은 에러와 마주하게 됩니다. 이렇게 에러가 발생하면 프로그램은 의도한 대로 작동하지 않거나 심각한 경우 비정상적으로 종료되게 됩니다.
이번 챕터에서는 자바에서 개발자가 프로그램을 만들면서 필연적으로 마주하게 되는 오류를 어떻게 효과적으로 핸들링할 수 있는지 자세히 알아보도록 하겠습니다.
본격적인 학습에 앞서, 먼저 다음의 학습 목표를 통해 이번 챕터의 학습 내용을 개략적으로 확인해 봅시다.
학습 목표
- 프로그래밍에서 예외 처리가 무엇인지 이해할 수 있다.
- 컴파일 에러와 런타임 에러의 차이를 이해하고 설명할 수 있다.
- 자바 예외 클래스의 상속 계층도를 통해 클래스 간 관계를 이해할 수 있다.
- 자바의 핵심적인 예외 처리 방법인 try-catch 문과 예외 전가에 대해 이해하고 설명할 수 있다.
- throws 키워드와 throw 키워드의 차이에 관해 설명할 수 있다.
예외 처리란?
앞서 언급한 것처럼, 개발자는 프로그램을 만들고 실행하는 과정에서 필연적으로 수많은 에러와 마주하게 됩니다.
이렇게 특정 원인에 의해서 에러가 발생하면 프로그램은 종종 원래 개발자가 의도한 대로 작동하지 않고 비정상적으로 종료되게 됩니다.
이번 챕터에서는 이처럼 예기치 않게 발생하는 에러에 대응할 수 있는 코드를 미리 사전에 작성하여 프로그램의 비정상적인 종료를 방지하고, 정상적인 실행 상태를 유지하기 위한 자바의 “예외 처리(Exception Handling)”에 대해 학습해 보도록 하겠습니다.
그렇다면 컴퓨터 프로그래밍에서 에러가 발생하는 이유가 무엇일까요?
에러가 발생하는 원인은 수없이 다양하지만, 다음의 몇 가지 예를 들 수 있습니다.
- 사용자의 입력 오류
- 네트워크 연결 끊김
- 디스크 메모리 공간 부족 등 물리적 한계
- 개발자의 코드 에러
- 존재하지(유효하지) 않는 파일 불러오기
위의 원인을 다시 크게 내부적인 요인과 외부적인 요인으로 구분할 수 있습니다.
먼저 대표적인 외부 요인으로 하드웨어의 문제, 네트워크의 연결 끊김, 사용자 조작 오류 등이 있고, 내부 요인으로는 대표적으로 주로 오늘 우리가 초점을 맞춰 학습하게 될 개발자의 코드 작성 에러를 언급할 수 있습니다.
그럼, 개발자가 프로그래밍하면서 발생시키기 쉬운 대표적인 몇 가지 오류를 몇 가지만 살펴보겠습니다.
인텔리제이를 열어서 다음의 코드를 입력해 보겠습니다.
public class ErrorTest {
public static void main(String[] args) {
BufferedReader notExist = new BufferedReader(new FileReader("없는 파일"));
notExist.readLine();
notExist.close();
}
}
위의 코드는 존재하지 않는 파일을 실행하고자 시도하고 있습니다.
실제로 코드를 실행하면 다음과 유사한 에러 메시지를 확인할 수 있습니다.
java: unreported exception java.io.FileNotFoundException; must be caught or declared to be thrown
FileNotFoundException이라는 이름에서 확인할 수 있는 것처럼, 실제로 존재하지 않는 파일을 불러오려 시도할 때 발생하는 예외입니다.
이제 다시 앞서 입력한 내용을 지우고 아래의 코드를 입력해 보도록 합니다.
public class ErrorTest {
public static void main(String[] args) {
int[] array = {2,4,6};
System.out.println(array[3]);
}
}
위의 코드는 배열의 범위 밖에 있는 값을 불러오고자 시도하고 있습니다. 그리고 해당 코드를 실행해 보면, 예상대로 아래와 같은 에러가 발생합니다.
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
at ErrorTest.main(ErrorTest.java:4)
이 에러는 ArrayIndexOutOfBoundsException이라는 이름에서 어렵지 않게 유추할 수 있듯이 배열의 범위를 벗어난 값을 불러오고자 시도할 때 발생하는 예외입니다.
많은 경우, 에러가 발생한 이유는 이렇게 발생한 에러 메시지를 확인하면 어렵지 않게 유추할 수 있습니다.
여기서 또 한 가지 눈여겨보아야 할 점이 있습니다.
앞서 우리가 실행해 본 두 가지 코드 모두 실행했을 때 에러가 발생하지만, 첫 번째 코드는 실행하기도 전에 우리의 IDE가 빨간색 줄을 그어 무언가 잘못되었음을 알려주는 한편, 두 번째 코드에서는 아무런 경고 없이 실제로 코드를 실행한 시점에 도달해서야 에러 발생을 알리고 있습니다.
결론적으로 말하자면, 첫 번째 코드와 두 번째 코드는 그 에러 발생 시점이 다르다고 할 수 있습니다.
이처럼 자바에서는 발생 시점에 따라 에러를 다시 컴파일 에러(Compile Time Error)와 런타임 에러(Run Time Error)로 구분하고 있습니다.
엄밀히 따지자면, 코드에 논리적인 오류가 있으면 발생하는 논리적 에러(Logical Error)까지 세 가지로 구분할 수 있지만 이번 학습에서 배울 핵심적인 내용이 아니기 때문에 생략하도록 합니다.
그럼, 다음 콘텐츠에서 컴파일 에러와 런타임 에러에 대해 좀 더 살펴보도록 하겠습니다.
컴파일 에러와 런타임 에러
컴파일 에러
먼저, 컴파일 에러는 이름 그대로 “컴파일할 때" 발생하는 에러를 가리킵니다.
주로 세미콜론 생략, 오탈자, 잘못된 자료형, 잘못된 포맷 등 문법적인 문제를 가리키는 신택스(syntax) 오류로부터 발생하기 때문에 신택스 에러(Syntax Errors)라고 부르기도 합니다.
컴파일 에러는 자바 컴파일러가 오류를 감지하여, 사용자에게 친절하게 알려주기 때문에 상대적으로 쉽게 발견하고 수정할 수 있습니다.
이전 콘텐츠에서 봤었던 첫 번째 코드에서 IDE가 친절하게 빨간 줄을 그어준 부분도 자바 컴파일러가 사전에 발생할 수 있는 에러를 발견하여 미리 주의를 준 것이라 이해할 수 있습니다.
이렇게 친절하고 손쉽게 발견할 수 있는 까닭에 개발자들 사이에서는 **“가장 좋은 에러는 컴파일 에러다"**라는 말이 있을 정도입니다.
다른 컴파일 에러의 예제를 하나만 더 살펴보도록 하겠습니다. 아래 예제를 인텔리제이를 켜서 한번 입력해 볼까요?
public class ErrorTest {
public static void main(String[] args) {
int i;
for (i= 1; i<= 5; i++ {
System.out.println(i);
}
}
}
위의 코드를 바르게 입력했다면, 아마 실행하기도 전에 여러분들의 인텔리제이가 빨간 줄로 친절하게 에러의 위치를 표시해 주는 것을 확인할 수 있었을 것입니다.
어디가 문제였나요?
for 문의 조건을 설정하는 곳에서 닫는 괄호가 빠진 매우 간단한 문제였습니다. 당연히 해당 코드를 실행했을 때도 에러가 발생하며 아래와 같은 문구를 확인할 수 있습니다.
java: ')' expected
이처럼 컴파일 에러는 상대적으로 발견하기도 쉽고, 어렵지 않게 해결할 수 있습니다.
런타임 에러
런타임 에러 또한 이름에서 손쉽게 유추할 수 있는 것처럼 코드를 실행하는 과정, 즉 런타임 시에 발생하는 에러를 가리킵니다. 즉, 프로그램이 실행될 때 만나게 되는 에러입니다. 마찬가지로 간단하게 코드로 살펴보겠습니다.
여러분들의 인텔리제이에서 다음의 코드를 입력해 보세요.
public class RuntimeErrorTest {
public static void main(String[] args) {
System.out.println(4 * 4);
System.out.println(4 / 0); // 예외 발생
}
}
//출력값
16
Exception in thread "main" java.lang.ArithmeticException: / by zero
at RuntimeErrorTest.main(RuntimeErrorTest.java:5)
위의 코드 예제를 입력해 보면 겉으로 보기에는 아무런 문제가 없는 것처럼 보입니다.
하지만 위의 코드를 실행해 보면, 아래 출력값에서 확인할 수 있듯이 ArithmeticException 예외가 발생합니다.
참고로, ArithmeticException 은 특정 숫자를 0으로 나눴을 때 발생하는 예외입니다.
컴파일 에러가 주로 문법 요소와 관련한 신택스(syntax) 에러였다면 런타임 에러는 주로 개발자가 컴퓨터가 수행할 수 없는 특정한 작업을 요청할 때 발생합니다.
나아가, 컴파일 에러가 자바 컴파일러에 의해 발견되는 것이었다면, 런타임 에러는 프로그램이 실행될 때 우리가 뒤에서 좀 더 자세히 배우게 될 자바 가상 머신(JVM)에 의해 감지됩니다.
에러와 예외
사실 지금까지는 넓은 의미에서 프로그램 실행 시 발생할 수 있는 모든 문제를 에러(error)라 불렀지만, 사실 엄밀한 의미에서 자바에서는 코드 실행(run-time) 시 잠재적으로 발생할 수 있는 프로그램 오류를 크게 에러(error)와 예외(exception)으로 구분하고 있습니다.
여기서 에러란 한번 발생하면 복구하기 어려운 수준의 심각한 오류를 의미하고, 대표적으로 메모리 부족(OutOfMemoryError)과 스택오버플로우(StackOverflowError) 등이 있습니다.
반면 예외는 잘못된 사용 또는 코딩으로 인한 상대적으로 미약한 수준의 오류로서 코드 수정 등을 통해 수습이 가능한 오류를 지칭합니다.
여기서 오늘 우리가 초점을 맞춰 학습할 오류는 주로 개발자에 의해 핸들링이 가능한 상대적으로 미약한 수준의 오류인 예외(Exception)와 관련된 것입니다.
예외 클래스의 상속 계층도
자바에서는 예외가 발생하면 예외 클래스로부터 객체를 생성하여 해당 인스턴스를 통해 예외 처리를 합니다.
자바의 모든 에러와 예외 클래스는 아래 이미지에서 확인할 수 있듯이 Throwable 클래스로부터 확장되며, 모든 예외의 최고 상위클래스는 Exception 클래스입니다.
모든 예외의 최고 상위 클래스인 Exception 클래스는 다시 크게 일반 예외 클래스와 실행 예외 클래스로 나눌 수 있습니다.
일반 예외 클래스(Exception)
런타임 시 발생하는 RuntimeException 클래스와 그 하위 클래스를 제외한 모든 Exception 클래스와 그 하위 클래스들을 가리킵니다.
컴파일러가 코드 실행 전에 예외 처리 코드 여부를 검사한다고 하여 checked 예외라 부르기도 합니다.
주로 잘못된 클래스명(ClassNotFoundException)이나 데이터 형식(DataFormatException) 등 사용자편의 실수로 발생하는 경우가 많습니다.
실행 예외 클래스(Runtime Exception)
앞서 언급한 런타임 시 발생하는 RuntimeException 클래스와 그 하위클래스를 지칭합니다.
컴파일러가 예외 처리 코드 여부를 검사하지 않는다는 의미에서 unchecked 예외라 부르기도 합니다.
주로 개발자의 실수로 발생하는 경우가 많고, 자바 문법 요소와 관련이 있습니다.
예컨대, 클래스 간 형변환 오류(ClassCastException), 벗어난 배열 범위 지정(ArrayIndexOutOfBoundsException), 값이 null인 참조변수 사용(NullPointerException) 등이 있습니다.
그럼, 이제 자바에서 이렇게 발생할 수 있는 다양한 예외들을 어떻게 처리할 수 있는지 살펴보도록 하겠습니다.
try - catch문
앞서 설명한 것처럼, 예외 처리란 잠재적으로 발생할 수 있는 비정상 종료나 오류에 대비하여 정상 실행을 유지할 수 있도록 처리하는 코드 작성 과정을 의미합니다.
자바에서 예외 처리는 try - catch 블록을 통해 구현할 수 있습니다. 그 기본적인 구조는 다음과 같습니다.
try {
// 예외가 발생할 가능성이 있는 코드를 삽입
}
catch (ExceptionType1 e1) {
// ExceptionType1 유형의 예외 발생 시 실행할 코드
}
catch (ExceptionType2 e2) {
// ExceptionType2 유형의 예외 발생 시 실행할 코드
}
finally {
// finally 블록은 옵셔널
// 예외 발생 여부와 상관없이 항상 실행
}
먼저, try 블록 안에는 예외가 발생할 가능성이 있는 코드를 삽입합니다.
만약에 작성한 코드가 예외 없이 정상적으로 실행되면 아래 catch 블록은 실행되지 않고 finally 블록이 실행됩니다.
finally 블록은 옵션으로 필수적으로 포함되지 않아도 되지만, 만약 포함될 때는 예외 발생 여부와 상관없이 항상 실행되게 됩니다.
catch 블록은 예외가 발생할 때 실행되는 코드로, 여러 종류의 예외를 처리할 수 있습니다.
모든 예외를 받을 수 있는 Exception 클래스 하나로 처리도 가능하며, 각기 다른 예외를 하나 이상의 catch 블록을 사용하여 처리할 수 있습니다.
만약 catch 블록이 여러 개인 경우, 일치하는 하나의 catch 블록만이 실행되고 예외처리 코드가 종료되거나 finally 블록으로 넘어가게 됩니다.
만약에 일치하는 블록을 찾지 못할 때는 예외는 처리되지 못합니다.
다음의 예제 코드를 통해서 좀 더 알아보겠습니다.
public class RuntimeExceptionTest {
public static void main(String[] args) {
System.out.println("[소문자 알파벳을 대문자로 출력하는 프로그램]");
printMyName("abc"); // (1)
printMyName(null); // (2) 넘겨주는 매개변수가 null인 경우 NullPointerException 발생
System.out.println("[프로그램 종료]");
}
static void printMyName(String str) {
String upperCaseAlphabet = str.toUpperCase();
System.out.println(upperCaseAlphabet);
}
}
//출력값
[소문자 알파벳을 대문자로 출력하는 프로그램]
ABC //(3) 정상 실행
Exception in thread "main" java.lang.NullPointerException // (4) 예외 발생!
at RuntimeExceptionTest.printMyName(RuntimeExceptionTest.java:11)
at RuntimeExceptionTest.main(RuntimeExceptionTest.java:6)
위의 코드 예제는 프로그램을 실행하면 소문자 알파벳을 대문자로 바꿔주는 매우 간단한 프로그램의 코드입니다.
printMyName 메서드를 먼저 확인해보 면, 문자열 타입의 매개변수 str을 받아 대문자로 변환해 준 다음에 해당 문자열을 출력하고 있습니다.
이러한 순서에 따라 (1) 번의 코드 라인에서 printMyName 메서드를 호출하면, (3) 번의 출력값에서 확인할 수 있는 것처럼, 해당 문자열이 대문자로 바뀌어 잘 출력되는 것을 확인할 수 있습니다.
하지만 (2) 번의 코드 라인에서 동일한 메서드를 호출하면 어떻게 될까요?
넘겨주는 매개변수의 값이 null이기 때문에 해당 메서드가 호출이 되면 (4) 번의 출력값처럼 NullPointerException 예외가 발생하게 됩니다.
이런 경우 우리는 앞서 배웠던 try-catch 문을 사용하여 발생할 수 있는 예외 처리를 할 수 있습니다. 아래 코드를 한번 보도록 하겠습니다.
public class RuntimeExceptionTest {
public static void main(String[] args) {
try {
System.out.println("[소문자 알파벳을 대문자로 출력하는 프로그램]");
printMyName(null); // (1) 예외 발생
printMyName("abc"); // 이 코드는 실행되지 않고 catch 문으로 이동
}
catch (ArithmeticException e) {
System.out.println("ArithmeticException 발생!"); // (2) 첫 번째 catch문
}
catch (NullPointerException e) { // (3) 두 번째 catch문
System.out.println("NullPointerException 발생!");
System.out.println("e.getMessage: " + e.getMessage()); // (4) 예외 정보를 얻는 방법 - 1
System.out.println("e.toString: " + e.toString()); // (4) 예외 정보를 얻는 방법 - 2
e.printStackTrace(); // (4) 예외 정보를 얻는 방법 - 3
}
finally {
System.out.println("[프로그램 종료]"); // (5) finally문
}
}
static void printMyName(String str) {
String upperCaseAlphabet = str.toUpperCase();
System.out.println(upperCaseAlphabet);
}
}
// 출력값
[소문자 알파벳을 대문자로 출력하는 프로그램]
NullPointerException 발생!
e.getMessage: null
e.toString: java.lang.NullPointerException
[프로그램 종료]
java.lang.NullPointerException
at RuntimeExceptionTest.printMyName(RuntimeExceptionTest.java:20)
at RuntimeExceptionTest.main(RuntimeExceptionTest.java:7)
위의 코드는 try-catch 문을 사용하여 예외처리를 하는 과정을 잘 보여줍니다. 하나씩 순차적으로 살펴보도록 하겠습니다.
(1) 예외 발생
먼저, try 문 안에서 순차적으로 잘 실행되는 코드가 null 값을 매개변수로 넘긴 printMyName 메서드가 호출되는 부분에서 예외가 발생했습니다.
따라서 다음 코드 라인인 printMyName("abc")는 호출되지 않고 예외가 발생한 시점에 catch 문으로 넘어갑니다.
(2) 첫 번째 catch문
발생한 예외가 NullPointerException인데, 첫 번째 catch 문에서 조건으로 받는 예외는 ArithmeticException이므로 예외처리가 되지 않고 그냥 지나갑니다.
참고로 이때 검사는 instanceOf 연산자를 통해 생성된 예외 클래스의 인스턴스가 조건과 일치하는지를 판단합니다.
한 가지 유의할 점은, 예외가 발생하면 catch 블록은 위에서부터 순차적으로 검사를 진행하기 때문에, 구체적인 예외 클래스인 하위클래스를 먼저 위에 배치하여 상위 예외 클래스가 먼저 실행되지 않도록 방지하는 것이 좋습니다.
마치 우리가 자바 기초에서 조건문을 배울 때 구체적인 범위의 조건을 먼저 정의해 줬던 것과 같습니다.
(3) 두 번째 catch문
발생한 예외와 일치하는 조건이므로 해당 catch 문의 코드블록이 순차적으로 실행됩니다.
(4) 번에서 확인할 수 있는 것처럼 예외가 발생할 때 생성되는 예외 객체로부터 해당 에러에 대한 정보를 얻을 수 있는데, 크게 세 가지의 방법이 있습니다.
각각의 방법이 어떤 차이를 가지는지 좀 더 자세한 내용이 궁금하다면 구글링을 통해 알아보세요.
(4) finally 문
앞서 언급했던 것처럼 finally 문은 꼭 포함되어야 하는 문법은 아니지만, 만약에 포함이 되어있다면 예외 발생 여부와 관계없이 무조건 실행되게 됩니다.
예외 전가
앞서 배운 try-catch 문 외에 예외를 호출한 곳으로 다시 예외를 떠넘기는 방법도 있습니다.
이를 위해서는 메서드의 선언부 끝에 아래와 같이 throws 키워드와 발생할 수 있는 예외들을 쉼표로 구분하여서 나열해 주면 됩니다.
반환타입 메서드명(매개변수, ...) throws 예외클래스1, 예외클래스2, ... {
...생략...
}
예를 들면, 특정 메서드에서 모든 종류의 예외가 발생할 가능성이 있는 경우 아래와 같이 작성할 수 있습니다.
void ExampleMethod() throws Exception {
}
이 경우, Exception 클래스는 모든 예외 클래스의 상위 클래스이기 때문에 그 하위 클래스 타입의 예외 클래스들이 모두 포함되게 됩니다.
참고로 우리가 코드를 실행할 때 사용하는 main() 메서드에서도 throws 키워드를 사용해서 예외를 넘길 수 있는데, 이 경우 자바 JVM이 최종적으로 예외의 내용을 콘솔에 출력하여 예외 처리를 수행합니다.
아래 예제 코드를 한번 살펴보도록 하겠습니다.
public class ThrowExceptionTest {
public static void main(String[] args) {
try {
throwException();
} catch (ClassNotFoundException e) {
System.out.println(e.getMessage());
}
}
static void throwException() throws ClassNotFoundException, NullPointerException {
Class.forName("java.lang.StringX");
}
}
//출력값
java.lang.StringX
위의 코드 예제를 살펴보면, 먼저 try-catch 문 안에서 throwException 메서드가 호출되지만, 잘못된 코드 작성으로 인한 런타임 에러가 발생합니다.
이렇게 예외가 발생한 경우, 앞서서 우리가 사용했던 예외 처리 방식은 잠재적으로 예외가 발생할 수 있는 곳에서 try-catch 문을 작성하여 예외 처리를 해주었습니다.
하지만 위의 코드의 경우에는 앞서 설명한 것처럼 throws 키워드를 사용하여 해당 예외를 발생한 메서드 안에서 처리하지 않고 메서드를 호출한 곳으로 다시 떠넘기고 있습니다.
따라서 이제 예외 처리의 책임은 throwException 메서드가 아닌 main 메서드가 지게 되었습니다.
예외를 의도적으로 발생시키기
마지막으로 throws 키워드와 유사한 throw 키워드를 사용하면 의도적으로 예외를 발생시킬 수 있다는 점도 기억해 두면 좋습니다. throw 키워드를 사용해서 의도적으로 예외를 만드는 방법은 다음과 같습니다.
public class ExceptionTest {
public static void main(String[] args) {
try {
Exception intendedException = new Exception("의도된 예외 만들기");
throw intendedException;
} catch (Exception e) {
System.out.println("고의로 예외 발생시키기 성공!");
}
}
}
//출력값
고의로 예외 발생시키기 성공!
이렇게 자바에서는 개발자의 실수 등으로 예기치 않게 발생할 수 있는 에러에 대응할 수 있는 코드를 미리 사전에 작성하여 프로그램의 오작동과 비정상적인 종료를 방지하는 예외 처리 방법을 제공하고 있습니다.
컬렉션 프레임워크(Collection Framework)
Java에서는 데이터를 저장하기 위해 널리 알려진 자료 구조를 바탕으로 객체들을 효율적으로 추가, 삭제, 검색할 수 있도록 컬렉션을 만들고, 관련된 인터페이스와 클래스를 포함해 두었습니다. 이를 총칭하여 컬렉션 프레임워크(Collection Framework)라고 부릅니다.
컬렉션 프레임워크가 제공하는 다양한 인터페이스와 구현 클래스 활용하면, 보다 객체 지향적이고 재사용성 높은 코드를 작성할 수 있습니다.
학습 목표
- 컬렉션 프레임워크의 핵심 인터페이스를 이해하고 사용할 수 있다.
- 주요 인터페이스와 컬렉션 클래스의 핵심 메서드를 사용할 수 있다.
- 필요에 따라 어떤 인터페이스와 컬렉션 클래스를 사용하는 것이 적합한지 결정할 수 있다.
컬렉션 프레임워크
컬렉션이란 여러 데이터의 집합을 의미합니다. 즉, 여러 데이터를 그룹으로 묶어놓은 것을 컬렉션이라고 하며, 이러한 컬렉션을 다루는 데에 있어 편리한 메서드들을 미리 정의해 놓은 것을 컬렉션 프레임워크라고 합니다.
컬렉션 프레임워크는 특정 자료 구조에 데이터를 추가하고, 삭제하고, 수정하고, 검색하는 등의 동작을 수행하는 편리한 메서드들을 제공해 줍니다.
컬렉션 프레임워크의 구조
컬렉션 프레임워크는 주요 인터페이스로 List, Set, Map을 제공합니다. 각각의 인터페이스를 요약하면 아래와 같습니다.
List
- List는 데이터의 순서가 유지되며, 중복 저장이 가능한 컬렉션을 구현하는 데에 사용됩니다.
- ArrayList, Vector, Stack, LinkedList 등이 List 인터페이스를 구현합니다.
Set
- Set은 데이터의 순서가 유지되지 않으며, 중복 저장이 불가능한 컬렉션을 구현하는 데에 사용됩니다.
- HashSet, TreeSet 등이 Set 인터페이스를 구현합니다.
Map
- Map은 키(key)와 값(value)의 쌍으로 데이터를 저장하는 컬렉션을 구현하는 데에 사용됩니다.
- 데이터의 순서가 유지되지 않으며, 키는 값을 식별하기 위해 사용되므로 중복 저장이 불가능하지만, 값은 중복 저장이 가능합니다.
- HashMap, HashTable, TreeMap, Properties 등
이 셋 중에서 List와 Set은 서로 공통점이 많아 위 그림과 같이 Collection이라는 인터페이스로 묶입니다. 즉, 이 둘의 공통점이 추출되어 추상화된 것이 바로 Collection이라는 인터페이스입니다.
Collection 인터페이스
Collection 인터페이스에는 아래와 같은 메서드들이 정의되어 있습니다. 이후 List와 Set을 학습하면서, 이들의 구현 클래스가 어떻게 Collection 인터페이스의 메서드들을 구현하고 있는지 확인해 보세요.
List
List 인터페이스는 배열과 같이 객체를 일렬로 늘어놓은 구조로 되어 있습니다. 객체를 인덱스로 관리하기 때문에 객체를 저장하면 자동으로 인덱스가 부여되고, 인덱스로 객체를 검색, 추가, 삭제할 수 있는 등의 여러 기능을 제공합니다.
List 인터페이스에서 공통으로 사용할 수 있는 메서드는 다음과 같습니다. 앞서 살펴본 컬렉션 인터페이스의 메서드 또한 상속받아 사용할 수 있습니다.
List 인터페이스를 구현한 클래스로는 ArrayList, Vector, LinkedList, Stack 등이 있습니다. 이 중에서 가장 중요하며 많이 사용되는 ArrayList와 LinkedList에 대해서 천천히 살펴봅시다.
ArrayList
ArrayList는 List 인터페이스를 구현한 클래스로, 컬렉션 프레임워크에서 가장 많이 사용됩니다. 기능적으로는 Vector와 동일하지만, 기존의 Vector를 개선한 것이므로, Vector보다는 주로 ArrayList를 사용합니다.
ArrayList에 객체를 추가하면 객체가 인덱스로 관리된다는 점에서는 배열과 유사합니다. 그러나 배열은 생성될 때 크기가 고정되며, 크기를 변경할 수 없는 반면, ArrayList는 저장 용량을 초과하여 객체들이 추가되면, 자동으로 저장용량이 늘어나게 됩니다. 또한, 리스트 계열 자료구조의 특성을 이어받아 데이터가 연속적으로 존재합니다. 즉, 데이터의 순서를 유지합니다.
ArrayList를 생성하기 위해서는 저장할 객체 타입을 타입 매개변수, 즉 제네릭으로 표기하고 기본 생성자를 호출합니다. 예를 들면 다음과 같습니다.
ArrayList<타입 매개변수> 객체명 = new ArrayList<타입 매개변수>(초기 저장 용량);
ArrayList<String> container1 = new ArrayList<String>();
// String 타입의 객체를 저장하는 ArrayList 생성
// 초기 용량이 인자로 전달되지 않으면 기본적으로 10으로 지정됩니다.
ArrayList<String> container2 = new ArrayList<String>(30);
// String 타입의 객체를 저장하는 ArrayList 생성
// 초기 용량을 30으로 지정하였습니다.
ArrayList에 객체를 추가하면 인덱스 0부터 차례대로 저장됩니다. 그리고 특정 인덱스의 객체를 제거하면, 바로 뒤 인덱스부터 마지막 인덱스까지 모두 앞으로 1씩 당겨집니다.
따라서 빈번한 객체 삭제와 삽입이 일어나는 곳에서는 ArrayList보다는 이후에 배우게 되는 LinkedList를 사용하는 것이 좋습니다.
다음은 ArrayList에 String 객체를 추가, 검색, 삭제하는 예제입니다.
public class ArrayListExample {
public static void main(String[] args) {
// ArrayList를 생성하여 list에 할당
ArrayList<String> list = new ArrayList<String>();
// String 타입의 데이터를 ArrayList에 추가
list.add("Java");
list.add("egg");
list.add("tree");
// 저장된 총 객체 수 얻기
int size = list.size();
// 0번 인덱스의 객체 얻기
String skill = list.get(0);
// 저장된 총 객체 수 만큼 조회
for(int i = 0; i < list.size(); i++){
String str = list.get(i);
System.out.println(i + ":" + str);
}
// for-each문으로 순회
for (String str: list) {
System.out.println(str);
}
// 0번 인덱스 객체 삭제
list.remove(0);
}
}
대부분 List 인터페이스에 정의된 메서드들을 구현하고 있는 것을 확인할 수 있습니다.
LinkedList
LinkedList 컬렉션은 데이터를 효율적으로 추가, 삭제, 변경하기 위해 사용합니다. 배열에는 모든 데이터가 연속적으로 존재하지만, LinkedList에는 불연속적으로 존재하며, 이 데이터는 서로 연결(link)되어 있습니다.
이를 알기 쉽게 그림으로 표현하면 다음과 같습니다.
그림을 통해 알 수 있듯, LinkedList의 각 요소(node)들은 자신과 연결된 이전 요소 및 다음 요소의 주소값과 데이터로 구성되어 있습니다.
LinkedList에서 데이터를 삭제하려면, 삭제하고자 하는 요소의 이전 요소가 삭제하고자 하는 요소의 다음 요소를 참조하도록 변경하면 됩니다. 링크를 끊어주는 방식이라고 생각하면 됩니다. 배열처럼 데이터를 이동하기 위해 복사할 필요가 없기 때문에 처리 속도가 훨씬 빠릅니다.
데이터를 추가할 때도 마찬가지로, 새로운 요소를 추가하고자 하는 위치의 이전 요소와 다음 요소 사이에 연결해 주면 됩니다. 즉, 이전 요소가 새로운 요소를 참조하고, 새로운 요소가 다음 요소를 참조하게 만드는 것입니다.
LinkedList를 활용한 예제를 살펴봅시다.
public class LinkedListExample {
public static void main(String[] args) {
// Linked List를 생성하여 list에 할당
LinkedList<String> list = new LinkedList<>();
// String 타입의 데이터를 LinkedList에 추가
list.add("Java");
list.add("egg");
list.add("tree");
// 저장된 총 객체 수 얻기
int size = list.size();
// 0번 인덱스의 객체 얻기
String skill = list.get(0);
// 저장된 총 객체 수 만큼 조회
for(int i = 0; i < list.size(); i++){
String str = list.get(i);
System.out.println(i + ":" + str);
}
// for-each문으로 순회
for (String str: list) {
System.out.println(str);
}
// 0번 인덱스 객체 삭제
list.remove(0);
}
}
ArrayList와 LinkedList 차이
먼저, ArrayList의 특성부터 살펴봅시다.
위 그림은 ArrayList에서 데이터를 추가하는 상황을 나타낸 그림입니다. ArrayList에서 데이터를 추가 또는 삭제하려면 그림과 같이 다른 데이터를 복사해서 이동해야 합니다.
ArrayList에 객체를 순차적으로 저장할 때는 데이터를 이동하지 않아도 되므로 작업 속도가 빠르지만, 중간에 위치한 객체를 추가 및 삭제할 때는 데이터 이동이 많이 일어나므로 속도가 저하됩니다.
반면 인덱스가 n인 요소의 주소값을 얻기 위해서는 배열의 주소 + n * 데이터 타입의 크기를 계산하여 데이터에 빠르게 접근이 가능하기 때문에 검색(읽기) 측면에서는 유리합니다.
즉, ArrayList는 다음과 같은 상황에 강점을 지닙니다.
- 데이터를 순차적으로 추가하거나 삭제하는 경우
- 순차적으로 추가한다는 것은 0번 인덱스에서부터 데이터를 추가하는 것을 의미합니다.
- 순차적으로 삭제한다는 것은 마지막 인덱스에서부터 데이터를 삭제하는 것을 의미합니다.
- 데이터를 불러오는 경우
- 인덱스를 통해 바로 데이터에 접근할 수 있으므로 검색이 빠릅니다.
다음과 같은 상황에서 ArrayList는 효율적이지 못합니다.
- 중간에 데이터를 추가하거나, 중간에 위치하는 데이터를 삭제하는 경우
- 추가 또는 삭제 시, 해당 데이터의 뒤에 위치한 값들을 뒤로 밀어주거나 앞으로 당겨주어야 합니다.
이제 LinkedList의 특성을 살펴봅시다.
위 그림의 Apple과 Orange 사이에 Mango라는 데이터를 추가하는 상황을 가정해 봅시다. 이때, 내부적으로 다음과 같은 동작이 이루어집니다.
- Mango 객체가 생성됩니다.
- Apple의 Next에 Mango의 주소값이 저장됩니다.
- 이때, Mango의 Prev에 Apple의 주소값이 저장됩니다.
- Mango의 Next에 Orange의 주소값이 저장됩니다.
- 이때, Orange의 Prev에 Mango의 주소값이 저장됩니다.
이처럼 LinkedList의 중간에 데이터를 추가하면, Next와 Prev에 저장되어 있는 주소값만 변경해 주면 되므로, 각 요소를 ArrayList처럼 뒤로 밀어내지 않아도 됩니다. 마찬가지로, 중간에 위치한 데이터를 삭제하는 경우에도 삭제한 데이터의 뒤에 위치하는 요소들을 앞으로 당기지 않아도 됩니다.
따라서 데이터를 중간에 추가하거나 삭제하는 경우, LinkedList는 ArrayList보다 빠른 속도를 보여줍니다.
하지만 데이터 검색할 때는 시작 인덱스에서부터 찾고자 하는 데이터까지 순차적으로 각 노드에 접근해야 하므로 데이터 검색에 있어서는 ArrayList보다 상대적으로 속도가 느립니다.
LinkedList가 강점을 가지는 상황은 다음과 같습니다.
- 중간에 위치하는 데이터를 추가하거나 삭제하는 경우
- 데이터를 중간에 추가하는 경우, Prev와 Next의 주소값만 변경하면 되므로, 다른 요소들을 이동시킬 필요가 없습니다.
결론적으로, 데이터의 잦은 변경이 예상된다면 LinkedList를, 데이터의 개수가 변하지 않는다면 ArrayList를 사용하는 것이 좋습니다.
Iterator
Iterator는 직역하면 반복자라는 의미가 있으며, 컬렉션에 저장된 요소들을 순차적으로 읽어오는 역할을 합니다.
이러한 Iterator의 컬렉션 순회 기능은 Iterator 인터페이스에 정의되어 있으며, Collection 인터페이스에는 Iterator 인터페이스를 구현한 클래스의 인스턴스를 반환하는 메서드인 iterator()가 정의되어 있습니다.
즉, Collection 인터페이스에 정의된 iterator()를 호출하면, Iterator 타입의 인스턴스가 반환됩니다.
따라서 Collection 인터페이스를 상속받는 List와 Set 인터페이스를 구현한 클래스들은 iterator() 메서드를 사용할 수 있습니다.
다음은 Iterator 인터페이스에 정의된 메서드로, iterator()를 통해 만들어진 인스턴스는 아래의 메서드를 사용할 수 있습니다.
Iterator를 활용하여 컬렉션의 객체를 읽어올 때는 next() 메서드를 사용합니다. next() 메서드를 사용하기 전에는 먼저 가져올 객체가 있는지 hasNext()를 통해 확인하는 것이 좋습니다.
hasNext() 메서드는 읽어올 다음 객체가 있으면, true를 리턴하고, 더 이상 가져올 객체가 없으면 false를 리턴합니다. 따라서 true가 리턴될 때만 next() 메서드가 동작하도록 코드를 작성해야 합니다.
다음은 List에서 String 객체들을 반복해서 하나씩 가져오는 코드 예제입니다.
ArrayList<String> list = ...;
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()) { // 읽어올 다음 객체가 있다면
String str = iterator.next(); // next()를 통해 다음 객체를 읽어옵니다.
...
}
Iterator를 사용하지 않더라도, for-each문을 이용해서 전체 객체를 대상으로 반복할 수 있습니다.
ArrayList<String> list = ...;
for(String str : list) {
...
}
next() 메서드로 가져온 객체를 컬렉션에서 제거하고 싶다면 remove() 메서드를 호출하면 됩니다. next() 메서드는 컬렉션의 객체를 그저 읽어오는 메서드로, 실제 컬렉션에서 객체를 빼내는 것은 아닙니다. 하지만, remove() 메서드는 컬렉션에서 실제로 객체를 삭제합니다.
ArrayList<String> list = ...;
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()){ // 다음 객체가 있다면
String str = iterator.next(); // 객체를 읽어오고,
if(str.equals("str과 같은 단어")){ // 조건에 부합한다면
iterator.remove(); // 해당 객체를 컬렉션에서 제거합니다.
}
}
Set
수학에서 Set은 집합을 의미합니다. 집합은 일반적으로 중복된 값을 허용하지 않습니다. 자바 컬렉션에서의 Set은 수학에서의 집합과 비슷합니다.
Set은 요소의 중복을 허용하지 않고, 저장 순서를 유지하지 않는 컬렉션입니다. 대표적인 Set을 구현한 클래스에는 HashSet, TreeSet이 있습니다.
Set 인터페이스에 정의된 메서드들은 다음과 같습니다.
HashSet
HashSet은 Set 인터페이스를 구현한 가장 대표적인 컬렉션 클래스입니다. 따라서, Set 인터페이스의 특성을 그대로 물려받으므로 중복된 값을 허용하지 않으며, 저장 순서를 유지하지 않습니다.
HashSet에 값을 추가할 때, 해당 값이 중복된 값인지 어떻게 판단할까요? 그 과정을 간단하게 설명하면 아래와 같습니다.
- add(Object o)를 통해 객체를 저장하고자 합니다.
- 이때, 저장하고자 하는 객체의 해시코드를 hashCode() 메서드를 통해 얻어냅니다.
- Set이 저장하고 있는 모든 객체의 해시코드를 hashCode() 메서드로 얻어냅니다.
- 저장하고자 하는 객체의 해시코드와, Set에 이미 저장되어 있던 객체들의 해시코드를 비교하여, 같은 해시코드가 있는지 검사합니다.
- 이때, 만약 같은 해시코드를 가진 객체가 존재한다면 아래의 5번으로 넘어갑니다.
- 같은 해시코드를 가진 객체가 존재하지 않는다면, Set에 객체가 추가되며 add(Object o) 메서드가 true를 리턴합니다.
- equals() 메서드를 통해 객체를 비교합니다.
- true가 리턴된다면 중복 객체로 간주하여 Set에 추가되지 않으며, add(Object o)가 false를 리턴합니다.
- false가 리턴된다면 Set에 객체가 추가되며, add(Object o) 메서드가 true를 리턴합니다.
Set을 활용하는 간단한 예제를 살펴보겠습니다.
import java.util.*;
public class Main {
public static void main(String[] args) {
// HashSet 생성
HashSet<String > languages = new HashSet<String>();
// HashSet에 객체 추가
languages.add("Java");
languages.add("Python");
languages.add("Javascript");
languages.add("C++");
languages.add("Kotlin");
languages.add("Ruby");
languages.add("Java"); // 중복
// 반복자 생성하여 it에 할당
Iterator it = languages.iterator();
// 반복자를 통해 HashSet을 순회하며 각 요소들을 출력
while(it.hasNext()) {
System.out.println(it.next());
}
}
}
인텔리제이에서 출력값을 직접 확인해 보세요. 입력한 순서대로 출력되지 않고, “Java”를 두 번 추가했지만 한 번만 저장된 것을 확인할 수 있습니다.
TreeSet
TreeSet은 이진 탐색 트리 형태로 데이터를 저장합니다. 데이터의 중복 저장을 허용하지 않고 저장 순서를 유지하지 않는 Set 인터페이스의 특징은 그대로 유지됩니다.
이진 탐색 트리(Binary Search Tree)란 하나의 부모 노드가 최대 두 개의 자식 노드와 연결되는 이진트리(Binary Tree)의 일종으로, 정렬과 검색에 특화된 자료 구조입니다.
이때 최상위 노드를 **‘루트'**라고 합니다. 아래 그림에서 10이 바로 루트 노드에 해당합니다.
이진 탐색 트리는 모든 왼쪽 자식의 값이 루트나 부모보다 작고, 모든 오른쪽 자식의 값이 루트나 부모보다 큰 값을 가지는 특징이 있습니다.
이러한 자료 구조를 구현하기 위한 의사 코드는 다음과 같습니다. 위 그림의 각 노드들은 아래 Node 클래스를 인스턴스화한 인스턴스에 해당합니다.
class Node {
Object element; // 객체의 주소값을 저장하는 참조변수 입니다.
Node left; // 왼쪽 자식 노드의 주소값을 저장하는 참조변수입니다.
Node right; // 오른쪽 자식 노드의 주소값을 저장하는 참조변수입니다.
}
간단한 예제를 살펴보겠습니다.
import java.util.TreeSet;
public class TreeSetExample {
public static void main(String[] args) {
// TreeSet 생성
TreeSet<String> workers = new TreeSet<>();
// TreeSet에 요소 추가
workers.add("Lee Java");
workers.add("Park Hacker");
workers.add("Kim Coding");
System.out.println(workers);
System.out.println(workers.first());
System.out.println(workers.last());
System.out.println(workers.higher("Lee"));
System.out.println(workers.subSet("Kim", "Park"));
}
}
출력값을 확인해 보면, 요소를 추가하기만 했음에도 불구하고, 자동으로 사전 편찬 순에 따라 오름차순으로 정렬된 것을 확인할 수 있습니다. 이는 TreeSet의 기본 정렬 방식이 오름차순이기 때문입니다. 이에 대해서는 이어지는 콘텐츠에서 학습합니다.
Map
Map 인터페이스는 키(key)와 값(value)으로 구성된 객체를 저장하는 구조로 되어 있습니다. 여기서 이 객체를 Entry 객체라고 하는데, 이 Entry 객체는 키와 값을 각각 Key 객체와 Value 객체로 저장합니다.
Map을 사용할 때 중요한 사실은 키는 중복으로 저장될 수 없지만, 값은 중복 저장이 가능하다는 것입니다. 이는 키의 역할이 값을 식별하는 것이기 때문입니다.
만약 기존에 저장된 키와 같은 키로 값을 저장하면, 기존의 값이 새로운 값으로 대치됩니다.
Map 인터페이스를 구현한 클래스에는 HashMap, Hashtable, TreeMap, SortedMap 등이 있습니다.
다음은 Map 인터페이스를 구현한 클래스에서 공통으로 사용할 수 있는 메서드입니다. List가 인덱스를 기준으로 관리되는 반면에, Map은 키(key)로 객체들을 관리하기 때문에 키를 매개값으로 갖는 메서드가 많습니다.
HashMap
HashMap은 Map 인터페이스를 구현한 대표적인 클래스입니다. HashMap은 아래 그림과 같이 키와 값으로 구성된 객체를 저장하는데, 이 객체를 Entry 객체라고 합니다.
HashMap은 해시 함수를 통해 '키'와 '값'이 저장되는 위치를 결정하므로, 사용자는 그 위치를 알 수 없고, 삽입되는 순서와 위치 또한 관계가 없습니다.
이렇게, HashMap은 이름 그대로 해싱(Hashing)을 사용하기 때문에 많은 양의 데이터를 검색하는 데 있어서 뛰어난 성능을 보입니다.
또한, HashMap의 개별 요소가 되는 Entry 객체는 Map 인터페이스의 내부 인터페이스인 Entry 인터페이스를 구현하며, Map.Entry 인터페이스에는 다음과 같은 메서드가 정의되어 있습니다.
이제 HashMap을 사용하는 방법을 익혀봅시다. HashMap을 생성할 때는 아래와 같이 키와 값의 타입을 따로 지정해주어야 합니다.
HashMap<String, Integer> hashmap = new HashMap<>();
바로 예제를 살펴봅시다. 아래에서 사용한 메서드가 어떻게 동작하는지, 그리고 각 메서드를 어떻게 사용하는지에 주목해 봅시다.
import java.util.*;
public class HashMapExample {
public static void main(String[] args) {
// HashMap 생성
HashMap<String, Integer> map = new HashMap<>();
// Entry 객체 저장
map.put("피카츄", 85);
map.put("꼬부기", 95);
map.put("야도란", 75);
map.put("파이리", 65);
map.put("피존투", 15);
// 저장된 총 Entry 수 얻기
System.out.println("총 entry 수: " + map.size());
// 객체 찾기
System.out.println("파이리 : " + map.get("파이리"));
// key를 요소로 가지는 Set을 생성 -> 아래에서 순회하기 위해 필요합니다.
Set<String> keySet = map.keySet();
// keySet을 순회하면서 value를 읽어옵니다.
Iterator<String> keyIterator = keySet.iterator();
while(keyIterator.hasNext()) {
String key = keyIterator.next();
Integer value = map.get(key);
System.out.println(key + " : " + value);
}
// 객체 삭제
map.remove("피존투");
System.out.println("총 entry 수: " + map.size());
// Entry 객체를 요소로 가지는 Set을 생성 -> 아래에서 순회하기 위해 필요합니다.
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
// entrySet을 순회하면서 value를 읽어옵니다.
Iterator<Map.Entry<String, Integer>> entryIterator = entrySet.iterator();
while(entryIterator.hasNext()) {
Map.Entry<String, Integer> entry = entryIterator.next();
String key = entry.getKey(); // Map.Entry 인터페이스의 메서드
Integer value = entry.getValue(); // Map.Entry 인터페이스의 메서드
System.out.println(key + " : " + value);
}
// 객체 전체 삭제
map.clear();
}
}
Map은 키와 값을 쌍으로 저장하기 때문에 iterator()를 직접 호출할 수 없습니다. 그 대신 keySet()이나 entrySet() 메서드를 이용해 Set 형태로 반환된 컬렉션에 iterator()를 호출하여 반복자를 만든 후, 반복자를 통해 순회할 수 있습니다.
Hashtable
Hashtable은 HashMap과 내부 구조가 동일하며, 사용 방법 또한 매우 유사합니다. Hashtable과 HashMap의 차이점은 추후 학습할 스레드와 관련이 있는데, 아직 스레드에 대해서 학습하지 않았으니, 여기에서는 간단하게 HashMap이 Hashtable의 새로운 버전이라고 이해해 주세요.
아래는 Hashtable을 활용하여 로그인 기능을 구현해 보는 간단한 예제입니다. 인텔리제이에 직접 입력해 보고, 예제 코드를 실행해 보세요.
import java.util.*;
public class HashtableExample {
public static void main(String[] args){
Hashtable<String, String> map = new Hashtable<String, String>();
map.put("Spring", "345");
map.put("Summer", "678");
map.put("Fall", "91011");
map.put("Winter", "1212");
System.out.println(map);
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("아이디와 비밀번호를 입력해 주세요");
System.out.println("아이디");
String id = scanner.nextLine();
System.out.println("비밀번호");
String password = scanner.nextLine();
if (map.containsKey(id)) {
if (map.get(id).equals(password)) {
System.out.println("로그인 되었습니다.");
break;
}
else System.out.println("비밀번호가 일치하지 않습니다. ");
}
else System.out.println("입력하신 아이디가 존재하지 않습니다.");
}
}
}
컬렉션 선택 방법