이게 대체 뭐냐고요? 저도 몰라요...

대체 왜 만드는 건가요

Calendar2notion을 만들면서 API 요청을 보내는 일이 굉장히 많습니다. 사실상 API 요청이 그 자체인 서비스이기도 하고요. 이 화실표 비동기 익명 꼬리 재귀 즉시 실행 함수 표현식 를 만들게 된 이유는 페이지네이션 된 API 요청을 받기 위함입니다.

API 들은 불친절(?)하게도 모든 데이터를 한 번에 주는 것이 아니라, 일정 개수(1000개 등)만 주기 때문에 어쩔 수 없이 구현해야 되는 기능이었습니다.

예전에는 그냥 while 문으로 퉁쳤었는데, 최근에 리펙토링을 하면서 조금 재미있게(?) 해결해보고 싶었습니다.

구현

과거 코드 (적용 X)

주의: 제가 예전에 작성한 코드인지라 조금 구닥다리 입니다.

// 자질구레한 try 문이나 API 호출은 생략했습니다. 그냥 this를 사용했다는 것만 알아두시면 됩니다.
let events = []
let eventList = await this.요청
events = [...eventList.data.items]

if (eventList.data.nextPageToken) {
    let nextPageToken = eventList.data.nextPageToken
    
    while (true) {
        await sleep(500)

        let eventList = await this.요청
        events = [...events, ...eventList.data.items]

        if (eventList.data.nextPageToken) {
            nextPageToken = eventList.data.nextPageToken
        } else {
            return events
        }
    }
} else {
    return events
}

이전에는 그냥 한 번 요청을 보내고 nextPageToken을 받아서 while 문을 돌리는 식으로 해결을 했었습니다.

현재 코드 (적용 O)

return await (async () => {
    const query = async (result = [], nextPageToken) => {
        const res = await this.요청
        return (res.data.nextPageToken ? await query([...result, ...res.data.items], res.data.nextPageToken) : result)
    }
    return await query()
})()

화살표 비동기 익명 꼬리 재귀 즉시 실행 함수 표현식 를 사용했더니 코드가 거의 반으로 줄어들었습니다. 심지어 가독성도 개인적으로 더 괜찮아 진것 같고요.

장점

불필요한 변수가 없다

화실표 비동기 익명 꼬리 재귀 즉시 실행 함수 표현식함수 가 아닌 즉시 실행 함수 표현식 (IIFE) 이니 만큼 그 자체가 하나의 식입니다. 따라서 따로 변수를 사용하지 않고 그 자체로 바로 내보낼 수 있어요.

과거 코드처럼 구현을 위해 events, eventList 와 같은 변수를 추가할 필요가 없다보니 여러모로 편한 부분이 있습니다.

코드가 짧다(?)

사실 이거는 화실표 익명 꼬리 재귀 즉시 실행 함수 표현식 보다는 그냥 예전에 제가 코드를 🐶처럼 작성한게 크긴 하지만, 그럼에도 코드가 확실이 짧아지고, 가독성이 비교적 상승하는 효과를 얻을 수 있습니다.

단점

코드 이해가 어려울 수 있다.

화실표 비동기 익명 꼬리 재귀 즉시 실행 함수 표현식화실표 함수, 비동기 함수, 익명함수, 재귀함수, 꼬리 재귀, 즉시 실행 함수 표현식 (IIFE) 라는 조금은 난이도 있는 내용이 6가지가 하나로 짬뽕🍛이 되어 있습니다. 심지어 타입스크립트까지 적용하고 나면 진짜 "이게 뭐지" 할 수도 있을 것 같아요.

// 타입스크립트가 적용된 버전
await (async () => {
  const query = async (result: calendar_v3.Schema$Event[] = [], nextPageToken?: string): Promise<calendar_v3.Schema$Event[]> => {
    const res: GaxiosResponse<calendar_v3.Schema$Events> = await this.요청()
    result = result.concat(res.data.items as calendar_v3.Schema$Event[])
    return (res.data.nextPageToken ? await query(result, res.data.nextPageToken) : result)
  }
  return await query()
})()

Node.js는 꼬리 재귀 최적화를 지원하지 않는다.

위 코드에서는 꼬리 재귀 최적화가 가능한 코드를 작성하였지만, 정작 런타입인 Node.js 에서는 꼬리 재귀 최적화를 지원하지 않습니다. 꼬리 재귀를 사용하는 이유인 스택 오버플로우 방지가 불가능합니다.

오해하면 안되는게, ECMAScript에서는 이미 ES2015에 꼬리 재귀 최적화에 대한 사양을 추가했습니다.

즉, Javascript의 사양으로는 꼬리 재귀가 가능해야 하나, 현재 Node.js 는 꼬리 재귀가 구현되지 않은 상태입니다. 현재는 Safari 에서만 제대로 된 꼬리 재귀를 사용할 수 있습니다.

관련 자료
https://v8.dev/blog/modern-javascript#proper-tail-calls
https://kangax.github.io/compat-table/es6/

여담

화실표 비동기 익명 꼬리 재귀 즉시 실행 함수 표현식 을 사용하지 않더라도 필요한 기능을 구현할 방법은 다양합니다.

이번에 이런 괴상한 함수를 쓴 이유도 이유가 있다기 보다는 리팩토링 을 하는 만큼 이전보다는 새로운 코드는 작성하자는 이유가 가장 컸습니다. "과거에는 이것밖에 못했지만, 지금은 이런 괴상한 것도 사용할 수 있다!" 라는 느낌인거죠.

자바스크립트의 재미있는 문법을 죄다 때려박은 만큼, 구현하는 과정도 재미있었습니다. 제가 운영진으로 있는 개발자 디스코드 서버인 세미콜론 에서 다른 분들과 이야기를 나누는것도 재미있었어요. (화실표 비동기 익명 꼬리 재귀 즉시 실행 함수 표현식 는 아니지만 저보다 더 괴상한 걸 만드시더라고요...)

혹시 관심 있으시면 들어오셔도 됩니다! 언제나 환영이에요 :) >>세미콜론 <<

암튼 Calendar2notion 동기화봇 의 리펙토링은 잘 진행되고 있습니다.

물론 다른 개인 프로젝트 x n + 동아리 프로젝트 + 생활기록부 마감 + 자기소개서 첨삭 + 기타 등등 으로 정신없이 바쁘고, 지금 리펙토링하는 동기화봇 뿐만 아니라 프론트엔드까지 갈아엎어야 할 것 같아서 아마 시간은 조금 많이 걸릴 것 같습니다.

그래도 기다려주시고, 이런 재미없는 글 보러 와주셔서 정말 감사합니다.

벌써 8월 11일 오전 3시네요. 여러분도 좋은 하루 보내시길 바랍니다!

(추가된) 여담

비동기 함수는 꼬리 재귀 최적화가 불가능하다(?)

아래 내용은 페이스북의 서재원님이 제 페이스북에서 알려주신 내용을 토대로 만들었습니다.

문서 원본

ECMA262 문서의 15.10.1 Static Semantics: IsInTailPosition 을 보면 7. If body is AsyncConciseBody, return false 라고 나와있습니다.

AsyncConciseBody 는 화살표 비동기 함수의 Body 입니다. 이 말은 즉, Async Arrow Function 은 꼬리재귀 최적화가 불가능하다는 이야기입니다. Node.js 가 꼬리재귀 최적화를 지원하지 않는 것과 별개에 애초에 꼬리재귀 최적화가 불가능하다는 것이 조금 슬프네요...

물론 제가 문서를 제대로 읽지 않아 잘못 이해 한 것일 수도 있습니다. ECMA262 문서를 본 적은 이번이 처음이며, 겨우 2시간 남짓 둘러본 것이 전부인지라 100% 확신을 하지는 못합니다. 혹시 관련해서 정확한 정보를 아신다면 댓글로 알려주세요! 😎

그래도 어그로 만땅을 위해 제목은 유지하도록 하겠습니다.

화살표 비동기 익명 꼬리 재귀 즉시 실행 함수 표현식이 아닌 다른 방식

아래도 페이스북의 서재원님이 제 페이스북에 남겨주신 내용을 수정하였습니다.

제가 이 글을 페이스북에 올렸더니 서재원님이 댓글로 다른 방법을 제시해주셨습니다.

let result = [];
let nextPageToken;

do {
  const res = await this.요청;
  result = result.concat(res.data.items);
} while (res.data.nextPageToken)

do ... while 문을 사용하셔서 구현을 해주셨습니다. 재귀함수가 아닌 반복문을 사용한 형태 중에서는 굉장히 깔끔한 형태라고 생각해서 가져왔습니다.

재귀함수를 사용하는 것보다 비교적 가독성이 괜찮은지라 이 방법을 사용하시는 것도 좋은 것 같습니다. 특히 반복문이다보니 스택 오버플로우를 걱정하지 않아도 된다는 장점이 있네요.

async function* requests() {
  do {
    const res = await this.요청;
    yield res;
  
  } while (res.data.nextPageToken)
}

let result = [];

for await(const { data: { items } } of requests()) {
  result = result.concat(items);
}

이 외에도 위처럼 제너레이터 함수를 이용한 방법을 제시해주시기도 했지만, 아쉽게도 위 방법은 화살표 함수가 아닌지라 탈락입니다. 외부의 this 를 받아서 사용해야 하지만 화살표 함수가 아닌 경우 별도의 this 를 가지기 때문에 그렇습니다. (사실 bind() 를 사용하거나 다른 방법도 있긴 하지만 그건 스킵하겠습니다. 타입 지정하기 귀찮아요...)

return await와 return의 차이

서재원님이 제 페이스북에 댓글을 남기시면서 아래 질문도 남기셨습니다.

댓글에서 return await func()return func() 가 차이가 없다고 알려주셨습니다. 저도 이게 정말 차이가 없는 건지 찾아봤는데, try ... catch 문에서 사소한 차이가 있습니다.

// Case 1
try {
	return await throwError()
} catch (err) {
	catchFunc() // 실행됨
}

// Case 2
try {
	return throwError()
} catch (err) {
	catchFunc() // 실행 안됨!
}

Case 1 에서 throwError() 함수에서 throw가 된 경우 catchFunc() 가 동작하지만 Case 2 에서는 catchFunc()가 동작하지 않습니다.

자세한 내용은 https://ooeunz.tistory.com/47 이 블로그 글을 참고하시면 좋을 것 같습니다.

여담의 여담

어쩌다 보니 본문보다 여담이 더 길어졌습니다. 사실 (추가된) 여담은 원래 작성할 계획이 아니었는데 페이스북에서 좋은 반응과 댓글이 있어서 틀린 내용도 보충할 겸 작성하게 되었습니다.

여기서 길게 쓰면 뇌절인 것 같아서 이쯤에서 줄이겠습니다. 🤪

놀랍게도 벌서 8월 12일 오전 3시네요...👋

profile
새로운 상상을 하고, 상상을 현실로 만드는 개발자

0개의 댓글