프로젝트가 성장하면 여러 사람이 코드를 작성하게 되고, "이건 이렇게 했는데 저건 왜 저렇게 했지?"라는 의문이 곳곳에서 터져 나온다. 코드 일관성은 단순한 미학의 문제가 아니라, 유지보수 비용에 직결되는 실용적인 문제다. 여기에 JPA의 N+1 쿼리 문제까지 더해지면, 운영 환경에서 성능 병목으로 이어진다.
Mapper 패턴 일관성
엔티티를 DTO로 변환하는 Mapper는 프로젝트에서 가장 자주 등장하는 유틸리티 클래스 중 하나다. 문제는 프로젝트 전체에서 일관된 패턴을 사용하지 않을 때 발생한다.
static vs instance 메서드
// 패턴 A: static 메서드
@Component
public class BinaryContentMapper {
public static BinaryContentDto toDto(BinaryContent content) {
return new BinaryContentDto(content.getId(), ...);
}
}
// 패턴 B: instance 메서드 (의존성 주입 활용)
@Component
@RequiredArgsConstructor
public class UserMapper {
private final SessionManager sessionManager;
public UserDto toDto(User user) {
return new UserDto(user.getId(), ...,
sessionManager.isOnline(user.getUsername()));
}
}
- 패턴 A는 외부 의존성이 없을 때 적합하다.
BinaryContentMapper.toDto(content)형태로 어디서든 호출 가능. - 패턴 B는 다른 빈의 도움이 필요할 때 사용한다.
SessionManager없이는 온라인 상태를 판단할 수 없다.
문제는 같은 클래스에 static과 instance 메서드가 혼재하거나, 프로젝트 전체에서 패턴이 뒤섞이는 것이다.
일관된 Mapper 전략
선택 기준은 명확하다.
- 다른 빈이 필요하면 — instance 메서드,
@Component로 등록, 생성자 주입 - 순수 변환이면 — static 메서드, 유틸리티 클래스로 사용 (이 경우
@Component는 불필요)
한 프로젝트에서 두 패턴이 공존할 수 있지만, 같은 Mapper 클래스 안에서는 하나의 패턴만 사용해야 한다. static 메서드인데 @Component가 붙어있다면 혼란의 신호다.
필드 접근 방식 통일
// getter 사용
readStatus.getId()
readStatus.getLastReadAt()
// 필드 직접 접근
readStatus.user.getId()
readStatus.channel.getId()
같은 Mapper 안에서 getter를 쓰다가 갑자기 필드에 직접 접근하면, "의도적인 건가? 아니면 실수인가?"라는 의문이 생긴다. 항상 getter를 사용하는 것이 캡슐화 원칙에도 맞고, 나중에 접근 제어를 변경할 때도 안전하다.
N+1 쿼리 문제
JPA를 사용할 때 가장 빈번하게 마주치는 성능 문제다.
N+1이란
"1개의 쿼리로 N개의 부모를 조회하고, 각 부모마다 1개의 추가 쿼리로 자식을 조회"하는 패턴이다.
// 채널 목록 조회
List<Channel> channels = channelRepository.findAll(); // 쿼리 1회
for (Channel channel : channels) {
channel.getParticipants().size(); // 채널마다 쿼리 1회씩 추가!
}
// 총 쿼리: 1 + N (채널 수만큼 추가 쿼리)
채널이 10개면 11개의 쿼리, 100개면 101개의 쿼리가 실행된다.
Fetch Join으로 해결
// Repository
@Query("SELECT c FROM Channel c LEFT JOIN FETCH c.participants WHERE c.type = 'PUBLIC'")
List<Channel> findAllPublicWithParticipants();
JOIN FETCH를 사용하면 한 번의 쿼리로 채널과 참가자를 함께 조회한다.
-- 실제 실행되는 SQL
SELECT c.*, u.*
FROM channels c
LEFT JOIN channel_participants cp ON c.id = cp.channel_id
LEFT JOIN users u ON cp.user_id = u.id
WHERE c.type = 'PUBLIC'
@EntityGraph
@Query 대신 @EntityGraph를 사용할 수도 있다.
@EntityGraph(attributePaths = {"participants"})
@Query("SELECT c FROM Channel c WHERE c.type = 'PUBLIC'")
List<Channel> findAllPublicWithParticipants();
@EntityGraph는 JPQL을 직접 작성하지 않고도 페치 전략을 선언적으로 지정할 수 있어, 간단한 경우에 더 깔끔하다.
Batch Size 설정
모든 곳에 Fetch Join을 적용하기 어려울 때, 배치 사이즈로 N+1의 영향을 줄일 수 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이렇게 하면 지연 로딩 시 한 번에 최대 100개의 엔티티를 IN 쿼리로 가져온다. N+1이 N/100+1로 줄어든다.
미사용 코드 정리
프로젝트가 진화하면서 더 이상 사용되지 않는 클래스, DTO, enum 등이 남아 있는 경우가 흔하다.
미사용 코드의 비용
- 인지 부하 — 새로운 팀원이 코드를 읽을 때 "이건 어디서 쓰이지?" 하고 시간을 낭비한다.
- 빌드 시간 — 컴파일과 테스트 대상이 불필요하게 늘어난다.
- 혼란 — 비슷한 이름의 사용/미사용 클래스가 공존하면 잘못된 것을 import하는 사고가 생긴다.
정리 기준
IDE의 "Find Usages" 기능이나 grep을 활용해 참조가 없는 코드를 찾고, 과감하게 삭제한다. 버전 관리 시스템(Git)이 있으므로 필요하면 언제든 복원할 수 있다.
메서드명과 변수명
코드는 사람이 읽는 것이다. 오타가 있는 메서드명은 작은 문제처럼 보이지만, API로 노출되면 수정이 어려워진다.
// 오타
public Instant getLastMassageAt(Channel channel) { }
// 수정
public Instant getLastMessageAt(Channel channel) { }
Massage(마사지)와 Message(메시지)의 차이. 내부 메서드라면 리팩토링으로 쉽게 고칠 수 있지만, 공개 API의 응답 필드에 오타가 포함되면 하위 호환성 때문에 수정하기 매우 까다로워진다.
명명 규칙 체크리스트
- 오타 검사 — IDE의 spell checker를 활성화
- 일관된 동사 —
get,find,fetch중 하나로 통일 - boolean 반환 —
is,has,can접두사 사용 - 컬렉션 반환 — 복수형 사용 (
getUsers,findAllChannels)
심화 분석
지연 로딩 vs 즉시 로딩
JPA의 관계 매핑에서 FetchType은 성능에 직접적인 영향을 준다.
- LAZY (지연 로딩) — 연관 엔티티를 실제로 접근할 때 쿼리 실행.
@OneToMany,@ManyToMany의 기본값. - EAGER (즉시 로딩) — 부모를 조회할 때 연관 엔티티도 함께 조회.
@ManyToOne,@OneToOne의 기본값.
@Entity
public class Channel {
@OneToMany(mappedBy = "channel", fetch = FetchType.LAZY) // 기본값
private Set<User> participants;
}
실무에서는 모든 관계를 LAZY로 설정하고, 필요한 곳에서만 Fetch Join이나 @EntityGraph로 함께 조회하는 것이 권장된다. EAGER는 의도하지 않은 쿼리 폭발을 일으키기 쉽다.
쿼리 로그로 N+1 탐지
개발 환경에서 쿼리 로그를 활성화하면 N+1 문제를 쉽게 발견할 수 있다.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
자주 하는 실수
@Component가 붙은 static-only 클래스
@Component
public class PageResponseMapper {
public static <T> PageResponse<T> fromPage(Page<T> page) { ... }
}
모든 메서드가 static이고 외부 의존성이 없는데 @Component가 붙어 있다면, 불필요하게 스프링 빈으로 등록되는 것이다. 이 경우 @Component를 제거하고 유틸리티 클래스로 사용하거나, 인스턴스 메서드로 전환해야 한다.
Fetch Join의 페이징 주의
// 위험: 컬렉션 Fetch Join + 페이징
@Query("SELECT c FROM Channel c JOIN FETCH c.participants")
Page<Channel> findAllWithParticipants(Pageable pageable);
하이버네이트는 컬렉션을 Fetch Join하면서 페이징을 사용하면 메모리에서 페이징을 수행한다(전체 데이터를 메모리로 로드한 뒤 잘라냄). 이것은 대량 데이터에서 OOM(Out of Memory)을 일으킬 수 있다.
해결 방법은 @BatchSize를 사용하거나, @EntityGraph와 함께 distinct를 활용하되 페이징은 부모 엔티티 기준으로만 적용하는 것이다.
enum 사용처 확인 없이 방치
public enum ChannelType {
TEXT("텍스트"), VOICE("음성");
}
비슷한 역할의 ChannelScope enum이 이미 사용되고 있는데 ChannelType이 남아 있다면, 어느 것이 정식인지 혼란이 생긴다. 미사용 enum은 반드시 정리해야 한다.
면접 Q&A
N+1 문제는 1개의 쿼리로 N개의 부모 엔티티를 조회한 후, 각 부모의 연관 엔티티를 조회하기 위해 N개의 추가 쿼리가 발생하는 현상입니다. Fetch Join(JOIN FETCH)을 사용하면 한 번의 쿼리로 연관 데이터를 함께 조회할 수 있고, @EntityGraph나 default_batch_fetch_size 설정으로도 해결할 수 있습니다.
스프링 AOP 프록시 기반으로 동작합니다. @Transactional이 붙은 빈을 스프링이 감지하면 프록시 객체를 생성하고, 외부에서 메서드를 호출할 때 프록시가 트랜잭션 시작 → 원본 메서드 호출 → 커밋/롤백 순서로 처리합니다. 따라서 같은 클래스 내부 호출이나 private 메서드에서는 동작하지 않습니다.
@Value는 문자열 기반이라 타입 안전성이 없고, 설정값이 여러 클래스에 분산됩니다. @ConfigurationProperties는 설정을 타입 안전한 객체로 바인딩하고, Bean Validation과 결합하여 애플리케이션 시작 시점에 설정값을 검증할 수 있습니다. IDE 자동완성, relaxed binding 등 개발 편의 기능도 제공합니다.
PUT은 리소스 전체를 교체합니다. 보내지 않은 필드는 null로 처리됩니다. PATCH는 보낸 필드만 부분 수정합니다. 사용자 이름만 변경하고 싶다면 PATCH가 적합합니다.
체크 예외(Exception 하위)는 컴파일러가 처리를 강제하고, 언체크 예외(RuntimeException 하위)는 강제하지 않습니다. 스프링의 @Transactional은 기본적으로 언체크 예외에서만 롤백하므로, 체크 예외가 발생하는 트랜잭션에서는 rollbackFor를 명시해야 합니다.