Day 24 — 종합: 인스타그램 서비스 계층을 조립하다
목차 20
드디어 Phase 3 의 마지막 시간이에요. 지난 몇 주 동안 우리는 부품을 하나씩 만들어 왔어요. 사람 한 명을 담는 Member, 게시물 한 개를 담는 Post, 데이터를 모으는 컬렉션(List·Set·Map), 타입을 안전하게 다루는 제네릭, 그리고 사고를 이름으로 말하는 커스텀 예외(custom exception, 우리 서비스 전용 예외)까지요.
부품은 다 갖췄는데, 정작 "회원가입을 하면 무슨 일이 일어나는가" 같은 진짜 기능은 아직 한 번도 조립해 본 적이 없어요. 부품 상자만 가득하고 완성품이 없는 셈이죠.
오늘은 이 부품들을 한자리에 모아 인스타그램의 "두뇌" 를 조립해요. 회원가입하면 저장되고, 같은 이메일로 또 가입하면 막히고, 게시물을 쓰면 캡션이 검사되고, 팔로우하면 두 사람이 이어지는 — 그런 살아 있는 흐름을 만들어요.
특히 지난 시간에 만들어 두고 무대에 못 올린 예외 두 개, DuplicateEmailException(이메일 중복)과 InvalidCaptionException(캡션 규칙 위반)이 오늘 드디어 제 이름값을 하러 나가요. 시작해볼게요!
🎯 학습 목표
- 데이터를 보관하는 저장소(Repository) 계층을
Map기반 인메모리(in-memory, 메모리 안에서만 사는) 저장소로 직접 구현할 수 있어요. - 가입·작성 규칙 같은 판단을 맡는 서비스(Service) 계층을 설계하고, 지난 시간에 만든 커스텀 예외를 실전에서 던질 수 있어요.
- 회원가입·게시물 CRUD(생성·조회·수정·삭제)·팔로우 기능을 서비스 메서드로 구현할 수 있어요.
- 저장소·서비스·받는 쪽(콘솔)이 층층이 협력하는 3계층 구조를 한 흐름으로 조립할 수 있어요.
오늘의 로드맵
- Step 1 — 부품 점검과 3계층 설계도: 그동안 만든 부품을 펼쳐 보고,
Member에 이메일 한 칸을 더해요. - Step 2 — MemberRepository: 지난 시간 씨앗 저장소를 본격 저장소로 키워요.
- Step 3 — PostRepository: 똑같은 저장소 패턴을 한 번 더 복제해요.
- Step 4 — MemberService: 회원가입에서
DuplicateEmailException을 실전 투입해요. - Step 5 — PostService: 게시물 CRUD 에
InvalidCaptionException을 붙여요. - Step 6 — FollowService: 두 회원을 잇고, 없는 회원은 예외로 막아요.
- Step 7 — 콘솔 데모: 세 서비스를 한 화면에서 굴려봐요.
- Step 8 — 종합 회고: 반복되는 코드를 짚으며 다음 시간을 예고해요.
Step 1: 부품 점검과 3계층 설계도
본격 조립에 들어가기 전에, 우리가 가진 부품을 책상 위에 한번 펼쳐 볼게요.
Member— 사람 한 명 (이름·팔로워 수·팔로잉 묶음 등)Post— 게시물 한 개 (내용·작성자·상태)- 커스텀 예외 삼총사 —
MemberNotFoundException·DuplicateEmailException·InvalidCaptionException - 지난 시간에 씨앗으로 만든
Map<Long, Member>저장소
이 부품들을 그냥 한 클래스에 다 쏟아부으면 어떻게 될까요? 데이터를 넣고 빼는 코드, 가입 규칙을 검사하는 코드, 사용자에게 보여주는 코드가 한 덩어리로 엉켜요. 나중에 "이메일 검사만 고치고 싶은데" 할 때 어디를 건드려야 할지 막막해지죠.
그래서 실무에서는 역할을 3계층(three layer) 으로 나눠요. 각 층이 한 가지 일만 맡는 거예요.
받는 쪽 (콘솔/화면) "회원가입 해줘", "이 글 올려줘" 같은 요청을 받아 결과를 보여줘요
│ 요청을 위임
▼
서비스 (Service) "이메일이 겹치나?", "캡션이 너무 긴가?" 같은 규칙을 판단해요
│ 저장/조회만 부탁
▼
저장소 (Repository) 데이터를 넣고 꺼내기만 해요. 규칙은 따지지 않아요
│
▼
Map 사물함 실제 데이터가 메모리 안에 보관돼요
위에서 아래로 부탁이 흐르고, 아래는 위가 시킨 일만 해요. 저장소는 규칙을 모르고, 서비스는 데이터를 어디에 어떻게 담는지 신경 쓰지 않아요. 각자 자기 일에만 집중하니, 한 곳을 고쳐도 다른 곳이 흔들리지 않아요.
오늘은 이 그림을 아래에서 위로 쌓아 올려요. 먼저 저장소, 그다음 서비스, 마지막에 받는 쪽 순서로요.
그 전에 작은 준비가 하나 필요해요. 회원가입에는 이메일이 필요한데, 우리 Member 에는 아직 이메일이 없거든요. 한 칸만 더해줄게요.
// com/instagram/javabasic/domain/member/Member.java
// 이메일 — 회원가입 때 "한 사람당 하나" 로 쓰는 값이에요. 같은 이메일로 두 번 가입하는 걸 막는 열쇠가 돼요.
private String email;
그리고 가입할 때 쓸 생성자를 하나 더 만들어요. 갓 가입한 회원은 팔로워도 게시물도 0 이니, 이름과 이메일만 받으면 충분해요.
// 회원가입용 생성자 — 이름과 이메일만 받아 새 회원을 만들어요.
// 가입 직후엔 팔로워·게시물 수가 모두 0 이라, 나머지 숫자 정보는 기본값(0)으로 둬요.
public Member(String username, String email) {
this.username = username;
this.email = email;
totalMembers++;
}
마지막으로 이메일을 읽고 바꾸는 통로(getter·setter)도 열어둬요.
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
기존 필드와 생성자는 그대로 두고 새 칸만 더했어요. 그래서 지난 시간까지 만든 Member 의 동작은 하나도 바뀌지 않아요. 이렇게 "있던 걸 건드리지 않고 새 기능만 더하는" 게 안전한 확장의 기본이에요.
💡 튜터의 결론
부품이 많아질수록 "어디에 무슨 일을 둘지" 가 중요해져요. 저장소는 넣고 꺼내기, 서비스는 규칙 판단, 받는 쪽은 보여주기. 층마다 한 가지 일만 맡기면, 나중에 고칠 때 건드릴 곳이 분명해져요.
자, 설계도를 그렸으니 맨 아래층부터 쌓아볼게요. 다음 Step 에서 저장소를 본격적으로 키워요.
Step 2: MemberRepository — 씨앗을 본격 저장소로
지난 시간에 회원 저장소의 씨앗을 만들었던 거 기억나시죠? Map<Long, Member> 에 "번호표(id) → 회원" 짝으로 보관하는 작은 클래스였어요. 그때는 번호표를 바깥에서 직접 붙여 넘겼어요. save(1L, member) 처럼요.
그런데 실제로 회원가입을 받다 보면, 번호표를 누가 매길지가 애매해져요. 가입하는 사람이 "저는 7번이요" 하고 정할 순 없잖아요? 번호표는 저장소가 알아서 순서대로 발급하는 게 자연스러워요.
그래서 오늘은 저장소를 이렇게 키워요. save(member) 라고 회원만 건네면, 저장소가 다음 번호표를 스스로 뽑아 붙이고 그 번호표를 돌려주는 거예요.
// com/instagram/javabasic/repository/MemberRepository.java
public class MemberRepository {
// 번호표(id) → 회원 짝을 보관하는 사물함이에요.
private final Map<Long, Member> store = new HashMap<>();
// 다음에 발급할 번호표예요. 저장할 때마다 1씩 올라가요(1, 2, 3, ...).
private long sequence = 0L;
// 저장 — 다음 번호표를 뽑아 회원에게 붙이고 보관한 뒤, 그 번호표(id)를 돌려줘요.
public Long save(Member member) {
sequence++;
Long id = sequence;
store.put(id, member);
return id;
}
sequence 라는 숫자 하나가 핵심이에요. 처음엔 0 이고, 저장할 때마다 1씩 올라가요. 그래서 첫 회원은 1번, 둘째 회원은 2번 번호표를 받아요. 은행에서 번호표 뽑는 기계랑 똑같죠. 다음 사람이 오면 자동으로 다음 번호가 나오는 거예요.
조회도 다듬어요. 지난 시간 씨앗은 없는 번호를 찾으면 IllegalArgumentException(자바 기본 예외)을 던졌어요. 오늘은 우리가 만든 MemberNotFoundException 으로 바꿔요.
// 조회 — id 로 회원을 찾아요. 없으면 MemberNotFoundException 을 던져 "그 회원 없어요" 를 분명히 알려요.
public Member findById(Long id) {
Member found = store.get(id);
if (found == null) {
throw new MemberNotFoundException("id " + id + " 에 해당하는 회원이 없어요.");
}
return found;
}
왜 바꿨을까요? 지난 시간 마지막에 다뤘듯, 예외의 이름이 곧 진단서의 병명이에요. IllegalArgumentException 은 "뭔가 인자가 잘못됐다" 는 막연한 말이지만, MemberNotFoundException 은 "회원 조회가 실패했다" 를 이름만으로 말해줘요. 잡는 쪽이 메시지를 읽지 않아도 무슨 사고인지 알죠.
이제 회원가입을 받으려면 한 가지가 더 필요해요. "이 이메일, 이미 누가 쓰고 있나?" 를 물어보는 기능이에요. Map 안의 회원을 하나하나 훑으면서 이메일을 비교하면 돼요.
// 이 이메일을 쓰는 회원이 이미 있는지 — 사물함 안의 회원을 하나하나 훑어 이메일을 비교해요.
public boolean existsByEmail(String email) {
for (Member member : store.values()) {
if (email != null && email.equals(member.getEmail())) {
return true;
}
}
return false;
}
store.values() 는 사물함에 든 회원 전체예요. 그걸 for 로 돌면서, 찾는 이메일과 같은 회원이 하나라도 있으면 바로 true 를 돌려줘요. 끝까지 못 찾으면 false 고요. 이 for 반복을 잘 기억해 두세요. 마지막 Step 에서 다시 만날 거예요.
저장소엔 편의 메서드 몇 개를 더 뒀어요. 이메일로 회원을 직접 찾는 findByEmail, 전체를 묶음으로 돌려주는 findAll, 몇 명인지 세는 count 예요. 모두 같은 Map 을 들여다보는 단순한 기능이라, 코드는 코드베이스에서 확인해보면 금방 눈에 들어와요.
💡 튜터의 결론
저장소는 "넣고 꺼내기" 만 해요. 번호표를 스스로 발급하고(
save), 없는 걸 찾으면 분명한 예외로 알리고(findById), 조건에 맞는 게 있는지 훑어보는(existsByEmail) 정도가 저장소의 일이에요. 가입 규칙 같은 판단은 여기 두지 않아요 — 그건 다음 층의 몫이에요.
저장소 한 층이 단단해졌어요. 그런데 게시물도 어딘가 보관해야겠죠? 다음 Step 에서 똑같은 패턴을 한 번 더 써먹어요.
Step 3: PostRepository — 같은 패턴을 한 번 더
좋은 소식이 있어요. 게시물 저장소는 방금 만든 회원 저장소와 거의 판박이예요. 사물함(Map)이 있고, 스스로 올라가는 번호표(sequence)가 있고, save 로 넣고 findById 로 꺼내요.
한 번 익힌 패턴을 복제하는 거라, 빠르게 훑어볼게요.
// com/instagram/javabasic/repository/PostRepository.java
public class PostRepository {
private final Map<Long, Post> store = new HashMap<>();
private long sequence = 0L;
// 저장 — 다음 번호표를 뽑아 붙이고 보관한 뒤 그 id 를 돌려줘요.
public Long save(Post post) {
sequence++;
Long id = sequence;
store.put(id, post);
return id;
}
// 조회 — 없으면 표준 예외로 "그 게시물 없어요" 를 알려요.
public Post findById(Long id) {
Post found = store.get(id);
if (found == null) {
throw new IllegalArgumentException("id " + id + " 에 해당하는 게시물이 없어요.");
}
return found;
}
여기서 한 가지가 회원 저장소와 달라요. 게시물을 못 찾았을 때는 커스텀 예외가 아니라 표준 IllegalArgumentException 을 던져요. "어? 회원은 전용 예외를 만들었으면서 게시물은 왜 안 만들어요?" 싶죠?
이게 오늘의 작은 설계 판단이에요. 회원 조회 실패는 인스타그램에서 워낙 자주 다루는 핵심 상황이라 전용 예외를 만들 가치가 있었어요. 반면 게시물 조회 실패는 표준 예외로도 충분해요.
모든 실패마다 전용 예외를 찍어내면, 예외 클래스만 수십 개로 불어나 오히려 관리가 어려워지거든요. (지난 시간 "생각해볼 주제" 에서 던진 질문, 기억나시죠? "커스텀 예외는 어디까지 만들어야 할까?")
게시물 저장소에는 특별한 기능이 하나 있어요. "이 사람이 쓴 글만 골라줘" 예요. 사물함을 훑으면서 작성자가 같은 글만 새 묶음에 담아요.
// 특정 작성자가 쓴 게시물만 골라 모아요 — 사물함을 훑으며 작성자가 같은 글만 새 리스트에 담아요.
public List<Post> findByAuthor(Member author) {
List<Post> result = new ArrayList<>();
for (Post post : store.values()) {
if (author != null && author.equals(post.getAuthor())) {
result.add(post);
}
}
return result;
}
이 for 반복도 잘 봐두세요. 전체 중에서 조건에 맞는 것만 골라 담아요. 방금 회원 저장소의 existsByEmail 과 비슷하죠? "전체를 훑으면서 조건을 따진다" 는 똑같은 작업을 우리는 벌써 두 번 손으로 짜고 있어요.
게시물을 지우는 deleteById 와 개수를 세는 count 도 있어요. deleteById 는 없는 번호를 지우려 하면 역시 IllegalArgumentException 으로 막아요. 이 저장소 패턴은 어떤 데이터에도 똑같이 쓸 수 있는데, 오늘 과제에서 댓글 저장소를 직접 만들며 한 번 더 익혀볼 거예요.
💡 튜터의 결론
한 번 잘 만든 패턴은 복제하면 돼요. 회원 저장소와 게시물 저장소는 사물함·번호표·
save·findById구조가 똑같아요. 다른 점은 딱 하나 — 조회 실패에 전용 예외를 쓸지(MemberNotFoundException), 표준 예외로 충분할지(IllegalArgumentException)예요. 그 판단 기준은 "이 사고를 잡는 쪽이 정말 따로 구분해야 하는가" 예요.
두 저장소가 준비됐어요. 이제 그 위에 규칙을 판단하는 서비스 층을 올려요. 드디어 지난 시간에 만든 예외 둘이 무대에 오를 차례예요.
Step 4: MemberService — 회원가입과 DuplicateEmailException
이제 서비스 층이에요. 서비스는 "할 일(비즈니스 로직)" 을 맡아요. 회원가입을 예로 들면, 저장소는 그냥 회원을 넣어주기만 하지만, "이메일이 비었는지, 형식이 맞는지, 이미 쓰는 이메일인지" 를 따지는 건 서비스의 몫이에요.
먼저 한 가지 짚고 갈게요. 서비스는 저장소를 어떻게 손에 넣을까요? 자기가 직접 new MemberRepository() 로 만들 수도 있지만, 우리는 생성자로 바깥에서 전달받는 방식을 써요.
// com/instagram/javabasic/service/MemberService.java
public class MemberService {
private final MemberRepository memberRepository;
// 생성자로 저장소를 전달받아요. 서비스는 "어떤 저장소를 쓸지" 를 스스로 정하지 않아요.
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
왜 굳이 밖에서 받을까요? 서비스가 저장소를 직접 만들어 버리면, 둘이 단단히 붙어버려요. 나중에 다른 저장소로 바꿔 끼우고 싶어도 서비스 코드를 뜯어고쳐야 하죠. 생성자로 받아 두면, 만드는 쪽에서 원하는 저장소를 골라 넣어줄 수 있어요. "객체를 외부에서 전달받기" 는 앞으로도 계속 만날 중요한 습관이에요.
이제 회원가입의 핵심이에요. 검사를 차례로 통과해야만 저장으로 넘어가요.
// 회원가입 — 이메일을 검사하고, 이미 쓰는 이메일이면 막은 뒤, 통과하면 저장하고 새 id 를 돌려줘요.
public Long signup(String username, String email) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("이메일은 비어 있을 수 없어요.");
}
if (!email.contains("@")) {
throw new IllegalArgumentException("이메일 형식이 올바르지 않아요: " + email);
}
if (memberRepository.existsByEmail(email)) {
throw new DuplicateEmailException("이미 가입된 이메일이에요: " + email);
}
Member member = new Member(username, email);
return memberRepository.save(member);
}
검사가 세 단계예요. 위에서부터 차례로 통과해야 다음으로 넘어가요.
- 이메일이 비었으면 → 막아요 (표준 예외)
@가 없으면 형식이 틀린 거니 → 막아요 (표준 예외)- 이미 쓰는 이메일이면 → 드디어
DuplicateEmailException으로 막아요
세 번째가 오늘의 주인공이에요. 지난 시간에 정의만 해두고 한 번도 던져보지 못한 DuplicateEmailException 이, 여기서 처음으로 진짜 일을 해요. 저장소의 existsByEmail 이 true 를 돌려주면, "이 이메일은 이미 누가 쓰고 있다" 는 뜻이니 가입을 막는 거죠.
세 검사를 모두 통과해야 비로소 new Member(username, email) 로 회원을 만들고 저장해요. 그러면 저장소가 번호표를 발급해 돌려주고, 그게 곧 새 회원의 id 가 돼요.
signup("jaehoon", "jaehoon@insta.com")
│
▼
[1] 이메일 비었나? ──── 비었으면 ──▶ IllegalArgumentException
│ 아니오
▼
[2] @ 없나? ────────── 없으면 ───▶ IllegalArgumentException
│ 아니오
▼
[3] 이미 쓰는 이메일? ── 그렇다 ──▶ DuplicateEmailException ◀ 지난 시간의 그 예외!
│ 아니오 (통과!)
▼
Member 만들기 → 저장소에 save → 새 id 돌려주기
회원을 id 로 찾는 기능도 서비스에 하나 둬요. 별다른 규칙은 없으니 저장소에 그대로 맡겨요. 없으면 저장소가 알아서 MemberNotFoundException 을 던지고요.
// id 로 회원 한 명을 찾아요 — 저장소에 그대로 맡겨요(없으면 저장소가 MemberNotFoundException 을 던져요).
public Member getMember(Long id) {
return memberRepository.findById(id);
}
💡 튜터의 결론
서비스와 저장소의 역할이 여기서 분명히 갈려요. 저장소는 "이메일이 있나?" 라는 물음에 사실만 답해요(
existsByEmail). 그 사실을 보고 "그럼 가입을 막자" 고 판단하는 건 서비스예요(signup). 사실 확인과 판단을 다른 층에 두면, 규칙이 바뀌어도 저장소는 손댈 필요가 없어요.
회원가입이 완성됐어요. 이제 게시물을 올리는 기능으로 가요. 남은 예외 하나, InvalidCaptionException 이 기다리고 있어요.
Step 5: PostService — 게시물 CRUD 와 InvalidCaptionException
게시물 서비스는 네 가지 일을 해요. 작성(Create)·조회(Read)·수정(Update)·삭제(Delete), 앞 글자를 따서 CRUD 라고 불러요. 데이터를 다루는 거의 모든 기능이 이 네 가지로 정리돼요.
먼저 캡션(caption, 게시물에 다는 글)에는 규칙이 있어요. 비어 있으면 안 되고, 너무 길어도 안 돼요. 이 검사를 한곳에 모아 둘게요. 작성할 때도, 수정할 때도 똑같이 불러 쓰려고요.
// com/instagram/javabasic/service/PostService.java
// 캡션 검사 규칙을 한곳에 모아 둬요 — 작성·수정 두 곳에서 똑같이 불러 써요.
private void validateCaption(String caption) {
if (caption == null || caption.isBlank()) {
throw new InvalidCaptionException("캡션은 비어 있을 수 없어요.");
}
if (caption.length() > MAX_CAPTION_LENGTH) {
throw new InvalidCaptionException(
"캡션은 " + MAX_CAPTION_LENGTH + "자를 넘을 수 없어요: 현재 " + caption.length() + "자");
}
}
여기서 지난 시간의 또 다른 주인공, InvalidCaptionException 이 등장해요. 캡션이 비었거나 정해둔 길이(MAX_CAPTION_LENGTH, 100자)를 넘으면 이 예외로 막아요. 예외 이름만 봐도 "아, 캡션 검증에서 걸렸구나" 를 알 수 있죠.
검사 규칙을 메서드 하나로 빼둔 이유가 있어요. 작성과 수정 두 곳에서 똑같은 규칙을 써야 하는데, 양쪽에 복사해 두면 나중에 규칙이 바뀔 때 두 곳을 다 고쳐야 하고, 한 곳을 깜빡하면 둘이 어긋나요. 한곳에 모아 두면 고칠 때도 한 번이면 돼요.
이제 작성(Create)이에요. 캡션을 검사하고, 통과하면 작성자와 묶어 게시물을 만들어 저장해요.
// 작성(Create) — 캡션을 검사하고, 통과하면 작성자와 묶어 게시물을 만든 뒤 저장하고 새 id 를 돌려줘요.
public Long createPost(Member author, String caption) {
validateCaption(caption);
Post post = new Post(caption, author, 0);
if (author != null) {
author.addWrittenPost(post);
}
return postRepository.save(post);
}
맨 첫 줄에서 검사부터 해요. 검사를 통과하지 못하면 그 자리에서 예외가 튀어나가, 아래의 저장 코드는 아예 실행되지 않아요. 그래서 규칙에 어긋난 게시물은 저장소에 절대 들어가지 못해요. "검사 먼저, 저장은 나중" 이 게시물 작성의 안전선이에요.
조회(Read)는 저장소에 그대로 맡겨요. 수정(Update)은 조금 더 흥미로워요.
// 수정(Update) — 보관된 글은 못 고치고, 새 캡션도 다시 검사한 뒤 내용을 바꿔요.
public void updateCaption(Long id, String newCaption) {
Post post = postRepository.findById(id);
if (!post.getStatus().isEditable()) {
throw new IllegalStateException("보관된 글은 수정할 수 없어요.");
}
validateCaption(newCaption);
post.editContent(newCaption);
}
수정에는 검사가 두 겹이에요.
- 첫째, "이 글이 지금 고칠 수 있는 상태인가?" 를 물어요. 여기서
PostStatus.isEditable()이 등장해요. 게시물 상태(공개·비공개·보관됨)를 enum 으로 만들 때 넣어둔 그 메서드예요. 보관된(ARCHIVED) 글이면false를 돌려주니, 수정을 막아요. - 둘째, 새로 들어온 캡션도 작성 때와 똑같이
validateCaption으로 검사해요. 수정이라고 규칙을 봐주지 않아요.
두 검사를 다 통과해야 post.editContent(newCaption) 로 내용을 바꿔요. 그런데 Post 에는 지금까지 내용을 바꾸는 통로가 없었어요. 한 번 쓴 게시물은 못 고쳤거든요. 수정 기능을 위해 Post 에 통로를 하나 열어줬어요.
// com/instagram/javabasic/domain/post/Post.java
// 게시물 내용을 새 글로 바꿔요. 지금까진 한 번 쓰면 못 고쳤는데, 수정 기능을 위해 통로를 하나 열어요.
// "고쳐도 되는 상태인가" 같은 규칙은 여기서 따지지 않아요 — 그 판단은 게시물을 다루는 서비스가 맡아요.
public void editContent(String newContent) {
this.content = newContent;
}
여기서도 층의 역할 분담이 보여요. Post.editContent 는 그냥 내용을 바꾸기만 해요. "보관된 글이면 막아야지" 같은 판단은 하지 않아요. 그 판단은 PostService.updateCaption 이 해요. 게시물 자신은 단순하게, 규칙은 서비스가 — 이 분담이 일관되게 흐르고 있죠.
삭제(Delete)는 저장소의 deleteById 에 맡기고, 한 작성자의 글만 모아주는 postsOf 도 있어요. 이 둘은 코드베이스에서 확인해보면 단순해요.
💡 튜터의 결론
CRUD 는 데이터를 다루는 기본 네 동작이에요. 작성과 수정에서 똑같은 규칙(
validateCaption)을 한곳에 모아 두면 중복이 사라지고, 수정에서는 "고칠 수 있는 상태인가" 를PostStatus.isEditable()에게 물어 한 겹 더 안전해져요. 검사는 서비스가, 실제 변경은 도메인 객체가 — 역할이 깔끔하게 나뉘어요.
회원과 게시물 서비스가 섰어요. 마지막 서비스는 사람과 사람을 잇는 팔로우예요. 여기서 MemberNotFoundException 이 다시 일하러 나와요.
Step 6: FollowService — 회원을 잇고, 없는 회원은 막다
팔로우는 사람과 사람을 잇는 일이에요. "재훈이 민지를 팔로우한다" 는 건, 재훈의 팔로잉 묶음에 민지를 더하는 거죠. 사실 회원을 잇는 실제 동작은 Member 가 이미 갖고 있어요. 지난 도메인 시간에 만든 follow·isFollowing 이요. 서비스는 그걸 빌려 쓰면 돼요.
대신 서비스는 그 앞뒤로 규칙을 챙겨요.
// com/instagram/javabasic/service/FollowService.java
// 팔로우 — followerId 회원이 targetId 회원을 팔로우해요.
public void follow(Long followerId, Long targetId) {
Member follower = memberRepository.findById(followerId);
Member target = memberRepository.findById(targetId);
if (follower.equals(target)) {
throw new IllegalArgumentException("자기 자신은 팔로우할 수 없어요.");
}
if (follower.isFollowing(target)) {
return;
}
follower.follow(target);
}
흐름을 따라가 볼게요.
- 먼저 두 회원을 저장소에서 찾아요. 둘 중 하나라도 없으면?
findById가 알아서MemberNotFoundException을 던져요. 우리가 따로 검사하지 않아도, 저장소가 만들어둔 안전선이 여기서 작동해요. 없는 사람을 팔로우하는 사고가 자동으로 막히는 거죠. - 자기 자신은 팔로우할 수 없으니 막아요. (인스타그램에서도 자기 프로필엔 팔로우 버튼이 없죠?)
- 이미 팔로우 중이면 조용히 끝내요. 같은 사람을 두 번 팔로우해도 묶음에 중복으로 쌓이지 않게요.
- 이 모든 걸 통과하면 비로소
follower.follow(target)로 둘을 이어요.
follow(재훈id, 민지id)
│
├─ 재훈 찾기 ── 없으면 ─▶ MemberNotFoundException ◀ 저장소가 자동으로 막아줘요
├─ 민지 찾기 ── 없으면 ─▶ MemberNotFoundException
│
├─ 재훈 == 민지? ── 그렇다 ─▶ IllegalArgumentException (자기 자신)
├─ 이미 팔로우 중? ─ 그렇다 ─▶ 조용히 끝 (중복 방지)
│
▼
재훈 ──팔로우──▶ 민지
서로 맞팔(둘 다 서로를 팔로우)인지 확인하는 기능도 하나 뒀어요.
// 서로 맞팔(둘 다 서로를 팔로우)인지 확인해요.
public boolean isMutual(Long aId, Long bId) {
Member a = memberRepository.findById(aId);
Member b = memberRepository.findById(bId);
return a.isFollowing(b) && b.isFollowing(a);
}
a 가 b 를 팔로우하고 그리고(&&) b 도 a 를 팔로우할 때만 true 예요. 한쪽만 팔로우하면 맞팔이 아니니 false 고요.
💡 튜터의 결론
좋은 설계는 아래층이 깔아둔 안전선을 위층이 공짜로 누리게 해요.
FollowService는 "없는 회원" 을 따로 검사하지 않아요. 그냥findById를 부르기만 해도, 저장소가 만들어둔MemberNotFoundException이 알아서 막아주거든요. 층을 잘 나누면, 한 번 만든 안전 장치가 여러 곳에서 거듭 일해요.
서비스 세 개가 모두 섰어요. 이제 이들을 한 화면에서 굴려, 진짜 인스타그램처럼 동작하는 모습을 봐요.
Step 7: 콘솔 데모 — 세 서비스를 한 흐름에서
지금까지 만든 걸 한자리에 조립할 시간이에요. 저장소 둘, 서비스 셋을 한 클래스에서 엮고, 실제 시나리오를 쭉 굴려봐요. 가입하고, 중복 가입에 막히고, 글을 쓰고, 긴 캡션에 막히고, 팔로우하고, 없는 회원 팔로우에 막히는 — 그런 흐름이에요.
먼저 부품을 한자리에 모아 연결해요.
// com/instagram/javabasic/service/InstagramApp.java
private final MemberRepository memberRepository = new MemberRepository();
private final PostRepository postRepository = new PostRepository();
private final MemberService memberService = new MemberService(memberRepository);
private final PostService postService = new PostService(postRepository);
private final FollowService followService = new FollowService(memberRepository);
Step 4 에서 "서비스는 저장소를 생성자로 전달받는다" 고 했죠? 여기가 바로 그 전달이 일어나는 곳이에요. MemberService 와 FollowService 에 같은 memberRepository 를 넣어줬어요. 그래서 가입으로 저장한 회원을, 팔로우 서비스도 같은 사물함에서 찾을 수 있어요. 부품들이 같은 데이터를 공유하게 되는 거죠.
이제 시나리오를 굴려요. 막힐 수 있는 자리는 try-catch 로 감싸, 예외가 나와도 프로그램이 멈추지 않고 안내만 보여주게 했어요.
// 1) 회원가입 두 명 — 정상 흐름
Long jaehoonId = memberService.signup("jaehoon", "jaehoon@insta.com");
Long minjiId = memberService.signup("minji", "minji@insta.com");
log("가입 완료: jaehoon(id=" + jaehoonId + "), minji(id=" + minjiId + ")");
// 2) 같은 이메일로 다시 가입 — DuplicateEmailException 으로 막힘
try {
memberService.signup("jaehoon2", "jaehoon@insta.com");
} catch (DuplicateEmailException e) {
log("가입 거절(중복 이메일): " + e.getMessage());
}
재훈과 민지가 가입하면 각각 1번, 2번 번호표를 받아요. 그다음 누군가 재훈과 같은 이메일로 또 가입을 시도하면? signup 안의 세 번째 검사에 걸려 DuplicateEmailException 이 튀어나와요. catch 가 그걸 받아 "가입 거절" 안내로 바꿔요.
게시물과 팔로우도 마찬가지로 흘러가요. 정상 흐름과 막히는 흐름을 나란히 보여줘요.
// 4) 너무 긴 캡션 — InvalidCaptionException 으로 막힘
try {
postService.createPost(memberService.getMember(jaehoonId), "가".repeat(200));
} catch (InvalidCaptionException e) {
log("작성 거절(캡션 규칙): " + e.getMessage());
}
// 6) 없는 회원을 팔로우 — MemberNotFoundException 으로 막힘
try {
followService.follow(jaehoonId, 999L);
} catch (MemberNotFoundException e) {
log("팔로우 거절(회원 없음): " + e.getMessage());
}
"가".repeat(200) 는 "가" 를 200번 이어붙인 글이에요. 100자 한도를 훌쩍 넘으니 InvalidCaptionException 으로 막히죠. 999번 회원은 가입한 적이 없으니 MemberNotFoundException 으로 막히고요.
이 데모를 실행하면 화면에 이렇게 찍혀요.
=== 인스타그램 서비스 계층 데모 ===
가입 완료: jaehoon(id=1), minji(id=2)
가입 거절(중복 이메일): 이미 가입된 이메일이에요: jaehoon@insta.com
게시물 작성 완료: id=1
작성 거절(캡션 규칙): 캡션은 100자를 넘을 수 없어요: 현재 200자
팔로우 완료: jaehoon → minji (맞팔 여부=false)
팔로우 거절(회원 없음): id 999 에 해당하는 회원이 없어요.
=== 데모 종료: 회원 2명, 게시물 1개 ===
세 개의 예외가 각자 제 이름값을 하는 게 한눈에 보여요. 중복 이메일은 DuplicateEmailException, 긴 캡션은 InvalidCaptionException, 없는 회원은 MemberNotFoundException.
그리고 중간중간 막히는 사고가 있었는데도 데모는 끝까지 굴러가서 "회원 2명, 게시물 1개" 로 마무리됐어요. try-catch 가 사고를 받아 안내로 바꿔준 덕분이에요.
💡 튜터의 결론
부품 하나하나는 단순해도, 잘 조립하면 살아 있는 서비스가 돼요. 저장소가 데이터를 보관하고, 서비스가 규칙을 판단하고, 받는 쪽이 예외를 안내로 바꿔요. 사고가 나도
try-catch가 받아주니 전체 흐름은 멈추지 않고요. 이게 우리가 Phase 3 내내 쌓아온 것들이 한자리에서 협력하는 모습이에요.
데모까지 돌려봤어요. 마지막으로 오늘 코드를 돌아보면서, 다음 시간에 무엇을 배울지 살짝 들여다볼게요.
Step 8: 종합 회고 — 그리고 다음 시간 예고
오늘 우리가 조립한 3계층을 한 그림으로 정리해 볼게요.
InstagramApp (받는 쪽) try-catch 로 예외를 사람이 읽을 안내로 바꿔요
│
▼
MemberService · PostService · FollowService (서비스)
│ 가입 규칙·캡션 규칙·팔로우 규칙을 판단해요
▼
MemberRepository · PostRepository (저장소)
│ 넣고 꺼내기만 해요. 번호표 발급·예외도 여기서
▼
Map 사물함 데이터가 메모리 안에 보관돼요
부품 상자만 가득했던 오프닝과 비교하면, 이제 진짜 흐름이 흐르는 완성품이 됐죠.
그런데 오늘 코드를 쓰면서 같은 모양을 반복한 자리가 있었어요. 기억하시나요? 회원 저장소의 existsByEmail 과 게시물 저장소의 findByAuthor 예요. 둘 다 이렇게 생겼어요.
// existsByEmail — 전체를 훑으며 "조건에 맞는 게 있나?" 를 따져요
for (Member member : store.values()) {
if (email != null && email.equals(member.getEmail())) {
return true;
}
}
// findByAuthor — 전체를 훑으며 "조건에 맞는 것만" 골라 담아요
for (Post post : store.values()) {
if (author != null && author.equals(post.getAuthor())) {
result.add(post);
}
}
"전체를 훑으면서 조건을 따진다" 는 작업을, 우리는 매번 for 와 if 를 손으로 짜서 했어요. 거르고(filter), 찾고(find), 골라 담는 이 패턴은 데이터를 다루다 보면 끝없이 반복돼요. 그때마다 여러 줄을 적는 건 은근히 번거롭죠.
다음 시간부터 우리는 이 반복을 확 줄이는 도구를 배워요. "조건에 맞는 것만 걸러줘", "이걸 저것으로 바꿔줘" 같은 주문을 한 줄로 표현하는 방법이에요. 위의 for 여러 줄이 단 한 줄로 줄어드는 마법을 보게 될 거예요. Phase 4 "모던 자바" 의 시작, 람다(lambda)와 함수형 도구의 세계예요.
💡 튜터의 결론
오늘 손으로 짠 "훑고 거르는"
for반복은 다음 시간을 위한 발판이에요. 지금은 여러 줄로 적었지만, 곧 한 줄로 줄이는 법을 배워요. 반복이 눈에 거슬리기 시작했다면, 그건 다음 단계로 넘어갈 준비가 됐다는 신호예요.
마무리
- Step 1: 부품을 한곳에 쏟지 않고, 저장소·서비스·받는 쪽 3계층으로 나눠요. 각 층이 한 가지 일만 맡으면 고칠 곳이 분명해져요. 회원가입 키로 쓸 이메일 한 칸을
Member에 더했어요. - Step 2: 저장소는 번호표를 스스로 발급해요(
save가 자동 증가 id 를 돌려줌). 없는 회원을 찾으면MemberNotFoundException으로 분명히 알리고,existsByEmail로 이메일 존재 여부를 훑어봐요. - Step 3: 게시물 저장소는 같은 패턴의 복제예요. 다른 점은 조회 실패에 표준 예외를 쓴 것 — 모든 실패에 전용 예외가 필요한 건 아니에요.
- Step 4: 서비스는 규칙을 판단해요. 회원가입은 세 검사를 차례로 통과해야 하고, 이미 쓰는 이메일이면
DuplicateEmailException으로 막아요. 저장소는 생성자로 전달받아요. - Step 5: CRUD 는 데이터의 기본 네 동작이에요. 캡션 검사를 한곳에 모아 작성·수정에서 함께 쓰고, 수정에서는
PostStatus.isEditable()로 보관된 글을 막아요. 어긋나면InvalidCaptionException. - Step 6: 팔로우는 두 회원을 잇는 일.
findById만 불러도 없는 회원은 저장소가 자동으로 막아줘요. 자기 자신·중복 팔로우도 서비스가 걸러요. - Step 7: 세 서비스를 한자리에 조립해 굴려보니, 예외 삼총사가 각자 제 이름값을 하며 사고를 막았어요.
try-catch덕에 흐름은 멈추지 않고요. - Step 8: "훑고 거르는"
for반복이 곳곳에 쌓였어요. 다음 시간에 이걸 한 줄로 줄이는 도구를 배워요.
다음 시간엔 — 코드를 더 짧고 선언적으로
오늘로 Phase 3 가 끝났어요. 우리는 순수 자바만으로 인스타그램의 두뇌, 즉 서비스 계층을 통째로 조립해냈어요. 회원가입부터 게시물 CRUD, 팔로우까지 — 진짜 백엔드가 하는 일을 손으로 다 짜본 거예요.
다음 시간부터는 Phase 4 "모던 자바" 예요. 첫 주제는 람다(lambda)와 함수형 인터페이스예요. 오늘 우리가 여러 줄로 짠 "전체를 훑으며 조건을 따지는" for 반복을, 한 줄짜리 주문으로 바꾸는 법을 배워요.
지금까지 "어떻게 하나하나 처리할지" 를 길게 적었다면, 앞으로는 "무엇을 원하는지" 만 짧게 선언하게 돼요. 같은 일을 더 적은 코드로, 더 읽기 쉽게요. Phase 3 를 끝까지 따라온 여러분이라면 충분히 준비됐어요. 정말 수고 많으셨어요!
과제
오늘 만든 저장소·서비스·예외를 직접 손보는 과제예요. 셋 다 오늘 조립한 흐름의 연장이니, 코드베이스의 repository·service 패키지를 열어 옆에 두고 풀면 편해요. 람다·스트림 같은 다음 시간 문법은 아직 쓰지 않아요. 오늘까지 배운 클래스·컬렉션·예외 범위에서 풀면 돼요.
과제 1: [기초] CommentRepository 를 직접 만들기
해야 할 일
댓글을 보관하는 CommentRepository 를 새로 만들어보세요. Step 2·3 에서 익힌 저장소 패턴(사물함 + 번호표)을 댓글에 그대로 복제하는 과제예요.
요구사항
- 새 클래스
CommentRepository를 만들고, 안에Map<Long, Comment> store와 번호표long sequence를 둬요. (Comment는 지난 도메인 시간에 만든 댓글 클래스예요.) Long save(Comment comment): 번호표를 1 올려 붙이고 보관한 뒤 그 id 를 돌려줘요.Comment findById(Long id): 없으면IllegalArgumentException을 던져요. (게시물처럼 댓글도 표준 예외로 충분해요 — Step 3 의 판단을 따라요.)void deleteById(Long id): 없는 id 를 지우려 하면IllegalArgumentException, 있으면store.remove(id).int count(): 보관된 댓글 수를 돌려줘요.main에서 댓글 하나를 저장하고 →findById로 다시 꺼내 확인하고 → 지운 뒤count()가 0 인지 보고 → 같은 id 를 또 지울 때 예외가 나는지try-catch로 받아 출력해요.
힌트
MemberRepository·PostRepository를 옆에 두고 거의 그대로 베끼면 돼요. 타입만Comment로 바꾸는 거예요.Map에 어떤 키가 들어 있는지 확인할 땐store.containsKey(id)를 써요.- 왜
MemberNotFoundException이 아니라IllegalArgumentException일까요? Step 3 에서 다룬 "전용 예외는 어디까지" 를 떠올리면 답이 보여요.
과제 2: [응용] EmailChangeService 로 이메일 변경 기능 만들기
해야 할 일
회원의 이메일을 바꾸는 새 서비스 EmailChangeService 를 만들어, 두 예외가 한 메서드에서 협력하게 해보세요. 회원 조회와 이메일 중복을 한 흐름에서 다루는 과제예요.
요구사항
- 새 클래스
EmailChangeService를 만들고, 생성자로MemberRepository를 전달받아요. (Step 4 의 "저장소를 외부에서 전달받기" 와 똑같아요.) void changeEmail(Long id, String newEmail)메서드를 만들어요.- 먼저
findById(id)로 회원을 찾아요. 없으면 저장소가MemberNotFoundException을 던지니, 따로 검사하지 않아도 돼요. - 새 이메일 형식이 틀리면(
@없음 등)IllegalArgumentException으로 막아요. - 바꾸려는 새 이메일이 이미 다른 회원이 쓰는 거라면(
existsByEmail)DuplicateEmailException을 던져요. - 검사를 통과하면
member.setEmail(newEmail)로 바꿔요.
- 먼저
main에서 회원 둘을 가입시키고 → 한 회원을 비어 있는 새 이메일로 바꿔 성공시키고 → 다른 회원이 쓰는 이메일로 바꾸려 할 때DuplicateEmailException이 나는지 확인해요.
힌트
- 한 메서드 안에서 두 가지 사고(
MemberNotFoundException·DuplicateEmailException)를 다룰 수 있어요. 하나는 저장소가 자동으로, 하나는 서비스가 직접 던져요. - 함정 하나 — 회원이 "자기가 지금 쓰는 이메일" 로 바꾸려 하면
existsByEmail이true가 나와서 엉뚱하게 막힐 수 있어요. "다른 회원이 쓸 때만" 막으려면 어떤 조건을 더 붙여야 할지 생각해보세요.
과제 3: [심화] FeedService 로 팔로잉 피드 만들기
해야 할 일
내가 팔로우한 사람들의 게시물을 한데 모아주는 FeedService.buildFeed 를 만들어보세요. 저장소 둘과 회원의 팔로잉 묶음을 함께 엮는 종합 과제예요.
요구사항
FeedService클래스를 만들고, 생성자로MemberRepository와PostRepository를 둘 다 전달받아요.List<Post> buildFeed(Long memberId)메서드를 만들어요.memberId로 회원을 찾아요(없으면 저장소가MemberNotFoundException).- 그 회원의 팔로잉 묶음을
getFollowingCount()와getFollowing(int index)로 하나씩 꺼내요. - 팔로잉 한 명마다
postRepository.findByAuthor(...)로 그 사람의 게시물을 가져와, 결과 묶음(List<Post>)에 모두 더해요. - 모은 게시물 묶음을 돌려줘요.
main에서 회원 셋을 가입시키고 → 한 명이 나머지 둘을 팔로우하게 하고 → 각자 게시물을 쓰게 한 뒤 →buildFeed로 모은 피드의 개수를 출력해요.
힌트
- 바깥
for로 팔로잉을 한 명씩 돌고, 그 안에서findByAuthor가 돌려준 묶음을result.addAll(...)로 합치면 돼요.for안에for가 들어가는 중첩 반복이에요. - 이 과제에서
for가 여러 겹 쌓이는 게 느껴질 거예요. 바로 다음 시간에 이 반복을 한 줄로 줄이는 도구를 배우니, 오늘은 "왜 줄이고 싶은지" 를 몸으로 느껴두는 게 목적이에요.
생각해볼 주제
오늘 서비스 계층을 조립하면서 자연스럽게 떠오르는 질문들이에요. 정답을 외우기보다, 왜 그런지 곰곰이 생각해보면 설계를 보는 눈이 깊어져요.
1. 검증은 어느 층의 일일까?
오늘 우리는 캡션 길이 검사를 저장소(PostRepository)가 아니라 서비스(PostService)에 뒀어요. 저장소는 그냥 받은 게시물을 넣기만 하고, "캡션이 너무 긴가?" 같은 판단은 하지 않죠.
그런데 반대로 생각할 수도 있어요. "어차피 저장소가 데이터의 문지기인데, 검사도 저장소가 하면 안 되나?" 검증을 서비스에 두는 것과 저장소에 두는 것은 각각 어떤 장단점이 있을까요? 같은 저장소를 여러 서비스가 함께 쓰는 상황까지 떠올리며 생각해보세요.
2. 메모리에만 사는 데이터는 어디로 갈까?
우리 저장소는 데이터를 Map 에 담아요. 빠르고 간단하죠. 그런데 프로그램을 끄면 그 Map 은 통째로 사라져요. 오늘 가입시킨 회원도, 올린 게시물도 다음에 켜면 흔적도 없죠.
진짜 인스타그램은 앱을 껐다 켜도 내 게시물이 그대로 있어요. 그렇다면 데이터를 어디에 보관해야 꺼져도 살아남을까요? 그리고 또 하나 — 1초에 수천 명이 동시에 가입하면, 우리의 sequence++ 번호표가 두 사람에게 같은 번호를 줄 수도 있지 않을까요? 인메모리 저장소의 한계를 짚어보세요.
3. 예외는 어느 층까지 올라가야 할까?
오늘 서비스 메서드들은 사고가 나면 예외를 던졌고, 그걸 받는 쪽(InstagramApp)이 try-catch 로 잡아 안내로 바꿨어요. 즉, 예외를 서비스가 직접 잡지 않고 받는 쪽까지 올려보낸 거예요.
그런데 서비스가 직접 잡아서 처리할 수도 있었어요. 예외를 어느 층에서 잡는 게 좋을까요? 서비스가 곧장 잡는 것과, 받는 쪽까지 올려보내는 것은 각각 언제 어울릴지 생각해보세요. "이 사고를 어떻게 보여줄지 아는 건 누구인가" 를 기준으로 따져보면 실마리가 보여요.
✅ 예시 답안정답 보기
오늘 과제는 "저장소 패턴을 새 데이터에 복제한다", "한 메서드에서 두 예외를 협력시킨다", "여러 저장소를 엮어 피드를 만든다" 세 가지가 핵심이에요. 모두 오늘 조립한 3계층 흐름의 연장이라, repository·service 패키지를 옆에 두고 풀면 편해요. 람다·스트림 같은 다음 시간 문법은 아직 쓰지 않아요 — 오늘까지 배운 클래스·컬렉션·예외 범위에서 풀면 돼요.
세 과제 모두 정답이 하나는 아니에요. 아래 예시와 다르게 풀었더라도 요구사항을 만족하고 저장소·서비스·예외를 제대로 엮었다면 훌륭한 답이에요.
과제 예시답안
과제 1 예시답안 — CommentRepository 를 직접 만들기
핵심 접근
이 과제의 주제는 "한 번 익힌 저장소 패턴을 새 데이터에 그대로 복제한다" 예요. 회원 저장소와 게시물 저장소가 사물함(Map)·번호표(sequence)·save·findById 구조로 판박이였죠. 댓글 저장소도 똑같아요. 타입만 Comment 로 바꾸면 돼요.
조회·삭제 실패에 어떤 예외를 쓸지가 작은 판단이에요. 댓글은 게시물과 마찬가지로 표준 IllegalArgumentException 으로 충분해요. "이 사고를 잡는 쪽이 따로 구분할 일이 있는가" 를 떠올리면, 댓글 조회 실패는 전용 예외까지 만들 필요가 없거든요.
예시 구현
// com/instagram/javabasic/service/solution/day24/CommentRepository.java
public class CommentRepository {
private final Map<Long, Comment> store = new HashMap<>();
private long sequence = 0L;
// 저장 — 다음 번호표를 뽑아 붙이고 보관한 뒤 그 id 를 돌려줘요.
public Long save(Comment comment) {
sequence++;
Long id = sequence;
store.put(id, comment);
return id;
}
// 조회 — 없으면 표준 예외로 막아요.
public Comment findById(Long id) {
Comment found = store.get(id);
if (found == null) {
throw new IllegalArgumentException("id " + id + " 에 해당하는 댓글이 없어요.");
}
return found;
}
삭제와 개수 세기도 게시물 저장소와 똑같아요.
// 삭제 — 없는 id 를 지우려 하면 표준 예외로 막아요.
public void deleteById(Long id) {
if (!store.containsKey(id)) {
throw new IllegalArgumentException("id " + id + " 에 해당하는 댓글이 없어 지울 수 없어요.");
}
store.remove(id);
}
public int count() {
return store.size();
}
핵심은 store.containsKey(id) 예요. 지우기 전에 "그 번호가 사물함에 있나?" 를 먼저 물어보고, 없으면 예외로 막는 거예요. 이 확인을 빼먹으면 없는 걸 지워도 아무 일 없이 조용히 넘어가, "지운 줄 알았는데 안 지워진" 혼란이 생겨요.
main 으로 굴려볼게요.
public static void main(String[] args) {
CommentRepository repo = new CommentRepository();
Long id = repo.save(new Comment("jaehoon", "좋은 사진이네요!", 0));
System.out.println("저장됨: " + repo.findById(id).getText());
repo.deleteById(id);
System.out.println("삭제 후 개수: " + repo.count());
// 같은 id 를 또 지우면 — 이번엔 막혀요.
try {
repo.deleteById(id);
} catch (IllegalArgumentException e) {
System.out.println("삭제 실패: " + e.getMessage());
}
}
실행하면 이렇게 나와요.
저장됨: 좋은 사진이네요!
삭제 후 개수: 0
삭제 실패: id 1 에 해당하는 댓글이 없어 지울 수 없어요.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| 사물함 + 번호표 | Map<Long, Comment> 와 long sequence 를 갖췄는가 |
상 |
| 자동 id 발급 | save 가 sequence 를 1 올려 붙이고 그 id 를 돌려주는가 |
상 |
| 조회 실패 처리 | findById 가 없을 때 IllegalArgumentException 을 던지는가 |
상 |
| 삭제 전 존재 확인 | deleteById 가 containsKey 로 먼저 확인하는가 |
중 |
| main 시연 | 저장·조회·삭제·삭제 실패 네 경우를 모두 굴렸는가 | 하 |
흔한 실수
- 삭제 전 확인을 빼먹기 →
store.remove(id)는 없는 키를 지워도 예외를 던지지 않아요. 그래서containsKey확인 없이 지우면, 없는 댓글을 지워도 조용히 성공한 것처럼 보여요. "막아야 할 상황" 을 직접 검사해 예외로 알려야 해요. - 번호표를 안 올리고 같은 id 재사용 →
sequence++를 빼먹으면 모든 댓글이 같은 번호를 받아 사물함에서 덮어써져요. 저장할 때마다 1씩 올라가는 게 핵심이에요. - 굳이
CommentNotFoundException을 새로 만들기 → 만들어도 틀린 건 아니지만, 댓글 조회 실패를 잡는 쪽이 따로 구분할 일이 없다면 표준 예외로 충분해요. 전용 예외가 늘수록 관리할 클래스도 늘어나요.
과제 2 예시답안 — EmailChangeService 로 이메일 변경 기능 만들기
핵심 접근
이 과제의 주제는 "한 메서드 안에서 두 예외가 각자 다른 방식으로 협력한다" 예요. 회원을 못 찾는 사고(MemberNotFoundException)는 저장소가 자동으로 던지고, 이메일이 겹치는 사고(DuplicateEmailException)는 서비스가 직접 던져요. 두 사고가 한 흐름에 자연스럽게 녹아드는 거예요.
저장소는 Step 4 처럼 생성자로 전달받아요. 서비스가 직접 만들지 않고 밖에서 받아두면, 회원가입에 쓴 저장소와 같은 사물함을 공유할 수 있어요.
예시 구현
// com/instagram/javabasic/service/solution/day24/EmailChangeService.java
public class EmailChangeService {
private final MemberRepository memberRepository;
public EmailChangeService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 이메일 변경 — 회원을 찾고(없으면 자동 예외), 형식·중복을 검사한 뒤 바꿔요.
public void changeEmail(Long id, String newEmail) {
Member member = memberRepository.findById(id);
if (newEmail == null || !newEmail.contains("@")) {
throw new IllegalArgumentException("이메일 형식이 올바르지 않아요: " + newEmail);
}
// 자기가 이미 쓰는 이메일은 그대로 둬도 되니 검사에서 빼요 — "다른 회원" 이 쓸 때만 막아요.
if (!newEmail.equals(member.getEmail()) && memberRepository.existsByEmail(newEmail)) {
throw new DuplicateEmailException("이미 가입된 이메일이에요: " + newEmail);
}
member.setEmail(newEmail);
}
}
흐름을 따라가 볼게요.
- 첫 줄
findById(id)— 회원을 찾아요. 없으면 저장소가 알아서MemberNotFoundException을 던져요. 우리가 따로if로 검사하지 않아도 돼요. 저장소가 만들어둔 안전선을 그대로 누리는 거예요. - 형식 검사 —
@가 없으면 이메일이 아니니 표준 예외로 막아요. - 중복 검사 — 핵심은
!newEmail.equals(member.getEmail())이에요. 회원이 "자기가 지금 쓰는 이메일" 로 바꾸려 하면,existsByEmail이 자기 자신을 찾아true를 돌려줘요. 그대로 막으면 멀쩡한 변경이 엉뚱하게 거절되죠. 그래서 "새 이메일이 지금 내 것과 다를 때만" 중복 검사를 해요.
main 으로 확인해 볼게요.
public static void main(String[] args) {
MemberRepository repo = new MemberRepository();
EmailChangeService service = new EmailChangeService(repo);
Long jaehoonId = repo.save(new Member("jaehoon", "jaehoon@insta.com"));
repo.save(new Member("minji", "minji@insta.com"));
// 비어 있는 새 이메일로 변경 — 성공
service.changeEmail(jaehoonId, "jaehoon2@insta.com");
System.out.println("변경 후: " + repo.findById(jaehoonId).getEmail());
// 민지가 쓰는 이메일로 변경 시도 — 막힘
try {
service.changeEmail(jaehoonId, "minji@insta.com");
} catch (DuplicateEmailException e) {
System.out.println("변경 실패: " + e.getMessage());
}
}
실행하면 이렇게 나와요.
변경 후: jaehoon2@insta.com
변경 실패: 이미 가입된 이메일이에요: minji@insta.com
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| 저장소 외부 주입 | 생성자로 MemberRepository 를 전달받는가 |
중 |
| 회원 조회 예외 활용 | findById 가 던지는 MemberNotFoundException 을 따로 검사 없이 활용했는가 |
상 |
| 중복 검사 | 다른 회원의 이메일일 때 DuplicateEmailException 을 던지는가 |
상 |
| 자기 이메일 함정 처리 | "자기가 쓰는 이메일" 은 막지 않도록 조건을 더했는가 | 상 |
| main 시연 | 성공·중복 거절 두 경우를 굴렸는가 | 하 |
흔한 실수
- 자기 이메일 함정을 못 보기 →
existsByEmail(newEmail)만 쓰면, 회원이 자기 이메일을 그대로 다시 넣을 때true가 나와 막혀요.!newEmail.equals(member.getEmail())조건을 빠뜨리면 "안 바꾸겠다는데 왜 막혀?" 하는 버그가 생겨요. - 회원 조회를 직접 검사하기 →
if (member == null)로 따로 검사할 필요가 없어요.findById가 이미 없을 때 예외를 던지니까요. 저장소가 깔아둔 안전선을 중복으로 또 깔 이유가 없어요. - 형식 검사를 중복 검사 뒤에 두기 → 순서가 어긋난 이메일(
@없는 값)로도 사물함을 훑게 돼서 헛수고예요. 형식부터 막고, 통과한 것만 중복 검사로 넘기는 게 깔끔해요.
과제 3 예시답안 — FeedService 로 팔로잉 피드 만들기
핵심 접근
이 과제의 주제는 "여러 저장소와 회원의 팔로잉 묶음을 엮어, 흩어진 데이터를 한데 모은다" 예요. 내 피드는 곧 "내가 팔로우한 사람들의 게시물 모음" 이죠. 그러려면 회원 저장소(나를 찾고, 내 팔로잉을 꺼내려고)와 게시물 저장소(그 사람들의 글을 찾으려고)가 둘 다 필요해요. 그래서 생성자로 저장소 둘을 받아요.
만드는 모양은 "바깥 for 로 팔로잉을 한 명씩 돌고, 그 사람마다 게시물을 찾아 모으는" 중첩 반복이에요.
예시 구현
// com/instagram/javabasic/service/solution/day24/FeedService.java
public class FeedService {
private final MemberRepository memberRepository;
private final PostRepository postRepository;
public FeedService(MemberRepository memberRepository, PostRepository postRepository) {
this.memberRepository = memberRepository;
this.postRepository = postRepository;
}
// 피드 만들기 — 내가 팔로우한 사람들의 게시물을 모두 모아 돌려줘요.
public List<Post> buildFeed(Long memberId) {
Member me = memberRepository.findById(memberId);
List<Post> feed = new ArrayList<>();
for (int i = 0; i < me.getFollowingCount(); i++) {
Member followee = me.getFollowing(i);
feed.addAll(postRepository.findByAuthor(followee));
}
return feed;
}
}
흐름을 따라가 볼게요.
findById(memberId)로 나를 찾아요. 없으면 저장소가MemberNotFoundException을 던지고요.me.getFollowingCount()만큼for를 돌면서,getFollowing(i)로 팔로잉을 한 명씩 꺼내요.- 팔로잉 한 명마다
postRepository.findByAuthor(...)로 그 사람의 게시물 묶음을 가져와,feed.addAll(...)로 결과에 모두 합쳐요.
여기서 반복이 두 겹이라는 점을 눈여겨보세요. 바깥은 내가 직접 적은 for(팔로잉 순회)이고, 안쪽은 findByAuthor 속에 숨어 있는 for(게시물 거르기)예요. 팔로잉이 10명이고 각자 글이 많으면, 이 중첩 반복이 꽤 길어져요.
main 으로 확인해 볼게요.
public static void main(String[] args) {
MemberRepository memberRepo = new MemberRepository();
PostRepository postRepo = new PostRepository();
FeedService feedService = new FeedService(memberRepo, postRepo);
Member me = new Member("jaehoon", "jaehoon@insta.com");
Member minji = new Member("minji", "minji@insta.com");
Member seungwoo = new Member("seungwoo", "seungwoo@insta.com");
Long meId = memberRepo.save(me);
memberRepo.save(minji);
memberRepo.save(seungwoo);
me.follow(minji);
me.follow(seungwoo);
postRepo.save(new Post("민지의 첫 글", minji, 0));
postRepo.save(new Post("민지의 둘째 글", minji, 0));
postRepo.save(new Post("승우의 글", seungwoo, 0));
List<Post> feed = feedService.buildFeed(meId);
System.out.println("내 피드 게시물 수: " + feed.size());
}
실행하면 내 피드 게시물 수: 3 이 나와요. 민지의 글 둘, 승우의 글 하나가 모인 거죠.
채점 포인트
| 항목 | 배점 기준 | 가중 |
|---|---|---|
| 저장소 둘 주입 | 생성자로 회원·게시물 저장소를 둘 다 전달받는가 | 상 |
| 회원 조회 | findById 로 나를 찾고, 없으면 예외가 흐르게 두는가 |
중 |
| 팔로잉 순회 | getFollowingCount·getFollowing(i) 로 팔로잉을 한 명씩 도는가 |
상 |
| 게시물 합치기 | findByAuthor 결과를 addAll 로 모으는가 |
상 |
| 중첩 반복 인식 | 반복이 두 겹으로 도는 구조를 이해했는가 | 중 |
흔한 실수
add와addAll을 헷갈리기 →findByAuthor는 게시물 묶음(List)을 돌려줘요.feed.add(...)로 넣으면 "리스트 안에 리스트" 가 들어가 버려요. 묶음을 통째로 펼쳐 합치려면addAll(...)을 써야 해요.- 팔로잉을 직접
Map에서 찾으려 하기 → 팔로잉 정보는 회원(Member) 자신이 배열로 안고 있어요. 저장소를 또 뒤질 필요 없이me.getFollowing(i)로 바로 꺼내면 돼요. - 반복이 길어지는 걸 그냥 넘기기 → 이 중첩
for가 번거롭게 느껴졌다면 좋은 감각이에요. 다음 시간에 배울 도구로 이 반복을 훨씬 짧게 줄일 수 있거든요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 검증은 어느 층의 일일까?
[문제 상황 요약]
오늘 우리는 캡션 길이 검사를 저장소가 아니라 서비스(PostService)에 뒀어요. 저장소는 받은 게시물을 넣기만 하고, "캡션이 너무 긴가?" 같은 판단은 하지 않죠. 그런데 "어차피 저장소가 데이터의 문지기인데, 검사도 저장소가 하면 안 되나?" 싶기도 해요. 검증을 어느 층에 두는 게 좋을까요?
[튜터의 가이드 및 해설]
먼저 "검증" 이 한 종류가 아니라는 걸 짚어야 해요. 크게 세 결이 있어요.
- 형식 검증 — "이메일에
@가 있나", "캡션이 비었나" 같은 모양 검사. 받는 쪽에 가까울수록 좋아요. 잘못된 입력을 일찍 거를수록 안쪽까지 들어와 헛수고하는 걸 막거든요. - 비즈니스 규칙 검증 — "이미 가입된 이메일인가", "보관된 글은 못 고친다" 같은 우리 서비스만의 규칙. 이건 서비스의 몫이에요. 규칙은 서비스마다 다를 수 있으니까요.
- 최후의 보루 — 그래도 새는 걸 데이터 계층에서 마지막으로 막는 거예요. 우리 메모리 저장소는 이게 약하지만, 진짜 데이터베이스라면 "이메일 중복 금지" 같은 제약을 DB 자체에 걸어둘 수 있어요.
그럼 왜 저장소에 비즈니스 검증을 두지 않을까요? 저장소는 여러 서비스가 함께 쓰는 공용 부품이기 때문이에요. 캡션 길이 규칙을 저장소에 박아두면, 다른 곳에서 "이번엔 규칙 없이 그냥 저장하고 싶은" 경우에 손발이 묶여요. 저장소는 규칙을 모르게 두고, 규칙은 그때그때 서비스가 챙기는 게 유연해요.
실무에서는 이렇게 정리해요. "이 데이터가 모양이 맞는가" 는 입구에서, "우리 서비스 규칙에 맞는가" 는 서비스에서, "그래도 깨지면 안 되는 최소 약속" 은 데이터 계층에서. 한 검증을 한 층에만 의지하지 않고, 층마다 자기 수준의 방어선을 갖는 거예요.
🎯 면접관을 홀리는 핵심 멘트
"검증은 한 군데서 몰아 하는 게 아니라 층마다 역할이 달라요. 형식 검사는 입구에서 일찍 걸러 헛수고를 막고, 비즈니스 규칙은 서비스가 판단하고, 데이터 계층은 최후의 보루로 최소한의 약속을 지켜요. 저장소에 비즈니스 규칙을 박지 않는 이유는, 저장소가 여러 서비스가 공유하는 공용 부품이라 특정 규칙에 묶이면 재사용성이 떨어지기 때문입니다."
생각해볼 주제 2 예시답안 — 메모리에만 사는 데이터는 어디로 갈까?
[문제 상황 요약]
우리 저장소는 데이터를 Map 에 담아요. 빠르고 간단하죠. 그런데 프로그램을 끄면 그 Map 은 통째로 사라져요. 오늘 가입시킨 회원도, 올린 게시물도 다음에 켜면 흔적이 없죠. 진짜 인스타그램은 앱을 껐다 켜도 내 게시물이 그대로인데 말이에요. 데이터를 어디에 보관해야 꺼져도 살아남을까요?
[튜터의 가이드 및 해설]
핵심은 "메모리(RAM)는 휘발성" 이라는 점이에요. 전원이 끊기면 그 안의 모든 게 날아가요. Map 은 프로그램이 도는 동안에만 살아 있는 임시 공간이라, 우리 데이터도 프로그램과 운명을 같이해요.
오래 살아남게 하려면, 전원이 꺼져도 남는 곳에 적어둬야 해요. 가장 단순하게는 파일에 저장하는 거고, 본격적으로는 데이터베이스(database)를 써요. 데이터베이스는 데이터를 디스크에 안전하게 보관하고, 빠르게 찾고, 여럿이 동시에 다뤄도 꼬이지 않게 관리해주는 전문 도구예요. 다음 과목에서 깊이 만나게 될 거예요.
여기서 또 하나 짚을 게 있어요. 우리의 번호표 sequence++ 예요. 한 명씩 차례로 가입할 땐 멀쩡하지만, 1초에 수천 명이 동시에 가입한다고 해봐요.
두 요청이 거의 같은 순간에 sequence 의 값을 읽으면, 둘 다 같은 번호를 보고 같은 id 를 발급할 수 있어요. 번호표 기계가 같은 번호를 두 장 뽑는 셈이죠. 이런 걸 동시성 문제(race condition, 경쟁 상태)라고 불러요.
진짜 데이터베이스는 번호 발급을 "한 번에 한 명씩" 으로 안전하게 보장해줘서 이 문제를 막아요. 우리가 손으로 만든 인메모리 저장소는 단순해서 좋지만, 영속성(꺼져도 남기)과 동시성(여럿이 동시에) 두 가지가 약해요. 그래서 실무에선 이 둘을 데이터베이스에 맡기는 거예요.
🎯 면접관을 홀리는 핵심 멘트
"인메모리 저장소는 빠르고 단순하지만 두 가지가 약해요 — 전원이 꺼지면 데이터가 날아가는 휘발성과, 여럿이 동시에 접근할 때 번호가 꼬이는 동시성 문제죠. 그래서 영속성과 동시 접근 제어가 필요한 실제 서비스는 데이터베이스에 저장을 맡깁니다. 인메모리는 캐시나 테스트처럼 '잠깐 빠르게' 가 필요한 곳에 쓰고요."
생각해볼 주제 3 예시답안 — 예외는 어느 층까지 올라가야 할까?
[문제 상황 요약]
오늘 서비스 메서드들은 사고가 나면 예외를 던졌고, 받는 쪽(InstagramApp)이 try-catch 로 잡아 안내로 바꿨어요. 즉, 서비스가 예외를 직접 잡지 않고 받는 쪽까지 올려보낸 거예요. 그런데 서비스가 곧장 잡을 수도 있었죠. 예외를 어느 층에서 잡는 게 좋을까요?
[튜터의 가이드 및 해설]
판단 기준은 하나예요. "이 사고를 의미 있게 처리할 수 있는 층이 어디인가" 죠.
서비스를 다시 떠올려봐요. MemberService 는 "이메일이 겹쳤다" 는 사실은 알지만, 그걸 사용자에게 어떻게 보여줄지는 몰라요. 콘솔이면 글자로 안내하고, 화면이면 입력칸을 빨갛게 하고, 다른 프로그램이 부른 거면 또 다른 신호로 답해야 하죠.
서비스가 이걸 다 알 순 없어요. 그래서 서비스는 "사고가 났다" 만 예외로 알리고, "어떻게 보여줄지" 는 받는 쪽에 맡기는 거예요.
그래서 오늘 InstagramApp 이 try-catch 로 잡았어요. 받는 쪽은 사용자에게 무엇을 어떻게 보여줄지 아는 층이니까요. 예외를 잡는 자리는 "그 사고에 대해 실제로 무언가 할 수 있는 곳" 이어야 해요. 재시도하거나, 안내를 띄우거나, 대체 동작으로 넘어가거나 — 의미 있는 대응이 가능한 층에서 잡는 거죠.
반대로 피해야 할 건 "잡아놓고 아무것도 안 하기" 예요. 서비스가 예외를 catch 해서 그냥 삼켜버리면, 사고가 났는데도 위층은 아무 일 없던 것처럼 흘러가요. 지난 시간 안티패턴에서 본 "빈 catch" 죠. 처리할 수 없는 사고라면, 차라리 잡지 말고 위로 올려보내는 게 정직해요. 자기가 해결할 수 있을 때만 잡고, 아니면 흘려보내는 — 그게 예외를 다루는 기본 자세예요.
🎯 면접관을 홀리는 핵심 멘트
"예외는 '그 사고에 대해 실제로 무언가 할 수 있는 층' 에서 잡아야 해요. 서비스는 '무슨 사고가 났는지' 는 알아도 '사용자에게 어떻게 보여줄지' 는 모르니, 보여주는 방법을 아는 받는 쪽까지 예외를 올려보내는 게 자연스럽죠. 처리할 수도 없으면서 잡아서 삼키는 건 사고를 숨기는 최악의 안티패턴이고요. 잡을 수 있을 때만 잡고, 아니면 흘려보내는 게 원칙입니다."
