ORM을 통해서 SQL 쿼리 없이 데이터를 데이터베이스에 저장하고 관리할 수 있게 되었습니다.
그래서 개발자는 더욱 편해지게 되었던 것이었죠. 익숙한 언어로 데이터베이스의 데이터를 더 쉽게 조작할 수 있게 되었으니까요.
'ORM을 배워야 하는 것은 문제가 되지만요? 사실은 SQL도 잘 짤 수 있어야 하기도 합니다. 사실 배워야 할 것이 늘었다고 볼 수 있습니다. 지금 당장 저도 배우고 있긴 하거든요..'
근데 사실 ORM은 어떤 역할을 담당하냐면?
애플리케이션은 객체로 데이터 처리
반면에, 데이터베이스는 테이블로 데이터 처리
어라? 서로 다르네?
위와 같은 패러다임(paradigm)의 불일치를 ORM이 가운데에서 딱 조절을 해줍니다. 그래서 ORM이 뭔지에 대해서 조금 더 심화과정을 작성해보려고 합니다. 이래야 쓰는 이유를 확실하게 이해할 수 있을 것 같습니다.
패러다임(paradigm)이란?
이론적인 틀이나 개념의 집합체를 의미합니다.
이제 어떤 내용을 설명드릴지 작성을 해보겠습니다.
첫번째, 패러다임의 불일치
두 번째, ORM은 어떻게 이를 해결하고 있을까?
위 두가지를 조사해 온 내용을 바탕으로 작성해 보도록 하겠습니다.
객체 지향 언어와 관계형 데이터베이스간의
패러다임 불일치 문제.
일 번 : 상속
객체 지향 언어에서는 상속을 통해 클래스를 확장할 수 있습니다. 확장성이 높습니다. 하지만 데이터베이스에서는 이를 직접 테이블로 표현하기 어렵습니다. 아래의 코드와 같습니다.
TypeScript
extends를 사용해 상속을 할 수 있습니다.
@Entity()
class BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
createdAt: Date;
}
@Entity()
class User extends BaseEntity {
@Column()
name: string;
}
@Entity()
class Admin extends User {
@Column
isAdmin: boolean;
}
SQL
상속은 고사하고 중복된 칼럼을 계속해서 추가해줘야 합니다.
CREATE TABLE "base_entity" (
"id" SERIAL PRIMARY KEY,
"created_at" TIMESTAMP NOT NULL
);
CREATE TABLE "user" (
"id" SERIAL PRIMARY KEY,
"name" VARCHAR(100) NOT NULL,
"created_at" TIMESTAMP NOT NULL
);
CREATE TABLE "admin" (
"id" SERIAL PRIMARY KEY,
"is_admin" BOOLEAN NOT NULL,
"created_at" TIMESTAMP NOT NULL,
"name" VARCHAR(100) NOT NULL
);
이번 : 객체지향의 참조와 테이블의 외래키 문제
객체는 다른 객체를 직접 참조하지만 데이터베이스에서는 이를 외래키로 표현해야 합니다. @ManyToOne은 @JoinColumn을 암묵적으로 적용시켜 주기 때문에 외래키가 자동으로 적용됩니다.
TypeScript
@Entity()
class Post{
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(()=>User, user=> user.posts)
user: User;
}
SQL
CREATE TABLE "post" (
"id" SERIAL PRIMARY KEY,
"title" VARCHAR(100) NOT NULL,
"user_id" INTEGER,
FOREIGN KEY ("user_id") REFERENCES "user"("id")
);
삼 번 : 객체 간의 자유로운 참조, 데이터베이스는 관계의 깊이에 따른 성능 차이
객체간의 복잡한 관계를 자유롭게 참조 가능하지만, 데이터베이스에서는 테이블 관계의 깊이에 따라 탐색 성능이 달라집니다. 데이터베이스에서 테이블 관계의 깊이는 여러 테이블이 연속적인 관계를 맺고 있는 정도입니다. 관계의 깊이가 깊어질수록 조인 연산을 많이 수행해야 하며, 이는 쿼리 성능에 영향을 미칠 수 있습니다.
이러한 관계의 깊이는 당연히 데이터베이스 쿼리 성능에 중요한 영향을 미치게 됩니다. 깊이가 깊어질수록 쿼리가 복잡해지고 성능 저하가 발생되는 것은 당연하겠죠? 예시를 보도록 하겠습니다.
TypeScript
Department와 Employee 객체 간의 자유로운 참조를 보여줍니다. 객체 간의 관계가 깊어지더라도 프로그램 내에서 자유롭게 참조할 수 있습니다.
class Department {
name: string;
employees: Employee[] = [];
constructor(name: string) {
this.name = name;
}
addEmployee(employee: Employee) {
this.employees.push(employee);
}
}
class Employee {
name: string;
department: Department;
constructor(name: string, department: Department) {
this.name = name;
this.department = department;
department.addEmployee(this);
}
}
const department = new Department("Engineering");
const emp1 = new Employee("Alice", department);
const emp2 = new Employee("Bob", department);
console.log(department.employees); // [emp1, emp2]
SQL
이 쿼리는 4개의 테이블을 조인하여 Company, Department, Employee, Project의 데이터를 함께 조회합니다. 관계가 깊어질수록 조인 연산의 수가 증가하고, 성능에 영향을 미칠 수 있습니다.
CREATE TABLE Company (
id SERIAL PRIMARY KEY,
name VARCHAR(255)
);
CREATE TABLE Department (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
company_id INTEGER,
FOREIGN KEY (company_id) REFERENCES Company(id)
);
CREATE TABLE Employee (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
department_id INTEGER,
FOREIGN KEY (department_id) REFERENCES Department(id)
);
CREATE TABLE Project (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
employee_id INTEGER,
FOREIGN KEY (employee_id) REFERENCES Employee(id)
);
SELECT
Company.name AS company_name,
Department.name AS department_name,
Employee.name AS employee_name,
Project.name AS project_name
FROM
Company
JOIN
Department ON Company.id = Department.company_id
JOIN
Employee ON Department.id = Employee.department_id
JOIN
Project ON Employee.id = Project.employee_id;
이러한 문제를 해결하기 위해서는 인덱스를 사용하거나, 쿼리 최적화, 캐싱의 방법을 사용해야 할 것입니다.
인덱스 활용 방안
CREATE INDEX idx_department_company_id ON Department(company_id);
CREATE INDEX idx_employee_department_id ON Employee(department_id);
CREATE INDEX idx_project_employee_id ON Project(employee_id);
쿼리 최적화
실은 실행계획(explain)을 보고 처리했던 적이 있었는데, 이 내용은 추후에 작성해 보도록 해보겠습니다. 현재는 필요한 테이블만 조인해 쿼리를 최적화했습니다.
SELECT
Company.name AS company_name,
Department.name AS department_name
FROM
Company
JOIN
Department ON Company.id = Department.company_id;
캐싱(Caching)
Redis를 활용해 캐싱이 가능할 것입니다.
사번 : 객체 간 방향이 존재합니다. 하지만 데이터베이스는 조인(Join)을 통해 방향 없이 여러 테이블을 묶을 수 있습니다.
아까도 살짝 봤지만 상속을 통해 방향성이 존재하고 있죠? 객체간 방향성이라는 것은 객체들이 서로 어떻게 의존하고 상호작용하는 지를 나타내는 것인데요. 이는 주로 클래스 간의 관계로 설명되며, 한 객체가 다른 객체에 의존할 때 방향이 생기게 되는 것입니다. 이러한 방향성은 애플리케이션의 구조와 데이터의 흐름을 이해하는데 굉장히 중요합니다.
특히나 객체 지향 프로그램에서는 객체 간의 방향성이 명확하게 정의된 빈다.
이는 코드의 구조와 데이터 흐름을 이해하는 데 도움을 주며, 객체 간의 의존성을 명확하게 나타냅니다. 반면, 데이터베이스에서는 테이블 간의 관계가 방향성을 가지지 않으며, 조인(Join)을 통해 여러 테이블을 묶어서 조회할 수 있습니다. 이는 테이블 간의 관계를 탐색할 때 더 유연한 접근 방식을 제공합니다.
위의 삼 번과 유사한 느낌이죠? 아무래도 삼번과 사번은 엮어도 될 것 같습니다. 하지만 방향성에 대해서는 코드 예시를 통해서 봐야 조금이라도 더 이해할 수 있을 것 같습니다.
TypeScript
Profile 객체는 User 객체에 의존하며, User 객체는 Profile 객체를 참조합니다. 이는 profile 객체가 user 객체에 대해 방향을 가지는 것을 의미합니다.
class User {
id: number;
name: string;
profile: Profile;
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
setProfile(profile: Profile) {
this.profile = profile;
}
}
class Profile {
bio: string;
user: User;
constructor(bio: string, user: User) {
this.bio = bio;
this.user = user;
}
}
const user = new User(1, "John Doe");
const profile = new Profile("This is John's bio", user);
user.setProfile(profile);
console.log(user);
console.log(profile);
방향성에 대해서 간단하게 설명해 보겠습니다.
객체의 의존성: Profile 객체는 User 객체에 대한 참조를 가지고 있습니다. 이는 Profile 객체가 User 객체의 존재를 알고 있어야 한다는 것을 의미합니다. 따라서 Profile은 User에 의존합니다.
단방향 관계: 위의 예제에서는 Profile 객체가 User 객체를 참조하고 있지만, User 객체는 Profile 객체를 명시적으로 참조하지 않습니다. 이는 단방향 관계를 나타냅니다. 하지만 User 객체가 Profile 객체를 참조하도록 하면, 양방향 관계를 형성할 수 있습니다.
관계의 명확성: 객체 간의 관계는 코드에서 명확하게 표현됩니다. 이는 코드의 구조를 이해하고 유지보수하는 데 도움이 됩니다. 예를 들어, Profile 객체가 User 객체에 대한 참조를 가지고 있으면, Profile 객체가 User 객체의 정보를 사용할 수 있음을 명확하게 알 수 있습니다.
SQL
데이터베이스에서는 테이블 간의 관계를 표현할 때 방향성이 명시적이지 않습니다. 대신, 테이블 간의 관계는 외래 키(Foreign Key)와 조인(Join)을 통해 정의됩니다. 이는 테이블 간의 관계가 방향성을 가지지 않음을 의미합니다.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE profiles (
id SERIAL PRIMARY KEY,
bio TEXT NOT NULL,
user_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id)
);
Join을 통해 관계를 조회
SELECT users.name, profiles.bio
FROM users
JOIN profiles ON users.id = profiles.user_id;
위의 SQL 예제에서 profiles 테이블은 users 테이블과 외래 키로 연결되어 있지만, 이는 방향성을 나타내지 않습니다. 조인(Join)을 통해 두 테이블 간의 관계를 탐색할 수 있습니다.
오번: 2개의 객체 간 다대다 관계를 형성합니다. 하지만, 두 테이블로 다대다 관계 형성이 불가능합니다.
TypeScript
@Entity()
class Category {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Post, post => post.categories)
@JoinTable()
posts: Post[];
}
@Entity()
class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToMany(() => Category, category => category.posts)
categories: Category[];
}
SQL
CREATE TABLE "category" (
"id" SERIAL PRIMARY KEY,
"name" VARCHAR(100) NOT NULL
);
CREATE TABLE "post" (
"id" SERIAL PRIMARY KEY,
"title" VARCHAR(100) NOT NULL
);
CREATE TABLE "post_categories_category" (
"post_id" INTEGER,
"category_id" INTEGER,
FOREIGN KEY ("post_id") REFERENCES "post"("id"),
FOREIGN KEY ("category_id") REFERENCES "category"("id"),
PRIMARY KEY ("post_id", "category_id")
);
이건 설명하지 않아도 괜찮죠? 가 아니라 글을 순서를 반대로 써서 알고 있는 줄 알았습니다. 다음 너와 나의 관계는 뭐야라는 글에서 읽을 수 있습니다.
여기까지 객체지향 언어와 데이터베이스 간의 불일치를 보여주는 다섯 가지였습니다. 저도 이런 내용을 접했을 때 꽤나 ORM이 다양한 일을 하는구나 헀습니다. 그래서 무슨 일을 할까요? 그것에 대해서 알아보겠습니다.
위와 같은 패러다임의 불일치를 TypeORM에서는 어떠한 방식으로 해결을 하고 있을까?
일 번 : 객체 지향 프로그래밍 지원
TypeORM은 TypeScript와 자연스럽게 통합되어 객체 지향적인 방법으로 데이터베이스를 다룰 수 있습니다. 이는 코드의 가독성과 유지 보수성을 높여줍니다. 데이터베이스는 객체 지향적인 특성을 갖고 있지 않습니다. 이로 인해서 개발자는 코드의 가독성과 유지 보수성을 높여줍니다.
이번 : 자동화된 데이터베이스 스키마 관리
TypeORM은 데이터베이스 스키마를 자동으로 생성하고 관리합니다. 따라서 개발자가 직접 SQL 쿼리를 작성하고 테이블을 관리할 필요가 없습니다. 개발자는 비즈니스 로직에 좀 더 집중해서 문제를 해결할 수 있습니다.
삼 번 : 타입의 안정성
TypeScript의 타입 시스템을 활 요하여 데이터베이스 쿼리와 결과에 대해 타입 검사를 수행할 수 있습니다. 이는 런타임 에러를 줄이고 코드의 안정성을 높입니다. 특히 자바스크립트는 원형 타입을 정의하는 것이 없기 때문에 타입스크립트로 타입을 지정해 문제를 해결합니다.
사번 : 강력한 쿼리 빌더
TypeORM은 복잡한 쿼리를 작성하기 위한 강력한 쿼리 빌더를 제공합니다. 이를 통해 SQL 쿼리를 직접 작성하지 않고도 복잡한 데이터베이스 작업을 수행할 수 있습니다. 복잡한 쿼리 작성을 진행해야 하지만, 쿼리 빌더를 이용해 그러한 문제를 해결할 수 있습니다. 이외에도 다른 기술들이 존재합니다.
위와 같은 이유로 typeORM을 사용하면 데이터베이스와 상호작용하는 코드가 더 직관적이고 요지보수하기 쉬워집니다. 또한, 자동화된 데이터베이스 스키마 관리와 타입 안전성을 제공하여 개발자가 더욱 생산적이고 안전하게 코드를 작성할 수 있습니다. 직접 SQL을 작성하는 경우보다 코드의 복잡성이 줄어들고, 여러 데이터베이스를 지원하여 유연성을 확보할 수 있습니다.
하지만 득이 있으면 실이 있는 법입니다. 득만 있는 기술은 없습니다. 트레이드오프라고 하나 이걸?
ORM의 큰 단점입니다.
로직의 복잡도가 올라갈수록 ORM의 최적화가 부족해질 수 있다. 별도의 특수한 튜닝을 위해 SQL문을 직접 작성해야 합니다. 복잡한 쿼리는 오히려 SQL문으로 작성하는 것이 편합니다.
완벽한 정답이란 없다. ORM을 굳이 사용해야 한다면, 패러다임 일치의 문제가 발생하니 그럴 때 사용해야 함이 분명한데, 특히 로직이 복잡해져 SQL문을 직접 작성할 줄도 알아야 한다.
여기까지 왜 ORM을 사용하는가에 대한 내용이었습니다. 객체지향언어와 데이터베이스 간의 패러다임의 불일치로 개발자가 곤욕을 겪고 있는 것을 해결하기 위해서 등장했습니다. 하지만 위에 작성한 대로 단점도 보유하고 있습니다. 모든 기술에는 정답이 없기 때문입니다. 그래서 잘 고민해서 ORM을 도입해야 할 것 같습니다. 이상 ORM을 왜 쓰는가에 대한 글이었습니다.
'Project > TypeORM' 카테고리의 다른 글
[번역] OneToMany / ManyToOne Relations (0) | 2024.08.21 |
---|---|
[번역] @OneToOne Relations (0) | 2024.08.19 |
어떻게 데이터를 건드릴 수 있을까? (0) | 2024.08.14 |
너랑 나랑 무슨 관계(relation)야? (0) | 2024.08.13 |
왜냐하면, ORM을 사용하기로 했다. (0) | 2024.08.12 |