Spring은 Java 생태계에서 가장 인기 있는 애플리케이션 프레임워크로, IoC 컨테이너, AOP, MVC, 트랜잭션 관리 등 다양한 기능을 제공하여 엔터프라이즈 애플리케이션 개발을 크게 단순화합니다.
Spring 핵심 모듈
Spring 프레임워크는 여러 모듈로 구성되어 있으며 필요에 따라 도입할 수 있습니다:
| 모듈 | 설명 |
|---|---|
| spring-core | IoC 컨테이너의 핵심 기반 |
| spring-beans | BeanFactory — Bean 생성 및 관리 담당 |
| spring-context | ApplicationContext — beans 기반에 더 풍부한 기능 제공 |
| spring-aop | 관점 지향 프로그래밍 지원 |
| spring-webmvc | Spring MVC 프레임워크 |
| spring-tx | 트랜잭션 관리 |
| spring-data-jpa | JPA 통합 |
| spring-boot | 관례 우선 설정, 자동 구성 |
IoC(제어의 역전)와 DI(의존성 주입)
IoC란 무엇인가
전통적인 방식에서는 객체가 직접 의존 객체를 생성합니다:
public class UserService {
// 직접 new — 결합도가 높음
private UserRepository repo = new UserRepositoryImpl();
}
IoC(Inversion of Control)는 객체 생성과 의존 관계 관리를 Spring 컨테이너에 위임합니다. 객체는 필요한 것만 선언하고, 컨테이너가 주입합니다:
@Service
public class UserService {
private final UserRepository repo;
// Spring이 자동으로 주입
public UserService(UserRepository repo) {
this.repo = repo;
}
}
제어권이 객체 자신에서 컨테이너로 "역전"되는 것 — 이것이 IoC의 핵심 사상입니다.
DI의 세 가지 방식
1. 생성자 주입(권장)
@Service
public class OrderService {
private final UserRepository userRepo;
private final ProductRepository productRepo;
// 생성자가 하나뿐일 때 @Autowired 생략 가능
public OrderService(UserRepository userRepo, ProductRepository productRepo) {
this.userRepo = userRepo;
this.productRepo = productRepo;
}
}
장점: 의존을 final로 선언할 수 있어 불변성이 보장되고, 테스트하기 쉬우며, 객체 생성 시점에 의존이 준비됩니다.
2. 세터 주입
@Service
public class NotificationService {
private EmailSender emailSender;
@Autowired
public void setEmailSender(EmailSender emailSender) {
this.emailSender = emailSender;
}
}
선택적 의존이나 런타임에 구현체를 동적으로 교체해야 하는 시나리오에 적합합니다.
3. 필드 주입(비권장)
@Service
public class UserService {
@Autowired
private UserRepository userRepo; // final 선언 불가, 단위 테스트 어려움
}
코드는 간결하지만 final을 사용할 수 없고, 컨테이너 외부에서 인스턴스화하면 의존이 null이 되므로 운영 코드에서는 사용하지 않는 것을 권장합니다.
Bean 관리
Bean 정의 방식
애노테이션 방식(가장 일반적)
@Component // 범용 컴포넌트
@Service // 비즈니스 레이어
@Repository // 데이터 접근 레이어(JPA 예외 변환도 활성화)
@Controller // MVC 컨트롤러
@RestController // = @Controller + @ResponseBody
Java Config 방식
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public EmailSender emailSender() {
return new SmtpEmailSender();
}
}
XML 방식(레거시 프로젝트에서 여전히 사용)
<bean id="userService" class="com.example.UserService">
<constructor-arg ref="userRepository"/>
</bean>
Bean 스코프
@Component
@Scope("singleton") // 기본값 — 컨테이너에 인스턴스가 하나만 존재
public class AppConfig { }
@Component
@Scope("prototype") // getBean()마다 새 인스턴스 생성
public class RequestProcessor { }
// Web 환경 전용
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Component
public class RequestContext { }
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
@Component
public class UserSession { }
| 스코프 | 설명 |
|---|---|
| singleton | 기본값 — 컨테이너에서 유일한 인스턴스 |
| prototype | 주입 / getBean()마다 새 인스턴스 생성 |
| request | HTTP 요청마다 인스턴스 하나(Web 환경) |
| session | HTTP 세션마다 인스턴스 하나(Web 환경) |
Bean 생명주기
인스턴스화
→ 프로퍼티 주입
→ BeanNameAware / BeanFactoryAware / ApplicationContextAware
→ BeanPostProcessor#postProcessBeforeInitialization
→ @PostConstruct
→ InitializingBean#afterPropertiesSet
→ init-method
→ BeanPostProcessor#postProcessAfterInitialization
→ 【Bean 사용 가능】
→ @PreDestroy
→ DisposableBean#destroy
→ destroy-method
→ 소멸
자주 사용하는 생명주기 훅:
@Component
public class DataSourcePool {
@PostConstruct
public void init() {
// Bean 초기화 완료 후 실행 — 커넥션 풀 워밍업에 적합
System.out.println("커넥션 풀 초기화 완료");
}
@PreDestroy
public void destroy() {
// 컨테이너 종료 전 실행 — 리소스 해제에 적합
System.out.println("커넥션 풀 리소스 해제");
}
}
자주 사용하는 애노테이션
주입과 설정
// 타입으로 자동 주입
@Autowired
private UserRepository userRepo;
// 같은 타입의 Bean이 여러 개일 때 이름으로 지정
@Autowired
@Qualifier("mysqlUserRepo")
private UserRepository userRepo;
// @Autowired + @Qualifier와 동일, 더 간결(JSR-250 표준)
@Resource(name = "mysqlUserRepo")
private UserRepository userRepo;
// 설정 파일의 값 주입
@Value("${app.name}")
private String appName;
// 기본값과 함께 주입
@Value("${app.timeout:30}")
private int timeout;
// SpEL 표현식 주입
@Value("#{systemProperties['user.home']}")
private String userHome;
설정 클래스 애노테이션
@Configuration // 설정 클래스 표시
@ComponentScan("com.example") // 스캔할 패키지 지정
@PropertySource("classpath:app.properties") // 추가 프로퍼티 파일 로드
@Import(SecurityConfig.class) // 다른 설정 클래스 임포트
public class AppConfig { }
조건부 어셈블리(Spring Boot에서 자주 사용)
// 프로퍼티가 존재하고 지정된 값일 때만 어셈블
@ConditionalOnProperty(name = "feature.cache.enabled", havingValue = "true")
@Bean
public CacheManager cacheManager() {
return new RedisCacheManager(...);
}
// 지정된 타입의 Bean이 없을 때만 어셈블(기본 구현 제공)
@ConditionalOnMissingBean(DataSource.class)
@Bean
public DataSource embeddedDataSource() {
return new EmbeddedDatabaseBuilder().build();
}
// 지정된 클래스가 클래스패스에 있을 때만 어셈블
@ConditionalOnClass(RedisTemplate.class)
@Configuration
public class RedisAutoConfig { }
AOP(관점 지향 프로그래밍)
AOP를 사용하면 비즈니스 코드를 수정하지 않고도 로깅, 권한 검사, 성능 모니터링, 트랜잭션 등 횡단 관심사를 일관되게 처리할 수 있습니다.
핵심 개념
| 개념 | 설명 |
|---|---|
| Aspect(어스펙트) | 횡단 로직을 캡슐화한 클래스. @Aspect로 애노테이션 |
| Pointcut(포인트컷) | 어느 메서드에 적용할지를 정의하는 표현식 |
| Advice(어드바이스) | 포인트컷이 트리거될 때 실행되는 코드 |
| JoinPoint(조인포인트) | 인터셉트할 수 있는 실행 지점(메서드 호출, 예외 등) |
| Weaving(위빙) | 어스펙트 로직을 대상 객체에 적용하는 과정 |
어드바이스 유형
@Aspect
@Component
public class LoggingAspect {
// 포인트컷 표현식 — service 패키지의 모든 메서드에 매칭
@Pointcut("execution(* com.example.service.*.*(..))")
public void serviceMethods() {}
// 메서드 실행 전
@Before("serviceMethods()")
public void logBefore(JoinPoint joinPoint) {
System.out.println("메서드 호출:" + joinPoint.getSignature().getName());
}
// 메서드 실행 후(예외 여부에 관계없이)
@After("serviceMethods()")
public void logAfter(JoinPoint joinPoint) {
System.out.println("메서드 실행 완료");
}
// 정상 반환 후
@AfterReturning(pointcut = "serviceMethods()", returning = "result")
public void logAfterReturning(Object result) {
System.out.println("반환값:" + result);
}
// 예외 발생 후
@AfterThrowing(pointcut = "serviceMethods()", throwing = "ex")
public void logAfterThrowing(Exception ex) {
System.out.println("예외:" + ex.getMessage());
}
// 어라운드 어드바이스(가장 강력 — 메서드 실행 여부를 제어 가능)
@Around("serviceMethods()")
public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed(); // 대상 메서드 실행
long elapsed = System.currentTimeMillis() - start;
System.out.println(pjp.getSignature() + " 처리 시간:" + elapsed + "ms");
return result;
} catch (Exception e) {
System.out.println("예외 발생:" + e.getMessage());
throw e;
}
}
}
포인트컷 표현식
// 특정 메서드에 정확히 매칭
execution(public String com.example.service.UserService.findById(Long))
// 클래스의 모든 메서드에 매칭
execution(* com.example.service.UserService.*(..))
// 패키지 및 하위 패키지의 모든 메서드에 매칭(..은 임의의 하위 패키지)
execution(* com.example.service..*.*(..))
// 특정 애노테이션이 붙은 메서드에 매칭
@annotation(com.example.annotation.Log)
// 패키지 내 모든 타입에 매칭
within(com.example.service.*)
// 조합(get으로 시작하는 메서드 제외)
@Pointcut("execution(* com.example.service.*.*(..)) && !execution(* com.example.service.*.get*(..))")
AOP 실전:통합 운영 로그
// 커스텀 애노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String value() default "";
}
// 어스펙트 구현
@Aspect
@Component
public class OperationLogAspect {
@Autowired
private LogService logService;
@Around("@annotation(operationLog)")
public Object record(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
String operation = operationLog.value();
try {
Object result = pjp.proceed();
logService.save(username, operation, "SUCCESS");
return result;
} catch (Exception e) {
logService.save(username, operation, "FAILED: " + e.getMessage());
throw e;
}
}
}
// 애노테이션 사용
@Service
public class UserService {
@OperationLog("사용자 삭제")
public void deleteUser(Long id) {
// 비즈니스 코드는 로그를 신경 쓰지 않아도 됨
}
}
Spring MVC
요청 처리 흐름
클라이언트 요청
→ DispatcherServlet(프론트 컨트롤러)
→ HandlerMapping(해당 Controller 메서드 탐색)
→ HandlerAdapter(Controller 메서드 실행)
→ ModelAndView 또는 @ResponseBody 데이터 반환
→ ViewResolver(뷰 이름이면 해당 뷰 탐색)
→ 렌더링 → 클라이언트 응답
Controller 자주 쓰는 애노테이션
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
// GET /api/users
@GetMapping
public List<User> list() {
return userService.findAll();
}
// GET /api/users/1
@GetMapping("/{id}")
public ResponseEntity<User> getById(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/users
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public User create(@RequestBody @Valid CreateUserRequest req) {
return userService.create(req);
}
// PUT /api/users/1
@PutMapping("/{id}")
public User update(@PathVariable Long id, @RequestBody @Valid UpdateUserRequest req) {
return userService.update(id, req);
}
// DELETE /api/users/1
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
// GET /api/users?page=0&size=10&keyword=Alice
@GetMapping("/search")
public Page<User> search(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword
) {
return userService.search(keyword, PageRequest.of(page, size));
}
}
파라미터 바인딩
// 경로 파라미터
@GetMapping("/{id}")
public User get(@PathVariable Long id) { ... }
// 쿼리 파라미터(기본값 포함)
@GetMapping
public List<User> list(
@RequestParam String name,
@RequestParam(defaultValue = "0") int page
) { ... }
// 요청 바디(JSON → 객체)
@PostMapping
public User create(@RequestBody CreateUserRequest req) { ... }
// 요청 헤더
@GetMapping
public String auth(@RequestHeader("Authorization") String token) { ... }
// Cookie
@GetMapping
public String session(@CookieValue("sessionId") String sid) { ... }
// 쿼리 파라미터를 객체에 자동 바인딩(?name=Alice&age=25 → UserSearchRequest)
@GetMapping
public Page<User> search(UserSearchRequest req) { ... }
전역 예외 처리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(EntityNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors()
.stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.toList());
return new ErrorResponse("VALIDATION_FAILED", errors.toString());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneral(Exception ex) {
return new ErrorResponse("INTERNAL_ERROR", "서버 내부 오류");
}
}
Spring Boot
자동 설정 원리
Spring Boot는 @SpringBootApplication을 통해 자동 설정을 활성화하며, 이는 세 가지 애노테이션의 조합입니다:
@SpringBootConfiguration // @Configuration과 동일
@EnableAutoConfiguration // 핵심: 자동 설정 활성화
@ComponentScan // 현재 패키지와 하위 패키지 스캔
public @interface SpringBootApplication { }
@EnableAutoConfiguration은 AutoConfigurationImportSelector를 통해 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports에 나열된 모든 자동 설정 클래스를 읽고, 각 @ConditionalOnXxx 애노테이션의 조건을 평가하여 Bean을 선택적으로 등록합니다.
전체 흐름을 단순화하면:
시작
→ AutoConfiguration.imports 스캔
→ @ConditionalOnXxx 조건 순차 확인
→ 조건 충족 → Bean 등록
→ 조건 미충족 → 건너뜀
설정 파일
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root
password: secret
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
server:
port: 8080
servlet:
context-path: /api
app:
jwt:
secret: my-secret-key
expiration: 86400
다중 환경 설정
# application.yml
spring:
profiles:
active: dev # 기본으로 dev 환경 활성화
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:testdb
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:mysql://prod-server:3306/mydb
설정을 클래스에 바인딩
@ConfigurationProperties(prefix = "app.jwt")
@Component
public class JwtProperties {
private String secret;
private long expiration;
// getters & setters
}
트랜잭션 관리
기본 사용법
@Service
public class TransferService {
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepo.findById(fromId).orElseThrow();
Account to = accountRepo.findById(toId).orElseThrow();
from.deduct(amount);
to.add(amount);
accountRepo.save(from);
accountRepo.save(to);
// 메서드 정상 종료 → 자동 커밋
// RuntimeException 발생 → 자동 롤백
}
}
트랜잭션 전파
// REQUIRED(기본값): 기존 트랜잭션에 참여, 없으면 새로 생성
@Transactional(propagation = Propagation.REQUIRED)
// REQUIRES_NEW: 항상 새 트랜잭션 생성, 현재 트랜잭션 일시 중단
@Transactional(propagation = Propagation.REQUIRES_NEW)
// SUPPORTS: 기존 트랜잭션에 참여, 없으면 비트랜잭션으로 실행
@Transactional(propagation = Propagation.SUPPORTS)
// NOT_SUPPORTED: 비트랜잭션으로 실행, 현재 트랜잭션이 있으면 일시 중단
@Transactional(propagation = Propagation.NOT_SUPPORTED)
// NEVER: 트랜잭션 내 실행 금지, 있으면 예외 발생
@Transactional(propagation = Propagation.NEVER)
// MANDATORY: 트랜잭션 내에서만 실행, 없으면 예외 발생
@Transactional(propagation = Propagation.MANDATORY)
// NESTED: 중첩 트랜잭션(세이브포인트 사용). 외부 롤백 시 내부도 롤백, 내부 롤백은 외부에 영향 없음
@Transactional(propagation = Propagation.NESTED)
격리 수준
| 격리 수준 | 더티 읽기 | 반복 불가 읽기 | 팬텀 읽기 |
|---|---|---|---|
| READ_UNCOMMITTED | ✅ 가능 | ✅ 가능 | ✅ 가능 |
| READ_COMMITTED | ❌ 불가 | ✅ 가능 | ✅ 가능 |
| REPEATABLE_READ | ❌ 불가 | ❌ 불가 | ✅ 가능 |
| SERIALIZABLE | ❌ 불가 | ❌ 불가 | ❌ 불가 |
@Transactional(isolation = Isolation.READ_COMMITTED) // Oracle 기본값
@Transactional(isolation = Isolation.REPEATABLE_READ) // MySQL InnoDB 기본값
@Transactional(isolation = Isolation.SERIALIZABLE) // 가장 엄격, 성능 최저
주의사항
@Service
public class UserService {
// ❌ 자기 호출은 트랜잭션을 트리거하지 않음(this는 프록시가 아니므로 AOP가 작동하지 않음)
public void outer() {
this.inner();
}
@Transactional
public void inner() { ... }
// ✅ 컨테이너에서 프록시 객체를 가져와서 호출
@Autowired
private ApplicationContext context;
public void outer2() {
context.getBean(UserService.class).inner();
}
// ❌ 기본적으로 RuntimeException만 롤백, 검사 예외는 롤백하지 않음
@Transactional
public void riskyOp() throws IOException {
// IOException은 기본적으로 롤백하지 않음
}
// ✅ 모든 예외에서 롤백하도록 명시적으로 지정
@Transactional(rollbackFor = Exception.class)
public void safeOp() throws IOException { ... }
// ❌ private 메서드의 @Transactional은 무효(AOP는 private 메서드를 프록시할 수 없음)
@Transactional
private void internalUpdate() { ... }
}
Spring Data JPA
Repository 레이어
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 메서드 명명 규칙 — Spring이 구현을 자동 생성
List<User> findByEmail(String email);
List<User> findByAgeGreaterThan(int age);
Optional<User> findByEmailAndStatus(String email, UserStatus status);
boolean existsByEmail(String email);
long countByStatus(UserStatus status);
// 페이징 및 정렬
Page<User> findByStatus(UserStatus status, Pageable pageable);
List<User> findAll(Sort sort);
// 커스텀 JPQL
@Query("SELECT u FROM User u WHERE u.name LIKE %:keyword% OR u.email LIKE %:keyword%")
List<User> search(@Param("keyword") String keyword);
// 네이티브 SQL
@Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)
List<User> findCreatedAfter(@Param("date") LocalDate date);
// 수정 / 삭제 작업에는 @Modifying 필요
@Modifying
@Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
int batchUpdateStatus(@Param("ids") List<Long> ids, @Param("status") UserStatus status);
}
엔티티 정의
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true)
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Enumerated(EnumType.STRING)
private UserStatus status;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
// 지연 로딩 — N+1 문제 방지
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Order> orders;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
private Department department;
}
자주 발생하는 문제와 모범 사례
순환 의존성
Spring Boot 2.6+는 기본적으로 순환 의존성을 금지하며, 감지되면 BeanCurrentlyInCreationException을 발생시킵니다.
// ❌ 순환 의존성(A는 B에 의존하고, B는 A에 의존)
@Service class A { @Autowired B b; }
@Service class B { @Autowired A a; }
// ✅ 해결책 1: 설계 개선 — 공통 로직을 새 클래스 C로 추출(권장)
@Service class C { /* 공통 로직 */ }
@Service class A { @Autowired C c; }
@Service class B { @Autowired C c; }
// ✅ 해결책 2: @Lazy 지연 주입으로 초기화 교착 상태 해소
@Service class A {
@Autowired @Lazy B b;
}
// ✅ 해결책 3: 세터 주입(근본 해결이 아닌 회피책이므로 비권장)
@Service class A {
private B b;
@Autowired
public void setB(B b) { this.b = b; }
}
여러 Bean 중 선택
// 두 개의 UserRepository 구현체
@Component("mysqlUserRepo")
public class MysqlUserRepository implements UserRepository { ... }
@Component("redisUserRepo")
public class RedisUserRepository implements UserRepository { ... }
// 방법 1: @Qualifier로 이름 지정
@Autowired
@Qualifier("mysqlUserRepo")
private UserRepository userRepo;
// 방법 2: @Primary로 기본 구현체 표시
@Primary
@Component
public class MysqlUserRepository implements UserRepository { ... }
// 방법 3: 모든 구현체를 주입받아 직접 선택
@Autowired
private List<UserRepository> allRepos;
@Autowired
private Map<String, UserRepository> repoMap; // 키는 bean 이름
자주 빠지는 함정
// 1. private 메서드의 @Transactional은 무효
@Transactional // ❌ 무효 — AOP는 private 메서드를 프록시할 수 없음
private void internalUpdate() { ... }
// 2. static 메서드의 애노테이션은 무효(AOP는 인스턴스 프록시 기반)
@Cacheable("users") // ❌ 무효
public static User staticGet(Long id) { ... }
// 3. prototype Bean을 singleton에 주입하면 항상 같은 인스턴스가 반환됨
// ✅ @Lookup을 사용하면 Spring이 호출마다 새로운 prototype 인스턴스를 생성
@Component
public class SingletonBean {
@Lookup
public PrototypeBean getPrototype() {
return null; // Spring이 이 메서드를 오버라이드하여 매번 새 인스턴스를 반환
}
}
// 4. Session 종료 후 JPA 지연 로딩 관계에 접근하면 LazyInitializationException 발생
// ✅ 해결책: Service 레이어에서 @Transactional로 Session 유지, 또는 JOIN FETCH 사용
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
Fonnpo