Java 32
데이터 모음, 이제는 컬렉션!
배열도 여러 값을 담는데 왜 List와 Map을 새로 배우고, 꺾쇠 제네릭과 import까지 필요한지 부담스럽다.
근거 · 교안 p235-p243
1장: 배열의 한계와 컬렉션의 등장
솔라는 모니터에 코드를 띄워둔 채 미간을 찌푸렸다. 이제 막 세 개의 칸을 가진 상자를 만들어, 오늘 할 일들을 차곡차곡 정리해 넣은 참이었다.
String[] toDos = new String[3];
toDos[0] = "고양이 모래 사기";
toDos[1] = "스터디 과제 제출";
toDos[2] = "저녁에 조깅하기";
코드는 깔끔했다. 배열이라는 이름의 보관함에 할 일 목록이 잘 정리된 것 같아 만족스러웠다. 그런데 그때, 잊고 있던 할 일이 하나 떠올랐다. ‘아, 맞다! 은행 업무!’
솔라는 망설임 없이 키보드에 손을 올렸다. 네 번째 할 일을 추가하는 건 간단해 보였다.
toDos[3] = "은행 업무 보기"; // 네 번째 칸에 추가해 볼까?
엔터 키를 누르고 실행하는 순간, 콘솔 창에 붉은색 글씨가 가득 들어찼다. ArrayIndexOutOfBoundsException. 익숙하면서도 반갑지 않은 에러였다.
“아, 또 이거야!”
작게 터져 나온 짜증 섞인 목소리에, 거실에서 책을 읽던 언니 루나가 고개를 돌렸다. 솔라의 책상으로 다가온 루나는 복잡한 에러 메시지 대신, 솔라가 마지막에 추가한 코드 한 줄을 가만히 들여다보았다.
“새로운 할 일이 생겼나 보네.”
“응. 근데 왜 추가가 안 되지? 배열은 여러 데이터를 담는 상자라며. 세 개 담았으니까 네 번째 칸도 쓸 수 있는 거 아니야?”
솔라의 질문에는 억울함이 묻어났다. 분명 배열은 여러 값을 저장하기 위해 배운 것인데, 정작 필요할 때 값을 하나 더 넣을 수가 없다는 사실이 답답했다.
루나는 솔라의 코드 첫 줄을 손가락으로 가리켰다.
String[] toDos = new String[3];
“솔라, 네가 이 보관함을 처음 만들 때, 컴퓨터랑 뭐라고 약속했는지 기억나?”
“음… 문자열을 담을 거고, 이름은 toDos라고… 아.”
솔라는 [3]이라는 숫자를 보고 말끝을 흐렸다. 처음부터 ‘정확히 세 칸만 쓰겠다’고 선언한 기억이 떠올랐다. 약속을 어겼으니 컴퓨터가 화를 내는 건 당연했다.
“정확히 세 칸만 쓰기로 약속했구나. 그럼 네 번째 할 일을 넣으려면 어떻게 해야 할까?”
“음… 약속을 바꿔야지. 칸이 네 개인 새로운 배열을 만들어야 하나?”
솔라는 스스로 답을 찾고는 코드를 수정하기 시작했다. 기존 배열은 그대로 둔 채, 더 큰 새 배열을 만들었다.
String[] newToDos = new String[4];
하지만 곧 새로운 막막함이 찾아왔다. 새 상자를 만들었더니, 기존 상자에 들어있던 내용물들이 텅 비어 있었다. 솔라는 잠시 생각하더니, 하나씩 손으로 옮기듯 코드를 작성했다.
// 이전 배열의 내용을 새 배열로 복사
newToDos[0] = toDos[0];
newToDos[1] = toDos[1];
newToDos[2] = toDos[2];
// 이제야 새로운 할 일을 추가
newToDos[3] = "은행 업무 보기";
코드를 다 작성하고 나자 솔라는 허탈한 표정을 지었다. “와… 그냥 할 일 하나 추가하려고 새 배열 만들고, 예전 할 일들을 전부 이사시킨 셈이네. 너무 번거롭다.”
“그럼 이번엔 두 번째 할 일, ‘스터디 과제 제출’을 끝냈다고 치고 목록에서 빼볼까? 중간에 빈칸이 남지 않게.”
루나의 말에 솔라는 다시 한번 코드를 노려보았다. newToDos[1]을 지우고 나면 그 자리가 빌 것이다. 빈칸을 없애려면, 뒤에 있는 ‘조깅하기’와 ‘은행 업무’를 한 칸씩 앞으로 당겨와야 했다.
newToDos[1] = newToDos[2]; // 3번 칸 내용을 2번으로
newToDos[2] = newToDos[3]; // 4번 칸 내용을 3번으로
newToDos[3] = null; // 마지막 칸은 비우기
“알겠다!”
결과를 확인하기도 전에 솔라가 외쳤다. 그녀는 방금 자신이 겪은 두 가지 상황을 떠올렸다. 데이터를 하나 추가하기 위해 더 큰 배열을 만들어 일일이 복사해야 했던 번거로움. 중간 데이터를 하나 삭제하기 위해 뒤따라오던 모든 데이터의 위치를 직접 옮겨줘야 했던 수고.
“배열은 처음에 약속한 크기를 절대 바꿀 수 없구나. 그래서 중간에 뭔가를 넣거나 빼려면 내가 직접 데이터를 옮기는, 이 귀찮은 이사 작업을 전부 해줘야 하는 거였어. 이게 ‘유연하지 않다’는 뜻이었네.”
마침내 ‘유연성’이라는 단어의 의미를 몸으로 깨달은 솔라였다.
루나는 빙그레 웃으며 솔라의 모니터에 새로운 코드 창을 열었다.
“만약, 그런 이사 작업을 대신 해주는 똑똑한 도구 상자가 있다면 어떨까? 우리는 그냥 ‘이거 추가해줘’ 또는 ‘이거 빼줘’라고 말만 하면, 상자 크기를 늘리거나 줄이고 내용물 자리까지 알아서 정리해 주는 거야.”
루나는 키보드를 몇 번 두드려 짧은 코드를 보여주었다.
import java.util.ArrayList;
import java.util.List;
List<String> toDoList = new ArrayList<>();
toDoList.add("고양이 모래 사기");
toDoList.add("스터디 과제 제출");
toDoList.add("저녁에 조깅하기");
System.out.println("처음 목록: " + toDoList);
toDoList.add("은행 업무 보기"); // 그냥 추가!
System.out.println("추가 후: " + toDoList);
toDoList.remove("스터디 과제 제출"); // 이름으로 바로 제거!
System.out.println("제거 후: " + toDoList);
실행 결과는 놀라웠다.
처음 목록: [고양이 모래 사기, 스터디 과제 제출, 저녁에 조깅하기]
추가 후: [고양이 모래 사기, 스터디 과제 제출, 저녁에 조깅하기, 은행 업무 보기]
제거 후: [고양이 모래 사기, 저녁에 조깅하기, 은행 업무 보기]
솔라의 눈이 동그래졌다. “헐. add 하니까 그냥 들어가고, remove 하니까 알아서 중간 자리가 채워졌어. 내가 아까 했던 그 모든 복사 작업을 이 List라는 애가 다 알아서 해주는 거네!”
배열의 고정된 칸막이를 허물고, 필요에 따라 크기를 조절하며 데이터를 손쉽게 다루는 것. 솔라는 이것이 바로 컬렉션의 시작이라는 것을 직감했다. 답답했던 문제가 해결되자 새로운 궁금증이 고개를 들었다.
“언니, 그런데 이 코드는 좀 이상해. List라는 것도 있고 ArrayList라는 것도 있네. 그리고 아까 강의 노트에서 슬쩍 봤는데, Map이라는 것도 있던데? 데이터 모음이라면서 왜 이렇게 종류가 많아? List는 언제 쓰고, Map은 또 언제 쓰는 거야?”
2장: List와 Map, 언제 무엇을 사용할까?
솔라의 질문에 루나는 바로 대답하는 대신, 솔라의 책상에 있던 빈 메모지와 펜을 가져왔다. 그리고 메모지를 반으로 접어 두 개의 칸을 만들었다. 왼쪽 칸의 제목으로는 ‘오늘 할 일’, 오른쪽 칸의 제목으로는 ‘친구 연락처’라고 적었다. 두 개의 서로 다른 목록이 나란히 놓였다.
루나는 먼저 왼쪽 칸, ‘오늘 할 일’ 아래에 이렇게 적었다.
- 고양이 모래 사기
- 스터디 과제 제출
- 저녁에 조깅하기
- 은행 업무 보기
그리고는 오른쪽 칸, ‘친구 연락처’ 아래에도 내용을 채웠다.
- 김솔라: 010-1234-5678
- 박루나: 010-9876-5432
- 이코딩: 010-1111-2222
“솔라, 네가 아까 본 List와 Map이 바로 이 두 목록과 같아. 둘 다 여러 데이터를 담는 똑똑한 보관함이지만, 데이터를 정리하고 찾는 방식이 전혀 달라. 만약 이 두 목록을 코드로 관리한다면 어떻게 해야 할까?”
솔라는 메모지를 내려다보았다. 왼쪽의 ‘오늘 할 일’ 목록은 순서가 중요해 보였다. 먼저 할 일, 나중에 할 일. 반면 오른쪽의 ‘친구 연락처’는 순서가 의미 없어 보였다. 중요한 것은 ‘김솔라’라는 이름으로 전화번호를 바로 찾는 것이었다.
“음, ‘오늘 할 일’ 목록은 방금 본 List를 쓰면 딱 맞을 것 같아. 데이터를 넣은 순서대로 착착 쌓이니까. 첫 번째 할 일을 가져오려면 get(0)을 쓰면 되고.”
솔라는 자신 있게 List를 사용한 코드를 작성하기 시작했다. 이전 장에서 봤던, 배열의 불편함을 해결해 주던 바로 그 코드였다.
import java.util.ArrayList;
import java.util.List;
List<String> toDoList = new ArrayList<>();
toDoList.add("고양이 모래 사기");
toDoList.add("스터디 과제 제출");
toDoList.add("저녁에 조깅하기");
// 두 번째 할 일은 뭐였지? 아, 인덱스는 0부터 시작하니까...
String secondTodo = toDoList.get(1);
System.out.println("두 번째 할 일: " + secondTodo); // "스터디 과제 제출"
결과는 예상대로였다. List는 순서대로 데이터를 저장하고, 그 순서(인덱스)를 이용해 데이터를 정확히 꺼내 왔다. 마치 번호표가 붙은 보관함 같았다.
“좋아. 그럼 오른쪽 ‘친구 연락처’는 어때? List로 한번 관리해 볼래?”
루나의 말에 솔라는 잠시 고민에 빠졌다. 이름과 전화번호, 두 개의 정보가 한 쌍을 이루고 있었다.
“어… 이것도 List에 다 넣어보면 되지 않을까?”
솔라는 약간의 억지를 부리며 코드를 짜기 시작했다.
List<String> contacts = new ArrayList<>();
contacts.add("김솔라");
contacts.add("010-1234-5678");
contacts.add("박루나");
contacts.add("010-9876-5432");
contacts.add("이코딩");
contacts.add("010-1111-2222");
// 여기서 '박루나'의 전화번호를 찾으려면...?
코드를 작성하던 솔라의 손가락이 멈칫했다. ‘박루나’의 전화번호를 찾으려면, 먼저 List 안에서 ‘박루나’라는 문자열을 찾아야 한다. 그리고 그 문자열이 2번 인덱스에 있으니, 전화번호는 바로 다음 칸인 3번 인덱스에 있을 거라고 ‘가정’해야만 했다. 만약 누가 중간에 데이터를 잘못 넣거나 순서를 바꾸면 모든 게 엉망이 될 터였다.
“이건 좀… 이상해. ‘박루나’의 번호를 찾고 싶을 뿐인데, ‘박루나’가 몇 번째에 있는지부터 알아내야 하잖아. 너무 번거롭고 실수하기도 쉬워.”
솔라는 스스로 만든 코드의 문제점을 정확히 짚어냈다. 루나는 기다렸다는 듯이 새로운 도구를 꺼내 보였다.
import java.util.HashMap;
import java.util.Map;
Map<String, String> contactMap = new HashMap<>();
contactMap.put("김솔라", "010-1234-5678");
contactMap.put("박루나", "010-9876-5432");
contactMap.put("이코딩", "010-1111-2222");
// '박루나'의 전화번호를 바로 찾아보자.
String lunaPhoneNumber = contactMap.get("박루나");
System.out.println("박루나의 연락처: " + lunaPhoneNumber); // "010-9876-5432"
솔라의 눈이 다시 한번 커졌다. put이라는 메서드로 이름과 전화번호를 함께 넣었고, get 메서드에 순서 번호 대신 ‘박루나’라는 이름을 직접 넣자 전화번호가 바로 튀어나왔다. 중간 과정을 모두 생략한 듯한 편리함이었다.
솔라는 두 개의 코드와 메모지 위의 두 목록을 번갈아 보았다. 그러다 무릎을 탁 쳤다.
“알았다! List와 Map의 차이는 데이터를 찾는 기준이었어! List는 순서, 즉 ‘몇 번째 데이터’인지가 중요한 선착순 줄 같은 거고, Map은 순서와 상관없이 ‘이름표’를 붙여두고 그 이름표로 바로 찾는 사전 같은 거네!”
List는 0, 1, 2… 라는 순서(index) 로 데이터를 찾는 보관함.
Map은 ‘김솔라’, ‘박루나’ 같은 고유한 이름표(Key) 로 데이터를 찾는 보관함.
데이터를 넣는 순서가 중요하고 그 순서대로 꺼내 써야 한다면 List를, 순서보다는 고유한 이름으로 데이터를 저장하고 빠르게 찾고 싶을 때는 Map을 써야 한다는 것을 깨달은 것이다. List는 ‘할 일 목록’처럼 순차적인 데이터에, Map은 ‘연락처’나 ‘사물함 비밀번호’처럼 이름과 값이 짝을 이루는 데이터에 어울렸다.
명쾌하게 정리가 되자, 코드의 다른 부분이 눈에 들어오기 시작했다.
“언니, 이제 List랑 Map은 확실히 구분하겠어. 그런데 아까부터 계속 거슬리는 게 있어. List<String>, Map<String, String>… 이 뾰족한 괄호 < >는 도대체 뭐야? 코드를 더 복잡하게만 만드는 것 같은데, 그냥 List list = new ArrayList() 이렇게 쓰면 안 되는 거야? 그리고 저 위에 import는 왜 자꾸 써야 하고?”
3장: 제네릭과 import, 꼭 써야 할까?
솔라는 List와 Map의 차이를 명확히 구분해주는 두 개의 예제 코드를 나란히 띄워놓고 만족스러운 미소를 지었다. 순서가 중요하면 List, 이름표가 중요하면 Map. 머릿속이 깔끔하게 정리되는 기분이었다. 하지만 그 명쾌함 속에서도, 코드의 한 부분이 계속 눈에 가시처럼 박혀 있었다.
List<String>, Map<String, String>
뾰족한 괄호, < >. 꼭 저렇게 타입을 명시해야만 할까? 코드를 더 복잡하게만 만드는 장식처럼 느껴졌다. 솔라는 ‘에이, 설마.’ 하는 마음으로 새로운 코드 창을 열었다. 그리고 자신의 가설을 증명해 보려는 듯, 의도적으로 뾰족 괄호를 빼고 코드를 작성하기 시작했다.
// 뾰족 괄호 없이, 더 간단하게!
List shoppingList = new ArrayList();
“이게 훨씬 보기 좋네.” 솔라는 혼잣말을 하며 쇼핑 목록을 채워나갔다. 이번에는 물건의 이름과 수량을 함께 담아보기로 했다.
shoppingList.add("계란");
shoppingList.add(30); // 계란 30개
shoppingList.add("우유");
shoppingList.add(1); // 우유 1개
코드는 아무런 문제 없이 작성되었다. 뾰족 괄호를 뺐더니 문자열(String)과 숫자(Integer)를 한 목록에 자유롭게 담을 수 있기까지 했다. 솔라는 의기양양한 표정으로 루나를 돌아보았다. “언니, 봐. 이렇게 뾰족 괄호 없어도 잘 되는데? 오히려 아무거나 다 담을 수 있어서 더 편한 것 같아.”
루나는 솔라가 작성한 코드를 조용히 들여다보았다. 반박하는 대신, 차분한 목소리로 물었다.
“좋아. 그럼 그 쇼핑 목록에서 두 번째로 넣은 물건을 꺼내서, 그게 뭔지 화면에 출력해 줄래?”
“두 번째 물건? 숫자 30이겠네. 쉽지.”
솔라는 자신만만하게 코드를 추가했다. 목록의 두 번째 요소를 꺼내려면 get(1)을 쓰면 된다.
// 두 번째 요소(인덱스 1)를 꺼내서 문자열 변수에 담아보자.
String item = (String) shoppingList.get(1);
System.out.println(item);
솔라는 코드를 실행하기 전에 잠시 멈칫했다. 꺼낸 데이터는 숫자 30이지만, 컴퓨터는 아직 그 사실을 모른다. shoppingList는 그저 ‘어떤 객체(Object)’를 담고 있을 뿐이기 때문이다. 그래서 (String)이라고 괄호를 붙여 “이건 문자열이니까 그렇게 알아들어!”라고 컴퓨터에게 강제로 알려줘야 했다. 조금 찝찝했지만, 일단 실행 버튼을 눌렀다.
결과는 처참했다. 콘솔에 또다시 붉은색 에러 메시지가 나타났다. java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String.
“아…!”
솔라는 짧은 탄식을 내뱉었다. 에러 메시지는 명확했다. ‘숫자(Integer)를 어떻게 문자열(String)로 바꾸라는 거야!’ 컴퓨터의 항의였다.
루나는 솔라의 첫 번째 코드, List<String> toDoList가 적힌 창을 가리켰다.
“아까 할 일 목록을 만들 때, 네가 List<String>이라고 썼던 건 컴퓨터와 한 약속이야. ‘이 목록에는 문자열만 담을게. 다른 건 절대 안 넣어.’ 라고 말이야.”
루나는 솔라의 ‘간단한’ 코드를 수정했다.
// <String> 이라는 약속을 추가하면 어떻게 될까?
List<String> saferShoppingList = new ArrayList<>();
saferShoppingList.add("계란");
saferShoppingList.add(30); // <- 여기서 바로 에러가 난다!
코드를 수정하자마자, add(30) 부분에 즉시 붉은 밑줄이 생겼다. 실행 버튼을 누르기도 전에, 코드를 작성하는 단계에서부터 “약속 위반이야! 여긴 문자열만 넣기로 했잖아!”라고 알려주는 것이었다.
그제야 솔라는 깨달았다. “아… 뾰족 괄호는 코드를 복잡하게 만드는 장식이 아니었구나. ‘이 보관함에는 이런 종류의 물건만 넣겠다’고 써 붙이는 ‘타입 명세서’ 같은 거였어. 그래서 다른 종류의 물건을 넣으려고 하면, 미리 막아주는 안전장치 역할을 하는 거구나.”
코드가 실행되는 도중에 터지는 폭탄(런타임 에러)을 설치하는 것보다, 코드를 짜는 단계에서부터 잘못된 부분을 바로 알려주는 것이 훨씬 안전하고 명확했다. 제네릭, 즉 뾰족 괄호는 바로 그 ‘타입 안전성’을 위한 중요한 약속이었던 것이다.
“하나 더. 그럼 이건 어때?”
솔라는 이제 import 문에 대한 궁금증을 해결할 차례라고 생각했다. 그녀는 코드 맨 위에 있던 import java.util.List;와 import java.util.ArrayList;를 과감하게 지워버렸다. 그러자 순식간에 List와 ArrayList라는 단어들에 빨간 밑줄이 그어졌다. ‘cannot find symbol’ 이라는 메시지와 함께.
“이건 또 왜? 컴퓨터가 List가 뭔지 모를 리가 없잖아. 방금까지 잘 썼는데.”
루나는 책상 위에서 서울 지도 한 장을 가져와 펼쳤다.
“내가 솔라 너한테 ‘서울에 있는 우리 동네 카페에서 만나’라고만 말하면, 찾아올 수 있겠어?”
“말도 안 돼. 서울에 카페가 몇 개고, 우리 동네가 어디인지도 모르는데. 최소한 ‘마포구 연남동’에 있는 ‘루나 카페’라고 주소는 알려줘야지.”
솔라는 당연하다는 듯 대답했고, 그 순간 자신의 말 속에 답이 있다는 것을 직감했다.
루나는 미소를 지으며 말했다. “맞아. 자바의 세계는 서울처럼 아주 넓은 도시야. 수많은 개발자들이 List라는 이름의 클래스를 만들었을 수도 있어. 그래서 그냥 List라고만 하면, 자바(컴파일러)는 대체 어느 동네에 있는 어느 List를 말하는 건지 알 수가 없는 거야.”
“import java.util.List; 라는 문장은, ‘내가 앞으로 코드에서 List라고 부르는 건, java라는 거대한 도시의 util이라는 동네에 있는 List라는 이름의 카페를 말하는 거야’ 라고 미리 알려주는 주소록 같은 거구나!”
import는 불필요한 반복을 줄여주는 약속이었다. 매번 java.util.List<String> list = new java.util.ArrayList<>(); 라고 전체 주소를 쓰는 대신, 맨 위에 한 번만 주소를 알려주고 코드 안에서는 List라는 간단한 이름만 사용하게 해주는 것이다.
모든 의문이 풀린 솔라는 다시 키보드에 손을 올렸다. 이제는 컬렉션을 자신 있게 다룰 수 있을 것 같았다. 그녀는 오늘 배운 내용들을 정리하기 위한 작은 용어 사전을 만들기로 했다. 이름과 뜻이 짝을 이루니 Map이 제격이었다.
import java.util.HashMap;
import java.util.Map;
// 나만의 개발 용어 사전 만들기
Map<String, String> myGlossary = new HashMap<>();
// 제네릭(<>)으로 타입 안전성 확보!
myGlossary.put("컬렉션", "데이터를 효율적으로 관리하고 조작하기 위한 객체들의 모음. 크기가 유연함.");
myGlossary.put("제네릭", "컬렉션에 저장할 데이터 타입을 미리 지정하여 타입 안전성을 높이는 기능. 뾰족 괄호<>를 사용.");
myGlossary.put("import", "다른 패키지에 있는 클래스를 사용하기 위해 경로를 지정해주는 구문. 코드의 가독성을 높여줌.");
// '제네릭'의 뜻을 찾아보자.
String definition = myGlossary.get("제네릭");
System.out.println("제네릭: " + definition);
코드를 작성하는 솔라의 손길에는 더 이상 망설임이 없었다. import 문을 먼저 작성하고, Map<String, String>으로 타입을 명확히 지정한 뒤, 데이터를 추가하고 조회했다. 배열의 한계에서 시작된 여정이, List와 Map의 용도를 구분하고, 제네릭과 import의 필요성까지 이해하는 것으로 마무리되고 있었다. 복잡하고 부담스럽게만 보였던 기호들이, 이제는 코드를 더 단단하고 명확하게 만들어주는 든든한 약속으로 보였다.