Skip to main content

9. 컬렉션 프레임워크와 자료 관리

프로그램을 만들다 보면 결국 이런 순간이 온다. 학생 목록을 저장해야 하고, 주문 상태를 모아야 하고, 회원 아이디로 사용자를 빨리 찾아야 한다. 화면은 화려해 보여도, 내부에서는 거의 항상 데이터를 담고 꺼내고 정리하는 일이 벌어진다. 자바 실전 프로그램의 대부분은 결국 자료를 어떻게 관리하느냐에 달려 있다고 해도 과장이 아니다.

지금까지는 변수, 배열, 클래스, 객체를 배우며 프로그램의 기본 뼈대를 세웠다. 이제는 그 뼈대 위에 실제 데이터를 효율적으로 쌓는 법을 배울 차례다. 이번 장의 주인공은 컬렉션 프레임워크다. 이름은 조금 거창하지만, 쉽게 말하면 데이터를 담는 상자들의 잘 정리된 모음이다.

컬렉션 프레임워크와 자료 관리

배열만으로는 왜 부족할까

배열은 자바를 처음 배울 때 아주 좋은 출발점이다. 같은 타입의 값을 묶어 관리할 수 있고, 인덱스로 빠르게 접근할 수 있다. 하지만 배열만으로 프로그램을 계속 만들다 보면 불편한 점이 금방 드러난다.

가장 큰 문제는 크기가 고정된다는 점이다. 학생 10명을 담으려고 배열을 만들었는데 갑자기 11번째 학생이 등록되면 어떻게 할까. 더 큰 배열을 다시 만들고 값을 복사해야 한다. 중간에 값을 삽입하거나 삭제할 때도 손이 많이 간다. 중복을 막고 싶은 경우, 키로 값을 찾고 싶은 경우도 배열만으로는 번거롭다.

배열이 교실의 고정 좌석표라면, 컬렉션은 상황에 따라 자리를 늘리고 줄이고 재배치할 수 있는 접수대라고 생각하면 쉽다.

컬렉션 프레임워크란 무엇인가

컬렉션 프레임워크는 여러 데이터를 저장하고 관리하기 위한 자바의 표준 도구 모음이다. 여기서 중요한 것은 단순히 클래스 몇 개를 외우는 것이 아니라, 데이터의 성격에 따라 알맞은 도구를 고르는 감각을 익히는 것이다.

자바의 컬렉션은 크게 세 갈래로 이해하면 된다.

  • List: 순서가 있고, 중복 저장이 가능하다.

  • Set: 중복을 허용하지 않는다.

  • Map: 키와 값의 쌍으로 저장한다.

그리고 이 자료들을 순회할 때 Iterator를 사용하고, 정렬 기준을 만들 때 Comparable과 Comparator를 사용한다. 여기에 제네릭까지 결합하면 "무엇을 담는 컬렉션인지"가 더 분명해지고 타입도 안전해진다.

또 하나 눈여겨볼 점은 인터페이스와 구현 클래스의 관계다. 예를 들어 다음 코드는 자바에서 아주 흔한 형태다.

List<String> names = new ArrayList<>();

왼쪽의 List는 "어떤 방식으로 다룰 것인가"를 나타내는 인터페이스이고, 오른쪽의 ArrayList는 실제 구현 클래스다. 즉, 사용하는 쪽에서는 큰 규칙인 List를 보고, 실제 동작은 ArrayList가 담당한다. 이전 장에서 배운 다형성이 여기서도 자연스럽게 등장한다.

List: 순서 있는 자료를 다루는 가장 친숙한 컬렉션

List는 가장 먼저 익혀야 할 컬렉션이다. 배열과 비슷하게 순서가 있고, 인덱스로 접근할 수 있으며, 같은 값도 여러 번 저장할 수 있다.

할 일 목록을 예로 들어 보자.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> todos = new ArrayList<>();

        todos.add("자바 복습");
        todos.add("컬렉션 예제 풀기");
        todos.add("자바 복습");

        System.out.println(todos.get(0));
        System.out.println(todos.size());
        System.out.println(todos);
    }
}

여기서는 자바 복습이 두 번 들어간다. List는 중복을 허용하기 때문이다. 또 get(0)처럼 인덱스로 접근할 수 있으므로 순서가 중요한 데이터에 잘 맞는다.

List 구현체 중 가장 자주 보는 것은 ArrayList다. 학습 단계는 물론 실무에서도 기본 선택지처럼 많이 쓰인다. 읽기와 끝에 추가하는 작업이 편하고 사용법도 직관적이기 때문이다.

LinkedList도 List의 구현체지만, 초반에는 무조건 둘을 비교하며 고민할 필요는 없다. 대부분의 경우는 ArrayList부터 시작해도 충분하다. 특별히 중간 삽입과 삭제가 아주 빈번한 구조가 아니라면, 초보 단계에서 ArrayList는 훌륭한 기본값이다.

Set: 중복을 막고 싶을 때 쓰는 컬렉션

어떤 데이터는 한 번만 존재해야 한다. 예를 들어 회원 아이디, 태그, 출석한 학생 이름 목록 같은 것들이다. 이런 상황에서 List를 쓰면 중복 검사를 직접 해야 한다. 반면 Set은 애초에 중복을 허용하지 않으므로 훨씬 자연스럽다.

import java.util.HashSet;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        Set<String> tags = new HashSet<>();

        tags.add("java");
        tags.add("collection");
        tags.add("java");

        System.out.println(tags.size());
        System.out.println(tags);
    }
}

위 코드에서 java를 두 번 추가했지만 실제로는 하나만 저장된다. 이것이 Set의 핵심이다.

초보자가 자주 놀라는 부분도 있다. HashSet은 입력한 순서를 보장하지 않는다. 따라서 출력 결과의 순서가 예상과 다를 수 있다. 중복 제거가 목적이라면 아주 유용하지만, 순서까지 꼭 지켜야 한다고 생각하면 당황할 수 있다.

정리하면 Set은 "순서보다 유일성"이 중요할 때 선택하는 도구다. 출석 체크에서 같은 사람이 두 번 출석하는 것을 막는 느낌이라고 보면 된다.

Map: 키로 값을 찾는 자료 관리의 핵심

Map은 List, Set과는 조금 다르다. 값을 하나씩 저장하는 것이 아니라, 키(key)와 값(value)을 한 쌍으로 저장한다. 예를 들어 학생 이름과 점수, 상품 코드와 상품 정보, 회원 아이디와 회원 객체를 연결할 때 아주 유용하다.

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

public class Main {
    public static void main(String[] args) {
        Map<String, Integer> scores = new HashMap<>();

        scores.put("김자바", 95);
        scores.put("이코드", 88);
        scores.put("박버그", 72);

        System.out.println(scores.get("김자바"));
        System.out.println(scores.containsKey("이코드"));
    }
}

Map에서는 키가 아주 중요하다. 키는 중복될 수 없고, 같은 키로 다시 put()하면 기존 값이 덮어써진다.

scores.put("김자바", 95);
scores.put("김자바", 100);

System.out.println(scores.get("김자바")); // 100

즉, Map은 "몇 번째에 있지?"보다 "이 이름에 해당하는 값이 뭐지?"처럼 검색할 때 강력하다. 전화번호부를 떠올리면 이해가 쉽다. 사람 이름이 키이고, 전화번호가 값인 셈이다.

Map의 모든 데이터를 순회할 때는 entrySet()을 자주 사용한다.

for (Map.Entry<String, Integer> entry : scores.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

이 방식은 키와 값을 함께 다루기 편해서 실무에서도 자주 보게 된다.

List, Set, Map은 언제 골라야 할까

컬렉션을 처음 배우면 가장 많이 나오는 질문이 이것이다. "그래서 뭘 써야 하죠?" 정답은 데이터의 성격을 먼저 보는 것이다.

  • 순서가 중요하고, 중복도 허용해야 하면 List

  • 중복을 막는 것이 가장 중요하면 Set

  • 어떤 기준값으로 빠르게 찾아야 하면 Map

예를 들어 수강 신청 순서를 보여 주는 목록은 List가 어울린다. 이미 등록한 학생인지 확인하는 용도는 Set이 잘 맞는다. 학번으로 학생 정보를 찾는 기능은 Map이 가장 자연스럽다.

컬렉션은 서로 경쟁하는 도구가 아니라, 역할이 다른 도구들이다. 망치와 드라이버 중 무엇이 더 좋은지 묻는 것과 비슷하다. 나사를 박을 때는 드라이버가, 못을 박을 때는 망치가 맞다.

Iterator: 데이터를 안전하게 순회하는 방법

컬렉션에 데이터가 들어 있으면 결국 하나씩 꺼내 보게 된다. 가장 익숙한 방법은 for-each 문이다.

for (String name : names) {
    System.out.println(name);
}

이 방식은 간단하고 읽기 쉽다. 하지만 순회 중에 요소를 삭제해야 하는 상황에서는 조심해야 한다. for-each를 돌면서 직접 remove()를 호출하면 예외가 발생할 수 있다.

이럴 때 쓰는 도구가 Iterator다. Iterator는 컬렉션을 한 칸씩 이동하며 값을 읽어 오는 객체다. 그리고 순회 중 안전하게 삭제하는 기능도 제공한다.

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("김");
        names.add("이자바");
        names.add("박");
        names.add("최코드");

        Iterator<String> iterator = names.iterator();

        while (iterator.hasNext()) {
            String name = iterator.next();

            if (name.length() <= 1) {
                iterator.remove();
            }
        }

        System.out.println(names);
    }
}

여기서 중요한 메서드는 세 가지다.

  • hasNext(): 다음 요소가 있는지 확인한다.

  • next(): 다음 요소를 꺼낸다.

  • remove(): 현재 요소를 안전하게 삭제한다.

초보 단계에서는 for-each를 기본으로 쓰되, 순회 중 삭제가 필요하면 Iterator를 떠올리는 습관을 들이면 좋다.

Comparable: 객체 스스로 기본 정렬 기준을 갖게 하기

숫자나 문자열은 기본적으로 비교가 가능하지만, 우리가 만든 클래스는 정렬 기준을 직접 알려 주어야 한다. 예를 들어 학생 객체를 점수순으로 정렬하려면 자바는 "무엇을 기준으로 앞뒤를 나눠야 하죠?"라고 묻는다.

이때 클래스 자체에 기본 정렬 기준을 심어 두는 방법이 Comparable이다.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

class Student implements Comparable<Student> {
    private String name;
    private int score;

    public Student(String name, int score) {
        this.name = name;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    @Override
    public int compareTo(Student other) {
        return Integer.compare(this.score, other.score);
    }

    @Override
    public String toString() {
        return name + "(" + score + ")";
    }
}

public class Main {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();
        students.add(new Student("김자바", 90));
        students.add(new Student("이코드", 75));
        students.add(new Student("박디버그", 82));

        Collections.sort(students);
        System.out.println(students);
    }
}

compareTo()는 비교 결과를 숫자로 돌려준다. 현재 객체가 더 작으면 음수, 같으면 0, 더 크면 양수를 반환하는 방식이다.

여기서는 점수 오름차순이 기본 정렬 기준이 된다. 즉, Comparable은 "이 객체의 대표 정렬 방식은 이것입니다"라고 선언하는 것에 가깝다.

Comparator: 상황에 따라 다른 정렬 기준을 주기

하지만 현실에서는 정렬 기준이 하나로 끝나지 않는다. 어떤 때는 점수순, 어떤 때는 이름순, 어떤 때는 점수 내림차순이 필요하다. 이때 기본 기준을 매번 클래스 안에 다 넣는 것은 무리다.

그래서 등장하는 것이 Comparator다. Comparator는 클래스 바깥에서 비교 규칙을 따로 만드는 방식이다.

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

class Student {
    private String name;
    private int score;

    public Student(String name, int score) {
        this.name = name;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    @Override
    public String toString() {
        return name + "(" + score + ")";
    }
}

public class Main {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();
        students.add(new Student("김자바", 90));
        students.add(new Student("이코드", 75));
        students.add(new Student("박디버그", 82));

        students.sort(new Comparator<Student>() {
            @Override
            public int compare(Student s1, Student s2) {
                return Integer.compare(s2.getScore(), s1.getScore());
            }
        });

        System.out.println(students);
    }
}

이 코드는 점수 내림차순으로 정렬한다. 높은 점수가 먼저 오게 되는 것이다.

둘의 차이를 한 문장으로 정리하면 이렇다.

  • Comparable: 객체가 스스로 가진 기본 정렬 규칙

  • Comparator: 필요할 때 바깥에서 주입하는 추가 정렬 규칙

학생증에 적힌 공식 학번 순서가 Comparable이라면, 오늘 행사에서는 키 큰 순으로 줄 서라는 임시 규칙이 Comparator라고 생각하면 된다.

제네릭과 컬렉션의 궁합

컬렉션을 제대로 쓰려면 제네릭을 빼놓을 수 없다. 이미 제네릭 자체는 배웠지만, 컬렉션에서 제네릭은 거의 안전벨트 수준으로 중요하다.

List<String> names = new ArrayList<>();
Map<String, Integer> scores = new HashMap<>();
Set<Long> ids = new HashSet<>();

이 코드만 봐도 무엇을 담는지 바로 알 수 있다. names는 문자열 목록이고, scores는 문자열 키와 정수 값의 쌍이며, ids는 정수형 식별자 집합이다.

반대로 제네릭 없이 컬렉션을 사용하면 문제가 생긴다.

List list = new ArrayList();
list.add("자바");
list.add(100);

이렇게 되면 서로 다른 타입이 한데 섞일 수 있고, 값을 꺼낼 때 형변환 문제나 실행 중 오류가 생길 수 있다. 그래서 특별한 이유가 없다면 컬렉션에는 항상 제네릭을 붙인다고 생각하는 편이 좋다.

이번 장에서의 핵심은 제네릭 문법 자체를 다시 파고드는 것이 아니라, 컬렉션이 어떤 타입의 데이터를 다루는지 명확하게 만드는 데 제네릭이 꼭 필요하다는 점이다.

실전 예제: 학생 관리 프로그램에 컬렉션 적용하기

이제 여러 도구를 한 번에 묶어 보자. 학생을 등록하고, 중복 학번을 막고, 학번으로 조회하고, 정렬하고, 특정 조건의 학생을 제거하는 간단한 예제를 생각해 볼 수 있다.

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

class Student implements Comparable<Student> {
    private String id;
    private String name;
    private int score;

    public Student(String id, String name, int score) {
        this.id = id;
        this.name = name;
        this.score = score;
    }

    public String getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    @Override
    public int compareTo(Student other) {
        return this.id.compareTo(other.id);
    }

    @Override
    public String toString() {
        return id + " / " + name + " / " + score;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>();
        Set<String> idSet = new HashSet<>();
        Map<String, Student> studentMap = new HashMap<>();

        addStudent(students, idSet, studentMap, new Student("S001", "김자바", 90));
        addStudent(students, idSet, studentMap, new Student("S002", "이코드", 75));
        addStudent(students, idSet, studentMap, new Student("S003", "박디버그", 58));
        addStudent(students, idSet, studentMap, new Student("S002", "중복학번", 100));

        System.out.println("전체 학생 목록");
        for (Student student : students) {
            System.out.println(student);
        }

        System.out.println();
        System.out.println("학번 S002 조회");
        System.out.println(studentMap.get("S002"));

        Collections.sort(students);
        System.out.println();
        System.out.println("학번순 정렬");
        for (Student student : students) {
            System.out.println(student);
        }

        students.sort(new Comparator<Student>() {
            @Override
            public int compare(Student s1, Student s2) {
                return Integer.compare(s2.getScore(), s1.getScore());
            }
        });

        System.out.println();
        System.out.println("점수 내림차순 정렬");
        for (Student student : students) {
            System.out.println(student);
        }

        Iterator<Student> iterator = students.iterator();
        while (iterator.hasNext()) {
            Student student = iterator.next();
            if (student.getScore() < 60) {
                iterator.remove();
                idSet.remove(student.getId());
                studentMap.remove(student.getId());
            }
        }

        System.out.println();
        System.out.println("60점 미만 제거 후");
        for (Student student : students) {
            System.out.println(student);
        }
    }

    public static void addStudent(List<Student> students, Set<String> idSet,
                                  Map<String, Student> studentMap, Student student) {
        if (idSet.add(student.getId())) {
            students.add(student);
            studentMap.put(student.getId(), student);
        } else {
            System.out.println("중복 학번이어서 등록하지 않습니다: " + student.getId());
        }
    }
}

이 예제 하나에 이번 장의 핵심이 거의 다 들어 있다.

List<Student>는 전체 학생 목록을 순서대로 관리한다. Set<String>은 같은 학번이 두 번 들어오지 않게 막는다. Map<String, Student>는 학번으로 학생을 빠르게 찾게 해 준다. Comparable은 학번순 기본 정렬을, Comparator는 점수순 같은 추가 정렬을 담당한다. Iterator는 조건에 맞는 학생을 순회 중 안전하게 제거하는 데 사용된다.

실무 프로그램도 본질은 비슷하다. 회원, 주문, 상품, 게시글처럼 이름만 달라질 뿐, 결국 데이터를 저장하고 찾고 정렬하고 제거하는 흐름이 반복된다.

컬렉션을 사용할 때 자주 하는 실수

초보자가 자주 부딪히는 함정도 미리 알아 두면 좋다.

순서가 없는 컬렉션에서 순서를 기대하는 실수

HashSet, HashMap은 저장 순서를 보장하는 도구가 아니다. 출력했을 때 넣은 순서대로 나오리라 기대하면 곤란하다. 중복 제거와 빠른 관리가 목적이지, 줄 맞춰 행진하는 도구는 아니다.

제네릭 없이 컬렉션을 쓰는 실수

컬렉션을 raw type으로 사용하면 타입이 섞여 버릴 수 있다. 처음에는 편해 보여도 나중에 디버깅이 훨씬 힘들어진다. 컬렉션에는 타입을 명확히 적는 습관이 중요하다.

for-each 순회 중 직접 삭제하는 실수

다음처럼 쓰면 문제가 생길 수 있다.

for (String name : names) {
    if (name.length() <= 1) {
        names.remove(name);
    }
}

순회하면서 삭제가 필요하면 Iterator를 사용해야 한다. 이 차이는 실전에서 꽤 자주 발목을 잡는다.

compareTo와 compare를 대충 만드는 실수

정렬 기준을 구현할 때 return this.score - other.score;처럼 단순히 빼는 방식을 자주 쓰곤 한다. 학습 예제에서는 동작할 때가 많지만, 좋은 습관은 Integer.compare() 같은 비교 메서드를 사용하는 것이다. 의도도 더 분명하고 안전하다.

좋은 자료 관리는 좋은 설계로 이어진다

컬렉션은 단순히 문법 문제가 아니다. 설계 문제이기도 하다. 같은 데이터를 어떤 구조에 담느냐에 따라 코드가 읽기 쉬워지기도 하고, 반대로 복잡해지기도 한다.

예를 들어 회원 목록을 단순 출력만 할 거라면 List 하나로도 충분할 수 있다. 하지만 회원 아이디 중복을 막고 빠르게 조회해야 한다면 Set과 Map까지 함께 쓰는 편이 더 자연스럽다. 즉, 자료구조를 잘 고르는 것은 곧 문제를 잘 나누는 일이다.

이 감각은 자바 학습이 뒤로 갈수록 점점 더 중요해진다. 나중에 스트림을 배우든, 데이터베이스를 배우든, 멀티스레딩을 배우든 결국 바닥에는 "이 데이터를 어떤 구조로 다룰까"라는 질문이 깔려 있다.

정리

이번 장에서는 자바 컬렉션 프레임워크의 핵심인 List, Set, Map을 중심으로 자료를 효율적으로 저장하고 탐색하는 방법을 살펴보았다.

List는 순서와 중복이 필요한 데이터에 어울리고, Set은 중복 제거가 핵심일 때 빛난다. Map은 키를 이용해 값을 찾는 상황에서 매우 강력하다. 데이터를 순회할 때는 Iterator가 안전한 도우미가 되고, 객체 정렬에서는 Comparable과 Comparator가 기준을 만들어 준다. 그리고 이 모든 컬렉션을 제대로 쓰기 위해 제네릭은 거의 필수라고 할 수 있다.

결국 좋은 프로그램은 데이터를 잘 다루는 프로그램이다. 무언가를 저장하고, 필요한 것을 빠르게 찾고, 잘못된 중복을 막고, 원하는 순서로 보여 주는 능력은 실전 자바 개발의 핵심 기초다. 다음 장에서는 한 걸음 더 나아가 현대 자바 문법을 배우며, 람다와 스트림 같은 도구로 이 데이터 처리 작업을 더 간결하고 표현력 있게 만드는 방법을 만나게 된다.

책 목록

처음부터 실무까지 단계별로 배우는 JAVA 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기