Next.js 환경에서 클린 아키텍처를 어떻게 적용했는지, 그리고 그 과정에서 무엇을 얻었는지 작성하는 글입니다.

1. Presentation 계층: API Route 핸들러

      // app/api/spaces/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { SbSpaceRepository } from '../../infrastructure/repositories/SbSpaceRepository';
import { GetSpaceUsecase } from '../application/usecases/GetSpaceUsecase';
import { createClient } from '../../infrastructure/supabase/server';
import { SbRoomRepository } from '../../infrastructure/repositories/SbRoomRepository';

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const spaceId = Number(searchParams.get('spaceId'));

    // --- 의존성 주입 (Dependency Injection) ---
    const supabase = await createClient();
    const spaceRepository = new SbSpaceRepository(supabase);
    const roomRepository = new SbRoomRepository(supabase);
    const getSpaceUsecase = new GetSpaceUsecase(
      spaceRepository,
      roomRepository
    );
    // -----------------------------------------

    const spaces = await getSpaceUsecase.execute(spaceId);
    return NextResponse.json(spaces);

  } catch (error) {
    console.error('Error fetching spaces:', error);
    return NextResponse.json(
      { error: 'Failed to fetch spaces' },
      { status: 500 }
    );
  }
}
    

getSpaceUsecase가 어떻게 데이터를 가져오는지, DB가 Supabase인지 모릅니다. 오직 Usecase를 실행합니다.

2. Application 계층: Usecase

Usecase는 "공간 정보를 가져온다"는 애플리케이션의 특정 동작을 책임집니다. 어떤 Repository를 통해 데이터를 가져올지 알고 있지만, 그 Repository가 어떻게 구현되었는지는 모르며 오직 인터페이스에만 의존합니다.

      // application/usecases/GetSpaceUsecase.ts

import { SpaceRepository } from '@/app/api/domain/repository/SpaceRepository';
import { RoomRepository } from '@/app/api/domain/repository/RoomRepository';
import { GetSpaceDto } from '../dtos/GetSpaceDto';

export class GetSpaceUsecase {
  // 구현체(SbSpaceRepository)가 아닌 추상화(SpaceRepository)에 의존
  constructor(
    private spaceRepository: SpaceRepository,
    private roomRepository: RoomRepository
  ) {}

  async execute(id: number): Promise<GetSpaceDto> {
    // 1. Space 정보를 가져온다.
    const space = await this.spaceRepository.findById(id);
    // 2. Room 정보를 가져온다.
    const rooms = await this.roomRepository.findBySpaceId(id);

    // 3. 데이터를 DTO(Data Transfer Object)로 가공하여 반환한다.
    const roomInfos = rooms.map((room) => ({
        id: room.id,
        name: room.name,
        // ...
    }));
      
    return new GetSpaceDto(space.id, space.name, roomInfos);
  }
}
    

Usecase는 여러 비즈니스 로직을 수행하고, 결과를 외부로 전달할 DTO 형태로 가공합니다.

3. Infrastructure 계층: Repository 구현체

여기가 Supabase와 직접 통신하는부분입니다. Domain 계층에 정의된 SpaceRepository 인터페이스를 실제로 구현합니다.

      // infrastructure/repositories/SbSpaceRepository.ts

import { Space } from '../../domain/entities/Space';
import { SpaceRepository } from '../../domain/repository/SpaceRepository';
import { supabaseAdmin } from '../supabase/client';

export class SbSpaceRepository implements SpaceRepository { // 인터페이스 구현
  constructor(private supabase: SupabaseClient) {}

  async findById(id: number): Promise<Space> {
    const { data, error } = await this.supabase
      .from('space')
      .select('*') // JOIN을 사용한다면 여기에 복잡한 쿼리가 들어갈 수 있음
      .eq('id', id)
      .single();

    if (error) {
      throw new Error(`공간 조회 중 오류 발생: ${error.message}`);
    }

    return data;
  }
}
    

나중에 데이터베이스를 Supabase에서 다른 것으로 바꾸고 싶을 때, 이 파일과 createClient 부분만 수정하면 됩니다. Usecase나 API 핸들러는 건드릴 필요가 없습니다.

 

클린 아키텍처는 모든 프로젝트에 적용해야 하는 것은 아니라고 생각했으며 작은 규모의 프로젝트에서는 오버 엔지니어링이 될 수 있습니다.  그러나, 프로젝트가 복잡해질 가능성이 있는 경우에는 초기에 이 구조를 도입하는 것이 장기적으로 생산성과 안정성을 얻을 수 있습니다.

단순히 코드를 작성하는 것이 아닌 견고하고 유연한 소프트웨어를 설계하는 경험으로 한 단계 더 성장하는 것을 느꼈습니다.

 

728x90