문서 읽는 데 79분 · day19

Day 19 — 컬렉션 (2): Set과 Map (중복 없는 명단, 이름표 사물함)

전체 21강 중 19강 · 자바 기초
난이도 · 입문

지난 시간 우리는 배열을 졸업하고 ListArrayList 를 만났죠. 크기를 미리 안 정해도 알아서 늘어나고, 순서대로 담고 꺼내는 그릇이었어요. 그런데 List 에는 두 가지 성격이 있었어요. 순서가 있고(0번, 1번, 2번…), 중복도 허용한다(같은 #travel 을 두 번 add 하면 두 번 다 들어간다)는 거요.

이 두 성격이 가끔 곤란할 때가 있어요. 지난 시간 마지막에 던진 질문 기억나시죠. "이 게시물에 좋아요 누른 사람 목록에 같은 사람이 두 번 들어가면 안 되잖아요." 장원영이 실수로 좋아요 버튼을 두 번 눌렀다고 좋아요가 2로 세지면 곤란하겠죠. 또 하나, "username 으로 회원을 바로 찾기" 처럼 이름표(키)를 대면 값이 툭 튀어나오는 저장소도 필요했어요. List 로 찾으려면 처음부터 끝까지 훑어야 하니까요.

오늘은 이 두 가지를 풀어주는 그릇 두 개를 만나요. 중복을 자동으로 걸러주는 Set(세트, 중복 없는 명단), 그리고 이름표(키)와 값을 짝지어 저장하는 Map(맵, 이름표 사물함)이에요. 둘 다 List 와 형제 같은 도구라 금방 익숙해질 거예요. 그리고 "두 회원이 같은 사람인지" 를 판단하는 equalshashCode 가 왜 짝꿍인지도 오늘 정확히 만나요. 자, Day 19 시작해봐요!

🎯 학습 목표

  • List 로 좋아요를 관리할 때 생기는 중복 문제를 설명하고, HashSet 으로 add·contains·remove·size 를 다룰 수 있어요.
  • HashSetTreeSet 의 차이(속도 vs 자동 정렬)를 알고, 상황에 맞게 고를 수 있어요.
  • HashMap 으로 이름표(키)를 대면 값이 바로 나오는 저장소를 만들고, put·get·containsKey·getOrDefault 를 쓸 수 있어요.
  • equalshashCode 가 왜 짝꿍이어야 하는지 버킷 구조로 이해하고, 직접 만든 클래스를 Set 에 안전하게 담을 수 있어요.
  • TreeMap 으로 키가 자동 정렬되는 저장소를 만들고, Iterator(순회 도우미) 로 순회 중 안전하게 삭제할 수 있어요.
  • 불변 컬렉션(List.of·Set.of·Map.of)이 무엇이고 언제 쓰는지 설명할 수 있어요.

Step 1. Set이 필요한 이유 — 중복 없는 좋아요 목록

지난 시간에 배운 List 로 "좋아요 누른 사람" 을 관리한다고 해볼게요. 어떤 일이 벌어질까요?

 좋아요 목록을 List 로 관리하면…
   minji 가 좋아요 클릭   → [minji]
   jaehoon 이 좋아요 클릭 → [minji, jaehoon]
   minji 가 또 클릭!      → [minji, jaehoon, minji]   ← 같은 사람이 두 번!
                                                좋아요 수가 3? 실제론 2명인데…

List 는 시키는 대로 다 담아요. minji 가 두 번 눌렀으면 두 번 다 들어가죠. 그러면 우리가 직접 "이 사람 이미 눌렀나?" 를 매번 검사해서 안 담아야 해요. 지난 시간 과제에서 if (tags.contains(tag)) return; 한 줄로 중복을 막아본 기억이 있죠? 그 검사를 매번 손으로 하는 거예요. 귀찮기도 하고, 깜빡하면 바로 버그예요.

그래서 자바는 아예 "같은 값은 한 번만 담는 명단" 을 따로 만들어뒀어요. 이게 바로 Set(세트, 집합) 이에요. 그중 가장 많이 쓰는 HashSet(해시셋) 부터 만나볼게요.

// com/instagram/javabasic/collection/HashSetBasic.java
import java.util.HashSet;
import java.util.Set;

public class HashSetBasic {

    // 좋아요 누른 사람들의 username 을 모아요. 같은 사람이 두 번 눌러도 한 번만 남아요.
    public Set<String> likedUsernames() {
        Set<String> liked = new HashSet<>();
        liked.add("minji");
        liked.add("jaehoon");
        liked.add("minji"); // 중복 — 자동으로 무시돼요
        return liked;
    }
}

List 를 쓸 때와 모양이 거의 똑같죠? Set<String> 으로 변수를 받고 new HashSet<>() 로 그릇을 만들어요. 꺾쇠 <String> 도 지난 시간과 같은 약속이에요 — "이 명단엔 String 만 담아요." add 로 담는 것도 그대로고요.

다른 건 딱 하나, add("minji") 를 두 번 했는데 안에는 minji 가 하나만 남는다는 거예요. 실행해보면 이렇게 나와요.

HashSetBasic demo = new HashSetBasic();

Set<String> liked = demo.likedUsernames();
System.out.println("좋아요 누른 사람 수: " + liked.size()); // 2 (minji 중복 제거)
System.out.println("minji 가 눌렀나요? " + liked.contains("minji")); // true

minji 를 두 번 넣었는데 크기는 2 예요. Set 이 알아서 중복을 걸러낸 거죠. 우리가 contains 로 미리 검사할 필요가 없어요.

 Set 은 같은 값을 한 번만 담아요
   add("minji")   → { minji }
   add("jaehoon") → { minji, jaehoon }
   add("minji")   → { minji, jaehoon }   ← 이미 있어서 그냥 무시!

그런데 "방금 정말 새로 담겼는지" 가 궁금할 때가 있어요. 예를 들어 "이미 좋아요 누른 사람이면 알림을 보내지 말자" 같은 처리요. add 는 사실 그 답을 true/false 로 돌려줘요.

public boolean addReturnsFalseOnDuplicate() {
    Set<String> liked = new HashSet<>();
    liked.add("minji");          // true (새로 담김)
    return liked.add("minji");   // false (이미 있음)
}

처음 담을 땐 "새로 담았어요" 라는 뜻으로 true, 이미 있는 값을 또 담으려 하면 "안 담았어요(이미 있음)" 라는 뜻으로 false 를 줘요. 좋아요 토글 같은 기능을 만들 때 아주 요긴해요.

찾고 빼는 것도 List 처럼 메서드 하나면 돼요.

// 어떤 username 이 명단에 있는지 contains() 로 물어봐요.
public boolean hasLiked(Set<String> liked, String username) {
    return liked.contains(username);
}

// 좋아요를 취소하면 remove() 로 명단에서 빼요.
public Set<String> cancelLike(Set<String> liked, String username) {
    liked.remove(username);
    return liked;
}

contains 로 들어있는지 물어보고, remove 로 빼요. 좋아요를 취소하면 명단에서 그 사람을 빼는 거죠. 지난 시간 끝에 "중복 제거가 필요하면 Set" 이라고 흘려둔 약속, 이렇게 회수했어요.

💡 튜터의 결론

Set 은 "같은 값을 한 번만 담는 명단" 이에요. List 와 달리 중복을 자동으로 걸러줘서, "좋아요 누른 사람" 처럼 한 사람당 한 번만 세야 하는 곳에 딱 맞아요. add·contains·remove·size 사용법은 List 와 거의 같아요.


Step 2. HashSet vs TreeSet — 빠르게냐, 정렬해서냐

Set 에도 종류가 있어요. 방금 쓴 HashSet 말고 TreeSet(트리셋) 이라는 형제가 하나 더 있어요. 둘 다 중복을 걸러주는 건 똑같은데, 한 가지가 달라요. TreeSet 은 담는 순간 자동으로 사전순(가나다·ABC)으로 정렬해줘요.

해시태그를 모으는 예로 둘을 비교해볼게요. 먼저 HashSet 부터요.

// com/instagram/javabasic/collection/UniqueTagSet.java
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;

public class UniqueTagSet {

    // HashSet — 중복만 제거해요. "#jeju" 를 두 번 넣어도 한 번만 남아요.
    public Set<String> hashSetTags() {
        Set<String> tags = new HashSet<>();
        tags.add("#travel");
        tags.add("#jeju");
        tags.add("#sunset");
        tags.add("#jeju"); // 중복 — 무시돼요
        return tags;
    }
}

Step 1과 똑같아요. 중복만 걸러내고, 순서는 신경 안 써요. 다음은 TreeSet 이에요.

// TreeSet — 중복 제거 + 자동 정렬. 뒤섞어 넣어도 안에서는 사전순으로 줄을 서요.
public TreeSet<String> treeSetTags() {
    TreeSet<String> tags = new TreeSet<>();
    tags.add("#travel");
    tags.add("#jeju");
    tags.add("#sunset");
    tags.add("#cafe");
    return tags;
}

넣은 순서는 travel → jeju → sunset → cafe 인데, TreeSet 안에서는 어떻게 들어 있을까요? 실행해서 확인해봐요.

UniqueTagSet demo = new UniqueTagSet();

Set<String> hash = demo.hashSetTags();
System.out.println("HashSet 크기(중복 제거): " + hash.size()); // 3

TreeSet<String> tree = demo.treeSetTags();
System.out.println("TreeSet 첫 태그(사전순 최소): " + tree.first());
System.out.println("TreeSet 끝 태그(사전순 최대): " + tree.last());
System.out.println("정렬된 전체: " + demo.sortedTagList());

HashSet 은 jeju 중복이 빠져서 크기가 3 이에요. TreeSet 은 넣은 순서와 상관없이 안에서 사전순으로 줄을 서요. 그래서 first() 는 사전순으로 가장 앞인 #cafe, last() 는 가장 뒤인 #travel 이 나와요.

 넣은 순서:   #travel  #jeju  #sunset  #cafe
                          │
                  TreeSet 이 자동 정렬
                          ▼
 안에서는:    #cafe → #jeju → #sunset → #travel   (사전순)
              ↑first()                  ↑last()

TreeSet 을 처음부터 순회하면 이 정렬된 순서대로 나와요. 그 순서를 List 에 옮겨 담아 확인해볼 수 있어요.

// TreeSet 을 처음부터 순회하면 정렬된 순서대로 나와요.
public List<String> sortedTagList() {
    TreeSet<String> tags = treeSetTags();
    List<String> result = new ArrayList<>();
    for (String tag : tags) {
        result.add(tag);
    }
    return result;
}

향상된 for로 TreeSet 을 훑으면 사전순으로 하나씩 나오니까, List 에 담으면 [#cafe, #jeju, #sunset, #travel] 이 돼요. 정렬 알고리즘을 한 줄도 안 짰는데 정렬된 결과가 나오죠. first()·last()TreeSet 만 가진 메서드라 변수 타입도 TreeSet<String> 으로 받았어요.

자, 이제 둘을 다 써봤으니 비교표를 봐요.

HashSet TreeSet
중복 제거
저장 순서 보장 안 함(뒤죽박죽) 항상 사전순 자동 정렬
속도 빠름 살짝 느림(정렬 비용)
언제 쓰나 그냥 중복만 없앨 때 정렬된 상태가 필요할 때

여기서 트레이드오프(trade-off, 하나를 얻으면 하나를 내주는 맞바꿈)가 처음 등장해요. TreeSet 은 정렬을 공짜로 해주는 게 아니에요. 담을 때마다 "이게 어디 들어가야 사전순이 되지?" 를 계산하느라 HashSet 보다 살짝 느려요. 그래서 정렬이 꼭 필요할 때만 TreeSet 을 쓰고, 그냥 중복만 없애면 될 땐 더 빠른 HashSet 을 써요.

💡 튜터의 결론

둘 다 중복을 걸러줘요. 차이는 정렬이에요. HashSet 은 빠르지만 순서가 뒤죽박죽, TreeSet 은 항상 사전순으로 줄 세우는 대신 살짝 느려요. "정렬된 상태가 필요한가?" 만 따져서 고르면 돼요.


Step 3. HashMap 첫 만남 — 키로 회원 빠르게 찾기

이번엔 두 번째 그릇, Map(맵) 이에요. 비유부터 잡고 갈게요. Map 은 이름표가 붙은 사물함이에요.

 Map = 이름표(키) → 물건(값) 사물함

   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
   │ "minji"     │  │ "jaehoon"   │  │ "seungwoo"  │   ← 이름표(키)
   ├─────────────┤  ├─────────────┤  ├─────────────┤
   │ Member 객체 │  │ Member 객체 │  │ Member 객체 │   ← 물건(값)
   └─────────────┘  └─────────────┘  └─────────────┘

   "minji" 라는 이름표를 대면 → minji 의 Member 가 툭 나와요

왜 이게 필요할까요? 지금까지 우리가 회원을 찾는 방식을 떠올려봐요. List 에 회원을 담아두고 "username 이 minji 인 사람" 을 찾으려면, 리스트를 처음부터 한 명씩 훑으면서 이름을 비교해야 했어요. 회원이 100만 명이면 운이 나쁘면 100만 번을 비교하죠. 그런데 사물함은 이름표만 대면 단번에 꺼내잖아요. Map 이 바로 그 사물함이에요.

 List 로 찾기:  [minji][jaehoon][...][...] ... 100만 명
                 ↑ 처음부터 하나씩 비교 (느림)

 Map 으로 찾기: "minji" 이름표 → 단번에 도착 (빠름)

가장 많이 쓰는 HashMap(해시맵) 으로 "username → Member" 사물함을 만들어볼게요.

// com/instagram/javabasic/collection/UsernameToMember.java
import java.util.HashMap;
import java.util.Map;

import com.instagram.javabasic.domain.member.Member;

public class UsernameToMember {

    // username 을 이름표로, Member 객체를 내용물로 보관하는 사물함이에요.
    private final Map<String, Member> members = new HashMap<>();

    // 회원을 등록해요. member 의 username 을 키로 삼아 짝지어 넣어요.
    public void register(Member member) {
        members.put(member.getUsername(), member);
    }

    // username 으로 회원을 찾아요. 없으면 null 이 나와요.
    public Member find(String username) {
        return members.get(username);
    }
}

꺾쇠가 이번엔 두 칸이에요. Map<String, Member> 는 "이름표(키)는 String, 안에 넣는 물건(값)은 Member" 라는 약속이에요. 쉼표로 둘을 나란히 적어요. SetList 는 담는 것 하나만 적었는데, Map 은 키와 값 두 가지라 두 칸인 거죠.

사용법도 사물함처럼 직관적이에요.

  • put(키, 값) — 이름표에 물건을 넣어요. (사물함에 보관)
  • get(키) — 이름표를 대고 물건을 꺼내요. 없으면 null 이 나와요.

나머지 두 메서드도 봐요.

// 그 이름이 등록돼 있는지만 물어봐요.
public boolean exists(String username) {
    return members.containsKey(username);
}

// 없을 때 대신 돌려줄 값을 미리 정해 두고 꺼내요(getOrDefault).
public Member findOrDefault(String username, Member fallback) {
    return members.getOrDefault(username, fallback);
}

containsKey 는 "그 이름표가 사물함에 있나?" 만 물어봐요. getOrDefault 는 살짝 친절한 get 이에요. get 은 없는 키를 찾으면 null 을 주는데, null 은 다루기 까다롭죠(잘못 쓰면 프로그램이 터져요). getOrDefault(키, 기본값) 는 "없으면 이거라도 줘" 하고 미리 정한 기본값을 돌려줘서 null 사고를 피할 수 있어요.

실행해서 확인해봐요.

UsernameToMember repo = new UsernameToMember();
repo.register(new Member("minji", 8500, 150, 12, 400));
repo.register(new Member("jaehoon", 1240, 42, 3, 120));

System.out.println("등록 수: " + repo.size()); // 2
Member found = repo.find("minji");
System.out.println("찾은 사람: " + found);
System.out.println("없는 이름 find: " + repo.find("unknown")); // null
System.out.println("minji 있나요? " + repo.exists("minji"));   // true

find("minji") 가 minji 의 Member 객체를 단번에 꺼내줘요. 리스트를 훑지 않고요. 없는 이름 "unknown" 을 찾으면 null 이 나오죠. 지난 시간 끝에 "키로 바로 찾기가 필요하면 Map" 이라고 흘려둔 약속, 이렇게 회수했어요.

💡 튜터의 결론

Map 은 "이름표(키) → 물건(값)" 사물함이에요. List 처럼 처음부터 훑지 않고, 키만 대면 값이 단번에 나와요. put(넣기)·get(꺼내기)·containsKey(있나?)·getOrDefault(없으면 기본값) 네 개만 알면 충분해요.


Step 4. equals와 hashCode 계약 — 같은 사람을 같은 사람으로

지금까지 Set 에 담은 건 String(문자열) 이었어요. 그런데 우리가 Day 16에서 직접 만든 Member 객체를 HashSet 에 담으면 어떻게 될까요? 같은 사람(username 이 같은)을 두 번 담으면 Set 이 중복으로 걸러줄까요?

지난 시간 끝에 "두 회원이 같은 사람인지 판단하는 equalshashCode 가 왜 짝꿍인지 다음 시간에" 라고 했죠. 오늘이 그날이에요. 여기서 자바의 아주 중요한 약속(계약) 하나를 만나요.

먼저 HashSet 이 어떻게 중복을 걸러내는지부터 알아야 해요. HashSet 은 사실 칸이 여러 개인 큰 사물함이에요. 값을 담을 때 두 단계를 거쳐요.

 HashSet 이 값을 담는 두 단계

   1단계) hashCode 로 "몇 번 칸에 둘지" 를 정해요
   2단계) 그 칸 안에서 equals 로 "이미 같은 게 있나" 확인해요

   ┌── 0번 칸 ──┐ ┌── 1번 칸 ──┐ ┌── 2번 칸 ──┐
   │           │ │  minji    │ │           │
   └───────────┘ └───────────┘ └───────────┘
                       ↑
            hashCode 가 "1번 칸으로 가" 라고 정하면
            1번 칸 안에서만 equals 로 비교해요

핵심은 이거예요. HashSet 은 먼저 hashCode(값을 숫자로 요약한 것) 로 칸을 정하고, 그 칸 안에서만 equals 로 같은지 확인해요. 모든 값을 일일이 비교하지 않고 칸으로 먼저 좁히기 때문에 빠른 거죠.

여기서 사고가 터져요. 만약 equals 만 정의하고 hashCode 를 안 맞춰두면 어떻게 될까요?

 equals 만 있고 hashCode 가 제각각이면…

   minji(A) 의 hashCode → 0번 칸으로 감
   minji(B) 의 hashCode → 5번 칸으로 감   ← 같은 사람인데 다른 칸!

   ┌── 0번 칸 ──┐        ┌── 5번 칸 ──┐
   │  minji(A) │  ...   │  minji(B) │
   └───────────┘        └───────────┘

   서로 다른 칸에 있으니 equals 비교조차 안 해요
   → 같은 사람인데 둘 다 담겨버려요 (중복 제거 실패!)

같은 minji 인데 hashCode 가 다르면 다른 칸에 들어가요. 다른 칸에 있으면 HashSet 은 둘을 비교조차 안 해요(같은 칸 안에서만 비교하니까요). 결국 같은 사람이 둘 다 담겨서 중복 제거가 실패해요.

그래서 자바에는 이런 약속이 있어요.

두 객체가 equals 로 같다면, hashCode 도 반드시 같아야 한다.

이 약속을 지키려면, equals 를 username 기준으로 정의했으면 hashCode 도 username 기준으로 맞춰야 해요. 우리 Member 클래스를 봐요.

// com/instagram/javabasic/domain/member/Member.java
// equals — 두 객체가 "같은 사람인가" 를 username 기준으로 비교해요.
@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null || getClass() != obj.getClass()) return false;
    Member other = (Member) obj;
    return username != null && username.equals(other.username);
}

// hashCode — equals 와 짝꿍이에요. equals 가 username 으로 같다고 하면 hashCode 도 같아야 해요.
@Override
public int hashCode() {
    return Objects.hash(username);
}

equals 는 username 이 같으면 "같은 사람" 이라고 해요. 그리고 hashCodeObjects.hash(username) 로 username 만 가지고 숫자를 만들어요. Objects.hash(...) 는 자바가 준비해둔 도구인데, 넘긴 값으로 깔끔하게 숫자 요약을 만들어줘요. 둘 다 username 기준이니까, username 이 같은 두 minji 는 같은 숫자가 나와서 같은 칸으로 가고, 그 칸에서 equals 비교로 중복이 걸러져요.

직접 확인해볼게요.

// com/instagram/javabasic/collection/HashCodeContract.java
public Set<Member> uniqueMembers() {
    Set<Member> members = new HashSet<>();
    members.add(new Member("minji", 8500, 150, 12, 400));
    members.add(new Member("minji", 9999, 200, 20, 500)); // 같은 username — 중복으로 걸러져요
    members.add(new Member("jaehoon", 1240, 42, 3, 120));
    return members;
}

팔로워 수도 다르고 게시물 수도 다른 두 Member 인데, username 이 둘 다 "minji" 예요. 실행하면 이렇게 나와요.

HashCodeContract demo = new HashCodeContract();
Set<Member> members = demo.uniqueMembers();

System.out.println("중복 제거 후 인원: " + members.size()); // 2 (minji 둘은 하나로)

Member a = new Member("minji", 8500, 150, 12, 400);
Member b = new Member("minji", 9999, 200, 20, 500);
System.out.println("두 minji equals? " + a.equals(b));         // true
System.out.println("두 minji hashCode 같나? " + (a.hashCode() == b.hashCode())); // true

세 명을 넣었는데 인원은 2 예요. minji 둘이 같은 사람으로 인식돼 하나로 합쳐진 거죠. equalstrue, hashCode 도 같은 값이 나와요. 짝꿍이 잘 맞춰진 덕분이에요.

🙋 학생 질문 — "튜터님, equals 랑 hashCode 를 매번 손으로 써야 하나요? 복잡해 보여요."

좋은 질문이에요! 다행히 손으로 다 칠 필요는 거의 없어요. IntelliJ 같은 IDE 에서 클래스 안에 커서를 두고 메뉴(또는 단축키)로 "generate → equals() and hashCode()" 를 고르면, 어떤 필드를 기준으로 비교할지만 체크하면 자동으로 만들어줘요. username 만 체크하면 위와 똑같은 코드가 생겨요.

다만 자동 생성을 쓰더라도 "왜 둘이 짝꿍이어야 하는지" 는 알고 있어야 해요. 그래야 "username 으로만 같은지 보고 싶은데 IDE 가 모든 필드를 넣었네?" 같은 상황에서 직접 고칠 수 있거든요. 도구는 손이 편해지라고 있는 거고, 원리는 머리로 알고 있는 게 좋아요.

💡 튜터의 결론

HashSet·HashMap 은 먼저 hashCode 로 칸을 정하고, 그 칸에서 equals 로 같은지 확인해요. 그래서 둘은 짝꿍이에요 — "equals 로 같으면 hashCode 도 같아야 한다." equals 를 username 기준으로 만들었으면 hashCodeObjects.hash(username) 로 맞춰주면 돼요.


Step 5. TreeMap과 정렬된 저장소 — 날짜순 팔로우 기록

Step 2에서 HashSet 의 정렬 버전이 TreeSet 이었죠. Map 에도 똑같은 형제가 있어요. HashMap 의 정렬 버전인 TreeMap(트리맵) 이에요. TreeMap 은 이름표(키) 를 자동으로 정렬해 주는 사물함이에요.

언제 쓸까요? "팔로우한 순서대로 기록을 보고 싶다" 같은 경우요. 팔로우한 날짜를 키로, 누구를 팔로우했는지를 값으로 넣어두면, 날짜를 뒤죽박죽 넣어도 안에서는 항상 시간순으로 줄을 서요.

여기서 한 가지 약속을 정할게요. 날짜를 표현하는 진짜 날짜 타입은 아직 안 배웠으니, "2026-01-15" 같은 문자열로 표현해요. 다행히 이 형식(ISO 형식)은 문자열을 사전순으로 비교하면 그대로 시간순이 돼요. "2026-01-15""2026-03-20" 보다 사전순으로 앞이고, 실제로도 더 이른 날짜죠. 그래서 문자열 정렬이 곧 날짜 정렬이 돼요.

// com/instagram/javabasic/collection/FollowDateTreeMap.java
import java.util.TreeMap;

public class FollowDateTreeMap {

    // 날짜(키) → username(값) 을 보관해요. TreeMap 이라 키가 자동 정렬돼요.
    private final TreeMap<String, String> follows = new TreeMap<>();

    // 팔로우 한 건을 기록해요. 어떤 순서로 넣든 안에서는 날짜순으로 정리돼요.
    public void recordFollow(String date, String username) {
        follows.put(date, username);
    }

    // 가장 이른 팔로우 날짜 — TreeMap 은 firstKey() 로 제일 작은 키를 바로 알려줘요.
    public String earliestDate() {
        return follows.firstKey();
    }

    // 가장 늦은 팔로우 날짜 — lastKey() 로 제일 큰 키를 알려줘요.
    public String latestDate() {
        return follows.lastKey();
    }
}

put·getHashMap 과 똑같이 쓰는데, TreeMapfirstKey()(가장 작은 키)·lastKey()(가장 큰 키) 를 추가로 가지고 있어요. 키가 정렬돼 있으니 양 끝을 바로 알 수 있는 거죠. TreeSetfirst()·last() 와 닮았죠?

순회도 해볼게요. TreeMap 을 훑으면 키가 작은 것부터(이른 날짜부터) 차례로 나와요.

// 날짜순으로 정렬된 username 목록을 돌려줘요.
public List<String> followsInOrder() {
    List<String> result = new ArrayList<>();
    for (Map.Entry<String, String> entry : follows.entrySet()) {
        result.add(entry.getValue());
    }
    return result;
}

여기 새 얼굴이 둘 있어요. entrySet()Map.Entry 예요. Map 은 키와 값이 짝지어 있다고 했죠. entrySet() 은 그 "키-값 한 쌍" 들을 하나씩 꺼낼 수 있게 모아줘요. 한 쌍이 Map.Entry(엔트리, 한 칸의 키-값 묶음) 고요. 향상된 for로 돌면서 entry.getValue() 로 값(username)을 꺼내요. 키가 필요하면 entry.getKey() 를 쓰면 되고요.

 entrySet() 으로 한 쌍씩 꺼내기

   ┌─ "2026-01-15" → "minji"    ┐
   │  "2026-02-10" → "jaehoon"  │  ← TreeMap 이라 날짜순 정렬됨
   └─ "2026-03-20" → "seungwoo" ┘
        getKey()      getValue()

실행해봐요. 일부러 날짜를 뒤섞어 넣어요.

FollowDateTreeMap log = new FollowDateTreeMap();
// 일부러 뒤섞어 넣어요
log.recordFollow("2026-03-20", "seungwoo");
log.recordFollow("2026-01-15", "minji");
log.recordFollow("2026-02-10", "jaehoon");

System.out.println("가장 이른 팔로우 날짜: " + log.earliestDate()); // 2026-01-15
System.out.println("가장 늦은 팔로우 날짜: " + log.latestDate());   // 2026-03-20
System.out.println("시간순 팔로우 순서: " + log.followsInOrder());  // [minji, jaehoon, seungwoo]

3월 → 1월 → 2월 순서로 뒤섞어 넣었는데, firstKey() 는 가장 이른 1월, lastKey() 는 가장 늦은 3월이 나와요. 순회 결과도 [minji, jaehoon, seungwoo] 로 시간순이고요. TreeMap 이 넣는 족족 키를 정렬해둔 덕분이에요.

💡 튜터의 결론

TreeMap 은 키가 자동 정렬되는 Map 이에요. HashMap 의 정렬 버전이라고 보면 돼요. firstKey()·lastKey() 로 양 끝 키를 바로 얻고, entrySet() 으로 키-값 쌍을 정렬된 순서대로 순회할 수 있어요. 키 정렬이 필요할 때만 쓰고, 아니면 더 빠른 HashMap 을 써요.


Step 6. Iterator로 안전하게 순회 — 삭제 중 함정

이번엔 함정 하나를 짚고 갈게요. 명단을 순회하면서 동시에 항목을 삭제하려고 하면 사고가 나기 쉬워요. 예를 들어 "활동 안 하는 회원을 명단에서 골라 빼기" 같은 작업이요.

순진하게 향상된 for로 돌면서 빼면 어떻게 될까요?

// com/instagram/javabasic/collection/SafeRemovalIterator.java
// 위험한 방법 — for-each 로 돌면서 list.remove() 를 직접 불러요.
public void unsafeRemove(List<String> usernames, List<String> inactive) {
    List<String> copy = new ArrayList<>(usernames);
    for (String name : copy) {
        if (inactive.contains(name)) {
            copy.remove(name); // 순회 중에 직접 지우면 예외가 터져요
        }
    }
}

이 코드를 실행하면 프로그램이 멈추면서 예외를 던져요. 이름이 길어요 — ConcurrentModificationException(순회 중 변경 예외) 이에요. 풀어서 읽으면 "내가 명단을 한 명씩 훑고 있는 중인데, 누가 갑자기 명단을 바꿔버렸어!" 라는 항의예요.

 향상된 for 로 순회 중 삭제하면…

   순회 중:  [minji][jaehoon][seungwoo][yuna]
                       ↑ jaehoon 차례, "어? 비활성이네 삭제!"
                       remove(jaehoon)

   순회 도우미: "잠깐, 방금 명단이 바뀌었잖아!
                내가 어디까지 봤는지 기준이 흔들렸어!"
                → ConcurrentModificationException 💥

향상된 for는 속으로 "다음, 다음" 하면서 위치를 세고 있는데, 그 와중에 항목이 빠지면 위치 기준이 흔들려서 자바가 안전을 위해 멈춰버리는 거예요. 한 명을 건너뛰거나 엉뚱한 사람을 빼는 사고를 막으려고요.

그럼 어떻게 안전하게 지울까요? Iterator(이터레이터, 순회 도우미) 를 직접 꺼내 쓰면 돼요. Iterator 는 명단을 대신 훑어주는 작은 도우미예요. 향상된 for도 사실 속으로 이 Iterator 를 쓰고 있어요. 우리가 직접 꺼내 쓰면, 그 도우미에게 "방금 꺼낸 거 지워줘" 하고 삭제까지 맡길 수 있어요.

// 안전한 방법 — Iterator 로 순회하며 비활성 username 을 그 자리에서 지워요.
public List<String> removeInactive(List<String> usernames, List<String> inactive) {
    List<String> result = new ArrayList<>(usernames);
    Iterator<String> it = result.iterator();
    while (it.hasNext()) {
        String name = it.next();
        if (inactive.contains(name)) {
            it.remove(); // 순회 도우미에게 삭제를 맡기면 안전해요
        }
    }
    return result;
}

Iterator 는 세 동작으로 움직여요.

  • hasNext() — "다음 게 남았나?" 를 물어봐요. 남았으면 true.
  • next() — 다음 항목을 하나 꺼내요.
  • remove() — 방금 next() 로 꺼낸 항목을 지워요.

while (it.hasNext()) 로 다음이 있는 동안 계속 돌고, it.next() 로 하나 꺼내서, 비활성이면 it.remove() 로 지워요. 도우미가 직접 지우니까 위치 기준이 흔들리지 않아서 안전해요. list.remove() 가 아니라 it.remove() 라는 게 핵심이에요.

실행해서 둘을 비교해봐요.

SafeRemovalIterator demo = new SafeRemovalIterator();

List<String> all = new ArrayList<>(List.of("minji", "jaehoon", "seungwoo", "yuna"));
List<String> inactive = List.of("jaehoon", "yuna");

List<String> survivors = demo.removeInactive(all, inactive);
System.out.println("정리 후 남은 사람: " + survivors); // [minji, seungwoo]

try {
    demo.unsafeRemove(all, inactive);
} catch (Exception e) {
    System.out.println("잘못된 방법이 던진 예외: " + e.getClass().getSimpleName());
}

안전한 removeInactive 는 jaehoon·yuna 를 깔끔히 빼고 [minji, seungwoo] 를 남겨요. 반면 위험한 unsafeRemove 는 Day 17에서 배운 try-catch 로 감싸 두었더니, ConcurrentModificationException 이 잡혀서 예외 이름이 출력돼요. 같은 일을 하려는데 한쪽만 터지죠.

💡 튜터의 결론

명단을 순회하면서 동시에 삭제하려면 향상된 for로 list.remove() 를 부르면 안 돼요 — ConcurrentModificationException(순회 중 변경 예외) 이 터져요. 대신 Iterator(순회 도우미) 를 꺼내 hasNextnextremove 로 도우미에게 삭제를 맡기면 안전해요.


Step 7. 불변 컬렉션 — List.of / Set.of / Map.of

지금까지 만든 컬렉션은 전부 만든 뒤에 add·remove 로 마음대로 바꿀 수 있었어요. 그런데 가끔은 "한 번 정하면 절대 바뀌면 안 되는 값" 이 있어요. 예를 들어 "허용된 권한 목록", "고정된 안내 문구", "등급 코드 표" 같은 거요. 이런 값이 실수로 바뀌면 큰 사고가 나죠.

자바는 이런 경우를 위해 불변(immutable, 바뀌지 않는) 컬렉션을 만드는 간단한 방법을 줘요. List.of(...)·Set.of(...)·Map.of(...) 예요.

// com/instagram/javabasic/collection/ImmutableCollectionsDemo.java
import java.util.List;
import java.util.Map;
import java.util.Set;

public class ImmutableCollectionsDemo {

    // 고정된 추천 해시태그 목록 — 바뀌지 않아요.
    public List<String> constantTags() {
        return List.of("#daily", "#instagood", "#photo");
    }

    // 허용된 역할(권한) 집합 — Set.of 로 중복 없이 고정해요.
    public Set<String> allowedRoles() {
        return Set.of("USER", "ADMIN", "GUEST");
    }

    // 등급 코드 → 한글 라벨 — Map.of 로 고정 매핑을 만들어요.
    public Map<String, String> gradeLabels() {
        return Map.of(
                "S", "강력 추천",
                "A", "추천",
                "B", "보통");
    }
}

List.of(...) 는 괄호 안에 넣은 값들로 바로 리스트를 만들어줘요. new ArrayList<>() 한 다음 add 를 여러 번 부르는 것보다 훨씬 간결하죠. Set.of·Map.of 도 같은 식이에요. Map.of 는 "키, 값, 키, 값…" 순서로 쌍을 적어요.

핵심은 이렇게 만든 컬렉션은 못 바꾼다는 거예요. 일부러 바꾸려고 하면 어떻게 되는지 봐요.

// 불변 리스트에 add 를 시도하는 잘못된 예 — UnsupportedOperationException 이 터져요.
public void tryModify(List<String> immutableList) {
    immutableList.add("#newtag"); // 불변 컬렉션은 수정을 거부해요
}

List.of 로 만든 리스트에 add 를 부르면, 자바가 UnsupportedOperationException(지원 안 함 예외) 을 던져요. "이 리스트는 수정하는 기능을 지원하지 않아요" 라는 뜻이에요. 실행해서 확인해봐요.

ImmutableCollectionsDemo demo = new ImmutableCollectionsDemo();

System.out.println("고정 태그: " + demo.constantTags());
System.out.println("허용 역할: " + demo.allowedRoles());
System.out.println("등급 라벨: " + demo.gradeLabels());

try {
    demo.tryModify(demo.constantTags());
} catch (Exception e) {
    System.out.println("수정 시도가 막혔어요: " + e.getClass().getSimpleName());
}

세 컬렉션은 멀쩡히 출력되는데, tryModifyadd 를 시도하니 예외가 잡혀요. "바뀌면 안 되는 값" 이라는 우리 의도가 코드로 단단히 지켜진 거죠.

 보통 컬렉션 vs 불변 컬렉션

   new ArrayList<>()   →  add/remove 자유롭게  (바뀔 수 있음)
   List.of(...)        →  add 하면 거부 💥     (절대 안 바뀜)

   "이 값은 절대 안 바뀌어야 해" 를 코드로 고정해 두는 안전장치

언제 쓸까요? "이 목록은 프로그램 내내 고정" 이라는 게 분명할 때 써요. 그러면 다른 누군가(또는 미래의 나)가 실수로 값을 바꾸려 해도 예외가 막아주니까, 버그를 미리 차단할 수 있어요. 반대로 자주 추가·삭제해야 하는 데이터는 보통 ArrayList·HashMap 을 써야겠죠.

💡 튜터의 결론

List.of·Set.of·Map.of 는 한 번 만들면 못 바꾸는 불변 컬렉션을 간결하게 만들어줘요. 수정하려 하면 UnsupportedOperationException 으로 막혀요. "절대 바뀌면 안 되는 값" 을 코드로 고정해 실수를 막을 때 써요.


Step 8. 종합 실습 — 피드에 Set/Map 적용

오늘의 마지막은 지금까지 배운 List·Set·Map 을 한데 엮는 실습이에요. 작은 피드 저장소를 만들어볼게요. 인스타 피드에는 두 가지 관계가 있어요.

  1. "한 회원이 쓴 글들" — 한 사람이 여러 글을 쓰니까 "회원 → 글 목록" 이에요.
  2. "한 글에 좋아요 누른 사람들" — 같은 사람이 두 번 누르면 안 되니까 "글 → 좋아요 회원 집합" 이에요.

이걸 자료구조로 옮기면 이렇게 돼요.

 두 가지 관계를 겹쳐 쌓기 (중첩 자료구조)

  postsByMember:  Map<Long, List<Post>>
   ┌─ 1L(minji)  → [ 제주 노을, 카페 투어 ]        ← 한 사람 : 여러 글 (List)
   └─ 2L(jaehoon)→ [ ... ]

  likesByPost:    Map<Long, Set<Long>>
   ┌─ 100L(글)   → { 2L, 3L }                      ← 한 글 : 좋아요 회원 (Set)
   │                  ↑ 2L 이 또 눌러도 한 번만! (중복 무시)
   └─ 101L(글)   → { ... }

Map<Long, List<Post>> 는 "회원 번호 → 글 목록" 이에요. 값이 List<Post> 인 게 보이죠? 회원 한 명이 여러 글을 쓰니까 값 자리에 List 가 들어간 거예요. Map<Long, Set<Long>> 은 "글 번호 → 좋아요 누른 회원 번호 집합" 이에요. 값이 Set 이라 같은 사람이 두 번 좋아요를 눌러도 한 번만 세져요. 키로 쓴 Long(롱) 은 큰 정수를 담는 타입인데, 번호(id) 표현에 흔히 써요. 코드로 봐요.

// com/instagram/javabasic/collection/FeedRepository.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.instagram.javabasic.domain.post.Post;

public class FeedRepository {

    // 회원 번호 → 그 회원이 쓴 글 목록
    private final Map<Long, List<Post>> postsByMember = new HashMap<>();

    // 글 번호 → 그 글에 좋아요 누른 회원 번호 집합
    private final Map<Long, Set<Long>> likesByPost = new HashMap<>();

    // 회원이 글 하나를 올려요. 그 회원의 목록이 아직 없으면 새로 만들어 담아요.
    public void addPost(Long memberId, Post post) {
        List<Post> posts = postsByMember.get(memberId);
        if (posts == null) {
            posts = new ArrayList<>();
            postsByMember.put(memberId, posts);
        }
        posts.add(post);
    }
}

addPost 안의 흐름을 짚어볼게요. 회원이 글을 올리면, 먼저 get(memberId) 로 그 회원의 글 목록을 꺼내봐요. 그런데 처음 글을 올리는 회원이면 목록이 아직 없겠죠? 그럼 getnull 을 줘요. 그래서 if (posts == null) 로 검사해서, 없으면 새 ArrayList 를 만들어 사물함에 넣어둬요. 그 다음 글을 add 하고요. "사물함에 칸이 없으면 새 칸을 만들고, 있으면 그 칸에 더한다" 는 아주 흔한 패턴이에요.

좋아요 쪽도 같은 흐름이에요.

// 어떤 회원이 글에 좋아요를 눌러요. Set 에 담기 때문에 같은 사람이 또 눌러도 한 번만 세져요.
public void like(Long postId, Long memberId) {
    Set<Long> likers = likesByPost.get(postId);
    if (likers == null) {
        likers = new HashSet<>();
        likesByPost.put(postId, likers);
    }
    likers.add(memberId);
}

// 그 글의 좋아요 수 — 좋아요 누른 회원 집합의 크기예요(중복 없이).
public int likeCount(Long postId) {
    Set<Long> likers = likesByPost.get(postId);
    if (likers == null) {
        return 0;
    }
    return likers.size();
}

like 도 똑같이 "없으면 새 HashSet 만들고, 있으면 거기 더한다" 예요. 다만 글 목록은 List, 좋아요는 Set 인 게 핵심 차이예요. likeCount 는 그 Set 의 크기를 돌려줘요. Set 이라 중복이 없으니, 크기가 곧 "서로 다른 사람 몇 명이 좋아요를 눌렀나" 가 되죠.

자, 오늘 오프닝에서 던진 그 질문 — "같은 사람이 좋아요 두 번 누르면?" 을 직접 실행으로 확인해봐요.

FeedRepository repo = new FeedRepository();

Long minjiId = 1L;
repo.addPost(minjiId, new Post("제주 노을", "minji", 0));
repo.addPost(minjiId, new Post("카페 투어", "minji", 0));
System.out.println("minji 가 쓴 글 수: " + repo.getPostsOf(minjiId).size()); // 2

Long postId = 100L;
repo.like(postId, 2L);
repo.like(postId, 2L); // 같은 사람이 또 좋아요 — Set 이 무시해요
System.out.println("글 100 의 좋아요 수: " + repo.likeCount(postId)); // 2

(실제 코드에서는 회원 3L 도 좋아요를 눌러요.) minji 가 쓴 글은 List 에 차곡차곡 쌓여 2 개. 그리고 회원 2L 이 두 번 좋아요를 눌렀지만, Set 이 중복을 무시해서 좋아요 수는 2(2L 과 3L) 로 정확히 세져요. List 만 썼다면 3으로 잘못 세졌을 일을, Set 이 깔끔하게 막아준 거죠.

이게 오늘 배운 도구들의 진짜 힘이에요. 글 목록처럼 순서대로 쌓는 건 List, 좋아요처럼 중복을 막아야 하는 건 Set, 번호로 바로 찾는 건 Map. 각 그릇의 성격을 알면, 자료를 어디에 담을지 자연스럽게 손이 가요.

💡 튜터의 결론

자료구조는 겹겹이 쌓을 수 있어요. Map<Long, List<Post>>(회원→글 목록), Map<Long, Set<Long>>(글→좋아요 집합) 처럼요. "순서대로 쌓기는 List, 중복 막기는 Set, 번호로 찾기는 Map" 이라는 성격만 알면, 복잡한 관계도 알맞은 그릇을 골라 깔끔하게 담을 수 있어요.


마무리 — 오늘 배운 것 압축 요약

  • Step 1: Set(HashSet)은 같은 값을 한 번만 담아요. 좋아요처럼 중복을 막아야 할 때 써요. add·contains·remove·size.
  • Step 2: HashSet은 빠르고 순서 없음, TreeSet은 자동 사전순 정렬(대신 살짝 느림). 정렬이 필요할 때만 TreeSet.
  • Step 3: Map(HashMap)은 이름표(키)→값 사물함. 키만 대면 단번에 꺼내요. put·get·containsKey·getOrDefault.
  • Step 4: HashSet·HashMaphashCode로 칸을 정하고 equals로 비교해요. 둘은 짝꿍 — equals로 같으면 hashCode도 같아야 해요.
  • Step 5: TreeMap은 키가 자동 정렬되는 Map. firstKey·lastKey·entrySet 순회.
  • Step 6: 순회 중 삭제는 향상된 for로 하면 예외가 터져요. Iterator(순회 도우미)의 remove로 안전하게.
  • Step 7: List.of·Set.of·Map.of는 못 바꾸는 불변 컬렉션. 절대 안 바뀔 값을 코드로 못 박을 때.
  • Step 8: List·Set·Map을 겹쳐 쌓아 피드 저장소를 만들었어요. 성격에 맞는 그릇 고르기.

다음 시간엔 — 꺾쇠 <>의 정체, 제네릭

오늘 내내 우리는 Set<String>·Map<String, Member>·List<Post> 처럼 꺾쇠 <> 안에 타입을 적었어요. 지난 시간 List<String> 부터 계속 써왔지만, 사실 이 꺾쇠가 정확히 무엇인지는 "타입을 적는 약속" 정도로만 알고 넘어갔죠.

다음 시간엔 이 꺾쇠의 정체, 제네릭(generic) 을 정면으로 다뤄요. 지금까지는 자바가 만들어둔 List<String> 을 가져다 쓰기만 했는데, 다음 시간엔 내가 직접 <T> 같은 꺾쇠를 만들어서 "어떤 타입이든 담을 수 있는 유연한 그릇" 을 손수 설계하는 법을 배워요.

그리고 오늘 슬쩍 지나간 신기한 점 하나 — Map 에서 get 으로 꺼낼 때 형변환((Member) 같은 캐스팅)을 한 번도 안 했죠? 분명 Member 객체가 그대로 나왔는데요. 옛날 자바였다면 꺼낼 때마다 형변환을 해야 했어요. 왜 지금은 안 해도 되는지, 그 비밀도 제네릭에서 풀려요. 컬렉션을 자유자재로 다루게 된 오늘, 그 컬렉션을 받쳐주는 토대를 다음 시간에 만나요. 수고 많으셨어요!


과제

오늘 배운 Set·Map·equals/hashCode·TreeMap 을 손에 익히는 과제예요. 모두 오늘까지 배운 문법(클래스·메서드·List/Set/Map·제네릭 소비·equals/hashCode·향상된 for·예외 처리)만으로 풀 수 있어요. 람다·Stream은 아직 안 배웠으니, 순회는 향상된 for나 Iterator 로 해주세요.

[기초] 과제 1 — 해시태그 중복 정리기

해야 할 일

여러 게시물에서 모은 해시태그를 중복 없이 정리하고, 사전순으로 보여주는 TagCollector 클래스를 작성해보세요.

요구사항

  • private final Set<String> tags = new HashSet<>(); 필드를 둬요.
  • boolean add(String tag) — 태그를 담고, 새로 담겼으면 true, 이미 있으면 false 를 돌려줘요(Set.add 의 반환값을 그대로 활용).
  • boolean has(String tag) — 그 태그가 있는지.
  • int size() — 지금 서로 다른 태그 개수.
  • List<String> sorted() — 태그를 사전순으로 정렬List 로 돌려줘요.

힌트

  • 중복 자동 제거는 Set 이 알아서 해줘요. 우리가 contains 검사를 따로 안 해도 돼요.
  • 사전순 정렬은 TreeSet 에 옮겨 담거나(new TreeSet<>(tags)), 지난 시간 Collections.sort 를 활용하는 두 가지 길이 있어요. 둘 중 하나를 골라보세요.

[기초~중] 과제 2 — 회원 사물함 (username → Member)

해야 할 일

username 을 키로 회원을 빠르게 찾는 MemberDirectory 클래스를 작성해보세요.

요구사항

  • private final Map<String, Member> directory = new HashMap<>(); 필드를 둬요. (Member 는 오늘 배운 그 클래스를 그대로 써요.)
  • void register(Member member)member.getUsername() 을 키로 등록해요.
  • Member find(String username) — 키로 찾되, 없으면 null 대신 예외(NoSuchElementException 이나 직접 만든 예외)를 던지도록 해보세요. Day 17에서 배운 예외를 떠올려요.
  • boolean isRegistered(String username)containsKey 로 등록 여부 확인.
  • int count() — 등록된 회원 수.

힌트

  • find 에서 "없으면 예외" 는 if (!directory.containsKey(username)) throw ...; 패턴이 깔끔해요.
  • getOrDefault 와 비교해보면 좋아요. "없을 때 기본값을 줄까, 예외를 던질까" 는 상황에 따라 골라요.

[심화] 과제 3 — 팔로워 추천: 친구의 친구 (Set 교집합)

해야 할 일

"내가 팔로우하는 사람들이 공통으로 팔로우하는 사람" 을 추천 후보로 뽑는 FollowRecommender 를 작성해보세요. 두 Set 의 교집합(공통 원소) 을 구하는 게 핵심이에요.

상황

minji 와 jaehoon 을 둘 다 팔로우하는 사람이 있다면, 그 사람은 나에게 좋은 추천 후보겠죠. 각 사람이 팔로우하는 username 명단을 Set<String> 으로 받아, 두 명단에 공통으로 들어 있는 사람 을 골라내요.

요구사항

  • Set<String> common(Set<String> a, Set<String> b)ab 양쪽에 모두 들어 있는 username 만 모아 새 Set 으로 돌려줘요.
  • 원본 a·b 는 바꾸지 말고, 결과는 새 HashSet 에 담아요.
  • main 에서 두 명단을 만들어 교집합 결과를 출력해보세요.

힌트

  • 한쪽 Set 을 향상된 for로 돌면서, b.contains(name)true 인 것만 결과 Setadd 하면 교집합이 돼요.
  • (참고: 자바 Set 에는 retainAll 이라는 교집합 메서드도 있지만, 그건 원본을 바꿔버려요. 원본 보존을 위해 새 Set 을 만드는 방식으로 직접 짜보는 게 이 과제의 목표예요.)

생각해볼 주제

오늘 배운 Set·Map 뒤에는 "자바가 왜 이렇게 만들었을까?" 하는 질문이 숨어 있어요. 정답을 외우기보다 곰곰이 생각해보면 도구를 보는 눈이 깊어져요.

주제 1 — HashSet 은 어떻게 중복을 "한 번에" 찾을까?

List 에서 중복을 막으려면 contains 로 처음부터 끝까지 훑어야 했어요. 그런데 HashSet 은 명단이 100만 개여도 중복을 거의 즉시 걸러내요. 어떻게 다 비교하지도 않고 "이미 있다" 를 알까요? Step 4에서 본 hashCode 로 칸을 먼저 정하는 구조를 떠올리며, "칸으로 먼저 좁힌다" 는 게 왜 이렇게 빠른지 생각해보세요.

주제 2 — HashMapTreeMap, 언제 무엇을 쓸까?

HashMap 은 빠르지만 순서가 없고, TreeMap 은 키가 정렬되는 대신 살짝 느려요. "회원 정보를 username 으로 빠르게 찾기" 와 "날짜순으로 정렬된 활동 기록 보여주기" — 두 경우에 각각 어느 쪽이 어울릴까요? 정렬이 필요 없는데도 습관적으로 TreeMap 을 쓰면 어떤 손해가 있을지도 함께 생각해보세요.

주제 3 — 불변 컬렉션이 왜 "안전" 한가?

List.of 로 만든 목록은 바꾸려 하면 예외가 터져요. 언뜻 보면 "바꿀 수도 있어야 편한 거 아닌가?" 싶지만, 실무에서는 일부러 못 바꾸게 막는 경우가 많아요. 여러 곳에서 같은 목록을 함께 쓸 때, 어느 한 곳이 그 목록을 슬쩍 바꿔버리면 어떤 사고가 날 수 있을지 떠올려보세요. "못 바꾼다" 는 제약이 오히려 어떻게 안전을 주는지 나만의 답을 세워보세요.

✅ 예시 답안정답 보기

오늘 과제는 "중복은 Set 에게, 짝지어 찾기는 Map 에게 맡기기" 가 핵심이에요. 세 과제 모두 정답이 하나는 아니에요. 아래 예시와 다르게 풀었더라도, 요구사항을 만족하고 오늘 배운 Set·Map·TreeSet 을 제대로 썼다면 훌륭한 답이에요. 순회는 람다·Stream 없이 향상된 for로만 했어요.


과제 1 예시답안 — 해시태그 중복 정리기

핵심 접근

이 과제의 진짜 주제는 "Set 이 중복을 알아서 막아준다" 는 거예요. 지난 시간엔 List + contains 로 직접 걸렀지만, 오늘은 HashSet 에 그냥 add 만 하면 중복이 자동으로 사라져요. 그리고 Set.add 가 "새로 담겼으면 true, 이미 있으면 false" 를 돌려주니, 그 값을 그대로 반환하면 끝이에요. 사전순 정렬은 TreeSet 에 옮겨 담는 길을 골랐어요.

예시 구현

// com/instagram/javabasic/collection/solution/day19/TagCollector.java
class TagCollector {

    private final Set<String> tags = new HashSet<>();

    // Set.add 는 새로 담겼으면 true, 이미 있으면 false 를 돌려줘요.
    // 우리가 contains 로 따로 검사하지 않아도 중복은 알아서 걸러져요.
    boolean add(String tag) {
        return tags.add(tag);
    }

    boolean has(String tag) {
        return tags.contains(tag);
    }

    int size() {
        return tags.size();
    }

    // 사전순 정렬은 TreeSet 에 옮겨 담으면 자동으로 정렬돼요.
    // 그 순서를 그대로 List 에 담아 돌려줘요.
    List<String> sorted() {
        TreeSet<String> ordered = new TreeSet<>(tags);
        List<String> result = new ArrayList<>();
        for (String tag : ordered) {
            result.add(tag);
        }
        return result;
    }
}

addreturn tags.add(tag); 한 줄이라는 게 핵심이에요. Set 이 중복 판단과 반환값을 동시에 처리해주니, 우리가 손댈 게 없어요. sortednew TreeSet<>(tags) 로 옮겨 담는 순간 사전순으로 줄을 서고, 그 순서를 향상된 for로 List 에 옮겼어요.

채점 포인트

항목 배점 기준
Set 으로 중복 방지 contains 직접 검사 없이 Set 에 맡기는가
add 반환값 Set.add 의 boolean 결과를 그대로 돌려주는가
has·size 위임 contains·size() 를 직접 카운터 없이 위임하는가
사전순 정렬 TreeSet(또는 Collections.sort)로 정렬하는가
향상된 for 순회 인덱스 없이 for (String tag : ...) 로 도는가

흔한 실수

  • List + contains 로 다시 풀기 → 지난 시간 방식 그대로면 오늘 배운 의미가 사라져요. 중복 방지가 목적이면 Set 이 더 어울려요.
  • add 반환값 직접 만들기if (tags.contains(tag)) return false; tags.add(tag); return true; 처럼 길게 푸는 경우. return tags.add(tag); 한 줄이면 같은 동작이에요.
  • HashSet 순회로 정렬 기대HashSet 은 순서를 보장하지 않아요. 정렬이 필요하면 TreeSet 으로 옮기거나 Collections.sort 를 거쳐야 해요.

실무 개선 포인트 (심화)

지금은 sorted() 가 호출될 때마다 TreeSet 을 새로 만들어 정렬해요. 만약 "항상 정렬된 상태로만 쓴다" 면 처음부터 필드를 TreeSet<String> 으로 두는 선택도 있어요. 다만 그러면 담을 때마다 정렬 비용이 조금씩 들어요. "자주 담고 가끔 정렬해 보여준다" 면 지금처럼 HashSet 으로 담고 꺼낼 때 정렬하는 게 더 효율적이에요. 어느 동작이 잦은지를 보고 자료구조를 고르는 감각, 그게 컬렉션을 잘 쓰는 첫걸음이에요.


과제 2 예시답안 — 회원 사물함 (username → Member)

핵심 접근

Map<String, Member> 로 username 을 키 삼아 회원을 짝지어 두는 게 1단계예요. 오늘 수업의 사물함 패턴 그대로예요. 다른 점은 find 에서 "없으면 null" 대신 "없으면 예외" 로 바꾸는 거예요. containsKey 로 먼저 확인하고, 없으면 NoSuchElementException 을 던져요. Day 17에서 배운 예외를 떠올리면 자연스러워요.

예시 구현

// com/instagram/javabasic/collection/solution/day19/MemberDirectory.java
class MemberDirectory {

    private final Map<String, Member> directory = new HashMap<>();

    // username 을 키로 회원을 등록해요.
    void register(Member member) {
        directory.put(member.getUsername(), member);
    }

    // 키로 찾되, 없으면 null 대신 예외를 던져요.
    // "없을 때 조용히 null 을 주는 것" 보다 "없다고 분명히 알리는 것" 이 사고를 줄여요.
    Member find(String username) {
        if (!directory.containsKey(username)) {
            throw new NoSuchElementException("등록되지 않은 회원이에요: " + username);
        }
        return directory.get(username);
    }

    boolean isRegistered(String username) {
        return directory.containsKey(username);
    }

    int count() {
        return directory.size();
    }
}

findif (!directory.containsKey(username)) throw ...; 가 이 과제의 포인트예요. 키가 없을 때 null 을 돌려주면, 그 null 을 받은 쪽이 한참 뒤에 엉뚱한 곳에서 터질 수 있어요. 차라리 찾는 그 순간에 "없다" 고 분명히 알리면, 문제가 어디서 났는지 바로 보여요. register·isRegistered·countMapput·containsKey·size 에 그대로 맡겼어요.

main 으로 동작을 확인해 볼게요.

// com/instagram/javabasic/collection/solution/day19/MemberDirectory.java
public static void main(String[] args) {
    MemberDirectory dir = new MemberDirectory();
    dir.register(new Member("minji", 8500, 150, 12, 400));
    dir.register(new Member("jaehoon", 1240, 42, 3, 120));

    System.out.println("등록 수: " + dir.count());
    System.out.println("minji 등록됨? " + dir.isRegistered("minji"));
    System.out.println("찾은 사람: " + dir.find("minji"));

    try {
        dir.find("unknown");
    } catch (NoSuchElementException e) {
        System.out.println("예외로 막힘: " + e.getMessage());
    }
}

실행하면 이런 결과가 나와요.

등록 수: 2
minji 등록됨? true
찾은 사람: @minji (팔로워 8500, 점수 ...점)
예외로 막힘: 등록되지 않은 회원이에요: unknown

없는 이름을 찾으면 null 이 슬쩍 나오는 게 아니라, 예외가 터져서 바로 눈에 띄어요.

채점 포인트

항목 배점 기준
Map 짝짓기 put(username, member) 로 키-값 등록하는가
없을 때 예외 containsKey 확인 후 없으면 예외를 던지는가
isRegistered containsKey 로 등록 여부를 확인하는가
count 위임 직접 카운터 없이 directory.size() 에 맡기는가
예외 메시지 어떤 username 이 없었는지 메시지에 담는가

흔한 실수

  • null 그대로 반환return directory.get(username); 만 두면 없을 때 null 이 나와요. 과제 요구는 "없으면 예외" 예요.
  • get 으로 존재 확인if (directory.get(username) == null) 로 검사하는 경우. 값이 진짜 null 일 수도 있어 헷갈려요. 존재 여부는 containsKey 가 정확해요.
  • 예외 메시지 비움throw new NoSuchElementException(); 처럼 메시지가 없으면, 나중에 무엇이 없었는지 알기 어려워요. 키를 메시지에 담아줘요.

실무 개선 포인트 (심화)

"없을 때 예외" 와 "없을 때 기본값"(getOrDefault) 은 둘 다 맞는 선택이에요. 차이는 "없는 게 정상인가, 비정상인가" 예요. 로그인하려는 회원이 없다면 그건 비정상이니 예외가 어울려요. 반대로 "프로필 사진이 없으면 기본 이미지를 보여준다" 처럼 없는 게 흔하고 정상이면 getOrDefault 가 더 깔끔해요. 같은 Map 이라도 상황에 따라 "없음" 을 다르게 다룬다는 감각을 익혀두면, 다음 과목에서 회원 조회 로직을 짤 때 그대로 쓰여요.


과제 3 예시답안 — 팔로워 추천: 친구의 친구 (Set 교집합)

핵심 접근

Set 양쪽에 모두 들어 있는 원소(교집합) 를 직접 구하는 게 목표예요. 한쪽 Set 을 향상된 for로 돌면서, 다른 쪽에도 contains 로 있는지 확인하고, 양쪽에 다 있는 것만 새 Set 에 담아요. retainAll 을 쓰면 원본이 바뀌니, 일부러 새 HashSet 을 만들어 원본을 보존하는 게 이 과제의 숨은 목표예요.

예시 구현

// com/instagram/javabasic/collection/solution/day19/FollowRecommender.java
class FollowRecommender {

    // a 와 b 양쪽에 모두 들어 있는 username 만 모아 새 Set 으로 돌려줘요.
    // 원본 a·b 는 건드리지 않아요(새 HashSet 에만 담아요).
    Set<String> common(Set<String> a, Set<String> b) {
        Set<String> result = new HashSet<>();
        for (String name : a) {
            if (b.contains(name)) {
                result.add(name);
            }
        }
        return result;
    }

    public static void main(String[] args) {
        FollowRecommender recommender = new FollowRecommender();

        // minji 가 팔로우하는 사람들
        Set<String> minjiFollows = new HashSet<>();
        minjiFollows.add("seoyeon");
        minjiFollows.add("doyun");
        minjiFollows.add("hana");

        // jaehoon 이 팔로우하는 사람들
        Set<String> jaehoonFollows = new HashSet<>();
        jaehoonFollows.add("doyun");
        jaehoonFollows.add("hana");
        jaehoonFollows.add("minsu");

        Set<String> recommended = recommender.common(minjiFollows, jaehoonFollows);

        System.out.println("minji 팔로잉: " + minjiFollows.size() + "명");
        System.out.println("jaehoon 팔로잉: " + jaehoonFollows.size() + "명");
        System.out.println("공통(추천 후보): " + recommended);
    }
}

for (String name : a) 로 한쪽을 돌면서 b.contains(name)true 인 것만 골라 담는 게 교집합의 정체예요. Set.contains 는 명단이 길어도 거의 즉시 답하니(Step 4의 해시 구조 덕분), 두 명단이 커도 빠르게 공통을 찾아요. 결과는 새 HashSet 에만 담아, 원본 a·b 는 그대로 남아요.

실행하면 이런 결과가 나와요.

minji 팔로잉: 3명
jaehoon 팔로잉: 3명
공통(추천 후보): [doyun, hana]

doyunhana 가 양쪽 명단에 모두 있어서 추천 후보로 뽑혔어요. (출력 순서는 HashSet 이라 다를 수 있어요.)

채점 포인트

항목 배점 기준
교집합 로직 한쪽 순회 + 다른 쪽 contains 로 공통을 골라내는가
Set 반환 결과를 새 HashSet 에 담아 돌려주는가
원본 보존 a·b 를 바꾸지 않는가(retainAll 미사용)
향상된 for 인덱스 없이 for (String name : a) 로 도는가
main 검증 두 명단을 만들어 교집합 결과를 출력하는가

흔한 실수

  • retainAll 로 풀기a.retainAll(b); 는 교집합을 구하긴 하지만 a 원본을 바꿔버려요. 과제는 원본 보존이 목표라 새 Set 을 만들어야 해요.
  • 양쪽 다 순회ab 를 이중 for로 다 도는 경우. 한쪽만 돌고 다른 쪽은 contains 로 확인하면 충분하고, 더 빨라요.
  • 결과를 a 에 담기 → 골라낸 걸 a 에 다시 add 하는 경우. 원본이 오염돼요. 빈 새 Set 에만 담아요.

실무 개선 포인트 (심화)

지금은 a 를 돌면서 b.contains 를 확인했는데, 두 명단의 크기가 많이 다르면 "작은 쪽을 도는 게" 유리해요. 1만 명을 도는 것보다 100명을 돌면서 1만 명 쪽에 contains 하는 게 검사 횟수가 적거든요. contains 비용은 양쪽 모두 거의 즉시라서, 순회하는 쪽을 작은 명단으로 잡으면 그만큼 빨라져요. "교집합은 작은 집합을 기준으로 돈다" 는 작은 습관이 큰 데이터에서 차이를 만들어요.


생각해볼 주제 1 예시답안 — HashSet 은 어떻게 중복을 "한 번에" 찾을까?

[문제 상황 요약]

List 에서 중복을 막으려면 contains 로 처음부터 끝까지 훑어야 했어요. 그런데 HashSet 은 명단이 100만 개여도 중복을 거의 즉시 걸러내요. 다 비교하지도 않고 어떻게 "이미 있다" 를 알까요? Step 4에서 본 hashCode 로 칸을 먼저 정하는 구조를 떠올리며 생각해보는 주제예요.

[튜터의 가이드 및 해설]

핵심은 "전부 비교하지 않고, 칸으로 먼저 좁힌다" 예요. Listcontains 는 1번부터 100만 번까지 하나하나 같은지 물어봐요. 명단이 길수록 검사 횟수도 그만큼 늘어요. 반면 HashSet 은 값을 넣을 때 먼저 hashCode 로 "몇 번 칸에 둘지" 를 계산해요. 그래서 나중에 "이 값 있어?" 라고 물으면, 같은 계산으로 곧장 그 칸으로 가서 거기에 있는 몇 개만 equals 로 비교해요.

비유하면 도서관에서 책을 찾는 것과 같아요. 책을 아무 데나 꽂아두면 한 권 찾으려고 모든 책장을 다 봐야 해요(List 방식). 하지만 "ㄱ 으로 시작하는 책은 1번 책장" 처럼 규칙으로 칸을 정해두면, 찾을 때도 그 규칙으로 바로 1번 책장만 가면 돼요(HashSet 방식). 칸을 정하는 규칙이 바로 hashCode 예요.

그래서 명단이 100만 개여도, 칸 하나에 들어 있는 값은 보통 몇 개뿐이라 비교가 거의 즉시 끝나요. 단, 이 빠름에는 조건이 있어요. hashCode 가 값들을 여러 칸에 골고루 나눠줘야 해요. 만약 모두가 같은 칸으로 몰리면 그 칸 안에서 다시 List 처럼 하나하나 비교하게 돼서 빠름이 사라져요. Step 4에서 equals 를 바꾸면 hashCode 도 같이 맞춰야 한다고 했던 이유가 여기 있어요. 둘이 어긋나면 칸 계산이 틀려서, 같은 사람을 다른 칸에 넣어버리고 중복을 못 걸러요.

🎯 면접관을 홀리는 핵심 멘트

"List.contains 는 전부 순회해 명단 크기에 비례하지만, HashSethashCode 로 먼저 저장할 칸을 정하고 그 칸 안에서만 equals 로 비교합니다. 그래서 데이터가 아무리 많아도 평균적으로 거의 일정한 시간에 중복을 찾죠. 단 이건 hashCode 가 값을 여러 칸에 고르게 분산한다는 전제 위에서 성립하고, equalshashCode 가 일관되게 정의돼 있어야 한다는 점이 핵심입니다."


생각해볼 주제 2 예시답안 — HashMapTreeMap, 언제 무엇을 쓸까?

[문제 상황 요약]

HashMap 은 빠르지만 순서가 없고, TreeMap 은 키가 정렬되는 대신 살짝 느려요. "회원 정보를 username 으로 빠르게 찾기" 와 "날짜순으로 정렬된 활동 기록 보여주기" — 두 경우에 각각 어느 쪽이 어울릴까요? 정렬이 필요 없는데 습관적으로 TreeMap 을 쓰면 어떤 손해가 있을지도 생각해보는 주제예요.

[튜터의 가이드 및 해설]

판단 기준은 딱 하나예요. "키 순서가 필요한가, 아닌가." username 으로 회원을 찾을 때는 순서가 전혀 필요 없어요. 그냥 "minji" 라는 이름표로 그 사람을 빨리 꺼내기만 하면 돼요. 이럴 땐 HashMap 이 정답이에요. 칸 계산으로 곧장 찾아가니 가장 빠르거든요.

반대로 활동 기록을 날짜순으로 보여줘야 한다면 얘기가 달라져요. TreeMap 은 키(날짜)를 넣는 순간 자동으로 정렬된 자리에 꽂아둬요. 그래서 꺼낼 때 따로 정렬할 필요 없이 처음부터 끝까지 돌면 날짜순으로 나와요. "넣을 때 정렬해두고, 꺼낼 때 정렬된 채로 받는" 게 TreeMap 의 장점이에요.

문제는 "순서가 필요 없는데 습관적으로 TreeMap 을 쓰는" 경우예요. TreeMap 은 정렬된 자리를 유지하려고 넣을 때마다 키를 비교해 위치를 잡아요. 순서가 필요 없다면 이 비교는 전부 헛수고예요. 안 써도 될 정렬 비용을 계속 치르는 셈이죠. 그래서 기본은 HashMap 으로 두고, "정렬이 진짜 필요할 때만" TreeMap 으로 바꾸는 게 좋아요.

🎯 면접관을 홀리는 핵심 멘트

"선택 기준은 '키 순서가 필요한가' 하나입니다. username 으로 회원을 조회하듯 순서가 무의미하면 가장 빠른 HashMap 을, 날짜순 활동 기록처럼 정렬된 순회가 필요하면 TreeMap 을 씁니다. TreeMap 은 삽입할 때마다 정렬 위치를 잡는 비용을 치르므로, 정렬이 필요 없는데 습관적으로 쓰면 불필요한 오버헤드만 떠안습니다. 그래서 기본은 HashMap, 정렬 요구가 있을 때만 TreeMap 으로 가는 걸 원칙으로 합니다."


생각해볼 주제 3 예시답안 — 불변 컬렉션이 왜 "안전" 한가?

[문제 상황 요약]

List.of 로 만든 목록은 바꾸려 하면 예외가 터져요. 언뜻 "바꿀 수도 있어야 편한 거 아닌가?" 싶지만, 실무에서는 일부러 못 바꾸게 막는 경우가 많아요. 여러 곳에서 같은 목록을 함께 쓸 때 어느 한 곳이 그걸 슬쩍 바꿔버리면 어떤 사고가 날지 떠올려보는 주제예요.

[튜터의 가이드 및 해설]

핵심은 "여러 곳이 같은 목록을 나눠 쓸 때 생기는 사고" 예요. 컬렉션을 메서드로 넘기거나 다른 객체에 건네면, 사실은 같은 목록 하나를 둘이 가리키게 돼요. 사본이 아니라 같은 것이에요. 이때 한 곳이 add·remove 로 그 목록을 바꾸면, 그걸 같이 보던 다른 곳까지 영문도 모른 채 영향을 받아요.

예를 들어 "허용된 등급 목록(S·A·B)" 을 여러 화면이 함께 참조한다고 해봐요. 어느 화면의 코드가 실수로 그 목록에 "Z" 를 add 해버리면, 그 목록을 보던 모든 화면이 갑자기 "Z" 도 허용된 등급으로 알게 돼요. 더 무서운 건 이게 에러도 안 내고 조용히 번진다는 거예요. 한참 뒤 엉뚱한 화면에서 문제가 터지면 원인을 찾기가 정말 어려워요.

여기서 List.of 의 진가가 나와요. 불변으로 만들어두면 누가 실수로 add 를 부르는 순간 그 자리에서 예외가 터져요. 조용히 번지기 전에 "여긴 바꾸면 안 되는 값이야" 라고 즉시 막아주는 거예요. "못 바꾼다" 는 제약이 불편처럼 보이지만, 사실은 "아무도 몰래 바꿀 수 없으니 믿고 나눠 써도 된다" 는 안전을 줘요. 바뀔 일이 없는 값(고정 등급, 고정 안내 문구, 허용 권한 목록)일수록 불변으로 못 박아두면, 그 값을 둘러싼 모든 코드가 마음을 놓을 수 있어요.

🎯 면접관을 홀리는 핵심 멘트

"컬렉션을 여러 곳이 공유하면 같은 객체 하나를 함께 가리키게 되는데, 한 곳이 그걸 수정하면 다른 곳까지 조용히 영향을 받아 추적이 어려운 사고로 번집니다. List.of 같은 불변 컬렉션은 수정 시도 자체를 예외로 즉시 차단해, 공유해도 누구도 몰래 바꿀 수 없다는 신뢰를 보장합니다. 그래서 고정 등급이나 권한 목록처럼 바뀌어선 안 되는 값은 불변으로 선언해 의도를 코드에 못 박아 두는 걸 선호합니다."

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.