🛠 리팩토링 1단계: 객체 연관관계 맺기 & N+1 문제 직접 확인하기
문자열로 작성자를 관리하던 구조를
JPA 연관관계 기반 설계로 리팩토링하며 N+1 문제를 직접 재현하고 해결한 과정
1️⃣ 왜 리팩토링이 필요했을까?
기존 Article 엔티티에서는 작성자를 아래와 같이 관리하고 있었다.
private String author; // 작성자 이메일 문자열
❌ 이 구조의 문제점
- 작성자의 닉네임, ID, 권한 정보가 필요해질 때마다
→ UserRepository를 매번 조회 - 객체 간 관계가 아니라 문자열 의존
- DB 관점, 객체 관점 모두에서 확장성이 낮음
👉 객체지향 설계와 JPA를 쓰는 의미가 사라지는 구조
2️⃣ 리팩토링 목표
- Article이 작성자를 문자열이 아닌 User 객체로 직접 참조
- DB에는 외래키(FK) 로 저장
- 이후 발생할 N+1 문제를 눈으로 확인하고 해결까지 진행
3️⃣ Article 엔티티 리팩토링 (Before → After)
🔴 Before (리팩토링 전)
private String author;
🟢 After (리팩토링 후)
// Article.java
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
@JoinColumn(name = "author_id") // DB에는 FK로 저장
private User author;
@Builder
public Article(User author, String title, String content) {
this.author = author;
this.title = title;
this.content = content;
}
✅ 변경 포인트 정리
| 항목 | Before | After |
| 작성자 타입 | String | User |
| DB 저장 방식 | 이메일 문자열 | author_id (FK) |
| 연관관계 | 없음 | @ManyToOne |
| 로딩 전략 | 없음 | LAZY |
객체지향 설계 원칙에 따라
Article → User 단방향 연관관계만 우선 적용
(양방향은 실제 요구사항 발생 시 확장)
4️⃣ 왜 “빨간 줄(컴파일 에러)”이 터지기 시작할까?
author의 타입을 String → User로 바꾸는 순간,
기존 코드들이 연쇄적으로 깨지기 시작한다.
🔥 에러가 발생하는 지점들
- AddArticleRequest
- toEntity(String author) → 이제 User를 요구
- BlogService
- request.toEntity(userName)에서 타입 불일치
- 권한 체크 로직
// 기존
article.getAuthor().equals(userName);
// 수정 필요
article.getAuthor().getEmail().equals(userName);
👉 이 파급 효과가 바로 “진짜 리팩토링”
5️⃣ BlogService 리팩토링
@RequiredArgsConstructor
@Service
public class BlogService {
private final BlogRepository blogRepository;
private final UserRepository userRepository;
public Article save(AddArticleRequest request, String userName) {
// 이메일로 User 엔티티 조회
User user = userRepository.findByEmail(userName)
.orElseThrow(() -> new IllegalArgumentException("not found: " + userName));
// User 객체를 직접 전달
return blogRepository.save(request.toEntity(user));
}
private void authorizeArticleAuthor(Article article) {
String userName = SecurityContextHolder.getContext()
.getAuthentication().getName();
if (!article.getAuthor().getEmail().equals(userName)) {
throw new IllegalArgumentException("not authorized");
}
}
}
6️⃣ DTO까지 파급된다: ArticleViewResponse 수정
이제 article.getAuthor()는 User 객체다.
화면에는 User 전체가 아니라 닉네임 or 이메일만 필요하다.
@NoArgsConstructor
@Getter
public class ArticleViewResponse {
private Long id;
private String title;
private String content;
private LocalDateTime createdAt;
private String author; // 화면 출력용
public ArticleViewResponse(Article article) {
this.id = article.getId();
this.title = article.getTitle();
this.content = article.getContent();
this.createdAt = article.getCreatedAt();
// 🚨 이 한 줄이 N+1의 스위치
this.author = article.getAuthor().getNickname();
}
}
7️⃣ 드디어 N+1 문제가 터진다
처음엔 왜 N+1이 안 보였을까?
select * from article;
- 글 목록만 조회
- 작성자 정보 접근 ❌
- Lazy Loading 발동 ❌
👉 그래서 쿼리 1번만 실행됨
작성자 닉네임을 쓰는 순간
for (Article article : articles) {
System.out.println(article.getAuthor().getNickname());
}
실행 로그
select * from article; -- 1번
select * from users where id=?; -- 2번
select * from users where id=?; -- 3번
📌 글 2개 → 쿼리 3번
📌 글 100개 → 쿼리 101번
👉 이게 바로 N+1 문제
8️⃣ 오해 바로잡기
❓ “다른 사람 글을 가져와서 문제인가요?”
❌ 아니다.
진짜 문제는?
- 글 먼저 조회
- 그 다음 작성자 정보를 하나씩 다시 조회
- 데이터를 가져오는 ‘순서와 방식’의 문제
9️⃣ 해결: Fetch Join
BlogRepository 수정
public interface BlogRepository extends JpaRepository<Article, Long> {
@Query("select a from Article a join fetch a.author")
List<Article> findAll();
}
BlogService는 다시 단순해진다
public List<Article> findAll() {
return blogRepository.findAll();
}
실행 결과
select a.*, u.*
from article a
join users u on u.id = a.author_id;
✅ 쿼리 단 1번
✅ N+1 완전 해결
'프로젝트 > 스프링 부트 3 백엔드 개발자 되기 Blog + 선착순 강의 프로젝트' 카테고리의 다른 글
| 👥💡 Querydsl 페이징: fetchResults()가 비권장(Deprecated)된 이유와 실무 정석 패턴 (0) | 2026.02.14 |
|---|---|
| 👥🚨 수정시간 및 수정하기 작성자 권한 변경 (0) | 2026.02.14 |
| 👥🚨 수정은 됐는데 화면에는 “실패”라고 뜬다? (1) | 2026.02.07 |
| 👥🚨 로컬로 실행 후 배포한 서버에도 DB가 적용되는 이유 (0) | 2026.02.07 |
| 👥🚨 배포 후 로컬 실행 시 OAuth2 승인 오류 (0) | 2026.02.07 |