haileyjpark

[JavaScript] JS의 비동기 처리 코드 작성 방식 본문

JavaScript

[JavaScript] JS의 비동기 처리 코드 작성 방식

개발하는 헤일리 2022. 7. 24. 17:44
반응형

과제 프로젝트를 진행하면서, callback과 promise의 개념은 한번씩만 읽어 보고 계속 async/await 를 사용해왔다. async 함수에서는 await를 걸어 ‘기다려!’라고 표시해주어서 작업이 끝나면 그것을 반환해주고 다음 작업을 실행하는 것으로, 비동기 코드를 동기처럼 보이게 하는 것이라고 이해하고 사용했었다.

 

비동기와 callback, promist, async/await를 명확하게 이해하지 못한 채로 사용하다보니, 정말 너무 바보같은 의문에 빠지게 되었다. 너무 바보같은 의문이라서 부끄러웠기 때문에 다른 주제로 글을 쓰고 싶었지만 (이틀 동안 자괴감의 늪에 빠져 헤어나오지 못했다는 정현 인턴의 슬픈 전설...) 그래도 이번에 비동기를 좀 더 이해하게 된 것 같아서 공유해보려고 한다!

 

일단, 아래의 세 요청이 동시에 왔을 때 각각의 요청을 비동기로 처리해야 하는 것은 효율상 당연해서 따로 이해를 할 필요가 없었다.

  • 유저 정보를 조회하는 요청
  • 도서를 대출하는 요청
  • 도서 목록을 조회하는 요청

 

자바스크립트는 기본적으로 싱글 스레드에 동기식 언어이기 때문에 이 요청들을 비동기로 처리해주어야 한다. 비동기 처리를 하지 않게 되면, 요청들이 계속 콜스택에 쌓여 작업 소요시간이 계속 딜레이된다.

  • 비동기 처리를 하게되면,
    • 비동기 작업들은 콜스택에서 Web API로 이동하게 되고,
    • Web API에서 비동기 함수의 이벤트가 발생하면 해당 함수는 Callback Queue로 이동한다.
    • 이벤트 루프는 콜스택이 비어있는지 확인해서 콜스택이 비어있으면 Callback Queue에 있는 함수를 콜스택에 넘겨준다.
    • 실행이 완료된 함수는 콜스택에서 사라진다.

 

자바스크립트의 비동기 처리 구조

https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf

 

바보같은 의문


유저 정보 조회 / 도서 대출 / 도서 목록 조회 각각의 요청들 외에, 도서를 대출하는 하나의 서비스 함수는 함수 내의 모든 과정이 순차적으로 진행되는데 왜 비동기 함수지? 그 함수를 동기 함수로 만들면 안되나?

도서 대출 함수를 동기 함수로 만들면 콜백 큐로 안넘어 가고 콜스택으로 쌓이니까 유저 정보 조회같은 다른 요청들이랑 비동기로 처리해주기 위해서 비동기 함수로 만들어줘야 하는건가?


  • 순차적으로 진행되는 대출 로직
const createRentals = async (rentalData) => {
  const userRental = await {'유저의 대출 데이터를 받아오는 함수'};
  if ('유저의 대출 데이터가 5개보다 많거나 같으면') {
    return '대출 가능 권수를 초과하여 대출 불가능.';
  }
  // 연체 체크 모듈
  const isValid = await {'고객의 연체 여부를 체크하는 함수'};
  if ('연체 되었다면') {
    return '연체로 인해 대출 불가능.';
  }
	// 아래는 대출 데이터를 한꺼번에 생성하는 로직
  const bookRentals = await Promise.all(rentalData.map(
    async (singleRental) => {
      const { bookId } = singleRental;
      const rental = await rentalRepository.getOne({ bookId });
      const book = await {'책의 id값으로 책이 대출중인지 확인하는 함수'};
      if (rental) {
        return `현재 대출중인 책이라서 대출 불가능`;
      }
      const createdSingleRental = rentalRepository.createRentalTransaction(singleRental);
      return createdSingleRental;

 

위의 로직처럼, 유저의 대출 데이터를 받아와야 연체를 확인하고, 책의 데이터를 받아와야 대출중인지 알 수 있는데, 그래서 순차적으로 이루어져야 하는데 이게 왜 비동기인 것인가....?

그래서 대출 서비스 로직과 레포지토리 로직에서 async/await를 다 지워봤더니, await가 걸려있던 함수들은 모두 Promise { <pending> } 이 떴다.

 

그리고 이 의문에 대해 동기분이 정성스레 정리해주신 것을 보다가,

sequlelize는 promise 기반으로 만들어졌으므로 기본적으로 비동기 함수이다 라고 적어주신 것을 보고 이해를 할 수 있었다.

 

promise 객체가 반환되는 함수는 await 키워드를 걸어주지 않으면 pending 상태가 된다. (작업의 순차를 보장해주지 않기 때문)

→ 동기 함수로 await 없이 작성하면 모든 DB 쿼리가 pending이 된다.

그래서 async/await을 사용하거나 promise 체이닝을 사용하거나, 콜백함수로 콜백지옥을 만들어줘야 한다.

 

그럼 Sequelize는 왜 promise를 반환할까? → DB에서 데이터를 받아오는 작업은 기본적으로 비동기로 진행되어야 한다.

(DB에 hit하는 요청이 정말 많을텐데, DB 쿼리를 동기식으로 실행하게 되면 병목 현상이 일어나서 서버를 늘리게 되고, 이는 비효율을 초래한다.)

 

내가 이런 비동기 도르마무 지옥에 빠지게 된 계기는, 두달 남짓 조금 배웠다고 익숙해진 파이썬 기반 프레임워크 Django의 ORM 때문이었다.

 

  • Django에서 진행했던 미니 프로젝트의 간단한 POST 로직들
class CoursesView(APIView):
    permission_classes = [MyPermission]
		@Authorize
    @transaction.atomic()
    def post(self,request):
        course = Course.objects.create(user = request.user, 
                                        course_status=CourseStatus.objects.get(status=CourseStatusEnum.PENDING.value))
        
        if not request.user.is_creator:
            request.user.is_creator = True
            request.user.save()
          
        stats = Stat.objects.all()    
        stat_names = [stat.name for stat in stats]
        for stat_name in stat_names:    
            CourseStat.objects.create(course=course, stat= Stat.objects.get(name=stat_name), score=0)
            
        return JsonResponse({"courseId": course.id}, status = 201)

class CourseView(View):
@Authorize
    @transaction.atomic()
    def post(self,request, course_id):
        try:
            course = Course.objects.get(user=request.user, id=course_id)
            urls = image_handler.upload_files(request.FILES.getlist('detail_image_url'))
            
            course.thumbnail_image_url = image_handler.upload_file(request.FILES.__getitem__('thumbnail_image_url'))
            course.save()
        
            Media.objects.bulk_create([Media(type = 'image', course = course, url = url) for url in urls])
            return JsonResponse({"message":"SUCCESS"},status=201)

        except KeyError:
            return JsonResponse({"message":"KEY_ERROR"},status=400)

 

 

위의 로직에서 볼 수 있는 것 처럼, Django는 기본적으로 동기 방식이다. (Django ORM도 동기 방식이다.)

그래서 Django를 사용하면 DB의 병목현상을 필히 마주하게 되는가? 해서 조금 찾아보았는데,

Django ORM을 비동기로 작동시킬 수 있는 방법이 있다고 한다!

 

from asgiref.sync import sync_to_async

results = await sync_to_async(Blog.objects.get, thread_sensitive=True)(pk=123)
  • 첫번째 방법은, 위와 같이 sync_to_async라는 모듈을 import해서 사용하면 된다고 한다. 
from asgiref.sync import sync_to_async

async_function = sync_to_async(sync_function, thread_sensitive=False)
async_function = sync_to_async(sensitive_sync_function, thread_sensitive=True)

@sync_to_async
def sync_function(...):
    ...
  • 함수로도 사용할 수 있고, 데코레이터로도 사용할 수 있다고 한다.
  • 두번째 방법은, 라이브러리를 통해 ASGI 앱으로 만들어서 사용하는 방법이다.
  • 세번째 방법은, 배포 시에 gunicorn을 사용하면, gunicorn을 통해서 여러 개의 프로세스 혹은 쓰레드를 구동할 수 있다. 그렇게 되면 동시에 여러 개의 스레드에서 Django가 실행되면서 많은 리퀘스트를 처리하게 된다.

 


그래서 왜 async/await 인지?

  • async/await 도서 대출 데이터 생성 코드
const rentalService = async (rentalData) => {
  const { userId } = rentalData[0];
  const userRental = await rentalRepository.getRentals({ userId });
  if (userRental.length >= 5) {
    return '이 고객은 현재 대출 가능 권수를 초과하여 대출이 불가능합니다.';
  }
  // 연체 체크 모듈
  const isValid = await checkOverdue(userId);
  if (!isValid.status) {
    return '이 고객은 연체로 인해 현재 대출이 불가능합니다.';
  }
  // 대출 데이터 대량 생성
  const bookRentals = await Promise.all(rentalData.map(
    async (singleRental) => {
      const { bookId } = singleRental;
      const rental = await rentalRepository.getOne({ bookId });
      const book = await bookRepository.getById(bookId);
      if (rental) {
        return `< ${book.BookInfo.title} > 은 현재 대출중입니다.`;
      }
      const createdSingleRental = rentalRepository.createRentalTransaction(singleRental);
      return createdSingleRental;
    },
  ));
  return bookRentals;
};
  • promise 도서 대출 데이터 생성 코드
const rentalService = (rentalData) => {
    const { userId } = rentalData[0];
    const rentalPromise = () => {
        const userRental = rentalRepository.getRentals({ userId });
        userRental.then((userRental)=> {
            if (userRental.length >= 5) {
                return '이 고객은 현재 대출 가능 권수를 초과하여 대출이 불가능합니다.';
            }
        }).catch((err)=>{console.log(err);})
        const isValid = checkOverdue(userId);
        isValid.then((isValid)=> {
            if (!isValid.status) {
                return '이 고객은 연체로 인해 현재 대출이 불가능합니다.';
            }
        }).catch((err)=>{console.log(err);})
    }
    rentalPromise.then(()=> {
        const bookRentals = Promise.all(rentalData.map(
            (singleRental) => {
                const { bookId } = singleRental;
                const rentalCheck = () => {
                const rental = rentalRepository.getOne({ bookId });  
                const rentalState = rental.then((rental)=> {
                    return rental;
                }).catch((err)=>{console.log(err);})
                const book = bookRepository.getById(bookId);
                book.then(()=> {
                    if (rentalState) {
                        return `< ${book.BookInfo.title} > 은 현재 대출중입니다.`;
                    }
                    }).catch((err)=>{console.log(err);})
                };
            rentalCheck.then(()=> {
                const createdSingleRental = rentalRepository.createRentalTransaction(singleRental);
                createdSingleRental.then((value)=> {
                    return value;
                    }).catch((err)=>{console.log(err);})
                }).catch((err)=>{console.log(err);})
              
                return bookRentals;
            },
          ));
    })
};

 

  • async/await로 작성한 코드를 promise 체이닝으로 바꿔보았다. 코드가 깔끔하지 못하고, then이 끝날 때마다 catch가 반복된다.
    • async/await 방식의 코드는 하나의 catch로 try문 안의 모든 에러에 접근할 수 있다.
    • async/await로 작성한 코드는, 코드의 흐름을 이해하기가 쉽다.

 

  • 하지만 async 밖에서는 순서를 보장해주지 않으므로, async 함수 밖의 로직과 함께 생각해야할 때는 주의해서 사용해야 함.

Callback

도서 대출 로직은 콜백함수로 변경해보지 않았지만, 콜백지옥이 형성될 것을 알 수 있다.

콜백 함수는 콜백 지옥 외에도 믿음성과 관련하여 다음과 같은 문제점들을 가지고 있다.

  • 너무 일찍 콜백을 호출
  • 너무 늦게 콜백을 호출
  • 너무 적게 또는 너무 많이 콜백을 호출
  • 필요한 환경/인자를 정상적으로 콜백에 전달 못함
  • 발생 가능한 에러/예외를 무시함
//딥다이브의 예제 코드
try {
  setTimeout(() => { throw new Error('Error!'); }, 1000);
} catch (e) {
  console.log('에러를 캐치하지 못한다..');
  console.log(e);
}

위 코드에서 setTimeout 함수의 에러는 catch문에서 캐치되지 않는다. setTimeout이 테스크 큐로 이동해서 호출을 기다리는 동안 try/catch문을 실행하는 함수는 이미 콜스택에서 제거되었기 때문에, 해당 함수가 제거된 뒤에 실행되는 setTimeout 함수의 에러는 catch문에서 캐치할 수 없는 것이다.

 

이런 방법을 보완하기 위해 콜백함수를 사용할 때는 오류 우선 콜백패턴을 필수적으로 사용해야 한다. 오류 우선 콜백 패턴을 사용하게 되면 함수 안에 비즈니스 로직과는 관계없는, 콜백 함수의 문제를 보완하기 위한 코드들이 많아지게 된다. 이런 문제점을 방지하기 위해서는 promise, 또는 async/await 구문을 쓰는 것이 좋을 것 같다.

 

하지만 동기적 함수를 사용하게 될 경우에는 콜백을 사용하는 것도 좋을 것 같다. 동기 함수를 사용하게 되면 코드가 순차적으로 진행되므로 콜백지옥이 생길 일이 없다. 하지만 우리가 동기 함수를 사용하게 될 일도 많지는 않다...

 


Promise 병렬 처리

  • Promise.all 은 모든 프라미스가 처리될 때까지 기다렸다가 그 결괏값을 담은 배열을 반환하고, 주어진 프라미스 중 하나라도 실패하면 Promise.all은 전체가 reject된다.
  • Promise.allSettled는 fulfilled / rejected 의 상태와 함께 결괏값을 반환한다.
  • Promise.race는 가장 먼저 처리된 프라미스의 결과 또는 에러를 담은 프라미스 객체를 반환한다.
  • Promise.then + Promise.then + Promise.then 는 Promise.all이고, Promise.then.then.then.then은 await이다.
const bookRentals = await Promise.all(rentalData.map(
    async (singleRental) => {
      const { bookId } = singleRental;
      const rental = await rentalRepository.getOne({ bookId });
      const book = await bookRepository.getById(bookId);
      if (rental) {
        return `< ${book.BookInfo.title} > 은 현재 대출중입니다.`;
      }
      const createdSingleRental = rentalRepository.createRentalTransaction(singleRental);
      return createdSingleRental;
    },
  ));

현재 대출 관련 로직에서는, 유저 한 명이 도서 여러 권의 대출을 하는 것이기 때문에 한 건이 실패할 경우 전체가 실패하게 만들어야 한다고 생각해서 promise.all로 구현했다. (한 사람당 대출 권수가 10권으로 제한되어 있기 때문)

그런데 다량의 도서를 한번에 등록하는 로직의 경우에는, Promise.allSetteled를 사용해야할 것이다.

무엇을 사용하던, 실패한 건들을 retry하는 로직을 만들어야 할 것 같다.

retry!

  • stack overflow 유저의 retry 로직
const multiAsync = [
  retry(() => this.somethingA(), 3),
  retry(() => this.somethingB(), 3),
  retry(() => this.somethingC(), 3),
];
const [a, b, c] = await Promise.allSettled(multiAsync);

// or

const [a, b, c] =
    await Promise.allSettled([/* functions */].map(x => retry(x, 3));

 

위의 retry 로직을 작성한 유저는, common Promise retry 패턴이 프라미스 하나가 아니라 프라미스를 반환하는 함수 그 자체를 받기 때문에, Promise.all 또는 Promise.allSettled를 호출하기 전에 위의 코드와 같은 retry 함수로 각각의 개별 promise를 랩핑하는 것이 가장 실용적이라고 설명해주었다. (다른 프라미스들을 기다리지 않고 즉시 retry할 수 있음)

 

또한, 아래 이미지와 같이, 대출이 되지 않는 명확한 이유가 있어 예외 처리를 해주었을 경우에는 대출이 이루어지지 않았더라도 fulfilled로 처리되기 때문에 retry가 되지 않고, 원인을 알 수 없는 실패 건들만 retry가 될 것이다. retry의 시도 횟수에 대해서는 각 서비스마다 서버/DB에 부하가 큰 작업인지 작은 작업인지를 고려해야 한다.

 


마치며

사실 사용하는 언어의 문법도 잘 알아야하지만, 비동기를 잘 이해하려면 스레드/프로세스 등 운영체제도 잘 알아야하고, 이렇게 잘 알아야하고의 도르마무에 빠지게 되는데....

 

CPU를 비롯한 컴퓨터 하드웨어적인 부분의 가장 끝단과, 가장 빠르고 쉬운 부분부터 공부할 수 있는 방법인 작은 애플리케이션 만들어보기를 동시에 조금씩 진행하는 방법이 재미있게 공부할 수 있는 방법이라는 인터넷의 글을 언젠가 읽은 적이 있다. 미니 프로젝트를 하면서 궁금한 점이 하나둘씩 생기고, 컴퓨터를 공부하면서 또 하나둘씩 궁금한 점이 생기므로 양 극단에서부터 안쪽으로 점점 파고들어가면서 공부를 하는 방법이다.

 

이제 프로그래밍을 처음 접한 이후로 시간이 6개월 정도 흘렀다. 속도도 너무 느리고, 원하는 만큼의 내공이 쌓이지는 않았지만, 그래도 하나둘씩 알아가면서 즐거움을 느끼고 있다. 그게 중요한 것 같다!

아직 배워야 할 것이 너무나 많지만,(그냥 아직 아는 게 없는 것 같다..) 그래도 반 년 전에 if문과 for문을 배우고 HTML/CSS로 웹페이지 만들어보기 과제를 하던 것을 생각하면 앞으로 6개월 동안 얼마나 더 새로운 지식들이 쌓일지 기대가 된다. 지금 궁금한 점이 하나둘씩 생기고 있으니 시간이 생기는대로~~(잘 생기지 않지만)~~ CS 지식을 조금씩 첨가해봐야겠다.

 

 

참고자료