소감 & 회고록

[회고] 3rd 프로젝트: WECAR - c2c 차량 대여 플랫폼 제작

왈왈디 2023. 6. 8. 21:44
728x90

📍새로운 환경에서 시작된 프로젝트

  2개월 간의 학원 생활을 거쳐, 5월 한달 간 회사에서 인턴으로 근무하게 됐다. 처음 입사하고 첫 주에는 딱히 맡은 일이 없었다. 프론트엔드 동기와 함께 입사했는데, 우리 둘 다 현업에서 일하시는 개발자분들께 배울 수 있는 기회를 이대로 흘려보내고 싶지 않은 마음이었다. 마냥 기다리고 있기 보다는 직접 프로젝트를 기획해서 제안드리는 것으로 의견을 모았다. 

  우리는 학원에서 해보고 싶었으나 다른 주제에 밀려 못해본 프로젝트를 이 기회에 해보기로 했다. airbnb를 모델링하여 c2c 차량 대여 플랫폼을 만드는 것이었다. Notion에 producting을 담은 기획안과 sprintq별 계획을 준비해 사수님께 프로젝트를 제안드렸다. 

[WECAR 기획안]

처음 얘기를 꺼냈을 때 우려를 표하시던 사수님도, 기획안 발표를 들으시고는 프로젝트 진행을 허락해주셨다. 다만, 그 동안 학원에서는 웹사이트를 이용하는 user의 입장만 고려했다면, 이번에는 페이지를 관리하는 관리자의 입장도 고려하여 서비스를 만들어보라고 조언해주셨다. 생각해보면 어느 사이트나 관리자가 있기 마련일텐데, 학원에서 프로젝트를 할 때는 한 번도 생각해보지 못한 부분이었다. 평소 사용자의 입장으로만 서비스를 이용하다 보니 놓치게 된 부분이었던 것 같다. Front적인 면에서 관리자 페이지는 심미적인 부분이 크게 중요하지 않다는 조언도 주셨다. 

  그렇게 프론트엔드 1명, 백엔드 1명, 둘이서 약 3주 간의 프로젝트를 진행하게 되었다.

 

📍혼자서 백엔드 전체를

  혼자서 한 프로젝트의 백엔드 전체를 책임지는 일은 처음이었다. 물론 걱정스러운 마음도 있었지만, 처음엔 설렘과 기대가 컸다. 코드에 대한 욕심도 많고, 다양한 기능을 구현해보고 싶은 마음이 넘치는 새내기 개발자다 보니 맡은 기능이 적어서 아쉬운 것보다는 많아서 문제인 편이 차라리 더 좋았다.

  프로젝트를 마치고 돌이켜 봤을 때, 혼자서의 장점은 우선 매우 자유로웠다는 점이다. 여럿이 협업할 때에는 서로의 코드가 conflict 나지 않게 주의하고, 실수로 다른 사람이 작성한 코드를 삭제하거나 바꾸지 않게 조심해야했다. 혼자 하니 고치고 싶은 코드는 언제든지 내가 원할 때 고치고, 추가하고, 또 내 코드를 기다리는 사람이 없으니 막힐 때는 충분히 시간을 들여 고민할 수 있어 좋았다. 

또 다른 장점은, 내가 모든 코드를 파악하게 된다는 것이다. 여럿이서 서버를 만들 때는 내가 맡은 부분에만 집중하다 보면 전체적인 구조를 파악하지 못하곤 했다. 다른 사람이 사용한 라이브러리, 논리 구조, 외부 API, 통신 방식에 대해서도 이해하지 못하고 넘어갈 때가 많았다. 그럴 때 가장 아쉬웠는데, 혼자서 하니 그런 부분이 없어 좋았다. 특히, 지난 2차 프로젝트는 백엔드를 4명이서 진행하다 보니 그런 부분이 많았는데, 이번에 2차 프로젝트 때 내가 구현해보지 못한 기능들도 구현해볼 수 있어서 좋았다.

  이렇게 좋은 면도 컸지만, 사실 가장 아쉬운 부분은 지금 내가 잘 하고 있는건지, 이게 최선인건지 파악하기가 어려웠다는 것이다. ERD 설계부터 함께 의견을 나눌 백엔드 팀원이 한 명은 더 있으면 좋겠다는 생각이 들었다. 내가 짠 ERD가 남이 보기에도 괜찮아 보이는지, 내가 놓친 허점은 없는지 누군가와 함께 이야기하고 싶었다.

  코드를 짜면서도 정말 수많은 난관에 매일 부딪혔는데 그럴 때마다 함께 얘기하며 머리를 맞댈 사람이 있으면 좋겠다는 생각을 했다. 때론 서로 소리 내어 이야기를 하는 것만으로도 생각이 정리되고 해결 방안이 떠오를 때가 있는데, 그러지 못해 아쉬웠다.

그래도 다행이었던 것은 사수님들이 곁에 계셨다는 점이다. 아예 혼자서 했다면 난이도가 3배는 더 높지 않았을까.

  혼자서의 묘미가 있지만, 역시 더 좋은 서비스, 더 풍성한 서비스를 만들기 위해서는 함께 시너지를 낼 동료들이 필요하다는 것을 다시 한번 느끼는 경험이었다.

 

📍현업 개발자의 조언

  이번 프로젝트에서 무엇보다 값진 경험은 현업 개발자님의 피드백을 들을 수 있다는 점이었다. 사수님께서 매주 수요일마다 코드 리뷰를 진행하며 일주일 동안 작성한 코드를 모두 함께 보고 그에 대한 피드백을 주셨다. 학원에서는 배우지 못했던 부분들을 정말 많이 배웠다.

위에서 언급한 페이지 관리자를 고려하는 것 또한 그렇고, 특히 서버 개발자로서 보안 네트워크 효율에 대해 더 깊게 고민하는 계기가 됐다.

  보안이 중요하다는 것은 알고 있었지만, 당장 실천할 수 있는 보안을 높이는 방법이 아주 많았다는 걸 알게 됐다. Refresh Token을 처음 도입해봤으며, ip와 agent를 기록하며 로그인 히스토리를 남기는 것도 시도하게 되었다. 또, 쿼리문을 사용할 때 LIKE문 뒤에 해커가 query문을 입력해 데이터베이스를 삭제하는 등 조작할 수 있다는 것도 처음 알게 됐다. 뿐만 아니라, 사실 이전에 secret key 등을 환경변수로 가리지 않고 git hub에 올리는 실수를 종종하곤 했는데, 현업에서는 절대 있어선 안되는 실수라는 걸 배웠다. 

  안그래도 3차 프로젝트를 진행하던 중 RDS를 해킹당해 1,2차 프로젝트 데이터베이스가 해커에게 탈취당하는 경험을 했다. 백업 dump 파일도 모두 준비되어 있었고, 민감한 정보도 아니고 그저 연습용 프로젝트 데이터였기에 황당하다며 웃고 넘겼지만, 실제로 중요한 데이터였다면 아주 심각한 문제가 될 뻔했다. 이번 해킹 사건과 배움을 바탕으로 보안에 더 철저히 신경쓰는 백엔드 개발자가 되고 싶다.

[데이터를 되찾고 싶으면 비트코인을 내놓으라는 해커... 해킹 당하고 한참 뒤에 발견했다는 게 코메디...]

  네트워크 효율에 있어서도 그 동안 많이 간과하고 있었다는 생각이 들었다. 특히 이전에는 client에게 최대한 풍부하게 데이터를 보내주어 필요한 것이 생기면 꺼내서 쓰도록 했는데, 그러한 방식이 큰 resource 낭비라는 것을 알게 됐다. 또, 데이터베이스에 접속해 데이터를 불러오는 것도 resource를 많이 사용하는 일이라는 것을 깨닫고 더 효율적인 방식을 고안하려고 노력하게 되었다. 이제는 최소한으로 데이터베이스에 접속해 필요한 최소한의 정보만 client에게 응답하려고 한다. 

  그 외에도 Runtime 에러를 방지하기 위해 항상 노력해야 한다는 점도 크게 배웠다. Typeorm을 사용할 때 메서드의 인자를 string으로 입력하는 방식과 객체로 입력하는 방식이 있는데, string으로 작성하는 방식은 실행 전까지 오류를 감지할 수 없으니 객체로 사용하라고 조언해 주셨다.

  코드 작성 뿐만 아니라, 업무 방식, scrum 회의 방식 등에 대해서도 옆에서 보고 배우는 점이 많았다. 그 중 가장 센세이션 했던 방식은 프론트와 백이 통신할 때 서로의 코드를 git hub에서 클론 받아 각자의 local에서 실행시킨다는 점이었다. 여태까지 통신을 위해 서버를 열고 프론트 팀원에게 ip주소를 알려주고, 서버를 사용하는 중에는 코드 수정과 commit을 할 수 없는 불편함을 감수하며 살았는데, 수정된 코드를 push만 하면 팀원이 pull 받아 실행하면 된다니 너무나 간편하고 편리했다. 나 또한, 프론트의 코드로 페이지를 눈으로 보며 실행해보니 훨씬 더 편하고 효율적으로 개발을 할 수 있었다. 특히 샘플 데이터를 채워 넣을 때 그 동안은 데이터베이스에 명령어로 직접 넣었는데, 페이지를 사용해 데이터를 입력하니 아주 편했다.

  학원에서 얻지 못한 것들을 많이 얻을 수 있는 경험이었고, 앞으로 진짜 개발자가 되어 더 많은 현업의 문제들을 마주하며 새롭게 배워갈 날들이 기대 된다.

 

📍새로운 언어와 프레임워크

  이번 프로젝트가 더욱 특별했던 점은 학원의 도움 없이 처음 스스로 새로운 언와 프레임워크를 익혀 적용했다는 점이다. 기존에는 JavaScript와 Node.js, express를 사용했는데, 회사의 기술 스택에 맞추어 TypeScriptNestJS를 사용하게 됐다. 정말 어려움이 많았다. 특히나 첫 일주일은 회원가입과 로그인을 구현하는 데에만 일주일이 걸렸다. TypeScript의 type이라는 개념도 익숙하지 않았고, NestJS의 Module은 처음 접하는 개념이다 보니 Circular Dependency 때문에 얼마나 긴 시간을 고생한 건지 모르겠다. 또, 이전에는 TypeOrm의 메서드를 그다지 활용하지 않고 query문을 직접 작성했었는데, TypeOrm을 사용하는 것 또한 또다른 넘어야할 큰 산이었다. 초반 2주 동안은 매일 치열하게 머리를 싸매고 고생하며, 울고 싶은 날이 많았다. 그래도 매일을 그렇게 고민하다 보니 3주차 즈음에는 조금 익숙하게 사용할 수 있었다. 언어와 프레임워크를 익히는 가장 좋은 방법은 직접 스스로 부딪혀가며 사용해보는 것이라는 생각이 든다.

  짧은 시간이었지만 TypeScript와 NestJS를 사용해본 소감으로는 매우 강력하고 안정적인 도구라는 생각이 든다. Node.js와 express와 비교했을 때 편리한 기능들도 아주 많고, 런타임 에러가 훨씬 줄어들 수 밖에 없는 환경을 제공해준다. 더 큰 프로젝트를 TypeScript와 NestJS로 진행해보고 싶은 마음이 든다.

 

📍핵심 트러블 슈팅

1. 예약 날짜 처리

  프로젝트를 진행하며 가장 시간이 오래 걸리고 머리가 아팠던 부분은 바로 예약 날짜와 관련된 부분들이다. 여러가지 어려움들이 많았는데, 첫 번째로는 기준 시간대가 일정하지 않았다는 점이다. client로부터 받은 date string을 데이터베이스에 저장하고, 데이터베이스에 저장된 값을 눈으로 확인했을 때는 분명 문제가 없었다. 그런데 client에게 저장된 정보를 전달하기 위해 데이터베이스에서 호출해 전달하면 시간이 9시간 전으로 바뀌어 날짜까지 바뀌어버리는 것이었다.

  MySql의 time zone을 확인했을 때 확실히 Asia/Seoul로 되어있었고, TypeOrmModule 설정에서도 timezone은 'Asia/Seoul'로 설정해줬다. MySql은 확실히 한국 표준 시간으로 설정되어 있는 것 같은데, 그럼 JavaScript 코드에서 표준 시간대를 한국으로 설정해줘야 하는건가 싶어 package.json의 scripts에서 npm run start를 실행할 때 적용되도록 해보기도 하고 했지만, 전혀 효과가 없었다.

사수님들까지 나서서 함께 알아봐주신 결과, 데이터베이스에서 날짜를 호출할 때에는 어쩔 수 없이 국제 표준 시간으로 시간이 조정된다는 것이었다. 문제의 원인을 파악하고 나니 상황이 훨씬 간단해졌다. 날짜를 호출할 때마다 9시간을 다시 더해주며 한국 표준 시간으로 조정해주었다. entity의 필드에 transform 데코레이터를 사용할 지, 사용할 때마다 직접 시간을 더해줄 지, client가 처리하도록 할 지 고민했지만 우선은 내가 직접 처리하는 것으로 했다. dayjs와 moment와 같은 날짜와 시간을 조작하는 라이브러리들을 추천해주셔서 추후 그 라이브러리들을 사용하는 방식으로 코드를 리팩토링 하고 싶다.

  굉장히 애를 먹었던 또 다른 부분은 예약 날짜를 기준으로 상품을 필터링 하는 것이었다. user가 원하는 시작 날짜와 종료 날짜를 검색하면 그 기간으로 예약이 가능한 상품이 필터링 돼야 한다. 각 차량 상품에는 호스트가 설정한 예약 가능 시작 날짜와 종료 날짜가 있기에 차량의 예약 가능 기간과 검색된 기간을 비교하는 건 상대적으로 간단했다.

//src/cars/cars.service.ts
//차량 예약 가능 날짜 필터 추가

 if (filter.startDate && filter.endDate) {
   query
    .andWhere(
      'DATE_FORMAT(hostCar.startDate, "%Y-%m-%d") <= :startDate AND DATE_FORMAT(hostCar.endDate, "%Y-%m-%d") >= :startDate',
      { startDate: `${filter.startDate}` },
    )
    .andWhere(
      'DATE_FORMAT(hostCar.endDate, "%Y-%m-%d") >= :endDate AND DATE_FORMAT(hostCar.startDate, "%Y-%m-%d") <= :endDate',
      { endDate: `${filter.endDate}` },
    )
 }

 

  문제는 각 차량에는 이미 등록되어 있는 예약들이 있다는 것이다. user가 검색한 날짜와 하루라도 날짜가 겹치는 예약이 있다면 그 상품은 결과에 포함되지 말아야 한다. 사실 검색 로직을 명확히 이해하는 데에도 시간이 꽤 걸렸다는 사실.. 다이어그램을 그려가며 검색 로직을 열심히 세워봤다.

[경우의 수를 생각해보기 위한 다이어그램]

query문으로 원하는 결과를 호출하기 위해 여러 시도를 해봤지만, 계속해서 검색한 날짜와 날짜가 겹치지 않는 예약이 하나라도 있으면 해당 상품이 결과에 포함되는 방향으로 밖에 쿼리를 작성하지 못했다. 각 상품에 등록된 예약들의 날짜를 하나씩 확인해 상품을 걸러내는 방식을 생각했기에 query문은 지나치게 복잡해지고, 상품별로 필터링된 데이터를 호출한 후 service단에서 배열에 filter를 적용해 조건에 맞는 상품만 남기는 방식이 더 적합할 것이라 생각했다. 그렇게 작성한 코드가 아래와 같다.

 //src/cars/cars.service.ts
 //데이터 호출 후 filter로 차량에 등록된 예약 날짜 로직 처리 (한국 표준 시간 적용)

    if (filter.startDate && filter.endDate) {
    query
      .andWhere(
        'DATE_FORMAT(hostCar.startDate, "%Y-%m-%d") <= :startDate 
         AND DATE_FORMAT(hostCar.endDate, "%Y-%m-%d") >= :startDate',
        { startDate: `${filter.startDate}` },
      )
      .andWhere(
        'DATE_FORMAT(hostCar.endDate, "%Y-%m-%d") >= :endDate 
         AND DATE_FORMAT(hostCar.startDate, "%Y-%m-%d") <= :endDate',
          { endDate: `${filter.endDate}` },
        );
    }

    let filteredCars = await query.getMany();

    if (filter.startDate && filter.endDate) {
      filteredCars = filteredCars.filter((car) => {
        let result = true;
        car.bookings.forEach((booking) => {
          const bookingStartDate = new Date(booking.startDate);
          const bookingEndDate = new Date(booking.endDate);

          const correctedBookingStartDate = new Date(
            bookingStartDate.getTime() + 24 * 60 * 60 * 1000,
          );
          const correctedBookingEndDate = new Date(
            bookingEndDate.getTime() + 24 * 60 * 60 * 1000,
          );
          const filterStartDate = new Date(filter.startDate);
          const filterEndDate = new Date(filter.endDate);

          result =
            result &&
            (correctedBookingEndDate < filterStartDate ||
              correctedBookingStartDate > filterEndDate);
          return result;
        });
        return result;
      });
    }
    return Promise.all(filteredCars);
  }

  이렇게 작성한 코드로 원하는 데이터가 필터링 되었지만, 치명적인 문제가 있음을 발견했다. 바로 pagenation이 적용될 수 없다는 점이었다. 어떻게 해야하나 막막했지만 사수님이 절대 불가능한 조건은 아니니, 충분히 query문으로 작성할 수 있을 거라고 말씀해주셨다. 가능하다는 걸 알게 되니, 될 지 안될지 모르고 할 때보다 훨씬 스트레스가 덜 했다. 결국 완성한 query문은 아래와 같다.

   //src/cars/cars.service.ts
   //쿼리문으로 작성한 예약 날짜 필터 로직
   
   if (filter.startDate && filter.endDate) {
     query
       .andWhere(
         'DATE_FORMAT(hostCar.startDate, "%Y-%m-%d") <= :startDate AND DATE_FORMAT(hostCar.endDate, "%Y-%m-%d") >= :startDate',
         { startDate: `${filter.startDate}` },
       )
       .andWhere(
         'DATE_FORMAT(hostCar.endDate, "%Y-%m-%d") >= :endDate AND DATE_FORMAT(hostCar.startDate, "%Y-%m-%d") <= :endDate',
         { endDate: `${filter.endDate}` },
       )
       .leftJoin(
         (subQuery) =>
           subQuery
             .select('*')
             .from('bookings', 'booking')
             .where('!(start_date > :endDate or end_date < :startDate)', {
               startDate: filter.startDate,
               endDate: filter.endDate,
             }),
         'booking_query',
         'hostCar.id = booking_query.hostCarId',
       )
       .having('count(booking_query.id) < 1');
   }

  문제 해결의 핵심은 sub-query와 having을 함께 사용할 수 있다는 것, 조건의 역(!)을 활용하면 더욱 간단하다는 점이었다. TypeOrm을 떠나서 수도코드로 로직을 먼저 정리하고 그것을 어떻게든 쿼리문으로 작성하려 했다면 더 빨리 해결할 수 있었을 것 같다. 앞으로 데이터베이스 호출 로직에서 막힐 때는 먼저 수도 코드를 작성하고 그 후 직접 query문을 작성하여 동작하는 것을 확인하고, TypeOrm 메서드에 적용할 방법을 찾는 순으로 진행해야 겠다는 생각이 들었다.

 

2. Toss 결제 API 승인 요청과 취소 요청

  외부 API 사용은 처음이고, 다른 서버에 요청을 보내본 것도 처음이라 새로웠다. 처음 toss 결제 API를 사용할 때는 client에서 모든 결제 요청을 처리하고 서버에 저장할 데이터만 전송해준다고 생각해 부담이 없었다. 그러나 다시 확인해보니 client가 1차 결제 요청을 한 뒤, 서버에서 secretkey를 사용해 2차 승인 요청까지 완료한 후에 최종적으로 결제가 완료되는 것이었다.

  toss에 승인 요청을 보내고 응답을 받는 과정에서 단순히 그 동작만 하지 않고, 동시에 우리 데이터베이스의 예약과 결제의 상태를 업데이트하고 toss로부터 응답 받은 데이터를 저장하는 절차가 필요했다. 그런데 그 중 하나라도 에러가 난다면 다 함께 동작이 취소되어야 했기에, transaction 처리를 해줘야 했다. 처음에는 다른 서버에 요청을 보내면 그 요청을 다시 rollback할 수는 없다고 생각해, toss에 보내는 요청 과정에서 에러가 나는 상황까지만 transaction에 포함하고, 그 후 응답 데이터를 저장하는 과정을 포함하지 않았다. 아래가 그 코드다.

//src/payments/payments.service.ts
 
async completeTossPayment(tossKey: TossKeyDto) {
   let response;
   let payment;
   await this.entityManager.transaction(async (entityManager) => {
     payment = await this.paymentRepository.findOneBy({
       booking: { uuid: tossKey.orderId },
     });

     if (!payment) throw new NotFoundException('Create Payment First');
 
     const paymentStatus = await this.getPaymentStatus('SUCCESS');
     await entityManager.update(
       Payment,
       { booking: { uuid: tossKey.orderId } },
       { id: payment.id, status: paymentStatus },
     );
 
     const bookingStatus = await this.bookingsService.getBookingStatus(
       'BOOKED',
     );
 
     await entityManager.update(
       Booking,
       { uuid: tossKey.orderId },
       { uuid: tossKey.orderId, status: bookingStatus },
     );

     const encodedKey = Buffer.from(
       `${this.config.get('TOSS_KEY')}:`,
     ).toString('base64');

     const options = {
       method: 'POST',
       url: `${this.config.get('TOSS_URL')}`,
       headers: {
         'Content-Type': 'application/json',
         Authorization: `Basic ${encodedKey}`,
       },
       data: {
         paymentKey: tossKey.paymentKey,
         amount: tossKey.amount,
         orderId: tossKey.orderId,
       },
     };
     try {
       response = await firstValueFrom(this.httpService.request(options));
     } catch (error) {
       console.error(error);
       throw new ServiceUnavailableException('Toss Connection Error');
     }
   });

   if (!response.data)
     throw new ServiceUnavailableException('Toss Info Response Error');

   const tossInfoEntry = this.tossInfoRepository.create({
     status: response.data.status,
     currency: response.data.currency,
     requestedAt: response.data.requestedAt,
     approvedAt: response.data.approvedAt,
     totalAmount: response.data.totalAmount,
     vat: response.data.vat,
     method: response.data.method,
     payment: payment,
   });

   return this.tossInfoRepository.save(tossInfoEntry);
 }
}

  그러나 이렇게 될 경우 다시 승인 요청을 보내면 toss 측에 중복으로 승인 요청을 보내게 되는 것이므로 아무래도 이상했다. toss 결제 API 사이트를 살펴보다 승인 취소 요청을 보낼 수 있다는 것을 발견했다. 그렇게 요청을 보낸 후 에러가 발생한다면 승인 취소 요청을 보내도록 코드를 수정할 수 있었다.

//src/payments/payments.service.ts

async completeTossPayment(tossKey: TossKeyDto) {
  await this.entityManager.transaction(async (entityManager) => {
    const payment = await this.paymentRepository.findOneBy({
      booking: { uuid: tossKey.orderId },
    });

    if (!payment) throw new NotFoundException('Create Payment First');

    const paymentStatus = await this.getPaymentStatus('SUCCESS');
    await entityManager.update(
      Payment,
      { booking: { uuid: tossKey.orderId } },
      { id: payment.id, status: paymentStatus },
    );

    const bookingStatus = await this.bookingsService.getBookingStatus(
      'BOOKED',
    );

    await entityManager.update(
      Booking,
      { uuid: tossKey.orderId },
      { uuid: tossKey.orderId, status: bookingStatus },
    );

    const encodedKey = Buffer.from(
      `${this.config.get('TOSS_KEY')}:`,
    ).toString('base64');

    const options = {
      method: 'POST',
      url: `${this.config.get('TOSS_URL')}`,
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Basic ${encodedKey}`,
      },
      data: {
        paymentKey: tossKey.paymentKey,
        amount: tossKey.amount,
        orderId: tossKey.orderId,
      },
    };

    const response = await lastValueFrom(this.httpService.request(options));

    if (!response.data) {
      throw new ServiceUnavailableException('Toss Info Connection Error');
    }

    try {
      const tossInfoEntry = entityManager.create(TossInfo, {
        status: response.data.status,
        currency: response.data.currency,
        requestedAt: response.data.requestedAt,
        approvedAt: response.data.approvedAt,
        totalAmount: response.data.totalAmount,
        vat: response.data.vat,
        method: response.data.method,
        payment: payment,
      });

      return entityManager.save(TossInfo, tossInfoEntry);
    } catch (err) {
      const options = {
        method: 'POST',
        url: `${this.config.get('TOSS_CANCEL_URL')}/${
          tossKey.paymentKey
        }/cancel`,
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Basic ${encodedKey}`,
        },
        data: {
          cancelReason: '서버 에러',
        },
      };

      await lastValueFrom(this.httpService.request(options));
      throw new InternalServerErrorException('DataBase Error');
    }
  });
}

 

📍 그 외 트러블 슈팅

사실 이번 프로젝트는 처음 사용하는 언어와 프레임워크다 보니 크고 작은 트러블 슈팅이 정말 많았다. 위의 핵심 트러블 슈팅만큼 뿌듯하진 않아도 기억에 남는 것들을 적어보자.

 

1. AuthGuard의 미흡한 사용

 

  이번 프로젝트에서 access token과 refresh token을 모두 활용하고 싶었다. 프론트에서 token refresh 기능 구현에 어려움을 겪어서 access token만으로 사용자를 인증하며 서비스를 개발해나가고 있었다. 그 때는 문제를 파악하지 못했었는데, 다른 부분들을 다 개발하고 프론트에서 token refresh 기능을 구현하려고 할 때, access token이 만료됐는지 여부를 알아야 하나 만료 시와 토큰이 잘못 됐을 때의 error message가 동일해서 구별할 수 없다는 것이다. 그때부터 어떻게 하면 상황에 따라 적절한 error message를 보낼 수 있을 지 찾아봤다. 

  NestJS의 AuthGuard는 AuthGuard에서 PassportStrategy를 호출해 사용하는 구조로, 기존에 error처리를 strategy에만 해놨다는 사실을 깨닫고 AuthGuard 쪽에도 에러 처리를 해줬다. 그런데도 client가 받는 메시지에는 변화가 없었다. 알고보니 나는 Custom AuthGuard를 만들어 두고도 사용하지 않고 있었던 것이다. 아래와 같이 UseGuards 데코레이터기본 AuthGuard와 내가 만든 strategy만 적용한 모습이다.

  //src/auth/auth.controller.ts
  
  import { AuthGuard } from '@nestjs/passport';
  
  @Get('check/user')
  @UseGuards(AuthGuard('jwt-user')) //만들어둔 UserAuthGuard를 사용하지 않음
  isAuthenticatedUser(@User() user: ReqUser): Payload {
    return { id: user.id, name: user.name };
  }

사실 access token 인증을 구현했을 때도 AuthGuard의 구조를 확실히 이해하지 못하고 다른 이들의 코드를 조합해 어떻게든 구현했다고 생각했는데, 역시나 제대로 이해하지 못하고 작성한 코드는 후에 에러를 수정할 때 더 큰 고생을 하는 것 같다.

  이후 내가 만든 AuthGuard를 적용해줌으로써 문제를 해결했다.

//src/auth/security/user-auth.guard.ts
//상황에 따라 다른 error 메시지를 반환하도록 한 UserAuthGaurd

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard as NestAuthGuard } from '@nestjs/passport';

@Injectable()
export class UserAuthGuard extends NestAuthGuard('jwt-user') {
  canActivate(context: ExecutionContext) {
    return super.canActivate(context);
  }

  handleRequest(err: any, user: any, Info: any) {
    if (err || !user) {
      throw err || new UnauthorizedException(Info);
    }
    return user;
  }
}
  //src/auth/auth.controller.ts
  
  import { UserAuthGuard } from './security';
  
  @Get('check/user')
  @UseGuards(UserAuthGuard) //내가 만든 UserAuthGuard 적용
  isAuthenticatedUser(@User() user: ReqUser): Payload {
    return { id: user.id, name: user.name };
  }

 

2. Circular Dependency

 

  지옥 같았던 circular dependency. 순환 참조 오류가 발생하면 무서운 빨간 글씨로 module 전체에 문제가 있다는 식의 에러를 뱉기 때문에 심리적인 압박감도 상당하다. 처음 이 에러를 겪고 해결책을 검색했을 때 모두가 forwardRef(()=>)를 사용하라고 했으나 내 코드는 forwardRef를 사용해서 에러가 해결되지 않아서 매우 당황했다. 왜 나만 안되는 걸까 한참을 고미했는데, 다른 module에서 사용할 service를 exports해주지 않은 것이 원인이었다. 다른 블로그와 stackoverflow 글에서 왜 exports의 중요성을 언급해주지 않은 건지 조금은 원망스러웠지만, 초반에 circulr dependency를 겪고도 후반에 exports를 해야한다는 걸 잊고 같은 실수를 반복했던 날 보며, 나부터가 그 중요성을 제대로 기억하고 있어야 할 것 같다고 생각했다.

 

3. AWS S3 signed url

 

  이 부분은 단순히 내 코드의 문제는 아니었지만, 개발에 있어 중요한 부분이었던 것 같아 기록한다. 호스트 유저가 차량을 등록하는 과정에서 업로드하는 사진 파일을 저장하기 위해 AWS의 S3를 사용하기로 했다. S3를 사용해 파일을 저장하는 방식에는 여러가지가 있는데 우리는 네트워크 효율을 고려하여 서버에서 signed url을 생성해 전달하고 client가 파일을 업로드하는 방식을 택했다. 그렇기에 나의 역할은 signed url을 생성하는 것이었는데, 내가 생성해준 signed url로 client가 아무리 시도해도 파일 업로드가 되지 않았다. 도대체 이 url의 어디가 문제일지 하루 종일 코드를 뜯어보며 고민했는데, 알고보니 signed url은 원인이 아니었다.

  client가 모든 요청의 header에 user의 access token을 넣는 interceptor를 사용했는데, 그것이 외부 서버에 보내는 요청에도 적용되어 인증되지 않은 token을 가진 요청이라 거부된 것이었다. 서버와 client가 연쇄적으로 동작하는 기능이었기에, 문제의 원인을 파악하는 데에도 한참이 걸렸다. 내가 만약 client의 코드를 더 잘 파악하고 있었다면 문제를 더 빨리 파악할 수 있었을 것 같다는 생각이 들었다. 물론 내가 맡은 부분을 잘 하는 것도 중요하지만, 프론트엔드 코드를 어느정도 이해하고 있으면 전체적인 개발 진행에 큰 도움이 될 것 같다. 백엔드 언어로서 JavaScript의 강점이 프론트엔드도 같은 언어를 사용한다는 점이니, 그 점을 활용해 기회가 된다면 프론트엔드 개발도 어느정도 공부해보고 싶다.


여태까지 중 난이도 최상이었지만 새로운 도전을 좋아하는 나로서는 특히 더 즐겁고 만족스러운 프로젝트였다. 앞으로는 더 크고 안정적인 서비스들을 만들어나가는 개발자가 되고 싶다.

 

프로젝트 저장소 링크: https://github.com/walwald/WECAR

 

GitHub - walwald/WECAR: 팀 프로젝트: c2c 차량 대여 플랫폼 웹사이트 제작

팀 프로젝트: c2c 차량 대여 플랫폼 웹사이트 제작. Contribute to walwald/WECAR development by creating an account on GitHub.

github.com

 

728x90