문서 읽는 데 70분 · day20

Day 20 — 제네릭 (꺾쇠 <>의 정체, 내가 만드는 <T>)

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

지난 시간 우리는 Set(중복 없는 명단)과 Map(이름표 사물함)을 만났죠. 그러면서 내내 꺾쇠를 적었어요. Set<String>, Map<String, Member>, List<Post>… 이 꺾쇠 <> 안에 타입을 적는 걸 우리는 "이 그릇엔 이 타입만 담아요" 라는 약속 정도로만 알고 넘어갔어요.

그리고 지난 시간 끝에 슬쩍 신기한 점 하나를 짚었죠. Map 에서 get 으로 값을 꺼낼 때 형변환((Member) 같은 캐스팅)을 한 번도 안 했는데, Member 객체가 그대로 툭 나왔잖아요. 분명 사물함에서 꺼낸 건데 종류가 정확히 맞아떨어졌어요. 옛날 자바였다면 꺼낼 때마다 "이건 Member 야" 하고 직접 형변환을 해줘야 했어요. 왜 지금은 안 해도 되는지, 그 비밀을 오늘 풀어요.

오늘 주인공은 이 꺾쇠의 정체, 제네릭(generic) 이에요. 지금까지는 자바가 만들어둔 List<String> 을 가져다 쓰기만 했는데, 오늘은 내가 직접 <T> 라는 꺾쇠를 클래스에 달아서 "어떤 타입이든 담는 유연한 그릇" 을 손수 만들어봐요. 컬렉션을 자유자재로 다루게 된 지금, 그 컬렉션을 받쳐주는 토대를 정면으로 만나는 시간이에요. 자, Day 20 시작해봐요!

🎯 학습 목표

  • 제네릭(generic)이 "타입을 나중에 끼워 넣는 빈칸" 이라는 개념을 비유로 설명할 수 있어요.
  • 제네릭이 없던 시절 Object 와 형변환의 위험(ClassCastException)을 이해하고, 왜 제네릭이 더 안전한지 말할 수 있어요.
  • 직접 Box<T>·Pair<K, V> 같은 제네릭 클래스를 만들고, 형변환 없이 안전하게 값을 꺼낼 수 있어요.
  • 클래스 없이 메서드만 제네릭으로 만드는 법(<T> T first(...))을 익혀요.
  • 경계 타입(<T extends Comparable<T>>)으로 빈칸에 조건을 걸어, 비교가 필요한 동작을 안전하게 만들 수 있어요.
  • 와일드카드(? extends/? super)의 PECS 규칙을 큰 그림으로 이해하고, 회원 등급 계층에 적용해볼 수 있어요.
  • 제네릭 Repository<T> 하나로 회원 창고·게시물 창고를 찍어내고, 상속으로 전용 저장소를 만들 수 있어요.

Step 1. 꺾쇠 <>의 정체 — 제네릭이란?

먼저 코드 없이 개념부터 그림으로 잡고 갈게요. 제네릭을 한마디로 하면 "타입을 나중에 끼워 넣는 빈칸(틀)" 이에요.

붕어빵 틀을 떠올려보세요. 붕어빵 틀은 모양만 정해져 있고, 안에 무엇을 넣을지는 굽는 순간에 정해요. 팥을 넣으면 팥붕어빵, 슈크림을 넣으면 슈크림붕어빵이 나오죠. 틀은 하나인데, 넣는 속재료에 따라 결과물이 달라져요.

제네릭이 딱 이 붕어빵 틀이에요. "그릇" 이라는 틀은 하나만 만들어두고, "이 그릇엔 String 을 담을게" 또는 "이 그릇엔 Member 를 담을게" 하고 타입을 나중에 끼워 넣어요.

 붕어빵 틀  =  제네릭 틀(빈칸)

   틀  [   ?   ]        제네릭  Box< ? >
        │                        │
   팥을 넣으면 → 팥붕어빵     String 을 끼우면 → Box<String>  (이름 전용)
   슈크림 넣으면 → 슈크림붕어빵 Integer 를 끼우면 → Box<Integer> (숫자 전용)

   틀(코드)은 하나, 끼우는 재료(타입)에 따라 전용 그릇이 됨

지난 시간 우리가 쓴 List<String> 도 사실 이 구조였어요. 자바가 "리스트" 라는 틀(List<?>)을 미리 만들어뒀고, 우리는 그 빈칸에 String 을 끼워 List<String> 이라는 "문자열 전용 리스트" 를 만들어 쓴 거죠. 빈칸에 Post 를 끼우면 List<Post> 가 되고요.

그러니까 지금까지 우리는 남이 만든 틀을 가져다 쓰기만 했어요. 오늘은 이 틀을 내가 직접 만들어봐요. 빈칸을 가진 나만의 그릇을 설계하는 거예요.

💡 튜터의 결론

제네릭은 "타입을 나중에 끼워 넣는 빈칸(틀)" 이에요. 붕어빵 틀처럼 그릇은 하나만 만들고, 담을 타입은 쓸 때 정해요. 지금까지 List<String> 을 쓰기만 했다면, 오늘은 그 빈칸을 가진 그릇을 직접 만들어봐요.


Step 2. 제네릭이 없던 시절 — Object와 ClassCastException

내 손으로 그릇을 만들기 전에, 먼저 "제네릭이 없으면 어떤 불편이 있었나" 를 봐야 해요. 그래야 제네릭이 왜 고마운지 확 와닿거든요.

옛날 자바에는 제네릭이 없었어요. 그래서 "무엇이든 담는 만능 그릇" 을 만들려면 Object(오브젝트, 모든 클래스의 부모) 타입을 썼어요. Day 11에서 배웠죠? 모든 클래스는 결국 Object 를 물려받으니까, Object 타입 변수에는 String 이든 Member 든 무엇이든 담을 수 있어요.

// com/instagram/javabasic/generic/ObjectBox.java
public class ObjectBox {

    // 무엇이든 담을 수 있도록 Object 로 받아요. 그래서 타입 정보를 잃어버려요.
    private Object item;

    // 담기 — 어떤 객체든 그대로 들어가요.
    public void set(Object item) {
        this.item = item;
    }

    // 꺼내기 — Object 로 나와요. 쓰려면 우리가 직접 형변환을 해야 해요.
    public Object get() {
        return item;
    }
}

담는 건 편해요. 아무거나 들어가니까요. 그런데 문제는 꺼낼 때예요. get()Object 로 돌려주니까, 안에 String 을 넣었어도 나올 땐 그냥 "Object" 로 나와요. String 의 기능(예: length() 로 길이 구하기)을 쓰려면 우리가 직접 "이건 String 이야" 하고 형변환(casting, 타입 강제 변환)을 해줘야 해요.

ObjectBox box = new ObjectBox();
box.set("minji");

// 꺼낼 때 (String) 형변환이 꼭 필요해요. 안 그러면 String 의 기능을 못 써요.
String username = (String) box.get();
System.out.println("꺼낸 이름: " + username + " (길이 " + username.length() + ")");

(String) box.get() 처럼 앞에 (String) 을 붙여 "이걸 String 으로 취급해줘" 하고 강제로 변환해요. 그래야 username.length() 같은 String 기능을 쓸 수 있죠. 그릇이 안에 뭐가 들었는지 기억을 못 하니까, 꺼내는 사람이 매번 책임지고 형변환을 해야 하는 거예요.

여기서 진짜 사고가 터져요. 만약 형변환을 잘못하면 어떻게 될까요?

// 여기서 사고가 나요 — 안에는 String 이 들었는데 Integer 로 형변환하면
// 컴파일은 통과하지만 실행 중에 ClassCastException 이 터져요.
try {
    Integer wrong = (Integer) box.get();
    System.out.println(wrong);
} catch (ClassCastException e) {
    System.out.println("형변환 실패! 안에는 String 이 들었는데 Integer 로 꺼내려 했어요.");
}

안에는 분명 String("minji")이 들었는데, (Integer) 로 형변환을 시도했어요. 그런데 자바는 이걸 코드를 작성하는 순간(컴파일)에는 못 잡아요. "Object 니까 어쩌면 Integer 일 수도 있지" 하고 그냥 넘어가거든요. 그러다 실제로 프로그램을 실행해서 그 줄에 도달하는 순간, "어? 이건 String 인데 Integer 로 바꾸라고? 못 해!" 하면서 ClassCastException(형변환 실패 오류) 을 던져요.

 ObjectBox 의 위험 — 사고가 가장 늦게 들켜요

   코드 작성(컴파일)  →  "Object 니까 통과"   ✅  (못 잡음)
                                │
   실행 중 그 줄 도달  →  실제론 String 인데
                          Integer 로 형변환 시도
                          → ClassCastException 💥  (이제야 터짐)

   가장 늦게, 사용자 앞에서 터지는 게 제일 무서운 사고예요

이게 왜 무섭냐면, 코드를 짤 때는 멀쩡해 보이다가 실제 사용자가 그 기능을 쓰는 순간 터지기 때문이에요. 사고를 가장 늦게 발견하는 거죠. Day 17에서 배운 예외 중에서도, 이렇게 "실행 중에 갑자기" 터지는 부류예요.

지난 시간에 Map.get 으로 꺼낼 때 형변환을 한 번도 안 했던 게 신기하다고 했죠? 옛날 방식이라면 이렇게 매번 (Member) 형변환을 해야 했고, 잘못하면 위처럼 터졌을 거예요. 그 불편과 위험을 한 방에 없애주는 게 바로 다음 Step에서 만날 제네릭이에요.

참고로 이렇게 "실행 중에 갑자기 터지는 사고" 를 체계적으로 붙잡는 방법(try-catch 와 예외 계층)은 다음 시간에 본격적으로 다뤄요. 오늘은 위 코드에서 try-catch 로 살짝 감싸 "터지는 모습" 만 확인하고 넘어갈게요.

💡 튜터의 결론

제네릭이 없던 시절엔 Object 로 만능 그릇을 만들고, 꺼낼 때 직접 형변환을 했어요. 잘못 형변환하면 컴파일은 통과하고 실행 중에 ClassCastException 이 터져요 — 가장 늦게 들키는 무서운 사고죠. 이 위험을 없애려고 제네릭이 나왔어요.


Step 3. 내 손으로 만드는 제네릭 클래스 — Box<T>

이제 직접 만들 차례예요. Step 2의 ObjectBox 를 제네릭으로 바꿔볼게요. 핵심은 클래스 이름 옆에 붙는 <T> 딱 하나예요.

// com/instagram/javabasic/generic/Box.java
public class Box<T> {

    // T 타입 값 하나를 담아요. T 가 무엇인지는 객체를 만들 때 정해져요.
    private T item;

    // 담기 — T 타입만 받아요. 다른 타입을 넣으려 하면 컴파일 에러로 미리 막아줘요.
    public void set(T item) {
        this.item = item;
    }

    // 꺼내기 — T 타입 그대로 나와요. 형변환이 필요 없어요.
    public T get() {
        return item;
    }
}

ObjectBox 와 비교해보세요. Object 라고 적었던 자리가 전부 T 로 바뀌었고, 클래스 이름 옆에 <T> 가 붙었어요. 이 <T> 가 바로 Step 1의 붕어빵 틀 빈칸이에요. T 는 Type(타입)의 머리글자고요. "여기에 들어갈 타입은 객체를 만들 때 정할게요" 라는 빈칸 표시예요.

그럼 이 빈칸은 언제 채워질까요? 그릇을 만드는 순간이에요.

// T 자리에 String 을 끼웠어요. 이 그릇은 이제 String 전용이에요.
Box<String> nameBox = new Box<>();
nameBox.set("minji");
String name = nameBox.get();   // 형변환 (String) 이 필요 없어요!
System.out.println("이름 그릇: " + name + " (길이 " + name.length() + ")");

// T 자리에 Integer 를 끼우면 숫자 전용 그릇이 돼요.
Box<Integer> countBox = new Box<>();
countBox.set(8500);
int count = countBox.get();
System.out.println("숫자 그릇: " + count);

new Box<String>() 라고 만드는 순간, 빈칸 T 자리에 String 이 쏙 끼워져요. 그러면 이 그릇은 그 순간부터 "String 전용" 이 돼요. set 은 String 만 받고, get 은 String 을 그대로 돌려줘요.

여기서 ObjectBox 와 결정적으로 달라진 두 가지를 봐요.

첫째, 꺼낼 때 형변환이 사라졌어요. String name = nameBox.get();(String) 이 안 붙어 있죠? 그릇이 "나는 String 전용이야" 를 기억하고 있으니, 꺼내는 우리가 형변환할 필요가 없어요. 지난 시간 Map.get 에서 형변환이 없었던 비밀이 바로 이거예요 — Map<String, Member> 의 빈칸에 끼운 타입을 자바가 기억하고 있었던 거죠.

둘째, 엉뚱한 타입은 아예 못 넣어요. nameBox.set(123) 처럼 String 그릇에 숫자를 넣으려 하면, 실행도 하기 전에 코드를 작성하는 단계(컴파일)에서 빨간 줄이 그어져요. ObjectBox 는 실행 중에 터졌지만, Box<T> 는 아예 작성 단계에서 막아주는 거예요. 사고가 가장 빨리, 가장 안전하게 잡히는 거죠.

 ObjectBox  vs  Box<T>

   ObjectBox box;             Box<String> box;
   box.set("minji");          box.set("minji");
   Integer x = (Integer)      box.set(123);  ← 작성 단계에서
        box.get();  → 실행 중      바로 빨간 줄! (컴파일 에러)
        ClassCastException 💥      실행도 못 함, 안전

   "늦게 터지는 사고"  →  "아예 못 짜게 막기" 로 진화

마지막으로 이름 관례 하나만 짚을게요. 빈칸 이름은 꼭 T 일 필요는 없지만, 자바에서는 관례적으로 정해진 글자를 써요. 외우려 하지 말고 "아, 이런 약속이 있구나" 정도로 봐두세요.

글자 의미 주로 쓰는 곳
T Type (타입) 일반적인 타입 빈칸
E Element (원소) 컬렉션의 원소 (List<E>)
K Key (열쇠) Map 의 키
V Value (값) Map 의 값

💡 튜터의 결론

클래스 이름 옆에 <T> 를 붙이면 제네릭 클래스가 돼요. new Box<String>() 하는 순간 빈칸 T 에 String 이 끼워져요. 그래서 꺼낼 때 형변환이 필요 없고, 엉뚱한 타입은 작성 단계에서 바로 막혀요. ObjectBox 의 "늦게 터지는 사고" 가 "아예 못 짜게 막기" 로 진화한 거예요.


Step 4. 타입 매개변수 둘 — Pair<K, V>

빈칸은 하나만 둘 수 있는 게 아니에요. 두 개도 둘 수 있어요. 지난 시간 Map<String, Member> 처럼 꺾쇠 안에 쉼표로 둘을 나란히 적었던 거 기억나시죠? 그게 바로 빈칸 두 개예요.

"이름표 하나와 값 하나" 를 한 쌍으로 묶는 Pair<K, V>(페어, 짝) 를 만들어볼게요.

// com/instagram/javabasic/generic/Pair.java
public class Pair<K, V> {

    // 열쇠와 값 — 생성자에서 한 번 채우고 바꾸지 않아요(final).
    private final K key;
    private final V value;

    // 생성자 — 열쇠와 값을 한 번에 받아 한 쌍으로 묶어요.
    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    // 열쇠를 K 타입 그대로 돌려줘요 (형변환 불필요).
    public K getKey() {
        return key;
    }

    // 값을 V 타입 그대로 돌려줘요 (형변환 불필요).
    public V getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "(" + key + " → " + value + ")";
    }
}

클래스 옆에 <K, V> 두 칸이 보이죠. K 는 Key(열쇠), V 는 Value(값) 의 머리글자예요. Step 3의 Box 는 빈칸이 하나라 <T> 였는데, 여기는 두 가지 타입을 동시에 다루니까 빈칸도 두 개예요.

final 도 보이네요. Day 16에서 배운 그 final 이에요. 한 번 짝지은 열쇠와 값은 바뀌지 않도록 못 박아둔 거죠. 그래서 Pair 는 "한 번 묶으면 안 바뀌는 읽기 전용 묶음" 이 돼요.

써볼게요. 빈칸 두 개에 각각 다른 타입을 끼워요.

// K=String(이름표), V=Member(회원) 한 쌍을 묶었어요.
Member minji = new Member("minji", 8500, 150, 12, 400);
Pair<String, Member> entry = new Pair<>("minji", minji);

// 꺼낼 때 형변환이 필요 없어요 — getKey 는 String, getValue 는 Member 그대로 나와요.
String username = entry.getKey();
int followers = entry.getValue().getFollowers();
System.out.println("이름표: " + username + ", 팔로워: " + followers);

// 타입을 바꿔도 같은 Pair 틀을 그대로 써요 — K=String, V=Integer.
Pair<String, Integer> likeCount = new Pair<>("게시물 좋아요", 505);
System.out.println(likeCount);

new Pair<String, Member>(...) 라고 만들면 K 자리에 String, V 자리에 Member 가 끼워져요. 그러면 getKey() 는 String, getValue() 는 Member 를 형변환 없이 그대로 돌려주죠. 그래서 entry.getValue().getFollowers() 처럼 Member 의 기능을 바로 이어 쓸 수 있어요.

같은 Pair 틀에 이번엔 K=String, V=Integer 를 끼우면 "글자 → 숫자" 짝이 돼요. 틀은 하나인데 끼우는 타입에 따라 다양하게 쓰이죠. Step 1의 붕어빵 틀 그대로예요.

여기서 지난 시간 복선을 회수해요. Map<String, Member> 의 그 <String, Member> 가 바로 이 <K, V> 였어요. Map 은 내부적으로 "키와 값을 짝지어" 보관하는데, 그 한 쌍이 사실 Pair 같은 구조예요. 지난 시간 entrySet() 으로 꺼낸 Map.Entry(키-값 묶음) 가 바로 이 짝이었던 거죠.

💡 튜터의 결론

빈칸은 둘 이상도 둘 수 있어요. Pair<K, V>K(열쇠)·V(값) 두 빈칸으로 한 쌍을 묶어요. 지난 시간 Map<String, Member><String, Member> 가 바로 이 <K, V> 였어요 — Map 은 키-값 짝을 보관하는 그릇이었던 거죠.


Step 5. 제네릭 메서드 — 클래스 없이 메서드만

지금까지는 클래스에 <T> 를 붙였어요. 그런데 클래스 전체가 아니라 "메서드 하나" 에만 빈칸을 붙이고 싶을 때가 있어요. 예를 들어 "리스트의 첫 원소 꺼내기" 같은 동작은 String 리스트든 Member 리스트든 똑같이 필요하잖아요. 이걸 타입마다 따로 만들지 않고 하나로 끝내는 게 제네릭 메서드예요.

// com/instagram/javabasic/generic/GenericMethods.java
public class GenericMethods {

    // 리스트의 첫 번째 원소를 T 타입 그대로 돌려줘요. 비어 있으면 꺼낼 게 없으니 예외를 던져요.
    public static <T> T first(List<T> list) {
        if (list.isEmpty()) {
            throw new IllegalArgumentException("빈 리스트에는 첫 원소가 없어요.");
        }
        return list.get(0);
    }

    // 리스트의 i 번째와 j 번째 원소 자리를 맞바꿔요. 어떤 타입 리스트든 한 메서드로 처리해요.
    public static <T> void swap(List<T> list, int i, int j) {
        T temp = list.get(i);
        list.set(i, list.get(j));
        list.set(j, temp);
    }
}

여기서 봐야 할 핵심은 <T> 의 위치예요. 클래스 제네릭은 클래스 이름 옆에 <T> 를 붙였죠. 제네릭 메서드는 반환 타입 바로 앞에 <T> 를 붙여요.

 <T>  T  first(List<T> list)
  │    │    │
  │    │    └─ 메서드 이름
  │    └─ 반환 타입 (T 를 돌려줘요)
  └─ "이 메서드는 어떤 타입 T 든 다뤄요" 라는 빈칸 선언 (반환 타입 앞!)

이 맨 앞의 <T> 가 "이 메서드는 어떤 타입 T 든 처리할 수 있어요" 라는 선언이에요. 이게 있어야 그 뒤에서 T 를 마음껏 쓸 수 있어요. firstList<T> 를 받아 그 안의 T 하나를 돌려주고, swapList<T> 안의 두 자리를 맞바꿔요.

호출할 때는 빈칸이 알아서 채워져요.

// String 리스트 — 호출하는 순간 T 가 String 으로 정해져요.
List<String> names = new ArrayList<>();
names.add("minji");
names.add("jaehoon");
names.add("seungwoo");
System.out.println("첫 이름: " + first(names));   // minji

swap(names, 0, 2);
System.out.println("0↔2 교환 후 첫 이름: " + first(names)); // seungwoo

// Member 리스트 — 같은 메서드인데 이번엔 T 가 Member 로 정해져요.
List<Member> members = new ArrayList<>();
members.add(new Member("minji", 8500, 150, 12, 400));
members.add(new Member("jaehoon", 1240, 42, 3, 120));
Member firstMember = first(members);   // 형변환 없이 Member 그대로
System.out.println("첫 회원: " + firstMember.getUsername());

first(names) 를 부르면 자바가 "아, names 는 String 리스트네" 하고 T 를 String 으로 정해요. first(members) 를 부르면 같은 메서드인데 이번엔 T 가 Member 로 정해지죠. 우리는 빈칸을 일일이 채워주지 않아도, 넘긴 리스트를 보고 자바가 알아서 타입을 알아채요.

덕분에 firstString, firstMember 처럼 타입마다 메서드를 따로 만들 필요가 없어요. first 하나면 어떤 리스트든 첫 원소를 꺼내주죠. 그리고 String 리스트엔 String, Member 리스트엔 Member 가 형변환 없이 그대로 나와요.

💡 튜터의 결론

클래스 없이 메서드 하나만 제네릭으로 만들 수 있어요. 반환 타입 앞에 <T> 를 붙이는 게 핵심이에요(<T> T first(...)). 호출하는 순간 넘긴 리스트를 보고 T 가 자동으로 정해져서, 타입마다 메서드를 따로 만들지 않고 하나로 끝내요.


Step 6. 경계 타입 — <T extends Comparable<T>>로 최댓값 찾기

지금까지 빈칸 T 는 "아무 타입이나" 받았어요. 그런데 가끔은 "아무거나 말고, 이런 조건을 만족하는 타입만" 받고 싶을 때가 있어요. 그게 경계 타입(bounded type, 빈칸에 조건을 거는 것) 이에요.

왜 필요한지 예로 볼게요. "리스트에서 가장 큰 원소 찾기" 메서드를 만든다고 해봐요. 가장 큰 걸 찾으려면 원소끼리 서로 비교할 수 있어야 하죠. 그런데 만약 비교할 줄 모르는 타입이 들어오면? 자바는 "이 둘 중 누가 큰지 알 방법이 없는데?" 하고 막혀요.

Day 18~19에서 우리는 Comparable(서로 비교할 수 있는 약속) 을 배웠죠. compareTo 메서드로 "둘 중 누가 앞이냐" 를 정하는 인터페이스요. 그러니까 "비교 가능한 타입만 받겠다" 는 조건은 Comparable 로 표현할 수 있어요.

// com/instagram/javabasic/generic/MaxFinder.java
public class MaxFinder {

    // 리스트에서 가장 큰 원소를 찾아요. T 는 서로 비교 가능한(Comparable) 타입이어야 해요.
    // 빈 리스트면 비교할 게 없으니 예외를 던져요.
    public static <T extends Comparable<T>> T max(List<T> list) {
        if (list.isEmpty()) {
            throw new IllegalArgumentException("빈 리스트에는 최댓값이 없어요.");
        }
        T best = list.get(0);
        for (int i = 1; i < list.size(); i++) {
            T current = list.get(i);
            // current 가 best 보다 크면(compareTo 가 양수면) 챔피언을 갈아끼워요.
            if (current.compareTo(best) > 0) {
                best = current;
            }
        }
        return best;
    }
}

<T extends Comparable<T>> 가 핵심이에요. 풀어 읽으면 "T 는 아무거나가 아니라, Comparable(서로 비교할 수 있는) 타입이어야 해요" 라는 조건이에요. 여기서 extends 는 "물려받았다" 보다는 "이 조건을 만족한다" 정도로 읽으면 돼요.

 일반 빈칸  vs  조건 걸린 빈칸

   <T>                         <T extends Comparable<T>>
   아무 타입이나 OK             "비교할 수 있는 타입" 만 OK
        │                              │
   compareTo 가 없을 수도        compareTo 가 반드시 있음
        있어 → 비교 못 함          → 안심하고 a.compareTo(b) 호출

이 조건이 걸려 있으니, 메서드 안에서 current.compareTo(best) 를 안심하고 부를 수 있어요. T 가 비교 가능한 타입이라는 게 보장됐으니까요. 그래서 리스트를 한 바퀴 돌면서 "지금 챔피언(best)보다 큰 게 나오면 갈아끼우기" 로 최댓값을 찾아요.

써볼게요.

// Integer 리스트 — Integer 는 Comparable 이라 조건을 만족해요. 가장 큰 숫자를 찾아요.
List<Integer> likeCounts = new ArrayList<>();
likeCounts.add(120);
likeCounts.add(8500);
likeCounts.add(42);
System.out.println("최다 좋아요: " + max(likeCounts)); // 8500

// String 리스트 — String 도 Comparable 이고, 사전순으로 비교돼요(뒤 글자가 더 큼).
List<String> names = new ArrayList<>();
names.add("minji");
names.add("jaehoon");
names.add("seungwoo");
System.out.println("사전순 마지막 이름: " + max(names)); // seungwoo

// SortableMember 도 Comparable<SortableMember> 라 그대로 넣을 수 있어요(팔로워 기준 비교).
List<SortableMember> members = new ArrayList<>();
members.add(new SortableMember("minji", 8500));
members.add(new SortableMember("jaehoon", 1240));
System.out.println("팔로워 최다 회원: " + max(members)); // @minji(8500)

Integer·String 은 자바가 이미 Comparable 로 만들어둬서 그대로 들어가요. 그리고 지난 시간 정렬 실습에서 만난 SortableMember(팔로워 수로 비교하도록 Comparable 을 구현한 회원) 도 조건을 만족하니 그대로 넣을 수 있어요. 셋 다 같은 max 메서드 하나로 최댓값을 찾아요.

여기서 "왜 조건이 필요한가" 를 한 번 더 못 박고 갈게요. Day 16에서 만든 평범한 Member 클래스는 Comparable 을 구현하지 않았어요. 즉 "둘 중 누가 큰지" 를 정하는 compareTo 가 없죠. 그래서 List<Member>max 에 넣으려 하면, 자바가 작성 단계에서 "Member 는 비교 가능한 타입이 아니라 못 받아요" 하고 막아요. 만약 경계 조건이 없었다면 이런 타입도 받아버려서 안에서 비교하려다 사고가 났겠죠. 조건이 미리 걸러주는 거예요.

💡 튜터의 결론

경계 타입 <T extends Comparable<T>> 는 빈칸에 "비교할 수 있는 타입만" 이라는 조건을 걸어요. 덕분에 메서드 안에서 compareTo 를 안심하고 쓸 수 있죠. Integer·String·SortableMember 처럼 비교 가능한 타입만 들어오고, compareTo 가 없는 평범한 Member 는 작성 단계에서 막혀요.


Step 7. 와일드카드 — ? extends / ? super (PECS)

오늘 중 가장 머리 아픈 대목이에요. 먼저 마음 편하게 들어주세요. 지금 100% 이해 못 해도 전혀 괜찮아요. 이건 나중에 라이브러리 함수의 설명(시그니처)을 읽을 때 "아, 그때 그거구나" 하고 다시 만나는 개념이에요. 오늘은 "이런 게 있고, 큰 그림은 이렇다" 정도만 챙기면 충분해요.

먼저 물음표 ? 부터요. 와일드카드(wildcard) 의 ? 는 "어떤 타입이든" 을 뜻하는 표시예요. Day 10~11에서 배운 상속을 떠올려보세요. PremiumMember(프리미엄 회원)와 AdminMember(관리자 회원)는 둘 다 Member 를 물려받은 자식이었죠.

 Day 10~11 의 회원 계층

              Member (부모)
             ╱        ╲
    PremiumMember    AdminMember   (자식들)
    (프리미엄 회원)   (관리자 회원)

여기서 문제가 하나 생겨요. "회원들의 팔로워 합을 구하는 메서드" 를 만들고 싶어요. 그런데 List<Member> 로 받으면, List<PremiumMember>(프리미엄만 담긴 리스트)는 못 받아요. 자바에서 List<PremiumMember>List<Member> 의 자식이 아니거든요(이건 직관과 달라서 헷갈리는 지점이에요). 그래서 "Member 거나, Member 의 자식 타입 리스트면 다 받아줘" 라는 표현이 필요한데, 그게 ? extends 예요.

여기서 외울 짧은 말이 PECS — Producer Extends, Consumer Super 예요. 풀면 이래요.

  • 읽기만 할 거면(생산자, Producer) → extends: 리스트에서 값을 꺼내 읽기만 한다면 ? extends 부모 로 받아요.
  • 넣기만 할 거면(소비자, Consumer) → super: 리스트에 값을 담기만 한다면 ? super 자식 으로 받아요.
 PECS 외움말

   읽기(Producer)  →  ? extends   ←  리스트에서 꺼내 읽기만
   쓰기(Consumer)  →  ? super     ←  리스트에 담기만

코드로 봐요. 먼저 읽기(extends) 쪽이에요.

// com/instagram/javabasic/generic/WildcardFeed.java
// 읽기(생산자) — ? extends Member 라서 List<Member> 뿐 아니라
// List<PremiumMember>·List<AdminMember> 같은 자식 타입 리스트도 받아요.
// 안에서는 꺼내 읽기만 해요(팔로워 합산). 새로 넣지는 않아요.
public int totalFollowers(List<? extends Member> members) {
    int sum = 0;
    for (Member m : members) {
        sum = sum + m.getFollowers();
    }
    return sum;
}

// 읽기(생산자) — ? extends Content 라서 이미지·텍스트·영상 어떤 콘텐츠 리스트든 받아요.
// 각 콘텐츠의 render() 를 모아 한 줄 설명 목록을 만들어 돌려줘요.
public List<String> describeAll(List<? extends Content> contents) {
    List<String> lines = new ArrayList<>();
    for (Content c : contents) {
        lines.add(c.render());
    }
    return lines;
}

totalFollowers(List<? extends Member> members) 는 "Member 거나 그 자식 타입의 리스트면 뭐든 받아" 예요. 안에서는 회원을 꺼내 getFollowers() 로 팔로워 수를 읽기만 해요. 새로 넣지는 않죠. 그래서 extends 가 어울려요.

다음은 쓰기(super) 쪽이에요.

// 쓰기(소비자) — ? super Member 라서 List<Member> 뿐 아니라
// List<Object> 같은 부모 타입 리스트에도 회원을 안전하게 담을 수 있어요.
public void addMembers(List<? super Member> target, Member... members) {
    for (Member m : members) {
        target.add(m);
    }
}

addMembers(List<? super Member> target, ...) 는 "Member 거나 그 부모 타입의 리스트면 받아" 예요. 안에서는 회원을 add 로 담기만 하죠. 그래서 super 가 어울려요. 부모 타입(List<Object>)에는 자식(Member)을 안전하게 담을 수 있으니까요.

써볼게요.

WildcardFeed feed = new WildcardFeed();

// 프리미엄 회원만 담은 리스트도 ? extends Member 자리에 그대로 들어가요.
List<PremiumMember> premiums = new ArrayList<>();
premiums.add(new PremiumMember("minji", 8500, 150, 12, 400, true));
premiums.add(new PremiumMember("jaehoon", 1240, 42, 3, 120, false));
System.out.println("프리미엄 팔로워 합: " + feed.totalFollowers(premiums)); // 9740

// 관리자 회원 리스트도 같은 메서드로 처리돼요.
List<AdminMember> admins = new ArrayList<>();
admins.add(new AdminMember("admin1", 300, 10, 1, 50, "콘텐츠 관리자"));
System.out.println("관리자 팔로워 합: " + feed.totalFollowers(admins)); // 300

// ? super Member 자리에 List<Object> 를 넘겨도 회원이 안전하게 담겨요.
List<Object> anything = new ArrayList<>();
feed.addMembers(anything,
        new Member("seungwoo", 320, 12, 2, 80),
        new Member("hana", 540, 20, 4, 200));
System.out.println("Object 리스트에 담긴 회원 수: " + anything.size()); // 2

List<PremiumMember> 도, List<AdminMember>totalFollowers? extends Member 자리에 그대로 들어가요. 같은 메서드 하나로 등급이 다른 회원 묶음을 다 처리하죠. describeAll 도 마찬가지로 이미지·텍스트·영상 등 Content 의 자식이면 어떤 콘텐츠 리스트든 받아요. 반대로 addMembersList<Object> 같은 부모 타입 리스트에도 회원을 안전하게 담고요.

🙋 학생 질문 — "튜터님, extends 랑 super 중에 뭘 쓸지 매번 헷갈릴 것 같아요. 외우는 게 맞나요?"

지금은 외우지 않아도 돼요! 솔직히 실무 개발자도 "내가 직접 와일드카드를 설계하는" 경우는 자주 없어요. 대부분은 라이브러리(자바가 만들어둔 함수)의 설명을 읽다가 ? extends? super 를 만나는 쪽이에요.

그때 PECS 만 떠올리면 돼요. "이 리스트에서 값을 꺼내 읽는 함수구나 → extends 겠네", "이 리스트에 값을 담는 함수구나 → super 겠네" 하고 의미를 읽어내는 거죠. 직접 짜는 건 한참 뒤에, 정말 필요해질 때 천천히 익히면 돼요. 오늘은 "물음표는 어떤 자식이든이고, 읽기는 extends, 쓰기는 super" 라는 큰 그림만 챙기면 100점이에요.

💡 튜터의 결론

와일드카드 ? 는 "어떤 타입이든" 표시예요. PECS — 읽기(꺼내기)만 하면 ? extends, 쓰기(담기)만 하면 ? super. ? extends Member 덕분에 List<PremiumMember>·List<AdminMember> 같은 자식 리스트도 한 메서드로 처리해요. 지금 다 이해 못 해도 괜찮아요 — 라이브러리 설명을 읽을 때 다시 만나요.


Step 8. 종합 — 제네릭 Repository<T> 만들기

오늘의 마지막은 지금까지 배운 제네릭을 한데 모아 "만능 저장소" 를 만드는 거예요. 지난 시간에 우리는 UsernameToMember 라는 클래스를 만들었죠. Map 으로 "username → Member" 를 보관하는 회원 사물함이었어요. 그런데 가만 보면 이건 회원 전용이었어요. 게시물을 보관하려면 또 비슷한 클래스를 통째로 다시 짜야 했죠.

제네릭이 있으면 이걸 한 방에 일반화할 수 있어요. "무엇이든 id 로 보관하는 창고" 를 Repository<T> 하나로 만드는 거예요.

 지난 시간 → 오늘 (제네릭으로 일반화)

   Day 19                          Day 20
   UsernameToMember                Repository<T>
   (회원 전용 사물함)         ┌─ new Repository<Member>()  → 회원 창고
        │                     ├─ new Repository<Post>()    → 게시물 창고
   회원만 담김               └─ new Repository<무엇이든>() → 그 타입 전용 창고

   "전용 그릇 하나"  →  "틀 하나로 모든 창고를 찍어냄"

코드를 봐요.

// com/instagram/javabasic/generic/Repository.java
public class Repository<T> {

    // 번호표(id) 를 키로, T 타입 객체를 값으로 보관하는 사물함이에요.
    private final Map<Long, T> store = new HashMap<>();

    // 저장 — id 번호표를 붙여 객체를 넣어요. 같은 id 면 덮어써요.
    public void save(Long id, T value) {
        store.put(id, value);
    }

    // 조회 — id 로 객체를 찾아 T 타입 그대로 돌려줘요(형변환 불필요).
    // 없으면 null 대신 예외를 던져 "그 번호는 없어요" 를 분명히 알려요.
    public T findById(Long id) {
        T found = store.get(id);
        if (found == null) {
            throw new IllegalArgumentException("id " + id + " 에 해당하는 항목이 없어요.");
        }
        return found;
    }

    // 전체 목록 — 보관 중인 모든 객체를 리스트로 모아 돌려줘요.
    public List<T> findAll() {
        return new ArrayList<>(store.values());
    }

    // 보관 개수 — 지금 몇 개가 들어 있는지 알려줘요.
    public int count() {
        return store.size();
    }
}

클래스 옆에 <T> 가 붙은 제네릭 클래스예요(Step 3에서 배웠죠). 안을 보면 Map<Long, T> 가 있어요. "번호표(Long id) → 물건(T)" 짝으로 보관하는 사물함이에요. 지난 시간 Map 과 똑같은 구조인데, 값 자리가 고정된 타입이 아니라 빈칸 T 라는 게 핵심이에요.

findById 를 잘 봐주세요. id 로 찾아서 있으면 그대로 돌려주는데, 없으면 null 을 슬쩍 돌려주지 않고 예외를 던져요. 지난 시간 Map.get 은 없는 키면 null 을 줬죠. 그런데 null 은 다루기 까다로워서, 여기서는 "그 번호는 없어요" 를 예외로 분명하게 알려주는 쪽을 골랐어요. 이렇게 "없으면 예외" 를 제대로 다루는 방법은 다음 시간에 본격적으로 배워요.

이제 이 틀 하나로 회원 창고도, 게시물 창고도 만들 수 있어요. 그런데 회원 창고에는 "username 으로 찾기" 같은 회원만의 편의 기능을 더 얹고 싶어요. 그럴 땐 Day 10에서 배운 상속을 써요.

// com/instagram/javabasic/generic/MemberRepository.java
public class MemberRepository extends Repository<Member> {

    // 회원 저장소만의 편의 기능 — username 으로 회원을 찾아요.
    // 물려받은 findAll() 로 전체를 훑어 이름이 같은 사람을 돌려줘요. 없으면 예외를 던져요.
    public Member findByUsername(String username) {
        List<Member> all = findAll();
        for (Member m : all) {
            if (m.getUsername().equals(username)) {
                return m;
            }
        }
        throw new IllegalArgumentException("username " + username + " 인 회원이 없어요.");
    }
}

class MemberRepository extends Repository<Member> 이 한 줄이 핵심이에요. 빈칸 TMember 를 끼운 채로 상속받은 거예요. 그러면 부모 Repository<Member>save·findById·findAll·count 를 그대로 물려받아 회원용으로 바로 쓸 수 있어요. 거기에 회원만의 findByUsername 을 더 얹은 거고요.

써볼게요.

MemberRepository repo = new MemberRepository();
repo.save(1L, new Member("minji", 8500, 150, 12, 400));
repo.save(2L, new Member("jaehoon", 1240, 42, 3, 120));

System.out.println("보관 회원 수: " + repo.count());                 // 2
System.out.println("1번 회원: " + repo.findById(1L).getUsername()); // minji
System.out.println("이름으로 찾기: " + repo.findByUsername("jaehoon").getFollowers()); // 1240
System.out.println("전체 회원 수: " + repo.findAll().size());        // 2

repo.findById(1L) 은 부모에게서 물려받은 기능인데, Member 를 형변환 없이 그대로 돌려줘서 바로 .getUsername() 을 이어 쓸 수 있어요. findByUsername("jaehoon") 은 회원 창고만의 편의 기능이고요. 같은 방식으로 Repository<Post> 를 상속하면 게시물 저장소가 되니까, 제네릭 하나가 여러 저장소를 한 코드에서 찍어내는 셈이에요.

이게 오늘 배운 제네릭의 진짜 힘이에요. 지난 시간 UsernameToMember 처럼 타입마다 그릇을 따로 만들던 걸, Repository<T> 틀 하나로 모아버린 거죠. 붕어빵 틀 하나로 팥붕어빵도 슈크림붕어빵도 굽듯이요.

💡 튜터의 결론

제네릭 Repository<T> 하나면 new Repository<Member>()(회원 창고)·new Repository<Post>()(게시물 창고)를 따로 짤 필요가 없어요. extends Repository<Member> 한 줄로 공통 기능을 물려받고 findByUsername 같은 편의 기능만 얹으면 전용 저장소가 완성돼요. 지난 시간 UsernameToMember 가 제네릭으로 한 단계 올라선 모습이에요.


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

  • Step 1: 제네릭은 "타입을 나중에 끼워 넣는 빈칸(틀)". 붕어빵 틀에 속재료를 나중에 넣듯이.
  • Step 2: 제네릭 전엔 Object + 형변환. 잘못 형변환하면 실행 중에 ClassCastException — 가장 늦게 들키는 사고.
  • Step 3: 클래스 옆 <T> 로 제네릭 클래스. new Box<String>() 하면 빈칸이 채워져 형변환 없이 안전.
  • Step 4: 빈칸 둘 <K, V>. Pair<K, V> 가 곧 지난 시간 Map<String, Member><K, V>.
  • Step 5: 메서드만 제네릭. 반환 타입 앞 <T> 가 핵심(<T> T first(...)). 호출 시 자동으로 정해짐.
  • Step 6: 경계 타입 <T extends Comparable<T>> 로 "비교 가능한 타입만" 조건 걸기. compareTo 안심 호출.
  • Step 7: 와일드카드 ?. PECS — 읽기는 ? extends, 쓰기는 ? super. 자식 등급 리스트도 한 메서드로.
  • Step 8: 제네릭 Repository<T> 하나로 회원·게시물 창고를 찍어내고, 상속으로 전용 저장소 완성.

다음 시간엔 — 실행 중 사고를 잡는 예외 처리

오늘 우리는 두 번이나 "실행 중에 갑자기 터지는 사고" 를 만났어요. Step 2의 ObjectBox 가 던진 ClassCastException, 그리고 Step 8의 Repository.findById 가 없는 id 에 던진 예외요. 둘 다 "뭔가 잘못됐을 때 프로그램이 멈추며 알려주는" 신호였죠.

다음 시간엔 이 신호를 제대로 다루는 법, 예외 처리(exception handling) 를 본격적으로 배워요. Day 17에서 try-catch 를 살짝 맛봤는데, 다음 시간엔 예외에도 종류와 계층이 있다는 것, 내가 직접 예외를 만들어 던지는 법, 그리고 "이 사고는 잡아서 복구하고, 저 사고는 위로 올려보내는" 판단까지 익혀요. 오늘 findById 에서 슬쩍 던진 그 예외를, 다음 시간엔 멋지게 받아내요. 수고 많으셨어요!


과제

오늘 배운 제네릭(클래스·메서드·경계 타입·Repository<T>)을 손에 익히는 과제예요. 모두 오늘까지 배운 문법(클래스·상속·인터페이스·Comparable·List/Set/Map·제네릭·예외 처리·향상된 for)만으로 풀 수 있어요. 람다·Stream은 아직 안 배웠으니, 순회는 향상된 for로 해주세요.

[기초] 과제 1 — 제네릭 Stack<T> 만들기

해야 할 일

마지막에 넣은 게 가장 먼저 나오는 "쌓기 그릇" Stack<T> 를 제네릭 클래스로 만들어보세요. 접시를 쌓았다가 위에서부터 꺼내는 모습을 떠올리면 돼요.

요구사항

  • 클래스 이름 옆에 <T> 를 붙여 제네릭 클래스로 만들어요.
  • 내부에 private final List<T> items = new ArrayList<>(); 를 둬요.
  • void push(T value) — 맨 위에 하나 쌓아요.
  • T pop() — 맨 위 하나를 꺼내며 그릇에서 제거해요. 비어 있으면 예외를 던져요.
  • T peek() — 맨 위를 꺼내되 제거하지는 않고 들여다봐요. 비어 있으면 예외.
  • boolean isEmpty() — 비었는지.

힌트

  • "맨 위" 는 List 의 마지막 칸이에요. items.size() - 1 번 인덱스를 다루면 돼요.
  • 비어 있을 때 예외는 Day 17에서 배운 방식으로 던져요(IllegalStateException 등).
  • Stack<String> 으로도, Stack<Member> 로도 동작하는지 main 에서 둘 다 확인해보세요.

[중] 과제 2 — 제네릭 메서드 lastOfcount

해야 할 일

Step 5의 first 처럼, 리스트를 다루는 제네릭 메서드 두 개를 직접 만들어보세요.

요구사항

  • static <T> T lastOf(List<T> list) — 리스트의 마지막 원소를 돌려줘요. 비어 있으면 예외.
  • static <T> int count(List<T> list, T target) — 리스트 안에 target 과 같은 원소가 몇 개 있는지 세요. (같은지 비교는 equals 를 써요 — Day 19에서 배웠죠.)
  • String 리스트와 Member 리스트 양쪽에 같은 메서드를 호출해 확인해보세요.

힌트

  • 두 메서드 모두 반환 타입 앞에 <T> 를 붙이는 게 핵심이에요.
  • count 는 향상된 for로 돌면서 element.equals(target)true 인 것만 세면 돼요.

[심화] 과제 3 — 제네릭 Repository<T> 를 상속한 PostRepository

해야 할 일

Step 8의 MemberRepository 처럼, 게시물 전용 저장소 PostRepositoryRepository<Post> 를 상속해 만들어보세요.

요구사항

  • class PostRepository extends Repository<Post> 로 시작해, save·findById·findAll·count 를 그대로 물려받아요.
  • 게시물만의 편의 기능 하나를 추가해요. 예: List<Post> findByWriter(String username) — 특정 작성자의 글만 골라 새 List 로 돌려줘요. (작성자 비교는 Post 의 작성자 정보를 활용해요.)
  • 없는 작성자를 찾으면 빈 리스트를 돌려줄지, 예외를 던질지 직접 골라보세요. (정답은 없어요 — 어떤 게 더 자연스러운지 생각해보고 고르면 돼요.)

힌트

  • 물려받은 findAll() 로 전체 게시물을 가져와, 향상된 for로 작성자가 일치하는 것만 골라 담으면 돼요.
  • Repository<T> 의 코드는 한 글자도 안 고쳐도 돼요. 상속만으로 게시물 저장소가 되는 게 제네릭의 힘이에요.

생각해볼 주제

오늘 배운 제네릭 뒤에는 "자바가 왜 이렇게까지 타입을 따질까?" 하는 질문이 숨어 있어요. 정답을 외우기보다 곰곰이 생각해보면 도구를 보는 눈이 깊어져요.

주제 1 — "컴파일 때 막기"가 "실행 때 막기"보다 왜 좋을까?

Step 2의 ObjectBox 는 형변환 실수를 실행 중에 ClassCastException 으로 알려줬어요. 반면 Step 3의 Box<T> 는 엉뚱한 타입을 넣으려 하면 코드를 작성하는 단계에서 바로 빨간 줄을 그어줬죠. 둘 다 "잘못을 막아준다" 는 점은 같은데, 막아주는 시점이 달라요. 사용자가 앱을 쓰다가 터지는 것과, 개발자가 코드를 짜다가 알아채는 것 — 어느 쪽이 고치기 쉽고 비용이 적게 들까요? 왜 그런지 나만의 답을 세워보세요.

주제 2 — Box<Object> 하나면 다 담는데 왜 굳이 Box<String> 으로 좁힐까?

생각해보면 Box<Object> 를 만들면 String 도 Member 도 숫자도 다 담을 수 있어요. 그러면 그릇 하나로 모든 걸 해결하니 편할 것 같죠. 그런데 우리는 굳이 Box<String>, Box<Member> 처럼 타입을 좁혀서 써요. 일부러 담을 수 있는 종류를 줄이는 거죠. "더 많이 담을 수 있는 그릇" 을 두고 왜 "덜 담기는 그릇" 을 고를까요? Box<Object> 를 쓰면 꺼낼 때 무엇이 다시 필요해지는지(Step 2를 떠올려보세요), 그게 어떤 위험을 부르는지 생각해보세요.

주제 3 — PECS, 왜 읽을 땐 extends 쓸 땐 super 일까?

와일드카드에서 읽기(꺼내기)는 ? extends, 쓰기(담기)는 ? super 였어요. 외우면 되긴 하지만, 왜 하필 이렇게 짝지어졌을까요? 직관적으로 접근해보세요. List<? extends Member> 에서 무언가를 꺼내면 적어도 "Member 이긴 하다" 가 보장되니 읽기가 안전하고, List<? super Member> 에는 "Member 의 부모 자리" 니까 Member 를 담아도 넘치지 않아 쓰기가 안전해요. 이 두 문장이 왜 자연스러운지, 부모-자식 관계를 그림으로 그려가며 스스로 설명해보세요.

✅ 예시 답안정답 보기

오늘 과제는 "타입을 빈칸 <T> 로 남겨두고, 한 번 만든 그릇을 여러 타입에 재사용하기" 가 핵심이에요. 세 과제 모두 정답이 하나는 아니에요. 아래 예시와 다르게 풀었더라도, 요구사항을 만족하고 오늘 배운 제네릭 클래스·제네릭 메서드·상속을 제대로 썼다면 훌륭한 답이에요. 순회는 람다·Stream 없이 향상된 for로만 했어요.


과제 1 예시답안 — 제네릭 Stack<T> 만들기

핵심 접근

이 과제의 진짜 주제는 "한 번 만든 <T> 그릇이 어떤 타입에도 통한다" 는 거예요. 스택은 "마지막에 넣은 게 가장 먼저 나오는(LIFO)" 자료구조인데, 안쪽을 List<T> 하나로 두면 "맨 위" 는 리스트의 마지막 칸이 돼요. push 는 끝에 더하고, pop 은 마지막 칸을 꺼내며 제거하고, peek 은 꺼내되 제거는 안 해요. 비어 있을 때 꺼내려 하면 예외로 분명히 막는 게 포인트예요.

예시 구현

// com/instagram/javabasic/generic/solution/day20/Stack.java
public class Stack<T> {

    // 쌓아 둔 항목들을 보관하는 리스트예요. 마지막 칸이 "맨 위" 가 돼요.
    private final List<T> items = new ArrayList<>();

    // push — 맨 위에 하나 쌓아요. 리스트의 끝에 더하면 그게 새 "맨 위" 가 돼요.
    public void push(T value) {
        items.add(value);
    }

    // pop — 맨 위 항목을 꺼내면서 동시에 제거해요.
    // 비어 있으면 꺼낼 게 없으니 IllegalStateException 을 던져 "지금은 비었어요" 를 분명히 알려요.
    public T pop() {
        if (isEmpty()) {
            throw new IllegalStateException("스택이 비어 있어 꺼낼 수 없어요.");
        }
        int topIndex = items.size() - 1;
        T top = items.get(topIndex);
        items.remove(topIndex);
        return top;
    }

    // peek — 맨 위 항목을 살짝 들여다봐요. pop 과 달리 제거하지 않아요.
    // 비어 있으면 볼 게 없으니 똑같이 IllegalStateException 을 던져요.
    public T peek() {
        if (isEmpty()) {
            throw new IllegalStateException("스택이 비어 있어 들여다볼 수 없어요.");
        }
        return items.get(items.size() - 1);
    }

    // 비었는지 — 쌓인 게 하나도 없으면 true 예요.
    public boolean isEmpty() {
        return items.isEmpty();
    }

    // 개수 — 지금 몇 개가 쌓여 있는지 알려줘요.
    public int size() {
        return items.size();
    }
}

핵심은 클래스 이름 옆 <T> 와 "맨 위 = 마지막 칸" 약속이에요. pushitems.add(value) 로 끝에 더하니 그게 새 맨 위가 되고, popitems.size() - 1 번 칸을 꺼낸 다음 items.remove(topIndex) 로 제거까지 해요. peek 은 같은 칸을 get 으로 보기만 하고 제거는 하지 않아요. 이 한 그릇이 Stack<String> 에도 Stack<Member> 에도 그대로 통해요.

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

// com/instagram/javabasic/generic/solution/day20/Stack.java
public static void main(String[] args) {
    // 문자열 스택 — 나중에 넣은 게 먼저 나와요(LIFO)
    Stack<String> words = new Stack<>();
    words.push("첫째");
    words.push("둘째");
    words.push("셋째");

    System.out.println("쌓인 개수: " + words.size()); // 3
    System.out.println("맨 위 엿보기: " + words.peek()); // 셋째 (제거 안 함)
    System.out.println("꺼내기: " + words.pop());       // 셋째
    System.out.println("꺼내기: " + words.pop());       // 둘째
    System.out.println("남은 개수: " + words.size());   // 1

    // 회원 스택 — 같은 코드가 Member 에도 그대로 통해요
    Stack<Member> members = new Stack<>();
    members.push(new Member("minji", 8500, 150, 12, 400));
    members.push(new Member("jaehoon", 1240, 42, 3, 120));

    System.out.println("맨 위 회원: " + members.pop().getUsername()); // jaehoon
}

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

쌓인 개수: 3
맨 위 엿보기: 셋째
꺼내기: 셋째
꺼내기: 둘째
남은 개수: 1
맨 위 회원: jaehoon

peek 으로 본 "셋째" 가 사라지지 않고 그대로 남아 있다가, 바로 다음 pop 에서 다시 "셋째" 가 나오는 게 보이시죠? peek 은 보기만, pop 은 꺼내기까지 — 이 차이가 눈에 들어와요.

채점 포인트

항목 배점 기준 가중
<T> 제네릭 선언 클래스 이름 옆에 <T> 를 붙여 한 그릇이 여러 타입에 통하는가
LIFO + "맨 위 = 마지막 칸" push 는 끝에 더하고, 맨 위를 items.size() - 1 로 다루는가
pop 의 제거 마지막 칸을 꺼낸 뒤 remove 로 실제로 제거하는가
peek 의 비제거 맨 위를 get 으로 보기만 하고 제거하지 않는가
빈 스택 예외 pop·peek 모두 비었을 때 예외를 던지는가

흔한 실수

  • pop 에서 제거를 빼먹기return items.get(items.size() - 1); 만 두면 꺼낸 게 그대로 남아 있어요. pop 은 remove 까지 해야 "꺼낸다" 가 완성돼요. peek 과 pop 이 똑같아져 버리는 흔한 실수예요.
  • 빈 검사 누락 → 비었을 때 items.get(-1) 같은 접근이 일어나면 엉뚱한 예외가 터져요. isEmpty() 로 먼저 막고 의미가 분명한 IllegalStateException 을 던지면, "왜 터졌는지" 가 메시지에 담겨요.

실무 개선 포인트 (심화)

지금은 "맨 위" 를 리스트의 마지막 칸으로 잡았어요. 만약 첫 칸(index 0)을 맨 위로 잡으면 어떻게 될까요? pushpop 마다 모든 원소가 한 칸씩 밀려야 해서 느려져요. 마지막 칸을 맨 위로 두면 밀림 없이 끝에서만 더하고 빼니 빠르죠. 자료구조에서 "어느 쪽 끝을 입구로 삼느냐" 가 성능을 가르는 경우가 많은데, 스택이 그 첫 감각을 잡기 좋은 예제예요.


과제 2 예시답안 — 제네릭 메서드 lastOfcount

핵심 접근

이번엔 클래스가 아니라 "메서드 하나" 에만 빈칸을 붙이는 제네릭 메서드예요. 핵심은 반환 타입 바로 앞에 <T> 를 붙이는 위치예요. lastOf 는 리스트의 마지막 칸을 돌려주되 비어 있으면 예외를 던지고, count 는 향상된 for로 돌면서 equals 로 같은 원소를 세요. 여기서 비교는 == 가 아니라 equals 라는 점이 가장 중요해요.

예시 구현

// com/instagram/javabasic/generic/solution/day20/ListUtils.java
public class ListUtils {

    // 마지막 원소를 돌려줘요 — 리스트의 끝 칸(size()-1)이에요.
    // 비어 있으면 돌려줄 게 없으니 IllegalArgumentException 을 던져 "빈 리스트는 안 돼요" 를 알려요.
    public static <T> T lastOf(List<T> list) {
        if (list.isEmpty()) {
            throw new IllegalArgumentException("빈 리스트에는 마지막 원소가 없어요.");
        }
        return list.get(list.size() - 1);
    }

    // target 과 같은 원소가 몇 개인지 세요. 같은지는 equals 로 비교해요.
    // 향상된 for 로 리스트를 처음부터 끝까지 훑으면서 일치할 때마다 1씩 더해요.
    public static <T> int count(List<T> list, T target) {
        int found = 0;
        for (T item : list) {
            if (item.equals(target)) {
                found++;
            }
        }
        return found;
    }
}

public static <T> T lastOf(...) 에서 static 뒤, 반환 타입 T 앞에 끼어 있는 <T> 가 이 과제의 정체예요. 이게 "이 메서드는 어떤 타입 T 든 다뤄요" 라는 선언이라, 그 뒤로 T 를 자유롭게 쓸 수 있어요. countitem.equals(target) 도 핵심이에요. == 는 "같은 객체인가(주소가 같은가)" 를 보지만, 우리가 원하는 건 "내용이 같은가" 라서 equals 가 맞아요.

main 으로 확인해 볼게요.

// com/instagram/javabasic/generic/solution/day20/ListUtils.java
public static void main(String[] args) {
    // 문자열 리스트에서 마지막 원소와 개수 세기
    List<String> tags = new ArrayList<>();
    tags.add("#일상");
    tags.add("#맛집");
    tags.add("#일상");

    System.out.println("마지막 태그: " + lastOf(tags));          // #일상
    System.out.println("#일상 개수: " + count(tags, "#일상"));   // 2
    System.out.println("#여행 개수: " + count(tags, "#여행"));   // 0

    // 회원 리스트에서도 같은 메서드가 그대로 통해요(Member.equals 는 username 기준)
    List<Member> members = new ArrayList<>();
    members.add(new Member("minji", 8500, 150, 12, 400));
    members.add(new Member("jaehoon", 1240, 42, 3, 120));

    System.out.println("마지막 회원: " + lastOf(members).getUsername()); // jaehoon
    System.out.println("minji 개수: " + count(members, new Member("minji", 0, 0, 0, 0))); // 1
}

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

마지막 태그: #일상
#일상 개수: 2
#여행 개수: 0
마지막 회원: jaehoon
minji 개수: 1

마지막 줄을 잘 보세요. new Member("minji", 0, 0, 0, 0) 으로 팔로워·게시물 수가 전부 0인 "다른 객체" 를 만들어 넘겼는데도 개수가 1로 나와요. Member.equals 가 username 만으로 같음을 판단하기 때문이에요. == 로 비교했다면 주소가 달라서 0이 나왔을 거예요 — 그래서 equals 가 정답이에요.

채점 포인트

항목 배점 기준 가중
<T> 위치 반환 타입 바로 앞(<T> T lastOf, <T> int count)에 빈칸을 선언했는가
equals 비교 count 에서 == 가 아니라 item.equals(target) 으로 비교하는가
빈 리스트 예외 lastOf 가 비었을 때 예외를 던지는가
향상된 for count 가 인덱스 없이 for (T item : list) 로 도는가
두 타입 검증 String·Member 양쪽에 같은 메서드를 호출해 확인했는가

흔한 실수

  • == 로 비교하기if (item == target) 으로 세면, 위 main 의 "팔로워 0짜리 minji" 처럼 내용이 같아도 주소가 다른 객체는 못 세요. Member 의 개수가 0으로 나와버려요. "내용이 같은가" 는 항상 equals 예요.
  • <T> 를 빠뜨리기public static T lastOf(List<T> list) 처럼 반환 타입 앞 <T> 를 안 적으면, 자바가 T 가 뭔지 몰라 작성 단계에서 빨간 줄이 그어져요. 메서드 제네릭은 이 위치의 <T> 가 시작점이에요.

실무 개선 포인트 (심화)

count 에서 item.equals(target) 으로 비교했는데, 만약 리스트 안에 null 이 섞여 있으면 null.equals(...) 에서 사고가 날 수 있어요. 실무에서는 비교 주체를 뒤집어 target.equals(item) 으로 두거나, 자바가 제공하는 java.util.Objects.equals(item, target) 를 쓰면 한쪽이 null 이어도 안전하게 비교해줘요. "혹시 null 이 들어오면?" 을 한 번 떠올려보는 습관이 사고를 줄여요.


과제 3 예시답안 — 제네릭 Repository<T> 를 상속한 PostRepository

핵심 접근

이 과제의 숨은 목표는 "Repository<T> 코드를 한 글자도 안 고치고, 상속만으로 게시물 전용 저장소를 만든다" 예요. class PostRepository extends Repository<Post> 한 줄이면 save·findById·findAll·count 가 전부 따라와요. 거기에 게시물만의 편의 기능 findByWriter 를 얹는데, 물려받은 findAll() 로 전체를 훑어 작성자가 같은 글만 골라 담으면 돼요. 작성자 비교도 당연히 equals 예요.

예시 구현

// com/instagram/javabasic/generic/solution/day20/PostRepository.java
public class PostRepository extends Repository<Post> {

    // 게시물 저장소만의 편의 기능 — 작성자 이름(username)으로 그 사람의 글만 모아요.
    // 물려받은 findAll() 로 전체를 훑어 작성자가 같은 게시물만 새 리스트에 담아 돌려줘요.
    // 한 명도 없으면 빈 리스트를 돌려줘요(예외를 던지지 않아요 — "검색 결과 없음" 은 정상이니까요).
    public List<Post> findByWriter(String username) {
        List<Post> result = new ArrayList<>();
        List<Post> all = findAll();
        for (Post p : all) {
            if (p.getAuthorName() != null && p.getAuthorName().equals(username)) {
                result.add(p);
            }
        }
        return result;
    }
}

extends Repository<Post> 한 줄이 이 과제의 전부예요. 빈칸 TPost 를 끼운 채로 상속받았으니, save·findById·findAll·count 를 게시물용으로 그대로 써요. findByWriter 안에서는 물려받은 findAll() 로 전체 글을 가져와, p.getAuthorName().equals(username) 으로 작성자가 일치하는 것만 새 result 리스트에 담아 돌려줘요. getAuthorName() != null 을 먼저 확인해서 작성자가 없는 글에서 사고가 나지 않게 막았어요.

여기서 "없는 작성자면 빈 리스트냐, 예외냐" 를 골라야 했죠. 이 예시는 빈 리스트를 골랐어요. 검색 결과가 없는 건 "비정상" 이 아니라 "그냥 0건" 이라 자연스럽거든요. 단, findById 처럼 "분명 있어야 하는 걸 못 찾았을 때" 는 예외가 더 어울려요. 둘 다 맞는 선택이고, 기준은 "없는 게 정상인가, 비정상인가" 예요.

main 으로 확인해 볼게요.

// com/instagram/javabasic/generic/solution/day20/PostRepository.java
public static void main(String[] args) {
    PostRepository repo = new PostRepository();
    repo.save(1L, new Post("첫 게시물", "minji", 10));
    repo.save(2L, new Post("점심 인증", "jaehoon", 5));
    repo.save(3L, new Post("저녁 노을", "minji", 8));

    System.out.println("전체 게시물 수: " + repo.count());                     // 3
    System.out.println("minji 의 글 개수: " + repo.findByWriter("minji").size()); // 2
    System.out.println("jaehoon 의 글 개수: " + repo.findByWriter("jaehoon").size()); // 1
    System.out.println("없는 작성자(seoyeon) 의 글 개수: " + repo.findByWriter("seoyeon").size()); // 0
}

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

전체 게시물 수: 3
minji 의 글 개수: 2
jaehoon 의 글 개수: 1
없는 작성자(seoyeon) 의 글 개수: 0

repo.save·repo.countPostRepository 에 직접 적은 적이 없는데도 잘 동작하죠? 전부 부모 Repository<Post> 에서 물려받은 거예요. seoyeon 처럼 글이 없는 작성자는 빈 리스트라 size() 가 0으로 조용히 나와요.

채점 포인트

항목 배점 기준 가중
extends Repository<Post> 상속으로 시작해 공통 기능을 물려받았는가
물려받은 findAll() 활용 부모 메서드로 전체를 가져와 필터링하는가
작성자 비교 equals getAuthorName().equals(username) 으로 비교하는가(== 아님)
빈/예외 선택의 근거 "없으면 빈 리스트 vs 예외" 를 이유와 함께 골랐는가
새 리스트 반환 결과를 새 List 에 담아 돌려주는가

흔한 실수

  • Repository<T> 코드를 고치려 함 → 게시물용 기능을 넣으려고 부모 RepositoryfindByWriter 를 직접 추가하는 경우. 그러면 회원 창고에도 엉뚱한 게시물 기능이 따라붙어요. 부모는 그대로 두고 자식에서 얹는 게 상속의 핵심이에요.
  • 작성자 비교에 ==p.getAuthorName() == username 으로 비교하면, 같은 글자라도 다른 문자열 객체면 안 잡힐 수 있어요. 문자열 내용 비교는 항상 equals 예요.

실무 개선 포인트 (심화)

지금 findByWriter 는 호출될 때마다 전체 글을 한 바퀴 훑어요. 글이 몇 백 개면 괜찮지만, 수십만 개가 되면 매번 전체 순회가 부담이 돼요. 실무에서는 "작성자 → 그 사람의 글 목록" 을 미리 Map<String, List<Post>> 같은 형태로 묶어두고(이걸 인덱스라고 불러요), 검색할 때 바로 꺼내 쓰는 식으로 빠르게 만들어요. 다음 과목에서 데이터베이스를 배우면 이 "인덱스" 가 검색을 빠르게 해주는 핵심 장치로 다시 등장해요.


생각해볼 주제 1 예시답안 — "컴파일 때 막기" 가 "실행 때 막기" 보다 왜 좋을까?

[문제 상황 요약]

Step 2의 ObjectBox 는 형변환 실수를 실행 중에 ClassCastException 으로 알려줬어요. 반면 Step 3의 Box<T> 는 엉뚱한 타입을 넣으려 하면 코드를 작성하는 단계에서 바로 빨간 줄을 그어줬죠. 둘 다 잘못을 막아주지만 막아주는 시점이 달라요. 사용자가 앱을 쓰다 터지는 것과 개발자가 코드를 짜다 알아채는 것 — 어느 쪽이 고치기 쉽고 비용이 적게 들까요?

[튜터의 가이드 및 해설]

핵심은 "사고를 얼마나 일찍 발견하느냐" 예요. 같은 실수라도 발견이 빠를수록 고치기 싸고, 늦을수록 비싸요. 똑같은 형변환 실수 하나가 어디서 들키느냐에 따라 비용이 완전히 달라져요.

 같은 실수, 발견 시점에 따라 비용이 달라져요

   컴파일 때 (Box<T>)     →  개발자가 코드 짜는 그 순간 빨간 줄
                             → 0초 만에 발견, 바로 고침 → 싸다

   실행 때 (ObjectBox)    →  앱이 실제로 그 줄에 도달할 때 터짐
                             → 사용자 앞에서 발견 → 비싸다

ObjectBox 의 위험은 "코드 짤 땐 멀쩡해 보인다" 는 거예요. 안에 String 을 넣고 Integer 로 꺼내려 해도 자바는 "Object 니까 어쩌면 Integer 일 수도 있지" 하고 그냥 넘어가요. 그러다 실제 사용자가 그 기능을 쓰는 순간에야 터져요. 가장 늦게, 가장 안 좋은 자리에서 발견되는 거죠.

반면 Box<String> 에 숫자를 넣으려 하면 코드를 짜는 그 순간 빨간 줄이 떠요. 실행해보기도 전에 알아채니 그 자리에서 바로 고쳐요. 게다가 컴파일 때 막힌 사고는 절대 사용자에게 닿지 못해요 — 애초에 프로그램이 만들어지지조차 않거든요.

그래서 개발에는 "실패는 빠르고 가깝게 끌어당겨라" 는 원칙이 있어요. 같은 버그라도 운영 환경에서 사용자가 만나는 것보다, 개발자가 코드 짜다 즉시 만나는 게 백 배 낫다는 거예요. 제네릭의 타입 검사가 바로 이 "끌어당기기" 를 해줘요. 런타임에서 터질 사고를 컴파일 단계로 앞당기는 거죠.

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

"같은 버그라도 발견 시점이 비용을 가릅니다. 런타임 ClassCastException 은 사용자가 앱을 쓰는 순간 터져 가장 비싸게 들키지만, 제네릭은 타입 불일치를 컴파일 단계에서 막아 개발자가 코드 짜는 즉시 발견하게 합니다. 제네릭의 타입 안전성이 버그를 운영 환경이 아닌 컴파일 단계로 앞당겨, 사용자에게 닿기 전에 차단한다는 점이 핵심입니다."


생각해볼 주제 2 예시답안 — Box<Object> 하나면 다 담는데 왜 굳이 Box<String> 으로 좁힐까?

[문제 상황 요약]

Box<Object> 를 만들면 String 도 Member 도 숫자도 다 담을 수 있어요. 그릇 하나로 다 해결하니 편할 것 같죠. 그런데 우리는 굳이 Box<String>, Box<Member> 처럼 일부러 담을 종류를 줄여서 써요. "더 많이 담는 그릇" 을 두고 왜 "덜 담기는 그릇" 을 고를까요?

[튜터의 가이드 및 해설]

핵심은 "넓히면 꺼낼 때 다시 위험이 살아난다" 예요. Box<Object> 는 담을 땐 편하지만, 꺼낼 때 Step 2의 ObjectBox 와 똑같은 문제로 돌아가요.

 Box<Object>                    Box<String>

 담기:  뭐든 OK (편함)          담기:  String 만 OK (좁음)
 꺼내기: Object 로 나옴          꺼내기: String 그대로 나옴
        → (String) 형변환 필요          → 형변환 0
        → 잘못 캐스팅하면 💥             → 컴파일러가 보증

Box<Object> 에서 값을 꺼내면 Object 로 나와요. String 의 length() 같은 기능을 쓰려면 (String) 형변환을 다시 해줘야 하죠. 그리고 형변환을 하는 순간, "잘못 캐스팅하면 ClassCastException" 이라는 위험이 그대로 부활해요. 넓은 그릇의 편리함을 누리는 대가로, 우리가 Step 3에서 없앤 위험을 도로 불러들이는 거예요.

반대로 Box<String> 으로 좁히면 세 가지가 한꺼번에 좋아져요. 첫째, 꺼낼 때 형변환이 0이에요 — 그릇이 String 전용임을 기억하니까요. 둘째, 컴파일러가 "이 그릇엔 String 만 들어 있다" 를 보증하니 잘못 꺼낼 일이 없어요. 셋째, 코드만 봐도 "여긴 이름을 담는 그릇이구나" 하고 의도가 드러나요.

그래서 "제약이 곧 안전" 이에요. 담을 수 있는 종류를 일부러 줄이면, 그만큼 자바가 더 많은 걸 보증해줘요. 넓게 열어두면 자유로운 대신 그 자유가 곧 사고의 틈이 되고요. 꼭 여러 타입을 섞어 담아야 하는 게 아니라면, 타입을 좁게 못 박는 쪽이 거의 항상 더 안전해요.

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

"Box<Object> 는 담을 땐 편하지만 꺼낼 때 형변환이 필요해지고, 그 순간 ClassCastException 위험이 부활합니다. 타입을 Box<String> 으로 좁히면 형변환이 사라지고, 컴파일러가 내용물을 보증하며, 코드의 의도까지 드러납니다. 담을 수 있는 종류를 줄이는 제약이 곧 타입 안전성이라는 점, 그래서 굳이 넓은 그릇 대신 좁은 그릇을 고른다는 게 핵심입니다."


생각해볼 주제 3 예시답안 — PECS, 왜 읽을 땐 extends 쓸 땐 super 일까?

[문제 상황 요약]

와일드카드에서 읽기(꺼내기)는 ? extends, 쓰기(담기)는 ? super 였어요. 외우면 되긴 하지만, 왜 하필 이렇게 짝지어졌을까요? 부모-자식 관계를 그림으로 그려가며, 이 두 짝이 왜 자연스러운지 스스로 설명해보는 주제예요.

[튜터의 가이드 및 해설]

핵심은 "안전하게 보장되는 게 한쪽뿐이라, 그쪽만 허락한다" 예요. 먼저 회원 계층을 떠올려요.

              Member (부모)
             ╱        ╲
    PremiumMember    AdminMember   (자식들)

먼저 읽기(? extends Member) 를 봐요. List<? extends Member> 는 "Member 거나 그 자식의 리스트" 예요. 실제 안에 든 게 List<PremiumMember> 일 수도, List<AdminMember> 일 수도 있죠.

 ? extends Member 에서 꺼내면?

   List<PremiumMember> 일 수도, List<AdminMember> 일 수도
        │
   확실한 건 단 하나: 무엇이 나오든 "적어도 Member 이긴 하다"
        → Member 타입으로 꺼내 읽기 = 안전 ✅

   넣으려면?  지금 이 리스트가 Premium 전용인지 Admin 전용인지
              컴파일러가 모름 → 잘못 넣으면 사고 → 막음 ❌

꺼낼 땐 "무엇이 나오든 최소한 Member 다" 가 보장되니 Member m 으로 받아 읽기가 안전해요. 하지만 넣으려 하면, 이 리스트가 Premium 전용인지 Admin 전용인지 컴파일러가 알 수 없어서 막아요. 그래서 읽기(생산자)만 하는 자리엔 extends 가 어울려요.

이번엔 쓰기(? super Member) 예요. List<? super Member> 는 "Member 거나 그 부모의 리스트" 예요. List<Object> 일 수도 있죠.

 ? super Member 에 담으면?

   List<Member> 일 수도, List<Object> 일 수도 (부모 자리)
        │
   Member 를 담으면?  부모 자리엔 자식이 들어갈 수 있으니 = 안전 ✅
   (Object 자리에 Member 넣기 = 당연히 됨)

   꺼내면?  뭐가 나올지 부모까지 넓어서 알 수 없음
            → Object 로만 받을 수 있음 → 읽기는 제약 ❌

담을 땐 "부모 자리에 자식을 넣는" 거라 항상 안전해요(Object 자리에 Member 를 넣는 건 당연히 되죠). 하지만 꺼내면 그 자리가 Member 의 부모일 수도 있어서 뭐가 나올지 몰라요 — Object 로만 받을 수 있죠. 그래서 쓰기(소비자)만 하는 자리엔 super 가 어울려요.

정리하면 이래요. 읽을 땐 extends 가 "최소한 부모 타입은 보장" 해주니 안전하고, 쓸 땐 super 가 "부모 자리니 자식을 담아도 넘치지 않음" 을 보장해줘요. PECS — Producer Extends, Consumer Super 는 이 두 안전을 한 줄로 외운 거예요. 지금 100% 와닿지 않아도 괜찮아요. 라이브러리 함수 설명을 읽다 ? extends / ? super 를 만나면 "아, 읽는 쪽이구나 / 쓰는 쪽이구나" 하고 해석하는 데 이 직관이 다시 살아나요.

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

"PECS 는 '안전이 보장되는 방향만 허락한다' 로 이해합니다. ? extends 는 꺼낼 때 '최소한 부모 타입은 보장' 되니 읽기가 안전하지만, 정확한 자식 타입을 컴파일러가 몰라 넣기는 막힙니다. 반대로 ? super 는 부모 자리라 자식을 담아도 넘치지 않아 쓰기가 안전하지만, 꺼내면 Object 로만 받을 수 있습니다. 그래서 생산자엔 extends, 소비자엔 super — 안전한 방향이 곧 허락되는 방향이라는 게 핵심입니다."

더 배우려면

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

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