ORM을 쓸 때는 역시 특정 테이블 하나만 조회하는 것이 아니라 foreign key로 엮인 (성능상 일부러 foreign key 설정을 안 할 수도 있겠지만..) 다른 테이블의 것들도 함께 조회할 때 그 시점을 정하는 부분이 중요한 쟁점이다.
이때 메인 테이블과 엮인 다른 테이블의 row를 필요할 때,
(1) 일단 메인 테이블만 조회를 하고 필요할 때 다른 테이블의 row를 가져오는 방법 : Lazy loading(굳이 해석하자면, 게으른 로딩)
(2) 일단 메인 테이블과 함께 조회할 테이블을 미리 조인을 해서 다른 테이블의 row도 원할 때 한번에 조회하는 방법 : Eager loading(굳이 해석하자면, 탐욕적 로딩)
이렇게 두 가지 방법이 있는데요.
일단 1:1, 1:M, M:N 관계에서 이 두 가지 방법을 모두 코드로 실행해보겠습니다.
Student-ScholarshipAccount
// app.js
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const student = await Student.findOne({
where: { registrationNum: 20203718 },
});
const scholarshipAccount = await student.getScholarshipAccount();
console.log(scholarshipAccount.accountNum);
})();
지금 특정 학생의 장학금 계좌를 조회하고 있는데요. getScholarshipAccount라는 함수는 외래키 관계를 설정하면 자동으로 sequelize가 생성해주는 메소드입니다. 지금 끝 단어가 s가 붙지 않은 단수형이라는 점을 기억하세요. 이 코드를 실행해보면
이런 SQL이 실행됩니다. 일단은 Students 테이블에서 해당 학생 row를 조회하고, 해당 학생 row의 id 값을 StudentID 컬럼의 값으로 가진 ScholarshipAccounts 테이블에서의 row를 조회합니다. 이렇게 SQL이 총 두 번 실행된다는 걸 알 수 있습니다.
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const student = await Student.findOne({
where: { registrationNum: 20203718 },
include: ScholarshipAccount, //
});
const scholarshipAccount = await student.getScholarshipAccount();
console.log(scholarshipAccount.accountNum);
})();
지금 달라진 부분은 findOne 메소드 안에 include: ScholarshipAccount 라는 옵션이 추가되었다는 점입니다. 실행해보면
아까와 달리 LEFT OUTER JOIN이 이루어진다는 것을 알 수 있습니다. 이 부분이 아주 중요합니다. 하지만 여전히 해당 학생의 장학금 계좌를 조회하는 별도의 SQL이 실행된다는 것을 알 수 있는데요.
.getScholarshipAccount() 이 메소드 부분을 .ScholarshipAccount처럼 프로퍼티를 조회하는 코드로 바꿔봅시다.
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const student = await Student.findOne({
where: { registrationNum: 20203718 },
include: ScholarshipAccount,
});
const scholarshipAccount = student.ScholarshipAccount;
console.log(scholarshipAccount.accountNum);
})();
실행해보면
그럼 이제 단 하나의 SQL 쿼리만 실행되는 것을 알 수 있습니다. 이렇게 Eager loading은 처음부터 나중에 참조할 테이블을 조인해서 들고있기 때문에 SQL 쿼리 횟수를 줄일 수 있습니다. 그리고 Eager loading을 할 때는
const scholarshipAccount = student.ScholarshipAccount;
이 코드처럼 마치 student 객체 안의 프로퍼티처럼 해당 row를 가져올 수 있다는 점 기억하세요.
Lazy loading의 경우 student.getScholarshipAccount(); 처럼 메소드
Eager loading의 경우 student.ScholarshipAccount 처럼 프로퍼티
로 접근한다는 사실은 다음에서도 동일하게 적용됩니다.
Professor - Course
이번엔 전체 Professor와 각 교수에 딸린 Course들을 전부 조회해봅시다.
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const professors = await Professor.findAll();
professors.forEach(async (professor) => {
const courses = await professor.getCourses();
console.log(`! 교수님 성함: ${professor.name}`);
courses.forEach((course) => {
console.log(course.title);
});
});
})();
지금 일단 Professors 테이블 전체 row를 조회하고 각 Professor row의 id 값을 갖고 Courses 테이블에서 ProfessorId의 값이 그 id 값과 같은 row들을 매번 조회하고 있습니다. 이것은 소위 우리가 문제시하는 N+1 문제와도 연관이 있습니다. N+1 문제는 지금처럼 첫 번째 테이블 조회 한번(1) 그리고 해당 테이블의 각 row의 id 값을 갖고 다른 테이블의 연관 row들을 조회하는 N번을 하게 되는 현상을 의미합니다. 이 N+1 문제는 Sequelize 뿐만 아니라 다른 여러 ORM에서도 중요시되는 이슈인데요. Eager loading을 하면 이 문제를 해결할 수 있습니다.
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const professors = await Professor.findAll({
include: 'Courses',
});
professors.forEach(async (professor) => {
const courses = professor.Courses;
console.log(`! 교수님 성함: ${professor.name}`);
courses.forEach((course) => {
console.log(course.title);
});
});
})();
findAll 메소드에 include 옵션을 추가해주었고 그 안에 Courses라고 적어주었습니다. 이때 주의할 점은 이전에 1:1 관계에서는 include: 'Scholarship'처럼 단수형의 단어를 적어주었지만 지금처럼 1:M 관계에서는 include: 'Courses' 이렇게 복수형으로 적어주어야 합니다.
그리고 professor.getCourses() 부분도 professor.Courses로 변경하였습니다. 한번 실행 결과를 볼까요?
이렇게 Professors 테이블과 Courses 테이블을 아예 LEFT OUTER JOIN 해버리는 것을 볼 수 있습니다. 이전처럼 N+1 번의 쿼리 수행이 아닌 단 한번의 쿼리 수행으로 같은 결과를 얻을 수 있는 것이죠. 물론 조인 연산에 들어가는 DB 내의 비용이 있지만 그래도 여러번의 쿼리를 수행하는 것보다는 훨씬 효율적일 것입니다.
Students_Courses
이제 각 학생이 각 과목에 대해 맞은 점수를 출력해봅시다.
// app.js
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const students = await Student.findAll();
students.forEach(async (student) => {
const courses = await student.getCourses(); // (a)
console.log(`! 학생 이름 : ${student.name}`);
courses.forEach((course) => {
console.log(
`!! 과목 이름 : ${course.title} !!! 점수 : ${course.Grade.score}` // (b)
);
});
});
})();
코드가 살짝 복잡해진 것 같지만 주석 처리한 부분에만 주의하면 됩니다.
(a) student.getCourses() : 이 부분은 각 학생이 수강한 과목 목록을 가져오고 이 각 과목에 대해
(b) course.Grade.score : 해당 과목의 점수를 가져옵니다.
코드를 실행해보면
총 5번의 쿼리가 수행된 것을 알 수 있습니다. 일단
1번 : Students 테이블 전체를 조회한 후에,
N번 : Courses 테이블과 Grades 테이블을 INNER JOIN하고 그 다음 StudentID가 1인 것들, 2인 것들, 3인 것들, 4인 것들 이렇게 4번의 동작을 합니다. 뭔가 문제가 있어 보이죠?
이 코드를 Eager loading으로 바꿔 봅시다.
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const students = await Student.findAll({
include: 'Courses', //
});
students.forEach(async (student) => {
const courses = await student.Courses; //
console.log(`! 학생 이름 : ${student.name}`);
courses.forEach((course) => {
console.log(
`!! 과목 이름 : ${course.title} 점수 : ${course.Grade.score}`
);
});
});
})();
이전과 마찬가지로 include 옵션이 추가되고, getCourses() 부분이 Courses로 바뀐 것에 유의하세요. 실행해보면
Student 테이블과 "Courses 테이블과 Grades 테이블을 INNER JOIN 한 테이블"을 LEFT OUTER JOIN하고 있습니다. 단 한번의 쿼리만 실행되었네요. N+1 문제가 잘 해결되었습니다.
그런데 여기서 잠깐, M:N 관계에서 지금 Students 테이블을 기준으로만 조회를 해봤는데요. 이제 학생별 과목 점수가 아니라 과목별 학생 점수를 출력해보겠습니다. 코드와 그 결과만 보여드릴게요.
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const courses = await Course.findAll();
courses.forEach(async (course) => {
const students = await course.getStudents();
console.log(`! 과목 이름 : ${course.title}`);
students.forEach((student) => {
console.log(
`!! 학생 이름 : ${student.name} 점수 : ${student.Grade.score}`
);
});
});
})();
이미지에 담기지 않을 정도로 많은 수의 결과들이 출력됩니다. N+1 문제가 있습니다.
const db = require('./models/index.js');
const sequelize = db.sequelize;
const Student = db.Student;
const ScholarshipAccount = db.ScholarshipAccount;
const Course = db.Course;
const Professor = db.Professor;
const Grade = db.Grade;
(async () => {
const courses = await Course.findAll({
include: 'Students',
});
courses.forEach(async (course) => {
const students = await course.Students;
console.log(`! 과목 이름 : ${course.title}`);
students.forEach((student) => {
console.log(
`!! 학생 이름 : ${student.name} 점수 : ${student.Grade.score}`
);
});
});
})();
단 하나의 쿼리만 실행됩니다. Eager loading을 통해 N+1 문제를 해결했습니다.