Remix 서버 코드를 서버답게 관리하기

Controller-Service-Repository 그리고 DI

2024-10-27에 씀

Next.js나 Remix와 같은 SSR(서버사이드 렌더링) 프레임워크가 주류로 자리 잡으면서, 대부분의 웹 프로젝트가 SSR 프레임워크를 사용해 개발되고 있다. SSR 프레임워크를 사용하면 별도의 백엔드 서버를 구축하지 않아도 간단한 서버 로직을 수행할 수 있어서, 간단한 CRUD 기반의 프로젝트라면 하나의 프로젝트로 풀스택 개발이 가능하다.

하지만 클라이언트 코드와 서버 코드를 한 곳에 작성할 수 있게 되면서, 프로젝트가 복잡해질 가능성도 높아졌다. 특히 풀스택 개발을 할 경우 서버 측 코드에서 직접 데이터베이스에 접근해 값을 가져오거나 갱신하고 있다면 복잡도가 더욱 증가할 수 있다.

이 글에서는 Remix 프레임워크를 사용해 작성된 서버 코드를 Controller-Service-Repository 패턴으로 분리하고, Dependency Injection을 적용하는 과정에 대해 살펴본다. Remix 프레임워크를 사용하고 Supabase Client를 사용하는 상황을 예시로 들었지만, 다른 SSR 프레임워크나 ORM에 대해서도 동일하게 적용될 수 있다.

기존 코드 살펴보기

날짜 투표 서비스를 만든다고 해보자. /schedule/{key} 경로로 접근하면, key에 해당하는 약속의 정보를 보여준다. 하나의 약속에는 여러 명이 투표할 수 있으며, 약속은 Schedule, 투표는 Vote라고 부른다. 하나의 Schedule이 여러개의 Vote를 가질 수 있다.

캘린더 기반으로 약속 잡는 서비스가 없어서 만들었다. 여기에서 사용해 볼 수 있다

약속과 투표 데이터는 SSR 시점에 가져올 것이다. Remix의 loader 함수를 사용하면 서버에서 데이터를 fetch 해 올 수 있다.

1export const loader = async ({ params }) => {
2 const key = params.key;
3
4 try {
5 // supabase client를 생성한다
6 const db = createDbClient();
7 const scheduleResult = await db.from('schedule').select('*, vote(*)').eq('key', key);
8
9 if (scheduleResult.error) {
10 return new Response('', {
11 status: 404,
12 statusText: 'Not Found',
13 });
14 }
15
16 const schedule = scheduleResult.data[0];
17
18 return json({ key, schedule });
19 } catch (e) {
20 throw new Response('', {
21 status: 404,
22 statusText: 'Not Found',
23 });
24 }
25};
26
27export default function SchedulePage() {
28 // loader가 반환한 값을 Route Component 측에서 가져다 쓸 수 있다
29 const { key, schedule } = useLoaderData<typeof loader>();
30 // ...
31}

이런 방식으로 코드를 작성해도 잘 동작하지만, 확장성을 위해 고려해 볼만한 부분이 있다.

이런 우려점을 해결하기 위해, 비즈니스 로직과 데이터에 접근하는 로직을 loader 함수에서 빼낼 필요가 있다. 로직 분리를 위해, Controller - Service - Repository 패턴을 적용해 보려고 한다.

Controller-Service-Repository

코드의 관심사를 크게 세 부분으로 나누는 패턴으로, 보통 서버 애플리케이션에서 사용된다.

컨트롤러는 서비스를 호출하고, 서비스는 리포지토리를 호출한다. 서비스가 분리되면 비즈니스 로직을 재활용할 수 있게 되고, 리포지토리를 분리하면 데이터 소스를 변경하거나 데이터에 접근하는 방식을 변경하기 용이해진다.

이 글에서는 loader와 action이 컨트롤러 레이어의 역할을 한다고 보고, 여기에서 서비스와 리포지토리 레이어를 분리해 보겠다.

Service와 Repository 분리하기

위에서 /schedule/{key} 경로의 코드만 살펴봤지만, /vote/{key} 경로의 loader에서도 똑같이 key에 해당하는 스케줄 데이터를 가져와야 한다. /vote/{key} 경로에서도 위 코드와 같이 Supabase Client에 직접 접근하고 있다면, 아래와 같은 의존성을 가지게 된다.

각 loader 함수는 Supabase와 강하게 결합된 상태이고, 스케줄을 가져오는 로직도 중복된 로직이 작성되어 있다. 우선 “key 값을 기반으로 스케줄 데이터를 가져온다”는 중복 로직을 추출하여, 아래와 같이 ScheduleService를 만들어 주었다.

1export class ScheduleService {
2 async findByKey(key: string) {
3 const db = createDbClient();
4 const result = await db.from('schedule').select('*, vote(*)').eq('key', key);
5
6 if (result.error) {
7 throw new Error('존재하지 않는 스케줄');
8 }
9
10 return result.data[0];
11 }
12}
13
14// app/routes/schedule.$key.tsx
15export const loader = async ({ params }) => {
16 const key = params.key;
17
18 try {
19 const scheduleService = new ScheduleService();
20 const schedule = await scheduleService.findByKey(key);
21
22 return json({ key, schedule });
23 } catch (e) {
24 throw new Response('', {
25 status: 404,
26 statusText: 'Not Found',
27 });
28 }
29};

이제 두 loader 함수는 ScheduleService에 의존하고 있고, ScheduleService가 Supabase에 의존하고 있다.

ScheduleService에서 데이터에 접근하는 부분을 ScheduleRepository로 분리해 내면 아래와 같은 형태가 된다.

1export class ScheduleRepository {
2 async findByKey(key: string) {
3 const db = createDbClient();
4 return db.from('schedule').select('*, vote(*)').eq('key', key);
5 }
6}
7
8export class ScheduleService {
9 async findByKey(key: string) {
10 const repository = new ScheduleRepository();
11 const result = await repository.findByKey(key);
12
13 if (result.error) {
14 throw new Error('존재하지 않는 스케줄');
15 }
16
17 return result.data[0];
18 }
19}

아래 그림과 같이, Supabase에 대한 의존도 ScheduleRepository로 옮겨갔다.

코드는 모두 분리됐지만, 여전히 문제가 존재한다. ScheduleService가 여전히 Supabase에 대해 알고 있다는 점이다.

객체지향 설계 원칙 중 의존성 역전 원칙(Dependency Inversion Principle)에 의하면, 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 대신 추상화에 의존해야 한다. 비즈니스 로직을 담고 있을수록 고수준 모듈이고, 비즈니스 로직이 아닌 것은 ‘세부 사항’으로 여긴다.

여기에서는 ScheduleServiceScheduleRepository보다 고수준의 모듈이다. 그런데 ScheduleServiceScheduleRepository를 직접 알고 있고, 따라서 Supabase에 대한 세부 사항도 알고 있게 된다. 우리가 데이터에 접근하기 위해 Supabase를 사용한다는 것은 세부 사항이고, 이는 고수준 모듈인 ScheduleService가 알아서는 안 된다.

의존성의 방향을 바꾸기 위해서 두 모듈 사이에 인터페이스를 둘 수 있다.

이전과 어떤 차이가 있는지 알아보기 위해 Layered Architecture에 기반하여 각 모듈의 레이어를 구분해 보겠다. 서비스는 Domain 레이어에 속하고, 리포지토리는 Persistence 레이어에 속한다. 원래는 의존의 방향이 Domain 레이어에서 Persistence 레이어를 향했지만, 리포지토리의 인터페이스를 도메인 레이어에 속하게 함으로써 의존의 방향을 Persistence 레이어가 Domain 레이어로 향하게 역전시킬 수 있다.

이 부분에 대해 좀 더 알아보려면 아래 자료를 참고해 보시면 좋을 것 같다

ScheduleRepository의 인터페이스는 어떤 데이터베이스와도 무관해야 한다. 이를 위해, DB에서 가져온 데이터를 DTO(Data Transfer Object) 형태로 변환해서 서비스에 넘겨줘야 한다.

1interface ScheduleRepository {
2 findByKey: (key: string) => Promise<Schedule & { vote: Vote[] } | null>;
3}
4
5export class SupabaseScheduleRepository implements ScheduleRepository {
6 async findByKey(key: string) {
7 const db = createDbClient();
8 const result = await db.from('schedule').select('*, vote(*)').eq('key', key).throwOnError();
9 return result?.data?.[0] ?? null;
10 }
11}

이제 리포지토리의 인터페이스가 생겼으니, 서비스는 SupabaseScheduleRepository를 직접 알고 있는 것이 아니라 인터페이스인 ScheduleRepository만을 알면 된다.

의존성을 완전히 끊기 위해, 서비스가 직접 리포지토리 인스턴스를 생성하는 것이 아니라 생성자를 통해 리포지토리 인스턴스를 주입받도록 변경했다. 리포지토리도 매번 데이터베이스 클라이언트를 직접 생성하는 것이 아니라, 생성자를 통해 데이터베이스 클라이언트 인스턴스를 주입받도록 했다.

1export class SupabaseScheduleRepository implements ScheduleRepository {
2 constructor(private readonly database: SupabaseDatabaseClient) {}
3
4 async findByKey(key: string) {
5 const result = await this.database.from('schedule').select('*, vote(*)').eq('key', key).throwOnError();
6 return result?.data?.[0] ?? null;
7 }
8}
9
10export class ScheduleService {
11 /*
12 * 1. ScheduleRepository 인터페이스에 의존하게 되었다
13 * 2. 직접 인스턴스를 생성하는 것이 아니라 밖에서 주입받게 되었다
14 */
15 constructor(private readonly repository: ScheduleRepository) {}
16
17 async findByKey(key: string) {
18 const result = await this.repository.findByKey(key);
19
20 /* ... */
21 }
22}

이제 ScheduleServiceScheduleRepository 모두 인스턴스화가 될 때 바깥에서 databaserepository를 인자로 넘겨주어야 한다. 이 역할은 누가 해야 할까?

TSyringe로 의존성 주입하기

의존성 주입(Dependency Injection)은 어떤 객체가 필요로 하는 의존 객체를 외부에서 제공해 주는 방식이다. 이를 통해 객체가 직접 의존 객체를 생성하고 관리하는 책임을 제거할 수 있고, 따라서 객체 간의 결합을 느슨하게 할 수 있다.

설치하기

TSyringe를 사용하면 TypeScript의 데코레이터를 활용해 의존성을 주입해 줄 수 있다. 아래 커맨드로 필요한 라이브러리를 설치한다.

1npm install --save tsyringe @abraham/reflection

TSyringe 공식 문서에서는 reflect-metadata를 사용하는데, Vite & Remix 환경에서는 해당 라이브러리가 잘 동작하지 않았다. 대신 @abraham/reflection을 사용했다.

데코레이터를 사용하기 위해, tsconfig.json 파일을 수정해 주어야 한다.

1{
2 "compilerOptions": {
3 "experimentalDecorators": true,
4 "emitDecoratorMetadata": true
5 }
6}

의존성 주입하기

필요한 의존성을 자동으로 주입해 주기 위해, 두 가지 데코레이터를 사용한다.

  1. @injectable - 클래스 데코레이터로, 런타임에 의존성을 주입할 수 있게 해준다.
  2. @intect - 파라미터 데코레이터로, 토큰에 해당되는 값을 해당 파라미터에 주입해 준다.
1// 1. SupabaseScheduleRepository 클래스를 주입 가능한 클래스로 만든다
2@injectable()
3export class SupabaseScheduleRepository implements ScheduleRepository {
4 // 2. database 파라미터의 자리에, SupabaseClient 라는 이름의 토큰에 등록된 값이 주입되도록 한다
5 constructor(@inject('SupabaseClient') private readonly database: SupabaseDatabaseClient) {}
6
7 async findByKey(key: string) {
8 /* ... */
9 }
10}
11
12// 1. ScheduleService 클래스를 주입 가능한 클래스로 만든다
13@injectable()
14export class ScheduleService {
15 // 2. scheduleRepository 자리에 ScheduleRepository 이름의 토큰에 등록된 값이 주입되게 한다
16 constructor(@inject('ScheduleRepository') private readonly scheduleRepository: ScheduleRepository) {}
17
18 async findByKey(key: string) {
19 const result = await this.scheduleRepository.findByKey(key);
20 /* ... */
21 }
22}

어떤 자리에 어떤 값을 넣어줄지 TSyringe Container에 등록해 줄 수 있다. 위에서 SupabaseClientScheduleRepository 토큰을 사용했으므로, 각 토큰에 해당하는 값 혹은 클래스를 등록해 주었다.

1// src/.server/core/di/container.ts
2import '@abraham/reflection';
3import { container } from 'tsyringe';
4
5container.register('SupabaseClient', {
6 useValue: createSupabaseClient(), // DB 클라이언트 인스턴스를 생성해서 넣어준다
7});
8container.register('ScheduleRepository', {
9 useClass: SupabaseScheduleRepository, // ScheduleRepository 인터페이스를 구현한 클래스를 넣어준다
10});
11
12export { container };

register 코드는 데코레이터가 사용된 클래스가 처음으로 인스턴스화되기 이전 시점에 실행되어야 한다. 서버가 실행된 직후 이 코드가 실행될 수 있도록, entry.server.tsx 파일 최상단에 import 문을 추가해 주었다.

1// entry.server.tsx
2import 'src/.server/core/di/container'; // 파일 가장 위에 import
3
4/* ... */

그러면 loader 함수를 아래와 같이 작성할 수 있다.

1export const loader = async ({ params }) => {
2 const key = params.key;
3
4 try {
5 // TSyringe 컨테이너에서 ScheduleService를 resolve 해온다
6 const scheduleService = container.resolve(ScheduleService);
7 const schedule = await scheduleService.findByKey(key);
8
9 return json({ key, schedule });
10 } catch (e) {
11 throw new Response('', {
12 status: 404,
13 statusText: 'Not Found',
14 });
15 }
16};

이제 따로 ScheduleServiceScheduleRepository를 생성하지 않아도 알아서 인스턴스가 생성되고, 알아서 의존성이 주입된다. 만약 나중에 Supabase Client가 아니라 다른 ORM, 다른 데이터베이스를 사용하게 되더라도, ScheduleRepository를 새로 구현한 다음 TSyringe 컨테이너에 ScheduleRepository 토큰과 연결된 클래스를 바꿔주기만 하면 된다.

폴더 정리하기

서버에서 사용할 모듈은 .server 디렉토리 하위에 정리해 둘 수 있다. 이 프로젝트의 폴더 구조는 아래와 같다.

1.
2├── app
3│ └── routes
4├── public
5├── scripts
6└── src
7 ├── .server
8 │ ├── core
9 │ │ └── di
10 │ ├── database
11 │ │ └── supabase
12 │ └── domain
13 │ ├── schedule
14 │ └── vote
15 ├── ui
16 │ ├── components
17 │ ├── contants
18 │ └── functions
19 └── shared
20 ├── types
21 └── utils

클라이언트 쪽 코드에서 .server 디렉토리 하위의 모듈에 접근하려고 하면, 아래와 같이 서버 모듈에 접근할 수 없다는 에러가 발생한다. 참고로, 이 기능은 Remix Vite에서만 사용할 수 있다.

이렇게 서버 코드를 정리해 두면, 나중에 서버를 따로 분리하게 될 때 이 코드를 그대로 가져다 사용할 수 있을 것이다.

참고 자료

프로필 사진

조예진

이전 포스트
함수는 어디까지 접근 가능한가? - Closure와 this 이해하기
다음 포스트
CSS Modules와 CVA로 스타일 변형 관리하기