Fetch Type
연관관계를 설정할 때 반드시 확인해야하는 부분이다.
즉시 로딩 (Eager Loading)
연관된 엔티티를 즉시 함께 조회 한다. FetchType.EAGER 로 설정한다.
@ManyToOne(fetch = FetchType.EAGER) // 기본값
private Customer customer;
-- Order를 조회할 때 Customer도 JOIN해서 함께 가져옴
SELECT o.*, c.*
FROM orders o
LEFT JOIN customer c ON o.customer_id = c.id
WHERE o.id = 1
문제: 지금 당장 Customer 정보가 필요 없어도 항상 JOIN 쿼리가 실행된다.
연관된 엔티티가 많을수록 불필요한 데이터를 많이 가져와 성능이 저하된다. 특히 N+1 문제가 발생하기 쉽다.
지연 로딩 (Lazy Loading)
연관된 엔티티를 실제로 사용할 때 조회한다. FetchType.LAZY 로 설정한다.
@ManyToOne(fetch = FetchType.LAZY) // 권장
private Customer customer;
Order order = orderRepository.findById(1L).get();
// 이 시점: order만 조회, customer는 아직 조회 안 함
String customerName = order.getCustomer().getName();
// 이 시점: getName()이 호출되는 순간 Customer SELECT SQL 실행
Hibernate는 지연 로딩을 구현하기 위해 프록시(Proxy)객체를 사용한다. order.getCustomer() 가 반환하는 것은 실제 Customer 객체가 아니라, 실제 조회를 미루는 가짜 프록시 객체다. 실제 데이터에 접근하는 순간 DB에서 조회한다.
기본 fetch 전략
| 어노테이션 | 기본 fetch 전략 | 권장 설정 |
| @ManyToOne | EAGER | LAZY로 변경 권장 |
| @OneToOne | EAGER | LAZY로 변경 권장 |
| @OneToMany | LAZY | LAZY 유지 |
| @ManyToMany | LAZY | LAZY 유지 |
실무 원칙: 모든 연관관계는 LAZY 로딩으로 설정 하고, 필요한 경우에만 페치 조인(FETCH JOIN)으로 한 번에 가져오는 것이 성능 최적화의 기본이다.
N+1 문제
LAZY 로딩 덕분에 불필요한 데이터를 미리 안 가져오는 건 좋은데,
막상 연관 데이터가 필요한 순간 쿼리가 폭발적으로 늘어나는 부작용이 생긴다.
상황
Customer (1) ──── Order (N) ──── Product (1)
DB에 아래 데이터가 있다고 가정하자.
[customer] [orders]
┌────┬──────────┐ ┌────┬─────────────┬────────────┐
│ id │ name │ │ id │ customer_id │ product_id │
├────┼──────────┤ ├────┼─────────────┼────────────┤
│ 1 │ 홍길동 │ │ 1 │ 1 │ 1 │
│ 2 │ 김철수 │ │ 2 │ 1 │ 2 │
│ 3 │ 이영희 │ │ 3 │ 2 │ 1 │
└────┴──────────┘ │ 4 │ 3 │ 3 │
└────┴─────────────┴────────────┘
N+1이 터지는 코드
// OrderService.java
@Transactional(readOnly = true)
public void printAllOrders() {
List<Order> orders = orderRepository.findAll();
// ① 이 시점: SELECT * FROM orders → 쿼리 1번
for (Order order : orders) {
System.out.println(order.getCustomer().getName());
// ② 이 시점: SELECT * FROM customer WHERE id = ?
// → order마다 1번씩, 총 4번 추가 실행
}
}
실제로 실행되는 SQL을 보면 이렇다.
-- ① findAll() 실행 시
SELECT * FROM orders;
-- ② 루프를 돌며 각 order의 customer를 LAZY 로딩
SELECT * FROM customer WHERE id = 1;
SELECT * FROM customer WHERE id = 1; -- 이미 조회했는데 또 나감 (1차 캐시 덕분에 실제론 생략되지만)
SELECT * FROM customer WHERE id = 2;
SELECT * FROM customer WHERE id = 3;
-- 총 1 + 3 = 4번 (고객이 중복되지 않을 경우 기준)
-- 최악의 경우 1 + N번
왜 문제인가? Order가 1,000개라면 SQL이 1,001번 실행된다.
각 SQL은 DB 커넥션을 열고 닫는 네트워크 비용을 수반하기 때문에, 성능이 급격히 저하된다.
개발자들이 이 문제를 풀려고 시도하면서 세 가지 접근법이 등장했는데, 각각 해결 방식이 다르고, 장단점도 다르다.
해결책 1️⃣ Fetch Join
핵심 아이디어
"처음 조회할 때, 연관된 엔티티를 JOIN으로 한 번에 다 가져오자"
SQL의 JOIN과 개념은 같다.
차이점은 JPA에서 LAZY로 설정된 연관관계도 강제로 즉시 함께 로딩한다는 것이다.
사용법
JPQL에서 JOIN FETCH 키워드를 사용한다.
// OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
// 일반 JPQL — LAZY 설정이므로 customer는 나중에 따로 조회됨
@Query("SELECT o FROM Order o")
List<Order> findAll();
// Fetch Join JPQL — customer를 즉시 함께 조회
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();
// 여러 연관관계를 한 번에 Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.customer JOIN FETCH o.product")
List<Order> findAllWithCustomerAndProduct();
}
실제로 실행되는 SQL
-- findAllWithCustomer() 실행 시 → 쿼리 딱 1번
SELECT o.*, c.*
FROM orders o
INNER JOIN customer c ON o.customer_id = c.id;
Order와 Customer를 한 번의 SQL로 한 번에 가져온다. 루프를 돌며 추가 SQL이 실행되지 않는다.
// 이제 N+1이 발생하지 않음
@Transactional(readOnly = true)
public void printAllOrders() {
List<Order> orders = orderRepository.findAllWithCustomer();
// ① 이 시점: 위의 JOIN SQL 1번 실행, customer까지 모두 로딩 완료
for (Order order : orders) {
System.out.println(order.getCustomer().getName());
// ② 추가 SQL 없음! 이미 다 메모리에 있다
}
}
Fetch Join의 한계
편리하지만 단점도 있다. 반드시 알아야 한다.
단점 1: 컬렉션(1:N) Fetch Join + 페이징은 함께 쓸 수 없다
OneToMany 관계(컬렉션)를 Fetch Join하면서 동시에 페이징(LIMIT, OFFSET)을 사용하면
Hibernate가 경고를 띄우고 모든 데이터를 메모리에 올린 후 페이징을 처리한다.
// ❌ 위험한 코드 — 데이터가 많으면 OOM(Out of Memory) 발생 가능
@Query("SELECT c FROM Customer c JOIN FETCH c.orders")
Page<Customer> findAllWithOrders(Pageable pageable);
// → Hibernate 경고: "HHH90003004: firstResult/maxResults specified with collection fetch"
// → 전체 데이터를 메모리에 올린 후 Java에서 페이징 처리 → 매우 위험
왜 이런 일이 생기냐면, JOIN FETCH 로 1:N 관계를 조인하면 결과 행(row)이 뻥튀기되기 때문이다.
[orders JOIN customer 결과]
┌──────────┬──────────┐
│ order_id │ customer │
├──────────┼──────────┤
│ 1 │ 홍길동 │ ← customer 홍길동이
│ 2 │ 홍길동 │ ← 두 번 등장 (order 2개)
│ 3 │ 김철수 │
└──────────┴──────────┘
DB 입장에서는 행이 3개지만, Java 입장에서 Customer는 2명이다.
DB 레벨에서 LIMIT 1 을 하면 Customer 1명이 아니라 row 1개만 자르기 때문에, 결과가 뒤틀린다.
그래서 Hibernate는 메모리에 전부 올린 다음 Java에서 직접 자른다.
해결책: 컬렉션(1:N) Fetch Join과 페이징은 함께 쓰지 말 것. 이 상황에서는 BatchSize 를 사용하는 것이 정답이다.
단점 2: 두 개 이상의 컬렉션을 동시에 Fetch Join할 수 없다
// ❌ 불가능 — MultipleBagFetchException 발생
@Query("SELECT c FROM Customer c JOIN FETCH c.orders JOIN FETCH c.reviews")
List<Customer> findAllWithOrdersAndReviews();
List 타입 컬렉션을 두 개 이상 동시에 Fetch Join하면 Hibernate가 예외를 던진다. (Set으로 바꾸면 가능하지만 카테시안 곱(Cartesian Product) 문제가 생긴다.)
단점 3: JPQL을 직접 작성해야 한다
항상 @Query 에 JPQL을 직접 써야 하기 때문에, 쿼리 메서드처럼 선언적으로 사용할 수 없다. 코드가 다소 복잡해진다.
언제 쓰면 좋을까?
- 단일 연관관계(N:1, 1:1) 를 함께 조회할 때 → 가장 깔끔하고 효율적
- 페이징이 필요 없는 컬렉션 조회
- 성능이 중요한 핵심 쿼리에서 명시적으로 제어하고 싶을 때
해결책 2️⃣ EntityGraph
핵심 아이디어
"Fetch Join이랑 결과는 같은데, JPQL 없이 어노테이션으로 선언하고 싶다"
EntityGraph는 Fetch Join과 동일하게 JOIN을 통해 연관 엔티티를 함께 로딩한다.
차이는 JPQL 대신 어노테이션으로 설정한다는 것이다.
내부적으로 Hibernate는 EntityGraph를 보고 Fetch Join SQL을 생성한다.
방법 1: @EntityGraph 어노테이션 — 즉석에서 선언
// OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
// attributePaths에 함께 로딩할 연관관계 필드명을 적는다
@EntityGraph(attributePaths = {"customer"})
List<Order> findAll();
// 여러 개도 가능
@EntityGraph(attributePaths = {"customer", "product"})
List<Order> findAllBy();
// 기존 쿼리 메서드에도 그냥 붙일 수 있다
@EntityGraph(attributePaths = {"customer"})
List<Order> findByCustomerId(Long customerId);
}
실제로 실행되는 SQL
-- findAll() with @EntityGraph 실행 시 → Fetch Join과 동일한 SQL
SELECT o.*, c.*
FROM orders o
LEFT OUTER JOIN customer c ON o.customer_id = c.id;
Fetch Join vs EntityGraph SQL 차이
- Fetch Join: INNER JOIN (기본)
- EntityGraph: LEFT OUTER JOIN (기본)
LEFT OUTER JOIN이기 때문에 연관 엔티티가 null인 경우에도 Order가 조회된다.
방법 2: @NamedEntityGraph — Entity 클래스에 미리 정의
자주 재사용하는 그래프 패턴을 엔티티 클래스에 이름을 붙여 정의해두는 방법이다.
// Order.java
@Entity
@Table(name = "orders")
@NamedEntityGraph(
name = "Order.withCustomerAndProduct", // 그래프에 이름 부여
attributeNodes = {
@NamedAttributeNode("customer"),
@NamedAttributeNode("product")
}
)
public class Order {
// ... 필드들
}
// OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
// 이름으로 참조해서 사용
@EntityGraph("Order.withCustomerAndProduct")
List<Order> findAll();
@EntityGraph("Order.withCustomerAndProduct")
Optional<Order> findById(Long id);
}
EntityGraph의 한계
EntityGraph도 내부적으로 JOIN을 사용하기 때문에 Fetch Join과 동일한 한계 를 공유한다.
- 컬렉션(1:N) + 페이징 조합 시 메모리 페이징 문제 발생
- 두 개 이상 컬렉션 동시 로딩 시 문제 발생
추가적으로 EntityGraph만의 단점이 있다.
- JPQL과 함께 쓰면 충돌이 생기기 쉽다. @Query 에 이미 JOIN이 있는데 EntityGraph도 붙이면 예상치 못한 SQL이 생성될 수 있다.
- Fetch Join보다 동작이 덜 명시적이라 디버깅이 어려울 수 있다.
Fetch Join과의 선택 기준
| 상황 | 권장 |
| 쿼리 재사용이 많고, 선언적으로 관리하고 싶다 | EntityGraph |
| 복잡한 조건의 JPQL과 함께 사용한다 | Fetch Join |
| 성능 튜닝이 중요하고 SQL을 직접 제어하고 싶다 | Fetch Join |
| Spring Data JPA의 쿼리 메서드에 간단히 붙이고 싶다 | EntityGraph |
언제 쓰면 좋을까?
- 기존 쿼리 메서드에 빠르게 Fetch 전략을 추가하고 싶을 때
- 자주 재사용되는 로딩 패턴을 @NamedEntityGraph 로 이름 붙여 관리할 때
- JPQL 없이 선언형으로 연관관계 로딩을 제어하고 싶을 때
해결책 3️⃣ BatchSize
핵심 아이디어
"한 방에 JOIN으로 가져오는 게 아니라, 여러 ID를 묶어서 IN 쿼리로 한 번에 처리하자"
Fetch Join과 EntityGraph는 JOIN 으로 N+1을 해결했다. BatchSize는 완전히 다른 방식이다.
LAZY 로딩은 유지하되, 연관 엔티티를 하나씩 가져오는 대신, 여러 개의 ID를 모아서 WHERE id IN (...) 쿼리로 한 번에 가져온다.
작동 방식 — 단계별로 이해하기
batchSize = 100 으로 설정했다고 가정하자.
기존 N+1 방식:
① SELECT * FROM orders → order 300개 조회
② SELECT * FROM customer WHERE id = 1 → 1번
③ SELECT * FROM customer WHERE id = 2 → 1번
④ SELECT * FROM customer WHERE id = 3 → 1번
... (총 300번 추가)
→ 합계: 301번의 SQL
BatchSize = 100 방식:
① SELECT * FROM orders → order 300개 조회
② SELECT * FROM customer WHERE id IN (1,2,...,100) → 100개씩 묶어서
③ SELECT * FROM customer WHERE id IN (101,...,200) → 1번
④ SELECT * FROM customer WHERE id IN (201,...,300) → 1번
→ 합계: 4번의 SQL (1 + 300/100 = 4번)
N+1이 N+1 → 1 + (N / BatchSize) 로 줄어든다.
방법 1: 전역 설정 (application.yml)
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 전체 프로젝트에 적용
이 설정 하나만 추가하면 모든 LAZY 로딩 연관관계에 자동으로 BatchSize가 적용된다. 가장 간단하고 실무에서 많이 쓰는 방법이다.
방법 2: 특정 필드에만 @BatchSize 어노테이션
// Customer.java
@Entity
public class Customer {
@OneToMany(mappedBy = "customer")
@BatchSize(size = 100) // 이 컬렉션에만 적용
private List<Order> orders = new ArrayList<>();
}
// Order.java
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 50) // 이 연관관계에만 적용
@JoinColumn(name = "customer_id")
private Customer customer;
}
BatchSize가 유용한 상황 — 페이징과의 조합
BatchSize의 가장 큰 장점은 컬렉션(1:N) 조회 + 페이징을 동시에 해결한다는 것이다.
Fetch Join은 컬렉션 + 페이징을 함께 쓰면 메모리 페이징 문제가 생겼다. BatchSize는 이 문제가 없다.
// ✅ BatchSize + 페이징 — 완벽하게 동작
@Transactional(readOnly = true)
public Page<Customer> getCustomersWithOrders(Pageable pageable) {
// ① 먼저 Customer를 페이징으로 조회 (SQL 1번)
Page<Customer> customers = customerRepository.findAll(pageable);
// → SELECT * FROM customer LIMIT 10 OFFSET 0
// ② customers의 orders에 접근하는 순간 BatchSize IN 쿼리 실행
customers.getContent().forEach(c -> c.getOrders().size());
// → SELECT * FROM orders WHERE customer_id IN (1, 2, 3, ..., 10)
// → 페이징된 10명의 orders를 한 번에 가져옴
return customers;
}
-- 실행되는 SQL (총 2번)
-- ① 페이징 Customer 조회
SELECT * FROM customer LIMIT 10 OFFSET 0;
-- ② BatchSize IN 쿼리로 10명의 orders 한 번에 조회
SELECT * FROM orders WHERE customer_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
페이징도 정확하고, SQL도 2번뿐이다.
BatchSize 크기는 어떻게?
일반적으로 100 ~ 1000 사이를 권장한다.
너무 작으면 (예: 10):
IN 쿼리가 자주 나가서 성능 개선 효과가 줄어든다
너무 크면 (예: 10000):
IN 절에 들어가는 ID가 너무 많아 DB 쿼리 자체가 무거워진다
일부 DB는 IN 절 개수 제한이 있다 (Oracle: 1000개)
실무 기본값: 100
Oracle 사용 시 주의: Oracle은 IN 절에 최대 1000개까지만 허용한다.
default_batch_fetch_size = 1000 이 Oracle에서의 사실상 최대값이다.
BatchSize의 한계
- JOIN이 아닌 별도의 SELECT 쿼리를 추가로 실행한다. 즉, SQL이 완전히 1번이 되진 않는다.
- 연관 엔티티 데이터가 정말 많을 때는 IN 쿼리 자체가 무거워질 수 있다.
- Fetch Join처럼 "이 쿼리에서는 반드시 함께 가져와야 한다" 는 확신이 없을 때, 예상치 못한 시점에 IN 쿼리가 실행될 수 있다.
언제 쓰면 좋을까?
- 컬렉션(1:N) 관계 + 페이징이 함께 필요한 상황 → BatchSize가 사실상 유일한 해결책
- 전역 설정으로 프로젝트 전체의 LAZY 로딩 성능을 간단히 끌어올리고 싶을 때
- Fetch Join의 컬렉션 2개 이상 동시 로딩 제한을 우회하고 싶을 때
Fetch 방식 비교
핵심 차이 한눈에 보기
| Fetch | Join | EntityGraph | BatchSize |
| 해결 방식 | JOIN으로 한 번에 | JOIN으로 한 번에 | IN 쿼리로 묶어서 |
| SQL 실행 횟수 | 1번 | 1번 | 1 + N/size 번 |
| 설정 방식 | JPQL @Query | 어노테이션 | yml 또는 어노테이션 |
| 컬렉션 + 페이징 | ❌ 위험 | ❌ 위험 | ✅ 안전 |
| 컬렉션 2개 동시 | ❌ 불가 | ❌ 불가 | ✅ 가능 |
| 코드 복잡도 | 중간 (JPQL 작성) | 낮음 (어노테이션) | 매우 낮음 (전역 설정) |
| SQL 제어력 | 높음 | 중간 | 낮음 |
OSIV
OSIV는 Open Session In View의 줄임말이다.
Hibernate에서는 Session, JPA에서는 EntityManager라고 부르지만 같은 개념이다.
쉽게 말하면 "영속성 컨텍스트를 언제까지 열어둘 것인가"에 대한 설정이다.
배경 — 트랜잭션이 끝나면 무슨 일이 생기나
기본적으로 영속성 컨텍스트는 트랜잭션과 생명주기를 같이 한다고 했다.
트랜잭션 시작 → 영속성 컨텍스트 생성
트랜잭션 종료 → 영속성 컨텍스트 종료 → 엔티티가 준영속 상태
그런데 일반적인 Spring MVC 요청 흐름을 보면 이렇다.
HTTP 요청
↓
Controller
↓
Service ← @Transactional, 여기서 트랜잭션 시작/종료
↓
Controller ← 트랜잭션 이미 끝남
↓
View / Response 변환 ← 트랜잭션 이미 끝남
↓
HTTP 응답
Service에서 트랜잭션이 끝나면 영속성 컨텍스트도 닫힌다.
그 이후 Controller나 View에서 엔티티의 LAZY 로딩을 시도하면 어떻게 될까?
// Service
@Transactional
public Order getOrder(Long id) {
return orderRepository.findById(id).get();
// 트랜잭션 종료 → 영속성 컨텍스트 닫힘
}
// Controller
public ResponseEntity<?> getOrder(Long id) {
Order order = orderService.getOrder(id);
order.getCustomer().getName(); // ❌ LazyInitializationException 발생!
// 영속성 컨텍스트가 이미 닫혀있어서 LAZY 로딩 불가
}
이 문제를 해결하려고 등장한 것이 OSIV다.
OSIV On — 영속성 컨텍스트를 요청 끝까지 열어두기
OSIV를 켜면 영속성 컨텍스트의 생명주기가 트랜잭션이 아닌 HTTP 요청 전체로 늘어난다.
OSIV ON
HTTP 요청 → [영속성 컨텍스트 시작]
↓
Controller
↓
Service (@Transactional 시작/종료)
↓
Controller ← 영속성 컨텍스트 살아있음
↓
View ← 영속성 컨텍스트 살아있음 → LAZY 로딩 가능
↓
HTTP 응답 → [영속성 컨텍스트 종료]
// Controller에서 LAZY 로딩 가능
Order order = orderService.getOrder(id);
order.getCustomer().getName(); // ✅ 영속성 컨텍스트 살아있으니 동작함
Spring Boot의 OSIV 기본값은 true, 즉 켜져 있다.
OSIV On의 문제점
편하지만 치명적인 단점이 있다.
DB 커넥션을 너무 오래 점유한다.
OSIV ON일 때 DB 커넥션 점유 시간
HTTP 요청 들어옴 → DB 커넥션 획득
↓
Service 로직 (DB 작업)
↓
Controller 로직 (DB 작업 없음)
↓
JSON 직렬화 (DB 작업 없음)
↓
HTTP 응답 나감 → DB 커넥션 반환
DB 작업이 없는 구간에도 커넥션을 잡고 있다. 트래픽이 많아지면 커넥션 풀이 고갈되고 장애로 이어질 수 있다.
OSIV Off
# application.yml
spring:
jpa:
open-in-view: false
OSIV OFF
HTTP 요청
↓
Controller
↓
Service (@Transactional 시작)
→ DB 커넥션 획득
→ 로직 실행
→ 트랜잭션 종료 → 영속성 컨텍스트 종료
→ DB 커넥션 반환 ← 여기서 바로 반환
↓
Controller ← 영속성 컨텍스트 없음
↓
HTTP 응답
DB 커넥션을 실제로 필요한 순간에만 점유한다. 훨씬 효율적이다.
OSIV Off의 문제점
트랜잭션 밖, 즉 Service를 벗어나면 LAZY 로딩이 불가능하다.
// ❌ OSIV Off 상태에서 Controller에서 LAZY 로딩 시도
Order order = orderService.getOrder(id);
order.getCustomer().getName(); // LazyInitializationException 발생!
이 문제를 해결하는 방법이 두 가지다.
방법 1 — Service 안에서 미리 다 로딩하고 나오기
@Transactional
public Order getOrder(Long id) {
Order order = orderRepository.findById(id).get();
order.getCustomer().getName(); // 트랜잭션 안에서 미리 LAZY 로딩 강제
return order;
}
방법 2 — DTO로 변환해서 반환하기 (권장)
@Transactional
public OrderResponse getOrder(Long id) {
Order order = orderRepository.findById(id).get();
return new OrderResponse(order); // 트랜잭션 안에서 DTO로 변환
// DTO는 순수 Java 객체라 영속성 컨텍스트와 무관
}
DTO로 변환하면 영속성 컨텍스트가 닫혀도 아무 문제가 없다.
OSIV를 끄면 DTO 분리가 사실상 강제된다고 볼 수 있다.
결론
| OSIV On | OSIV Off | |
| 영속성 컨텍스트 | 요청 끝까지 유지 | 트랜잭션 안에서만 유지 |
| LAZY 로딩 | 어디서든 가능 | 트랜잭션 안에서만 가능 |
| DB 커넥션 | 요청 내내 점유 | 필요한 순간만 점유 |
| 실무 권장 | ❌ 트래픽 많으면 위험 | ✅ 권장 |
| 주의사항 | 없음 | Controller에서 LAZY 로딩 불가 |
실무에서는 OSIV를 끄고, Service 안에서 DTO로 변환해서 반환하는 패턴을 권장한다.
'Learning Log' 카테고리의 다른 글
| [멋사 클라우드 5기] Day 28 - JWT 그런데 Redis를 곁들인 (0) | 2026.03.10 |
|---|---|
| [멋사 클라우드 5기] Day 26, 27 - Spring Security & JWT (0) | 2026.03.09 |
| [멋사 클라우드 5기] Day 24 - Spring Data JPA (2) (0) | 2026.03.04 |
| [멋사 클라우드 5기] Day 23 - Spring Data JPA (1) (1) | 2026.03.03 |
| [멋사 클라우드 5기] Day 22 - SpringBoot 그리고 웹 서버 (0) | 2026.02.27 |
