RestTemplate
Server To Server

- 지금까지는 Client 즉, 브라우저로부터 요청을 받는 서버의 입장에서 개발을 진행해 왔습니다.
- 서비스 개발을 진행하다 보면 라이브러리 사용만으로는 구현이 힘든 기능들이 무수히 많이 존재합니다.
- 예를 들어 우리의 서비스에서 회원가입을 진행할 때 사용자의 주소를 받아야 한다면?
- 주소를 검색할 수 있는 기능을 구현해야 하는데 직접 구현을 하게 되면 많은 시간과 비용이 들어갑니다.
- 이때, 카카오에서 만든 주소 검색 API를 사용한다면 해당 기능을 간편하게 구현할 수 있습니다.

- 이럴 때 우리의 서버는 Client의 입장이 되어 Kakao 서버에 요청을 진행해야 합니다.
- Spring에서는 서버에서 다른 서버로 간편하게 요청할 수 있도록 RestTemplate 기능을 제공하고 있습니다.

- 프로젝트 2개를 만들어서 Client 입장의 서버는 8080 port, Server 입장의 서버는 7070 port로 동시에 실행시키겠습니다.
📊 최종 비교 요약표
| 구분 | GET (getForEntity) | POST (postForEntity) | EXCHANGE (exchange) |
| 목적 | 데이터 단순 조회 (가져오기) | 데이터 단순 전달/등록 (보내기) | 헤더(토큰) 포함 및 모든 HTTP 방식 처리 |
| 파라미터 형태 | 2개 (URI, 받을타입) |
3개 (URI, 보낼데이터, 받을타입) |
2개 (RequestEntity, 받을타입) |
| HTTP Body | 없음 | 있음 (Java 객체를 넣으면 JSON 자동 변환) |
있음/없음 선택 (RequestEntity 포장 시 .body()로 설정) |
| 헤더(Header) 조작 | 불가능 (기본값 전송) | 불가능 (기본값 전송) | 가능 (.header("Key", "Value")로 토큰 등 추가) |
| 주소 변수 처리 | .queryParam("key", "value") | .path("/{key}").expand("value") | URI 빌더에서 동일하게 만든 후 RequestEntity에 넣음 |
| 특징 (요약) | 파라미터가 적고 쓰기 편함 (Body 없음) | 객체만 넣으면 알아서 JSON으로 변환됨 | 만능키. 헤더 조작이 가능하고 RequestEntity를 써서 코드가 가장 깔끔함! |
RestTemplate의 Get 요청
💡요약: "RestTemplate은 서버 간 통신을 도와주는 도구이며, 단순한 구조는 자동 매핑을, 복잡한 중첩 구조는 String으로 받아 수동 파싱(org.json)하는 것이 정석이다!"
| 구분 | 단일 객체 요청 | 리스트/복잡한 JSON 요청 |
| 반환 타입 | ResponseEntity<ItemDto> | ResponseEntity<String> (통째로 받음) |
| 변환 방식 | 자동 변환 (Jackson) | 수동 파싱 (JSONObject, JSONArray) |
| 이유 | 구조가 단순함 | JSON이 { "items": [...] } 처럼 중첩된 경우 수동 파싱이 더 직관적임 |
Get 요청 방법
📌 Client 입장 서버
- 1. RestTemplate을 주입받습니다.
private final RestTemplate restTemplate;
// RestTemplateBuilder의 build()를 사용하여 RestTemplate을 생성합니다.
public RestTemplateService(RestTemplateBuilder builder) {
this.restTemplate = builder.build();
}
- 2. 요청받은 검색어를 Query String 방식으로 Server 입장의 서버로 RestTemplate를 사용하여 요청합니다.
public ItemDto getCallObject(String query) {
// 요청 URL 만들기
URI uri = UriComponentsBuilder
.fromUriString("http://localhost:7070")
.path("/api/server/get-call-obj")
.queryParam("query", query)
.encode()
.build()
.toUri();
log.info("uri = " + uri);
ResponseEntity<ItemDto> responseEntity = restTemplate.getForEntity(uri, ItemDto.class);
log.info("statusCode = " + responseEntity.getStatusCode());
return responseEntity.getBody();
}
- Spring의 UriComponentsBuilder를 사용해서 URI를 손쉽게 만들 수 있습니다.
- RestTemplate의 getForEntity는 Get 방식으로 해당 URI의 서버에 요청을 진행합니다.
- 첫 번째 파라미터에는 URI, 두 번째 파라미터에는 전달받은 데이터와 매핑하여 인스턴스화할 클래스의 타입을 주면 됩니다.
- 요청의 결괏값에 대해서 직접 JSON TO Object를 구현할 필요 없이 RestTemplate을 사용하면 자동으로 처리해 줍니다.
- 따라서 response.getBody()를 사용하여 두 번째 파라미터로 전달한 클래스 타입으로 자동 변환된 객체를 가져올 수 있습니다.
getForEntity의 자동 매핑 원리
- 단일 객체: restTemplate.getForEntity(uri, ItemDto.class)
- 핵심: 우리가 직접 JSON을 파싱 할 필요 없음. 스프링 내부의 Jackson 라이브러리가 JSON 데이터를 ItemDto 필드에 맞춰서 자동으로 쏙 넣어줌 (인스턴스화).
📌 Server 입장 서버
- Server 입장의 서버에서 itemList를 조회하여 요청받은 검색어에 맞는 Item을 반환합니다.
public Item getCallObject(String query) {
for (Item item : itemList) {
if(item.getTitle().equals(query)) {
return item;
}
}
return null;
}
요청한 Item이 여러 개라면?
📌 Client 입장 서버
// json
implementation 'org.json:json:20230227'
Item List 조회
public List<ItemDto> getCallList() {
// 요청 URL 만들기
URI uri = UriComponentsBuilder
.fromUriString("http://localhost:7070")
.path("/api/server/get-call-list")
.encode()
.build()
.toUri();
log.info("uri = " + uri);
ResponseEntity<String> responseEntity = restTemplate.getForEntity(uri, String.class);
log.info("statusCode = " + responseEntity.getStatusCode());
log.info("Body = " + responseEntity.getBody());
return fromJSONtoItems(responseEntity.getBody());
}
- 결과 값이 다중 JSON으로 넘어오기 때문에 JSON To Object를 사용하지 않고 일단 String 값 그대로를 가져옵니다.
- Server 입장 서버의 ItemResponseDto는 아래의 JSON 형태로 변환되어 전달됩니다.
{
"items":[
{"title":"Mac","price":3888000},
{"title":"iPad","price":1230000},
{"title":"iPhone","price":1550000},
{"title":"Watch","price":450000},
{"title":"AirPods","price":350000}
]
}
- JSON 처리를 도와주는 라이브러리를 추가하여 받아온 JSON 형태의 String을 처리합니다.
public List<ItemDto> fromJSONtoItems(String responseEntity) {
JSONObject jsonObject = new JSONObject(responseEntity);
JSONArray items = jsonObject.getJSONArray("items");
List<ItemDto> itemDtoList = new ArrayList<>();
for (Object item : items) {
ItemDto itemDto = new ItemDto((JSONObject) item);
itemDtoList.add(itemDto);
}
return itemDtoList;
}
- JSONObject: 전체 큰 보따리({ ... })를 먼저 잡는다.
- JSONArray: 보따리 안에 든 리스트([ ... ])를 꺼낸다.
- for문 + 생성자: 리스트를 돌면서 하나씩 ItemDto로 변환한다. (이때 ItemDto에 JSONObject 전용 생성자가 있으면 코드가 훨씬 깔끔해짐!)
JSONObject, JSONArray 이해하기
- 1) 문자열 정보를 JSONObject로 바꾸기
JSONObject jsonObject = new JSONObject(responseEntity);
- 2) JSONObject에서 items 배열 꺼내기
JSONArray items = jsonObject.getJSONArray("items");
- 3) JSONArray로 for문 돌면서 상품 하나씩 ItemDto로 변환하기
List<ItemDto> itemDtoList = new ArrayList<>();
for (Object item : items) {
ItemDto itemDto = new ItemDto((JSONObject) item);
itemDtoList.add(itemDto);
}
- 4) JSONObject에서 ItemDto로 변환하기
this.title = itemJson.getString("title");
this.price = itemJson.getInt("price");
- ItemDto에 받아온 JSONObject를 사용하여 초기화하는 생성자를 추가해 줍니다.
@Getter
@NoArgsConstructor
public class ItemDto {
private String title;
private int price;
public ItemDto(JSONObject itemJson) {
this.title = itemJson.getString("title");
this.price = itemJson.getInt("price");
}
}
📌 Server 입장 서버
- Server 입장의 서버에서 itemList를 ItemResponseDto에 담아 반환합니다.
public ItemResponseDto getCallList() {
ItemResponseDto responseDto = new ItemResponseDto();
for (Item item : itemList) {
responseDto.setItems(item);
}
return responseDto;
}
RestTemplate의 Post 요청
📌 Client 입장 서버
- 요청받은 검색어를 Query String 방식으로 Server 입장의 서버로 RestTemplate를 사용하여 요청합니다.
public ItemDto postCall(String query) {
// 요청 URL 만들기
URI uri = UriComponentsBuilder
.fromUriString("http://localhost:7070")
.path("/api/server/post-call/{query}")
.encode()
.build()
.expand(query)
.toUri();
log.info("uri = " + uri);
User user = new User("Robbie", "1234");
ResponseEntity<ItemDto> responseEntity = restTemplate.postForEntity(uri, user, ItemDto.class);
log.info("statusCode = " + responseEntity.getStatusCode());
return responseEntity.getBody();
}
- UriComponentsBuilder의 expand를 사용하여 {query} 안의 값을 동적으로 처리할 수 있습니다.
- RestTemplate의 postForEntity는 Post 방식으로 해당 URI의 서버에 요청을 진행합니다.
- 첫 번째 파라미터에는 URI, 두 번째 파라미터에는 HTTP Body에 넣어줄 데이터를 넣습니다.
- Java 객체를 두 번째 파라미터에 넣으면 자동으로 JSON 형태로 변환됩니다.
- 세 번째 파라미터에는 전달받은 데이터와 매핑하여 인스턴스화할 클래스의 타입을 주면 됩니다.
- 첫 번째 파라미터에는 URI, 두 번째 파라미터에는 HTTP Body에 넣어줄 데이터를 넣습니다.
📝 postForEntity: "내 데이터(Body)도 같이 받아줘!"
- GET은 데이터를 조회만 하니까 Body가 없지만, POST는 무언가를 생성/처리해 달라고 데이터를 품고 갑니다. 그래서 파라미터가 하나 더 늘어납니다.
- GET 방식: getForEntity(URI, 반환타입.class)
- POST 방식: postForEntity(URI, 보낼_데이터, 반환타입.class)
- 💡 마법의 자동 변환: 두 번째 파라미터에 Java 객체(예: user)를 툭 던져 넣으면, 스프링(Jackson)이 알아서 JSON 형태의 HTTP Body로 변환해서 전송해 줍니다. (우리가 직접 JSON 문자열로 짤 필요 없음!)
📝 URI에 구멍 뚫고 채우기 (expand)
- GET 방식에서 ?query=검색어 형태(queryParam)를 썼다면, 이번엔 경로 자체에 변수를 넣는 Path Variable 방식을 사용했습니다.
// 1. {query} 라는 빈 구멍을 뚫어놓고
.path("/api/server/post-call/{query}")
// 2. expand()로 그 구멍에 실제 값을 쏙 채워 넣는다!
.expand(query)
📌 Client 입장 서버
- Server 입장의 서버에서 itemList를 조회하여 요청받은 검색어에 맞는 Item을 반환합니다.
public Item postCall(String query, UserRequestDto userRequestDto) {
System.out.println("userRequestDto.getUsername() = " + userRequestDto.getUsername());
System.out.println("userRequestDto.getPassword() = " + userRequestDto.getPassword());
return getCallObject(query);
}
- 전달받은 HTTP Body의 User 데이터를 확인합니다.
RestTemplate의 exchange
요청 Header에 정보를 추가하고 싶다면?
- RestTemplate으로 요청을 보낼 때 Header에 특정 정보를 같이 전달하고 싶다면 어떻게 하면 될까요?
💡요약
- "헤더(Header)에 토큰을 담아서 통신해야 할 때는, URI/Method/Header/Body를 한 번에 조립할 수 있는 RequestEntity를 만들어서 restTemplate.exchange()로 전송한다!"
📌 Client 입장 서버
- RestTemplate의 exchange를 사용합니다.
public List<ItemDto> exchangeCall(String token) {
// 요청 URL 만들기
URI uri = UriComponentsBuilder
.fromUriString("http://localhost:7070")
.path("/api/server/exchange-call")
.encode()
.build()
.toUri();
log.info("uri = " + uri);
User user = new User("Robbie", "1234");
RequestEntity<User> requestEntity = RequestEntity
.post(uri)
.header("X-Authorization", token)
.body(user);
ResponseEntity<String> responseEntity = restTemplate.exchange(requestEntity, String.class);
return fromJSONtoItems(responseEntity.getBody());
}
📌 Server 입장 서버
- 전달된 header와 body의 정보를 확인할 수 있습니다.
public ItemResponseDto exchangeCall(String token, UserRequestDto requestDto) {
System.out.println("token = " + token);
System.out.println("requestDto.getUsername() = " + requestDto.getUsername());
System.out.println("requestDto.getPassword() = " + requestDto.getPassword());
return getCallList();
}
🚀 RestTemplate 심화: Header(토큰)와 Body 함께 보내기 (exchange)
- 왜 exchange가 필요한가?
- getForEntity나 postForEntity는 데이터를 보내고 받는 데는 좋지만, "내 JWT 토큰(Header)도 같이 보내줘!"라고 할 때는 한계가 있습니다.
- 헤더(Header)에 인증 토큰 같은 특수 값을 넣어서 보낼 때는 exchange() 메서드와 RequestEntity (또는 HttpEntity)를 사용해야 합니다.
- HttpEntity vs RequestEntity (포장 상자 비교)
- HttpEntity (기본 상자): 외부 서버와 통신할 때 데이터(Body)와 헤더(Header)를 함께 보낼 수 있게 해주는 객체.
- Header + Body만 가지고 있는 기본 객체. (이걸 쓰면 exchange() 호출할 때 URI와 Method를 따로 넘겨줘야 함)
- 내용물(Body)과 편지봉투(Header)만 포장함. 보낼 때 목적지(URI)와 방식(GET/POST)을 따로 알려줘야 함.
- RequestEntity (스마트 상자🔥): HttpEntity를 상속받은 객체. URI + Method + Header + Body를 모두 품고 있는 완성형 요청 객체.
- HttpEntity의 업그레이드 버전! 기존 기능에 추가로 목적지(URI)와 HTTP 메서드(GET, POST 등) 정보까지 객체 하나에 모두 담을 수 있음.
- 방식(Method) + 주소(URI) + 헤더(Header) + 내용물(Body)을 체인 형태로 한 번에 묶어서 깔끔하게 포장함.
- ResponseEntity: 이것도 HttpEntity의 자식. 서버에서 돌아온 응답(상태 코드 + Header + Body)을 담는 바구니.
- HttpEntity (기본 상자): 외부 서버와 통신할 때 데이터(Body)와 헤더(Header)를 함께 보낼 수 있게 해주는 객체.
결론: RestTemplate로 요청을 보낼 때, RequestEntity를 쓰면 코드를 좀 더 체인(Chain) 형태로 깔끔하게 작성할 수 있다!
Naver Open API
👉 네이버 서비스를 코드로 이용할 수 있는 서비스입니다.
https://developers.naver.com/products/intro/plan/
https://developers.naver.com/products/intro/plan/
developers.naver.com

상품 검색 API 구현
- 검색한 상품을 반환하는 API 생성
package com.sparta.springresttemplateclient.naver.controller;
import com.sparta.springresttemplateclient.naver.dto.ItemDto;
import com.sparta.springresttemplateclient.naver.service.NaverApiService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api")
public class NaverApiController {
private final NaverApiService naverApiService;
public NaverApiController(NaverApiService naverApiService) {
this.naverApiService = naverApiService;
}
@GetMapping("/search")
public List<ItemDto> searchItems(@RequestParam String query) {
return naverApiService.searchItems(query);
}
}
package com.sparta.springresttemplateclient.naver.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.json.JSONObject;
@Getter
@NoArgsConstructor
public class ItemDto {
private String title;
private String link;
private String image;
private int lprice;
public ItemDto(JSONObject itemJson) {
this.title = itemJson.getString("title");
this.link = itemJson.getString("link");
this.image = itemJson.getString("image");
this.lprice = itemJson.getInt("lprice");
}
}
package com.sparta.springresttemplateclient.naver.service;
import com.sparta.springresttemplateclient.naver.dto.ItemDto;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
@Slf4j(topic = "NAVER API")
@Service
public class NaverApiService {
private final RestTemplate restTemplate;
public NaverApiService(RestTemplateBuilder builder) {
this.restTemplate = builder.build();
}
public List<ItemDto> searchItems(String query) {
// 요청 URL 만들기
URI uri = UriComponentsBuilder
.fromUriString("https://openapi.naver.com")
.path("/v1/search/shop.json")
.queryParam("display", 15)
.queryParam("query", query)
.encode()
.build()
.toUri();
log.info("uri = " + uri);
RequestEntity<Void> requestEntity = RequestEntity
.get(uri)
.header("X-Naver-Client-Id", "Client-Id")
.header("X-Naver-Client-Secret", "Client-Secret")
.build();
ResponseEntity<String> responseEntity = restTemplate.exchange(requestEntity, String.class);
log.info("NAVER API Status Code : " + responseEntity.getStatusCode());
return fromJSONtoItems(responseEntity.getBody());
}
public List<ItemDto> fromJSONtoItems(String responseEntity) {
JSONObject jsonObject = new JSONObject(responseEntity);
JSONArray items = jsonObject.getJSONArray("items");
List<ItemDto> itemDtoList = new ArrayList<>();
for (Object item : items) {
ItemDto itemDto = new ItemDto((JSONObject) item);
itemDtoList.add(itemDto);
}
return itemDtoList;
}
}

Entity 연관 관계
고객
| id | name |
| 1 | Robbie |
| 2 | Robbert |
음식
| id | name | price |
| 1 | 후라이드 치킨 | 15000 |
| 2 | 양념 치킨 | 20000 |
| 3 | 고구마 피자 | 30000 |
| 4 | 아보카도 피자 | 50000 |
주문
| id | user_id | food_id | 주문일 |
| 1 | 1 | 1 | 2023-01-01 |
| 2 | 2 | 1 | 2023-01-01 |
| 3 | 2 | 2 | 2023-01-01 |
| 4 | 1 | 4 | 2023-01-01 |
| 5 | 2 | 3 | 2023-01-01 |
- 고객 1명은 음식 N개를 주문할 수 있습니다.
- 고객 : 음식 = 1 : N 관계
- 음식 1개는 고객 N명에게 주문될 수 있습니다.
- 음식 : 고객 = 1 : N 관계
- 결론적으로 고객과 음식은 N : M 관계입니다.
- 고객 : 음식 = N : M 관계
- 이렇듯 N : M 관계인 테이블들의 연관 관계를 해결하기 위해 orders 테이블처럼 중간 테이블을 사용할 수 있습니다.
- 고객 1명은 주문을 여러 번 할 수 있습니다.
- 고객 : 주문 = 1 : N
- 음식 1개는 주문이 여러 번 될 수 있습니다.
- 음식 : 주문 = 1 : N
- 고객 1명은 주문을 여러 번 할 수 있습니다.
DB table 간의 방향
- DB에서는 어떤 테이블을 기준으로 하든 원하는 정보를 JOIN을 사용하여 조회할 수 있습니다.
- 이처럼 DB 테이블 간의 관계에서는 방향의 개념이 없습니다
Entity 간의 연관 관계
- 그렇다면 JPA Entity에서는 이러한 테이블 간의 연관 관계를 어떻게 표현하고 있을까요?
- 음식 : 고객 = N : 1 관계를 표현해 보겠습니다.
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
}
- 한 명의 고객은 여러 번 주문이 가능한 상황입니다.
- 이를 Entity에서 여러번 가능함을 표현하기 위해 Java 컬렉션을 사용하여 List<Food> foodList = new ArrayList<>() 이처럼 표현할 수 있습니다.
- 그렇다면 Entity에서 이렇게까지 해서 표현을 하는 이유가 무엇일까요?
- DB 테이블에서는 고객 테이블 기준으로 음식의 정보를 조회하려고 할 때 JOIN을 사용하여 바로 조회가 가능하지만 고객 Entity 입장에서는 음식 Entity의 정보를 가지고 있지 않으면 음식의 정보를 조회할 방법이 없습니다.
- 따라서 DB 테이블에 실제 컬럼으로 존재하지는 않지만 Entity 상태에서 다른 Entity를 참조하기 위해 이러한 방법을 사용합니다.
- 현재 음식 Entity와 고객 Entity는 서로를 참조하고 있습니다.
- 이러한 관계를 양방향 관계라 부릅니다.
- 그럼 반대로 단방향 관계는 어떻게 표현되는지 보겠습니다.
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
- 음식 Entity에서만 고객 Entity를 참조할 수 있습니다.
- 이러한 관계를 단방향 관계라 부릅니다.
- 고객 Entity에는 음식 Entity의 정보가 없기 때문에 음식 정보를 조회할 수 없습니다.
💡정리
- DB 테이블에서는 테이블 사이의 연관관계를 FK(외래 키)로 맺을 수 있고 방향 상관없이 조회가 가능합니다.
- Entity에서는 상대 Entity를 참조하여 Entity 사이의 연관관계를 맺을 수 있습니다.
- 하지만 상대 Entity를 참조하지 않고 있다면 상대 Entity를 조회할 수 있는 방법이 없습니다.
- 따라서 Entity에서는 DB 테이블에는 없는 방향의 개념이 존재합니다.
- 데이터베이스 테이블에는 방향이라는 개념이 없지만, Entity 클래스 세계에서는 객체 형태이기 때문에 서로 참조하기 위해 상대 Entity의 타입을 필드로 가지고 있어야만 함.
- 그렇기 때문에 그런 참조할 만한 필드가 없다면 조회가 불가능한 상황이 발생해서 방향이라는 개념이 생기게 되었음.
- 그래서 서로 상대방의 Entitiy를 참조하고 있다면 양방향, 한쪽이라도 참조하지 못하고 있다면 단방향이라는 표현을 사용함.
1 대 1 관계
@OneToOne
- @OneToOne 애너테이션은 1 대 1 관계를 맺어주는 역할을 합니다.
- 고객 Entity와 음식 Entity가 1 대 1 관계라 가정하여 관계를 맺어보겠습니다.
단방향 관계
외래 키의 주인 정하기
- Entity에서 외래 키의 주인은 일반적으로 N(다)의 관계인 Entity이지만 1 대 1 관계에서는 외래 키의 주인을 직접 지정해야 합니다.
- 외래 키 주인만이 외래 키를 등록, 수정, 삭제할 수 있으며, 주인이 아닌 쪽은 오직 외래 키를 읽기만 가능합니다.
- @JoinColumn()은 외래 키의 주인이 활용하는 애너테이션입니다.
- 컬럼명, null 여부, unique 여부 등을 지정할 수 있습니다.

- 음식 Entity가 외래 키의 주인인 경우!
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@OneToOne
@JoinColumn(name = "user_id")
private User user;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}

- 고객 Entity가 외래 키의 주인인 경우!
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne
@JoinColumn(name = "food_id")
private Food food;
}
양방향 관계
- 양방향 관계에서 외래 키의 주인을 지정해 줄 때 mappedBy 옵션을 사용합니다.
- mappedBy의 속성값은 외래 키의 주인인 상대 Entity의 필드명을 의미합니다.
- 관계 설정 방법에 대해 정리해 보겠습니다.
- 단방향이라면 외래 키의 주인만 상대 Entity 타입의 필드를 가지면서 @JoinColumn()을 활용하여 외래 키의 속성을 설정해 주면 됩니다.
- 양방향이라면 외래 키의 주인은 상대 Entity 타입의 필드를 가지면서 @JoinColumn()을 활용하여 외래 키의 속성을 설정을 해줍니다.
- 그리고 상대 Entity는 외래 키의 주인 Entity 타입의 필드를 가지면서 mappedBy 옵션을 사용하여 속성값으로 외래 키의 주인 Entity에 선언된 @JoinColumn()으로 설정되고 있는 필드명을 넣어주면 됩니다.
⚠️ 주의!
- 외래 키의 주인 Entity에서 @JoinColumn() 애너테이션을 사용하지 않아도 default 옵션이 적용되기 때문에 생략이 가능합니다.
- 다만 1 대 N 관계에서 외래 키의 주인 Entity가 @JoinColumn() 애너테이션을 생략한다면 JPA가 외래 키를 저장할 컬럼을 파악할 수가 없어서 의도하지 않은 중간 테이블이 생성됩니다.
- 따라서 외래 키의 주인 Entity에서 @JoinColumn() 애너테이션을 활용하시는 게 좋습니다.
- 양방향 관계에서 mappedBy 옵션을 생략할 경우 JPA가 외래 키의 주인 Entity를 파악할 수가 없어 의도하지 않은 중간 테이블이 생성되기 때문에 반드시 설정해 주시는 게 좋습니다.

- 음식 Entity가 외래 키의 주인인 경우!
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@OneToOne
@JoinColumn(name = "user_id")
private User user;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne(mappedBy = "user")
private Food food;
}

- 고객 Entity가 외래 키의 주인인 경우!
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@OneToOne(mappedBy = "food")
private User user;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToOne
@JoinColumn(name = "food_id")
private Food food;
}
⭐ 연습하기
💡 요약
- 연관관계의 주인: @JoinColumn을 가진 쪽이다. (DB 테이블에 실제 외래 키를 가지는 쪽)
- 주인의 권한: 진짜 주인만이 외래 키의 값을 변경/저장할 수 있다.
- mappedBy의 역할: 주인이 아님을 선언. 이쪽은 오직 읽기(조회*만 가능하다.
- 양방향 저장의 규칙: 양방향일 때는 오류를 막기 위해 양쪽 객체 모두에 값을 세팅해 주는 '연관관계 편의 메서드(addFood)'를 만들어서 쓰는 것이 정석이다.
- 외래 키의 주인이 음식 Entity인 경우를 연습해 보겠습니다.
package com.sparta.jpaadvance.relation;
import com.sparta.jpaadvance.repository.FoodRepository;
import com.sparta.jpaadvance.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@Transactional
@SpringBootTest
public class OneToOneTest {
@Autowired
UserRepository userRepository;
@Autowired
FoodRepository foodRepository;
}
- @SpringBootTest: 스프링 부트의 전체 환경(컨텍스트)을 메모리에 그대로 로드해서, 실제 서버가 돌아가는 것과 똑같은 상태로 통합 테스트를 진행하게 해주는 어노테이션입니다.
- @Transactional: 테스트가 끝나면 DB를 원래 상태로 되돌려주는(롤백) 역할을 하며, 특히 테스트가 진행되는 동안 영속성 컨텍스트(Persistence Context)를 계속 살려두어 엔티티의 상태 변화를 추적하고 지연 로딩(Lazy Loading)을 가능하게 합니다. 지연 로딩이란 필요한 시점에 연관된 객체의 대이터를 불러오는 것입니다.
- @Autowired: 우리가 직접 new 키워드로 객체를 생성하지 않아도, 스프링 컨테이너가 알아서 필요한 부품(Repository 등)을 찾아 자동으로 연결(의존성 주입)해 주는 키워드입니다.
단방향
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
// Food Entity (외래 키의 주인 👑)
@OneToOne
@JoinColumn(name = "user_id") // "내가 DB에 user_id라는 컬럼을 만들고 관리할게!"
private User user;
}
- 목표: Food 테이블에 User의 외래 키(FK)를 두고, Food가 주인이 되게 만들기!
- @JoinColumn의 의미: 이 어노테이션이 붙은 곳이 연관관계의 진짜 주인(Owner)입니다. DB 테이블 상에서도 Food 테이블 안에 user_id라는 컬럼이 생기게 됩니다.
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Test
@Rollback(value = false) // 테스트에서는 @Transactional 에 의해 자동 rollback 됨으로 false 설정해준다.
@DisplayName("1대1 단방향 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
// 외래 키의 주인인 Food Entity user 필드에 user 객체를 추가해 줍니다.
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
food.setUser(user); // 외래 키(연관 관계) 설정
userRepository.save(user);
foodRepository.save(food);
}
- 설명: 주인이 Food이기 때문에, food.setUser(user)를 호출해야만 밥(Food)을 누가(User) 시켰는지 DB에 정상적으로 기록됩니다.
+)📝 Dirty Checking vs Save
- 왜 save()를 해야 하나요? (영속 vs 비영속)
- JPA를 배울 때 "어? @Transactional 붙이면 알아서 저장된다(변경 감지)고 배웠는데, 왜 굳이 save()를 또 쓰지?"
| 상황 | save() 필요 여부 | 이유 (JPA의 속마음) |
| new로 객체를 처음 만들었을 때 | 무조건 필요 (O) | "얘가 누군지 난 몰라! 일단 save() 해서 내 호적(영속성 컨텍스트)에 등록부터 해!" |
| DB에서 꺼내온 객체를 수정할 때 | 필요 없음 (X) (@Transactional 덕분) |
"얜 내가 이미 감시하고 있는 애야! 값 바뀌면 내가 알아서 트랜잭션 끝날 때 DB에 UPDATE 쳐줄게!" (이게 변경 감지) |
- 가장 핵심적인 이유는 우리가 방금 new 키워드로 갓 태어난 객체를 만들었기 때문입니다.
- new User()의 상태 (비영속): 자바 메모리에만 존재할 뿐, JPA는 이 객체가 세상에 태어난지도 모릅니다. (호적에 안 올라간 야생 상태)
- save()의 역할 (영속): "JPA야, 나 이거 새로 만들었으니까 네가 관리하는 호적(영속성 컨텍스트)에 좀 올려줘! 그리고 DB에 넣어서 ID(PK) 좀 발급받아와!"라고 등록하는 과정입니다.
- 💡 "어? Transactional 있으면 변경 감지(Dirty Checking)로 알아서 저장된다면서요?"
- 맞습니다. 하지만 변경 감지는 '이미 호적에 올라가 있는(영속 상태인) 객체'에게만 작동합니다.
- 변경 감지가 작동할 때: DB에서 findById로 꺼내온 객체(이미 호적에 있음)의 이름을 쓱 바꾸면, save() 안 해도 @Transactional이 끝날 때 알아서 UPDATE를 쳐줍니다.
- 변경 감지가 작동하지 않았을 때: new로 방금 만든 객체는 아직 호적에 없기 때문에, JPA가 감시를 안 합니다. 그래서 최초 1회는 무조건 save()를 눌러서 호적에 등록을 해줘야 하는 겁니다!
- 맞습니다. 하지만 변경 감지는 '이미 호적에 올라가 있는(영속 상태인) 객체'에게만 작동합니다.
양방향
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// User Entity (주인이 아닌 쪽 🪞)
@OneToOne(mappedBy = "user")
private Food food;
// ⭐️ 연관관계 편의 메서드
public void addFood(Food food) {
this.food = food; // 내 객체에 food 넣기
food.setUser(this); // 👑 진짜 주인(food) 객체에도 나(user)를 넣기!
}
}
- 목표: User 입장에서도 "내가 무슨 음식을 시켰지?" 하고 바로 찾아볼 수 있게 양쪽을 연결하기!
- mappedBy = "user"의 의미: "나는 외래 키의 주인이 아니야! 진짜 주인은 저기 Food 클래스에 있는 user 필드야!"라고 거울(가짜) 역할을 선언하는 겁니다. 이쪽에서는 값을 세팅해 봤자 DB에 저장(Update)되지 않습니다. 오직 조회(읽기)만 가능해요.
Q. 외래 키 조작 권한이 없는 User가 어떻게 연관관계를 저장하나요?
- A. User가 직접 외래 키를 조작하는 것이 아니라, addFood 메서드 안에서 진짜 주인인 Food 객체의 메서드(food.setUser(this))를 대신 호출해 주기 때문입니다.
- 즉, 주인이 아닌 쪽(User)은 DB를 바꿀 권한이 없으므로, 편의 메서드 안에서 진짜 주인(Food)의 Setter를 대신 호출해 주어 JPA가 정상적으로 DB를 업데이트하게 만듭니다.
- 결과적으로 user.addFood(food)를 한 번 호출하는 것만으로, 객체 지향적으로 양쪽 메모리에 데이터를 다 채워 넣음과 동시에, 진짜 주인(Food)에게도 값이 세팅되었으므로 JPA가 이를 감지하고 정상적으로 DB의 외래 키를 업데이트하게 됩니다.
@Test
@Rollback(value = false)
@DisplayName("1대1 양방향 테스트 : 외래 키 저장 실패")
void test2() {
Food food = new Food();
food.setName("고구마 피자");
food.setPrice(30000);
// 외래 키의 주인이 아닌 User 에서 Food 를 저장해보겠습니다.
User user = new User();
user.setName("Robbie");
user.setFood(food);
userRepository.save(user);
foodRepository.save(food);
// 확인해 보시면 user_id 값이 들어가 있지 않은 것을 확인하실 수 있습니다.
}
@Test
@Rollback(value = false)
@DisplayName("1대1 양방향 테스트 : 외래 키 저장 실패 -> 성공")
void test3() {
Food food = new Food();
food.setName("고구마 피자");
food.setPrice(30000);
// 외래 키의 주인이 아닌 User 에서 Food 를 저장하기 위해 addFood() 메서드 추가
// 외래 키(연관 관계) 설정 food.setUser(this); 추가
User user = new User();
user.setName("Robbie");
user.addFood(food);
userRepository.save(user);
foodRepository.save(food);
}
@Test
@Rollback(value = false)
@DisplayName("1대1 양방향 테스트")
void test4() {
User user = new User();
user.setName("Robbert");
Food food = new Food();
food.setName("고구마 피자");
food.setPrice(30000);
food.setUser(user); // 외래 키(연관 관계) 설정
userRepository.save(user);
foodRepository.save(food);
}
💻 테스트 코드 뜯어보기 (실패 vs 성공)
❌ test2 (저장 실패 케이스)
User user = new User();
user.setFood(food); // 가짜(주인이 아닌 쪽)에게만 값을 넣음 -> DB 저장 안 됨!
- 설명: User는 주인이 아닙니다(mappedBy). 가짜한테 "너 이거 저장해"라고 명령해 봤자, 실제 DB의 Food 테이블에는 user_id가 텅 비어(null) 있게 됩니다.
✅ test3 (저장 성공 케이스 - 편의 메서드 사용)
User user = new User();
user.addFood(food); // 편의 메서드 사용! (안에서 진짜 주인에게도 값을 세팅해 줌)
- 설명: 객체 지향적으로 생각했을 때, 양방향 관계라면 양쪽 객체 모두에 값을 넣어주는 게 가장 안전합니다. 그래서 아까 만든 addFood()를 사용해 "내(User) 안에도 넣고, 주인(Food) 안에도 넣는" 처리를 한 번에 해결한 것입니다.
✅ test4 (저장 성공 케이스 - 정석)
food.setUser(user); // 진짜 주인에게 값을 넣음 -> 당연히 정상 저장됨!
조회
@Test
@DisplayName("1대1 조회 : Food 기준 user 정보 조회")
void test5() {
Food food = foodRepository.findById(1L).orElseThrow(NullPointerException::new);
// 음식 정보 조회
System.out.println("food.getName() = " + food.getName());
// 음식을 주문한 고객 정보 조회
System.out.println("food.getUser().getName() = " + food.getUser().getName()); // 주인이니까 당연히 조회 가능
}
@Test
@DisplayName("1대1 조회 : User 기준 food 정보 조회")
void test6() {
User user = userRepository.findById(1L).orElseThrow(NullPointerException::new);
// 고객 정보 조회
System.out.println("user.getName() = " + user.getName());
// 해당 고객이 주문한 음식 정보 조회
Food food = user.getFood(); // ⭐️ 양방향(mappedBy)을 걸어둔 진짜 이유!
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
}
- 설명: 단방향이었으면 User에서 Food를 찾을 방법이 없어서, 번거롭게 FoodRepository를 또 뒤져야 했을 겁니다. 하지만 양방향을 뚫어놨기 때문에 user.getFood() 한 줄로 내가 주문한 음식을 바로 가져올 수 있습니다.
N 대 1 관계
- ManyToOne
- @ManyToOne 애너테이션은 N 대 1 관계를 맺어주는 역할을 합니다.
- 음식 Entity와 고객 Entity가 N 대 1 관계라 가정하여 관계를 맺어보겠습니다.
단방향 관계

- 음식 Entity가 N의 관계로 외래 키의 주인
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
양방향 관계

- 양방향 참조를 위해 고객 Entity에서 Java 컬렉션을 사용하여 음식 Entity 참조
- 음식 Entity가 N의 관계로 외래 키의 주인
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
}
| 코드 부분 | 역할 및 설명 (필기용) | 왜 이렇게 쓸까? (이유) |
| @OneToMany(mappedBy = "user") | "나는 1이고, 쟤들은 N이야!" 외래 키의 주인이 아님을 선언. |
하나의 User가 여러 Food를 가지는 1:N 관계임을 명시하고, 진짜 주인은 Food 클래스의 user 필드라고 알려줍니다. |
| List<Food> | 여러 개의 Food를 담는 바구니 타입 | 자바에서 여러 개의 객체를 묶어서 관리할 때 사용하는 '컬렉션(Collection)'의 한 종류입니다. |
| foodList | 변수명 | 음식들을 담은 리스트의 이름입니다. |
| = new ArrayList<>(); | 빈 바구니 생성 (초기화) | 이걸 안 쓰면 바구니가 아예 없는 상태(null)가 됩니다. 나중에 음식을 넣으려다 에러가 나는 걸 막기 위해 미리 빈 바구니를 만들어 두는 JPA 정석 패턴입니다. |
- 💡 (참고) 자바 컬렉션(Collection)과 ArrayList가 뭔가요?
- 컬렉션(Collection): 자바에서 데이터(객체)들을 여러 개 모아서 편리하게 관리할 수 있도록 만들어둔 '자료구조들의 모음'을 뜻하는 단어입니다. 배열(Array)의 업그레이드 버전이라고 생각하시면 됩니다.
- List: 컬렉션 중에서도 "순서가 있고, 데이터 중복을 허용하는" 상자의 규격(인터페이스)입니다.
- ArrayList: List 규격을 실제 사용할 수 있게 구현해 놓은 가장 대중적인 클래스입니다. (크기가 알아서 늘어나는 똑똑한 배열형 상자)
연습하기
package com.sparta.jpaadvance.relation;
import com.sparta.jpaadvance.repository.FoodRepository;
import com.sparta.jpaadvance.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@Transactional
@SpringBootTest
public class ManyToOneTest {
@Autowired
UserRepository userRepository;
@Autowired
FoodRepository foodRepository;
}
단방향
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Test
@Rollback(value = false)
@DisplayName("N대1 단방향 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
food.setUser(user); // 외래 키(연관 관계) 설정
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
food2.setUser(user); // 외래 키(연관 관계) 설정
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
양방향
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this); // 외래 키(연관 관계) 설정
}
}
@Test
@Rollback(value = false)
@DisplayName("N대1 양방향 테스트 : 외래 키 저장 실패")
void test2() {
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
// 외래 키의 주인이 아닌 User 에서 Food 를 저장해보겠습니다.
User user = new User();
user.setName("Robbie");
user.getFoodList().add(food);
user.getFoodList().add(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
// 확인해 보시면 user_id 값이 들어가 있지 않은 것을 확인하실 수 있습니다.
}
@Test
@Rollback(value = false)
@DisplayName("N대1 양방향 테스트 : 외래 키 저장 실패 -> 성공")
void test3() {
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
// 외래 키의 주인이 아닌 User 에서 Food 를 쉽게 저장하기 위해 addFoodList() 메서드 생성하고
// 해당 메서드에 외래 키(연관 관계) 설정 food.setUser(this); 추가
User user = new User();
user.setName("Robbie");
user.addFoodList(food);
user.addFoodList(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
@Test
@Rollback(value = false)
@DisplayName("N대1 양방향 테스트")
void test4() {
User user = new User();
user.setName("Robbert");
Food food = new Food();
food.setName("고구마 피자");
food.setPrice(30000);
food.setUser(user); // 외래 키(연관 관계) 설정
Food food2 = new Food();
food2.setName("아보카도 피자");
food2.setPrice(50000);
food2.setUser(user); // 외래 키(연관 관계) 설정
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
조회
@Test
@DisplayName("N대1 조회 : Food 기준 user 정보 조회")
void test5() {
Food food = foodRepository.findById(1L).orElseThrow(NullPointerException::new);
// 음식 정보 조회
System.out.println("food.getName() = " + food.getName());
// 음식을 주문한 고객 정보 조회
System.out.println("food.getUser().getName() = " + food.getUser().getName());
}
@Test
@DisplayName("N대1 조회 : User 기준 food 정보 조회")
void test6() {
User user = userRepository.findById(1L).orElseThrow(NullPointerException::new);
// 고객 정보 조회
System.out.println("user.getName() = " + user.getName());
// 해당 고객이 주문한 음식 정보 조회
List<Food> foodList = user.getFoodList();
for (Food food : foodList) {
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
}
}
1 대 N 관계
- @OneToMany
- @OneToMany 애너테이션은 1 대 N 관계를 맺어주는 역할을 합니다.
- 음식 Entity와 고객 Entity가 1 대 N 관계라 가정하여 관계를 맺어보겠습니다.
단방향 관계

- 외래 키를 관리하는 주인은 음식 Entity이지만 실제 외래 키는 고객 Entity가 가지고 있습니다.
- 1 : N에서 N 관계의 테이블이 외래 키를 가질 수 있기 때문에 외래 키는 N 관계인 users 테이블에 외래 키 컬럼을 만들어 추가하지만 외래 키의 주인인 음식 Entity를 통해 관리합니다.
❓ 왜 외래 키를 User 테이블에 넣어야 한대요? (DB의 고집)
- 자바 코드에서는 Food가 주인이라고 우겨도, 실제 DB 세상에서는 무조건 N(다수)인 쪽이 외래 키를 가져야만 데이터가 성립합니다.
- 예시: 치킨 한 마리(1)를 여러 명(N)이 나눠 먹는다고 칠게요.
- DB의 생각: "음식 테이블에 칸을 수십 개 만들어서 유저 이름을 적을래? 아니면 유저 테이블에 '내가 먹은 음식' 칸 하나만 만들래?"
- 당연히 유저 테이블에 음식 ID 칸(food_id)을 하나 만드는 게 효율적이죠? 그래서 외래 키는 무조건 User 테이블에 생깁니다.
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@OneToMany
@JoinColumn(name = "food_id") // users 테이블에 food_id 컬럼
private List<User> userList = new ArrayList<>();
}
1) 이름의 수수께끼: 왜 이번엔 food_id인가요?
- 가장 헷갈리는 게 @JoinColumn(name = "...")에 들어가는 이름이죠?
- 아까 (N:1 단방향): 주인인 Food 테이블에 유저를 가리키는 기둥을 세우니까 이름이 user_id였습니다. (내 테이블에 남의 아이디 적기)
- 지금 (1:N 단방향): 주인인 Food가 유저 테이블에 기둥을 박으라고 시키는 상황입니다. 유저 테이블 입장에서는 음식을 가리키는 기둥이 생기는 거니까 이름이 food_id가 되는 거예요.
💡 핵심: @JoinColumn의 name은 "실제 외래 키 기둥이 생길 위치의 컬럼 이름"입니다. 유저 테이블에 생기니까, 유저가 어떤 음식을 먹었는지 적기 위해 food_id라고 짓는 게 DB 입장에선 자연스러운 거죠.
2. "창구는 내(Food) 쪽인데, 물건은 남(User)의 집에?"
- "주인은 나지만 창고는 빌려 쓴다"는 말이 이 뜻입니다.
- 자바 코드: Food 클래스 안에 @OneToMany가 있죠? "내가 이 관계의 대장이야! 내가 유저들 관리할 거야!"라고 선언한 겁니다.
- DB 세상: 하지만 DB는 냉정합니다. "1 대 N 관계에서 외래 키는 무조건 N(유저)이 들고 있어야 해!"라고 못을 박습니다.
- 그래서 "관리는 Food 엔티티가 하는데, 실제 데이터(외래 키)는 User 테이블에 저장되는" 기괴한 구조가 탄생합니다.
3) 왜 UPDATE가 발생할까요?
- User 저장: 일단 User를 저장합니다. 이때 User는 지가 어떤 음식이랑 연결될지 모릅니다. (주인이 아니니까요!) 그래서 food_id는 일단 NULL로 들어갑니다. (INSERT 발생)
- Food 저장: 그다음에 Food를 저장합니다. (INSERT 발생)
- 연결 작업: 이제 대장인 Food가 말합니다. "어이 JPA, 아까 저장한 저 유저들 내 거니까 food_id에 내 ID 좀 적어줘."
- 추가 작업: JPA가 다시 User 테이블로 가서 food_id를 수정합니다. (UPDATE 발생)
- 정리: 남의 테이블에 있는 값을 나중에 수정하러 가야 하니까 생성(Insert) 외에 수정(Update) 쿼리가 한 번 더 나가는 비효율이 생기는 겁니다.
| 구분 | N : 1 단방향 (정석) | 1 : N 단방향 (지금 배우는 것) |
| 코드 위치 | Food에 @ManyToOne | Food에 @OneToMany |
| 주인 | Food (내가 주인) | Food (내가 주인) |
| 기둥 위치 | Food 테이블 (내 집) | User 테이블 (남의 집) |
| 기둥 이름 | user_id (남의 번호 적기) | food_id (내 번호 박기) |
| 쿼리 | INSERT 한 방에 끝! | INSERT 후 UPDATE 추가 발생 (나쁨) |
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
- 외래 키를 음식 Entity가 직접 가질 수 있다면 INSERT 발생 시 한 번에 처리할 수 있지만 실제 DB에서 외래 키를 고객 테이블이 가지고 있기 때문에 추가적인 UPDATE가 발생된다는 단점이 존재합니다.
즉, 코드의 치명적인 단점은 "주인과 실제 컬럼 위치가 다르다"는 겁니다.
- 상황: 주인은 Food 엔티티인데, 실제 외래 키 기둥(food_id)은 User 테이블에 박혀 있어요.
- 벌어지는 일 (Update의 저주):
- Food를 저장합니다. -> "오케이 치킨 저장 완료!"
- 그런데 User 테이블에 있는 food_id를 채워야 하죠?
- Food 입장에서는 남의 집(User 테이블) 문을 따고 들어가서 값을 수정(UPDATE) 해야 합니다.
- 결과: 나는 분명 Food를 하나 저장했는데, 로그를 보면 INSERT 한 번에 UPDATE가 또 나갑니다. (성능 낭비 + 머리 아픔)
양방향 관계
- 1 대 N 관계에서는 일반적으로 양방향 관계가 존재하지 않습니다.
- 1 대 N 관계에서 양방향 관계를 맺으려면 음식 Entity를 외래 키의 주인으로 정해주기 위해 고객 Entity에서 mappedBy 옵션을 사용해야 하지만 @ManyToOne 애너테이션은 mappedBy 속성을 제공하지 않습니다.
🚫 왜 @ManyToOne에는 mappedBy가 없을까?
- mappedBy의 의미를 다시 떠올려보세요. "난 가짜야! 주인은 저쪽에 있어!"라는 뜻이죠? 그런데 JPA 설계자들이 보기에 N(Many) 쪽이 주인이 아니라는 건 말이 안 된다고 생각한 거예요.
1. DB의 물리적 한계
- DB 테이블 구조상, 1 대 N 관계에서 외래 키(FK)는 무조건 N쪽 테이블에 생깁니다.
- 주인 = 외래 키를 직접 들고 있는 사람
- N쪽 = 외래 키를 들고 있음
- 결론: N쪽은 무조건 주인이 되어야만 합니다.
2. "업데이트 저주" 방지
- 만약 @ManyToOne에 mappedBy를 허용해서 1(음식)이 주인이 되게 내버려 두면, 아까 배웠던 "남의 집 테이블(User) 기둥을 수정하러 가는 UPDATE 쿼리"가 강제로 발생하게 됩니다.
- JPA는 성능이 나쁜 이 상황을 기본적으로 막고 싶어 해요. 그래서 "N쪽 너는 무조건 주인을 해! 딴 생각 하지 마!"라고 못을 박아둔 것이라 mappedBy 속성 자체가 아예 없는 겁니다.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "food_id", insertable = false, updatable = false)
private Food food;
}
- N 관계의 Entity인 고객 Entity에서 @JoinColum의 insertable과 updatable 옵션을 false로 설정하여 양쪽으로 JOIN 설정을 하면 양방향처럼 설정할 수는 있습니다.
| 질문 | 답변 (필기용) |
| 왜 지원 안 함? | N쪽(ManyToOne)은 물리적으로 외래 키를 가진 천생 주인이기 때문. 주인이 아님을 뜻하는 mappedBy 자체가 논리적으로 성립하지 않음. |
| 강제 설정법 | ManyToOne에 @JoinColumn을 쓰되, insertable = false, updatable = false를 걸어 "읽기 전용"으로 만든다. |
| 왜 이렇게 함? | 억지로 양방향 형태를 만들어 조회는 편하게 하되, 외래 키 관리 권한은 한쪽(Food)에만 집중시켜서 꼬이지 않게 하려고. |
연습하기
package com.sparta.jpaadvance.relation;
import com.sparta.jpaadvance.repository.FoodRepository;
import com.sparta.jpaadvance.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@Transactional
@SpringBootTest
public class OneToManyTest {
@Autowired
UserRepository userRepository;
@Autowired
FoodRepository foodRepository;
}
단방향
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@OneToMany
@JoinColumn(name = "food_id") // users 테이블에 food_id 컬럼
private List<User> userList = new ArrayList<>();
}
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
@Test
@Rollback(value = false)
@DisplayName("1대N 단방향 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
food.getUserList().add(user); // 외래 키(연관 관계) 설정
food.getUserList().add(user2); // 외래 키(연관 관계) 설정
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
// 추가적인 UPDATE 쿼리 발생을 확인할 수 있습니다.
}
조회
@Test
@DisplayName("1대N 조회 테스트")
void test2() {
Food food = foodRepository.findById(1L).orElseThrow(NullPointerException::new);
System.out.println("food.getName() = " + food.getName());
// 해당 음식을 주문한 고객 정보 조회
List<User> userList = food.getUserList();
for (User user : userList) {
System.out.println("user.getName() = " + user.getName());
}
}
N 대 M 관계
- @ManyToMany
- @ManyToMany 애너테이션은 N 대 M 관계를 맺어주는 역할을 합니다.
- 음식 Entity와 고객 Entity가 N 대 M 관계라 가정하여 관계를 맺어보겠습니다.
단방향 관계

- N : M 관계를 풀어내기 위해 중간 테이블(orders)을 생성하여 사용합니다.\
| 구분 | 내용 (필기용) |
| 핵심 원리 | DB는 N:M 관계를 직접 표현할 수 없어서, 무조건 중간 테이블(Join Table)을 몰래 만들어서 1:N, N:1로 쪼개어 관리합니다. |
| 주인 (단방향) | @ManyToMany @JoinTable(...) |
| 가짜 (양방향) | @ManyToMany(mappedBy = "주인의 필드명") |
| 가장 큰 단점 (🚨중요) | JPA가 몰래 만든 중간 테이블(orders)은 우리가 통제할 수 없습니다. (주문 시간, 수량 같은 추가 정보를 넣을 수 없음) |
- 음식 Entity가 외래 키의 주인
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToMany
@JoinTable(name = "orders", // 중간 테이블 생성
joinColumns = @JoinColumn(name = "food_id"), // 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
inverseJoinColumns = @JoinColumn(name = "user_id")) // 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
private List<User> userList = new ArrayList<>();
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
- 생성되는 중간 테이블을 컨트롤하기 어렵기 때문에 추후에 중간 테이블의 변경이 발생할 경우 문제가 발생할 가능성이 있습니다.
양방향 관계

- 음식 Entity가 외래 키의 주인
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToMany
@JoinTable(name = "orders", // 중간 테이블 생성
joinColumns = @JoinColumn(name = "food_id"), // 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
inverseJoinColumns = @JoinColumn(name = "user_id")) // 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
private List<User> userList = new ArrayList<>();
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(mappedBy = "userList")
private List<Food> foodList = new ArrayList<>();
}
- 반대 방향인 고객 Entity에 @ManyToMany 로 음식 Entity를 연결하고 mappedBy 옵션을 설정하여 외래 키의 주인을 설정하면 양방향 관계 맺음이 가능합니다.
연습하기
package com.sparta.jpaadvance.relation;
import com.sparta.jpaadvance.repository.FoodRepository;
import com.sparta.jpaadvance.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@Transactional
@SpringBootTest
public class ManyToManyTest {
@Autowired
UserRepository userRepository;
@Autowired
FoodRepository foodRepository;
}
단방향
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToMany
@JoinTable(name = "orders", // 중간 테이블 생성
joinColumns = @JoinColumn(name = "food_id"), // 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
inverseJoinColumns = @JoinColumn(name = "user_id")) // 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
private List<User> userList = new ArrayList<>();
}
@Test
@Rollback(value = false)
@DisplayName("N대M 단방향 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
food.getUserList().add(user);
food.getUserList().add(user2);
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
// 자동으로 중간 테이블 orders 가 create 되고 insert 됨을 확인할 수 있습니다.
}
- 💡 (복습) 여기서 왜 User를 먼저 save 했나요?
- 주인이 아닌 애들(User)을 먼저 DB에 넣어서 ID(PK)를 만들어 줘야, 나중에 주인(Food)이 중간 테이블에 걔네 ID를 무사히 적어 넣을 수 있기 때문입니다. (안 그러면 TransientPropertyValueException 에러 폭발!)
- 🤯 뒷단에서는 무슨 일이 벌어질까? (마법의 중간 테이블)
- User 저장: INSERT 2번 발생 (로비, 로버트 저장)
- Food 저장: INSERT 1번 발생 (치킨 저장)
- ✨ JPA의 마법 발동: 주인이 치킨 바구니(userList)를 쓱 봅니다. "어? 내 바구니에 로비랑 로버트가 있네? 중간 테이블에 적어놔야지!"
- orders 테이블 저장: INSERT 2번 발생 (치킨ID - 로비ID, 치킨ID - 로버트ID)
- 📝 핵심 요약
- "N:M 단방향 매핑 후 데이터를 저장할 때는, 주인이 아닌 객체(User)를 먼저 save하고, 주인(Food)의 컬렉션(리스트)에 객체들을 담아준 뒤 주인을 마지막에 save 하면 된다. 그러면 JPA가 알아서 중간 테이블(orders)을 만들고 데이터까지 예쁘게 연결(INSERT)해 준다!"
양방향
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToMany
@JoinTable(name = "orders", // 중간 테이블 생성
joinColumns = @JoinColumn(name = "food_id"), // 현재 위치인 Food Entity 에서 중간 테이블로 조인할 컬럼 설정
inverseJoinColumns = @JoinColumn(name = "user_id")) // 반대 위치인 User Entity 에서 중간 테이블로 조인할 컬럼 설정
private List<User> userList = new ArrayList<>();
public void addUserList(User user) {
this.userList.add(user); // 외래 키(연관 관계) 설정
user.getFoodList().add(this);
}
}
- 🔗 N:M 양방향 관계: "누가 주인인가?"
- Food (진짜 주인 👑): @JoinTable을 가지고 있습니다. 오직 Food만이 중간 테이블(orders)에 데이터를 넣을 수 있습니다.
- User (가짜/거울 🪞): mappedBy = "userList"를 가지고 있습니다. 얘한테 아무리 데이터를 넣어봤자 DB는 콧방귀도 뀌지 않습니다. (조회만 가능)
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany(mappedBy = "userList")
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.getUserList().add(this); // 외래 키(연관 관계) 설정
}
}
📌 addFoodList 메서드 분석
- 1번 줄 (this.foodList.add(food);)
- this는 현재 메서드를 호출한 User 객체입니다.
- User는 mappedBy가 설정된 가짜 주인이므로, 이 줄이 실행된다고 해서 JPA가 DB(중간 테이블)에 쿼리를 날리지 않습니다.
- 목적: 오직 현재 실행 중인 자바 프로그램 메모리 상에서, User 객체가 Food 데이터를 가지고 있게 만들기 위함입니다. (동일 트랜잭션 내에서 user.getFoodList()로 조회할 때 값이 나오게 하기 위해)
- 2번 줄 (food.getUserList().add(this);) ⭐️ 핵심
- 파라미터로 전달받은 food 객체의 userList를 꺼내와서, 거기에 this(현재 User 객체)를 집어넣습니다.
- Food 엔티티는 @JoinTable을 가진 '외래 키의 주인'입니다.
- 목적: JPA는 오직 '주인' 엔티티의 컬렉션(userList)에 변화가 생겼을 때만 이를 감지하여 DB 중간 테이블에 INSERT 쿼리를 발생시킵니다. 이 줄이 바로 그 JPA의 감지 조건을 충족시키는 역할을 합니다.
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트 : 외래 키 저장 실패")
void test2() {
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
// 외래 키의 주인이 아닌 User 에서 Food 를 저장해보겠습니다.
User user = new User();
user.setName("Robbie");
user.getFoodList().add(food);
user.getFoodList().add(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
// 확인해 보시면 orders 테이블에 food_id, user_id 값이 들어가 있지 않은 것을 확인하실 수 있습니다.
}
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트 : 외래 키 저장 실패 -> 성공")
void test3() {
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
// 외래 키의 주인이 아닌 User 에서 Food 를 쉽게 저장하기 위해 addFoodList() 메서드를 생성해서 사용합니다.
// 외래 키(연관 관계) 설정을 위해 Food 에서 userList 를 호출해 user 객체 자신을 add 합니다.
User user = new User();
user.setName("Robbie");
user.addFoodList(food);
user.addFoodList(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트")
void test4() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
Food food = new Food();
food.setName("아보카도 피자");
food.setPrice(50000);
food.getUserList().add(user); // 외래 키(연관 관계) 설정
food.getUserList().add(user2); // 외래 키(연관 관계) 설정
Food food2 = new Food();
food2.setName("고구마 피자");
food2.setPrice(30000);
food2.getUserList().add(user); // 외래 키(연관 관계) 설정
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
foodRepository.save(food2);
// User 를 통해 food 의 정보 조회
System.out.println("user.getName() = " + user.getName());
List<Food> foodList = user.getFoodList();
for (Food f : foodList) {
System.out.println("f.getName() = " + f.getName());
System.out.println("f.getPrice() = " + f.getPrice());
}
// 외래 키의 주인이 아닌 User 객체에 Food 의 정보를 넣어주지 않아도 DB 저장에는 문제가 없지만
// 이처럼 User 를 사용하여 food 의 정보를 조회할 수는 없습니다.
}
@Test
@Rollback(value = false)
@DisplayName("N대M 양방향 테스트 : 객체와 양방향의 장점 활용")
void test5() {
User user = new User();
user.setName("Robbie");
User user2 = new User();
user2.setName("Robbert");
// addUserList() 메서드를 생성해 user 정보를 추가하고
// 해당 메서드에 객체 활용을 위해 user 객체에 food 정보를 추가하는 코드를 추가합니다. user.getFoodList().add(this);
Food food = new Food();
food.setName("아보카도 피자");
food.setPrice(50000);
food.addUserList(user);
food.addUserList(user2);
Food food2 = new Food();
food2.setName("고구마 피자");
food2.setPrice(30000);
food2.addUserList(user);
userRepository.save(user);
userRepository.save(user2);
foodRepository.save(food);
foodRepository.save(food2);
// User 를 통해 food 의 정보 조회
System.out.println("user.getName() = " + user.getName());
List<Food> foodList = user.getFoodList();
for (Food f : foodList) {
System.out.println("f.getName() = " + f.getName());
System.out.println("f.getPrice() = " + f.getPrice());
}
}
❌ test2 (실패): 가짜 주인이 DB를 바꾸려 할 때
// 외래 키의 주인이 아닌 User가 Food를 담으려고 시도함
user.getFoodList().add(food);
- 결과: 실패! 중간 테이블(orders)에 데이터가 아예 안 들어갑니다.
- 이유: 주인이 아닌 User 쪽에만 값을 넣었기 때문입니다. JPA는 "넌 주인이 아니잖아? 무시할게~" 하고 DB에 아무것도 적어주지 않습니다.
✅ test3 (성공): 편의 메서드로 "대리 결제" 하기
// User 쪽에 만든 addFoodList() 편의 메서드 사용
user.addFoodList(food);
- 결과: 성공! 중간 테이블에 데이터가 잘 들어갑니다.
- 이유: User가 직접 DB를 바꾼 게 아니라, 편의 메서드 안에서 진짜 주인인 Food의 바구니에도 나(User)를 쏙 넣어주었기 때문(food.getUserList().add(this))입니다. 가짜가 진짜를 조종(?)해서 DB를 바꾼 셈이죠!
🔺 test4 (반쪽짜리 성공): DB는 맞는데, 자바가 멍청해질 때
// 진짜 주인인 Food에 값을 잘 넣었음
food.getUserList().add(user);
// 그 직후, User를 통해 Food를 조회해보면?
List<Food> foodList = user.getFoodList(); // 텅 비어있음! (출력 안 됨)
- 결과: DB에는 잘 들어갔지만, 바로 밑에서 System.out.println으로 출력하려니까 아무것도 안 나옵니다!
- 이유: DB 입장에서는 주인이 값을 넣었으니 OK입니다. 하지만 자바 메모리 상에서는 User의 바구니(foodList)에 아무것도 담아준 적이 없기 때문입니다. (객체 지향의 실패)
- "자바 메모리(객체)와 DB는 별개이기 때문에, 주인(Food) 쪽에만 값을 넣으면 DB에는 저장되지만 꺼내 쓸 자바 객체(User)의 바구니는 여전히 비어있게 됩니다. 따라서 양방향 매핑 시에는 반드시 '연관관계 편의 메서드'를 사용하여 양쪽 객체 모두에 값을 세팅해야 DB와 자바 코드가 완벽하게 동기화됩니다."
- 💡 그래서 결론! (왜 양쪽 다 코드를 적어야 하는가)
- DB 입장에서는 주인(Food) 쪽에만 값을 넣어주면 찰떡같이 알아듣고 저장을 합니다. 하지만 자바 객체 입장에서는 내가 직접 양쪽 바구니에 모두 담아주지 않으면, 한쪽 바구니는 영원히 비어있게 됩니다. 그래서 우리가 이렇게 두 줄을 묶어서 편의 메서드를 만든 것입니다.
public void addUserList(User user) {
// 1. DB 저장을 위해 '주인의 바구니'에 넣는 코드
this.userList.add(user);
// 2. 자바 메모리에서 출력이 잘 되도록 '가짜의 바구니'에도 억지로 챙겨 넣어주는 코드
user.getFoodList().add(this);
}
🌟 test5 (완벽한 성공): 연관관계 편의 메서드의 위력
// 진짜 주인 Food에 만든 addUserList() 편의 메서드 사용
food.addUserList(user);
// 출력 테스트
List<Food> foodList = user.getFoodList(); // 아보카도 피자, 고구마 피자 짠!
- 결과: 완벽! DB에도 잘 들어가고, 자바 코드에서도 조회가 잘 됩니다.
- 이유: addUserList() 메서드 안에서 "내(Food) 바구니에도 담고, 쟤(User) 바구니에도 담는" 작업을 동시에 처리했기 때문입니다.
- User는 DB를 바꿀 권한이 없지만, 편의 메서드 안에서 파라미터로 받은 Food(진짜 주인)의 컬렉션에 직접 접근하여 값을 넣어주었기 때문에 JPA의 동작 조건(주인의 데이터 변경)을 충족시켜 정상적으로 DB에 저장되는 것입니다.
조회
@Test
@DisplayName("N대M 조회 : Food 기준 user 정보 조회")
void test6() {
Food food = foodRepository.findById(1L).orElseThrow(NullPointerException::new);
// 음식 정보 조회
System.out.println("food.getName() = " + food.getName());
// 음식을 주문한 고객 정보 조회
List<User> userList = food.getUserList();
for (User user : userList) {
System.out.println("user.getName() = " + user.getName());
}
}
@Test
@DisplayName("N대M 조회 : User 기준 food 정보 조회")
void test7() {
User user = userRepository.findById(1L).orElseThrow(NullPointerException::new);
// 고객 정보 조회
System.out.println("user.getName() = " + user.getName());
// 해당 고객이 주문한 음식 정보 조회
List<Food> foodList = user.getFoodList();
for (Food food : foodList) {
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
}
}
- 양방향 매핑이 완벽하게 되어 있다면, 자바 코드에서 .get()을 호출하는 것만으로 JPA가 알아서 숨겨진 중간 테이블(orders)을 거쳐(JOIN) 반대편 데이터를 가져옵니다.
- 앞서 편의 메서드를 통해 DB와 자바 객체 양쪽에 데이터를 완벽하게 세팅해 두었기 때문에, 어느 엔티티에서 출발하든 자유롭게 상대방의 데이터를 조회할 수 있습니다.
- 순수 DB(SQL)였다면 SELECT ... JOIN ... JOIN ... 처럼 복잡한 쿼리를 직접 짜야했지만, JPA를 쓰면 단순히 자바의 리스트에서 값을 꺼내는 것처럼(get()) 우아하게 처리할 수 있습니다.
중간 테이블

- 중간 테이블 orders를 직접 생성하여 관리하면 변경 발생 시 컨트롤하기 쉽기 때문에 확장성에 좋습니다.
- 복잡한 다대다 관계를 포기하고, 가운데에 Order(주문)라는 중간 다리 역할을 하는 엔티티를 직접 만듭니다.
- User (1) ↔️ Order (N)
- Food (1) ↔️ Order (N)
음식
@Entity
@Table(name = "food")
public class Food {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@OneToMany(mappedBy = "food")
private List<Order> orderList = new ArrayList<>();
}
고객
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Order> orderList = new ArrayList<>();
}
주문
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "food_id")
private Food food;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
- 왜 이렇게 할까? (확장성): 이제 Order는 정식 자바 클래스이므로, 여기에 언제든지 private int count; (주문 수량), private LocalDateTime orderDate; (주문 시간) 같은 필드를 내 맘대로 추가할 수 있습니다!
중간 테이블 테스트
package com.sparta.jpaadvance.relation;
import com.sparta.jpaadvance.repository.FoodRepository;
import com.sparta.jpaadvance.repository.OrderRepository;
import com.sparta.jpaadvance.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@Transactional
@SpringBootTest
public class OrderTest {
@Autowired
UserRepository userRepository;
@Autowired
FoodRepository foodRepository;
@Autowired
OrderRepository orderRepository;
}
@Test
@Rollback(value = false)
@DisplayName("중간 테이블 Order Entity 테스트")
void test1() {
User user = new User();
user.setName("Robbie");
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
// 주문 저장
Order order = new Order();
order.setUser(user); // 외래 키(연관 관계) 설정
order.setFood(food); // 외래 키(연관 관계) 설정
userRepository.save(user);
foodRepository.save(food);
orderRepository.save(order);
}
@Test
@DisplayName("중간 테이블 Order Entity 조회")
void test2() {
// 1번 주문 조회
Order order = orderRepository.findById(1L).orElseThrow(NullPointerException::new);
// order 객체를 사용하여 고객 정보 조회
User user = order.getUser();
System.out.println("user.getName() = " + user.getName());
// order 객체를 사용하여 음식 정보 조회
Food food = order.getFood();
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
}
- 중간 테이블 Order에 주문일 컬럼 추가
package com.sparta.jpaadvance.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@Table(name = "orders")
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "food_id")
private Food food;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
@CreatedDate
@Temporal(TemporalType.TIMESTAMP)
private LocalDateTime orderDate;
}
지연 로딩

- 음식 테이블과 고객 테이블이 N : 1 양방향 관계라 가정해 보겠습니다.
@Test
@DisplayName("아보카도 피자 조회")
void test1() {
Food food = foodRepository.findById(2L).orElseThrow(NullPointerException::new);
System.out.println("food.getName() = " + food.getName());
System.out.println("food.getPrice() = " + food.getPrice());
System.out.println("아보카도 피자를 주문한 회원 정보 조회");
System.out.println("food.getUser().getName() = " + food.getUser().getName());
}

- “아보카도 피자”의 가격을 조회하려고 했을 뿐인데 자동으로 JOIN 문을 사용하여 연관관계가 설정되어 있는 고객 테이블의 정보도 가져오고 있습니다.
- JPA는 연관관계가 설정된 Entity의 정보를 바로 가져올지, 필요할 때 가져올지 정할 수 있습니다.
- 즉, 가져오는 방법을 정하게 되는데 JPA에서는 Fetch Type이라 부릅니다.
- Fetch Type의 종류에는 2가지가 있는데 하나는 LAZY, 다른 하나는 EAGER 입니다.
- LAZY는 지연 로딩으로 필요한 시점에 정보를 가져옵니다.
- EAGER는 즉시 로딩으로 이름의 뜻처럼 조회할 때 연관된 모든 Entity의 정보를 즉시 가져옵니다.*
- 기본적으로 @OneToMany 애너테이션은 Fetch Type의 default 값이 LAZY로 지정되어 있고 반대로 @ManyToOne 애너테이션은 EAGER로 되어있습니다.*
- 다른 연관관계 애너테이션들도 default 값이 있는데 이를 구분하는 방법이 있습니다.
- 애너테이션 이름에서 뒤쪽에 Many가 붙어있으면 설정된 해당 필드가 Java 컬렉션 타입일 것입니다.
- 즉, 해당 Entity의 정보가 여러 개 들어있을 수 있다는 것을 의미합니다.
- 따라서 효율적으로 정보를 조회하기 위해 지연 로딩이 default로 설정되어 있습니다.
- 반대로 이름 뒤쪽이 One일 경우 해당 Entity 정보가 한 개만 들어오기 때문에 즉시 정보를 가져와도 무리가 없어 즉시 로딩이 default로 설정되어 있습니다.
- 애너테이션 이름에서 뒤쪽에 Many가 붙어있으면 설정된 해당 필드가 Java 컬렉션 타입일 것입니다.
@Test
@Transactional
@DisplayName("Robbie 고객 조회")
void test2() {
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
System.out.println("Robbie가 주문한 음식 이름 조회");
for (Food food : user.getFoodList()) {
System.out.println(food.getName());
}
}

- 이번에는 Robbie 고객을 조회한 후 Robbie 고객이 주문한 음식들의 이름을 조회했습니다.
- @OneToMany 즉, default가 지연 로딩으로 설정되어 있기 때문에 우선 고객을 조회한 후
- user.getFoodList() 호출 즉, 주문한 음식의 정보가 필요한 시점에 음식 테이블에 해당 고객 Entity의 식별자 값을 사용하여 Select SQL이 수행되었습니다.
영속성 컨텍스트와 지연 로딩
- JPA 기초 강의에서 영속성 컨텍스트의 기능 기억나시나요?
- 1차 캐시
- 쓰기 지연 저장소
- 변경 감지
- 지연 로딩도 마찬가지로 영속성 컨텍스트의 기능 중 하나입니다.
- 따라서 지연 로딩된 Entity의 정보를 조회하려고 할 때는 반드시 영속성 컨텍스트가 존재해야 합니다.
- ‘영속성 컨텍스트가 존재해야 한다’라는 의미는 결국 ‘트랜잭션이 적용되어있어야 한다’라는 의미와 동일합니다.
@Test
@DisplayName("Robbie 고객 조회 실패")
void test3() {
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
System.out.println("Robbie가 주문한 음식 이름 조회");
for (Food food : user.getFoodList()) {
System.out.println(food.getName());
}
}

- ‘Robbie 고객 조회 실패’ 테스트 코드를 확인해 보시면 @Transactional이 test3() 메서드에 설정되어있지 않습니다.
- 즉, 트랜잭션이 적용되지 않았기 때문에 지연 로딩된 음식 Entity 정보들을 user.getFoodList() 즉, 필요한 시점에 조회하려고 하자 오류가 발생했습니다.
- 따라서 지연 로딩된 정보를 조회하려고 할 때는 반드시 트랜잭션이 적용되어 영속성 컨텍스트가 존재하는지를 확인해야 합니다.
영속성 전이
CASCADE : PERSIST

- 음식 테이블과 고객 테이블이 N : 1 양방향 관계라 가정해 보겠습니다.
- 고객 ‘Robbie’가 후라이드 치킨과 양념 치킨을 주문해 보겠습니다.
@Test
@DisplayName("Robbie 음식 주문")
void test1() {
// 고객 Robbie 가 후라이드 치킨과 양념 치킨을 주문합니다.
User user = new User();
user.setName("Robbie");
// 후라이드 치킨 주문
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
user.addFoodList(food);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
user.addFoodList(food2);
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
}
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);// 외래 키(연관 관계) 설정
}
}
- 외래 키(연관 관계) 설정을 위해 addFoodList 메서드를 고객 Entity에 추가하겠습니다.
userRepository.save(user);
foodRepository.save(food);
foodRepository.save(food2);
- Robbie가 음식을 주문하기 위해서는 위처럼 user, food, food2 모두 직접 save() 메서드를 호출하면서 영속화해야 합니다.
- JPA에서는 이를 간편하게 처리할 수 있는 방법으로 영속성 전이(CASCADE)의 PERSIST 옵션을 제공합니다.
- 영속성 전이: 영속 상태의 Entity에서 수행되는 작업들이 연관된 Entity까지 전파되는 상황을 뜻합니다.
- 영속성 전이를 적용하여 해당 Entity를 저장할 때 연관된 Entity까지 자동으로 저장하기 위해서는 자동으로 저장하려고 하는 연관된 Entity에 추가한 연관관계 애너테이션에 CASCADE의 PERSIST 옵션을 설정하면됩니다.
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST)
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);// 외래 키(연관 관계) 설정
}
}
- 고객 Entity의 @OneToMany 애너테이션에 영속성 전이를 적용해서 음식 Entity도 자동으로 저장될 수 있도록 만듭니다.
@Test
@DisplayName("영속성 전이 저장")
void test2() {
// 고객 Robbie 가 후라이드 치킨과 양념 치킨을 주문합니다.
User user = new User();
user.setName("Robbie");
// 후라이드 치킨 주문
Food food = new Food();
food.setName("후라이드 치킨");
food.setPrice(15000);
user.addFoodList(food);
Food food2 = new Food();
food2.setName("양념 치킨");
food2.setPrice(20000);
user.addFoodList(food2);
userRepository.save(user);
}
- CASCADE 설정을 적용했기 때문에 직접 음식 Entity 객체 food, food2를 영속 상태로 만들지 않아도 자동으로 잘 저장되었습니다.
CASCADE : REMOVE
- 이번에는 연관된 Entity를 손쉽게 삭제하는 방법에 대해서 학습하겠습니다.
- Robbie가 주문 APP을 탈퇴하려고 합니다.
- 주문한 음식 정보들을 모두 삭제하려고 하는데 어떻게 하면 될까요?
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user")
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);// 외래 키(연관 관계) 설정
}
}
- cascade = CascadeType.PERSIST 제거
@Test
@Transactional
@Rollback(value = false)
@DisplayName("Robbie 탈퇴")
void test3() {
// 고객 Robbie 를 조회합니다.
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
// Robbie 가 주문한 음식 조회
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
// 주문한 음식 데이터 삭제
foodRepository.deleteAll(user.getFoodList());
// Robbie 탈퇴
userRepository.delete(user);
}
- 주문한 음식 데이터를 삭제하기 위해서 지연 로딩된 음식 Entity들을 가져와 직접 삭제해 줍니다.
- 그 후 Robbie 고객의 Entity를 삭제합니다.
- JPA에서는 이를 간편하게 처리할 수 있는 방법으로 영속성 전이(CASCADE)의 REMOVE 옵션을 제공합니다.
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);// 외래 키(연관 관계) 설정
}
}
- cascade = {CascadeType.*PERSIST*, CascadeType.REMOVE} 이렇게 중복으로 옵션을 설정할 수도 있습니다.
- 고객 Entity의 @OneToMany 애너테이션에 연관된 음식 Entity도 자동으로 삭제될 수 있도록 REMOVE 옵션을 추가합니다.
@Test
@Transactional
@Rollback(value = false)
@DisplayName("영속성 전이 삭제")
void test4() {
// 고객 Robbie 를 조회합니다.
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
// Robbie 가 주문한 음식 조회
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
// Robbie 탈퇴
userRepository.delete(user);
}
- Robbie 고객 Entity 객체를 조회한 후 해당 객체를 delete 하자 자동으로 연관된 음식 데이터들이 삭제되었습니다.
고아 Entity 삭제
orphanRemoval
- CASCADE의 REMOVE 옵션을 적용하면 해당 Entity 객체를 삭제했을 때 연관된 Entity 객체들을 자동으로 삭제할 수 있었습니다.
- 하지만 REMOVE 옵션 같은 경우 연관된 Entity와 관계를 제거했다고 해서 자동으로 해당 Entity가 삭제되지는 않습니다.
@Test
@Transactional
@Rollback(value = false)
@DisplayName("연관관계 제거")
void test1() {
// 고객 Robbie 를 조회합니다.
User user = userRepository.findByName("Robbie");
System.out.println("user.getName() = " + user.getName());
// 연관된 음식 Entity 제거 : 후라이드 치킨
Food chicken = null;
for (Food food : user.getFoodList()) {
if(food.getName().equals("후라이드 치킨")) {
chicken = food;
}
}
if(chicken != null) {
user.getFoodList().remove(chicken);
}
// 연관관계 제거 확인
for (Food food : user.getFoodList()) {
System.out.println("food.getName() = " + food.getName());
}
}
- 후라이드 치킨 Entity 객체와 연관관계를 제거했지만 Delete SQL이 수행되지 않는 것을 확인할 수 있습니다.
- JPA에서는 이를 간편하게 처리할 수 있는 방법으로 orphanRemoval 옵션을 제공합니다.
@Entity
@Getter
@Setter
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<Food> foodList = new ArrayList<>();
public void addFoodList(Food food) {
this.foodList.add(food);
food.setUser(this);// 외래 키(연관 관계) 설정
}
}
- orphanRemoval 옵션을 설정해 준 후 다시 한번 확인해 보겠습니다.
- Delete SQL이 수행되어 후라이드 치킨 데이터가 삭제된 것을 확인할 수 있습니다.
- 추가로 orphanRemoval 옵션도 REMOVE 옵션과 마찬가지로 해당 Entity 즉, Robbie Entity 객체를 삭제하면 연관된 음식 Entity들이 자동으로 삭제됩니다.
⚠️ 주의!
- orphanRemoval이나 REMOVE 옵션을 사용할 때 삭제하려고 하는 연관된 Entity를 다른 곳에서 참조하고 있는지 아닌지를 꼭 확인해야 합니다.
- A와 B에 참조되고 있던 C를 B를 삭제하면서 같이 삭제하게 되면 A는 참조하고 있던 C가 사라졌기 때문에 문제가 발생할 수 있습니다.
- 따라서 orphanRemoval 같은 경우 @ManyToOne 같은 애너테이션에서는 사용할 수 없습니다.
- ManyToOne이 설정된 Entity는 해당 Entity 객체를 참조하는 다른 Entity 객체들이 있을 수 있기 때문에 속성으로 orphanRemoval를 가지고 있지 않습니다.
'Back-End > Spring' 카테고리의 다른 글
| 프로젝트 관리 심화: 챕터 2 (CI/CD) (0) | 2026.05.14 |
|---|---|
| 프로젝트 관리 심화: 챕터 1 (Docker) (0) | 2026.05.04 |
| MSA (Microservice Architecture) (0) | 2026.04.14 |
| Spring 숙련: 챕터 1 (0) | 2026.04.07 |
| Spring 입문 (0) | 2026.04.06 |