내일배움캠프

[내일배움캠프 사전캠프] JPA 연관관계 매핑

munsik22 2026. 4. 1. 14:54
 

스파르타클럽 | AI시대, 미래를 돌파하는 힘

누구나 잠재력을 깨워 나아가도록. IT 커리어의 모든 성장 과정을 스파르타클럽에서

academia.spartaclub.kr

🧩 JPA 연관관계 매핑

더보기
  • 일대일 관계를 나타내는 매핑 정보
  • 1:1 관계로 구성한다는 것은 하나의 테이블에서 관리할 수 있는 데이터일 가능성이 높기 때문에, 물리적으로 테이블을 분리해야 하는지 생각해봐야 함.
// 일대일 단방향
@Entity
public class Member {
  @Id @GeneratedValue
  @Column(name = "MEMBER_ID")
  private Long id;

  private String username;

  @OneToOne
  @JoinColumn(name = "LOCKER_ID")
  private Locker locker;
}

@Entity
public class Locker {
  @Id @GeneratedValue
  @Column(name = "LOCKER_ID")
  private Long id;

  private String name;
}
// 일대일 양방향
@Entity
public class Member {
  @Id @GeneratedValue
  @Column(name = "MEMBER_ID")
  private Long id;

  private String username;

  @OneToOne
  @JoinColumn(name = "LOCKER_ID")
  private Locker locker;
}

@Entity
public class Locker {
  @Id @GeneratedValue
  @Column(name = "LOCKER_ID")
  private Long id;

  private String name;

  @OneToOne(mappedBy = "locker")
  private Member member;
더보기
  • 일대다 관계를 나타내는 매핑 정보
  • 속도를 위해 기본적으로 FetchTypeLAZY로 설정됨
  • 속성
    • mappedBy: 연관관계의 주인 필드를 선택
    • fetch: 글로벌 패치 전략 설정
    • cascase: 영속성 전이 기능 사용
    • targetEntity: 연관된 엔티티 타입 정보 설정
// 일대다 단방향 관계
@Entity(name = "parent")
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @OneToMany
		@JoinColumn(name = "parent_id")
    private List<Child> childList;
}

@Entity(name = "child")
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

		@Column(name = "parent_id")
		private Long parentId;
}
// 다대일 양방향 관계
@Entity(name = "parent")
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @OneToMany(mappedBy="parent")
    private List<Child> childList;
}

@Entity(name = "child")
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

		@ManyToOne
		@JoinColumn(name = "parent_id")
		private Parent parent;
}
더보기
  • 다대일 관계를 나타내는 매핑 정보
  • 속성
    • optional: 기본값 true (false로 설정하면 연관된 엔티티가 반드시 존재해야 함)
    • fetch: 글로벌 패치 전략 설정 (기본값이 EGEAR로 설정되어 있으나 실무에서는 LAZY를 권장한다고 함)
    • cascase: 영속성 전이 기능 사용
    • targetEntity: 연관된 엔티티 타입 정보 설정
@Entity(name = "parent")
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}

@Entity(name = "child")
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
}

@JoinColumn

  • 외래 키 매핑 시 사용 (Join을 요청하기 위한 매핑정보로 사용됨)
  • @ManyToOne 어노테이션과 주로 사용됨
  • 어노테이션을 생략해도 외래키가 생성됨. (외래키의 이름이 기본 전략에 따라 결정됨)
  • 속성
    • name: 매핑할 외래키의 이름
    • referencedColumnName: 외래키가 참조하는 대상 테이블의 컬럼명
    • foreignKey: 외래키 제약조건 지정 (테이블 생성 시에만 적용됨)
    • unique/nullable/insertable/updateable/columnDefinition/table: @Column의 속성과 같음
더보기
  • 다대다 관계를 나타내는 매핑 정보
  • 중간 매핑테이블(JointTable)이 자동으로 생성되며, 이는 JPA 상에서 숨겨져서 (Entity 없이) 관리됨
  • 매핑 테이블 관리가 불가능에서 실무에서는 잘 사용되지 않는다고 한다.
@Entity
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToMany(mappedBy = "parents")
    private List<Child> childs;
}

@Entity
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToMany
    @JoinTable(
        name = "parent_child",
        joinColumns = @JoinColumn(name = "parent_id"),
        inverseJoinColumns = @JoinColumn(name = "child_id")
    )
    private List<Parent> parents;
}
  • 실무에서는 매핑 테이블을 아래와 같은 형태(@ManyToOne)로 직접 정의한다고 한다.
@Entity
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @OneToMany(mappedBy = "parent")
    private List<ParentChild> parentChilds;
}

@Entity
public class ParentChild {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
		@JoinColumn("parent_id")
    private Parent parent;

    @ManyToOne
		@JoinColumn("child_id")
    private Child child;
}

@Entity
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @OneToMany(mappedBy = "child")
    private List<ParentChild> parentChilds;
}

@OneToMany vs @ManyToOne

  • @ManyToOne: 여러 개가 하나를 참조
  • @OneToMany: 하나가 여러 개를 참조

예를 들어, Team과 Member에 대해서

  • 여러 Member는 하나의 Team에 속해 있을 수 있으니 Member -> Team은 @ManyToOne
  • 하나의 Team에는 여러 Member가 있을 수 있으니 Team -> Member는 @OneToMany
@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

@Entity
public class Team {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

 

그림으로 표현하자면 아래와 같다.

@ManyToOne
Doran ──┐
Oner  ──┤
Faker ──┼──> T1
Peyz  ──┤
Keria ──┘

@OneToMany
       ┌── Doran
       ├── Oner
T1 ────┼── Faker
       ├── Peyz
       └── Keria

 

1:M 관계에 대해 DB에서는 보통 M쪽 테이블이 외래키를 가진다. 위 예시에서는 member 테이블에 team_id가 들어간다. 그래서 JPA에서도 보통 @ManyToOne 쪽이 외래키를 직접 관리한다.

  • @ManyToOne @JoinColumn(name = "team_id") private Team team;: 실제 연관관계를 저장한다.
  • @OneToMany(mappedBy = "team") private List<Member> members;: 이미 Member.team가 관리하고 있는 관계를 읽어온다.

보통 연관관계의 주인은 @ManyToOne 쪽이 된다. 보통 조회 방향이 "자식 -> 부모" 방향이 많기 때문에 실무에서는 @ManyToOne을 더 자주 사용한다고 한다.

📦 주문 생성 및 조회

 

feat: 주문 생성 및 조회 기능 구현 · qkrwns1478/spring-order@110a0fa

@@ -46,6 +46,15 @@ public Product update(@PathVariable Long id, @RequestBody Product product) {

github.com

주문 시 상품은 1개만 선택할 수 있다고 가정해보자. 이 때 주문은 상품을 알아야 하지만, 상품은 주문을 몰라도 된다. 따라서 일대일 단방향으로 구현을 하기로 했다.

  • orders 테이블 생성
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id BIGINT,
    CONSTRAINT fk_orders_product
        FOREIGN KEY (product_id) REFERENCES products(id),
    CONSTRAINT uq_orders_product_id
        UNIQUE (product_id)
);
  • Product 엔티티 수정
package com.example.springorder.Product;

import jakarta.persistence.*;
import lombok.*;

@Setter @Getter
@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    private Long id;

    private String name;
    private int price;
    private int quantity;

    public Product() {}

    @Builder
    public Product(Long id, String name, int price, int quantity) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

}
  • Order 엔티티 생성
package com.example.springorder.Order;

import com.example.springorder.Product.Product;
import jakarta.persistence.*;
import lombok.*;

@Setter @Getter
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    @JoinColumn(name = "product_id")
    private Product product;

    public Order() {}

    @Builder
    public Order(Product product) {
        this.product = product;
    }
}
  • OrderController 구현
package com.example.springorder.Order;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@RestController
@RequestMapping("/orders")
public class OrderController {
    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    /* 주문 등록 */
    @PostMapping
    public Order create(@RequestBody Order order) {
        return orderRepository.save(order);
    }

    /* 주문 전체 조회 */
    @GetMapping
    public List<Order> findAll() {
        return orderRepository.findAll();
    }

    /* 주문 단건 조회 */
    @GetMapping("/{id}")
    public Order findById(@PathVariable Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}

일단 시작부터 400 에러가 터졌다🤯

2026-04-01T15:47:48.091+09:00  WARN 5000 --- [spring-order] [nio-8080-exec-3] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of `com.example.springorder.Product.Product` (although at least one Creator exists): no int/Int-argument constructor/factory method to deserialize from Number value (1)]
2026-04-01T15:48:45.359+09:00  WARN 5000 --- [spring-order] [nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)]
2026-04-01T15:50:35.850+09:00  WARN 5000 --- [spring-order] [io-8080-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)]

로그를 잘 읽어보니 Cannot map `null` into type `int`라고 한다. POST 요청을 보낼 때 product의 필드에 id만 적었는데, 실제로 Productname, price, quantity 필드가 필요하다. 우리가 원하는 것은 productid만 포함해야 하기 때문에, Order를 바로 받지 않고 요청 전용 DTO를 만들어서 따로 받기로 했다.

  • OrderRequest 생성
package com.example.springorder.Order;

import lombok.Getter;
import lombok.Setter;

@Setter @Getter
public class OrderRequest {
    private Long productId;
}
  • OrderController 수정
/* 주문 등록 */
    @PostMapping
    public Order create(@RequestBody OrderRequest orderRequest) {
        Product product = productRepository.findById(orderRequest.getProductId())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        Order order = Order.builder().product(product).build();
        return orderRepository.save(order);
    }

이번에는 500 에러가 터졌다😇

[spring-order] [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.InvalidDataAccessResourceUsageException: Could not prepare statement [Column "P1_0.PRODUCT_ID" not found; SQL statement:
select p1_0.product_id,p1_0.name,p1_0.price,p1_0.quantity from products p1_0 where p1_0.product_id=? [42122-240]] [select p1_0.product_id,p1_0.name,p1_0.price,p1_0.quantity from products p1_0 where p1_0.product_id=?]; SQL [select p1_0.product_id,p1_0.name,p1_0.price,p1_0.quantity from products p1_0 where p1_0.product_id=?]] with root cause

org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "P1_0.PRODUCT_ID" not found; SQL statement:
select p1_0.product_id,p1_0.name,p1_0.price,p1_0.quantity from products p1_0 where p1_0.product_id=? [42122-240]

로그를 보니 코드 상의 컬럼명과 실제 DB의 컬럼명이 일치하지 않아 발생한 에러로 보인다. 

  • Product 엔티티 재수정
package com.example.springorder.Product;

import jakarta.persistence.*;
import lombok.*;

@Setter @Getter
@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    // @Column(name = "product_id") ← 삭제
    private Long id;

    private String name;
    private int price;
    private int quantity;

    public Product() {}

    @Builder
    public Product(Long id, String name, int price, int quantity) {
        this.id = id;
        this.name = name;
        this.price = price;
        this.quantity = quantity;
    }

}

@Column(name = "product_id")을 지우고 기본 키 컬럼을 id로 그대로 사용했다. orders 테이블에서 product_id 컬럼을 만들었기 때문에 products 테이블의 기본 키 이름도 product_id로 바꿔야 하는 줄 알았지만 그럴 필요가 없었던 것이다 😅

연관관계 확인하기

이미 생성된 주문에 대하여, 상품의 이름을 변경했을 때 해당 주문에서도 변경된 상품의 이름이 반영되는지 확인해보자.

  • OrderResponse 생성: 상품 이름만 보여주면 되기 때문에 응답 전용 DTO를 생성했다.
package com.example.springorder.Order;

import lombok.Getter;
import lombok.Setter;

@Setter @Getter
public class OrderResponse {
    private Long id;
    private String productName;

    public OrderResponse(Long id, String name) {
        this.id = id;
        this.productName = name;
    }
}
  • OrderController 수정
/* 주문 단건 조회 */
    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable Long id) {
         Order order = orderRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        return new OrderResponse(id, order.getProduct().getName());
    }

상품 이름 변경 전

  • ProductRequest 생성: 아무 생각 없이 changeName의 파라미터로 String name만 달랑 줬더니 상품 이름이 JSON 형식으로 바뀐 것을 보고 깜짝 놀라서 서둘러 요청 DTO를 만들었다😅
package com.example.springorder.Product;

import lombok.Getter;
import lombok.Setter;

@Setter @Getter
public class ProductRequest {
    private String name;
}
  • ProductController 수정
/* 상품 이름 변경 */
    @PutMapping("/{id}")
    public Product changeName(@PathVariable Long id, @RequestBody ProductRequest productRequest) {
        Product new_product = productRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        new_product.setName(productRequest.getName());
        return productRepository.save(new_product);
    }

이번에는 실행조차 실패하고 터져버렸다 🤯

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource [org/springframework/boot/webmvc/autoconfigure/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Ambiguous mapping. Cannot map 'productController' method 
com.example.springorder.Product.ProductController#changeName(Long, String)
to {PUT [/products/{id}]}: There is already 'productController' bean method
com.example.springorder.Product.ProductController#update(Long, Product) mapped.
...
Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'productController' method 
com.example.springorder.Product.ProductController#changeName(Long, String)
to {PUT [/products/{id}]}: There is already 'productController' bean method
...

로그를 보니 이미 @PutMapping("/{id}")이 사용 중인데 같은 어노테이션으로 다른 함수를 매핑해서 Duplicated 오류가 난 것으로 보인다.

  • ProductController 재수정: 매핑을 PATCH로 변경했다.
/* 상품 이름 변경 */
    @PatchMapping("/{id}")
    public Product changeName(@PathVariable Long id, @RequestBody ProductRequest productRequest) {
        Product new_product = productRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        new_product.setName(productRequest.getName());
        return productRepository.save(new_product);
    }

상품의 이름을 바꾸면?
주문 역시 자동으로 바뀐 이름을 가져온다👍

PUTPATCH의 차이

  • PUT: 서버에 있는 리소스를 완전 대체
  • PATCH: 서버에 있는 리소스를 수정하고 부분 대체

💭 느낀 점

솔직히 말하면 이전에 Spring 프로젝트를 진행할 때는 DTO를 아무 생각 없이 만들었다😅 그냥 GPT가 이렇게 하니까, Gemini가 저렇게 쓰니까 '아, DTO라는 걸 만들어야 하는 구나'라고만 생각했지, 정작 DTO가 왜 필요한지에 대해서는 진지하게 생각해 본 적이 없었다.

 

하지만 오늘 AI 도움 없이 혼자 힘으로만 구현하면서 여러 에러들을 마주하게 되면서 DTO가 왜 필요한지 체감할 수 있었다.

DTO는 API에서 필요한 데이터만 주고받기 위해 쓴다.
  • 엔티티를 그대로 받으면 필드가 많아서 JSON 오류가 나기 쉽다.
  • 요청에 꼭 필요한 값만 받도록 만들 수 있다. (예: 상품 전체 대신 productId만 받기)
  • 응답도 필요한 값만 깔끔하게 보낼 수 있다. (예: 주문 전체 대신 주문번호, 상품명만 보내기)

📋 내일 할 일

  • 테이블 객체로 페이지 조회하기 강의 수강
  • 주문 목록 페이지네이션 조회 기능 구현