12.4 실전 고수 팁 (Pro Tips)
실무에서 자주 맞닥뜨리는 함정과, 고급 개발자들이 쓰는 패턴을 정리했습니다. 한 번씩 떠올리면 디버깅과 설계에 도움이 됩니다.
1. @Transactional은 같은 클래스 내부 호출에 걸리지 않는다
스프링의 @Transactional은 프록시를 통해 동작합니다. 따라서 같은 빈(Bean) 안에서 다른 메서드를 직접 호출하면 프록시를 거치지 않아 트랜잭션이 시작되지 않습니다.
@Service
public class OrderService {
public void placeOrder(OrderRequest req) {
validate(req);
saveOrder(req); // ❌ 같은 클래스 내부 호출 → @Transactional 무시됨
}
@Transactional(rollbackFor = Exception.class)
public void saveOrder(OrderRequest req) { ... }
}
해결: 트랜잭션이 필요한 메서드는 다른 빈에서 호출하거나, self 빈을 주입해 self.saveOrder(req)처럼 호출하는 식으로 프록시를 타게 만듭니다. 또는 트랜잭션 경계를 placeOrder 상단으로 올리는 것이 더 단순합니다.
2. LazyInitializationException: 트랜잭션 범위와 Fetch 전략
JPA에서 @OneToMany 등 Lazy 연관관계를 트랜잭션 밖에서 접근하면 LazyInitializationException이 납니다. “세션이 이미 닫혔다”는 뜻입니다.
- 서비스 계층에서 조회만 하는 메서드에는
@Transactional(readOnly = true)를 달아서, 트랜잭션(세션)이 메서드 끝까지 유지되게 하세요. - 필요하면
join fetch또는 **@EntityGraph**로 N+1을 막고, 한 번에 필요한 데이터만 가져오세요.
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) {
Order order = orderRepository.findByIdWithItems(id); // join fetch
return OrderDto.from(order); // order.getItems() 접근 시 세션 유효
}
3. N+1 문제: List 조회 시 batch size·fetch 전략
연관 엔티티가 Lazy일 때, 리스트를 순회하며 getItems() 등을 호출하면 N+1번 쿼리가 나갑니다.
@BatchSize(size = 100)(엔티티 또는 연관 필드에): in 쿼리로 묶어서 조회.- Repository에서
join fetch또는 **@EntityGraph**로 한 번에 가져오기. - 정말 목록만 필요하면 DTO 전용 쿼리(JPQL Projection, Native)로 필요한 컬럼만 조회하는 것이 가장 안전합니다.
4. 생성자 주입만 쓰고 필드 주입은 피하기
- 생성자 주입: 필수 의존성이 명확하고,
final로 불변 보장, 테스트 시 주입이 쉬움. - 필드 주입(
@Autowiredon field): 테스트할 때 Mock 주입이 어렵고, 순환 참조를 런타임까지 미룸.
실무에서는 생성자 1개면 @Autowired 생략 가능하므로, @RequiredArgsConstructor + final 필드 조합을 추천합니다.
5. 테스트: 필요한 만큼만 컨텍스트 띄우기
- 전체 플로우가 필요할 때만
@SpringBootTest를 쓰고, 컨트롤러만 검증할 때는@WebMvcTest, JPA만 검증할 때는@DataJpaTest를 쓰세요. @SpringBootTest를 남발하면 테스트가 느려지고, 불필요한 빈까지 올라가서 실패 원인 파악이 어려워집니다.- 통합 테스트는 Testcontainers로 DB를 고정하면 로컬/CI 환경이 같아져 재현성이 좋아집니다.
6. 환경별 설정 분리와 비밀값
- **
application-{profile}.yml**로 환경(dev, prod)을 나누고,spring.config.activate.on-profile또는 실행 인자로 프로파일을 지정하세요. - DB 비밀번호·API 키는
application.yml에 평문으로 두지 말고, 환경 변수나 Vault 등으로 주입하세요.
예:password: ${DB_PASSWORD:}(기본값 없이 넣으면 키 없을 때 기동 실패로 누락을 바로 알 수 있음).
7. 로그에 비밀·토큰·전체 요청 본문 넣지 않기
- 요청 본문을 로그로 남길 때 비밀번호·토큰·카드 번호 등은 마스킹하거나 제외하세요.
- 에러 로그에
e.getMessage()만 넣고, 스택 트레이스는 운영 환경에서는 로그 레벨을 조정해 필요한 경우에만 남기도록 하세요. (보안·용량)
8. Optional은 서비스에서 바로 처리하기
- Repository는
Optional을 반환하고, 서비스 계층에서orElseThrow(() -> new NotFoundException(...))등으로 한 번에 처리하는 패턴이 유지보수에 유리합니다. - 컨트롤러에서
Optional을 그대로 반환하지 말고, “없음”은 404 + 명확한 메시지로 매핑하세요.
9. API에는 Entity 대신 DTO 사용
- Entity를 그대로 JSON으로 노출하면 연관관계·Lazy·엔티티 변경 이력이 API 스펙에 묶이고, 보안·성능 문제가 생깁니다.
- 요청/응답은 전용 DTO로 받고 반환하고, 서비스는 Entity를 다루며 DTO 변환은 서비스 또는 전용 Mapper에서 처리하세요.
10. HikariCP 풀 크기 가이드
- 공식 권장:
connections ≈ (core_count * 2) + effective_spindle_count수준. - CPU 코어만 쓰는 일반 웹 앱은 코어 수의 2배 전후로 두고, 부하 테스트로 조정하는 경우가 많습니다.
- 풀을 너무 크게 잡으면 DB 쪽 연결 수와 메모리만 늘어나고 오히려 느려질 수 있습니다.
11. API 버전은 URL 경로에
/api/v1/users,/api/v2/users처럼 경로에 버전을 두면 캐시·클라이언트 배포·문서화가 단순해집니다.- 헤더 버전 관리도 가능하지만, 로그·모니터링·OpenAPI 문서와 맞추기 쉽다는 점에서 경로 버전을 많이 씁니다.
12. 예외는 계층별로 처리·변환
- Controller에서는 가능한 한 구체적인 예외만 잡고, 나머지는
@RestControllerAdvice에서 처리합니다. - Service는 도메인/비즈니스 예외를 던지고, Advice에서 HTTP 상태 코드·에러 코드·메시지를 매핑해 일관된 에러 응답을 만듭니다.
“어디서 터졌는지”보다 “클라이언트에게 뭘 보여줄지”를 한 곳에서 관리하는 것이 실전에 유리합니다.
팁
이 목록은 “실전에서 자주 쓰는 지식” 위주로 정리한 것입니다. 팀 컨벤션과 도메인에 맞게 골라 쓰고, 새로 겪은 함정이 있으면 팀 문서나 이 가이드에 한 줄씩 추가해 두면 다음에 큰 도움이 됩니다.