Day 19 — 컬렉션 (2): Set과 Map (중복 없는 명단, 이름표 사물함)
지난 시간 우리는 배열을 졸업하고 List 와 ArrayList 를 만났죠. 크기를 미리 안 정해도 알아서 늘어나고, 순서대로 담고 꺼내는 그릇이었어요. 그런데 List 에는 두 가지 성격이 있었어요. 순서가 있고(0번, 1번, 2번…), 중복도 허용한다(같은 #travel 을 두 번 add 하면 두 번 다 들어간다)는 거요.
이 두 성격이 가끔 곤란할 때가 있어요. 지난 시간 마지막에 던진 질문 기억나시죠. "이 게시물에 좋아요 누른 사람 목록에 같은 사람이 두 번 들어가면 안 되잖아요." 장원영이 실수로 좋아요 버튼을 두 번 눌렀다고 좋아요가 2로 세지면 곤란하겠죠. 또 하나, "username 으로 회원을 바로 찾기" 처럼 이름표(키)를 대면 값이 툭 튀어나오는 저장소도 필요했어요. List 로 찾으려면 처음부터 끝까지 훑어야 하니까요.
오늘은 이 두 가지를 풀어주는 그릇 두 개를 만나요. 중복을 자동으로 걸러주는 Set(세트, 중복 없는 명단), 그리고 이름표(키)와 값을 짝지어 저장하는 Map(맵, 이름표 사물함)이에요. 둘 다 List 와 형제 같은 도구라 금방 익숙해질 거예요. 그리고 "두 회원이 같은 사람인지" 를 판단하는 equals 와 hashCode 가 왜 짝꿍인지도 오늘 정확히 만나요. 자, Day 19 시작해봐요!
🎯 학습 목표
List로 좋아요를 관리할 때 생기는 중복 문제를 설명하고,HashSet으로add·contains·remove·size를 다룰 수 있어요.HashSet과TreeSet의 차이(속도 vs 자동 정렬)를 알고, 상황에 맞게 고를 수 있어요.HashMap으로 이름표(키)를 대면 값이 바로 나오는 저장소를 만들고,put·get·containsKey·getOrDefault를 쓸 수 있어요.equals와hashCode가 왜 짝꿍이어야 하는지 버킷 구조로 이해하고, 직접 만든 클래스를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" 라는 약속이에요. 쉼표로 둘을 나란히 적어요. Set 과 List 는 담는 것 하나만 적었는데, 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 이 중복으로 걸러줄까요?
지난 시간 끝에 "두 회원이 같은 사람인지 판단하는 equals 와 hashCode 가 왜 짝꿍인지 다음 시간에" 라고 했죠. 오늘이 그날이에요. 여기서 자바의 아주 중요한 약속(계약) 하나를 만나요.
먼저 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 이 같으면 "같은 사람" 이라고 해요. 그리고 hashCode 도 Objects.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 둘이 같은 사람으로 인식돼 하나로 합쳐진 거죠. equals 도 true, hashCode 도 같은 값이 나와요. 짝꿍이 잘 맞춰진 덕분이에요.
🙋 학생 질문 — "튜터님, equals 랑 hashCode 를 매번 손으로 써야 하나요? 복잡해 보여요."
좋은 질문이에요! 다행히 손으로 다 칠 필요는 거의 없어요. IntelliJ 같은 IDE 에서 클래스 안에 커서를 두고 메뉴(또는 단축키)로 "generate → equals() and hashCode()" 를 고르면, 어떤 필드를 기준으로 비교할지만 체크하면 자동으로 만들어줘요. username 만 체크하면 위와 똑같은 코드가 생겨요.
다만 자동 생성을 쓰더라도 "왜 둘이 짝꿍이어야 하는지" 는 알고 있어야 해요. 그래야 "username 으로만 같은지 보고 싶은데 IDE 가 모든 필드를 넣었네?" 같은 상황에서 직접 고칠 수 있거든요. 도구는 손이 편해지라고 있는 거고, 원리는 머리로 알고 있는 게 좋아요.
💡 튜터의 결론
HashSet·HashMap은 먼저hashCode로 칸을 정하고, 그 칸에서equals로 같은지 확인해요. 그래서 둘은 짝꿍이에요 — "equals로 같으면hashCode도 같아야 한다."equals를 username 기준으로 만들었으면hashCode도Objects.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·get 은 HashMap 과 똑같이 쓰는데, TreeMap 은 firstKey()(가장 작은 키)·lastKey()(가장 큰 키) 를 추가로 가지고 있어요. 키가 정렬돼 있으니 양 끝을 바로 알 수 있는 거죠. TreeSet 의 first()·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(순회 도우미) 를 꺼내hasNext→next→remove로 도우미에게 삭제를 맡기면 안전해요.
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());
}
세 컬렉션은 멀쩡히 출력되는데, tryModify 로 add 를 시도하니 예외가 잡혀요. "바뀌면 안 되는 값" 이라는 우리 의도가 코드로 단단히 지켜진 거죠.
보통 컬렉션 vs 불변 컬렉션
new ArrayList<>() → add/remove 자유롭게 (바뀔 수 있음)
List.of(...) → add 하면 거부 💥 (절대 안 바뀜)
"이 값은 절대 안 바뀌어야 해" 를 코드로 고정해 두는 안전장치
언제 쓸까요? "이 목록은 프로그램 내내 고정" 이라는 게 분명할 때 써요. 그러면 다른 누군가(또는 미래의 나)가 실수로 값을 바꾸려 해도 예외가 막아주니까, 버그를 미리 차단할 수 있어요. 반대로 자주 추가·삭제해야 하는 데이터는 보통 ArrayList·HashMap 을 써야겠죠.
💡 튜터의 결론
List.of·Set.of·Map.of는 한 번 만들면 못 바꾸는 불변 컬렉션을 간결하게 만들어줘요. 수정하려 하면UnsupportedOperationException으로 막혀요. "절대 바뀌면 안 되는 값" 을 코드로 고정해 실수를 막을 때 써요.
Step 8. 종합 실습 — 피드에 Set/Map 적용
오늘의 마지막은 지금까지 배운 List·Set·Map 을 한데 엮는 실습이에요. 작은 피드 저장소를 만들어볼게요. 인스타 피드에는 두 가지 관계가 있어요.
- "한 회원이 쓴 글들" — 한 사람이 여러 글을 쓰니까 "회원 → 글 목록" 이에요.
- "한 글에 좋아요 누른 사람들" — 같은 사람이 두 번 누르면 안 되니까 "글 → 좋아요 회원 집합" 이에요.
이걸 자료구조로 옮기면 이렇게 돼요.
두 가지 관계를 겹쳐 쌓기 (중첩 자료구조)
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) 로 그 회원의 글 목록을 꺼내봐요. 그런데 처음 글을 올리는 회원이면 목록이 아직 없겠죠? 그럼 get 이 null 을 줘요. 그래서 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·HashMap은hashCode로 칸을 정하고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)—a와b양쪽에 모두 들어 있는 username 만 모아 새Set으로 돌려줘요.- 원본
a·b는 바꾸지 말고, 결과는 새HashSet에 담아요. main에서 두 명단을 만들어 교집합 결과를 출력해보세요.
힌트
- 한쪽
Set을 향상된 for로 돌면서,b.contains(name)이true인 것만 결과Set에add하면 교집합이 돼요. - (참고: 자바
Set에는retainAll이라는 교집합 메서드도 있지만, 그건 원본을 바꿔버려요. 원본 보존을 위해 새Set을 만드는 방식으로 직접 짜보는 게 이 과제의 목표예요.)
생각해볼 주제
오늘 배운 Set·Map 뒤에는 "자바가 왜 이렇게 만들었을까?" 하는 질문이 숨어 있어요. 정답을 외우기보다 곰곰이 생각해보면 도구를 보는 눈이 깊어져요.
주제 1 — HashSet 은 어떻게 중복을 "한 번에" 찾을까?
List 에서 중복을 막으려면 contains 로 처음부터 끝까지 훑어야 했어요. 그런데 HashSet 은 명단이 100만 개여도 중복을 거의 즉시 걸러내요. 어떻게 다 비교하지도 않고 "이미 있다" 를 알까요? Step 4에서 본 hashCode 로 칸을 먼저 정하는 구조를 떠올리며, "칸으로 먼저 좁힌다" 는 게 왜 이렇게 빠른지 생각해보세요.
주제 2 — HashMap 과 TreeMap, 언제 무엇을 쓸까?
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;
}
}
add 가 return tags.add(tag); 한 줄이라는 게 핵심이에요. Set 이 중복 판단과 반환값을 동시에 처리해주니, 우리가 손댈 게 없어요. sorted 는 new 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();
}
}
find 의 if (!directory.containsKey(username)) throw ...; 가 이 과제의 포인트예요. 키가 없을 때 null 을 돌려주면, 그 null 을 받은 쪽이 한참 뒤에 엉뚱한 곳에서 터질 수 있어요. 차라리 찾는 그 순간에 "없다" 고 분명히 알리면, 문제가 어디서 났는지 바로 보여요. register·isRegistered·count 는 Map 의 put·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]
doyun 과 hana 가 양쪽 명단에 모두 있어서 추천 후보로 뽑혔어요. (출력 순서는 HashSet 이라 다를 수 있어요.)
채점 포인트
| 항목 | 배점 기준 |
|---|---|
| 교집합 로직 | 한쪽 순회 + 다른 쪽 contains 로 공통을 골라내는가 |
새 Set 반환 |
결과를 새 HashSet 에 담아 돌려주는가 |
| 원본 보존 | a·b 를 바꾸지 않는가(retainAll 미사용) |
| 향상된 for | 인덱스 없이 for (String name : a) 로 도는가 |
| main 검증 | 두 명단을 만들어 교집합 결과를 출력하는가 |
흔한 실수
retainAll로 풀기 →a.retainAll(b);는 교집합을 구하긴 하지만a원본을 바꿔버려요. 과제는 원본 보존이 목표라 새Set을 만들어야 해요.- 양쪽 다 순회 →
a와b를 이중 for로 다 도는 경우. 한쪽만 돌고 다른 쪽은contains로 확인하면 충분하고, 더 빨라요. - 결과를
a에 담기 → 골라낸 걸a에 다시add하는 경우. 원본이 오염돼요. 빈 새Set에만 담아요.
실무 개선 포인트 (심화)
지금은 a 를 돌면서 b.contains 를 확인했는데, 두 명단의 크기가 많이 다르면 "작은 쪽을 도는 게" 유리해요. 1만 명을 도는 것보다 100명을 돌면서 1만 명 쪽에 contains 하는 게 검사 횟수가 적거든요. contains 비용은 양쪽 모두 거의 즉시라서, 순회하는 쪽을 작은 명단으로 잡으면 그만큼 빨라져요. "교집합은 작은 집합을 기준으로 돈다" 는 작은 습관이 큰 데이터에서 차이를 만들어요.
생각해볼 주제 1 예시답안 — HashSet 은 어떻게 중복을 "한 번에" 찾을까?
[문제 상황 요약]
List 에서 중복을 막으려면 contains 로 처음부터 끝까지 훑어야 했어요. 그런데 HashSet 은 명단이 100만 개여도 중복을 거의 즉시 걸러내요. 다 비교하지도 않고 어떻게 "이미 있다" 를 알까요? Step 4에서 본 hashCode 로 칸을 먼저 정하는 구조를 떠올리며 생각해보는 주제예요.
[튜터의 가이드 및 해설]
핵심은 "전부 비교하지 않고, 칸으로 먼저 좁힌다" 예요. List 의 contains 는 1번부터 100만 번까지 하나하나 같은지 물어봐요. 명단이 길수록 검사 횟수도 그만큼 늘어요. 반면 HashSet 은 값을 넣을 때 먼저 hashCode 로 "몇 번 칸에 둘지" 를 계산해요. 그래서 나중에 "이 값 있어?" 라고 물으면, 같은 계산으로 곧장 그 칸으로 가서 거기에 있는 몇 개만 equals 로 비교해요.
비유하면 도서관에서 책을 찾는 것과 같아요. 책을 아무 데나 꽂아두면 한 권 찾으려고 모든 책장을 다 봐야 해요(List 방식). 하지만 "ㄱ 으로 시작하는 책은 1번 책장" 처럼 규칙으로 칸을 정해두면, 찾을 때도 그 규칙으로 바로 1번 책장만 가면 돼요(HashSet 방식). 칸을 정하는 규칙이 바로 hashCode 예요.
그래서 명단이 100만 개여도, 칸 하나에 들어 있는 값은 보통 몇 개뿐이라 비교가 거의 즉시 끝나요. 단, 이 빠름에는 조건이 있어요. hashCode 가 값들을 여러 칸에 골고루 나눠줘야 해요. 만약 모두가 같은 칸으로 몰리면 그 칸 안에서 다시 List 처럼 하나하나 비교하게 돼서 빠름이 사라져요. Step 4에서 equals 를 바꾸면 hashCode 도 같이 맞춰야 한다고 했던 이유가 여기 있어요. 둘이 어긋나면 칸 계산이 틀려서, 같은 사람을 다른 칸에 넣어버리고 중복을 못 걸러요.
🎯 면접관을 홀리는 핵심 멘트
"
List.contains는 전부 순회해 명단 크기에 비례하지만,HashSet은hashCode로 먼저 저장할 칸을 정하고 그 칸 안에서만equals로 비교합니다. 그래서 데이터가 아무리 많아도 평균적으로 거의 일정한 시간에 중복을 찾죠. 단 이건hashCode가 값을 여러 칸에 고르게 분산한다는 전제 위에서 성립하고,equals와hashCode가 일관되게 정의돼 있어야 한다는 점이 핵심입니다."
생각해볼 주제 2 예시답안 — HashMap 과 TreeMap, 언제 무엇을 쓸까?
[문제 상황 요약]
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같은 불변 컬렉션은 수정 시도 자체를 예외로 즉시 차단해, 공유해도 누구도 몰래 바꿀 수 없다는 신뢰를 보장합니다. 그래서 고정 등급이나 권한 목록처럼 바뀌어선 안 되는 값은 불변으로 선언해 의도를 코드에 못 박아 두는 걸 선호합니다."