본문 바로가기

디미페이

🐣백지에서 시작하는 디미페이 v2 백엔드 리팩터링

책 읽는 유령

 

저는 디미페이 v2 프로젝트를 시작하면서 백엔드를 처음부터 다시 개발하기로 결정했어요. 이 글에선 왜 이런 선택을 하게 되었고, 타입스크립트 백엔드를 개발하기 위해 어떤 도구들을 선택했는지 공유해보려 해요.

 

이전 백엔드는 어땠는대요?

디미페이 v1 백엔드는 TypeScript와 Expressjs 바탕의 디미고인 v3 백엔드 코드 베이스를 기반으로 개발되었어요. 디미고인(교내 인트라넷)과 다른 점은 데이터 모델 도구로 Prisma와 Postgresql을 사용했다는 점이 있고, 나머진 매우 비슷한 모습이에요.

 

디미고인 백엔드 아키텍처는 간단한 CRUD 서버를 만들기에 간결하고 체계적인 구조를 가지고 있어요. 디미페이도 처음엔 이 구조에 잘 적응했어요. 하지만 복잡한 로직을 처리하는 일이 늘어나면서 점점 CURD 코드베이스의 한계가 느껴지기 시작했어요.

 

"이번에도 컨트롤러 하나 만들면 되겠지."

"완전 처음부터 구현하고있는데, 아무래도 이건 아닌 것 같아."

 

처음엔 릴리즈 일정을 맞추려고 너무 빨리 만들다 보니 엉망이 되지 않았나 생각했어요. 그런데 이 코드베이스에선 아무리 여유를 가지고 개발한다 하더라도 이 정도 규모의 백엔드 코드를 깔끔하게 다룰 수 없다는 걸 깨달았어요. 제가 알아낸 이유는 규칙이 없고 즉흥적으로 만들고 있다는 것이었어요. 여러 책임을 가지고 있는 큰 함수를 작은 함수로 분리하더라도, 그 분리의 기준이 명확하지 않았었어요. 지금 옛날 코드를 둘러보면 리펙터링이라고 해놓은 게 입력 유효성 검사와 비즈니스 로직을 함께 처리하고 있는 게 꽤 있더라고요.

 

사실 express는 개발자가 따라야 할 엄격한 규칙이 없어(unopinionated) 개발자가 자유롭게 사용하도록 만들어진 프레임워크예요. 그러나 개발 경험이 많지 않았던 저에겐 오히려 단점이었어요. 저는 이 제자리걸음 리펙터링을 잠시 접어두고 리펙터링을 자꾸 실패하는 원인이 무엇이고, 큰 코드베이스는 어떻게 코드를 관리하고 있는지 알아내고 싶었어요.

 

DDD & 헥사고날 아키텍처

여러 오픈소스를 둘러보다 우연히 Domain-Driven Hexagon이라는 리포지토리를 찾게 되었어요. 그리고 이건 앞으로 저의 백엔드 여정을 완전히 바꿔놓았죠. 이 리포지토리는 DDD(도메인 주도 설계)와 헥사고날 아키텍처를 구현한 예제 소스코드예요. 예제 코드지만, 아무것도 모르는 제가 보더라도 적절히 분리된 책임과 깔끔한 폴더 구조는 제가 해결하고 싶었던 모든 것을 해결해 줄 수 있다는 걸 느낄 수 있었어요.

Domain-driven Hexagonal Architecture Diagram

 

결과적으로 바로 이게 제가 찾던 거였어요. 위 그림은 이 소스코드에서 구현하고 있는 헥사고날 아키텍처의 다이어그램이에요. 이 그림을 모두 이해하는 데만 정말 많은 시간이 들었어요. 모든 걸 여기서 설명하기엔 내용이 너무 길어지니, 지금은 도메인 주도 설계와 헥사고날 아키텍처가 도대체 뭐길래 대단한지 간단히 알아보도록 해요.

 

DDD(Domain-Driven Design) Nutshell!

큰 의미로 도메인은 조직이 하는 일과 그 조직 안의 세계를 뜻해요. DDD에선 도메인을 기업의 핵심 서비스인 비즈니스 도메인과 이와 함께 동작하는 하위 도메인으로 나누고, 이들의 경계를 적절히 설정하여 일관되고 예측 가능한 동작을 설계할 수 있도록 도와줘요. 그리고 이를 코드상에 엔티티(Entity)와 애그리겟(aggregate) 등의 도메인 빌딩 블록으로 구현해요.

 

이렇게 도메인 모델을 구현하면, 잘 사용하는 방법도 중요해요. 결제 승인 로직이 있다면, 이 로직을 사용자가 직접 호출할 순 없잖아요? 사용자 입력을 검증하고, 트랜잭션을 시작하고, 결제 완료 알림을 보내는 등 해야 할게 많으니까요. 그런데 만약 이런 것까지 도메인 로직에 전부 집어넣는다면 더 이상 도메인 모델이라고 부를 수 없을 뿐만 아니라 코드를 유지하기도 정말 어려워질 거예요. 왜냐면 디미페이 v1 백엔드가 그랬거든요:)

 

이런 문제를 예방하고 정돈된 코드베이스를 유지하기 위한 방법을 아키텍처 패턴라고 불러요. DDD에서 사용할 수 있는 대표적인 아키텍처에는 계층형 아키텍처(layered architecture), 클린 아키텍처(clean architecture), 어니언 아키텍처(onion architecture), 헥사고날(또는 포트-어댑터) 아키텍처(hexagonal/port-adapter architecture) 등이 있어요. 많아 보이지만, 사실 계층형 아키텍처에서 파생되고 파생된 것들이라 압도되지 않아도 괜찮아요.

 

계층형 아키텍처는 인터페이스, 애플리케이션, 도메인, 인프라의 네 가지 계층으로 나누고, 상위 계층만 하위 계층에 의존할 수 있다는 규칙이 있어요.

  • 인터페이스 계층: UI와 API 엔트포인트 등 사용자와 사용작용을 처리
  • 애플리케이션 계층: 인터페이스와 도메인 계층을 연결하며, 필요한 리소스를 불러오고 도매인 로직을 호출
  • 도메인 계층: 엔티티와 비즈니스 로직을 관리
  • 인프라 계층: 리파지토리 등의 기술적 지원을 담당
InterfaceApplicationDomainInfrastructure
레이어 아키텍처

 

전통적인 계층형 아키텍처는 인프라 계층이 가장 아래에 있어요. 근데 이 규칙 때문에 영속성을 담당하는 리파지토리가 도메인 계층을 참조할 수 없는 문제가 발생해요. 이를 해결하려면 도메인 계층에서 정의한 리파지토리 인터페이스를 애플리케이션 계층에서 구현하도록 만들 수 있어요. 하지만 더 좋은 방법이 있는데, 바로 의존성 역행의 원리(DIP)를 이용하여 인프라 계층은 제일 위로, 도메인 계층은 가장 아래로 만드는 거예요.

 

이 규칙이 적용된 아키텍처가 클린 아키텍처, 어니언 아키텍처, 헥사고날 아키텍처예요. DIP는 로버트 마틴이 제안했고, 하위 모듈이  상위 모듈에서 정의한 인터페이스에 의존한다는 규칙을 가지고 있어요. 즉, 인프라는 애플리케이션이나 도메인 계층에서 정의된 인터페이스를 의존할 수 있어요. 이 모습이 그 유명한 클린 아키텍처의 모습이에요.

 

InfrastructureInterfaceApplicationDomain
Inversed Dependencies

 

 

이제 헥사고날 아키텍처를 설명할 수 있어요. 헥사고날은 앨리스테어 콕번이 재시한 아키텍처로, 포트라는 개념을 명시한 아키텍처예요. 포트의 역할 중 하나는 애플리케이션의 내부와 외부의 경계를 만들어 주는 거예요. 여기서 "내부"는 도메인 모델과 애플리케이션을 뜻하고, "외부"는  API, DB, 메시징과 같은 말 그대로 외부(인프라와 비슷함)를 뜻해요. 포트를 구현하는 어댑터를 구현하면 애플리케이션 내부와 호환되기 때문에 새로운 어뎁터를 만들고 테스트가 용이해지는 장점이 있어요.

"왜 이름이 헥사고날이에요?"
콕번의 글에서도 나와있듯이, 육각형에 큰 의미가 있는 게 아니라, 육각형은 포트와 어댑터 그림을 넣을 자리가 많아서 사용했다고 해요. 그래서 포트와 어댑터 아키텍처로 이름이 바뀌긴 했지만, 여전히 많은 사람들이 헥사고날이라고 부르기 때문에 두 이름을 가지게 되었어요. 하지만 포트 덕분에 여러 애플리케이션이 쉽게 연결될 수 있다는 점이 벌집과 비슷해서 육각형이라는 해석도 있는데, 이 말도 그럴싸하지 않나요?

 

ApplicationAdapterAdapterAdapterAdapterAdapterAdapterDBmessage queue3rd party APImocked3rd party API
hexagonal architecture

 

만약 클린 아키텍처를 사용하고 있다면, 헥사고날 아키텍처를 사용하고 있을 가능성이 커요. DI를 사용하기 위해선 인터페이스를 만들고, 이를 구현하는 "어댑터"를 만들었을 거니까요.

 

하지만 항상 DDD와 아키텍처 선택이 최선이 아닐 때가 있어요. 오히려 간단한 시스템에선 장점을 많이 느끼지 못할 거예요. 그리고 지금까지 보면 알겠지만, 알아야 할 내용이 정말 방대해요. 저는 다행히 백엔드 팀에 저 혼자 밖에 없어서 이것저것 실험적인 선택을 자유롭게 할 수 있었지만, 팀원이 이 두 개념에 대해 자세히 알지 못한다면 개발 시간이 더 늘어날 뿐만 아니라 오히려 잘못된 설계로 인해 또다시 처음부터 리랙터링을 해야 할 수도 있어요. 

그러나 높은 확장성과 유지보수성, 그리고 복잡한 비즈니스 로직을 쉽게 추가할 수 있어 장기적으로 안정적이고 견고한 시스템을 기대할 수 있어요. 이 개념을 알고 있는 새 개발자가 들어오면 새로운 코드베이스에 적용하는 시간도 확실히 줄어들고 생산성도 눈에 띄게 높아질 거예요.

 

 DDD와 아키텍처에 관심이 생겼다면 이번 기회에 새로운 개념에 발을 담가보는 것도 좋을 것 같아요.

 

Nest.js

DDD와 헥사고날 아키텍처를 JavaScript 환경에서 구현하려면 저번처럼 그냥 Expressjs를 사용할 순 없어요. 헥사고날 아키텍처는 기본적으로 DI(Dependency Inversion)가 사용되기 때문이에요. JavaScritp에서 유명한 DI 라이브러리에는 InversifyJs와 마이크로소프트에서 만든 tsyringe가 있어요. 하지만 개발자가 DI 인프라를 관리하지 않아도 되고, OOP와 FP에 친화적인 데다 모듈러 아키텍처를 기본적으로 채택하고 마이크로서비스까지 만들 수 있는 서버 사이드 프레임워크가 있으면 얼마나 좋을까요? 😉

 

와우 Nest.js가 바로 그 프레임워크예요. Nest.js는 Java Spring의 TypeScript & Node.js 버전이라고 생각하면 쉽게 이해할 수 있을 거예요. 데코레이터로 라우팅과 기타 메타데이터를 추가할 수 있고, OOP와 DI가 적용된 프레임워크예요. 하지만 http 프레임워크는 아니에요. 내부적으론 이미 잘 만들어진 ExpressFastify를 사용해요. Nest.js는 http 프레임워크를 엄격한 규칙 아래에서 효율적으로 개발할 수 있게 도와줘요.

 

Nestjs logo

 

"DI 라이브러리가 있으면 express 프로젝트에 DI 라이브러리를 도입할 수도 있지 않나요?"
맞아요. 저도 Nestjs를 선택하기 전 이 접근 방법을 직접 구현해 보고 오픈소스도 찾아보았어요. 하지만 Nestjs는 DI 뿐만 아니라 앞서 얘기한 것처럼 개발 시간과 오류를 줄일 수 있는 내장 기능이 많았고, 성공 사례 또한 쉽게 찾아볼 수 있어 Nest가 안전한 선택이라고 생각했어요.

 

cons

오히려 이런 규칙과 추상화 때문에 Nestjs를 선호하지 않는 사람들도 꾀나 많다는 것도 알게 되었어요. 다른 언어보다 JavaScript로 백엔드를 개발하는 큰 이유 중 하나가 빠른 개발과 배포인데, Nest 규칙을 따르다 보면 이 장점이 사라지기 때문이에요. Node.js와 Express로 웹 서버를 띄우려면 파일 하나에 6줄의 코드로 충분하지만, Nest는 파일이 4개나 필요해요. 그래서 그럴 바엔 Java를 쓰겠다는 의견도 많았어요. 또한 부실한 문서가 진입 장벽을 높이는 것도 사실이에요.

 

모두 타당한 의견이에요. 그래도 제가 Nest를 선택하게 된 이유는 더 쳬계적인 코드 스타일과 아키텍처의 필요성을 바닐라 환경에서 느꼈기 때문이에요. 그리고 여전히 ORM, 로거, 런타임 타입 체크 등의 Nodejs 라이브러리를 자유롭게 선택할 수 있기 때문에 개인적으로 Nest가 너무 많은 제약사항을 둔다고 느껴지진 않았어요.

디미고인 v4 백엔드도 Nest.js로 만들어졌어요. 이제 더 이상 리팩터링 하진 않겠죠? 

 

Nest Nutshell 😗

Nest가 어떻게 생겼는지 빠르게 살펴보아요. Nest는 모듈러 아키텍처이고, 각 모듈은 요청을 처리하는 컨트롤러(Controller), 로직을 담당하는 서비스를 중심으로 이루어져 있어요. 그리고 데코레이터로 필요한 메타데이터를 설정해요.

// paymentMethod.service.ts
@injectable()
export class PaymentMethodService {
  list() {
    // .. do something
  }
}
// paymentMethod.controller.ts
@Controller('payments/methods')
export class CatsController {
  constructor(
    private readonly paymentMethodService: PaymentMethodService
  ) {}

  @Get()
  @User()
  get() {
    return this.paymentMethodService.list();
  }
}

 

Mikro ORM

저는 Nest와 함께 Mikro ORM을 사용해요. JS 개발자라도 처음 들어보는 분이 많을 것 같은데, Mikro ORM은 Nodejs용 데이터 매퍼예요. 사용 방법은 TypeORM과 매우 비슷하지만, 다른 ORM들과의 차이점은 작업 단위(Unit of Work)와 식별자 맵(Identity Mapper) 패턴을 구현하고 공식 Nestjs 지원, Code-First와 Schema-Fist지원, 암시적 트랜잭션, result cache, 그리고 TsMorph를 이용하여 TypeScript를 더 효율적으로 사용할 수 있다는 점이 있어요. 또한 내부적으로 knex를 쿼리 빌더로 사용하기 때문에 type-safe 한 쿼리를 직접 만들어 사용할 수도 있어요.

 

비교적 최근에 만들어져 아직 커뮤니티가 작다고 인지도가 낮다는 점이 있지만, 꾸준한 업데이트와 빠른 깃헙 이슈 지원이 이루어지고, 실제 사용자들도 칭찬을 많이 하고 었어요 주목 해볼만한 ORM이에요.

@mikro-orm/core vs drizzle-orm vs knex vs prisma vs sequelize vs typeorm ❘ npm trends

 

Which ORM should be used? (polling) : r/node (reddit.com)

 

아직 비주류의 ORM을 선택했다는 걸 쉽게 이해하긴 어려울 것 같아요. 여러 ORM과 비교해서 제가 Mikro ORM을 선택하게 된 이유를 구체적으로 설명해 볼게요.

 

1. 새 요청 == 새 트랜잭션

보통 요청마다 새로운 트랜잭션을 시작할 거예요. Prisma와 Mikro ORM의 트랜잭션 처리 방법을 한번 비교해 볼게요. 예제는 결제 기록을 추가하고 사용한 결제 수단을 주 결제수단(main payment method)으로 바꾸는 코드예요.

 

Prisma는 $transaction API를 사용해서 대화형 트랜잭션을 구현해요.

function createTransaction({ status, totalPrice, user }: ApproveResult) {
  return prisma.$transaction(async (tx) => {
    // 1. 결제 기록 생성
    const transaction = await tx.transaction.create({
      data: {
      	status,
        totalPrice,
        user: { connect: { id: user } },
        // ... 
      },
    })
    
    // 2. 승인된 경우, 주 결제수단을 변경
    if(status === 'CONFIRMED') {
      await tx.user.update({
        data: {
          mainPaymentMethod: {
            connect: { id: paymentMethod }
          }
        }
      })
    }
  })
}

괜찮아 보이지만, 주 결제수단을 변경하는 코드를 분리하면 더 좋을 것 같아요. 음.. 그런데 tx를 어떻게 다른 함수로 공유할까요? 매개변수로 받는게 과연 최선일까요?

 

Mikro ORM과 Nest로는 어떻게 처리할 수 있는지 볼게요. Mikro ORM에는 prisma 클라이언트와 비슷한 개념인 엔티티 매니저가 있어요. 새로운 트랜잭션을 시작하는 방법 중 하나는 엔티티 매니저를 포크(fork)하고, 변경 사항을 만든 뒤 모든 변경 사항을 DB에 반영하는 flush() 메서드를 호출하는 거예요. 엔티티 매니저를 포크 하는 작업은 미들웨어에서 자동화할 수 있어요.

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
  constructor(private readonly orm: MikroORM) {}

  use(_req: Request, _res: Response, next: NextFunction) {
    RequestContext.create(this.orm.em, next)
  }
}

 

이제 서비스는 포크 된 엔티티 매니저를 주입(inject) 받아 트랜잭션에 관심을 가지지 않고 비즈니스 로직에만 집중할 수 있어요.

@Injectable()
export class TransactionService {
  constructor(private readonly orm: MikroORM) {}
  
  createTransaction({ status, totalPrice, user, paymetMethod }: ApproveResult) {
    this.orm.em.create(Transaction, {
      status,
      totalPrice,
      user
    })
    
    if(status === 'CONFIRMED') {
      this.changeMainPaymentMethod(user, paymentMethod)
    }
  }
  
  private changeMainPaymentMethod(user: User, paymentMethod: Paymentmethod) {
    user.mainPaymentMethod = paymentMethod
  }
}

 

 

사실 v1에서도 로직을 여러 함수로 나누어 처리하고 싶었지만, prisma의 이런 아쉬운 점이 리펙터링을 어렵게 만드는 원인 중 하나였어요. 근데 Prisma에선 이 문제를  아예 해결할 수 없을까요? 사실 관련된 논의가 이미 여러 번 있었고, 대안들도 다양하게 나왔어요.

저는 async local storage를 사용하는 걸 선호해요. async local storage는 Node.js에서 컨텍스트를 구현하는 일반적인 방법으로, 콜백 함수 안에서 모든 함수들이 어떤 값을 공유해서 사용할 수 있도록 만들어줘요. Mikro ORM의 RequestContext.create 함수도 async local storage를 사용해요. 

 

express-http-context@fastify/request-context를 사용하면 이를 Mikro ORM처럼 간단히 해결할 수 있어요. 하지만 이벤트 처리기나 배치 처리기 같이 http 요청 맥락이 아닌 경우에는 위 라이브러리와 별개로 컨텍스트 관리를 해줘야 해요. 반면 Mikro ORM은 요청뿐만 아니라 어디서든 새로운 컨텍스트를 만들 수 있는 @CreateRequestContext() 데코레이터가 있어 어디서든 트랜잭션을 쉽게 시작할 수 있어요.

@Injectable()
export class TransactionCompletedEventHandler {
  constructor(private readonly orm: MikroORM) {}
  
  @OnEvent(['transaction', TransactionStatus.CONFIRMED])
  @CreateRequestContext()
  async updateMainPaymentMethod(event: TransactionCompletedEvent) {
    event.user.mainPaymentMethod = ref(event.paymentMethod)
    
    try {
      await this.orm.em.flush()
    } catch(error) {
      // handle error
    }
  }
}

 

2.  Change Tracking

변경 추적(change tracking)은 정말 혁신적이라고 생각하는 기능 중 하나예요. 엔티티의 변경 사항을 저장하려면 보통 변경된 엔티티 인스턴스에서 save() 메서드를 불러요.

// typeorm
const user = new User()
user.firstName = "Javien"
user.lastName = "Lee"
await user.save()

// sequelize
const user = await User.create({ firstName: 'Javien' })
user.lastName = 'Lee'
await user.update({ name: 'Charlie' })
await user.save()

 

Mikro ORM은 flush() 한 번이면 충분해요. 모든 엔티티가 앤티티 매너저에서 작업 단위(unit of work)로 관리되기 때문에 변경 사항을 추적할 수 있어요. 

작업 단위(unit of wortk)는 데이터 베이스에 영향을 미치는 모든 작업을 보관하는 객체예요. 

 

const me = em.create(User, { firstName: 'Javien' })
const you = em.create(User, { firstName: 'Pim' })
user.lastName = 'Lee'
await em.flush()

덕분에 여러 엔티티가 여러 함수에 걸쳐 변경되어도 우아하게 처리할 수 있죠.

 

3. Code First

ORM을 사용할 땐 code first와 schema first 중 하나를 선택하게돼요. 전자는 코드로 테이블의 세부사항을 정의하는 방법이고, 후자는 SQL 등으로 스키마를 먼저 작성하고, 이 스키마를 바탕으로 코드에 엔티티 클래스를 만들어요. Prisma는 schema first만 지원해요. Prisma 스키마를 작성하면 CLI로 타입과 클라이언트 코드가 생성되고, 코드에서 사용할 수 있어요. CLI 덕분에 코드를 작성하지 않아도 될 것 같지만, prisma에는 엔티티라는 개념을 사용하지 않아서 DDD의 엔티티를 구현하려면 클래스 코드를 직접 작성해야 했어요.

 

반면 Mikro ORM은 typeorm처럼 엔티티 클래스로 스키마를 정의할 수 있어서 번거로움이 훨씬 적었죠.

@Entity({ repository: () => ProductRepository })
export class Product extends BaseEntity {
  [EntityRepositoryType]?: ProductRepository

  @Property({ columnType: 'text' })
  name!: string

  @Property({ columnType: 'text' })
  alias?: string

  @Index()
  @Property({ columnType: 'text' })
  barcode!: string

  @Property()
  sellingPrice!: number

  @Property()
  disabled: boolean & Opt = false


  @OneToMany({
    entity: () => ProductInOutLog,
    mappedBy: 'product',
  })
  productInOutLog = new Collection<ProductInOutLog>(this)

  @ManyToMany()
  category = new Collection<Category>(this)

  constructor(dto: EntityCreateProps<Product>) {
    super()
    Object.assign(this, dto)
  }
}

이렇게 Mikro ORM은 제가 v1에서 겪던 문제와 고민을 해결해 주었어요. 만약 간단한 CURD 작업만 요구한다면 Prisma를 선택할 것 같지만, 큰 규모의 프로젝트에서 분산된 로직을 트랜잭션으로 부드럽게 처리하는데는 Mikro ORM이 좋은 도구하고 생각해요.

 

Bun 

Bun은 2023년 9월에 1.0.0을 릴리즈한 새로운 자바스크립트 런타임이에요. Bun은 타입스크립트, 패키지 매니터, 내장 테스트 라이브러리 지원과 기존 런타임 대비 엄청난 속도 등으로 많은 관심을 받고 있어요. 프로젝트를 리팩터링 하는데 엄청 빠른 새로운 런타임? 어떻게 참을 수 있나요. 저는 이번에 Bun을 적극적으로 도입했어요.

 

Bun

 

그러나 v1.0.0 릴리즈 당시엔 Nestjs에서 꼭 필요한 타입스크립트 기능인 emitDecoratorMetadata가 아직 지원되지 않았어요.  다행히 v1.0.3에서 지원하기 시작하면서 Bun과 Nest.js를 공식적으로 함께 사용할 수 있게 되었어요! 😋

emitDecoratorMetadata는 데코레이터에서 데코레이터가 적용된 대상의 타입을 사용할 수 있게 해 줘요.

 

써보니 정말 빠르고 큰 문제 없어 잘 작동해서 지금까진 매우 만족 해요.

 


 

지금까지 디미페이 v2에서 새로 사용하는 개발 도구들을 소개해드렸어요. 프레임워크도 바꾸고 아키텍처도 도입하는 등 매우 실험적인 선택의 어떻게 보면 무모한 선택이 많았던 것 같아요. DDD와 여러 아키텍처를 이해하는데만 1년 걸렸고(아직 완벽히 이해하진 못했습니다), 타입스크립트로 구현하기 위해서 많은 오픈소스를 참고하고 갈아엎으면서 많이 노력했었어요.

다음 글에선 Nestjs 개발 과정에 대해 자세히 다뤄보려 해요. 읽어주셔서 정말 감사합니다 🥰