문서 읽는 데 61분 · day10

Day10: 상속 — 이미 만든 설계도를 물려받아 특화하기

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

안녕하세요! 여러분의 Java 가이드, 홍순구 튜터입니다.

Day 10에 오신 걸 환영합니다! 지난 시간엔 Member 라는 설계도에 행동(메서드) 을 붙여서, 회원이 자기 점수를 직접 계산하고 자기 데이터를 스스로 지키는 "살아 있는 객체" 로 만들었어요.

그런데 지난 시간 마지막에 제가 이런 떡밥을 하나 던져뒀죠. 회원이 정말 한 종류뿐일까요?

인스타그램을 떠올려보세요. 그냥 사진 올리고 좋아요 누르는 일반 회원도 있고, 누군가 올린 부적절한 게시물을 지울 수 있는 관리자 회원도 있고, 돈을 내고 광고 없이 쓰는 프리미엄 회원도 있어요.

이 셋은 전부 "회원" 이에요. 이름도 있고, 팔로워도 있고, 추천 점수도 계산하죠. 그러면서도 각자 조금씩 다른 점이 있어요. 관리자는 게시물을 지울 수 있고, 프리미엄은 광고가 없고요.

여기서 문제가 생겨요. 관리자 회원을 만들겠다고 Member 클래스를 통째로 복사해서 AdminMember 라는 새 클래스를 만들면 어떻게 될까요? 이름·팔로워·점수 계산 같은 똑같은 코드가 두 군데에 살게 돼요. 나중에 점수 계산법이 바뀌면? 두 군데를 다 고쳐야 해요. 하나라도 빠뜨리면 버그죠.

오늘은 바로 이 이야기예요. 이미 잘 만들어 둔 Member물려받아서, 다른 점만 덧붙이는 방법을 배웁니다. 이걸 상속(inheritance) 이라고 불러요. "물려받는다" 는 뜻이에요.

오늘의 주제는 "상속 — 이미 만든 설계도를 물려받아 특화하기" 입니다.

🎯 학습 목표

  • 같은 코드를 여러 클래스에 복사할 때의 고통을 이해하고, 왜 상속이 그걸 해결하는지 설명할 수 있다
  • extends 키워드로 부모 클래스의 필드와 메서드를 물려받는 자식 클래스를 만들 수 있다
  • super(...) 로 부모 생성자를 먼저 호출하는 순서와 규칙을 안다
  • 물려받은 메서드를 자식이 자기 식으로 다시 정의하는 오버라이딩(overriding)@Override 의 의미를 안다
  • 모든 클래스의 보이지 않는 부모인 Object 를 알고, toString()equals() 를 오버라이딩할 수 있다
  • static 필드가 부모와 자식 사이에서 어떻게 공유되는지 설명할 수 있다

Step 1. 왜 상속이 필요할까? — 일반 회원과 관리자 회원

상속이라는 단어를 꺼내기 전에, 먼저 "상속이 없으면 얼마나 불편한가" 부터 같이 느껴봐요. 이름부터 외우면 비전공자는 멈칫하거든요. 문제를 먼저 보고, 해결책에 이름을 붙이는 순서로 갈게요.

같은 코드를 두 번 쓰는 고통

지난 시간 우리가 만든 Member 에는 이런 게 들어 있었어요.

  • 다섯 가지 정보: 이름, 팔로워 수, 게시물 수, 함께 아는 친구 수, 활동 일수
  • 추천 점수 계산 메서드 calculateRecommendScore()
  • 등급 판정 메서드 grade()
  • getter/setter, 전체 회원 수를 세는 static 카운터

이제 관리자 회원을 만들어야 해요. 관리자도 회원이니까 위의 모든 게 똑같이 필요해요. 이름도 있고, 팔로워도 있고, 점수도 계산하죠. 거기에 관리자만의 것 하나가 더 붙어요. "게시물을 삭제할 수 있다" 는 행동이요.

상속을 모른다면, 우리는 이렇게 하고 싶어질 거예요.

[ 상속이 없을 때 — 통째로 복사 ]

  Member.java                  AdminMember.java (복붙!)
  ┌────────────────────┐       ┌────────────────────┐
  │ username            │       │ username            │ ← 똑같음
  │ followers           │       │ followers           │ ← 똑같음
  │ posts               │       │ posts               │ ← 똑같음
  │ calculateScore()    │  복사 │ calculateScore()    │ ← 똑같음
  │ grade()             │ ────► │ grade()             │ ← 똑같음
  │ getUsername()...    │       │ getUsername()...    │ ← 똑같음
  └────────────────────┘       │ deletePost()        │ ← 이것만 다름!
                               └────────────────────┘

그림을 보면 답답하죠? 딱 한 줄(deletePost()) 추가하려고 나머지 수십 줄을 그대로 베껴 쓴 거예요.

이게 왜 위험하냐면요. 다음 달에 "추천 점수 계산법을 바꾸자" 는 요청이 오면, MemberAdminMember 두 곳을 똑같이 고쳐야 해요. 한 곳만 고치고 다른 곳을 깜빡하면, 일반 회원과 관리자의 점수가 서로 다르게 계산되는 이상한 버그가 생겨요. 복사한 코드는 시간이 지날수록 조금씩 어긋나면서 골칫거리가 됩니다.

해결책에 이름을 붙이면 — 상속

이 고통을 없애는 방법이 바로 상속(inheritance) 이에요. "이미 만든 Member 를 그대로 물려받고, 다른 점(deletePost)만 덧붙이자" 는 거죠.

[ 상속이 있을 때 — 물려받기 ]

         Member (부모)
       ┌──────────────────┐
       │ username          │
       │ followers, posts  │
       │ calculateScore()  │
       │ grade(), getter   │
       └──────────────────┘
                ▲
                │ extends (물려받음)
        ┌───────┴────────┐
   AdminMember        PremiumMember
   ┌─────────────┐    ┌─────────────┐
   │ + adminRole │    │ + adProtected│
   │ + deletePost│    │ (광고 없음)  │
   └─────────────┘    └─────────────┘

위에 있는 Member부모 클래스(superclass, 슈퍼클래스) 라고 불러요. 위쪽에 있다고 "슈퍼" 예요. 아래에서 물려받는 AdminMember, PremiumMember자식 클래스(subclass, 서브클래스) 라고 해요.

자식은 부모가 가진 걸 전부 그대로 받아요. 이름·팔로워·점수 계산을 다시 안 써도 이미 가지고 있죠. 그리고 자기만의 것만 추가하면 돼요. 부모 코드는 단 한 군데(Member.java)에만 있으니, 점수 계산법이 바뀌어도 거기 한 곳만 고치면 모든 자식에 자동으로 반영돼요.

"관리자도 회원이다" — is-a 관계

상속을 언제 쓰면 되는지 판단하는 아주 쉬운 기준이 하나 있어요. 바로 "~는 ~이다" 가 자연스럽게 말이 되는지 보는 거예요. 영어로 is-a 관계 라고 해요.

  • "관리자는 회원이다" → 자연스럽죠? → 상속 OK (AdminMember extends Member)
  • "프리미엄 회원은 회원이다" → 자연스럽죠? → 상속 OK
  • "회원은 게시물이다" → 이상하죠? → 상속하면 안 됨

관리자는 회원의 한 종류예요. 회원이 할 수 있는 건 관리자도 다 할 수 있고, 거기에 관리자만의 능력이 더 붙죠. 이렇게 "A는 B의 한 종류다" 가 말이 될 때 상속을 쓰면 자연스러워요.

💡 튜터의 결론

같은 코드를 여러 클래스에 복사하면 나중에 고칠 때 지옥이 와요. "A는 B다(is-a)" 가 말이 되면, A가 B를 상속해서 공통 코드를 물려받고 다른 점만 추가하세요. 공통 코드는 부모 한 곳에만 두는 게 상속의 핵심 가치예요.


Step 2. extends — 자식 클래스 선언과 필드 물려받기

이제 코드로 상속을 만들어볼게요. 관리자 회원을 표현하는 AdminMember 클래스를 Member 로부터 물려받게 하겠습니다.

extends 한 줄이면 물려받기 시작

상속의 핵심은 클래스 선언 첫 줄에 있는 extends 키워드 하나예요. extends 는 "확장하다, 늘리다" 라는 뜻인데, 여기서는 "물려받아 늘린다" 는 의미로 쓰여요.

// com/instagram/javabasic/domain/member/AdminMember.java
public class AdminMember extends Member {

    // 관리자만 추가로 갖는 정보 — 예: "콘텐츠 관리자"
    private String adminRole;

    public String getAdminRole() {
        return adminRole;
    }
}

extends Member 이 한 부분이에요. "AdminMemberMember 를 물려받는다" 는 선언이죠.

이 한 줄을 쓰는 순간, AdminMember 는 별도로 다시 쓰지 않아도 Member 가 가진 걸 전부 가져요. username, followers, posts 같은 필드도, calculateRecommendScore(), grade(), getUsername() 같은 메서드도 전부요. 우리가 AdminMember 안에 새로 적은 건 관리자만의 정보인 adminRole 하나뿐인데, 실제로는 Member 의 모든 걸 갖춘 회원이 되는 거예요.

비유하자면 이래요. Member 가 "기본형 스마트폰 설계도" 라면, AdminMember 는 "그 설계도를 그대로 물려받은 뒤 카메라 하나만 더 붙인 모델" 이에요. 화면·배터리·통화 기능은 기본형에서 그대로 물려받았으니, 추가한 카메라만 새로 설계하면 되는 거죠.

private 필드는 물려받아도 직접 못 쓴다

여기서 비전공자분들이 꼭 한 번 멈칫하는 지점이 있어요. 미리 짚고 갈게요.

지난 시간에 우리가 Member 의 필드를 전부 private 으로 숨겼던 거 기억나시죠? private String username; 처럼요. private 은 "이 클래스 안에서만 쓸 수 있다" 는 자물쇠예요.

그런데 자식인 AdminMember 는 부모와 다른 클래스예요. 그래서 username 을 물려받긴 했지만, private 자물쇠 때문에 AdminMember 안에서 username직접 꺼내 쓸 수는 없어요.

[ private 필드 접근 ]

   Member (부모)
   ┌─────────────────────────────┐
   │ private username  🔒        │ ← 자물쇠: Member 안에서만
   │ public getUsername() ────────┼──► 누구나 이 통로로는 OK
   └─────────────────────────────┘
            ▲ extends
   AdminMember (자식)
   ┌─────────────────────────────┐
   │ username 을 직접? ❌ (자물쇠) │
   │ getUsername() 으로? ✅       │
   └─────────────────────────────┘

그럼 자식은 부모의 이름을 어떻게 읽을까요? 지난 시간에 만들어둔 public getter 통로, 즉 getUsername() 을 쓰면 돼요. public 은 "누구나 쓸 수 있다" 는 뜻이라 자식도 당연히 쓸 수 있죠. 이게 캡슐화를 배운 보람이에요. 자물쇠는 잠그되, 정해진 통로는 열어둔 덕분에 자식도 안전하게 부모 데이터를 읽을 수 있는 거예요.

이 원리는 곧 Step 4에서 deletePost() 를 만들 때 다시 만나요. 관리자가 게시물을 지우면서 자기 이름을 화면에 보여줘야 하는데, 그때 username 을 직접 못 쓰고 getUsername() 을 부르거든요. "아, 그래서 그렇게 쓰는구나" 하고 연결될 거예요.

💡 튜터의 결론

extends 부모 한 줄이면 부모의 필드·메서드를 전부 물려받아요. 단, 부모의 private 필드는 물려받아도 자식이 직접은 못 쓰고, 부모가 열어둔 public getter 통로로 읽어요. 캡슐화의 자물쇠는 자식에게도 똑같이 적용돼요.


Step 3. super() — 부모 생성자를 먼저 부르기

AdminMemberMember 를 물려받는 건 알았어요. 그런데 한 가지 풀어야 할 숙제가 있어요. 객체를 만들 때 그 다섯 가지 정보를 어떻게 채울까요?

부모가 가진 필드는 부모 생성자가 채운다

Member 의 필드(username, followers 등)는 전부 private 이라 자식이 직접 못 건드린다고 했죠. 그러면 AdminMember 객체를 만들 때 이름·팔로워 같은 값을 어떻게 넣을까요?

답은 "부모의 생성자에게 부탁한다" 예요. 지난 시간에 만든 Member 의 매개변수 생성자가 그 다섯 필드를 채워주거든요. 그걸 자식이 불러주면 돼요. 이때 부모 생성자를 부르는 키워드가 바로 super(...) 예요. super 는 "위, 윗사람" 이라는 뜻으로 여기서는 "부모" 를 가리켜요.

// com/instagram/javabasic/domain/member/AdminMember.java
public AdminMember(String username, int followers, int posts, int mutualFriends, int daysActive, String adminRole) {
    super(username, followers, posts, mutualFriends, daysActive);
    this.adminRole = adminRole;
}

AdminMember 의 생성자를 한 줄씩 읽어볼게요.

첫 줄 super(username, followers, posts, mutualFriends, daysActive); 가 핵심이에요. 이건 "부모(Member)의 생성자를 불러서, 물려받은 다섯 필드를 먼저 채워줘" 라는 부탁이에요. 이 한 줄 덕분에 private 이라 직접 못 건드리던 부모 필드들이 부모의 손을 거쳐 깔끔하게 채워져요.

그 다음 줄 this.adminRole = adminRole; 는 부모와 상관없는, 관리자만의 필드를 채우는 부분이에요. 부모 필드를 다 채운 뒤에 자식 필드를 채우는 순서죠.

1층부터 짓고 2층을 올린다

이 순서를 집 짓기에 비유하면 이해가 쉬워요.

[ AdminMember 객체가 만들어지는 순서 ]

  ① super(...) 호출
     └─► Member 생성자 실행
         username, followers, posts...  ← 부모 필드 채움 (1층)
         totalMembers++                 ← 부모 생성자 안의 일도 함께 실행!

  ② super 가 끝난 뒤
     this.adminRole = adminRole;        ← 자식 필드 채움 (2층)

건물을 지을 때 1층 없이 2층부터 올릴 수는 없죠. 객체도 마찬가지예요. 부모 부분(1층)을 먼저 완성하고, 그 위에 자식 부분(2층)을 올려요. 그래서 super(...)반드시 자식 생성자의 첫 줄 에 와야 해요. 1층부터 지어야 하니까요.

여기서 재미있는 사실 하나. Member 생성자 안에는 totalMembers++ 가 들어 있었던 거 기억나시죠? 전체 회원 수를 세는 카운터요. AdminMember 를 만들면 super(...) 가 부모 생성자를 부르면서 이 totalMembers++ 도 같이 실행돼요. 즉, 관리자 회원을 하나 만들어도 전체 회원 수에 자동으로 포함되는 거예요. 이건 Step 6에서 다시 자세히 다룰게요.

💡 튜터의 결론

자식 생성자의 첫 줄 super(...) 가 부모 생성자를 불러 물려받은 필드를 먼저 채워요. "1층(부모)부터 짓고 2층(자식)을 올린다" 가 객체 생성 순서예요. 그래서 super(...) 는 항상 첫 줄에 와요.


Step 4. 메서드 오버라이딩과 @Override

지금까지는 부모 걸 그대로 물려받기만 했어요. 그런데 자식이 부모와 똑같이 행동하지 않고, "나는 좀 다르게 하고 싶어" 할 때가 있어요. 관리자는 추천 점수를 일반 회원과 똑같이 계산하면 좀 섭섭하잖아요? 관리자니까 보너스 점수를 좀 받고 싶어요.

물려받은 메서드를 자기 식으로 다시 정의하기

이렇게 부모가 물려준 메서드를 자식이 자기 식으로 다시 정의하는 것오버라이딩(overriding) 이라고 해요. "덮어쓴다" 는 뜻이에요. 부모의 메서드 위에 자식의 새 버전을 덮어씌우는 거죠.

AdminMember 가 점수 계산을 오버라이딩한 코드를 볼게요.

// 오버라이딩 — 부모의 점수 계산을 super 로 그대로 쓰고, 관리자 보너스 50점을 더해요.
@Override
public int calculateRecommendScore() {
    return super.calculateRecommendScore() + 50;
}

이 코드가 왜 이렇게 생겼는지 한 줄씩 풀어볼게요.

부모인 Member 에도 calculateRecommendScore() 가 있었죠. 팔로워·게시물·친구 수로 점수를 계산하는 메서드요. 관리자라고 그 계산을 처음부터 다시 쓸 필요는 없어요. 그 계산은 그대로 쓰고, 끝에 보너스 50점만 더하면 되거든요.

그래서 super.calculateRecommendScore() 를 써요. Step 3에서 super(...) 가 부모 생성자를 불렀던 것처럼, super.메서드()부모의 그 메서드 를 부르는 거예요. 부모가 계산한 원래 점수를 가져온 다음, + 50 으로 관리자 보너스를 붙이는 거죠.

이게 상속의 우아한 점이에요. 부모 계산을 통째로 복사하지 않고, 부모가 한 일을 그대로 가져다 쓰면서 내 것만 살짝 더했어요. 나중에 부모의 점수 계산법이 바뀌어도, super.calculateRecommendScore() 가 알아서 바뀐 계산을 가져오니 관리자 점수도 자동으로 따라가요.

실제로 어떤 숫자가 나오는지 볼까요. 이 동작은 코드베이스의 점수 계산 검증으로 확인해두었는데요. 지난 시간에 본 jaehoon 의 스탯(팔로워 1240, 게시물 42 등)으로 계산하면 부모 점수가 104점이 나와요. 같은 스탯의 AdminMember 라면 거기에 보너스 50을 더해 154점이 되죠. 부모 계산을 그대로 쓰면서 관리자만 더 받는 게 코드로 확인되는 거예요.

@Override — "이건 부모 걸 덮어쓰는 거예요" 표시

코드 위에 붙은 @Override 가 오늘 처음 만나는 새 문법이에요. 짚고 갈게요. @ 로 시작하는 건 어노테이션(annotation) 이라고 부르는데, "이 코드에 대한 메모, 표시" 정도로 생각하면 돼요.

@Override 는 "이 메서드는 부모 메서드를 덮어쓰는(오버라이딩하는) 거예요" 라고 표시하는 거예요. 그런데 이게 단순한 메모가 아니라, 아주 고마운 안전장치 역할을 해요.

예를 들어 우리가 실수로 메서드 이름을 calculateRecomendScore() 처럼 오타를 냈다고 해봐요. (m이 하나 빠졌죠.) @Override 가 없으면, 자바는 이걸 그냥 "관리자만의 새 메서드" 라고 받아들여서 아무 말 없이 넘어가요. 부모 걸 덮어쓰려던 의도와 다르게, 엉뚱한 새 메서드가 하나 생기는 거죠. 찾기 힘든 버그예요.

@Override 를 붙여두면, 자바가 "어? 부모에 calculateRecomendScore 라는 메서드가 없는데? 덮어쓸 게 없어요!" 하고 곧바로 빨간 줄로 알려줘요. 오타를 컴파일 단계에서 바로 잡아주는 거죠. 그래서 오버라이딩할 땐 @Override 를 붙이는 습관을 들이면 좋아요.

오버라이딩 vs 새 메서드 추가 — deletePost()

오버라이딩과 헷갈리기 쉬운 게 하나 있어요. "부모에 아예 없던 새 메서드를 추가" 하는 경우예요. 지난 시간 떡밥이었던 "관리자는 게시물을 지울 수 있다" 를 코드로 만들어볼게요.

// 관리자만의 행동 — username 은 부모의 private 필드라 직접 못 쓰고,
// 부모가 열어둔 public getter(getUsername())로 읽어요.
public String deletePost(String postId) {
    return "[관리자] " + getUsername() + " 가 게시물 " + postId + " 을(를) 삭제했어요.";
}

deletePost() 는 부모인 Member 에는 아예 없는 메서드예요. 부모 걸 덮어쓰는 게 아니라, 자식이 새로 만든 자기만의 행동이죠. 그래서 @Override 를 안 붙여요. 덮어쓸 부모 메서드가 없으니까요.

여기서 Step 2에서 예고한 장면이 나와요. 관리자 이름을 화면에 넣으려고 getUsername() 을 부르고 있죠? username 은 부모의 private 필드라 자식이 직접 못 쓴다고 했잖아요. 그래서 부모가 열어둔 getUsername() 통로로 읽는 거예요. "아, 그래서 이렇게 쓰는구나" 가 여기서 연결돼요.

정리하면 자식이 부모 위에 얹을 수 있는 건 두 종류예요.

종류 의미 @Override 예시
오버라이딩 부모에 있던 메서드를 자기 식으로 다시 정의 붙임 calculateRecommendScore()
새 메서드 추가 부모에 없던 자식만의 새 행동 안 붙임 deletePost()

💡 튜터의 결론

부모 메서드를 자기 식으로 다시 정의하는 게 오버라이딩, 위에 @Override 를 붙여요. super.메서드() 로 부모가 한 일을 가져다 쓰면서 내 것만 더할 수 있어요. 부모에 없던 새 행동(deletePost)을 추가하는 건 오버라이딩이 아니라 그냥 새 메서드예요.


Step 5. Object 클래스 — toString()과 equals()

상속 얘기를 하다 보면 자연스럽게 풀리는 비밀이 하나 있어요. 지난 시간 Member 코드에 이미 @Override public String toString() 이 있었던 거, 눈치채셨나요? 우리는 Member 가 누군가를 상속한다고 쓴 적이 없는데, 어떻게 부모 걸 "오버라이딩(@Override)" 한 걸까요?

모든 클래스의 보이지 않는 부모, Object

비밀은 이거예요. 자바의 모든 클래스는 자동으로 Object 라는 클래스를 상속해요. 우리가 extends 를 안 써도요.

[ 보이지 않는 최상위 부모 ]

              Object  ← 모든 클래스의 부모 (자바가 자동으로 연결)
            ┌──┴──┐
        toString()  equals()  ← 우리가 안 만들어도 이미 있음
                │
              Member  ← extends 안 써도 Object 를 자동 상속
                │
            AdminMember

그러니까 Member 는 우리가 안 적었어도 사실은 extends Object 가 숨어 있는 거예요. 그래서 MembertoString() 이나 equals() 를 적으면, 사실은 Object 가 물려준 그 메서드들을 오버라이딩하는 거였어요. 그래서 @Override 가 붙은 거죠.

AdminMember 입장에서 보면 부모가 둘이에요. 바로 위 부모는 Member, 그 위 할아버지는 Object. 이렇게 위로 쭉 이어지는 게 상속이에요.

toString() — 객체의 자기소개

Object 가 물려준 toString() 을 그냥 두면 어떻게 될까요? 객체를 화면에 출력했을 때 이런 게 찍혀요.

com.instagram.javabasic.domain.member.Member@1b6d3586

@1b6d3586 같은 건 객체가 메모리 어디에 있는지 알려주는 주소인데, 사람이 읽기엔 전혀 친절하지 않죠. 그래서 우리는 toString() 을 오버라이딩해서 사람이 읽을 수 있는 글로 바꿔둔 거예요.

// com/instagram/javabasic/domain/member/Member.java
@Override
public String toString() {
    return "@" + username + " (팔로워 " + followers + ", 점수 " + calculateRecommendScore() + "점)";
}

toString() 은 객체의 자기소개 라고 생각하면 돼요. "나는 @jaehoon이고, 팔로워는 1240명, 점수는 104점이야" 하고 자기를 소개하는 거죠. 이제 객체를 출력하면 주소 대신 이 자기소개가 나와요.

AdminMember 도 자기소개를 자기 식으로 오버라이딩했어요. 부모 자기소개에 역할 표시만 덧붙이는 식으로요.

// com/instagram/javabasic/domain/member/AdminMember.java
@Override
public String toString() {
    return super.toString() + " [관리자: " + adminRole + "]";
}

super.toString() 으로 부모의 자기소개를 그대로 가져온 다음, [관리자: 콘텐츠 관리자] 같은 꼬리표만 붙였어요. Step 4의 점수 오버라이딩과 똑같은 방식이죠. 부모가 한 일을 재사용하고 내 것만 더하기.

equals() — 같은 사람인지 알아보기

Object 가 물려준 또 하나의 메서드가 equals() 예요. "두 객체가 같은가?" 를 판단하는 메서드죠.

그런데 기본 equals() 는 좀 야박해요. 두 객체가 메모리에서 완전히 똑같은 자리 에 있을 때만 같다고 봐요. 즉 같은 객체일 때만요. 그래서 이름이 똑같이 jaehoon 인 회원 객체 두 개를 따로 만들면, 우리 눈엔 같은 사람 같지만 equals() 는 "다르다(false)" 고 답해요. 메모리 자리가 다르니까요.

우리가 원하는 건 "이름이 같으면 같은 사람" 이잖아요. 그래서 equals() 를 값 기준으로 비교하도록 오버라이딩해요.

// com/instagram/javabasic/domain/member/Member.java
@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);
}

한 줄씩 천천히 볼게요.

  • if (this == obj) return true; — 비교 대상이 나 자신과 완전히 같은 객체면 당연히 같으니 바로 true.
  • if (obj == null || getClass() != obj.getClass()) return false; — 비교 대상이 비어 있거나(null), 아예 종류가 다른 클래스면 다르다고 판정.
  • Member other = (Member) obj; — 비교 대상을 Member 로 다룰 수 있게 형태를 맞춰주는 부분이에요. 이 (Member) 라는 형 변환은 다음 시간(다형성)에 자세히 배워요. 지금은 "비교하려면 이렇게 한 줄 쓴다" 정도로만 기억하면 충분해요.
  • return username != null && username.equals(other.username); — 두 회원의 이름이 같으면 같은 사람으로 봐요.

equals() 는 객체의 "같은 사람인지 알아보기" 라고 기억하면 돼요. 자기소개(toString)가 "나는 누구다" 라면, equals 는 "너랑 나랑 같은 사람이야?" 를 묻는 거죠.

그리고 한 가지만 가볍게 귀띔할게요. 보통 equals() 를 바꾸면 hashCode() 라는 짝꿍 메서드도 함께 손봐줘야 해요. 지금은 깊이 안 들어갈 거예요. 이 짝꿍이 왜 중요한지는 나중에 여러 데이터를 빠르게 모아 담는 컬렉션 을 배울 때 다시 만나요. 지금은 "equals 옆에 hashCode 라는 짝꿍이 있다더라" 정도만 알아두면 돼요.

💡 튜터의 결론

모든 클래스는 Object 를 자동으로 상속해요. 그래서 toString()·equals() 는 안 만들어도 이미 있어요. 객체를 사람이 읽게 하려면 toString()(자기소개)을, 값으로 같다고 보려면 equals()(같은 사람 알아보기)를 오버라이딩하세요.


Step 6. static 필드와 상속 — 전체 회원 수 공유하기

Step 3에서 살짝 흘렸던 이야기를 마저 풀어볼게요. AdminMember 를 만들면 전체 회원 수가 어떻게 될까요?

static 칸은 부모와 자식이 함께 쓴다

지난 시간에 배운 static int totalMembers 를 다시 떠올려봐요. 이건 객체마다 따로 있는 게 아니라, 클래스에 딱 하나만 있는 칸이었죠. 회원을 하나 만들 때마다 생성자 안의 totalMembers++ 가 이 하나뿐인 칸을 1씩 올렸어요.

그럼 AdminMember 는요? AdminMemberMember 를 상속하니까, 이 totalMembers 칸도 부모와 같은 칸을 함께 써요. 자식이라고 따로 새 칸을 갖는 게 아니에요.

[ static 칸 vs 인스턴스 칸 ]

  ┌─────────────────────────────────────┐
  │  static totalMembers = 3   ← 단 한 칸 │  Member·AdminMember 가
  │                              (공유)   │  같이 쓰는 칸
  └─────────────────────────────────────┘

  jaehoon (Member)    minji (Member)    admin (AdminMember)
  ┌──────────────┐    ┌──────────────┐  ┌──────────────────┐
  │ followers 1240│    │ followers 850│  │ followers 320     │
  │ posts 42      │    │ posts 150    │  │ adminRole "콘텐츠"│
  └──────────────┘    └──────────────┘  └──────────────────┘
   └─ 객체마다 따로인 칸(인스턴스 필드) ──┘

위 그림에서 아래쪽 팔로워·게시물 칸은 객체마다 따로예요. jaehoon과 minji는 팔로워 수가 다르죠. 하지만 맨 위의 totalMembers 칸은 셋이 함께 쓰는 단 하나의 칸이에요.

AdminMember 를 만들어도 전체 회원 수에 포함된다

이게 왜 자연스러운 결과인지 보여드릴게요.

Step 3에서 AdminMember 생성자의 첫 줄 super(...) 가 부모 Member 의 생성자를 부른다고 했죠? 그 부모 생성자 안에는 totalMembers++ 가 들어 있어요. 그러니까 AdminMember 를 하나 만들면, super(...) → 부모 생성자 실행 → totalMembers++ 가 자동으로 이어져요.

즉 일반 회원을 만들든 관리자 회원을 만들든, 결국 부모 생성자를 거치니까 전체 회원 수에 똑같이 한 명으로 잡혀요. "관리자도 회원이다" 라는 is-a 관계가 숫자 세기에서도 그대로 드러나는 거죠. 관리자도 회원이니까 전체 회원 수에 당연히 포함되고요.

이건 우리가 따로 코드를 더 쓴 게 아니에요. super(...) 로 부모 생성자를 부르도록 만들어둔 덕분에 자연스럽게 따라온 결과예요. 상속이 우리 대신 일을 해준 거죠.

💡 튜터의 결론

static 칸은 클래스에 하나뿐이라 부모와 자식이 같은 칸을 함께 써요. 자식 생성자가 super(...) 로 부모 생성자를 부르니, 관리자를 만들어도 전체 회원 수에 자연스럽게 포함돼요.


Step 7. 종합 — AdminMember 가 움직이는 모습 확인하기

지금까지 만든 부품을 하나로 모아서, AdminMember 가 실제로 어떻게 움직이는지 따라가볼게요. 관리자 회원 한 명을 만들어서 여러 동작을 시켜보는 거예요.

관리자 한 명을 만들어 일을 시켜보면

jaehoon 의 스탯에 "콘텐츠 관리자" 역할을 가진 AdminMember 를 하나 만들었다고 해봐요. 이 관리자에게 차례로 일을 시키면 이런 일이 벌어져요.

먼저 게시물을 지우게 하면, deletePost("post_99")"[관리자] @jaehoon 가 게시물 post_99 을(를) 삭제했어요." 같은 글을 돌려줘요. 이건 부모에 없던, 관리자만의 새 행동이었죠. 그리고 자기 이름은 getUsername() 통로로 읽어왔고요.

점수를 계산시키면 어떻게 될까요? 같은 스탯의 일반 회원이라면 104점인데, 이 관리자는 오버라이딩 덕분에 거기에 보너스 50을 더한 154점 이 나와요. 등급(grade())을 물어보면, 이 154점을 기준으로 판정하죠.

객체를 그냥 출력하면 우리가 오버라이딩해둔 자기소개가 나와요. @jaehoon (팔로워 1240, 점수 154점) [관리자: 콘텐츠 관리자] 처럼요. 부모 자기소개 뒤에 관리자 꼬리표가 붙은 모습이에요.

이 동작들은 코드베이스에서 점수 154점, 자기소개 형식 등으로 검증해두었어요.

흥미로운 발견 — 부모 안에서 자식이 불린다

여기서 오늘 가장 신기한 장면 하나를 보여드릴게요. 다음 시간으로 가는 다리가 되는 발견이에요.

AdminMember 의 자기소개를 다시 떠올려봐요.

@Override
public String toString() {
    return super.toString() + " [관리자: " + adminRole + "]";
}

super.toString() 으로 부모의 자기소개를 불렀죠. 부모의 자기소개는 이렇게 생겼었어요.

return "@" + username + " (팔로워 " + followers + ", 점수 " + calculateRecommendScore() + "점)";

여기 점수 부분에서 부모가 calculateRecommendScore() 를 부르고 있어요. 그런데 관리자의 자기소개를 출력했더니 점수가 104점이 아니라 154점으로 나왔어요!

이상하지 않나요? 우리는 분명 부모의 toString() 을 불렀어요. 그 안에서 점수를 계산했고요. 그런데 부모 메서드 안에서 계산한 점수가, 부모 버전(104점)이 아니라 자식이 오버라이딩한 버전(154점) 으로 나온 거예요.

[ super.toString() 을 부른 관리자 객체 ]

  super.toString()  ← 부모 코드 실행 중
       │
       └─► calculateRecommendScore() 호출
              │
              ▼  이 객체는 AdminMember 라서...
           자식이 오버라이딩한 154점 버전이 불린다! (104점 아님)

부모 코드 안에서 점수를 계산했는데, "이 객체가 실제로는 관리자(AdminMember)" 라는 걸 자바가 기억하고 있다가 자식 버전을 불러준 거예요. 부모 메서드 안에서 자식의 오버라이딩된 버전이 호출되는 이 신기한 동작 이 바로 다음 시간에 배울 다형성(polymorphism) 의 핵심이에요.

지금은 "어? 부모를 불렀는데 자식 게 나오네?" 하는 신기함만 기억해두세요. 이 정체를 다음 시간에 시원하게 풀어드릴게요.

참고로, 오프닝에서 말한 또 다른 회원인 프리미엄 회원(PremiumMember) 은 오늘 코드로 만들지 않았어요. 이건 여러분이 오늘 배운 걸 모아 직접 만들어보는 과제로 남겨둘게요. Member 를 상속하고, super(...) 를 부르고, 점수를 오버라이딩하는 연습이죠.

💡 튜터의 결론

부모의 toString() 안에서 점수를 계산했는데, 실제 객체가 관리자라서 자식의 154점 버전이 불려요. "부모 안에서 자식 메서드가 호출되는" 이 신기한 동작이 다음 시간 다형성의 핵심이에요. 일단 신기함만 기억해두세요.


마무리

오늘 우리는 지난 시간에 완성한 Member 를 여러 종류의 회원으로 갈라지게 만들었어요.

  1. 상속이 필요한 이유 — 같은 코드를 복사하면 나중에 고칠 때 지옥. "A는 B다(is-a)" 면 물려받자.
  2. extendsextends Member 한 줄로 부모의 필드·메서드를 전부 물려받았어요. private 필드는 getter 통로로 읽고요.
  3. super() — 자식 생성자 첫 줄에서 부모 생성자를 불러 물려받은 필드를 먼저 채웠어요. 1층부터 짓고 2층을 올리는 순서죠.
  4. 오버라이딩 + @Override — 물려받은 점수 계산을 관리자 식으로 다시 정의하고, super.메서드() 로 부모 일을 재사용했어요. @Override 는 오타까지 잡아주는 안전장치였고요.
  5. Object 의 toString()·equals() — 모든 클래스의 보이지 않는 부모 Object 가 물려준 메서드를 오버라이딩해, 객체에 자기소개와 "같은 사람 알아보기" 를 붙였어요.
  6. static 과 상속 — 부모·자식이 하나뿐인 static 칸을 공유해서, 관리자를 만들어도 전체 회원 수에 자연스럽게 포함됐어요.
  7. 종합 동작 — 관리자 하나를 만들어 삭제·점수(154)·자기소개를 확인하고, "부모 안에서 자식이 불리는" 신기한 발견까지 봤어요.

지난 시간엔 데이터에 행동을 붙여 객체를 살아 있게 했고, 오늘은 그 객체를 물려받아 여러 종류로 풍성하게 만들었어요. 하나의 Member 가 일반 회원과 관리자 회원으로 갈라지면서 코드가 훨씬 단정해졌죠.

다음 시간 예고

오늘 마지막에 본 그 신기한 장면, 기억나시죠? 부모의 toString() 을 불렀는데 자식이 오버라이딩한 154점이 나왔던 거요. 다음 시간엔 그 정체인 다형성(polymorphism) 을 제대로 파헤쳐요.

특히 이런 걸 배워요.

  • Member m = new AdminMember(...) 처럼, 부모 타입 변수에 자식 객체를 담는
  • 그렇게 담아도 m.calculateRecommendScore() 를 부르면 자식 버전(154점)이 불리는 이유
  • 어떤 변수에 담긴 객체가 진짜로 관리자인지 아닌지 알아보는 instanceof
  • 오늘 Step 5에서 살짝 본 (Member) obj 같은 형 변환

오늘 본 "부모 안에서 자식이 불리는" 신기함이, 다음 시간엔 객체지향에서 가장 강력한 무기로 바뀌어요. 다음 시간에 만나요!


과제

오늘 배운 extends, super(), 오버라이딩, @Override, toString()/equals() 를 손에 익히는 과제 세 개예요. 모두 코드베이스의 Member·AdminMember 와 같은 패키지에 연습하면 돼요.

과제 1: [기본] AdminMember 에 새 행동 추가하기

오늘 관리자에게 deletePost() 라는 자기만의 행동을 붙였죠. 이번엔 관리자에게 행동 하나를 더 맡기는 과제예요.

해야 할 일:

AdminMember"회원을 정지시키는" 행동을 새 메서드로 추가하세요.

요구사항:

  • AdminMember 클래스에 String suspendMember(String targetUsername) 메서드를 추가하세요.
  • 호출되면 "[관리자] @관리자이름 가 @대상이름 을(를) 정지시켰어요." 형식의 글을 돌려주게 하세요.
  • 관리자 자기 이름은 deletePost() 가 그랬듯이 getUsername() 통로로 읽어오세요. (username 은 부모의 private 필드라 직접 못 써요.)
  • main 에서 관리자 하나를 만들고 이 메서드를 불러 결과를 출력하세요.

힌트:

  • 이건 부모에 없던 관리자만의 새 행동이에요. 그러니 @Override 를 붙이지 않아요. (덮어쓸 부모 메서드가 없으니까요.)
  • 두 개의 이름(관리자 자기 이름, 대상 이름)을 다루게 돼요. 자기 이름은 getUsername(), 대상 이름은 매개변수로 받은 값을 쓰면 돼요.

과제 2: [응용] toString() 과 equals() 직접 오버라이딩 연습

지난 시간 과제에서 만든 Post 클래스(게시물)를 기억하시죠. 이번엔 그 Post 에 오늘 배운 toString()equals() 를 직접 오버라이딩해보는 과제예요.

해야 할 일:

Post 클래스에 사람이 읽을 수 있는 자기소개와, "같은 게시물인지 알아보기" 를 붙이세요.

요구사항:

  • Post@Override public String toString() 을 추가해, 게시물을 출력하면 주소(@1b6d3586) 대신 "[게시물] 내용: ..., 좋아요: N" 형식의 글이 나오게 하세요.
  • Post@Override public boolean equals(Object obj) 를 추가해, 두 게시물의 게시물 ID(또는 작성자+내용) 가 같으면 같은 게시물로 보게 하세요.
  • Member.equals() 의 구조(맨 위 this == obj 검사, null/클래스 검사, 그다음 값 비교)를 그대로 참고하세요.
  • main 에서 내용이 똑같은 게시물 객체 두 개를 따로 만든 뒤, equals 오버라이딩 전(주소 비교)과 후(값 비교)의 결과가 어떻게 달라지는지 출력으로 확인하세요.

힌트:

  • equals 안의 (Post) obj 형 변환 한 줄은 오늘 Member.equals() 에서 본 (Member) obj 와 같은 모양이에요. 다음 시간에 자세히 배우니, 지금은 그대로 따라 써도 돼요.
  • 오버라이딩이니 두 메서드 위에 @Override 를 꼭 붙여 안전장치를 챙기세요.

과제 3: [심화] PremiumMember 직접 만들기

오프닝과 Step 7에서 예고한, 광고 없이 쓰는 프리미엄 회원 을 직접 만들어보는 과제예요. 오늘 배운 상속의 거의 모든 걸 한 번에 연습하게 돼요.

해야 할 일:

Member 를 상속하는 PremiumMember 클래스를 새로 만드세요.

요구사항:

  • PremiumMember extends Member 로 선언하세요.
  • 프리미엄만의 추가 필드를 하나 두세요. 예: private boolean adProtected; (광고 차단 여부) 또는 구독 등급을 나타내는 필드.
  • 생성자 첫 줄에서 super(username, followers, posts, mutualFriends, daysActive); 로 부모 생성자를 부르고, 그다음 줄에서 프리미엄 필드를 채우세요.
  • calculateRecommendScore() 를 오버라이딩하세요. super.calculateRecommendScore() 로 부모 점수를 가져온 뒤, 프리미엄만의 보너스(예: +30점)를 더하세요. 위에 @Override 를 붙이고요.
  • toString() 을 오버라이딩하세요. AdminMember 가 그랬듯이 super.toString() 뒤에 " [프리미엄]" 같은 표시를 붙이면 돼요.
  • main 에서 PremiumMember 하나를 만들어 점수·등급·자기소개를 출력해보세요.

힌트:

  • AdminMember 의 구조를 거의 그대로 따라가면 돼요. "관리자 보너스 50" 을 "프리미엄 보너스 30" 으로 바꾸고, "관리자 역할" 대신 "광고 차단 여부" 를 넣는 식이죠.
  • 프리미엄 필드도 private 으로 숨기고, 필요하면 getter 를 만들어두면 깔끔해요. (예: isAdProtected())
  • PremiumMember 를 만들면 전체 회원 수(totalMembers)가 어떻게 변할지도 한번 출력해서 확인해보세요. (Step 6을 떠올리면 답이 보여요.)

생각해볼 주제

오늘 배운 상속 너머의 이야기를 세 가지 던져드릴게요. 정답이 정해진 질문이 아니에요. 직접 코드를 떠올리며 본인의 답을 만들어보세요.

1. 상속과 복붙(복사), 그 경계는 어디일까?

오늘 우리는 "같은 코드를 복사하면 나중에 고칠 때 지옥이 온다" 며 상속을 배웠어요. 그래서 상속이 항상 정답처럼 느껴질 수 있어요.

하지만 상속도 늘 좋기만 한 건 아니에요. 자식이 부모에 너무 강하게 묶여 있어서, 부모를 살짝 고치면 모든 자식이 줄줄이 영향을 받기도 하거든요. 부모 코드를 한 줄 바꿨더니 멀리 있는 자식 클래스에서 엉뚱한 버그가 터지는 식으로요. "상속이 코드 재사용에 좋다" 는 장점과, "부모-자식이 강하게 묶인다" 는 단점 사이에서 언제 상속을 쓰고 언제 안 쓰는 게 좋을지 본인의 생각을 정리해보세요.

2. is-a 가 아닌데 상속하면 어떤 일이 벌어질까?

오늘 "관리자는 회원이다(is-a)" 가 말이 되니까 상속했어요. 그런데 만약 단지 "Member 에 쓸모 있는 메서드가 많으니까, 코드 재사용하려고" Post(게시물)가 Member 를 상속한다면 어떨까요?

"게시물은 회원이다" 는 전혀 말이 안 되죠. 당장은 메서드 몇 개를 공짜로 얻어서 편할지 몰라도, 게시물에 calculateRecommendScore()getFollowers() 같은 엉뚱한 메서드가 딸려 들어와요. "코드 재사용" 만을 위해 is-a 가 아닌 관계를 상속으로 묶으면 어떤 문제가 생길지, 그리고 그럴 땐 상속 대신 어떤 방법이 더 나을지 떠올려보세요.

3. equals 를 바꾸면 왜 hashCode 도 신경 써야 할까?

오늘 Member.equals() 를 "이름이 같으면 같은 사람" 으로 오버라이딩했어요. 그리고 "보통 equals 를 바꾸면 hashCode 라는 짝꿍도 함께 손본다" 고 가볍게 귀띔했죠.

겉으로 보면 둘은 따로 노는 메서드 같아요. 하나는 "같은가?" 를 묻고, 하나는 숫자를 돌려주니까요. 그런데 자바에는 "같다고 본 두 객체는 같은 hashCode 를 가져야 한다" 는 약속이 있어요. 이 약속을 어기면, 나중에 여러 데이터를 빠르게 모아 담는 도구를 쓸 때 "분명 넣었는데 못 찾는" 이상한 일이 생기기도 해요. 왜 "같은 사람" 판정과 "숫자(hashCode)" 가 짝을 이뤄야 하는지, 지금은 가벼운 호기심 수준에서 떠올려보세요. (깊은 답은 컬렉션을 배울 때 다시 만나요.)

✅ 예시 답안정답 보기

오늘 배운 extends, super(), 오버라이딩, @Override, toString()/equals() 를 손에 익히는 답안이에요. 정답이 하나뿐인 건 아니에요. 아래 코드는 "이렇게 풀면 깔끔하다" 는 모범 사례 중 하나로 봐주세요.

과제 1 예시답안: AdminMember 에 새 행동 추가하기

핵심 접근

관리자에게 "회원을 정지시키는" 새 행동을 붙이는 과제예요. 핵심은 이게 부모(Member)에 없던 새 행동 이라는 점이에요. 부모에 없으니 덮어쓸 게 없고, 그래서 @Override 를 붙이지 않아요. 이미 만들어둔 deletePost() 와 똑같은 결을 따라가면 돼요. 관리자 자기 이름은 부모의 private 필드라 직접 못 쓰니 getUsername() 통로로 읽고, 정지 대상 이름은 매개변수로 받아요.

예시 구현

// com/instagram/javabasic/domain/member/AdminMember.java
// (AdminMember 클래스 안에 메서드 하나를 더 추가)

// 관리자만의 또 다른 행동 — 부모(Member)에 없는 새 행동이라 @Override 를 붙이지 않아요.
// 여기서도 username 은 부모의 public getter(getUsername())로 읽어요.
public String suspendMember(String targetUsername) {
    return "[관리자] @" + getUsername() + " 가 @" + targetUsername + " 을(를) 정지시켰어요.";
}

main 에서는 관리자 하나를 만들어 이 메서드를 불러보면 돼요.

AdminMember admin = new AdminMember("jaehoon", 1240, 42, 5, 90, "콘텐츠 관리자");
System.out.println(admin.suspendMember("spammer123"));
// 출력: [관리자] @jaehoon 가 @spammer123 을(를) 정지시켰어요.

이름 두 개를 어떻게 나눠 다루는지 그림으로 보면 이래요.

 admin.suspendMember("spammer123")
        │                  │
   getUsername()       targetUsername
   = "jaehoon"         = "spammer123"
   (자기 이름,          (정지 대상,
    부모 getter 통로)    매개변수로 받음)
        │                  │
        └──── "[관리자] @jaehoon 가 @spammer123 을(를) 정지시켰어요." ────┘

이 동작은 코드베이스 AdminMemberTest 에서 검증되어 있어요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
@Override 안 붙임 부모에 없는 새 행동임을 이해하고 @Override 를 빼는가
반환 형식 정확성 "[관리자] @이름 가 @대상 을(를) 정지시켰어요." 형식·@ 위치가 맞는가
자기 이름 읽기 username 직접 접근 대신 getUsername() 통로를 쓰는가
대상 이름 출처 정지 대상을 매개변수로 받아 쓰는가
main 호출·출력 관리자를 만들어 실제로 불러 결과를 확인하는가

흔한 실수

  • @Override 를 습관적으로 붙임 → 부모 MembersuspendMember 가 없으니 컴파일 에러가 나요. @Override 는 "부모 걸 덮어쓴다" 는 뜻이라, 새 행동엔 붙이지 않아요. 에러 메시지(method does not override...)가 친절히 알려주니 당황 말고 읽어보세요.
  • this.username 으로 자기 이름 접근username 은 부모의 private 필드라 자식에서 직접 못 봐요. getUsername() 통로로 읽어야 해요.
  • @ 를 빠뜨림 → 요구한 형식은 @jaehoon 처럼 골뱅이가 붙어요. 문자열 조합할 때 "@" + 를 잊지 않게 조심해요.

실무 개선 포인트 (심화)

지금은 메서드가 그냥 문장(String)을 돌려주지만, 실제 서비스라면 "정지" 가 진짜 데이터에 반영돼야 해요. 예를 들어 대상 회원에게 정지됨 같은 상태 값을 두고 그걸 바꾸는 식이죠. 또 하나, 아무 이름이나 넘기면 정지가 되는 건 위험해요. 빈 문자열이나 null 이 들어왔을 때 막아주는 검사를 앞에 한 줄 넣어두면 더 안전한 코드가 돼요. (이런 "값이 들어올 때 검사하기" 는 지난 시간 setFollowers 의 음수 검사에서 이미 맛본 결이에요.)


과제 2 예시답안: toString() 과 equals() 직접 오버라이딩 연습

핵심 접근

지난 시간에 만든 Post 에 두 가지를 붙여요. 하나는 사람이 읽을 수 있는 자기소개(toString), 다른 하나는 "같은 게시물인지 알아보기"(equals)예요. 둘 다 보이지 않는 부모 Object 가 물려준 메서드를 다시 정의(오버라이딩) 하는 거라 @Override 를 붙여요. equals 의 뼈대는 오늘 본 Member.equals() 구조를 그대로 따라가면 돼요. 맨 위에서 자기 자신인지 보고, null·클래스가 다른지 거른 뒤, 값을 비교하는 순서요.

예시 구현

// com/instagram/javabasic/domain/post/Post.java
// (Post 클래스 안에 두 메서드를 추가)

// toString — 게시물을 그대로 출력하면 주소가 찍혀요.
// 이렇게 새로 정의하면 우리가 원하는 글로 바뀌어요.
@Override
public String toString() {
    return "[게시물] 내용: " + content + ", 좋아요: " + likeCount;
}

// equals — 작성자와 내용이 모두 같으면 "같은 게시물" 로 봐요.
// 기본 동작은 메모리 주소 비교라서, 값으로 같은지 보려면 이렇게 새로 정의해요.
@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (obj == null || getClass() != obj.getClass()) return false;
    Post other = (Post) obj;
    return authorName != null && authorName.equals(other.authorName)
            && content != null && content.equals(other.content);
}

equals 안의 네 줄이 무슨 일을 하는지 한 줄씩 보면 이래요.

 if (this == obj) return true;          ← 1) 똑같은 그 객체면 당연히 true
 if (obj == null || ...class다름) false; ← 2) 비어있거나 종류가 다르면 false
 Post other = (Post) obj;               ← 3) Post 로 바꿔 꺼냄 (형 변환 — 다음 시간)
 작성자 같음 && 내용 같음 → 결과 반환     ← 4) 진짜 값을 비교

main 에서 오버라이딩 전과 후의 차이를 확인해볼게요.

Post p1 = new Post("오늘 날씨 좋다", "jaehoon", 10);
Post p2 = new Post("오늘 날씨 좋다", "jaehoon", 10);

// == 는 항상 "같은 객체(주소)인가" 만 봐요 → 따로 만들었으니 false
System.out.println(p1 == p2);        // false

// equals 오버라이딩 후 → 작성자+내용이 같으니 true
System.out.println(p1.equals(p2));   // true

// toString 오버라이딩 후 → 주소 대신 사람이 읽는 글
System.out.println(p1);              // [게시물] 내용: 오늘 날씨 좋다, 좋아요: 10

오버라이딩 전이라면 p1.equals(p2) 도 주소를 비교해서 false 가 나와요. 새로 정의했더니 "내용이 같으면 같은 게시물" 로 바뀐 거죠. 이 동작은 코드베이스 PostTest 에서 검증되어 있어요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
equals 4단 구조 this==objnull/getClass → 형 변환 → 값 비교 순서를 지키는가
값 비교 기준 작성자+내용(또는 게시물 ID) 으로 "같음" 을 판정하는가
@Override 부착 두 메서드 위에 @Override 를 붙여 안전장치를 챙기는가
toString 형식 "[게시물] 내용: ..., 좋아요: N" 형식이 맞는가
전/후 차이 확인 같은 내용 게시물 2개로 주소 비교 false → 값 비교 true 를 출력으로 보이는가
null 안전 비교 문자열 비교 전 null 검사를 넣는가 (선택, 있으면 가점)

흔한 실수

  • equals(Post obj) 로 매개변수 타입을 좁힘 → 부모 Objectequals 는 매개변수가 Object 예요. 타입을 Post 로 바꾸면 오버라이딩이 아니라 전혀 다른 메서드가 돼버려요. @Override 가 이 실수를 빨간 줄로 잡아줘요.
  • == 로 문자열 내용 비교content == other.content 는 주소 비교라 내용이 같아도 false 가 날 수 있어요. 문자열 값 비교는 .equals() 를 써야 해요.
  • this == obj 검사를 빼먹음 → 없어도 동작은 하지만, 같은 객체를 비교할 때 굳이 값까지 다 비교하게 돼요. 맨 위 한 줄로 빠르게 끝내는 게 관례예요.
  • toString 에 좋아요 수를 빠뜨림 → 요구 형식엔 , 좋아요: N 이 들어가요. 출력을 직접 눈으로 확인하면 빠진 걸 바로 알 수 있어요.

실무 개선 포인트 (심화)

진짜 서비스에서는 게시물마다 고유 번호(ID) 가 있어요. 그러면 작성자+내용 대신 그 ID 하나만 비교하는 게 더 정확하고 빨라요. 작성자와 내용이 똑같은 게시물을 두 번 올릴 수도 있으니까요. 그리고 오늘 교안에서 "equals 를 바꾸면 hashCode 도 함께 손본다" 고 살짝 귀띔했죠. 실무 코드라면 equals 옆에 hashCode 도 짝으로 만들어둬요. 왜 그래야 하는지는 아래 생각해볼 주제 3에서 더 풀어볼게요.


과제 3 예시답안: PremiumMember 직접 만들기

핵심 접근

오늘 배운 상속의 거의 전부를 한 번에 연습하는 과제예요. AdminMember 의 구조를 그대로 따라가되, "관리자 보너스 50" 을 "프리미엄 보너스 30" 으로, "역할" 을 "광고 차단 여부" 로 바꾸는 거예요. 핵심은 네 가지예요. (1) extends Member 로 부모를 물려받고, (2) 생성자 첫 줄에서 super(...) 로 부모 필드를 먼저 채우고, (3) calculateRecommendScore() 를 오버라이딩해 super 점수에 +30, (4) toString()super 결과 뒤로 " [프리미엄]" 을 붙여요.

예시 구현

// com/instagram/javabasic/domain/member/PremiumMember.java
// 프리미엄 회원 — 일반 회원(Member)이 가진 모든 것을 그대로 물려받고(extends),
// 거기에 프리미엄만의 정보(광고 차단 여부)와 혜택(추천 점수 보너스)을 더해요.
public class PremiumMember extends Member {

    // 프리미엄만 추가로 갖는 정보 — 광고를 차단할지 여부
    private boolean adProtected;

    // 생성자 — 첫 줄 super(...) 로 부모(Member)의 다섯 필드를 먼저 채우고,
    // 그 다음 프리미엄만의 필드를 채워요.
    public PremiumMember(String username, int followers, int posts, int mutualFriends, int daysActive, boolean adProtected) {
        super(username, followers, posts, mutualFriends, daysActive);
        this.adProtected = adProtected;
    }

    public boolean isAdProtected() {
        return adProtected;
    }

    // 오버라이딩 — 부모의 점수 계산을 super 로 그대로 쓰고, 프리미엄 보너스 30점을 더해요.
    @Override
    public int calculateRecommendScore() {
        return super.calculateRecommendScore() + 30;
    }

    // 오버라이딩 — 부모의 toString 결과를 super 로 가져와 뒤에 프리미엄 표시를 붙여요.
    @Override
    public String toString() {
        return super.toString() + " [프리미엄]";
    }
}

main 에서 jaehoon 스탯으로 만들어보면 점수가 어떻게 오르는지 보여요.

PremiumMember premium = new PremiumMember("jaehoon", 1240, 42, 5, 90, true);

System.out.println(premium.calculateRecommendScore()); // 134 (부모 104 + 보너스 30)
System.out.println(premium.isAdProtected());           // true
System.out.println(premium);                            // ...점수 134점) [프리미엄]
System.out.println(Member.getTotalMembers());           // 프리미엄도 회원이라 수가 늘어남

점수가 어떻게 134가 되는지 계산을 따라가면 이래요.

 부모 calculateRecommendScore() (jaehoon 스탯)
   팔로워 1240/100 =  12
   게시물   42/5   =   8
   친구    5 * 10  =  50
   활동   90/30   =   3
                  ───────
   부모 점수        =  73 ... (스탯에 따라 달라짐)
                       │
   super.calculate...() = 부모 점수
        + 30 (프리미엄 보너스)
                       │
                  최종 점수 = 부모 점수 + 30

여기서 중요한 건 super.calculateRecommendScore() 로 부모 계산을 그대로 재사용 한다는 점이에요. 같은 식을 복사하지 않고 부모 걸 빌려 쓴 뒤 +30만 얹은 거죠. 그리고 PremiumMember 를 만들 때도 super(...) 가 부모 생성자를 부르고, 그 안에서 totalMembers++ 가 돌아요. 그래서 프리미엄을 만들어도 전체 회원 수가 자연스럽게 늘어나요. (Step 6에서 본 "하나뿐인 static 칸을 부모·자식이 함께 쓴다" 가 여기서 회수돼요.) 이 동작은 코드베이스 PremiumMemberTest 에서 검증되어 있어요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
super(...) 첫 줄 호출 생성자 맨 첫 줄에서 부모 생성자를 부르는가 (순서가 핵심)
점수 오버라이딩 super.calculateRecommendScore() + 30 으로 부모 계산을 재사용하는가
@Override 부착 오버라이딩 두 메서드에 @Override 를 붙이는가
toString 합성 super.toString() + " [프리미엄]" 으로 부모 글에 표시를 덧붙이는가
필드 캡슐화 adProtectedprivate 으로 숨기고 isAdProtected() getter 를 두는가
totalMembers 확인 프리미엄 생성 후 전체 회원 수가 늘어남을 확인하는가

흔한 실수

  • super(...) 를 생성자 첫 줄이 아닌 곳에 둠super(...) 는 반드시 자식 생성자의 맨 첫 줄 이어야 해요. 다른 코드 뒤에 두면 컴파일 에러가 나요. "1층(부모)부터 짓고 2층(자식)을 올린다" 는 순서를 떠올리면 헷갈리지 않아요.
  • 점수 식을 통째로 복사super.calculateRecommendScore() 를 안 쓰고 팔로워·게시물 계산을 다시 적으면, 나중에 부모 점수 규칙이 바뀔 때 프리미엄만 안 따라가요. 부모 걸 빌려 쓰고 +30만 얹는 게 상속의 핵심이에요.
  • toString 에서 super.toString() 을 안 부름super 없이 직접 다 적으면 부모가 만든 자기소개를 통째로 베끼는 셈이에요. super.toString() 뒤에 " [프리미엄]" 만 붙이는 게 깔끔해요.
  • 보너스를 곱셈으로 착각 → 요구는 +30(더하기) 이지 ×30이 아니에요. 출력 점수가 이상하게 크면 이 자리부터 확인해보세요.

실무 개선 포인트 (심화)

지금은 프리미엄 회원만 보너스 +30을 받는데, 실제 서비스라면 "실버·골드·다이아" 처럼 등급마다 보너스가 다를 수 있어요. 그럴 땐 보너스 값을 필드로 두고 등급에 따라 다르게 채우는 식으로 늘려갈 수 있어요. 또 AdminMember(+50)와 PremiumMember(+30)가 둘 다 super.calculateRecommendScore() 에 보너스만 다르게 얹는 똑 닮은 모양이죠. 이런 "같은 뼈대, 다른 숫자" 가 여러 개 보이기 시작하면, 다음 시간에 배울 다형성 으로 더 우아하게 묶을 수 있어요. 오늘은 "이렇게 갈래가 늘어나는구나" 까지만 느껴두면 충분해요.


생각해볼 주제 예시답안

생각해볼 주제 1 예시답안: 상속과 복붙(복사), 그 경계는 어디일까?

[문제 상황 요약]

오늘 "같은 코드를 복사하면 나중에 고칠 때 지옥이 온다" 며 상속을 배웠어요. 그래서 상속이 늘 정답처럼 느껴질 수 있어요. 하지만 상속도 공짜는 아니에요. 자식이 부모에 강하게 묶여서, 부모를 살짝 고치면 멀리 있는 자식들이 줄줄이 영향을 받기도 해요. 그럼 언제 상속을 쓰고, 언제 안 쓰는 게 좋을까요?

[튜터의 가이드 및 해설]

먼저 복붙(복사)이 왜 위험한지 떠올려볼게요. AdminMemberPremiumMember 가 점수 계산식을 각자 통째로 복사해뒀다고 해봐요. 나중에 "팔로워 점수를 100명당 1점에서 50명당 1점으로 바꾸자" 가 되면, 복사해둔 모든 곳을 일일이 찾아 고쳐야 해요. 하나라도 빠뜨리면 회원 종류마다 점수가 제멋대로가 되죠. 이게 복붙의 지옥이에요.

상속은 이걸 깔끔하게 풀어요. 부모 Member 의 계산식 한 곳만 고치면 모든 자식이 자동으로 따라와요. super.calculateRecommendScore() 로 부모 걸 빌려 쓰고 있으니까요. 이게 상속의 가장 큰 장점, 재사용 이에요.

그런데 바로 그 "자동으로 따라온다" 가 양날의 검이에요. 부모를 고치면 모든 자식이 영향을 받는다는 건, 반대로 부모를 함부로 못 고친다 는 뜻이기도 해요. 자식이 10개쯤 붙어 있으면, 부모 한 줄 바꾸는 게 무서워져요. 어떤 자식에서 엉뚱한 버그가 터질지 모르니까요. 이걸 "부모-자식이 강하게 묶였다(강결합)" 고 표현해요.

그래서 판단 기준을 이렇게 정리해볼 수 있어요.

  • Option A — 상속을 쓴다: "A는 B다(is-a)" 가 자연스럽고, 자식들이 부모의 행동 대부분을 진짜로 공유할 때. 예: "관리자도 회원이다", "프리미엄도 회원이다". 장점은 재사용·자동 반영, 단점은 강결합.
  • Option B — 그냥 복사하거나 따로 둔다: 두 코드가 우연히 비슷할 뿐 본질이 다를 때. 예를 들어 "게시물" 과 "회원" 은 둘 다 이름 비슷한 필드가 있어도 전혀 다른 존재죠. 억지로 부모로 묶으면 나중에 더 꼬여요.

현업에서는 보통 "is-a 관계가 진짜로 성립하는지" 를 먼저 묻고, 성립할 때만 상속을 써요. 단지 "코드 몇 줄 아끼려고" 묶는 건 피해요. 그리고 자식이 너무 많아져 부모를 못 고칠 지경이 되면, 상속을 풀고 다른 방식(다음 주제에서 나올 합성)으로 갈아타기도 해요.

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

"상속의 장점인 '재사용' 과 단점인 '강결합' 은 사실 같은 동전의 양면입니다. 부모를 고치면 모든 자식이 자동으로 따라오는 게 장점이자, 동시에 부모를 함부로 못 고치게 만드는 족쇄죠. 그래서 저는 'is-a 관계가 자연스러운가' 를 먼저 묻고, 단지 코드 재사용 목적이라면 상속 대신 다른 방법을 먼저 고려합니다."


생각해볼 주제 2 예시답안: is-a 가 아닌데 상속하면 어떤 일이 벌어질까?

[문제 상황 요약]

오늘 "관리자는 회원이다(is-a)" 가 말이 되니까 상속했어요. 그런데 만약 단지 "Member 에 쓸모 있는 메서드가 많으니까, 코드 재사용하려고" Post(게시물)가 Member 를 상속한다면 어떨까요? "게시물은 회원이다" 는 전혀 말이 안 되죠. 이렇게 is-a 가 아닌데 상속하면 무슨 일이 벌어질까요?

[튜터의 가이드 및 해설]

Post extends Member 를 해버리면, 게시물이 Member 의 모든 행동을 통째로 물려받아요. 그럼 이런 황당한 코드가 컴파일도 되고 호출도 돼요.

 Post post = new Post(...);
 post.calculateRecommendScore();  // 게시물의 추천 점수??
 post.getFollowers();             // 게시물의 팔로워 수??
 Member.getTotalMembers();        // 게시물을 만들었더니 "회원 수" 가 늘어남??

게시물에 "팔로워 수" 나 "추천 점수" 같은 엉뚱한 메서드가 딸려 들어와요. 당장은 공짜로 메서드를 얻어서 편할지 몰라도, 이 코드를 처음 보는 사람은 "게시물에 왜 팔로워가 있지?" 하고 멈칫해요. 더 나쁜 건, 게시물을 만들 때마다 totalMembers 가 늘어나서 회원 수 통계가 엉망이 된다는 거예요.

핵심은 이거예요. 상속은 "코드를 빌려오는 도구" 가 아니라 "종류를 표현하는 도구" 예요. "A는 B의 한 종류다" 가 말이 될 때만 써야 해요. "관리자는 회원의 한 종류다" 는 자연스럽지만, "게시물은 회원의 한 종류다" 는 말이 안 되죠.

그럼 "게시물과 회원이 관계가 있긴 한데" 싶을 때는 어떻게 할까요? 게시물엔 분명 작성자(회원)가 있잖아요. 이건 "게시물은 회원이다(is-a)" 가 아니라 "게시물은 작성자(회원)를 가진다(has-a)" 예요. 이럴 땐 상속 대신 합성(조합) 을 써요.

  • Option A — 상속 (is-a): Post extends Member. "게시물은 회원이다" → 말이 안 됨. 엉뚱한 메서드가 딸려옴.
  • Option B — 합성 (has-a): 게시물 안에 작성자(Member)를 필드로 가진다. "게시물은 작성자를 가진다" → 자연스러움. 필요한 정보만 깔끔하게 꺼내 씀.
 [상속 - is-a]              [합성 - has-a]
 Post                       Post
   extends Member             ├ String content
   → 팔로워·추천점수            ├ int likeCount
     전부 딸려옴 (엉뚱!)        └ Member author  ← 작성자를 "가진다"
                                  필요하면 author.getUsername() 만 꺼냄

현업에서는 보통 "A는 B다" 면 상속, "A는 B를 가진다" 면 합성 으로 갈라요. 합성은 필요한 것만 골라 쓸 수 있어서 강결합도 덜하고, 엉뚱한 메서드가 딸려올 일도 없어요. 그래서 "고민되면 상속보다 합성을 먼저 생각하라" 는 말이 자주 나와요. (합성은 다음 단계 학습에서 더 자세히 만나요. 지금은 "is-a 가 아니면 상속하지 말자" 까지만 챙겨도 충분해요.)

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

"상속은 '코드를 빌려오는 도구' 가 아니라 '종류를 표현하는 도구' 입니다. 'A는 B다(is-a)' 가 성립할 때만 써야죠. 단지 메서드 재사용이 목적인데 is-a 가 아니라면, 엉뚱한 메서드가 딸려와 코드가 거짓말을 하게 됩니다. 게시물과 작성자처럼 'A는 B를 가진다(has-a)' 관계라면 상속이 아니라 합성으로 풀어야 합니다."


생각해볼 주제 3 예시답안: equals 를 바꾸면 왜 hashCode 도 신경 써야 할까?

[문제 상황 요약]

오늘 Member.equals() 를 "이름이 같으면 같은 사람" 으로 오버라이딩했어요. 그리고 "보통 equals 를 바꾸면 hashCode 라는 짝꿍도 함께 손본다" 고 가볍게 귀띔했죠. 겉으로 보면 둘은 따로 노는 메서드 같아요. 하나는 "같은가?" 를 묻고, 하나는 숫자를 돌려주니까요. 그런데 왜 이 둘이 짝을 이뤄야 할까요?

[튜터의 가이드 및 해설]

먼저 hashCode 가 뭔지 비유로 잡아볼게요. 큰 도서관에서 책을 찾는다고 해봐요. 책마다 일일이 제목을 다 비교하면 너무 느려요. 그래서 책을 "분류 번호" 로 먼저 나눠서 해당 칸만 뒤져요. hashCode 가 바로 이 분류 번호 같은 거예요. 객체를 어느 칸에 넣을지 빠르게 정해주는 숫자죠.

자바에는 이런 약속이 있어요. "equals 로 같다고 본 두 객체는, 반드시 같은 hashCode(분류 번호) 를 가져야 한다." 왜 이런 약속이 필요할까요?

여러 데이터를 빠르게 모아 담는 도구(나중에 배울 컬렉션)는 이렇게 동작해요. (1) 먼저 hashCode 로 "몇 번 칸에 넣을지" 정하고, (2) 그 칸 안에서만 equals 로 진짜 같은 걸 찾아요. 분류 번호로 칸을 좁힌 뒤, 그 칸 안에서만 정밀 비교하는 거죠. 그래야 빠르니까요.

여기서 약속을 어기면 무슨 일이 생기는지 그림으로 볼게요.

 jaehoon 객체 A 를 0번 칸에 넣음 (hashCode=0)
 나중에 jaehoon 객체 B 로 찾으려는데...

 [equals 만 바꾸고 hashCode 는 그대로 둔 경우]
   A 의 hashCode = 17  (주소 기반, 제각각)
   B 의 hashCode = 92  (주소 기반, 제각각)
        │
   서로 다른 칸을 뒤짐 → equals 비교까지 가지도 못함
        │
   "분명 넣었는데 못 찾음!" 😱

 [equals 와 hashCode 를 짝으로 바꾼 경우]
   A 의 hashCode = 이름("jaehoon") 기반 = 555
   B 의 hashCode = 이름("jaehoon") 기반 = 555  ← 같은 칸!
        │
   같은 칸 안에서 equals 비교 → "같은 사람!" → 찾음 ✅

equals 만 "이름이 같으면 같다" 로 바꾸고 hashCode 는 손대지 않으면, 이름이 같은 두 객체가 서로 다른 칸에 들어가요. 그래서 분명히 넣었는데도 못 찾는 황당한 일이 생겨요. 분류 번호가 달라서 아예 다른 칸을 뒤지니까요.

지금 단계에서는 이렇게만 기억하면 충분해요. "equals 를 값 기준으로 바꿨다면, hashCode 도 같은 값 기준으로 함께 바꿔준다." 깊은 동작 원리와 실제 코드는 데이터를 모아 담는 도구(컬렉션) 를 배울 때 다시 제대로 만나요. 오늘은 "둘은 짝꿍이다" 라는 호기심만 챙겨두세요.

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

"equals 와 hashCode 는 짝꿍입니다. 자바에는 '같다고 본 객체는 같은 hashCode 를 가져야 한다' 는 약속이 있고, 컬렉션은 먼저 hashCode 로 저장 칸을 정한 뒤 그 칸 안에서만 equals 로 비교하기 때문이죠. equals 만 값 기준으로 바꾸고 hashCode 를 그대로 두면, 같은 값의 객체가 서로 다른 칸에 들어가 '분명 넣었는데 못 찾는' 버그가 생깁니다. 그래서 둘은 항상 같은 기준으로 함께 재정의합니다."

더 배우려면

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

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