Chapter 11

Java 컬렉션과 문자열

크기가 변하는 인벤토리를 List, Set, Map으로 관리하고 String 비교, 분리, StringBuilder 사용법을 배웁니다.

난이도
초급
예상 학습
55분
Java 기준
Java 25 LTS
최종 검토

전체 22개 Chapter 확장 구조 중 Chapter 11입니다.

기능 구분

정식 Java 25에서도 쓸 수 있는 이전 버전 기능

Java SE 정식 API

List, Set, Map, String API는 Java 25에서도 그대로 쓰는 표준 라이브러리입니다.

이 Chapter의 예제는 Preview 또는 Incubator API 실행 옵션 없이 Java 25 LTS 정식 기능 기준으로 읽을 수 있습니다.

Learning goals

오늘 배울 내용

  • 배열과 컬렉션의 차이 설명하기
  • List, Set, Map을 목적에 맞게 선택하기
  • equals로 문자열 내용 비교하기
  • split과 StringBuilder로 문자열 처리하기

크기가 변하는 데이터 다루기

배열은 길이가 고정되어 있어 슬롯 수가 정해진 경우 간단합니다. 하지만 아이템을 계속 추가하고 제거한다면 컬렉션이 편리합니다. Java의 대표 컬렉션은 목적이 서로 다릅니다.

  • List: 순서가 있고 중복을 허용합니다.
  • Set: 같은 값을 한 번만 보관합니다.
  • Map: key와 value를 짝으로 보관합니다.

구현 클래스로는 ArrayList, HashSet, HashMap을 자주 사용합니다. 변수 타입은 인터페이스인 List, Set, Map으로 선언하면 구현을 바꾸기 쉽습니다.

인벤토리와 발견 지역 관리

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        List<String> inventory = new ArrayList<>();
        inventory.add("Torch");
        inventory.add("Bread");
        inventory.add("Torch");

        Set<String> discoveredAreas = new HashSet<>();
        discoveredAreas.add("Forest");
        discoveredAreas.add("Cave");
        discoveredAreas.add("Forest");

        Map<String, Integer> itemCounts = new HashMap<>();
        for (String item : inventory) {
            itemCounts.merge(item, 1, Integer::sum);
        }

        System.out.println("인벤토리 슬롯: " + inventory.size());
        System.out.println("발견 지역 수: " + discoveredAreas.size());
        System.out.println("Torch 개수: " + itemCounts.get("Torch"));
    }
}

예상 출력:

인벤토리 슬롯: 3
발견 지역 수: 2
Torch 개수: 2

List에는 Torch가 두 번 들어가지만 Set의 Forest는 한 번만 남습니다. Map의 merge는 key가 없으면 1을 넣고, 있으면 기존 값과 1을 더합니다.

문자열은 내용으로 비교하기

명령어를 처리할 때는 equals를 사용합니다.

public class Main {
    public static void main(String[] args) {
        String command = new String("build");

        System.out.println(command == "build");
        System.out.println(command.equals("build"));
    }
}

예상 출력:

false
true

첫 비교는 객체 참조를, 두 번째 비교는 글자 내용을 확인합니다. 고정 문자열 쪽에서 "build".equals(command)로 호출하면 command가 null이어도 안전합니다.

명령어 분리와 조립

public class Main {
    public static void main(String[] args) {
        String commandLine = "place stone 4";
        String[] parts = commandLine.split(" ");

        StringBuilder result = new StringBuilder();
        result.append("동작=").append(parts[0]);
        result.append(", 블록=").append(parts[1]);
        result.append(", 개수=").append(parts[2]);

        System.out.println(result);
    }
}

예상 출력:

동작=place, 블록=stone, 개수=4

split은 구분자를 기준으로 문자열 배열을 만듭니다. 반복해서 문자열을 이어 붙일 때는 매번 새 String을 만드는 +보다 StringBuilder가 의도를 잘 드러내고 효율적입니다.

컬렉션 선택 질문

순서가 필요한가, 중복을 허용하는가, 이름으로 값을 찾아야 하는가를 차례로 물어보세요. 파티원 대기열은 List, 방문한 지역 목록은 Set, 아이템별 개수는 Map이 자연스럽습니다.

직접 해보기

  1. 인벤토리에서 Bread를 제거하고 크기를 다시 출력합니다.
  2. 아이템 개수 Map에 Map을 1개 추가합니다.
  3. give Alex torch 문자열을 나누어 대상과 아이템을 출력합니다.
Map 힌트

itemCounts.put("Map", 1);로 직접 넣거나 itemCounts.merge("Map", 1, Integer::sum);을 사용할 수 있습니다.

null과 없는 key

Map에 key가 없으면 get은 null을 반환할 수 있습니다. 숫자로 바로 계산하려면 getOrDefault("Key", 0)을 사용해 기본값을 정하세요.

컬렉션은 인터페이스로 생각하기

Java 컬렉션을 배울 때 ArrayList, HashSet, HashMap 이름을 먼저 외우면 종류가 많아 보여 부담스럽습니다. 더 좋은 순서는 목적을 먼저 고르는 것입니다. 순서가 중요하고 중복이 가능하면 List, 중복을 없애고 포함 여부를 확인하려면 Set, 이름이나 ID로 값을 찾으려면 Map입니다. 그다음에 실제 구현 클래스를 선택합니다.

변수 타입을 인터페이스로 두는 이유도 여기에 있습니다.

List<String> inventory = new ArrayList<>();
Set<String> discoveredAreas = new HashSet<>();
Map<String, Integer> itemCounts = new HashMap<>();

왼쪽은 “이 변수를 어떻게 사용할 것인가”를 말하고, 오른쪽은 “어떤 구현으로 시작할 것인가”를 말합니다. 나중에 삽입 순서를 유지해야 해서 HashSetLinkedHashSet으로 바꾸더라도, 바깥 코드가 Set의 기능만 사용했다면 변경이 쉽습니다. 구현 클래스의 특수 기능에 기대기 시작하면 교체가 어려워집니다.

List를 사용할 때의 판단 기준

List는 순서와 인덱스가 있는 목록입니다. 대기열, 랭킹, 입력 순서가 중요한 로그처럼 “몇 번째인가”가 의미 있는 데이터에 잘 맞습니다. 같은 값이 여러 번 들어갈 수 있다는 점도 중요합니다. 인벤토리에 Torch가 2개 들어 있는 상황을 표현하려면 List가 자연스럽습니다.

List<String> inventory = new ArrayList<>();
inventory.add("Torch");
inventory.add("Bread");
inventory.add("Torch");

System.out.println(inventory.get(0));
System.out.println(inventory.contains("Bread"));

인덱스 접근은 편리하지만 잘못된 인덱스를 사용하면 IndexOutOfBoundsException이 발생합니다. 반복문에서 index <= inventory.size()처럼 등호를 넣으면 마지막에 존재하지 않는 칸을 접근하게 됩니다. 인덱스가 필요 없으면 향상된 for문을 사용하는 편이 안전합니다.

for (String item : inventory) {
    System.out.println(item);
}

반복 중에 List를 직접 수정하는 것은 주의해야 합니다. 순회하면서 삭제해야 할 때는 새 목록을 만들거나, Iterator를 사용하거나, Stream과 filter를 배운 뒤 조건에 맞는 결과를 만드는 방식이 더 안전합니다.

Set은 중복 제거 이상의 의미가 있습니다

Set은 같은 값을 하나만 저장합니다. 그래서 방문한 지역, 이미 처리한 주문 ID, 권한 목록처럼 “있다 또는 없다”가 핵심인 데이터에 적합합니다. contains로 포함 여부를 빠르게 확인할 수 있다는 점도 자주 쓰입니다.

Set<String> unlockedBadges = new HashSet<>();
unlockedBadges.add("FIRST_BUILD");
unlockedBadges.add("FIRST_BUILD");

if (unlockedBadges.contains("FIRST_BUILD")) {
    System.out.println("첫 건축 배지를 이미 획득했습니다.");
}

HashSet은 일반적으로 반복 순서를 보장하지 않습니다. 지금 실행했을 때 특정 순서로 보였더라도 그 순서에 의존하면 안 됩니다. 화면에 입력 순서대로 보여야 한다면 LinkedHashSet, 정렬된 순서가 필요하면 TreeSet 같은 구현을 검토할 수 있습니다. 다만 처음에는 순서가 필요한 데이터인지 먼저 질문하세요. 순서가 필요하면 Set보다 List가 더 자연스러울 수도 있습니다.

직접 만든 객체를 Set에 넣을 때는 equalshashCode 규칙이 중요합니다. 같은 내용의 객체를 같은 값으로 볼지, 서로 다른 객체로 볼지 정해야 하기 때문입니다. 이 과정은 초급 이후에 다뤄도 늦지 않습니다. 지금은 문자열과 숫자처럼 표준 타입으로 Set의 감각을 익히세요.

Map은 작은 데이터베이스처럼 다루기

Map은 key로 value를 찾는 구조입니다. 아이템 이름으로 개수를 찾거나, 사용자 ID로 프로필을 찾거나, 설정 이름으로 값을 찾을 때 적합합니다. key는 중복될 수 없고, 같은 key에 값을 다시 넣으면 이전 값이 바뀝니다.

Map<String, Integer> itemCounts = new HashMap<>();
itemCounts.put("Torch", 1);
itemCounts.put("Torch", 2);

System.out.println(itemCounts.get("Torch"));

출력은 2입니다. “추가”가 아니라 “해당 key의 값을 교체”했기 때문입니다. 개수를 누적하려면 기존 값을 읽어 더하거나 merge를 사용합니다.

itemCounts.merge("Bread", 1, Integer::sum);
itemCounts.merge("Bread", 1, Integer::sum);
System.out.println(itemCounts.get("Bread"));

없는 key를 읽으면 null이 나올 수 있습니다. int count = itemCounts.get("Map");처럼 바로 기본형에 대입하면 null을 int로 바꿀 수 없어 예외가 발생합니다. 기본값이 필요하면 getOrDefault를 사용하세요.

int mapCount = itemCounts.getOrDefault("Map", 0);

문자열은 불변입니다

Java의 String은 한 번 만들어지면 내용이 바뀌지 않습니다. text += "!"처럼 보이는 코드도 기존 문자열을 고치는 것이 아니라 새 문자열을 만들어 변수에 다시 연결합니다. 짧은 예제에서는 문제가 되지 않지만, 반복문 안에서 수백 번 이어 붙이면 불필요한 문자열 객체가 많이 생깁니다.

String message = "Hello";
message = message + " Java";

첫 번째 "Hello" 문자열이 "Hello Java"로 바뀐 것이 아닙니다. 새 문자열이 만들어지고 message가 새 값을 가리키게 됩니다. 그래서 반복 조립에는 StringBuilder를 사용합니다.

StringBuilder builder = new StringBuilder();
for (String item : inventory) {
    builder.append("- ").append(item).append(System.lineSeparator());
}
System.out.print(builder);

StringBuilder는 내부 버퍼에 내용을 추가하므로 반복 조립 의도가 분명합니다. 단, 단순한 한두 번의 문자열 결합까지 무조건 StringBuilder로 바꿀 필요는 없습니다. 읽기 쉬운 쪽을 선택하세요.

문자열 비교와 정규화

명령어를 처리할 때는 입력값이 사용자의 손에서 오기 때문에 공백과 대소문자 문제가 자주 생깁니다. " build ""build"는 그대로 비교하면 다릅니다. 명령어라면 비교 전에 trim이나 strip, toLowerCase로 정규화할 수 있습니다.

String command = " Build ".strip().toLowerCase();

if ("build".equals(command)) {
    System.out.println("건축 명령을 실행합니다.");
}

"build".equals(command)처럼 고정 문자열을 왼쪽에 두면 command가 null이어도 false가 될 뿐 예외가 나지 않습니다. null이 가능한 값인지 모를 때는 이 패턴이 안전합니다.

문자열을 나눌 때 split(" ")은 공백 하나만 기준으로 동작합니다. 공백이 여러 개 섞일 수 있으면 정규식 \\s+를 사용할 수 있습니다.

String[] parts = "give   Alex   torch".strip().split("\\s+");

정규식은 강력하지만 처음에는 읽기 어려울 수 있습니다. 복잡한 파싱이 필요하면 작은 예제를 만들어 입력과 결과를 확인하면서 사용하세요.

컬렉션과 문자열을 함께 쓰는 예제

다음 예제는 명령어 문자열 목록을 읽어 아이템 개수를 누적합니다.

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        List<String> commands = List.of(
            "add torch",
            "add bread",
            "add torch",
            "remove bread"
        );

        Map<String, Integer> counts = new HashMap<>();

        for (String command : commands) {
            String[] parts = command.split(" ");
            String action = parts[0];
            String item = parts[1];

            if ("add".equals(action)) {
                counts.merge(item, 1, Integer::sum);
            } else if ("remove".equals(action)) {
                counts.merge(item, -1, Integer::sum);
            }
        }

        System.out.println(counts);
    }
}

이 코드는 짧지만 실무적 질문을 여러 개 담고 있습니다. 명령어 형식이 잘못되면 parts[1]에서 예외가 날 수 있습니다. 제거 결과가 음수가 되어도 되는지도 정해야 합니다. 출력 순서가 중요하면 HashMap 대신 다른 구현이 필요할 수 있습니다. 컬렉션을 제대로 쓰려면 자료구조 선택뿐 아니라 데이터 규칙까지 함께 생각해야 합니다.

반복문과 Stream 사이의 선택

Modern Java에서는 Stream을 사용해 컬렉션을 처리할 수 있습니다. 하지만 컬렉션을 처음 배울 때는 for문으로 흐름을 분명히 이해하는 것이 좋습니다. Stream은 필터링, 변환, 집계가 읽기 좋게 이어질 때 빛납니다. 중간에 상태를 여러 번 바꾸거나 예외 처리가 복잡하면 평범한 반복문이 더 명확할 수 있습니다.

선택 기준은 “팀원이 한눈에 의도를 읽을 수 있는가”입니다. 단순히 최신 문법이라서 Stream을 쓰는 것이 아니라, 데이터 흐름을 더 잘 표현할 때 사용하세요. 다음 Chapter 이후에 예외와 Modern Java를 배우면 이 선택 기준이 더 선명해집니다.

이번 장의 정리

컬렉션은 데이터를 담는 통만이 아니라 데이터의 사용 방식을 표현합니다. 순서와 중복이 중요하면 List, 포함 여부와 유일성이 중요하면 Set, key로 값을 찾아야 하면 Map을 고릅니다. 문자열은 불변이며, 내용 비교에는 equals를 사용하고 반복 조립에는 StringBuilder를 고려합니다. 컬렉션과 문자열 처리는 거의 모든 Java 프로그램의 기본 체력이므로, 작은 예제에서 자료구조 선택 이유를 말로 설명하는 연습을 해 두세요.

FAQ

자주 묻는 질문

ArrayList는 배열보다 항상 좋은가요?

크기가 변하고 추가·삭제가 잦으면 편리하지만, 길이가 고정되고 기본형을 간단히 다룰 때는 배열이 더 직접적입니다.

HashSet과 HashMap의 순서는 보장되나요?

반복 순서에 의존하면 안 됩니다. 삽입 순서가 필요하면 LinkedHashSet이나 LinkedHashMap 같은 목적에 맞는 구현을 검토하세요.

문자열 비교에 ==를 쓰면 왜 위험한가요?

==는 두 변수가 같은 객체를 가리키는지 비교합니다. 글자 내용이 같은지는 equals로 비교해야 합니다.

작성·편집 ToolPado

콘텐츠 유형 Java 학습 가이드

Java 기준 Java 25 LTS

마지막 검토