📅 연관관계와 N+1 문제 💥

2025. 5. 5. 21:18·[내일배움캠프-Sparta]/Spring 6기
반응형

1️⃣ 연관관계 매핑은 왜 필요한가?

JPA 에서 연관관계 매핑은 영속성 컨텍스트와 함께 가장 중요한 두 가지 개념에 속한다.

 

RDB와 객체지행 프로그래밍의 패러다임 차이로 인해 연관관계에 대한 매핑작업를 개발자가 직접해야했고, ORM(JPA/Hibernate)가 이 부분을 대신 해줌.

Team과 Member는 1:N 관계로 하나의 팀에 여러 가입자가 존재할 수 있다. 

우리는 위 테이블에 저장된 데이터를 조회해서 사용하려면 어떻게 해야할까?

1. SQL 쿼리를 통해 필요한 데이터를 조회

SELECT * FROM team WHERE team_id = 1;
SELECT * FROM member WHERE team_id = 1;

2. 조회된 데이터를 객체로 생성해 우리가 쓰기 편한 상태로 만든다.

  • Team ➡ Member 로 참조하는 객체
public class Team {
   private Long id;
   private String name;
   private String logoImageUrl;
   private List<Member> members; // Team -> Member 참조
   private LocalDateTime createdAt;
   private LocalDateTime updatedAt;
}
  • Member ➡ Team 로 참조하는 객체
public class Member {
  private Long id;
  private String name;
  private int age;
  private Team team; // Member -> Team 참조
  private LocalDateTime createdAt;
}

public class Member {
  private Long id;
  private String name;
  private int age;
  private String teamName;
  private LocalDateTime createdAt;
}

이 과정은 매우 번거롭다!!

그래서 ORM(JPA/Hibername) 가 “연관관계 매핑” 이라는 것을 통해 이 부분을 자동으로 해준다!!

연관관계 매핑” 이라는 작업이 필요하게 되었을까?

  • RDB 테이블에는 연관관계에 있는 두 테이블간의 참조라는 개념이 없기 때문
SELECT * FROM team JOIN member ON team.team_id = member.team_id;
SELECT * FROM member JOIN team ON team.team_id = member.team_id;
  • RDB에서 연관관계에 있는 두 테이블을 JOIN 해 한번에 조회할 수 있고, 여기에는 방향 개념이 없음.
  • 하지만, 우리가 사용하는 OOP에서는 "의존성", "참조" 라는 방향이라는 개념이 존재
  • 이러한 패러다임의 불일치를 해결하기 위해  "연관관계 매핑"이 필요한 것임.

✅  Team → Member 방향으로의 참조를 만들지 Member → Team 방향으로의 참조를 만들지는 내가 해당 객체를 어떻게 사용하는지에 따라 달라질 수 있다. (방향이 정해진건 없다!)

2️⃣ 단방향 연관관계

연관관계에 있는 두 객체사이에 한 방향으로의 참조가 존재하는 상태를 말한다.

  •  Team → Member
  •  Member → Team

Team 입장에서는 Member 와 1:N 연관관계를 가지지만, Member 입장에서는 Team 과 N:1 연관관계이고 어떤 방향이든 두 객체 사이의 한 방향의 참조만 존재한다면 모두 단방향 연관관계

✅ @OneToOne

  • 두 테이블/객체가 1:1 연관관계일 때 사용함
  • 1:1 연관관계에 있는 객체간 참조 방향을 어떻게할지 잘 결정해야함.
  • 정말 물리적으로 분리되어야하는 테이블인지 고민 해봐야함. ( 데이터 생명주기 혹은 사용패턴을 토대로 고민 )

✅ @ManyToOne

@Entity
public class Team {
	
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "team_id")
  private Long id;
  
  private String name;	
  private LocalDateTime createdAt;
  private LocalDateTime updatedAt;
}

@Entity
public class Member {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "member_id")
  private Long id;

  private String name;
  private int age;

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

  private LocalDateTime createdAt;
}

우리가 @ManyToOne 을 기본으로 사용하고 있을까?

  1. 테이블상의 외래키 위치와 객체상의 참조 위치가 동일해 헷갈리지 않는다.
  2. 별다른 설정 없이도 우리가 생각한대로 SQL 쿼리가 잘 동작한다. (이 부분은 @OneToMany 쪽에 있는 문제를 봄으로써 어떤 문제가 생길수 있는지 확인하자!)

✅ @OneToMany

@Entity
public class Team {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "team_id")
  private Long id;

  private String name;

  @OneToMany
  @JoinColumn(name = "team_id")
  private List<Member> members = new ArrayList<>();

  private LocalDateTime createdAt;
  private LocalDateTime updatedAt;
}

@Entity
public class Member {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "member_id")
  private Long id;
 
  private String name;
  private int age;
  private LocalDateTime createdAt;
}

@OneToMany 의 사용을 지양하라는 포스팅을 많이 봤을 것이다. 

  1. RDB의 외래키의 위치랑 객체 참조의 위치가 달라져 헷갈릴 수 있음.
  2. 1번 문제로 인해 INSERT 쿼리 요청시 불필요한 UPDATE 쿼리가 발생함.
  3. 단방향 연관관계로 사용할 때  @JoinColumn 을 반드시 사용하며, 사용하지 않을때 불필요한 연결 테이블이 생성될수 있음.
  4. 연관관계에 있는 Entity(Member)를  save() 해주지 않으면 제대로 저장이 안됨. 
    • 이런 경우 반드시 cascade 옵션중 PERSIST를 사용해야함.
  • 단방향 연관관계의 방향으로 @OneToMany 를 사용할때 주의할 것들이 존재할 뿐 명확한 단점이 있는것은 아님.
  • @OneToMany 연관관계는 사용하면 안돼! 이런 포인트에서의 주의해야 할 부분이 있으니 Trade-Off 하자!로 접근
    1. 도메인을 정의할 때 더 자연스러운 방향으로 설계하는 비즈니스 로직을 개발하는 과정에서 더 자연스럽고 더 잘 읽히는 코드를 만들 수 있다.
    2. 항상 같이 조회되는 상황에서는 @OneToMany로 조회 할때 코드가 자연스러워 진다.
    3. Post ㅡ Hashtag처럼  항상 Post ➡ Hashtag 로만 참조하는 조회패턴을 가진다면 @OneToMany를 적극 고려

💡 사연이 있는 코드를 만들기위해 노력하자!

취업후 개발은 피드백, 설득, 논쟁의 연속임. 이런 상황에 사연이 없는 코드로는 다른 사람에게 충분한 설득력을 제공할 수 없음을 명심!  뿐만 아니라 내 역량이 부족함을 드러내는 상황을 마주할 수 있고 더 나아가 팀원에게 신뢰를 잃게 될수도 있다.

3️⃣ 양방향 연관관계

@Entity
public class Team {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "team_id")
  private Long id;

  private String name;

  @OneToMany(mappedBy = "team") // 연관관계의 주인 명시!⭐
  private List<Member> members = new ArrayList<>();

  private LocalDateTime createdAt;
  private LocalDateTime updatedAt;
}

@Entity
public class Member {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "member_id")
  private Long id;

  private String name;
  private int age;

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

  private LocalDateTime createdAt;
}

✅ mappedBy

양방향 매핑할 때 반드시 한 쪽의 객체에는 mappedBy를 옵션을  설정해야 한다.

  • 양방향 매핑을 적용한다면 Member 객체뿐만 아니라 Team 객체도 members 필드를 통해 Member 테이블에 접근이 가능해지기 때문에 혼란이 생길수 있다.
  • 만약 Member 객체와 Team 객체가 서로 Member 테이블의 데이터 값을 변경하려 한다면 무결성을 해칠 가능성도 생길 것
  • 그래서, 두 객체 중 하나의 객체만 테이블을 관리 하도록 정하는 것이 MappedBy 옵션임
  • Team 객체에 mappedBy가 적용 되어 있는데, Team 객체는 Member 테이블을 관리 할수 없고, Member 객체만이 권한을 받고 주인이 아닌 쪽은 읽기(조회)만 가능 
  • mappedBy가 정의되지 않은 객체가 주인(Owner)이 되는 것
  • 일반적으로 외래키를 가진 객체를 주인으로 정의하는 것이 좋음.

객체 사이에는 "양방향" 이라는 개념이 ❌

  • Team → Member , Member → Team 두 단방향 연관관계가 존재하는 상황을 양방향 연관관계라고 한다!
더보기

 💡 하나의 단방향 연관관계만으로도 이미 연관관계 매핑은 완성됐다. 또다른 단방향 연관관계를 추가함으로써 양방향 연관관계를 만드는 것은 편의를 위해 반대 방향의 조회 기능이 추가된 것일 뿐이다.

✅ 지양해야하는 이유

  1. 불필요한 연관 관계로 인해 설계의 복잡도가 증가
  2. 양방향 의존성은 순환참조 및 데이터 동기화 등에서 문제가 생길 가능서이 존재
  3. 객체 사이의 과도한 결합으로 서로 변경을 전파하게 되어 변경에 유연하지 못한 구조가 됨 ( "OOP의 의존성" )
  4. 객체의 독립성이 저하되어 테스트 코드를 작성하기 어렵다.

✅ 단점만 있는 기술은 없다!

어떠한 상황에서 고려해볼수 있을까?

  1. 이미 @ManyToOne 연관관계를 사용하고 있는 상황에서 @OneToMany 방향으로 조회(객체 참조) 패턴이 많이 필요한 상황➡ 단순 조회 목적 
  2. @OneToMany 단방향 연관관계를 매핑을 사용할 때 발생하는 "INSERT 쿼리시 연관관계를 처리를 위한 UPDATE 쿼리가 함께 발생하는 문제"를 해소하기 위한 상황 

양방향 연관관계는 "필요"에 의해 추가되어야하는 것을 꼭 기억하기! ⭐

우선 하나의 단방향 연관관계로 시작!! 이후 반대 방향의 객체 참조가 필요시 양방향 연관관계를 추가해도 테이블에 영향을 주지 않기 때문에 충분이 유연함.

4️⃣ N+1 문제

@Entity
public class Todo {
	
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "todo_id")
  private Long id;
  
  private String contents;
  private LocalDate date = LocalDate.now();
  private boolean isComplete = false;
  
  @OneToMany(mappedBy = "todo", fetch = FetchType.EAGER)
  public List<Comment> comments = new ArrayList<>();
}

@Entity
public class Comment {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "comment_id")
  private Long id;
  
  @ManyToOne(fetch = FetchType.EAGER)
  @JoinColumn(name = "todo_id")
  private Todo todo;
  private String contents;
}
  • Todo 와 Comment 는 1:N 연관관계를 가지는 테이블이다.
  • Todo 와 Comment 객체는 양방향 연관관계 매핑을 맺고 있다.
    • 양방향 연관관계를 사용한 이유는 @OneToMany, @ManyToOne 에 대한 N+1 문제를 하나의 예제코드에서 모두 살펴보기 위함
  • 두 연관관계 매핑에서 모두 FetchType.EAGER 를 적용했다.

✅ N+1 문제란?

N+1 문제는 JPA를 이용해 연관관계에 있는 두 테이블의 데이터를 조회할 때 JOIN을 이용한 SELECT 쿼리 1개가 발생할거라 기대했지만, JOIN을 이용하지 않은 1개의 SELECT 쿼리 + 연관관계에 있는 데이터를 조회하기 위한 N개의 SELECT 쿼리로 총 N+1개의 SELECT 쿼리가 발생하는 문제를 말한다.

  • 예를 들어 모든 Comment 를 조회하는 상황에서 발생하는 N+1 문제를 살펴보자.
    • 내가 의도한 SELECT 쿼리 :
      • SELECT * FROM comment c JOIN todo t ON c.todo_id = t.todo_id
    • 하지만 JPA 는 내 의도와 다르게 Comment 를 먼저 조회하고, 조회된 N개의 Comment 에 대한 Todo 를 별도의 N번의 쿼리로 조회해 N+1 문제를 발생시킨다.
      • JOIN 을 사용하지 않는 1개의 쿼리 : SELECT * FROM comment
      • 연관관계에 있는 데이터를 조회하기 위한 N개의 쿼리 :
        • SELECT * FROM todo WHERE todo_id = 1
        • SELECT * FROM todo WHERE todo_id = 2
        • SELECT * FROM todo WHERE todo_id = 3
        • …
        • SELECT * FROM todo WHERE todo_id = N-2
        • SELECT * FROM todo WHERE todo_id = N-1
        • SELECT * FROM todo WHERE todo_id = N
더보기

💡 ORM 이 가지는 고질적인 문제인 N+1 문제는 왜 발생하는걸까?

JPA 입장에서 연관관계에 있는 두 테이블을 JOIN 해서 가지고오고 싶은지 확신할 수 없기 때문!!

만약, “연관관계 = JOIN” 이라고 가정해버릴 경우 너무 많은 연관관계 그래프가 한번에 조회될 수 있다. (load-on-demand 전략)

즉, 어떤 시점에 어떤 연관관계 그래프를 한번에 조회해야하는지 JPA 입장에서 알 수 없기 때문에 N+1 문제가 발생하고 우리는 Fetch Join, Entity Graph 와 같은걸 통해 명시적으로 알려주는 것이다!

✅ N+1 문제를 발견하다!

public List<TodoResponse> retrieveAllTodos() { // OneToMany
  return todoRepository.findAll()
    .stream()
    .map(TodoResponse::from)
    .toList();
}

public List<CommentResponse> retrieveAllComments() { // ManyToOne
  return commentRepository.findAll()
    .stream()
    .map(CommentResponse::from)
    .toList();
}

✅ 이유는 EAGER 때문?

  • 예제 코드에서 Todo, Comment 는 서로에 대한 연관관계 Fetch 전략을 모두 EAGER 로 설정했다.
  • LAZY Fetch 전략을 사용하면 N+1 문제가 해결될거라 오해하기 쉽다. 그렇다면 Fetch 전략을 LAZY 로 설정하면 N+1 문제가 해결될까?
  • 아니다!!
  • 경우에 따라 Lazy Loading 이 발생하지 않는 로직일 경우, 즉 연관관계를 참조하지 않는 경우 N개의 쿼리가 생략될 수는 있다.
  • 하지만, 연관관계에 있는 Entity 를 사용하는 시점에 결국 N개의 SELECT 쿼리가 발생해 N+1 문제가 여전히 발생하는걸 알 수 있다.
  • 즉, LAZY Fetch 전략을 통해 N+1 문제를 완전히 해결할 수는 없다!

✅ N+1 해결해보자! ⭐

  • N+1 문제를 해결하는 방법에는 3가지가 있다.
    1. Batch Size 조절
    2. Fetch Join
    3. EntityGraph

Batch Size 조절을 통해 N+1 문제를 해결

  • Batch Size 를 조절하는 것은 사실 N+1 문제를 근본적으로 해결하는 것은 아니다.
  • N개(N/100})의 쿼리가 너무 많으니까 특정 개수씩 묶어 In Query 를 날려서 쿼리의 개수를 줄여주는 것이다.
  • application.yml 에 spring.jpa.properties.hibernate.defalt_batch_fetch_size: 1000 설정을 통해 Batch Size 를 조절할 수 있다.
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000
  • N개의 쿼리가 하나의 In Query 로 합쳐진 것을 확인할 수 있다.
  • N+1 문제를 근본적으로 해결해 JOIN 을 통해 한번에 조회하도록 하진 못했지만, 불필요한 쿼리가 기하급수적으로 늘어나는 것은 막을 수 있다.
  • N+1 문제를 근본적으로 해결하는 것이 우선이나, 내가 놓친 부분에 대해서 최소한의 방어책이 되어줄 수 있기 때문에 해당 옵션에 대한 사용을 권장

⭐ Fetch Join 을 통해 N+1 문제를 해결

  • Fetch Join 은 JPA(JPQL) 에서 지원하는 JOIN 방식이다. (데이터베이스에 있는 개념 x)
  • Fetch Join 을 사용할 경우 연관관계에 있는 Entity 까지 한번에 조회해 영속화해준다.
  • Fetch Join 은 JPQL 로 JOIN FETCH 구문을 사용해 구현할 수 있다. JPA 뿐만 아니라 QueryDSL 에서도 Fetch Join 을 위한 메소드를 제공
@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {

  @Query("SELECT todo FROM Todo todo JOIN FETCH todo.comments")
  List<Todo> findAllWithFetchJoin();
}

위와같이 JOIN FETCH 이후에 연관관계에 대한 정보를 넣어주면 끝

  • 최초 의도한대로 JOIN 을 이용한 단 1개의 SELECT 쿼리만 발생했다.
  • 즉, N+1 문제를 근본적으로 해결했다고 할 수 있다.

EntityGraph 를 이용해 N+1 문제를 해결

  • @EntityGraph 를 이용해 해당 Entity 의 연관관계에 대한 정보를 넣어주는 방식이다.
@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {

  @EntityGraph(attributePaths = ["comments"])
  @Query("SELECT todo FROM Todo todo")
  List<Todo> findAllWithEntityGraph();
}
  • 마찬가지로 최초 의도한대로 JOIN 을 이용한 단 1개의 SELECT 쿼리만 발생했다.
  • 즉, N+1 문제가 근본적으로 해결
더보기

💡 @EntityGraph 를 이용한 결과에서 꼭 기억해야할 것은 Outer Join 이 발생했다는 것이다.

  • Left Join 은 Left Outer Join 의 약자다.
  • Outer Join 특성상 상황에 따라 중복 데이터가 나올 수 있다.
  • Set<> 자료구조를 사용하거나 distinct 키워드를 통해 중복을 제거할 수 있다.

뿐만 아니라 연관관계가 복잡해질 경우 @EntityGraph 를 사용하게 되었을 때 복잡도가 급증하는 문제도 존재

🔍 (부록) 꼭 연관관계 매핑을 사용 해야 하는것은 🙅‍♂️

RDB 에 외래키를 통한 연관관계가 있다고 항상 @JoinColumn 을 이용해 연관관계 매핑을 해야하는 것은 아니다! 실제로 실무에서 @JoinColumn 방식이 아니라 ID 참조 방식을 사용하는 케이스도 많다.

@Entity
public class Member {

  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "member_id")
  private Long id;

  private String name;
  private int age;
  private Long teamId;
  private LocalDateTime createdAt;
}
  • 이렇게하면 ORM(JPA/Hibernate) 장점 다 갖다 버리는거 아니야? 라고 생각할 수 있다.
    • 버리는게 맞다!!
    • JPA 가 기껏 객체지향 프로그래밍 하기 편하라고 연관관계 매핑을 해주고 있는데 그걸 극구 사양하는 것과 동일한 코드다.
  • 그렇다면 왜(Why) 이런 방식의 참조를 사용하는걸까?
    1. 관련도 없는 너무 많은 Entity 객체들이 한번에 변경될 수 있다. 특히 DDD 에서는 Aggregate 단위로 Domain 개념을 묶어서 정의하는데, 모든 연관관계를 @JoinColumn 을 사용해 연결지을 경우 이 경계를 명확히 정의하기가 어려워진다.
      1. Aggregate 외부의 변경을 원천차단하고, Aggregate 자체의 응집도를 높이기 위한 목적!
      2. Aggregate 내부의 연관관계는 @JoinColumn 을 이용하고, 외부와의 연관관계는 ID 참조 방식을 이용하도록 이야기된다.
    2. 연관관계 그래프가 길어지면 길어질수록 N+1 문제와 같은 쿼리들을 제어하기 어려워진다.
    3. MSA, Multi Module Project 로의 시스템 확장에 있어서 유연하지 못한 구조가 만들어진다.

🔍 (부록) 외래키(FK)를 안쓰기도 함 ❓

  • RDB 에서 데이터 정합성, 무결성을 보장하기 위해 반드시 사용해야하는 개념이라고 생각했기 때문이다. (실제로도 그렇다)
  • 아직도 왜 외래키를 사용하지 않는지 모두를 설득할만한 답을 찾진 못했다. 그래도 그동안 깨달은 나름대로의 이유를 소개해볼까한다!
    • 가장 먼저 떠오르는 것은 귀찮아서(생산성)다!!
      1. 외래키를 설정하게되면 개발하면서 귀찮은 일이 굉장히 많이 발생한다.
      2. 개발 후 테스트를 하는 과정에서 데이터를 INSERT 하는데 외래키가 걸려있으면 관련 데이터들을 “순서대로” 다 넣어줘야만한다.
      3. 반대로 데이터를 삭제하는데도 “순서대로” 데이터를 삭제해줘야하는 번거로움이 존재한다.
      4. 만약 데이터베이스를 확장하는 과정에서 마이그레이션하는데 외래키가 걸려있다면 여기서도 귀찮음이 발생하게 됨
    • 데이터의 무결성과 정합성보다 개발 편의성, 확장성을 더 중요하게 생각하기 때문이다.
  • 이성적으로는 당연히 외래키를 사용해야하는것 아니야..? 이럴거면 RDB 왜쓰지..? 라고 생각할 수 있지만, 현실적인 부분을 고려해 Trade-Off 할 수 있는 부분이라는 사실만 알고 넘어가면 좋을 것 같다!
더보기

개발의 많은 부분에서 정답이 없다는 사실을 인정해야한다.

정답을 찾기 위해 공부한다면 시간이 지날수록 상황이 안좋아질 수 있다!

 

반응형

'[내일배움캠프-Sparta] > Spring 6기' 카테고리의 다른 글

테스트 코드 ( 베이직 )  (0) 2025.05.06
⚓Spring Security ( 스탠다드 )  (0) 2025.05.06
🔍 영속성과 Transactional  (0) 2025.05.04
TIL 50 [ JPA 심화 - ( 테이블 객체 다루는법, JpaRepository 쿼리,페이징처리, JPQL ,QueryDSL, Auditing, 필요한부분 갱신 ) , 트랜잭션 복습 ]  (0) 2025.05.02
🚧 트랜잭션 (스탠다드)  (0) 2025.05.02
'[내일배움캠프-Sparta]/Spring 6기' 카테고리의 다른 글
  • 테스트 코드 ( 베이직 )
  • ⚓Spring Security ( 스탠다드 )
  • 🔍 영속성과 Transactional
  • TIL 50 [ JPA 심화 - ( 테이블 객체 다루는법, JpaRepository 쿼리,페이징처리, JPQL ,QueryDSL, Auditing, 필요한부분 갱신 ) , 트랜잭션 복습 ]
dimenshun
dimenshun
한 소년의 개발 일기
    반응형
  • dimenshun
    Dev Life Notes
    dimenshun
  • 전체
    오늘
    어제
    • 분류 전체보기 (268)
      • CS (23)
        • 자료구조 (0)
        • 알고리즘 (0)
        • 컴퓨터 구조 (8)
        • 네트워크 (6)
        • 운영체제 (3)
        • DB ( + SQLD ) (5)
        • SW공학 (1)
      • 프로그래밍 (3)
        • Java (0)
        • Spring (0)
        • HTML,CSS (3)
        • JavaScript (0)
      • 개발 툴 (7)
        • Git(버전관리) (1)
        • Docker (3)
        • AWS (2)
        • JSP (1)
      • 코딩테스트(Algorithm) (125)
        • 백준 (6)
        • 프로그래머스 (119)
      • [내일배움캠프-Sparta] (110)
        • Spring 6기 (106)
        • KPT 회고 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    It
    KPT
    SQLD
    SQL
    docker
    web
    내일배움캠프
    network
    개발자
    AWS
    웹
    Java
    컴퓨터구조
    db
    배포
    백엔드
    세션
    Python
    CPU
    알고리즘
    트랜잭션
    cs
    OS
    운영체제
    Testcode
    네트워크
    코딩테스트
    Til
    메모리
    spring
  • hELLO· Designed By정상우.v4.10.3
dimenshun
📅 연관관계와 N+1 문제 💥
상단으로

티스토리툴바