Day12: 추상 클래스 — 직접 만들 수 없는 '뼈대 부모'
안녕하세요! 여러분의 Java 가이드, 홍순구 튜터입니다.
Day 12에 오신 걸 환영합니다! 지난 시간 마지막에 제가 떡밥 하나를 슬쩍 흘려뒀어요. 기억나시나요? "도형" 이라는 부모 이야기였죠.
우리는 지금까지 new Member(...) 로 일반 회원을 아무 문제 없이 직접 만들 수 있었어요.
그런데 제가 이런 질문을 던졌었죠. "만약 이 부모가, 그 자체로는 불완전한 뼈대라서 직접 만들면 안 되고 반드시 자식 중 하나로만 써야 한다면 어떨까요?"
예로 든 게 "도형" 이었어요. "도형 하나 만들어줘" 라는 말은 너무 막연하잖아요. 넓이를 구하려 해도 그게 원인지 사각형인지 모르면 계산을 못 하니까요.
그래서 "도형" 은 직접 만들지 못하게 막아두고, 반드시 "원" 이나 "사각형" 같은 구체적인 자식으로만 쓰게 하고 싶을 수 있다고 했어요.
오늘은 그 이야기를 시원하게 풀어드릴게요.
"직접 만들 수 없고, 뼈대 역할만 하는 부모" 를 만드는 방법이 바로 오늘의 주제, 추상 클래스(abstract class) 입니다.
abstract 라는 키워드 하나로 "이 클래스는 직접 객체로 만들지 마. 반드시 자식으로 상속해서 써" 라고 못 박아두는 거예요.
조금 더 와닿게 일상으로 가볼게요. "악기" 라는 단어를 생각해봐요. "악기 하나 가져와봐" 라고 하면 뭘 가져와야 할까요? 기타? 피아노? 드럼? "악기" 는 공통된 개념일 뿐, 그 자체로 연주할 수 있는 물건이 아니에요. 우리가 실제로 손에 쥐고 소리를 내는 건 언제나 기타, 피아노 같은 구체적인 악기죠. "동물" 도 마찬가지예요. 동물원에 "동물 한 마리" 가 그냥 있을 순 없어요. 사자거나, 펭귄이거나, 코끼리거나 — 반드시 어떤 구체적인 종류로 존재하죠. 이렇게 "공통 개념이긴 한데, 그 자체로는 실체가 될 수 없는 것" 을 코드로 표현하는 도구가 추상 클래스예요.
그러면 추상 클래스는 일반 클래스랑 뭐가 다를까요? 핵심은 두 가지예요.
┌─────────────────────────────────┐
│ abstract class 도형 (뼈대) │ ← new 도형(...) ❌ 직접 못 만들어요
│ ─────────────────────────── │
│ 공통으로 가진 것: 색깔 │ ← 부모가 미리 채워둔 부분
│ 빈칸(자식이 채울 것): 넓이() │ ← 본문 없이 "있어야 한다" 고만 선언
└─────────────────────────────────┘
▲ ▲
extends │ │ extends
┌─────────┴────────┐ ┌──────┴───────────┐
│ 원 (자식) │ │ 사각형 (자식) │
│ 넓이() = πr² │ │ 넓이() = 가로×세로 │ ← 빈칸을 각자 채워요
└──────────────────┘ └───────────────────┘
new 원(...) ✅ new 사각형(...) ✅
하나는 방금 본 것처럼 직접 만들 수 없다는 것. new 도형(...) 을 하려 들면 자바가 컴파일 단계에서 막아줘요.
다른 하나는 부모가 "이런 메서드는 반드시 있어야 해" 라고 빈칸만 선언해두고, 그 빈칸의 실제 내용은 자식에게 강제로 떠넘긴다는 것. 이 빈칸을 abstract 메서드(추상 메서드) 라고 불러요.
원의 넓이 공식과 사각형의 넓이 공식은 완전히 다르잖아요. 그러니 부모가 미리 정해줄 수가 없어요. "넓이를 구하는 메서드는 무조건 있어야 한다" 는 약속만 부모가 걸어두고, 진짜 공식은 자식이 채우는 거죠.
오늘 우리가 갈 길을 미리 그려볼게요.
- 먼저
abstract키워드로 "직접 만들 수 없는 클래스" 를 선언하고, 진짜로new가 막히는 걸 눈으로 확인해요. - 그다음 abstract 메서드, 즉 본문이 없는 "빈칸" 메서드가 어떻게 자식에게 구현을 강제하는지 봐요. (안 채우면 컴파일 에러가 나요. 이 강제력이 핵심이에요.)
- 이어서 "뼈대는 부모가 정하고, 빈칸은 자식이 채운다" 는 아이디어를 한 단계 더 밀어붙인 템플릿 메서드 라는 깔끔한 설계를 맛봐요. 부모가 전체 틀을 짜두고 그 안의 일부만 자식이 채우게 하는 방식이에요.
- 마지막으로 "그래서 언제 추상 클래스를 쓰고, 언제 그냥 일반 클래스를 쓰면 되는가?" — 추상 클래스 vs 일반 클래스 판단 기준을 정리하며 마무리해요.
오늘의 실습 도메인도 우리에게 익숙한 인스타그램이에요. 바로 콘텐츠(Content) 입니다. 인스타 피드를 떠올려보세요. 거기 올라오는 게시물에는 여러 종류가 있죠. 사진 한 장(이미지), 짧은 동영상(영상), 글만 있는 게시물(텍스트). 이 셋은 전부 "콘텐츠" 라는 공통점이 있어요. 작성자가 있고, 좋아요 수가 있죠.
┌──────────────────────────┐
│ abstract Content (뼈대) │ ← new Content(...) ❌
│ ────────────────────── │
│ 공통: 작성자, 좋아요 수 │
│ 빈칸: getType(), preview()│ ← 미리보기 모양은 종류마다 달라요
└──────────────────────────┘
▲ ▲ ▲
┌────────┴──┐ ┌──────┴─────┐ ┌────┴─────────┐
│ImageContent│ │VideoContent│ │ TextContent │
│"📷 사진 N장"│ │"▶ 0:45 영상"│ │ "글 앞부분만…" │
└───────────┘ └────────────┘ └──────────────┘
그런데 피드에 보일 미리보기 모양은 종류마다 완전히 달라요. 이미지는 "사진 3장" 처럼, 영상은 "0:45 영상" 처럼, 텍스트는 글의 앞부분을 잘라서 보여주죠.
바로 이 지점이 "도형" 비유와 똑같아요. "콘텐츠 자체" 만 따로 떼어 만들면 그게 무슨 종류인지 알 수 없으니 미리보기를 어떻게 그릴지 정할 수가 없어요.
그래서 Content 는 추상 클래스로 두고 직접 못 만들게 막은 다음, 미리보기는 "빈칸" 으로 비워서 각 자식(이미지·영상·텍스트)이 자기 방식대로 채우게 만들 거예요. 오늘 이 구조를 처음부터 끝까지 직접 손으로 지어봅니다.
자, 그럼 "직접 만들 수 없는 부모" 라는 게 진짜로 가능한지부터 눈으로 확인하러 가볼까요?
🎯 학습 목표
- 추상 클래스(abstract class) 가 무엇이고, 왜 "직접 만들 수 없는 부모" 가 필요한지 설명할 수 있다
abstract키워드로 클래스를 선언하면new로 직접 객체를 만들 수 없다는 것을 이해하고 확인한다- abstract 메서드(추상 메서드) 가 본문 없는 "빈칸" 이라는 것, 그리고 자식이 반드시 채워야 한다는 강제력을 이해한다
- 부모가 전체 틀을 정하고 빈칸만 자식이 채우는 템플릿 메서드 방식의 장점을 체감한다
- 어떤 상황에서 추상 클래스를 쓰고, 어떤 상황에서 일반 클래스로 충분한지 판단 기준을 세울 수 있다
- 인스타그램 콘텐츠(Content) 도메인을 추상 클래스 + 자식 클래스 구조로 직접 설계한다
Step 1. abstract 키워드 — "직접 만들 수 없는 클래스" 선언하기
오프닝에서 콘텐츠(Content) 이야기를 꺼냈죠. 인스타 피드에 올라오는 게시물에는 사진·영상·텍스트 세 종류가 있는데, 셋 다 "콘텐츠" 라는 공통점이 있다고요. 작성자도 있고, 좋아요 수도 있고요.
그런데 한번 상상해볼게요. 만약 우리가 "콘텐츠" 라는 걸 종류 구분 없이 그냥 하나 만든다면 어떻게 될까요? 피드에 미리보기를 그려야 하는데, 이게 사진인지 영상인지 글인지를 모르잖아요. 사진이면 "사진 3장" 처럼, 영상이면 "0:45 영상" 처럼 보여줘야 하는데, 종류를 모르면 무엇을 그려야 할지 정할 수가 없어요.
그래서 우리는 이런 결정을 내릴 거예요. "콘텐츠 그 자체" 는 직접 만들지 못하게 막아두자. 반드시 사진·영상·텍스트 같은 구체적인 자식으로만 만들게 하자. 이게 오늘 Step 1의 목표예요. 진짜로 직접 만드는 게 막히는지 눈으로 확인하는 것까지 해봅니다.
클래스 선언 앞에 abstract 한 단어를 붙여요
방법은 의외로 간단해요. 클래스를 선언할 때 class 앞에 abstract 라는 단어 하나만 붙이면 됩니다. abstract 는 우리말로 "추상적인", 쉽게 말해 "아직 구체적이지 않은" 이라는 뜻이에요.
코드베이스의 Content 클래스를 함께 볼게요. 전체를 다 보지 말고, 우선 클래스 선언과 공통 필드, 생성자까지만 발췌해서 봅니다.
// com/instagram/javabasic/domain/content/Content.java
public abstract class Content {
// 모든 콘텐츠가 공통으로 갖는 정보 — 자식이 super(...) 로 채워요.
private String authorName; // 작성자 이름
private int likeCount; // 좋아요 수
// 생성자 — abstract 클래스도 생성자를 가져요. 직접 new 는 못 하지만,
// 자식이 super(...) 로 이 생성자를 불러 공통 필드를 채우는 통로예요.
public Content(String authorName, int likeCount) {
this.authorName = authorName;
this.likeCount = likeCount;
}
// ... (자식이 채워야 할 빈칸 메서드와 공통 메서드는 다음 Step에서 본격적으로 다뤄요)
}
맨 윗줄 public abstract class Content 에서 abstract 한 단어가 오늘의 주인공이에요. 이 단어가 붙은 순간, 자바는 "이 클래스는 직접 객체로 만들면 안 된다" 고 딱 정해둬요.
나머지는 지난 시간까지 배운 것과 똑같아요. authorName 과 likeCount 는 모든 콘텐츠가 공통으로 갖는 정보라서 private 으로 숨겨뒀고요. 생성자도 우리가 늘 쓰던 그 모양 그대로예요. "어? 직접 못 만든다면서 생성자는 왜 있어요?" 하는 생각이 들 수 있는데, 이건 자식이 super(...) 로 불러서 공통 필드를 채우는 통로로 쓰여요. 이 이야기는 다음 Step에서 자세히 풀게요.
참고로 클래스 안쪽에는 자식이 반드시 채워야 할 "빈칸" 메서드도 들어 있는데, 그건 Step 2에서 본격적으로 다룰 거라 여기선 잠깐 미뤄둘게요. 지금은 "abstract 를 붙이면 직접 못 만든다" 한 가지에만 집중해요.
진짜로 new 가 막히는지 눈으로 확인해요
말로만 "직접 못 만든다" 고 하면 와닿지 않죠. 직접 해보면서 자바가 막아주는 걸 눈으로 봐야 진짜 믿음이 가요. 데모를 실행하는 ContentDemoMain 의 main 안을 볼게요.
// com/instagram/javabasic/domain/content/ContentDemoMain.java
public static void main(String[] args) {
// Content 는 abstract 라 직접 만들 수 없어요. 아래 줄은 컴파일 에러가 나요.
// Content c = new Content("minji", 10); // 'Content' is abstract; cannot be instantiated
// 대신 자식들을 만들어 부모 타입 배열에 담아요 (이 부분은 뒤 Step에서 다뤄요)
// ...
}
가운데 줄을 보세요.
// Content c = new Content("minji", 10);
지금은 맨 앞에 // 가 붙어 주석 처리돼 있어요. 한번 직접 이 줄의 // 를 떼고 살아 있는 코드로 만들어보세요. 그러면 IntelliJ가 곧바로 그 줄에 빨간 밑줄을 그어줄 거예요. 마우스를 올려보면 이런 메시지가 떠요.
'Content' is abstract; cannot be instantiated
우리말로 옮기면 "Content 는 추상 클래스라서 인스턴스(객체)를 만들 수 없습니다" 라는 뜻이에요. instantiate 가 "객체를 만들다" 라는 뜻이거든요.
에러를 보면 가슴이 철렁할 수 있는데, 사실 이건 자바가 우리를 도와주는 거예요. 우리가 "콘텐츠는 종류 없이 못 만들게 하고 싶다" 고 abstract 로 선언해뒀잖아요. 그 약속을 자바가 대신 지켜주는 거죠. 만약 자바가 안 막아줬다면, 종류를 알 수 없는 정체불명의 콘텐츠 객체가 만들어져서 미리보기를 그릴 때 골치가 아팠을 거예요.
⚠️ 에러 메시지는 적이 아니라 안내문이에요. 빨간 줄이 떴을 때 당황하지 말고 메시지를 천천히 읽어보세요. "is abstract; cannot be instantiated" 처럼, 무엇이 왜 안 되는지를 영어로 또박또박 알려줘요. 메시지를 읽는 습관만 들이면 에러가 무섭지 않아요.
🙋 학생 질문 — "튜터님, 직접 만들지도 못하는 클래스를 왜 만들어요? 쓸모가 없는 거 아닌가요?"
아주 좋은 질문이에요! 직접 못 만든다니까 처음엔 쓸모없어 보일 수 있죠.
핵심은 Content 가 혼자 쓰이려고 있는 게 아니라는 거예요. Content 는 사진·영상·텍스트 같은 자식들이 공통으로 물려받을 "뼈대 부모" 역할이에요. 작성자·좋아요처럼 모든 콘텐츠가 똑같이 가질 부분을 한 군데에 모아두고, 자식들은 그걸 그대로 물려받아 쓰는 거죠.
오프닝의 "악기" 비유를 떠올려보세요. "악기" 라는 물건을 직접 손에 쥐고 연주할 순 없어요. 우리가 실제로 연주하는 건 늘 기타·피아노 같은 구체적인 악기죠. 그래도 "악기" 라는 공통 개념은 분명히 의미가 있어요. 음을 낸다는 공통점을 묶어주니까요. Content 도 똑같아요. 직접 만들 순 없지만, 자식들을 하나로 묶어주는 든든한 뼈대예요.
이런 클래스를 "추상 클래스" 라고 불러요
이제 이름을 붙여볼게요. 방금처럼 abstract 키워드가 붙어서 직접 객체로 만들 수 없고, 자식이 상속해서 쓰도록 뼈대 역할만 하는 클래스를 추상 클래스(abstract class) 라고 불러요. abstract 가 "추상적인" 이라는 뜻이니, 말 그대로 "아직 구체적이지 않은, 뼈대만 있는 클래스" 인 거죠.
지난 시간까지 우리가 만들었던 Member 나 Post 처럼 new 로 자유롭게 객체를 만들 수 있는 보통 클래스는, 추상 클래스와 구분해서 일반 클래스 라고 불러요. (영어로는 "구체적인" 이라는 뜻의 concrete 를 붙여 concrete class 라고도 해요.) 둘의 차이를 본격적으로 비교하는 건 오늘 마지막 Step의 몫이니, 지금은 "직접 만들 수 있으면 일반 클래스, abstract 붙어서 직접 못 만들면 추상 클래스" 정도만 잡아두면 충분해요.
💡 클래스 선언 앞에
abstract한 단어를 붙이면, 그 클래스는new로 직접 객체를 만들 수 없는 추상 클래스가 돼요. 자바가 컴파일 단계에서'Content' is abstract; cannot be instantiated라며 막아주죠. 직접 만들 순 없지만, 자식들이 공통 부분을 물려받을 든든한 뼈대 부모 역할을 해요.
자, "직접 만들 수 없는 부모" 가 진짜로 가능하다는 걸 확인했어요. 그런데 아까 Content 안에 슬쩍 보였던 "자식이 반드시 채워야 할 빈칸" 메서드, 기억나시죠? 다음 Step에서는 바로 그 빈칸, 즉 abstract 메서드 가 어떻게 자식에게 구현을 강제하는지 살펴볼게요.
Step 2. abstract 메서드 — 본문 없는 "빈칸" 이 자식에게 구현을 강제한다
방금 Content 안에 슬쩍 보였던 그 "빈칸" 메서드를 이제 제대로 펼쳐볼 차례예요.
다시 콘텐츠 이야기로 돌아가볼게요. 사진·영상·텍스트는 모두 콘텐츠지만, 피드에 보일 미리보기 모양은 종류마다 완전히 다르죠. 이미지는 "사진 3장", 영상은 "0:45 영상", 텍스트는 글 앞부분을 잘라서 보여주잖아요. 그러면 부모인 Content 가 미리보기 내용을 미리 정해줄 수 있을까요?
못 정해줘요. 부모 입장에선 이게 사진인지 영상인지 알 수가 없으니까요. 미리 내용을 적어두면 오히려 다 틀려버려요. 그렇다고 그냥 빼버리면, 자식이 미리보기를 깜빡하고 안 만들 수도 있죠.
그래서 부모는 이렇게 약속만 걸어둬요. "미리보기를 만드는 메서드는 무조건 있어야 해. 단, 그 안에 뭘 채울지는 자식 너희가 정해." 내용은 비워두고 "있어야 한다" 는 약속만 남기는 이 빈칸이 바로 abstract 메서드예요.
본문 { } 가 없고 세미콜론 ; 으로 끝나요
Content.java 에서 이 빈칸 두 개를 발췌해서 볼게요.
// com/instagram/javabasic/domain/content/Content.java
// 콘텐츠 종류를 글자로 — 자식마다 "이미지" / "영상" / "텍스트" 로 다르게 채워요.
public abstract String getType();
// 피드에 보일 짧은 미리보기 — 종류마다 보여줄 내용이 다르니 자식이 정해요.
public abstract String preview();
지금까지 우리가 본 메서드들과 모양이 어떻게 다른지 나란히 놓고 비교해볼게요.
일반 메서드 (본문이 있어요)
public void addLike() { ← 중괄호 { 로 시작해서
this.likeCount++; ← 안에 실제 동작이 들어 있고
} ← } 로 닫혀요
abstract 메서드 (빈칸이에요)
public abstract String getType();
↑ 중괄호 { } 가 아예 없고
세미콜론 ; 하나로 딱 끝나요
일반 메서드는 우리가 Day 6부터 쭉 봐온 그 모양이에요. { 로 열고 안에 동작을 적은 뒤 } 로 닫죠. 그런데 abstract 메서드는 중괄호가 통째로 없어요. 대신 메서드 이름 뒤에 세미콜론 ; 만 찍고 끝나요. 이게 "본문 없는 빈칸" 의 생김새예요.
public abstract String getType(); 한 줄을 토큰별로 가볍게 읽어볼게요.
public— 누구나 부를 수 있다는 접근 범위 (지금까지와 똑같아요)abstract— "이건 빈칸이야, 내용은 자식이 채워" 라는 표시String— 이 메서드가 돌려줄 값의 종류 (글자를 돌려주겠다는 뜻)getType()— 메서드 이름;— 본문 없이 선언만 하고 끝낸다는 마침표
새로 등장한 건 사실상 abstract 한 단어뿐이에요. 나머지는 우리가 늘 보던 메서드 선언 그대로고, 다만 { } 본문을 적는 대신 약속만 남기고 ; 으로 끊은 거죠.
주석도 함께 읽어보면 부모의 의도가 그대로 드러나요. getType() 위 주석은 "자식마다 이미지 / 영상 / 텍스트 로 다르게 채워요", preview() 위 주석은 "종류마다 보여줄 내용이 다르니 자식이 정해요" 라고 적혀 있죠. 부모는 "이런 메서드가 있어야 한다" 는 뼈대만 세우고, 살은 자식에게 떠넘기겠다는 거예요.
빈칸을 안 채우면 자바가 잔소리를 해요 (이게 강제력이에요)
여기서 가장 중요한 질문. "그래서 자식이 이 빈칸을 깜빡하고 안 채우면 어떻게 되나요?"
답은 시원해요. 컴파일 에러가 나요. 자바가 자식 클래스를 보자마자 "너 부모한테 받은 빈칸 두 개 중에 아직 안 채운 게 있잖아!" 하고 빨간 줄로 잔소리를 해요.
예를 들어 사진 콘텐츠를 담을 ImageContent 라는 자식을 만들면서 getType() 을 안 채웠다고 해볼게요. 그러면 IntelliJ가 클래스 이름 쪽에 빨간 줄을 긋고 이런 메시지를 보여줘요.
Class 'ImageContent' must either be declared abstract
or implement abstract method 'getType()' in 'Content'
우리말로 풀면 "ImageContent 클래스는 추상(abstract)으로 선언하든가, 아니면 부모 Content 의 abstract 메서드 getType() 을 구현(implement)하든가 둘 중 하나를 해야 한다" 는 뜻이에요. 한마디로 "빈칸 채우든가, 아니면 너도 추상 클래스가 되든가" 인 거죠.
이 강제력이 abstract 메서드의 진짜 핵심이에요. 부모가 빈칸을 걸어두면, 자식은 그걸 절대 빼먹을 수가 없어요. 빼먹는 순간 자바가 컴파일 단계에서 막아주니까요.
⚠️
must either be declared abstract or implement abstract method라는 메시지가 좀 길죠? 겁먹지 말고 핵심 단어만 잡으면 돼요.implement abstract method 'getType()'— "getType() 이라는 빈칸을 채워라" 는 뜻이에요. 에러 메시지는 늘 "무엇을 안 했는지" 를 알려주는 친절한 안내문이에요.
생각해보면 시험지 빈칸 채우기랑 똑같아요. 선생님(부모)이 "여기 빈칸 두 개는 꼭 답을 적어라" 하고 시험지를 나눠주면, 학생(자식)은 그 칸을 비워두고 제출할 수가 없죠. 빈칸을 비워두면 채점(컴파일)이 안 되는 거예요. abstract 메서드도 똑같이, 자식이 반드시 답을 적게 만드는 빈칸이에요.
빈칸이 있는 클래스는 직접 만들 수 없는 게 당연해요
마지막으로 Step 1과 한 가지를 연결해둘게요. Content 에는 getType(), preview() 처럼 아직 내용이 안 채워진 빈칸이 들어 있어요. 그러니 만약 new Content(...) 로 직접 만든다면, 그 객체의 getType() 을 불렀을 때 돌려줄 내용이 아예 없는 셈이죠. 빈칸이 비어 있으니까요.
그래서 자바는 약속해요. 빈칸(abstract 메서드)이 하나라도 들어 있는 클래스는 반드시 추상 클래스여야 한다고요. Step 1에서 Content 가 new 로 막혔던 이유가 바로 이거예요. 안에 채우다 만 빈칸이 있으니, 직접 객체로 만들면 안 되는 게 당연한 거죠.
💡 abstract 메서드는 본문
{ }없이;으로 끝나는 "빈칸" 선언이에요. 부모가 "이 메서드는 반드시 있어야 한다" 는 약속만 걸어두면, 자식은 그 빈칸을 반드시 채워야 하고 안 채우면 컴파일 에러가 나요. 이 강제력 덕분에 자식이 꼭 필요한 메서드를 빼먹는 실수를 자바가 막아줘요.
자, 부모가 빈칸을 어떻게 걸어두는지 봤어요. 그러면 이제 자식 쪽으로 시선을 옮겨볼게요. 다음 Step에서는 사진·영상·텍스트 자식들이 이 빈칸을 각자 어떻게 채워서 자기만의 미리보기를 완성하는지 직접 만들어봅니다.
Step 3. 자식 클래스가 @Override 로 빈칸을 채운다
부모 Content 가 getType(), preview() 라는 빈칸 두 개를 걸어뒀죠. 그런데 빈칸은 어디까지나 "있어야 한다" 는 약속일 뿐이에요. 그 약속만으로는 피드에 보여줄 진짜 미리보기가 아직 안 나와요. 누군가는 그 빈칸에 실제 내용을 적어줘야 하죠.
그 "누군가" 가 바로 자식이에요. 사진 콘텐츠라면 "사진 주소", 영상 콘텐츠라면 "재생 시간" 처럼, 종류마다 다른 내용으로 빈칸을 채워야 비로소 콘텐츠가 완성돼요. 이번 Step에서는 사진을 담는 ImageContent 와 영상을 담는 VideoContent, 두 자식이 같은 빈칸을 어떻게 서로 다르게 채우는지 직접 만들어봅니다.
사진 콘텐츠 — ImageContent 가 빈칸을 채워요
먼저 사진 콘텐츠예요. 전체 코드를 같이 보고 하나씩 뜯어볼게요.
// com/instagram/javabasic/domain/content/ImageContent.java
public class ImageContent extends Content {
// 이미지만 추가로 갖는 정보 — 사진 주소
private String imageUrl;
// 생성자 — 첫 줄 super(...) 로 부모의 공통 필드(작성자·좋아요)를 먼저 채우고,
// 그 다음 이미지만의 필드를 채워요.
public ImageContent(String authorName, int likeCount, String imageUrl) {
super(authorName, likeCount);
this.imageUrl = imageUrl;
}
public String getImageUrl() {
return imageUrl;
}
// 부모의 빈칸을 채워요 — 이미지의 종류 이름
@Override
public String getType() {
return "이미지";
}
// 부모의 빈칸을 채워요 — 이미지는 작성자와 사진 주소를 미리보기로 보여줘요
@Override
public String preview() {
return getAuthorName() + " 님의 사진: " + imageUrl;
}
}
맨 윗줄부터 차근차근 볼게요.
public class ImageContent extends Content — extends 로 부모 Content 를 물려받았어요. Day 10에서 배운 상속 그대로예요. 그런데 여기서 눈여겨볼 게 하나 있어요. 클래스 앞에 abstract 가 안 붙어 있죠? 자식인 ImageContent 는 일반 클래스예요. 부모는 추상이지만, 빈칸을 다 채운 자식은 일반 클래스가 될 수 있어요. (왜 그런지는 이 Step 끝에서 정리할게요.)
다음은 생성자예요. 첫 줄을 보세요.
super(authorName, likeCount);
이 super(...) 도 Day 10에서 봤던 그 친구예요. "부모의 생성자를 먼저 불러줘" 라는 뜻이죠. 부모 Content 의 생성자가 authorName 과 likeCount 라는 공통 필드를 채워주니까, 자식은 그걸 super(...) 로 부탁하는 거예요. 그러고 나서 자기만 가진 imageUrl 을 채우고요. "공통은 부모에게 맡기고, 나만의 것만 내가 챙긴다" 는 깔끔한 분업이에요.
이제 오늘의 핵심, 빈칸을 채우는 두 메서드예요.
@Override
public String getType() {
return "이미지";
}
부모가 public abstract String getType(); 라고 빈칸만 선언했던 거 기억나시죠? 자식은 거기에 { return "이미지"; } 라는 실제 본문을 채워 넣었어요. 세미콜론으로 끝나던 빈칸이, 이제 중괄호가 달린 진짜 메서드가 된 거예요. preview() 도 마찬가지로 getAuthorName() + " 님의 사진: " + imageUrl 이라는 자기만의 미리보기 내용을 채웠고요.
메서드 위에 붙은 @Override 도 낯설지 않으실 거예요. Day 10에서 "부모가 정한 메서드를 자식이 다시 정의(재정의)한다" 는 의미로 배웠죠. 여기서는 그게 "부모가 비워둔 빈칸을 자식이 채운다" 라는 모습으로 나타나요. 부모가 약속만 해둔 메서드를 자식이 실제로 완성한다는 점에서, 오버라이딩과 추상 메서드 구현은 사실 한 가족인 셈이에요.
영상 콘텐츠 — 같은 빈칸을 다르게 채워요
이번엔 영상 콘텐츠예요. 핵심만 발췌해서 볼게요.
// com/instagram/javabasic/domain/content/VideoContent.java
public class VideoContent extends Content {
// 영상만 추가로 갖는 정보
private String videoUrl; // 영상 주소
private int durationSeconds; // 재생 시간(초)
// 생성자 — super(...) 로 공통 필드를 먼저 채우고, 영상만의 필드를 채워요.
public VideoContent(String authorName, int likeCount, String videoUrl, int durationSeconds) {
super(authorName, likeCount);
this.videoUrl = videoUrl;
this.durationSeconds = durationSeconds;
}
// 부모의 빈칸을 채워요 — 영상의 종류 이름
@Override
public String getType() {
return "영상";
}
// 부모의 빈칸을 채워요 — 영상은 재생 시간을 미리보기에 함께 보여줘요
@Override
public String preview() {
return getAuthorName() + " 님의 영상 (" + durationSeconds + "초)";
}
}
구조는 ImageContent 와 완전히 똑같아요. extends Content 로 부모를 물려받고, 생성자 첫 줄에서 super(...) 로 공통 필드를 채우고, @Override 로 빈칸 두 개를 채워요. 영상은 사진 주소 대신 영상 주소(videoUrl)와 재생 시간(durationSeconds)을 갖는다는 게 다를 뿐이죠.
그런데 여기서 진짜 재미있는 걸 봐주세요. 부모가 걸어둔 빈칸은 똑같은 두 개인데, 자식이 채운 내용은 완전히 달라요.
부모가 걸어둔 빈칸 자식이 채운 내용
┌──────────────────┐
│ getType() │ ──► ImageContent: "이미지"
│ (종류 이름) │ ──► VideoContent: "영상"
└──────────────────┘
┌──────────────────┐
│ preview() │ ──► ImageContent: "minji 님의 사진: beach.jpg"
│ (미리보기) │ ──► VideoContent: "jaehoon 님의 영상 (45초)"
└──────────────────┘
같은 빈칸을 받았지만, 사진은 사진답게, 영상은 영상답게 채웠어요. 부모는 "미리보기 메서드가 있어야 한다" 는 틀만 정해주고, 그 안을 어떻게 채울지는 각 자식이 자기 방식대로 결정한 거죠. 이게 추상 메서드가 가진 힘이에요. 공통의 약속은 한 군데(부모)에 두고, 종류별로 다른 내용은 각자(자식)가 알아서 채우게 만드는 거예요.
빵 틀에 비유해볼게요. 같은 붕어빵 틀(부모의 빈칸)을 줘도, 누구는 팥을 넣고 누구는 슈크림을 넣잖아요. 틀은 하나지만 속은 만드는 사람 마음이죠. 빈칸을 채우는 자식도 똑같아요. 같은 preview() 라는 틀에 사진은 사진 내용을, 영상은 영상 내용을 넣는 거예요.
🙋 학생 질문 — "튜터님, 자식이 빈칸 두 개 중에 하나만 채우면 어떻게 돼요?"
좋은 질문이에요! 결론부터 말하면, 하나만 채우면 안 돼요. 부모가 걸어둔 빈칸은 전부 채워야 해요.
예를 들어 ImageContent 에서 getType() 은 채웠는데 preview() 를 깜빡 빼먹었다고 해볼게요. 그러면 Step 2에서 봤던 그 빨간 줄이 또 떠요. "아직 안 채운 빈칸(preview())이 남아 있다" 고 자바가 잡아주거든요.
자식이 일반 클래스(직접 new 할 수 있는 클래스)가 되려면, 부모한테 받은 빈칸을 하나도 남김없이 다 채워야 해요. 시험지 빈칸을 절반만 채우면 통과 못 하는 것과 똑같죠. 그래서 우리도 ImageContent 와 VideoContent 둘 다 getType() 과 preview() 를 빠짐없이 채운 거예요.
빈칸을 다 채운 자식은 직접 new 할 수 있어요
이제 Step 1과 비교하면서 정리해볼게요. Step 1에서 부모 Content 는 new Content(...) 가 막혔어요. 안에 채우다 만 빈칸이 있었으니까요. 그런데 자식 ImageContent 와 VideoContent 는 그 빈칸을 전부 채웠죠. 더 이상 비어 있는 칸이 없어요.
그래서 이 자식들은 직접 만들 수 있어요.
new ImageContent("minji", 120, "beach.jpg"); // OK! 빈칸을 다 채웠으니 직접 만들 수 있어요
new VideoContent("jaehoon", 340, "trip.mp4", 45); // OK!
new Content(...) 는 막히지만 new ImageContent(...) 는 잘 되는 이유가 바로 이거예요. 부모는 빈칸이 남아 있어 추상 클래스로 남고, 빈칸을 다 채운 자식은 일반 클래스가 되어 직접 객체로 만들 수 있는 거죠.
💡 자식 클래스는
extends로 추상 부모를 물려받고,@Override로 부모가 비워둔 빈칸(abstract 메서드)을 채워요. 같은 빈칸이라도 사진은 "이미지", 영상은 "영상" 처럼 종류마다 다르게 채울 수 있어요. 빈칸을 전부 채운 자식은 더 이상 추상이 아니라 직접new할 수 있는 일반 클래스가 됩니다.
자, 자식들이 빈칸을 각자 채워서 진짜 콘텐츠가 완성됐어요. 그런데 부모 Content 안에는 빈칸 말고도 흥미로운 게 하나 더 있었어요. 부모가 직접 본문까지 다 만들어둔 render() 라는 메서드인데, 그 안에서 자식이 채운 빈칸을 불러 쓰고 있죠. 다음 Step에서는 "틀은 부모가 짜고, 그 안의 일부만 자식이 채운다" 는 이 똑똑한 설계를 살펴볼게요.
Step 4. 템플릿 메서드 — 틀은 부모가, 빈칸은 자식이
피드에 콘텐츠 한 줄을 그린다고 생각해볼게요. 인스타 피드의 한 줄은 보통 이런 모양이에요. [종류] 미리보기 (♥ 좋아요 수). 그러니까 [이미지] minji 님의 사진: beach.jpg (♥ 120) 처럼요.
여기서 잠깐 생각해봐요. 이 한 줄을 조립하는 형식, 즉 "대괄호 안에 종류, 그다음 미리보기, 끝에 하트와 좋아요 수" 라는 틀은 사진이든 영상이든 텍스트든 전부 똑같죠. 종류 이름과 미리보기 내용만 다를 뿐, 조립 순서와 모양은 모든 콘텐츠가 공유해요.
그러면 이 조립 코드를 자식마다 따로 만들어야 할까요? ImageContent 에도 한 줄 조립 코드를 쓰고, VideoContent 에도 똑같은 걸 복사해서 붙이고... 이러면 두 가지 문제가 생겨요. 첫째, 거의 똑같은 코드를 자식 수만큼 복붙하는 낭비예요. 둘째, 나중에 피드 형식을 바꾸고 싶을 때(예: 하트 대신 다른 모양으로) 자식 전부를 일일이 고쳐야 해요. 하나라도 빠뜨리면 콘텐츠마다 형식이 달라지는 사고가 나죠.
그래서 더 똑똑한 방법이 있어요. 공통된 조립 틀은 부모가 딱 한 번만 만들어두고, 그 틀 안에서 종류마다 달라지는 부분만 자식에게 맡기는 거예요.
부모가 만들어둔 완성된 메서드 — render()
Content.java 에서 부모가 직접 본문까지 채워둔 메서드들을 볼게요.
// com/instagram/javabasic/domain/content/Content.java
// 좋아요 한 번 — 모든 콘텐츠가 똑같이 동작하니 부모가 한 번만 만들어 물려줘요.
public void addLike() {
this.likeCount++;
}
// 템플릿 메서드 — 피드 한 줄을 조립하는 "틀" 은 부모가 정하고,
// 그 안에서 종류([이미지])와 미리보기 내용은 자식이 채운 getType()/preview() 에 맡겨요.
public String render() {
return "[" + getType() + "] " + preview() + " (♥ " + likeCount + ")";
}
이 두 메서드는 Step 2에서 본 빈칸(abstract 메서드)과 달리, 부모가 본문 { } 까지 꽉 채워둔 완성된 메서드예요. 이렇게 본문이 다 들어 있어 자식이 그대로 물려받아 쓸 수 있는 메서드를 일반(concrete) 메서드라고 불러요.
addLike() 부터 볼게요. 좋아요를 하나 올리는 동작은 사진이든 영상이든 전부 똑같죠. 그래서 부모가 한 번만 만들어두고 자식 모두에게 물려줘요. 자식은 이걸 다시 만들 필요 없이 그냥 가져다 쓰면 돼요.
이제 오늘의 주인공 render() 예요. 천천히 뜯어볼게요.
return "[" + getType() + "] " + preview() + " (♥ " + likeCount + ")";
여기서 묘한 점이 하나 있어요. getType() 과 preview() 가 보이죠? 이 둘은 부모 Content 에서는 본문 없는 빈칸(abstract 메서드)이었어요. 그런데 부모가 자기 메서드인 render() 안에서 그 빈칸들을 떡하니 불러 쓰고 있어요. "아직 내용도 안 정해진 메서드를 어떻게 미리 불러요?" 싶죠?
바로 이게 핵심이에요. 부모는 "여기서 종류 이름이 들어가고, 여기서 미리보기가 들어간다" 는 위치만 표시해둬요. 실제로 무슨 글자가 채워질지는 부모도 몰라요. 그건 실행하는 순간, 진짜 객체가 채워둔 내용으로 결정되거든요.
부모가 불러도 실제로는 자식이 채운 내용이 실행돼요
이 동작, Day 11에서 이미 만난 적 있어요. 기억하시나요? 부모 타입으로 메서드를 불러도 실제 객체가 자식이면 자식이 오버라이딩한 버전이 실행됐죠. 그걸 동적 디스패치 라고 불렀어요.
render() 안에서 벌어지는 일이 딱 그거예요. render() 는 부모가 만든 메서드 하나뿐인데, 그 안의 getType() 과 preview() 는 실제 객체가 누구냐에 따라 다르게 불려요.
render() 는 부모가 만든 틀 하나뿐
┌──────────────────────────────────────────────┐
│ "[" + getType() + "] " + preview() + " (♥ " │
│ ▲ ▲ │
└──────────────┼──────────────┼─────────────────┘
│ │ 실행 순간, 실제 객체가 채운 내용이 불려와요
┌──────────┴───┐ ┌─────┴──────────┐
이미지면 "이미지" 이미지면 "minji 님의 사진: beach.jpg"
영상이면 "영상" 영상이면 "jaehoon 님의 영상 (45초)"
그래서 똑같은 render() 를 불러도, 안에 담긴 객체가 이미지면 이미지 한 줄이, 영상이면 영상 한 줄이 튀어나와요. 틀은 하나인데 결과는 종류별로 갈리는 거죠.
자식은 render() 를 한 줄도 안 만들었어요
여기서 진짜 신기한 걸 확인해볼게요. Step 3에서 만든 ImageContent 와 VideoContent 를 다시 떠올려보세요. 두 자식은 빈칸 두 개(getType, preview)만 채웠지, render() 는 한 줄도 만들지 않았어요. 그런데도 render() 를 부를 수 있어요. 부모한테 물려받았으니까요.
ImageContent image = new ImageContent("minji", 120, "beach.jpg");
System.out.println(image.render());
// 출력: [이미지] minji 님의 사진: beach.jpg (♥ 120)
VideoContent video = new VideoContent("jaehoon", 340, "trip.mp4", 45);
System.out.println(video.render());
// 출력: [영상] jaehoon 님의 영상 (45초) (♥ 340)
빈칸 두 개만 채웠을 뿐인데 완성된 한 줄이 자동으로 조립돼서 나와요. 종류 이름과 미리보기는 자식이 채운 내용으로, 대괄호와 하트 같은 틀은 부모가 만든 그대로요. 자식 입장에선 "나는 빈칸만 채웠는데 한 줄이 알아서 완성됐네?" 하는 기분이죠.
와플 기계에 비유해볼게요. 기계(부모의 render())는 와플 모양을 똑같이 찍어내요. 격자무늬 틀은 늘 같죠. 그런데 안에 붓는 반죽(자식이 채운 getType·preview)에 따라 플레인 와플이 되기도 하고 초코 와플이 되기도 해요. 우리는 반죽만 다르게 부으면(빈칸만 채우면) 되고, 찍어내는 일은 기계가 알아서 해줘요.
🙋 학생 질문 — "튜터님, 그럼 자식이 render() 를 자기 식대로 다시 만들 수도 있나요?"
가능은 해요! render() 는 빈칸(abstract)이 아니라 완성된 일반 메서드라서, 자식이 굳이 원하면 @Override 로 다시 정의해 자기만의 형식으로 바꿀 수도 있어요. Day 10·11에서 배운 오버라이딩 그대로요.
하지만 여기서는 일부러 그렇게 안 했어요. 피드 한 줄의 형식은 모든 콘텐츠가 똑같이 가져가는 게 목적이거든요. 자식마다 제각각 render() 를 다시 만들어버리면, 우리가 애써 부모에 모아둔 공통 틀이 다시 흩어지죠. 그래서 "공통 틀은 부모 것 그대로 쓰고, 종류마다 다른 빈칸만 채운다" 는 약속을 지킨 거예요. 이게 이 설계의 핵심 의도예요.
부모 클래스 안에는 두 종류의 메서드가 함께 살아요
여기까지 보면 부모 Content 안에 성격이 다른 두 종류의 메서드가 같이 들어 있다는 걸 알 수 있어요. 한 클래스 안에 둘이 공존해요.
| 메서드 종류 | 본문 | 누가 채우나 | Content 의 예 |
|---|---|---|---|
| abstract 메서드 (빈칸) | 없음 (; 으로 끝) |
자식이 @Override 로 채움 |
getType(), preview() |
| 일반(concrete) 메서드 (완성) | 있음 ({ }) |
부모가 다 만들어 물려줌 | render(), addLike(), getter |
"변하는 부분" 은 빈칸으로 비워 자식에게 맡기고, "모든 자식이 똑같이 쓰는 부분" 은 부모가 완성해서 물려줘요. 이렇게 역할을 나눠두니, 공통 코드는 한 군데에만 있고 종류별 차이만 각자 채우면 되는 깔끔한 구조가 만들어져요.
이 설계를 "템플릿 메서드 패턴" 이라고 불러요
이제 이름을 붙여볼게요. 방금처럼 부모가 전체 틀(템플릿)을 일반 메서드로 짜두고, 그 틀 안에서 변하는 일부 단계만 빈칸으로 비워 자식이 채우게 하는 설계를 템플릿 메서드 패턴(template method pattern) 이라고 불러요. render() 가 바로 그 "템플릿(틀) 역할을 하는 메서드" 라서 템플릿 메서드라고 부르는 거예요.
이름이 좀 거창한데, 외울 필요는 전혀 없어요. 딱 한 줄만 기억하면 돼요. "틀은 부모가, 빈칸은 자식이." 이 말만 떠올리면 템플릿 메서드가 뭔지 다시 그려낼 수 있어요.
💡 부모는 전체 조립 틀을 일반 메서드(
render())로 한 번만 만들어두고, 그 안에서 종류마다 달라지는 부분은 빈칸(getType,preview)으로 자식에게 맡겨요. 자식은 빈칸만 채워도 완성된 결과가 자동으로 나오죠. 이렇게 "틀은 부모가, 빈칸은 자식이" 채우는 설계가 템플릿 메서드 패턴이에요.
자, 이제 추상 클래스의 핵심 구조를 다 둘러봤어요. 그런데 한 가지 궁금증이 남죠. "그래서 대체 언제 추상 클래스를 쓰고, 언제 그냥 일반 클래스로 충분한 거지?" 다음 Step에서는 그 판단 기준을 정리하면서, 추상 클래스가 빛나는 상황과 굳이 필요 없는 상황을 갈라볼게요.
Step 5. 언제 추상 클래스를 쓰고, 언제 일반 클래스로 충분할까
Step 1부터 4까지 추상 클래스를 직접 손으로 지어봤어요. abstract 로 직접 못 만들게 막고, 빈칸을 걸고, 자식이 채우고, 템플릿 메서드로 틀을 공유하는 것까지요. 그러면 이제 가장 현실적인 질문이 남아요. "내가 새 클래스를 만들 때, 이걸 추상으로 해야 할지 그냥 일반 클래스로 둬도 될지 어떻게 판단하지?"
좋은 소식은, 우리가 이미 양쪽을 다 경험해봤다는 거예요. Day 8부터 만들어온 Member 는 일반 클래스였고, 오늘 만든 Content 는 추상 클래스였죠. 이 둘을 나란히 놓고 비교하면 판단 기준이 자연스럽게 잡혀요.
판단을 도와주는 질문 세 가지
새 클래스를 만들 때 스스로에게 이 질문들을 던져보면 좋아요.
질문 1. "이 클래스를 그 자체로 직접 만들어 쓸 일이 있나?"
new 로 직접 객체를 만들어 쓸 일이 있으면 일반 클래스면 충분해요. 반대로 "종류를 정하지 않고 만들면 불완전해서 곤란하다" 면 추상 클래스가 어울려요. 오늘 Content 가 딱 그랬죠. 종류 없는 콘텐츠는 미리보기를 그릴 수 없으니, 직접 만들면 안 되는 거였어요.
질문 2. "자식마다 반드시 다르게 구현해야 하는 행동이 있나?"
종류마다 내용이 완전히 달라서 부모가 미리 정해줄 수 없는 행동이 있다면, 그 행동을 빈칸(abstract 메서드)으로 두고 부모를 추상 클래스로 만들면 좋아요. Content 의 preview() 가 그랬어요. 사진·영상·텍스트의 미리보기가 전부 다르니 부모가 정할 수 없었죠.
질문 3. "여러 자식이 공통으로 가질 필드나 행동이 있나?"
작성자·좋아요처럼 모든 자식이 똑같이 가질 게 있으면, 그건 부모로 묶을 가치가 있어요. 다만 이건 추상이든 일반이든 "상속을 쓸 이유" 일 뿐이에요. 추상으로 만들지는 앞의 두 질문이 결정해요.
이 흐름을 그림으로 정리하면 이래요.
새 부모 클래스를 만들 때
│
▼
이 부모를 그 자체로 직접 new 할 일이 있나?
│
┌────┴─────────────────────┐
있다 없다 (종류 없이 만들면 불완전)
│ │
▼ ▼
자식이 꼭 채워야 할 자식이 반드시 다르게
빈칸은 없나? 구현할 행동이 있나?
│ │
▼ ▼
일반 클래스 추상 클래스 + abstract 메서드
(예: Member) (예: Content)
우리 코드 두 사례로 확인해요
Member 와 Content 를 위 질문에 대입해볼게요. 둘 다 우리가 직접 만들어봤으니 비교가 쉬워요.
Member(Day 8~11)부터요. 우리는 new Member("jaehoon", ...) 처럼 일반 회원을 직접 만들어 쓴 적이 많았죠. 일반 회원 그 자체로도 완전한 의미가 있었거든요. 그래서 자식인 AdminMember·PremiumMember 가 있었어도 Member 를 추상으로 만들지 않았어요. 부모를 직접 쓸 일이 있으니 일반 클래스가 맞았던 거죠.
Content(오늘)는 반대였어요. "콘텐츠 자체" 는 종류를 모르면 미리보기를 못 그려서 직접 만들 일이 없었어요. 게다가 미리보기는 자식마다 반드시 다르게 채워야 했고요. 그래서 추상 클래스가 정답이었어요.
| 따져볼 점 | Member (일반 클래스) |
Content (추상 클래스) |
|---|---|---|
부모를 직접 new 할 일이 있나? |
있다 (일반 회원도 완전함) | 없다 (종류 없으면 불완전) |
| 자식이 반드시 다르게 채울 행동이 있나? | 딱히 없다 | 있다 (preview, getType) |
| 공통 필드/행동을 부모로 묶나? | 묶는다 (이름·팔로워 등) | 묶는다 (작성자·좋아요) |
| 그래서 판정은? | 일반 클래스로 충분 | 추상 클래스가 어울림 |
표의 마지막 줄을 보세요. 둘 다 공통 필드를 부모로 묶는 건 똑같았어요. 차이는 위의 두 줄, 즉 "직접 만들 일이 있나"와 "꼭 다르게 채울 행동이 있나"에서 갈렸죠.
"상속을 쓴다 = 추상 클래스" 가 아니에요
여기서 초보자가 자주 하는 오해를 하나 풀고 갈게요. "부모-자식 상속을 쓰면 부모는 무조건 추상 클래스로 만들어야 하나요?" 아니에요!
상속을 쓴다고 부모를 꼭 추상으로 만들 필요는 없어요. 우리 Member 가 바로 그 증거예요. Member 는 AdminMember·PremiumMember 라는 자식을 거느린 어엿한 부모지만, 그 자신도 일반 클래스로 멀쩡히 잘 쓰였잖아요.
정리하면 이래요. 부모를 직접 만들어 쓸 일이 있으면 그냥 일반 클래스로 두고 상속만 해도 돼요. 추상 클래스는 "부모를 직접 못 만들게 막고 싶다 + 자식이 반드시 채울 빈칸이 있다" 이 두 조건이 만날 때 꺼내 드는 선택이에요.
🙋 학생 질문 — "튜터님, 그럼 헷갈리니까 그냥 모든 부모를 추상 클래스로 만들면 안 되나요?"
마음은 이해하지만, 그건 권하지 않아요. 추상 클래스는 "직접 만들면 안 되는 분명한 이유" 가 있을 때 쓰는 도구거든요.
생각해보세요. 만약 Member 를 억지로 추상으로 만들어버리면, 평범한 일반 회원을 만들고 싶을 때마다 "어? 일반 회원은 직접 못 만드네?" 하고 막혀버려요. 멀쩡히 쓸 수 있던 걸 일부러 불편하게 만드는 셈이죠.
그래서 기준은 단순해요. "이 클래스, 종류 없이 직접 만들면 진짜로 곤란한가?" 이 답이 "그렇다" 일 때만 추상으로 만들면 좋아요. Content 처럼요. 답이 "딱히 곤란할 거 없는데?" 라면 일반 클래스가 더 편하고 자연스러워요. 도구는 필요한 곳에만 쓰는 게 좋답니다.
💡 직접 만들어 쓸 일이 있으면 일반 클래스, 종류 없이 만들면 불완전하고 자식이 반드시 채울 빈칸이 있으면 추상 클래스예요. 상속을 쓴다고 부모를 꼭 추상으로 만들 필요는 없어요(
Member가 그 예). 추상 클래스는 "직접 못 만들게 막을 분명한 이유" 가 있을 때만 꺼내 쓰면 충분해요.
자, 이제 추상 클래스를 언제 쓸지 판단하는 눈까지 갖췄어요. 머리로 이해한 걸 손으로 굳혀야 진짜 내 것이 되겠죠. 다음 Step에서는 오늘 만든 Content, ImageContent, VideoContent 에 텍스트 콘텐츠 하나를 더해서, 여러 종류의 콘텐츠를 한 피드에 모아 출력하는 종합 실습을 해봅니다.
Step 6. 종합 실습 — 텍스트 콘텐츠를 더하고 한 피드에 모아 출력하기
드디어 오늘의 마지막 Step이에요. 지금까지 배운 걸 한자리에 모아볼 거예요. 추상 클래스(Content), 빈칸(abstract 메서드), 빈칸 채우기(@Override), 그리고 부모가 짠 틀(render() 템플릿 메서드)까지요. 여기에 Day 11에서 배운 다형성까지 더하면 오늘의 그림이 완성돼요.
먼저 세 번째 자식을 하나 더 만들고, 그다음 사진·영상·텍스트 세 종류를 배열 하나에 담아서 한꺼번에 출력해볼게요.
세 번째 자식 — TextContent
지금까지 사진과 영상을 만들었으니, 이번엔 글만 있는 텍스트 콘텐츠 차례예요. TextContent.java 를 볼게요.
// com/instagram/javabasic/domain/content/TextContent.java
public class TextContent extends Content {
// 텍스트만 추가로 갖는 정보 — 게시글 본문
private String body;
// 생성자 — super(...) 로 공통 필드를 먼저 채우고, 본문을 채워요.
public TextContent(String authorName, int likeCount, String body) {
super(authorName, likeCount);
this.body = body;
}
// 부모의 빈칸을 채워요 — 텍스트의 종류 이름
@Override
public String getType() {
return "텍스트";
}
// 부모의 빈칸을 채워요 — 본문이 길면 앞 10글자만 잘라 "..." 를 붙여 미리보기로 보여줘요.
@Override
public String preview() {
if (body.length() > 10) {
return body.substring(0, 10) + "...";
}
return body;
}
}
구조는 ImageContent·VideoContent 와 판박이예요. extends Content 로 물려받고, super(...) 로 공통 필드를 채우고, @Override 로 빈칸 두 개를 채웠죠.
그런데 preview() 를 보세요. 사진·영상은 그냥 글자를 이어 붙이기만 했는데, 텍스트는 약간의 로직이 들어갔어요.
if (body.length() > 10) {
return body.substring(0, 10) + "...";
}
return body;
본문이 길면 통째로 다 보여주면 피드가 지저분해지잖아요. 그래서 본문 글자 수가 10글자보다 길면 앞 10글자만 잘라내고 뒤에 ... 을 붙여요. 짧으면 그냥 그대로 보여주고요. length() 로 글자 수를 재고 substring(0, 10) 으로 앞 10글자를 자르는 건, 이전에 문자열 다룰 때 써본 String 기본 기능이에요. if 분기도 익숙하죠.
여기서 짚어둘 게 하나 있어요. 텍스트는 미리보기를 만드는 데 이렇게 약간의 계산이 들어가지만, 부모 입장에선 똑같이 preview() 라는 빈칸 하나일 뿐이에요. 사진은 단순하게, 텍스트는 자르기 로직을 넣어서 — 빈칸 안을 얼마나 단순하게 또는 복잡하게 채우든, 부모는 신경 쓰지 않아요. "미리보기를 돌려준다" 는 약속만 지키면 되니까요. 이게 빈칸 방식의 자유로움이에요.
세 종류를 배열 하나에 담아 한꺼번에 출력해요
이제 절정이에요. 사진·영상·텍스트 셋을 Content[] 배열 하나에 담고, 똑같은 render() 만 반복해서 불러볼게요. ContentDemoMain.java 전체를 봅니다.
// com/instagram/javabasic/domain/content/ContentDemoMain.java
public static void main(String[] args) {
// Content 는 abstract 라 직접 만들 수 없어요. 아래 줄은 컴파일 에러가 나요.
// Content c = new Content("minji", 10); // 'Content' is abstract; cannot be instantiated
// 대신 자식들을 만들어 부모 타입 배열에 담아요 — 자식을 부모 자리에 담는 게 업캐스팅
Content[] feed = {
new ImageContent("minji", 120, "beach.jpg"),
new VideoContent("jaehoon", 340, "trip.mp4", 45),
new TextContent("seungwoo", 12, "오늘 날씨가 정말 좋네요 산책하기 딱 좋은 하루")
};
// 향상된 for 로 순회하며 같은 render() 만 불러요.
for (Content content : feed) {
System.out.println(content.render());
}
}
이 코드, 사실 우리가 지난 시간(Day 11)에 봤던 그 패턴이랑 똑같아요. 그때 Member[] 배열에 일반 회원·관리자·프리미엄을 섞어 담고 똑같은 메서드를 불렀더니, 실제 객체에 따라 다른 결과가 나왔죠. 오늘은 그 주인공이 Content 로 바뀌었을 뿐이에요.
한 줄씩 짚어볼게요.
Content[] feed = { new ImageContent(...), new VideoContent(...), new TextContent(...) };
배열 타입은 Content[](부모)인데, 실제로 담는 건 사진·영상·텍스트(자식)예요. 자식을 부모 타입으로 담는 것, 이게 Day 11에서 배운 업캐스팅 이에요. "관리자도 회원이다" 가 참이었듯, "사진도 콘텐츠다" 가 참이니 아무 문제 없이 담겨요.
for (Content content : feed) {
System.out.println(content.render());
}
향상된 for 로 배열을 돌면서 똑같이 content.render() 만 불러요. 우리가 부르는 건 늘 같은 render() 한 줄인데, 그 안에서 getType()·preview() 가 실제 객체에 따라 다르게 채워지죠. 같은 메서드 호출이 객체에 따라 다르게 동작하는 것, 이게 Day 11의 동적 디스패치 예요.
실행 결과 — 같은 render() 한 줄로 세 모습이 나와요
자, 실행하면 콘솔에 이렇게 찍혀요.
[이미지] minji 님의 사진: beach.jpg (♥ 120)
[영상] jaehoon 님의 영상 (45초) (♥ 340)
[텍스트] 오늘 날씨가 정말 ... (♥ 12)
우리가 부른 건 content.render() 단 한 줄뿐인데, 콘텐츠 종류에 따라 세 가지 다른 모습이 나왔어요. 대괄호와 하트 틀은 똑같이 부모 render() 가 그렸고, 그 안의 종류 이름과 미리보기는 각 자식이 채운 내용이 들어갔죠. 추상 클래스와 다형성이 만나는 오늘의 절정 장면이에요.
🙋 학생 질문 — "튜터님, 텍스트 줄에 왜 '좋네요' 부터 안 보이고 '...' 으로 끊겼어요?"
날카로운 관찰이에요! 원래 본문은 "오늘 날씨가 정말 좋네요 산책하기 딱 좋은 하루" 라는 긴 문장이었죠. 그런데 출력엔 "오늘 날씨가 정말 ..." 까지만 나왔어요.
이건 TextContent 의 preview() 가 한 일이에요. 본문이 10글자보다 길면 앞 10글자만 잘라낸다고 했잖아요. 본문 맨 앞에서 10글자를 세어보면 "오늘 날씨가 정말 " 까지예요. 공백도 한 글자로 세니까, "오늘"(2) + " "(1) + "날씨가"(3) + " "(1) + "정말"(2) + " "(1), 이렇게 딱 10글자죠. 그 뒤부터는 잘려나가고 ... 이 대신 붙은 거예요.
그래서 "좋네요" 부터는 미리보기에서 안 보여요. 피드에서는 짧게 맛보기만 보여주고, 전체 글은 게시물을 눌러 들어가야 보이는 거랑 똑같은 동작이죠.
오늘 만든 구조를 한눈에
오늘 우리가 직접 지은 구조를 통째로 그려보면 이래요. 이번엔 실제 출력까지 채워서요.
┌──────────────────────────────┐
│ abstract Content (뼈대 부모) │ ← new Content(...) ❌
│ ──────────────────────── │
│ 공통(완성): render(), addLike() │ ← 부모가 만들어 물려줌
│ 빈칸(자식이 채움): getType, preview│
└──────────────────────────────┘
▲ ▲ ▲
┌────────┴──┐ ┌───────┴─────┐ ┌──────┴────────┐
│ImageContent│ │VideoContent │ │ TextContent │
│ "이미지" │ │ "영상" │ │ "텍스트" │
└───────────┘ └─────────────┘ └───────────────┘
│ │ │
[이미지] minji 님의 [영상] jaehoon 님의 [텍스트] 오늘 날씨가
사진: beach.jpg 영상 (45초) 정말 ...
(♥ 120) (♥ 340) (♥ 12)
부모 하나에 자식 셋. 부모는 직접 만들 수 없는 뼈대로 공통 부분(render, addLike)을 물려주고, 자식들은 각자 빈칸을 채워 자기만의 미리보기를 완성했어요. 그리고 셋을 한 배열에 담아 똑같은 render() 로 출력하니, 종류별로 다른 줄이 자동으로 나왔죠.
💡 추상 부모 하나(
Content)에 자식 여럿(ImageContent·VideoContent·TextContent)을 두고, 부모 타입 배열(Content[])에 담아 같은 메서드(render())만 불러도 종류마다 다른 결과가 나와요. 추상 클래스 + 빈칸 채우기 + 템플릿 메서드 + 다형성이 한자리에 모이면, 새 콘텐츠 종류가 늘어나도 자식 하나만 추가하면 되는 유연한 구조가 완성돼요.
이걸로 추상 클래스의 모든 조각을 직접 손으로 만들어봤어요. 처음엔 "직접 만들 수 없는 클래스" 가 낯설었을 텐데, 이제는 그게 왜 필요하고 어떻게 쓰는지 한 줄로 설명할 수 있게 됐죠. 정말 수고 많으셨어요!
마무리
오늘 꽤 묵직한 개념을 다뤘어요. "직접 만들 수 없는 클래스" 라는 낯선 출발점에서 시작해서, 빈칸을 걸고 채우고, 부모가 틀을 짜고 자식이 채우는 똑똑한 설계까지 왔죠. 머릿속에 흩어진 조각들을 한 번에 모아 정리해볼게요.
오늘 배운 것 한눈에 정리
abstract키워드 — 클래스 앞에 붙이면new로 직접 못 만드는 추상 클래스가 돼요. 종류 없이 만들면 불완전한 "뼈대 부모" 를 표현하죠.- abstract 메서드 — 본문
{ }없이;으로 끝나는 빈칸이에요. 자식이 반드시 채워야 하고, 안 채우면 컴파일 에러가 나는 강제력이 핵심이에요. - 자식의
@Override— 같은 빈칸이라도 사진은 "이미지", 영상은 "영상" 처럼 종류별로 다르게 채워요. 빈칸을 다 채운 자식은 직접new할 수 있는 일반 클래스가 되고요. - 템플릿 메서드 (
render()) — 공통 틀은 부모가 한 번만 만들고, 그 안에서 변하는 부분만 빈칸으로 자식에게 맡겨요. "틀은 부모가, 빈칸은 자식이." - 판단 기준 — 직접 만들 일이 없고 자식이 반드시 채울 빈칸이 있을 때 추상 클래스, 직접 만들어 쓸 일이 있으면 일반 클래스.
- 종합 실습 —
Content[]배열 하나에 사진·영상·텍스트를 담고 같은render()만 불러도, 객체 종류에 따라 다른 줄이 나오는 다형성을 확인했어요.
추상 클래스를 만들까 말까, 세 가지만 물어보세요
새 클래스를 설계하다 "이거 추상으로 할까?" 망설여질 때, 이 세 질문을 떠올리면 편해요.
- 이 부모를 그 자체로 직접
new할 일이 있나요? — 없으면 추상 클래스 후보예요. - 자식마다 반드시 다르게 구현할 행동이 있나요? — 있으면 그 행동을 abstract 메서드(빈칸)로 두면 좋아요.
- 여러 자식이 공통으로 가질 필드나 행동을 부모에 모았나요? — 공통 부분은 부모가 한 번만 만들어 물려주면 깔끔해요.
💡 추상 클래스의 핵심은 딱 두 가지예요. 하나는 "직접 못 만들게 막는다", 또 하나는 "자식이 반드시 채울 빈칸을 건다". 이 둘이 만나면 뼈대는 부모가 정하고 세부 구현은 자식이 책임지는, 유연하면서도 안전한 설계가 나와요.
다음 시간 예고 — "여러 역할을 동시에 맡고 싶다면?"
오늘 우리는 추상 클래스로 "뼈대는 부모가 정하고 빈칸은 자식이 채운다" 를 멋지게 해냈어요. 그런데 추상 클래스에는 한 가지 한계가 있어요. 자바에서 자식은 부모를 딱 하나만 물려받을 수 있거든요. ImageContent 가 Content 를 물려받았으면, 다른 부모를 동시에 또 물려받진 못해요.
그런데 현실에선 이런 일이 생겨요. 어떤 콘텐츠는 "공유할 수 있는 것" 이면서 동시에 "댓글을 달 수 있는 것" 이기도 하잖아요. 한 콘텐츠가 여러 역할을 동시에 갖고 싶은 거죠. 부모 하나만 물려받는 추상 클래스만으로는 이걸 표현하기가 답답해요.
그래서 다음 시간엔 추상 클래스보다 더 순수하게 "약속" 만 담는 새로운 문법, 인터페이스(interface) 를 배워요. 인터페이스는 implements(구현한다) 라는 키워드로 자식에게 약속을 떠넘기는데, 신기하게도 여러 개의 약속을 한 자식이 동시에 맺을 수 있어요. "공유 가능" + "댓글 가능" 같은 여러 역할을 한 콘텐츠에 동시에 입힐 수 있는 거죠.
그리고 오늘 배운 추상 클래스와 다음에 배울 인터페이스가 비슷해 보이는데 대체 뭐가 다른지, 언제 무엇을 써야 하는지도 나란히 정리할게요. 오늘 추상 클래스를 제대로 익혀뒀으니, 다음 시간 인터페이스는 훨씬 수월하게 따라올 수 있을 거예요. 그럼 다음 시간에 만나요!
과제
오늘 배운 추상 클래스, abstract 메서드, @Override, 템플릿 메서드를 손에 익히는 과제 세 개예요.
모두 코드베이스의 Content·ImageContent·VideoContent·TextContent 와 같은 패키지에서 연습하면 돼요. 부모 Content 가 어떻게 빈칸을 걸고 자식이 어떻게 채웠는지 곁눈질로 참고하되, 새로 만드는 부분은 직접 손으로 짜보는 게 핵심이에요.
과제 1: [기본] Content 계열에 네 번째 자식 추가하기
Step 6에서 사진·영상·텍스트 세 자식을 한 배열에 담아 출력했죠. 이번엔 네 번째 종류를 직접 만들어, "부모는 한 줄도 안 건드려도 새 콘텐츠가 추가된다" 를 눈으로 확인하는 과제예요.
해야 할 일:
링크를 공유하는 콘텐츠 LinkContent 를 새로 만들어 Content 를 상속하고, 부모가 걸어둔 빈칸 두 개를 채우세요.
요구사항:
LinkContent는Content를extends로 물려받으세요. (abstract는 붙이지 않아요. 빈칸을 다 채울 거니까요.)- 링크만의 고유 필드
private String url;을 추가하세요. - 생성자는 첫 줄에서
super(authorName, likeCount);로 공통 필드를 채우고, 그다음url을 채우세요. @Override로getType()을 채워"링크"를 돌려주세요.@Override로preview()를 채워 작성자 이름과 링크 주소가 함께 보이는 문구를 돌려주세요. (예:getAuthorName() + " 님의 링크: " + url)ContentDemoMain의Content[]배열에new LinkContent(...)를 한 줄 추가하고 실행해, 링크 콘텐츠도 같은render()로 한 줄이 출력되는지 확인하세요.
힌트:
ImageContent를 그대로 옆에 두고 따라 만들면 가장 쉬워요. 구조가 거의 같고 고유 필드만imageUrl→url로 바뀌는 정도예요.- 부모
Content와 다른 자식들(ImageContent등)은 한 글자도 안 고쳐도 돼요. 자식 하나를 새로 만들고 배열에 한 줄 추가하는 것만으로 끝나는지 확인해보세요. 이게 추상 클래스 설계의 힘이에요.
과제 2: [응용] 새로운 추상 부모 도메인 "알림(Notification)" 설계하기
오늘은 이미 만들어진 Content 의 구조를 따라갔지만, 이번엔 추상 부모를 처음부터 직접 설계해보는 과제예요.
인스타그램에는 "회원님을 팔로우했습니다", "회원님 게시물을 좋아합니다" 같은 알림이 종류별로 뜨죠. 알림 문구는 종류마다 다르지만, 받는 사람과 알림 한 줄을 포맷하는 틀은 공통이에요. 딱 추상 클래스로 표현하기 좋은 구조죠.
해야 할 일:
추상 클래스 Notification 을 부모로 두고, 종류별 알림 자식 두세 개를 만들어 같은 방식으로 출력해보세요.
요구사항:
- 추상 클래스
Notification을 만드세요. 모든 알림이 공통으로 갖는 필드private String receiverName;(알림 받는 사람)을 넣고, 생성자로 채우세요. - 알림 문구는 종류마다 다르니, abstract 메서드
public abstract String message();를 빈칸으로 걸어두세요. - 알림 한 줄을 포맷하는 템플릿 메서드(예:
public String render())를 부모에 만드세요. 받는 사람 이름과message()를 조합한 한 줄을 돌려주면 돼요. (예:"[" + receiverName + "] " + message()) - 자식으로
FollowNotification(팔로우 알림),LikeNotification(좋아요 알림)을 만들고, 각각message()빈칸을 종류에 맞는 문구로 채우세요. (댓글 알림CommentNotification까지 만들면 더 좋아요.) - 자식들을
Notification[]배열에 담아 향상된 for 로 순회하며render()를 출력하세요.
힌트:
- 무엇을 빈칸(abstract 메서드)으로 두고 무엇을 부모의 완성 메서드로 둘지 스스로 나눠보는 게 이번 과제의 진짜 목적이에요. "종류마다 달라지는 것은 빈칸, 모든 알림이 똑같이 쓰는 것은 부모 완성 메서드" 기준으로 가르면 돼요.
message()안에서 각 자식이 자기 상황에 맞는 문구(예: 팔로우 알림은 "회원님을 팔로우하기 시작했습니다")를 돌려주게 하면,render()한 줄로 종류별 알림이 다르게 출력될 거예요.
과제 3: [심화] 부모에 빈칸을 하나 더 걸었을 때 모든 자식이 받는 영향 처리하기
추상 메서드의 강제력을 거꾸로 체감해보는 과제예요. 부모에 빈칸을 새로 하나 걸면, 그 부모를 상속한 모든 자식이 그 빈칸을 채워야만 하죠. 이번엔 그 강제력이 실제로 어떻게 번지는지 직접 겪어볼 거예요.
해야 할 일:
Content 에 "이 콘텐츠가 재생 가능한가?" 를 알려주는 abstract 메서드를 새로 추가하고, 세 자식이 각자 알맞게 채우도록 만드세요.
요구사항:
Content에 abstract 메서드public abstract boolean isPlayable();를 새로 추가하세요. (본문 없이;으로 끝나는 빈칸이에요.)- 이 빈칸을 추가한 직후,
ImageContent·VideoContent·TextContent세 자식에 어떤 일이 생기는지 먼저 관찰하세요. (컴파일 에러가 어디에, 무슨 메시지로 뜨는지 눈으로 확인해보세요.) - 세 자식 모두
@Override로isPlayable()을 채우세요. 영상(VideoContent)만true를 돌려주고, 이미지와 텍스트는false를 돌려주면 돼요. ContentDemoMain에서 배열을 순회하며, 재생 가능한 콘텐츠일 때만 추가 문구(예:" ▶ 재생 가능")를 함께 출력하도록 확장해보세요.
힌트:
- 빈칸을 추가하자마자 세 자식 모두 빨간 줄이 뜰 거예요. Step 2에서 봤던 그 강제력이 한꺼번에 세 곳에 번지는 셈이죠. 이게 "빈칸을 추가하면 모든 자식이 영향받는다" 는 추상 메서드의 양면성이에요.
- 출력 확장은 향상된 for 안에서
if (content.isPlayable())로 분기하면 돼요. 부모 타입 변수(content)로도isPlayable()을 부를 수 있는 건, 그게 부모Content에 선언된 메서드이기 때문이에요. (동적 디스패치로 실제 자식 버전이 불려요.)
생각해볼 주제
오늘 배운 추상 클래스 너머의 이야기를 세 가지 던져드릴게요. 정답이 정해진 질문이 아니에요. 직접 코드를 떠올리며 본인의 답을 만들어보세요.
1. 자식이 있다고 무조건 부모를 추상으로 만들어야 할까?
오늘 Content 는 자식들이 직접 만들어 써야 하니 추상 클래스로 뒀어요. 그런데 지난 시간에 만든 Member 는 AdminMember·PremiumMember 라는 자식이 있는데도 일반 클래스였죠.
둘 다 부모-자식 구조인데 한쪽은 추상, 한쪽은 일반이에요.
이 차이를 가른 기준이 뭐였을까요? "일반 회원 그 자체로도 완전한 의미가 있다" 와 "콘텐츠 자체는 종류 없이 만들면 불완전하다" 의 차이를 본인의 말로 정리해보세요.
그리고 거꾸로, 만약 누군가 Member 를 억지로 추상 클래스로 바꾼다면 어떤 불편이 생길지도 상상해보세요. 추상으로 만드는 게 언제 도움이 되고 언제 거추장스러운 족쇄가 되는지, 그 경계에 대한 본인의 감을 잡아보는 게 목표예요.
2. abstract 메서드의 강제력은 항상 좋기만 할까?
abstract 메서드의 가장 큰 장점은 강제력이에요. 자식이 빈칸을 빠뜨리면 자바가 컴파일 단계에서 막아주니, 꼭 필요한 메서드를 깜빡하는 실수를 원천 봉쇄하죠. 그런데 이 강제력에는 다른 얼굴도 있어요.
심화 과제에서 살짝 맛봤듯, 부모에 빈칸을 하나 새로 걸면 그 부모를 상속한 모든 자식이 그 빈칸을 채워야만 해요. 자식이 3개면 3곳, 10개면 10곳을 다 손봐야 하죠. "빈칸을 안 채우면 막아주는 안전함" 과 "빈칸이 늘면 모든 자식을 손봐야 하는 부담", 이 둘은 사실 같은 동전의 양면이에요. 자식이 아주 많은 상황에서 부모에 새 abstract 메서드를 추가해야 한다면, 여러분은 이 트레이드오프를 어떻게 다룰지 고민해보세요. 빈칸을 꼭 추가해야 하는지, 아니면 다른 방법(예: 부모에 기본 동작이 있는 일반 메서드로 두고 필요한 자식만 덮어쓰기)이 더 나을지 비교해보면 좋아요.
3. 공통 틀을 부모가 쥐고 있는 게 항상 좋을까?
템플릿 메서드(render())의 매력은 "한곳만 고치면 다 바뀐다" 예요. 부모의 render() 형식을 한 번 바꾸면, 사진·영상·텍스트 모든 자식의 출력이 일제히 바뀌죠. 형식을 통일된 채로 유지하기에 더없이 좋아요.
그런데 이 "한곳만 고치면 다 바뀜" 은 양날의 검이기도 해요.
만약 어떤 콘텐츠 하나만 다른 형식으로 보여줘야 하는 요구가 생긴다면 어떨까요? 공통 틀에 묶여 있어서 그 하나만 예외로 빼기가 까다로울 수 있어요. 반대로 부모 틀을 잘못 건드리면 멀쩡하던 모든 자식의 출력이 한꺼번에 망가질 위험도 있고요. 공통 틀을 부모에 모으는 설계가 빛나는 상황과, 오히려 발목을 잡는 상황을 각각 떠올려보세요. "공통화는 어디까지가 적당한가" 라는, 개발자가 평생 고민하는 질문의 출발점이랍니다.
✅ 예시 답안정답 보기
오늘 배운 추상 클래스, abstract 메서드, @Override, 템플릿 메서드를 손에 익히는 답안이에요.
정답이 하나뿐인 건 아니에요. 아래 코드는 "이렇게 풀면 깔끔하다" 는 모범 사례 중 하나로 봐주세요.
모두 코드베이스의 Content·ImageContent·VideoContent·TextContent 를 곁눈질로 참고하며 짜면 돼요. 부모가 어떻게 빈칸을 걸고 자식이 어떻게 채웠는지 따라 보면 길이 보일 거예요.
과제 1 예시답안: Content 계열에 네 번째 자식 추가하기
링크를 공유하는 콘텐츠 LinkContent 를 새로 만들어 Content 를 상속하고, 부모가 걸어둔 빈칸 두 개(getType·preview)를 채우는 과제예요.
핵심 접근
이 과제의 진짜 목표는 코드를 많이 짜는 게 아니라, "부모를 한 글자도 안 고쳐도 새 종류가 추가된다" 를 눈으로 보는 거예요.
ImageContent 를 옆에 두고 거의 그대로 따라 만들되, 고유 필드만 imageUrl → linkUrl 로 바꾸면 돼요. 그리고 @Override 로 부모의 빈칸 getType()·preview() 두 개를 채우면, 부모 Content 가 이미 갖고 있는 render() 템플릿이 자동으로 링크 콘텐츠도 한 줄로 그려줘요.
예시 구현
// com/instagram/javabasic/solution/day12/LinkContent.java
// 과제 1 — Content 계열의 네 번째 자식이에요.
import com.instagram.javabasic.domain.content.Content;
public class LinkContent extends Content {
// 링크 콘텐츠만 추가로 갖는 정보 — 공유하는 주소
private String linkUrl;
// 생성자 — 첫 줄 super(...) 로 부모의 공통 필드(작성자·좋아요)를 먼저 채우고,
// 그 다음 링크만의 필드를 채워요.
public LinkContent(String authorName, int likeCount, String linkUrl) {
super(authorName, likeCount);
this.linkUrl = linkUrl;
}
public String getLinkUrl() {
return linkUrl;
}
// 부모의 빈칸을 채워요 — 링크의 종류 이름
@Override
public String getType() {
return "링크";
}
// 부모의 빈칸을 채워요 — 링크는 작성자와 공유한 주소를 미리보기로 보여줘요
@Override
public String preview() {
return getAuthorName() + " 님이 공유한 링크: " + linkUrl;
}
}
ContentDemoMain 처럼 배열에 한 줄만 추가해 실행해보면 돼요.
// com/instagram/javabasic/solution/day12/LinkContentDemoMain.java
Content[] feed = {
new ImageContent("minji", 120, "beach.jpg"),
new VideoContent("jaehoon", 340, "trip.mp4", 45),
new TextContent("seungwoo", "오늘 날씨가 정말 좋네요 산책하기 딱 좋은 하루"),
new LinkContent("minji", 8, "https://spartacodingclub.kr")
};
// 같은 render() 만 부르는데, 실제 종류에 따라 채워진 빈칸이 달라
// 종류별로 다른 한 줄이 나와요 (다형성 + 템플릿 메서드).
for (Content content : feed) {
System.out.println(content.render());
}
코드 해설
여기서 눈여겨볼 건 세 가지예요.
첫째, extends Content 만 했지 abstract 는 안 붙였어요. 빈칸을 다 채울 거니까 LinkContent 는 직접 new 할 수 있는 완성된 클래스예요. 부모 Content 가 abstract 인 것과 대비돼요.
둘째, render() 를 만들지 않았어요. 그런데도 출력이 다른 콘텐츠와 똑같은 형식으로 나와요. 부모 Content.render() 가 "[" + getType() + "] " + preview() + " (♥ " + likeCount + ")" 라는 틀을 이미 쥐고 있고, 그 안의 getType()·preview() 만 자식이 채운 버전으로 불리거든요. 이게 템플릿 메서드의 힘이에요.
셋째, 부모 Content 와 다른 자식들(ImageContent 등)은 한 글자도 안 고쳤어요. 자식 파일 하나를 새로 만들고 배열에 한 줄 추가한 게 전부예요.
실행 결과
[이미지] minji 님의 사진: beach.jpg (♥ 120)
[영상] jaehoon 님의 영상 (45초) (♥ 340)
[텍스트] 오늘 날씨가 정말 ... (♥ 12)
[링크] minji 님이 공유한 링크: https://spartacodingclub.kr (♥ 8)
맨 아래 링크 줄이 위 세 줄과 정확히 같은 형식([종류] 미리보기 (♥ 좋아요))으로 합류했죠? 부모 틀을 그대로 쓴 증거예요. 이 동작은 코드베이스 Day12SolutionTest 에서 검증되어 있어요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 부모 무수정 | 부모 Content 와 기존 자식들을 한 글자도 안 고쳤는가 |
상 |
| 두 빈칸 구현 | getType()·preview() 두 abstract 메서드를 모두 채웠는가 |
상 |
super(...) 호출 |
생성자 첫 줄에서 super(authorName, likeCount) 로 공통 필드를 채웠는가 |
중 |
@Override 표기 |
채운 두 메서드에 @Override 를 붙였는가 |
중 |
| 배열 합류 확인 | Content[] 배열에 LinkContent 를 담아 같은 render() 로 출력했는가 |
중 |
render() 미작성 |
자식이 render() 를 다시 만들지 않았는가 (부모 템플릿 재사용) |
하 |
흔한 실수
- 자식에
render()를 또 만듦 → 부모가 이미 완성한 메서드라 다시 만들 필요가 없어요. 자식은 빈칸(getType·preview)만 채우면 부모 틀이 알아서 그려줘요. getType()만 채우고preview()를 빠뜨림 → 빈칸 두 개 중 하나라도 안 채우면 "abstract method preview() is not implemented" 컴파일 에러가 나요. 둘 다 채워야 클래스가 완성돼요.super(...)없이 필드를 직접 채우려 함 →authorName·likeCount는 부모의private필드라 자식이 직접 못 건드려요. 반드시 생성자 첫 줄에서super(...)로 부모에게 맡겨야 해요.
실무 개선 포인트 (심화)
지금은 콘텐츠를 코드 안에서 new 로 직접 만들어 배열에 손으로 담았어요. 실제 서비스라면 피드에 콘텐츠가 수백 개라 이렇게 일일이 못 담죠.
또 콘텐츠 종류가 계속 늘어나면(스토리·릴스·라이브…) 자식 파일도 같이 늘어나요. 그래도 핵심은 그대로예요 — 부모는 안 건드리고 자식만 더한다는 점이요. 종류가 아무리 늘어도 render() 를 부르는 쪽 코드는 한 줄도 안 바뀐다는 게 이 설계의 진짜 가치예요.
과제 2 예시답안: 새로운 추상 부모 도메인 "알림(Notification)" 설계하기
이번엔 이미 만들어진 구조를 따라가는 게 아니라, 추상 부모를 처음부터 직접 설계하는 과제예요. 추상 클래스 Notification 을 부모로 두고, 종류별 알림 자식들을 만들어 같은 방식으로 출력해봐요.
핵심 접근
이 과제의 진짜 목적은 "무엇을 빈칸(abstract)으로 두고 무엇을 부모의 완성 메서드로 둘지" 를 스스로 가르는 연습 이에요.
기준은 단순해요. "종류마다 달라지는 것은 빈칸, 모든 알림이 똑같이 쓰는 것은 부모 완성 메서드" 예요.
알림 문구(message())는 팔로우·좋아요·댓글마다 다르니 abstract 빈칸으로 걸고, 알림 한 줄을 조립하는 틀(render())은 모든 알림이 똑같이 쓰니 부모가 완성해서 물려줘요. 받는 사람·시점 같은 공통 정보도 부모가 한 번만 갖고요.
예시 구현
먼저 추상 부모 Notification 이에요.
// com/instagram/javabasic/solution/day12/Notification.java
// 과제 2 — 콘텐츠와는 무관한 새 도메인의 "뼈대 부모" 예요.
public abstract class Notification {
// 모든 알림이 공통으로 갖는 정보 — 자식이 super(...) 로 채워요.
private String receiverName; // 받는 사람
private String createdAgo; // 언제 왔는지 (예: "5분 전") — 시간 API 없이 글자로만
public Notification(String receiverName, String createdAgo) {
this.receiverName = receiverName;
this.createdAgo = createdAgo;
}
// abstract 메서드 — 종류마다 다른 알림 문구. 본문 없이 세미콜론으로 끝나는 빈칸이에요.
public abstract String message();
// 템플릿 메서드 — 알림 한 줄을 조립하는 틀은 부모가 정하고, 가운데 문구만 자식에 맡겨요.
public String render() {
return "[" + receiverName + "] " + message() + " (" + createdAgo + ")";
}
public String getReceiverName() {
return receiverName;
}
public String getCreatedAgo() {
return createdAgo;
}
}
다음은 종류별 자식 셋이에요. 각자 부모의 빈칸 message() 만 자기 종류에 맞게 채워요.
// com/instagram/javabasic/solution/day12/FollowNotification.java
public class FollowNotification extends Notification {
private String actorName; // 팔로우한 사람
public FollowNotification(String receiverName, String createdAgo, String actorName) {
super(receiverName, createdAgo);
this.actorName = actorName;
}
@Override
public String message() {
return actorName + "님이 회원님을 팔로우했습니다";
}
}
// com/instagram/javabasic/solution/day12/LikeNotification.java
public class LikeNotification extends Notification {
private String actorName; // 좋아요를 누른 사람
public LikeNotification(String receiverName, String createdAgo, String actorName) {
super(receiverName, createdAgo);
this.actorName = actorName;
}
@Override
public String message() {
return actorName + "님이 회원님의 게시물을 좋아합니다";
}
}
// com/instagram/javabasic/solution/day12/CommentNotification.java
// 댓글 알림은 행위자 외에 댓글 내용까지 보여주는 게 자연스러워, 고유 필드를 두 개 가져요.
public class CommentNotification extends Notification {
private String actorName;
private String commentText;
public CommentNotification(String receiverName, String createdAgo, String actorName, String commentText) {
super(receiverName, createdAgo);
this.actorName = actorName;
this.commentText = commentText;
}
@Override
public String message() {
return actorName + "님이 댓글을 남겼습니다: " + commentText;
}
}
마지막으로 셋을 Notification[] 배열에 담아 같은 render() 만 불러요.
// com/instagram/javabasic/solution/day12/NotificationDemoMain.java
Notification[] alerts = {
new FollowNotification("minji", "5분 전", "jaehoon"),
new LikeNotification("minji", "12분 전", "seungwoo"),
new CommentNotification("minji", "1시간 전", "yujin", "사진 너무 예뻐요!")
};
// 향상된 for 로 순회하며 같은 render() 만 불러요.
for (Notification alert : alerts) {
System.out.println(alert.render());
}
코드 해설
설계의 핵심은 부모와 자식의 책임을 가른 방식이에요. 그림으로 보면 이래요.
Notification (추상 부모)
┌───────────────────────┴───────────────────────┐
│ 공통으로 갖는 것 (부모가 한 번만) │
│ - receiverName, createdAgo 필드 │
│ - render() 틀: "[받는사람] {문구} (시점)" │
│ 종류마다 다른 것 (빈칸 → 자식이 채움) │
│ - message() ← abstract │
└───────────────────────┬───────────────────────┘
│ │ │
FollowNotification LikeNotification CommentNotification
message()= message()= message()=
"...팔로우했습니다" "...좋아합니다" "...남겼습니다: 내용"
render() 는 부모가 한 번만 만들었는데, 가운데 message() 자리에 자식마다 다른 문구가 끼워져요. 그래서 for 문에서 똑같은 alert.render() 를 부르는데도 종류별로 다른 한 줄이 나와요. 이게 다형성과 템플릿 메서드가 만나는 지점이에요.
FollowNotification·LikeNotification 은 고유 필드가 actorName 하나지만, CommentNotification 은 댓글 내용까지 보여줘야 해서 commentText 까지 두 개를 가져요. 자식마다 필요한 만큼 고유 필드를 자유롭게 가질 수 있다는 것도 함께 봐두세요.
실행 결과
[minji] jaehoon님이 회원님을 팔로우했습니다 (5분 전)
[minji] seungwoo님이 회원님의 게시물을 좋아합니다 (12분 전)
[minji] yujin님이 댓글을 남겼습니다: 사진 너무 예뻐요! (1시간 전)
[받는사람] {문구} (시점) 이라는 틀은 셋 다 똑같고, 가운데 문구만 종류별로 달라졌죠. 부모가 틀을, 자식이 문구를 책임진 결과예요. 이 동작은 코드베이스 Day12SolutionTest 에서 검증되어 있어요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 책임 분리 | 종류마다 다른 message() 는 abstract, 공통 render() 는 부모 완성 메서드로 갈랐는가 |
상 |
| abstract 부모 | Notification 을 abstract class 로 선언하고 message() 를 abstract 빈칸으로 걸었는가 |
상 |
| 템플릿 메서드 | 부모 render() 가 message() 를 끼워 한 줄을 조립하는가 |
상 |
| 자식 2개 이상 | FollowNotification·LikeNotification 등 자식을 둘 이상 만들어 message() 를 채웠는가 |
중 |
| 배열 순회 | Notification[] 배열에 담아 향상된 for 로 같은 render() 를 불렀는가 |
중 |
| 댓글 알림 | CommentNotification 까지 만들어 고유 필드 두 개를 다뤘는가 (선택, 있으면 가점) |
하 |
흔한 실수
render()까지 abstract 로 걸어버림 → 그러면 자식 셋이 전부render()를 똑같이 다시 만들어야 해서 중복이 생겨요. 모든 알림이 똑같이 쓰는 틀은 부모가 완성해서 한 번만 두는 게 맞아요.message()를 부모에 본문까지 만들어둠 → 종류마다 달라지는 문구를 부모가 미리 정하면, 자식이 종류를 바꿀 수가 없어요. 달라지는 부분은 빈칸으로 비워둬야 자식이 채울 수 있어요.new Notification(...)으로 알림을 직접 만들려 함 →abstract클래스라 직접new가 안 돼요. "무슨 알림인지" 가 정해진 자식(FollowNotification등)만 만들 수 있어요. 이게 abstract 로 막아두는 이유예요.
실무 개선 포인트 (심화)
알림 종류는 실제 서비스에서 정말 많아져요(멘션·태그·라이브 시작·생일…). 그래도 이 설계는 그대로 버텨요. 새 알림은 Notification 을 상속하고 message() 만 채운 자식 파일 하나를 더하면 끝이고, render() 와 출력하는 쪽 코드는 안 건드려도 되거든요.
지금은 createdAgo 를 "5분 전" 같은 글자로 직접 넣었지만, 실제로는 알림이 만들어진 시각을 기록해두고 "지금으로부터 얼마 전인지" 를 계산해 보여줘요. 그 시간 계산 도구는 나중 Phase 에서 배우니, 지금은 "시점도 부모가 공통으로 들고 있으면 편하구나" 정도만 느껴두면 충분해요.
과제 3 예시답안: 부모에 빈칸을 하나 더 걸었을 때 모든 자식이 받는 영향 처리하기
추상 메서드의 강제력을 거꾸로 체감하는 과제예요. 부모에 빈칸(abstract 메서드)을 새로 하나 걸면, 그 부모를 상속한 모든 자식이 그 빈칸을 채워야만 컴파일이 돼요. 이 강제력이 실제로 어떻게 번지는지 직접 겪어봐요.
핵심 접근
발제는 Content 에 빈칸을 더하라고 했지만, 그러면 과제 1에서 쓰던 Content 와 그 자식들까지 전부 영향을 받아 실습이 꼬일 수 있어요. 그래서 예시답안에서는 연습 전용 부모 MediaItem 을 따로 두고, getType·preview 두 빈칸에 더해 isPlayable() 빈칸까지 셋을 건 다음, 세 자식(ImageItem·VideoItem·TextItem)이 셋을 모두 채우도록 만들었어요. 핵심 체험은 같아요 — 빈칸 하나를 부모에 더했더니 세 자식 전부가 그걸 구현해야만 빨간 줄이 사라진다 는 거예요.
여러분이 발제대로 Content 에 바로 isPlayable() 을 추가해도 결과 체험은 동일해요. 어느 쪽이든 "부모가 빈칸을 늘리면 모든 자식이 구현을 강제당한다" 를 눈으로 보는 게 목표예요.
예시 구현
먼저 빈칸 셋을 건 부모 MediaItem 이에요. 세 번째 빈칸 isPlayable() 이 이번 과제의 주인공이에요.
// com/instagram/javabasic/solution/day12/MediaItem.java
public abstract class MediaItem {
private String authorName;
public MediaItem(String authorName) {
this.authorName = authorName;
}
// ===== abstract 메서드 셋 — 자식이 모두 채워야 하는 빈칸이에요 =====
public abstract String getType();
public abstract String preview();
// 새로 추가한 빈칸 — 재생 가능한 미디어인가? (영상만 true)
// 이 한 줄을 부모에 더하는 순간, 아래 세 자식 전부 isPlayable() 을 구현해야 해요.
public abstract boolean isPlayable();
public String getAuthorName() {
return authorName;
}
}
세 자식은 빈칸 셋을 모두 채우는데, isPlayable() 만 종류에 따라 답이 갈려요. 영상만 true 예요.
// com/instagram/javabasic/solution/day12/ImageItem.java
public class ImageItem extends MediaItem {
private String imageUrl;
public ImageItem(String authorName, String imageUrl) {
super(authorName);
this.imageUrl = imageUrl;
}
@Override
public String getType() {
return "이미지";
}
@Override
public String preview() {
return getAuthorName() + " 님의 사진: " + imageUrl;
}
// 이미지는 재생 대상이 아니에요 — false
@Override
public boolean isPlayable() {
return false;
}
}
// com/instagram/javabasic/solution/day12/VideoItem.java
public class VideoItem extends MediaItem {
private String videoUrl;
private int durationSeconds;
public VideoItem(String authorName, String videoUrl, int durationSeconds) {
super(authorName);
this.videoUrl = videoUrl;
this.durationSeconds = durationSeconds;
}
@Override
public String getType() {
return "영상";
}
@Override
public String preview() {
return getAuthorName() + " 님의 영상 (" + durationSeconds + "초)";
}
// 영상은 재생할 수 있어요 — true (셋 중 유일하게 true)
@Override
public boolean isPlayable() {
return true;
}
}
TextItem 도 같은 방식인데, 미리보기에서 글이 10글자보다 길면 앞 10글자만 자르고 ... 을 붙여요.
// com/instagram/javabasic/solution/day12/TextItem.java
public class TextItem extends MediaItem {
private String body;
public TextItem(String authorName, String body) {
super(authorName);
this.body = body;
}
@Override
public String getType() {
return "텍스트";
}
@Override
public String preview() {
if (body.length() > 10) {
return body.substring(0, 10) + "...";
}
return body;
}
// 텍스트는 재생 대상이 아니에요 — false
@Override
public boolean isPlayable() {
return false;
}
}
데모에서는 배열을 순회하며 isPlayable() 결과에 따라 출력 문구를 갈라요.
// com/instagram/javabasic/solution/day12/MediaItemDemoMain.java
MediaItem[] items = {
new ImageItem("minji", "beach.jpg"),
new VideoItem("jaehoon", "trip.mp4", 45),
new TextItem("seungwoo", "오늘 날씨가 정말 좋네요 산책하기 딱 좋은 하루")
};
// 같은 isPlayable() 을 부르는데 종류마다 결과가 달라요.
// 영상만 true 라 "재생 가능" 으로, 나머지는 "재생 불가" 로 갈려요.
for (MediaItem item : items) {
if (item.isPlayable()) {
System.out.println("[" + item.getType() + "] " + item.preview() + " → 재생 가능");
} else {
System.out.println("[" + item.getType() + "] " + item.preview() + " → 재생 불가");
}
}
코드 해설
이 과제에서 꼭 직접 겪어봤으면 하는 순간이 있어요. 부모 MediaItem 에 isPlayable() 한 줄을 추가하고, 자식 셋은 아직 안 고친 그 시점이에요. 그 순간 IntelliJ 에서 세 자식 파일 모두에 빨간 줄이 떠요.
부모에 빈칸 추가:
public abstract boolean isPlayable(); ← 이 한 줄을 더한 순간
세 자식에 동시에 빨간 줄:
ImageItem → "abstract method isPlayable() is not implemented" ❌
VideoItem → "abstract method isPlayable() is not implemented" ❌
TextItem → "abstract method isPlayable() is not implemented" ❌
│
└─ 빈칸 하나가 세 자식 전부에게 "너도 채워" 라고 강제!
Step 2 에서 봤던 그 강제력이 한꺼번에 세 곳에 번지는 거예요. 자식이 셋이면 세 곳, 열이면 열 곳을 다 손봐야 해요. 이게 "빈칸을 추가하면 모든 자식이 영향받는다" 는 추상 메서드의 양면성이에요. 안 채우면 막아주니 안전하지만, 빈칸이 늘면 모든 자식을 손봐야 하는 부담이 따라와요.
출력 분기에서 item.isPlayable() 을 부르는 부분도 봐두세요. item 의 타입은 부모 MediaItem 인데도, 실제 객체가 영상이면 VideoItem 의 true 가, 이미지면 ImageItem 의 false 가 불려요. 부모 타입 변수로도 isPlayable() 을 부를 수 있는 건 그게 부모 MediaItem 에 선언된 메서드이기 때문이고, 실제로는 자식 버전이 불리는 게 동적 디스패치예요.
실행 결과
[이미지] minji 님의 사진: beach.jpg → 재생 불가
[영상] jaehoon 님의 영상 (45초) → 재생 가능
[텍스트] 오늘 날씨가 정말 ... → 재생 불가
영상만 재생 가능 으로, 이미지와 텍스트는 재생 불가 로 갈렸죠. 같은 isPlayable() 호출인데 종류마다 답이 다른 거예요. 이 동작은 코드베이스 Day12SolutionTest 에서 검증되어 있어요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 빈칸 추가 | 부모에 public abstract boolean isPlayable(); 를 본문 없이 세미콜론으로 추가했는가 |
상 |
| 강제력 관찰 | 빈칸 추가 직후 세 자식에 컴파일 에러가 뜨는 걸 관찰·이해했는가 | 상 |
| 세 자식 모두 구현 | 세 자식이 빠짐없이 isPlayable() 을 @Override 로 채웠는가 (하나라도 빼면 컴파일 에러) |
상 |
| 값 정확성 | 영상만 true, 이미지·텍스트는 false 를 돌려주는가 |
중 |
| 출력 분기 | 순회에서 if (item.isPlayable()) 로 "재생 가능/불가" 를 갈랐는가 |
중 |
| 부모 타입 호출 | 부모 타입 변수(item)로 isPlayable() 을 불러 동적 디스패치를 활용했는가 |
하 |
흔한 실수
- 자식 하나만 빠뜨려도 전체가 컴파일 안 됨 → 세 자식 중 하나라도
isPlayable()을 안 채우면 그 파일에서 "is not implemented" 에러가 나고, 프로젝트 전체가 실행이 안 돼요. 이건 버그가 아니라 abstract 의 강제력이 정확히 동작하는 거예요 — 빠뜨린 곳을 알려주는 안전망이에요. - 빈칸에 본문을 넣어버림 →
public abstract boolean isPlayable() { return false; }처럼{ }를 붙이면 abstract 가 아니에요. abstract 빈칸은 본문 없이 세미콜론(;)으로 끝나야 해요. 본문을 넣으면 강제력이 사라져요. - 자식 타입으로 캐스팅해서 부름 →
((VideoItem) item).isPlayable()처럼 굳이 내려받을 필요가 없어요.isPlayable()은 부모MediaItem에 선언돼 있어서, 부모 타입 변수item으로 그냥 부르면 동적 디스패치로 실제 자식 버전이 불려요.
실무 개선 포인트 (심화)
부모에 새 abstract 메서드를 거는 건 강력하지만, 자식이 아주 많을 땐 모든 자식을 한꺼번에 손봐야 해서 부담이 커요. 그래서 실무에서는 "정말 모든 자식이 각자 다르게 답해야 하는가" 를 먼저 따져봐요.
예를 들어 대부분의 미디어가 재생 불가 이고 영상만 예외라면, 부모에 isPlayable() 을 기본값 false 를 돌려주는 일반 메서드로 두고, 영상만 그걸 @Override 로 덮어쓰는 방법도 있어요. 그러면 이미지·텍스트는 손 안 대도 되죠. "abstract 빈칸으로 전원 강제" 와 "기본값 두고 필요한 자식만 덮어쓰기" 중 어느 쪽이 나은지는 생각해볼 주제 2에서 더 풀어볼게요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안: 자식이 있다고 무조건 부모를 추상으로 만들어야 할까?
[문제 상황 요약]
오늘 Content 는 추상 클래스로 뒀어요. 그런데 지난 시간에 만든 Member 는 AdminMember·PremiumMember 라는 자식이 있는데도 일반 클래스였죠. 둘 다 부모-자식 구조인데 한쪽은 추상, 한쪽은 일반이에요. 이 차이를 가른 기준은 뭐였을까요?
[튜터의 가이드 및 해설]
기준을 한 문장으로 잡으면 이래요. "부모 그 자체로 완전한 의미가 있느냐" 예요.
Member 를 떠올려봐요. 자식 없이 new Member("minji", ...) 만 해도 "민지라는 일반 회원" 으로 완전해요. 일반 회원은 그 자체로 멀쩡한 회원이거든요. 관리자·프리미엄은 거기에 권한이 더 붙은 특수한 종류일 뿐이에요. 그래서 Member 는 직접 만들어 쓸 수 있는 일반 클래스로 두는 게 자연스러워요.
반대로 Content 는 어떤가요? new Content("minji", 120) 만 하면 "무슨 콘텐츠인지" 를 알 수가 없어요. 이미지인지 영상인지 정해지지 않으면 미리보기(preview())를 어떻게 그릴지도 못 정하죠. 콘텐츠는 반드시 "이미지 / 영상 / 텍스트" 같은 종류가 정해져야 비로소 완전해져요. 그래서 Content 자체는 직접 만들지 못하게 abstract 로 막고, 종류가 정해진 자식만 만들게 한 거예요.
판단 기준을 이렇게 정리해볼 수 있어요.
- Option A — 일반 클래스로 두기: 부모가 그 자체로 완전한 의미를 가질 때. 예: 일반 회원은 그대로도 완전한 회원. 장점은 부모도 직접 만들어 쓸 수 있어 유연함. 단점은 "빈칸을 반드시 채워라" 는 강제력이 없음.
- Option B — 추상 클래스로 두기: 부모만으로는 불완전해서 종류가 꼭 정해져야 할 때. 예: 콘텐츠는 종류 없이 못 만듦. 장점은 직접 생성을 막고 자식에게 빈칸 구현을 강제함. 단점은 부모를 직접 못 만들어 살짝 빡빡함.
거꾸로 상상해볼까요? 만약 누군가 Member 를 억지로 추상으로 바꾸면, new Member(...) 가 막혀서 "일반 회원" 을 표현할 길이 없어져요. 일반 회원을 만들려고 NormalMember 같은 자식을 또 따로 만들어야 하는 거추장스러운 상황이 생기죠. 멀쩡히 완전한 부모를 억지로 추상으로 만들면 이렇게 족쇄가 돼요.
현업 감각으로는 "이 부모를 직접 new 했을 때 말이 되는가" 를 자문해봐요. 말이 되면 일반 클래스, "그건 종류가 정해져야 말이 되는데?" 싶으면 추상 클래스예요.
🎯 면접관을 홀리는 핵심 멘트
"자식이 있다는 사실만으로 부모를 추상으로 만들지는 않습니다. 기준은 '부모를 직접 생성했을 때 그 자체로 완전한 의미가 있는가' 입니다. 일반 회원처럼 부모 그대로도 완전하면 일반 클래스로 두고, 콘텐츠처럼 종류가 정해지지 않으면 불완전한 경우에만 추상 클래스로 직접 생성을 막습니다. 완전한 부모를 억지로 추상으로 만들면 일반 객체를 표현할 길이 막혀 오히려 거추장스러운 족쇄가 됩니다."
생각해볼 주제 2 예시답안: abstract 메서드의 강제력은 항상 좋기만 할까?
[문제 상황 요약]
abstract 메서드의 가장 큰 장점은 강제력이에요. 자식이 빈칸을 빠뜨리면 자바가 컴파일 단계에서 막아주니, 꼭 필요한 메서드를 깜빡하는 실수를 원천 봉쇄하죠. 그런데 심화 과제에서 봤듯, 부모에 빈칸을 하나 새로 걸면 그 부모를 상속한 모든 자식이 그 빈칸을 채워야만 해요. 자식이 3개면 3곳, 10개면 10곳을 다 손봐야 하죠. 이 강제력, 항상 좋기만 할까요?
[튜터의 가이드 및 해설]
먼저 강제력이 빛나는 경우를 떠올려봐요. 콘텐츠의 preview() 처럼 종류마다 반드시 다르게 답해야 하는 메서드라면, abstract 빈칸이 딱이에요. 새 콘텐츠를 만들면서 미리보기를 깜빡하면 컴파일러가 "너 이거 안 채웠어" 하고 막아주니까요. 빠뜨릴 수가 없어요.
문제는 자식이 아주 많은데, 새로 거는 빈칸이 대부분의 자식에게는 답이 똑같은 경우예요. 심화 과제의 isPlayable() 을 떠올려봐요. 영상만 true 이고 이미지·텍스트는 둘 다 false 였죠. 만약 미디어 종류가 20개인데 그중 19개가 false 라면, abstract 로 걸면 19개 자식에 똑같은 return false; 를 일일이 적어야 해요. 강제력이 오히려 노동이 되는 거예요.
이럴 때 쓸 수 있는 다른 길이 있어요.
- Option A — abstract 빈칸으로 전원 강제: 부모는
public abstract boolean isPlayable();만 선언. 장점은 모든 자식이 자기 답을 명시하게 강제돼 깜빡 실수가 없음. 단점은 자식이 많으면 전부 손봐야 하고, 답이 같은 자식에도 중복 코드가 생김. - Option B — 기본값 둔 일반 메서드 + 필요한 자식만 덮어쓰기: 부모에
public boolean isPlayable() { return false; }처럼 기본 동작을 두고, 영상처럼 다른 자식만@Override. 장점은 기본값과 같은 자식은 손 안 대도 됨. 단점은 강제력이 없어 "이 자식은 일부러 기본값을 쓴 건지, 깜빡한 건지" 가 코드만 봐선 안 드러남.
현업에서는 보통 이렇게 갈라요. "모든 자식이 반드시 자기 답을 정해야 하는 것" 은 abstract 로 강제하고, "대부분 기본값이고 예외만 다른 것" 은 기본 동작을 둔 일반 메서드로 둔다 예요. preview() 처럼 종류마다 무조건 달라야 하는 건 abstract 가 맞고, isPlayable() 처럼 대부분 false 이고 영상만 예외라면 기본값을 두는 쪽이 자식 수정 부담을 확 줄여줘요.
핵심은 "강제력" 과 "수정 부담" 이 같은 동전의 양면이라는 걸 아는 거예요. 안 채우면 막아주는 안전함을 얻는 대신, 빈칸이 늘면 모든 자식을 손봐야 하는 비용을 치르는 거죠. 그래서 부모에 새 abstract 를 걸기 전에 "이게 정말 모든 자식이 각자 답해야 하는 건가?" 를 한 번 자문해보면 좋아요.
🎯 면접관을 홀리는 핵심 멘트
"abstract 메서드의 강제력과 수정 부담은 같은 동전의 양면입니다. 빈칸을 안 채우면 컴파일러가 막아주는 안전함을 얻는 대신, 부모에 빈칸을 늘리면 상속한 모든 자식을 손봐야 하죠. 그래서 저는 '모든 자식이 반드시 자기 답을 정해야 하는 것' 만 abstract 로 강제하고, '대부분 기본값이고 예외만 다른 것' 은 부모에 기본 동작을 둔 일반 메서드로 두고 필요한 자식만 덮어씁니다. 새 abstract 를 걸기 전에 정말 전원이 각자 답해야 하는지부터 따져보는 게 자식이 많은 코드에서 특히 중요합니다."
생각해볼 주제 3 예시답안: 공통 틀을 부모가 쥐고 있는 게 항상 좋을까?
[문제 상황 요약]
템플릿 메서드(render())의 매력은 "한곳만 고치면 다 바뀐다" 예요. 부모의 render() 형식을 한 번 바꾸면 사진·영상·텍스트 모든 자식의 출력이 일제히 바뀌죠. 형식을 통일된 채 유지하기에 더없이 좋아요. 그런데 이 "한곳만 고치면 다 바뀜" 은 양날의 검이기도 해요.
[튜터의 가이드 및 해설]
먼저 빛나는 경우예요. 인스타 피드처럼 모든 콘텐츠가 같은 형식으로 보여야 하는 화면이라면 템플릿 메서드가 최고예요. 디자이너가 "피드 한 줄 형식을 [종류] 미리보기 (♥ 좋아요) 에서 다른 모양으로 바꿔주세요" 하면, 부모 render() 한 곳만 고치면 모든 종류가 일제히 새 형식으로 바뀌어요. 자식 파일은 하나도 안 건드려도 되고, 빠뜨려서 한 종류만 옛 형식으로 남는 사고도 없어요.
문제는 정반대 요구가 들어올 때예요. "광고 콘텐츠 하나만 다른 형식으로, 좋아요 수 대신 '광고' 배지를 보여주세요" 같은 요구요. 모든 자식이 부모의 한 틀에 묶여 있으니, 그 하나만 예외로 빼기가 까다로워요. 부모 틀을 건드리면 멀쩡한 다른 종류까지 영향을 받고, 그렇다고 그 자식만 render() 를 통째로 다시 만들면 통일성이 깨지죠.
또 하나의 위험은 부모 틀을 잘못 건드렸을 때예요. "한곳만 고치면 다 바뀜" 은 곧 "한곳을 망치면 다 망가짐" 이기도 해요. 부모 render() 에서 괄호 하나만 잘못 옮겨도 모든 종류의 출력이 한꺼번에 깨져요. 공통화의 힘이 클수록, 그 한 곳의 실수가 미치는 범위도 그만큼 넓어지는 거예요.
상황별로 정리해볼게요.
- Option A — 공통 틀을 부모가 강하게 쥐기 (템플릿 메서드): 모든 자식이 같은 형식이어야 할 때. 장점은 형식 변경이 한 곳으로 끝나고 통일성이 보장됨. 단점은 예외 하나 빼기가 어렵고, 부모를 잘못 고치면 전체가 한꺼번에 무너짐.
- Option B — 각 자식이 출력을 스스로 책임지기: 종류마다 형식이 제각각 달라야 할 때. 장점은 한 종류만 자유롭게 바꿔도 다른 종류에 영향 없음. 단점은 통일된 형식을 유지하려면 모든 자식을 똑같이 손봐야 해서 빠뜨리기 쉬움.
현업에서는 "형식이 통일돼야 하는 만큼만 부모가 쥔다" 가 균형점이에요. 공통이 확실한 부분(피드 한 줄 골격)은 부모 템플릿에 두되, 종류마다 진짜 달라지는 부분(preview())은 빈칸으로 자식에게 맡기는 식이죠. 오늘 render() 가 바로 이 균형이에요 — 골격은 부모가, 채워지는 내용은 자식이요. "공통화는 어디까지가 적당한가" 는 개발자가 평생 고민하는 질문이고, 정답은 "지금 통일돼야 하는 딱 그만큼" 이에요. 너무 적게 공통화하면 중복이 늘고, 너무 많이 공통화하면 예외를 못 빼서 발목이 잡혀요.
🎯 면접관을 홀리는 핵심 멘트
"템플릿 메서드의 '한곳만 고치면 다 바뀐다' 는 곧 '한곳을 망치면 다 망가진다' 이기도 합니다. 형식이 통일돼야 하는 화면에서는 부모가 틀을 쥐는 게 강력하지만, 한 종류만 예외로 빼야 하는 요구가 들어오면 공통 틀에 묶여 곤란해지죠. 그래서 저는 '지금 통일돼야 하는 딱 그만큼만' 부모가 공통 틀을 쥐게 하고, 종류마다 진짜 달라지는 부분은 빈칸으로 자식에 맡깁니다. 공통화는 적으면 중복이 늘고 많으면 예외를 못 빼니, 그 경계를 잡는 게 설계의 핵심이라고 봅니다."