본문으로 건너뛰기
Advertisement

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에서 @OneToManyLazy 연관관계를 트랜잭션 밖에서 접근하면 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로 불변 보장, 테스트 시 주입이 쉬움.
  • 필드 주입(@Autowired on 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 상태 코드·에러 코드·메시지를 매핑해 일관된 에러 응답을 만듭니다.
    “어디서 터졌는지”보다 “클라이언트에게 뭘 보여줄지”를 한 곳에서 관리하는 것이 실전에 유리합니다.

이 목록은 “실전에서 자주 쓰는 지식” 위주로 정리한 것입니다. 팀 컨벤션과 도메인에 맞게 골라 쓰고, 새로 겪은 함정이 있으면 팀 문서나 이 가이드에 한 줄씩 추가해 두면 다음에 큰 도움이 됩니다.

Advertisement