Bootcamp/Fundamentals

Spring 숙련 - 2

개발자 오리 2026. 6. 25. 15:37

💡  오늘 학습 키워드

  • RestTemplate
  • Entity 연관관계
  • Lazy
  • CASCADE

 

🎯  학습 내용 정리

1. RestTemplate

지금까지의 웹 애플리케이션 개발은 주로 브라우저(Client)로부터 요청을 받아 처리하는 서버의 입장에서 진행해왔다. 하지만 실제 서비스 개발에서는 외부 서비스와 연동해야 하는 경우가 매우 많다. 예를 들어 회원가입 과정에서 사용자의 주소를 입력받아야 한다고 가정해보자. 주소 검색 기능을 직접 구현하는 것도 가능하지만, 이미 잘 구축된 카카오 주소 검색 API와 같은 외부 서비스를 활용하는 것이 훨씬 효율적이다.

 

이처럼 서버가 다른 서버에게 요청을 보내고 응답을 받아오는 구조Server To Server 통신이라고 한다. 이때 우리 서버는 더 이상 서버의 역할만 수행하는 것이 아니라 외부 서버에 요청을 보내는 클라이언트의 역할도 수행하게 된다.

Spring에서는 이러한 서버 간 통신을 쉽게 구현할 수 있도록 RestTemplate을 제공한다.

 

 

그렇다면 Server To Server 통신이 왜 필요할까?

일반적인 구조에서는 사용자가 브라우저를 통해 서버에 요청을 보낸다.

Client → Server

하지만 외부 API를 사용해야 하는 경우에는 서버가 다시 다른 서버로 요청을 보내야 한다.

Client → 우리 서버 → 외부 서버

예를 들어 카카오 주소 검색 API를 사용하는 경우 다음과 같은 흐름이 된다.

사용자 → 우리 서버 → 카카오 서버

즉, 우리 서버는 사용자의 요청을 처리하는 동시에 다른 서버의 클라이언트 역할도 수행하게 된다.

 

 

RestTemplateSpring에서 제공하는 HTTP 통신 도구이다.

HTTP 요청을 직접 생성하고 응답을 파싱하는 복잡한 과정을 대신 처리해주며, 객체와 JSON 간 변환도 자동으로 수행해준다.

대표적으로 다음과 같은 HTTP 요청을 지원한다.

  • GET 요청
  • POST 요청
  • PUT 요청
  • DELETE 요청
  • Header 포함 요청

또한 응답 데이터를 Java 객체로 자동 변환해주는 기능도 제공한다.

 

 

RestTemplate은 Spring Boot에서 RestTemplateBuilder를 통해 생성할 수 있다.

private final RestTemplate restTemplate;

public RestTemplateService(RestTemplateBuilder builder) {
    this.restTemplate = builder.build();
}

이후 생성된 RestTemplate 객체를 통해 외부 서버와 통신할 수 있다.

 

 

가장 기본적인 통신 방식은 GET 요청이다.

클라이언트 서버에서 검색어를 전달받아 서버 서버에 요청하는 예제를 살펴보자.

public ItemDto getCallObject(String query) {
    URI uri = UriComponentsBuilder
            .fromUriString("http://localhost:7070")
            .path("/api/server/get-call-obj")
            .queryParam("query", query)
            .encode()
            .build()
            .toUri();

    ResponseEntity<ItemDto> responseEntity =
            restTemplate.getForEntity(uri, ItemDto.class);

    return responseEntity.getBody();
}

여기서 중요한 점은 다음과 같다.

 

URI 생성

UriComponentsBuilder

를 사용하면 Query String을 포함한 URI를 쉽게 생성할 수 있다.

예를 들어 다음과 같은 URL이 생성된다.

http://localhost:7070/api/server/get-call-obj?query=Mac

 

getForEntity()

restTemplate.getForEntity(uri, ItemDto.class)

는 GET 요청을 전송하고 응답을 받아온다.

응답으로 받은 JSON 데이터는 자동으로 ItemDto 객체로 변환된다.

 

 

단일 객체가 아니라 여러 개의 데이터를 받아오는 경우도 존재한다.

서버에서는 다음과 같은 JSON 형태를 반환한다.

{
  "items": [
    {
      "title": "Mac",
      "price": 3888000
    },
    {
      "title": "iPad",
      "price": 1230000
    }
  ]
}

이 경우에는 JSON 배열을 처리해야 한다.

ResponseEntity<String> responseEntity =
        restTemplate.getForEntity(uri, String.class);

응답을 먼저 String으로 받은 뒤 JSON 라이브러리를 이용해 직접 파싱할 수 있다.

JSONObject jsonObject = new JSONObject(responseEntity);
JSONArray items = jsonObject.getJSONArray("items");

배열을 순회하면서 ItemDto 객체로 변환한다.

for (Object item : items) {
    ItemDto itemDto = new ItemDto((JSONObject) item);
    itemDtoList.add(itemDto);
}

이 과정을 통해 여러 개의 데이터를 Java 객체 리스트로 변환할 수 있다.

 

 

GET 요청과 달리 POST 요청은 HTTP Body에 데이터를 담아 전송할 수 있다.

public ItemDto postCall(String query) {
    URI uri = UriComponentsBuilder
            .fromUriString("http://localhost:7070")
            .path("/api/server/post-call/{query}")
            .encode()
            .build()
            .expand(query)
            .toUri();

    User user = new User("Robbie", "1234");

    ResponseEntity<ItemDto> responseEntity =
            restTemplate.postForEntity(
                    uri,
                    user,
                    ItemDto.class
            );

    return responseEntity.getBody();
}

 

Path Variable 사용

.expand(query)

를 사용하면 URL의 {query} 부분에 동적으로 값을 주입할 수 있다.

 

예를 들어

.post-call/{query}

.post-call/Mac

으로 변환된다.

 

Body 자동 변환

User user = new User("Robbie", "1234");

객체를 전달하면 RestTemplate이 자동으로 JSON으로 변환하여 요청 Body에 담아준다.

{
  "username": "Robbie",
  "password": "1234"
}

 

 

실제 서비스에서는 인증 토큰이나 추가 정보를 Header에 담아 전송하는 경우가 많다.

이때는 exchange() 메서드를 사용한다.

public List<ItemDto> exchangeCall(String token) {

    URI uri = UriComponentsBuilder
            .fromUriString("http://localhost:7070")
            .path("/api/server/exchange-call")
            .build()
            .toUri();

    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());
}

 

RequestEntity는 다음 정보를 한 번에 담을 수 있다.

  • URI
  • Header
  • Body
  • HTTP Method
RequestEntity
        .post(uri)
        .header("X-Authorization", token)
        .body(user);

이를 통해 인증 토큰과 요청 데이터를 함께 전달할 수 있다.

 

 

RestTemplate의 주요 메서드는 다음과 같다.

메서드 설명
getForEntity() GET 요청
postForEntity() POST 요청
put() PUT 요청
delete() DELETE 요청
exchange() Header, Body 등을 포함한 다양한 요청 처리

실무에서는 Header 설정, 인증 토큰 전달, 다양한 HTTP Method 사용이 필요하기 때문에 exchange()가 자주 활용된다.

 

 

2. Entity 연관관계

JPA를 처음 학습할 때 가장 어렵게 느껴지는 부분 중 하나가 연관관계 매핑이다. 데이터베이스에서는 외래 키(FK)를 이용해 테이블 간 관계를 표현하지만, 객체지향에서는 객체 참조를 통해 관계를 표현한다.

 

고객(User)과 음식(Food) 그리고 주문(Order)을 사용하여 연간관계의 간단한 예시를 들어보도록 하겠다.

 

고객 테이블

create table users
(
    id bigint not null auto_increment,
    name varchar(255),
    primary key (id)
);

 

id name
1 Tom
2 Jerry

 

 

음식 테이블

create table food
(
    id bigint not null auto_increment,
    name varchar(255),
    price float(53) not null,
    primary key (id)
);

 

id name price
1 후라이드 치킨 15000
2 양념 치킨 20000
3 고구마 피자 30000
4 페퍼로니 피자 50000

 

 

고객과 음식의 관계를 생각해보자.

  • 고객 한 명은 여러 음식을 주문할 수 있다.
  • 음식 하나는 여러 고객이 주문할 수 있다.

즉,

고객 : 음식 = N : M

관계가 된다.

 

 

고객 테이블에 음식 정보를 저장하는 경우

ALTER TABLE users ADD food_id bigint;
id name food_id
1 Tom 1
2 Jerry 1
3 Tom 2

Tom이 여러 번 저장되면서 고객 정보가 중복된다.

 

 

음식 테이블에 고객 정보를 저장하는 경우

ALTER TABLE food ADD user_id bigint;

 

id name price user_id
1 후라이드 치킨 15000 1
2 후라이드 치킨 15000 2
3 양념 치킨 20000 1

이번에는 음식 정보가 중복된다.

 

 

실제 서비스에서는 이를 해결하기 위해 중간 테이블을 만들어 해결한다.

create table orders
(
    id bigint not null auto_increment,
    user_id bigint,
    food_id bigint,
    order_date date,
    primary key (id)
);

 

 

결국 최종 구조는 다음과 같다.

users

id name
1 Tom
2 Jerry

 

food

id name price
1 후라이드 치킨 15000
2 양념 치킨 20000
3 고구마 피자 30000
4 페퍼로니 피자 50000

 

orders

id user_id food_id order_date
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

관계로 분해할 수 있다.

 

 

DB와 Entity의 가장 큰 차이방향의 유무이다.

다음 SQL은 users 기준으로 조회한다.

SELECT u.name, f.name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN food f ON o.food_id = f.id;

 

반대로 food 기준으로도 조회 가능하다.

SELECT u.name, f.name
FROM food f
JOIN orders o ON f.id = o.food_id
JOIN users u ON o.user_id = u.id;

DB는 JOIN만 가능하다면 어느 방향에서든 조회가 가능하다.

즉, DB 테이블에는 방향 개념이 없다.

 

 

아래 코드를 살펴보자.

public class Food {

    @ManyToOne
    private User user;
}

Food는 User를 참조할 수 있다.

 

하지만 User가 Food를 참조하지 않으면

public class User {
    private Long id;
}

User에서는 Food 정보를 조회할 수 없다.

즉, Entity에는 방향 개념이 존재한다.

 

 

연관관계의 주인은 JPA에서 가장 중요한 개념이다.

연관관계의 주인만이 외래 키를 관리할 수 있다.

 

즉,

  • INSERT
  • UPDATE
  • DELETE

를 수행할 수 있다.

 

반대편 Entity는 조회만 가능하다. 외래 키를 관리하는 Entity연관관계의 주인이라고 한다.

 

 

@OneToOne은 1:1 관계를 표현한다.

단방향의 경우를 살펴보자.

 

Food가 주인인 경우

@Entity
public class Food {

    @OneToOne
    @JoinColumn(name = "user_id")
    private User user;
}

 

User가 주인인 경우

@Entity
public class User {

    @OneToOne
    @JoinColumn(name = "food_id")
    private Food food;
}

1:1 관계는 어느 쪽이든 외래 키 주인이 될 수 있다.

 

양방향의 경우를 살펴보자.

Food가 주인인 경우

@Entity
public class Food {

    @OneToOne
    @JoinColumn(name = "user_id")
    private User user;
}
@Entity
public class User {

    @OneToOne(mappedBy = "user")
    private Food food;
}

 

User가 주인인 경우

@Entity
public class User {

    @OneToOne
    @JoinColumn(name = "food_id")
    private Food food;
}
@Entity
public class Food {

    @OneToOne(mappedBy = "food")
    private User user;
}

 

 

 

@ManyToOneN:1 관계를 표현한다.

가장 많이 사용되는 연관관계이다.

 

단방향의 경우를 살펴보자.

음식 여러 개가 한 명의 고객을 참조한다.

@Entity
public class Food {

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}
@Entity
public class User {

    private Long id;
    private String name;
}

 

양방향의 경우를 살펴보자.

@Entity
public class Food {

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}
@Entity
public class User {

    @OneToMany(mappedBy = "user")
    private List<Food> foodList = new ArrayList<>();
}

 

 

 

양방향 관계에서는 객체 상태를 양쪽 모두 유지해야 한다. 이를 연관관계 편의 메서드라고 한다.

public void addFoodList(Food food) {
    this.foodList.add(food);
    food.setUser(this);
}

 

 

 

다만 저장을 할 때에도 주의가 필요하다.

잘못된 저장 방식

user.getFoodList().add(food);

위 코드는 User 컬렉션만 변경한다.

실제 외래 키는 Food가 관리하기 때문에 user_id가 저장되지 않는다.

 

올바른 저장 방식

user.addFoodList(food);

또는

food.setUser(user);

반드시 연관관계의 주인(Food)의 값이 변경되어야 한다.

 

 

조회하는 방법에 대해서 알아보자.

Food → User 조회

Food food = foodRepository.findById(1L).orElseThrow();

System.out.println(food.getUser().getName());

 

User → Food 조회

User user = userRepository.findById(1L).orElseThrow();

for (Food food : user.getFoodList()) {
    System.out.println(food.getName());
}

 

 

 

@OneToMany1:N 관계를 표현한다.

단방향의 경우를 살펴보자.

@Entity
public class Food {

    @OneToMany
    @JoinColumn(name = "food_id")
    private List<User> userList = new ArrayList<>();
}
@Entity
public class User {
}

 

 

실제 외래 키는 N쪽 테이블(users)에 존재한다.

하지만 JPA는 Food 엔티티를 통해 외래 키를 관리한다.

그래서 저장 시

INSERT
+
UPDATE

가 추가로 발생한다.

이 때문에 실무에서는 단방향 OneToMany를 잘 사용하지 않는다.

 

양방향의 경우를 살펴보자.

JPA는 OneToMany 양방향을 직접 지원하지 않는다.

대신 아래처럼 ManyToOne을 추가하여 구현한다.

@ManyToOne
@JoinColumn(
    name = "food_id",
    insertable = false,
    updatable = false
)
private Food food;

실무에서는 사실상

OneToMany + ManyToOne

조합을 사용한다.

 

 

@ManyToMany는 N:M 관계를 표현한다.

단방향의 경우를 살펴보자.

@Entity
public class Food {

    @ManyToMany
    @JoinTable(
        name = "orders",
        joinColumns = @JoinColumn(name = "food_id"),
        inverseJoinColumns = @JoinColumn(name = "user_id")
    )
    private List<User> userList = new ArrayList<>();
}

JPA가 자동으로 중간 테이블을 생성한다.

 

양방향의 경우를 살펴보자.

@Entity
public class Food {

    @ManyToMany
    @JoinTable(
        name = "orders",
        joinColumns = @JoinColumn(name = "food_id"),
        inverseJoinColumns = @JoinColumn(name = "user_id")
    )
    private List<User> userList = new ArrayList<>();
}
@Entity
public class User {

    @ManyToMany(mappedBy = "userList")
    private List<Food> foodList = new ArrayList<>();
}

 

 

ManyToMany는 실무에서 잘 사용하지 않는데, 그 이유는 중간 테이블을 직접 제어할 수 없기 때문이다.

예를 들어 주문 테이블에

주문일
수량
가격
주문상태

같은 컬럼이 추가되는 순간 사용이 어려워진다.

 

실무에서는 대부분 다음과 같이 사용한다.

User
  ↕
Order
  ↕
Food

즉, ManyToMany 대신 중간 엔티티(Order)를 생성한다.

 

JPA 연관관계를 이해할 때는 "외래 키는 누가 관리하는가?""어느 객체가 누구를 참조하는가?"라는 두 가지 질문을 항상 먼저 생각하는 습관이 중요하다.

 

 

3. Lazy

Lazy가 무엇인지 알기 위해선 FetchType에 대해서 먼저 알아야 한다.

 

JPA에서 엔티티 간 연관관계를 설정하면 연관된 엔티티를 언제 조회할지 결정해야 한다.

예를 들어 Food와 User가 N:1 관계라고 가정해보자.

@Entity
public class Food {

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}
@Entity
public class User {

    @OneToMany(mappedBy = "user")
    private List<Food> foodList = new ArrayList<>();
}

음식 하나를 조회할 때 주문한 고객 정보까지 함께 가져올 수도 있고, 필요할 때만 가져올 수도 있다.

JPA는 이를 FetchType이라는 개념으로 관리한다.

 

 

FetchType의 종류는 다음과 같다.

 

1. EAGER (즉시 로딩)

조회 시점에 연관된 엔티티까지 즉시 조회한다.

Food food = foodRepository.findById(2L)
        .orElseThrow();

System.out.println(food.getName());

기본적으로 @ManyToOne은 FetchType.EAGER가 적용된다.

따라서 단순히 음식을 조회했더라도 JPA는 연관된 사용자 정보까지 함께 조회하기 위해 JOIN 쿼리를 수행한다.

select ...
from food f
left outer join users u
on f.user_id = u.id
where f.id = ?

즉, 음식 정보만 필요해도 사용자 정보가 함께 조회된다.

 

EAGER의 특징은 다음과 같다.

  • 연관 엔티티를 즉시 조회
  • 추가 조회가 발생하지 않음
  • 사용하지 않는 데이터까지 함께 조회될 수 있음
  • 불필요한 JOIN 발생 가능

 

2. LAZY (지연 로딩)

실제로 연관 엔티티가 필요한 순간까지 조회를 미룬다.

예를 들어 사용자를 조회한다고 가정해보자.

User user = userRepository.findByName("Tom");

System.out.println(user.getName());

이 시점에는 사용자 정보만 조회된다.

select *
from users
where name = 'Tom'

이후 연관된 음식 목록이 필요한 순간

user.getFoodList();

JPA가 추가 쿼리를 실행한다.

select *
from food
where user_id = ?

즉, 실제로 필요한 시점에 데이터를 조회한다.

 

LAZY의 특징은 다음과 같다.

  • 필요한 시점에 조회
  • 초기 조회 성능이 좋음
  • 불필요한 데이터 조회 방지
  • 추가 쿼리가 발생할 수 있음

 

JPA는 연관관계에 따라 기본 FetchType을 다르게 설정한다.

연관관계 기본 FetchType
@ManyToOne EAGER
@OneToOne EAGER
@OneToMany LAZY
@ManyToMany LAZY

이를 구분하는 쉬운 방법이 있다.

 

뒤에 One이 붙는 경우

@ManyToOne
@OneToOne

연관 엔티티가 하나만 존재한다.

따라서 즉시 조회해도 부담이 적어 기본값이 EAGER이다.

 

뒤에 Many가 붙는 경우

@OneToMany
@ManyToMany

컬렉션 형태로 여러 데이터를 보관한다.

List<Food> foodList

수십 개, 수백 개의 데이터가 조회될 수 있기 때문에 기본값이 LAZY이다.

 

 

지연 로딩영속성 컨텍스트의 기능 중 하나이다.

따라서 LAZY로 설정된 데이터를 조회하려면 반드시 영속성 컨텍스트가 살아있어야 한다.

즉, 트랜잭션이 유지되고 있어야 한다.

 

정상 동작 예시

@Test
@Transactional
void test() {

    User user = userRepository.findByName("Tom");

    for (Food food : user.getFoodList()) {
        System.out.println(food.getName());
    }
}

@Transactional이 적용되어 있기 때문에 지연 로딩이 정상 동작한다.

 

오류 발생 예시

@Test
void test() {

    User user = userRepository.findByName("Tom");

    for (Food food : user.getFoodList()) {
        System.out.println(food.getName());
    }
}

트랜잭션이 종료된 상태에서 지연 로딩을 시도하면 예외가 발생한다.

대표적으로 다음과 같은 예외가 발생한다.

LazyInitializationException

이는 영속성 컨텍스트가 이미 종료되었기 때문이다.

 

 

4. CASCADE

CASCADE연관된 엔티티에 대한 작업을 자동으로 전파하는 기능이다.

예를 들어 고객이 음식을 주문하는 상황을 생각해보자.

User user = new User();

Food food1 = new Food();
Food food2 = new Food();

user.addFoodList(food1);
user.addFoodList(food2);

 

 

CASCADE 없이 저장할 경우

userRepository.save(user);

foodRepository.save(food1);
foodRepository.save(food2);

모든 엔티티를 직접 저장해야 한다.

연관 엔티티가 많아질수록 코드가 복잡해진다.

 

 

CASCADE PERSIST는 부모 엔티티 저장 시 자식 엔티티도 함께 저장하는 것이다.

@OneToMany(
    mappedBy = "user",
    cascade = CascadeType.PERSIST
)
private List<Food> foodList = new ArrayList<>();

 

저장 예시

User user = new User();

Food food1 = new Food();
Food food2 = new Food();

user.addFoodList(food1);
user.addFoodList(food2);

userRepository.save(user);

save(user)만 호출해도

  • User 저장
  • Food 저장
  • 연관관계 저장

이 모두 자동으로 수행된다.

 

 

영속성 전이(PERSIST)의 의미를 조금 더 자세하게 알아보자.

부모 엔티티가 영속 상태가 될 때

persist(user)

연관된 자식 엔티티에도 동일한 작업이 전파된다.

persist(food1)
persist(food2)

가 자동으로 수행되는 것이다.

 

 

CASCADE REMOVE부모 엔티티 삭제 시 자식 엔티티도 함께 삭제하는 것이다.

 

CASCADE 없이 삭제할 경우

foodRepository.deleteAll(user.getFoodList());

userRepository.delete(user);

먼저 자식 엔티티를 삭제하고

그 후 부모 엔티티를 삭제해야 한다.

 

CASCADE REMOVE 적용

@OneToMany(
    mappedBy = "user",
    cascade = {
        CascadeType.PERSIST,
        CascadeType.REMOVE
    }
)
private List<Food> foodList = new ArrayList<>();

 

 

 

삭제 예시

User user = userRepository.findByName("Robbie");

userRepository.delete(user);

이 한 줄만 실행해도

  1. Food 삭제
  2. User 삭제

순서로 자동 처리된다.

 

 

CASCADE 사용 시 주의사항이 있다.

CASCADE는 매우 편리하지만 무분별하게 사용하면 위험하다.

예를 들어 부모를 삭제했는데 의도하지 않게 연관된 자식 데이터가 모두 삭제될 수 있다.

 

따라서 다음과 같은 경우에 사용하는 것이 좋다.

  • 부모가 자식을 완전히 관리하는 경우
  • 자식이 부모 없이 존재할 수 없는 경우

예시

  • 주문(Order) - 주문상품(OrderItem)
  • 게시글(Post) - 댓글(Comment)

 

이러한 경우에는 사용에 주의가 필요하다.

  • 독립적으로 존재 가능한 엔티티

예시

  • 회원(User)
  • 상품(Product)

이러한 경우에는 삭제 전파가 예상치 못한 데이터 손실을 만들 수 있다.

 

 

📚  한줄 정리

  1. RestTemplate은 Spring에서 서버가 다른 서버와 HTTP 통신을 수행할 수 있도록 지원하는 도구로, 외부 API 연동을 쉽고 효율적으로 구현할 수 있게 해준다.
  2. Entity 연관관계 매핑은 데이터베이스의 외래 키 관계를 객체 참조 방식으로 표현하는 기술이다.
  3. LAZY는 JPA FetchType 중 하나로 필요한 시점에 로딩이 되며 반드시 트랜잭션이 유지되어야 한다.
  4. CASCADE는 부모 Entity의 작업을 자식 Entity로 전파하는 기능으로 부모가 자식을 완전히 관리하는 경우에만 신중하게 적용해야 한다.

'Bootcamp > Fundamentals' 카테고리의 다른 글

MSA 요약  (0) 2026.06.29
Spring 심화  (0) 2026.06.26
Spring 숙련 - 1  (0) 2026.06.24
Spring 입문 - 2  (0) 2026.06.23
Spring 입문 - 1  (0) 2026.06.22