♻️ 개발자로 재활용/🟢 JavaScript

비동기처리와 Callback

BuleRatel 2022. 12. 1. 23:13

JavaScript싱글 스레드이기 때문에 동기적으로 실행되지만, JS가 구현되는 런타임비동기적 구현이 가능한 환경이라고 했습니다. 원하는 순간에 따라 코드를 동기적으로/비동기적으로 다뤄야 할 일이 생길 겁니다. 이를 다루기 위해 어떤 걸 사용해야 하는지 알아봅시다.

 


 

JavaScript는 순서대로 작동한다?

📌 동기적 처리(Synchronous)

콘솔로 코드를 한번 작성해 봅시다. 아래의 코드는 어떤 순서대로 출력될까요?

console.log('log 1')
console.log('log 2')
console.log('log 3')

자바 스크립트는 순서대로 작동한다고 했습니다. 위의 코드도 순서대로 출력이 되었네요.

그럼 조금 다른 예시를 들어 볼게요. 아래의 코드는 어떤 순서대로 출력될까요?

console.log('log 1')

setTimeout(() => {
	console.log('log 2')
	}, 0)

console.log('log 3')

참고로 setTimeout() 함수는 첫 번째 인자로 ‘실행시킬 함수(콜백함수)’를 받고, 두 번째 인자로 ‘실행까지 걸리는 시간’을 받는 비동기 함수입니다(지금은 몰라도 괜찮아요). setTimeout()의 두번째 인자가 0이므로, 지연 시간이 없이 바로 실행될거라고 예상이 됩니다.

그런데 콘솔에 이런 결과가 나왔습니다. 자바 스크립트가 순서대로 실행된다고 알고 있는데, 출력되는 순서가 다릅니다. 왜 그럴까요? 바로 setTimeout() 비동기적 API이기 때문입니다.

 

📌 비동기적 처리(Asynchronous)

자바 스크립트는 코드를 순서대로 실행시키다가, 비동기적 API를 만나면 비동기를 처리하는 다른 프로그램에 전달해줍니다. 다른 프로그램이 비동기를 실행하는 동안 가지고 있는 코드를 마무리하고요. 모든 실행이 끝나면, 가장 마지막으로 비동기적 API를 받아 출력합니다. 위 코드가 실행되는 과정을 살펴보면 다음과 같습니다.

  • console.log('log 1')를 만나고, 콘솔에 'log 1'를 출력한다.
  • setTimeout()를 만났다. 비동기적 API이기 때문에, 비동기를 처리하는 다른 프로그램에게 넘긴다.
  • 다른 프로그램이 setTimeout()을 처리하는 동안 다음 코드를 실행시킨다.
  • console.log('log 3')를 만나고, 콘솔에 'log 3'를 출력한다.
  • 모든 코드가 끝나면, 비동기를 처리하는 프로그램에게 setTimeout()의 결과를 받아, 콘솔에 ‘log 3’을 출력한다.

위의 순서가 이해된다면 다음 코드도 입력해 보세요. 결과도 예상해 봅시다.

console.log('log 1')

setTimeout(() => {
	console.log('log 2')
	}, 1000)

console.log('log 3')
console.log('log 4')

setTimeout(() => {
	console.log('log 5')
	}, 0)

console.log('log 6')
console.log('log 7')

 

📌 비동기를 꼭 사용해야 하나요?

Q. 그러면 코드를 순서대로 사용하고 싶으면, 그냥 비동기적 API를 사용하지 않고 순서대로 입력하면 되지 않나요?

아쉽게도 앞서 배웠듯, 웹페이지는 사용자와 빠르게 상호작용하며 여러 테스크를 동시에 수행해야 하기 때문에 비동기적 수행이 필수적입니다. 이런 비동기 수행의 대표적인 예로 ‘통신’이 있습니다. 서버에서 어떤 자료를 받아온다고 생각해 봅시다. 동기적으로 일을 수행한다면, 서버에서 자료를 모두 받을 때까지 다른 작업은 시작하지 못할 것입니다.

정리하자면,

  • 웹페이지는 한 페이지에서 여러 활동이 동시다발적으로 일어나고 있습니다.
  • 자바 스크립트 자체는 싱글 스레드라, 코드가 동기적으로(순서대로) 동작합니다.
  • 자바 스크립트가 구현되는 환경, 즉 JS의 런타임은 비동기적으로 작동할 수 있습니다.
  • 자바 스크립트는 코드 실행 중 비동기적 API를 만나면 다른 곳에 넘긴 뒤, 자신의 코드를 모두 실행하고 나서야 비동기적 API를 처리합니다.
  • 비동기 처리는 효율적이지만 코드의 흐름 파악이 어려워 실행 결과를 예측할 수 없습니다.
  • 이런 비동기 처리를 해결하기 위해 DOM, setTimeout, AJAX, Callout, Promise, Async/await 등을 사용해 의도한 순서대로 코드가 실행되게 합니다.

 


 

Why Callback?

고차 함수를 배울 때 callback 함수에 대해서 배웠습니다. callback 함수다른 함수에 인자로 전달되는 함수를 말합니다. 매개변수를 받은 함수는 이 callback 함수를 필요에 따라 즉시 실행할 수도 있고, 나중에 실행할 수도 있습니다. 일반적으로도 아주 유용하지만, 비동기를 처리하는 데도 많이 씁니다.

아래의 코드와 함께 예시로 봅시다. 해당 함수는 setTimeout을 통해 전달인자로 받은 string을 출력하고, 출력되기까지 걸리는 시간을 랜덤하게 지정하는 명령을 수행하고 있습니다. setTimeout을 지금 당장 몰라도 괜찮습니다.

const printString = (string) => {
	setTimeout (() => { 
	console.log(string) 
	},
	Math.floor(Math.random() * 100) + 1)
};

const printAll = () => {
	printString('A')
	printString('B')
	printString('C')
};

이제 printAll()을 한번 실행해 봅시다. 순서는 어떻게 나올까요? printAll 함수에 A, B, C를 순서대로 넣었는데, 출력되는 문자의 순서는 매번 다릅니다. 실행 시간을 랜덤으로 지정해줬기 때문이죠.

이렇게 수행이 완료되는 시점을 짐작할 수 없지만, 순서대로 실행해야 하는 테스크들이 있을 겁니다. 이를 다루기 위해 흔하게 사용하는 방법이 callback 함수입니다.

 


 

Callback

수업에서는 ‘무슨 일이 언제 끝날지는 모르지만, 끝나면 연락해(callback). 그래야 다음 일을 실행할 수 있으니까’ 정도로 해석하고 있습니다. callback은 비동기 함수의 콜백 내부에서 다음 작업(비동기 함수 호출 등)을 합니다. 위의 함수를 조금 수정해서 순서를 지정해주도록 합니다.

const printString = (string, callback) => {
	// 이번에는 문자(string)과 함수(callback)을 함께 받습니다.

	setTimeout (() => { 
	console.log(string)
	callback() 
	},
	Math.floor(Math.random() * 100) + 1)
};

const printAll = () => {
	printString('A', () => {
	// 첫 번째 인자로 console.log에 들어갈 'A'를, 두 번째 인자로 익명함수를 줍니다.
		printString('B', () => {
		// 그 익명함수는 바로 여기 있습니다. 
		// 이 익명함수는 console.log를 실행시킨 뒤 두 번째 익명함수를 실행합니다.
			printString('C', () => {})
			// 실행되는 익명함수는 여기 있습니다.
			// cosole.log를 실행시키고, 뒤에 따라오는 익명함수를 실행합니다. 여기서는 비어 있네요.
		}) // B를 첫 번째 전달인자로 받는 함수를 닫습니다.
	}) // A를 첫 번째 전달인자로 받는 함수를 닫습니다.
}; // printAll 함수를 닫습니다

printAll()

이제 순서가 지정이 되었습니다. A가 콘솔 로그를 출력한 뒤 자신이 받은 함수를 실행시킵니다. 이 함수는 B라는 콘솔 로그를 출력하고 자신이 받은 함수를 실행시키고, 마지막으로 C가 콘솔 로그를 출력하고 자신이 받은 함수를 실행시키는 과정입니다. 직접 타이핑 해보세요.

콜백 함수는 사실 단순하게 생각할 수 있습니다. 첫 번째 인자로 실행시킬 인자를 받습니다. 두 번째 인자로 이 함수 다음에 실행시킬 함수를 받아 실행시킵니다. 바로 이해가 가지 않으면, 다음 예시를 한번 살펴봅시다.

function userFinder(id) {
  let user;
  setTimeout(() => {
    console.log("waited 1 sec.");
    user = {
      id: id,
      name: "User" + id,
      email: id + "@test.com",
    };
  }, 1000);
  return user;
}

const user = userFinder(1);
console.log("user:", user);

이 함수를 실행시키면 어떤 결과가 일어날까요? 단순히 흐름대로 본다면, userFinder 함수에 전달인자로 1을 주고, 이 1은 id라는 매개변수에 들어가게 됩니다. 콘솔을 찍고, 전달인자가 입력된 변수 user를 출력할거라고 생각하지요. 하지만 결과는 다음과 같습니다. 앞서 말했듯, setTimeout이 비동기 API이기 때문에 다른 프로그램에 넘기고 아래의 console.log("user:", user);를 먼저 출력해버리는 것이지요. 

이처럼 의도치 않은 코드 순서가 생길 때면 콜백 함수로 해결할 수 있습니다. 함수에서 결과값을 리턴받는 대신, 결과값을 이용해서 처리할 로직을 콜백 함수에 담아 인자로 전달해주는 것이지요.

function userFinder(id, callback) {
  setTimeout(() => {
    console.log("waited 1 sec.");
    const user = {
      id: id,
      name: "User" + id,
      email: id + "@test.com",
    };
    callback(user);
  }, 1000);
}

userFinder(1, function (user) {
  console.log("user:", user);
});

함수 userFinder에 이제 id와 callback이라는 매개변수를 줬습니다. 아래 줄을 보니 첫 번째 인자로 ‘1’을 전달하고, 두 번째 인자로 함수를 전달해 줬네요. 이 함수는 userFinder에 들어가서 callback(user)로 구현됩니다. 함수에게 전달인자로 주는 함수인 callback 함수의 역할을 하는 것입니다.


 

Callback Hell, 콜백 지옥!

하지만 callback 함수는 이른바 callback 지옥이라는 끔찍한 상황에 마주할 수 있습니다. 이전에 했던 코드를 생각해 봅시다. A→ B→ C→ … → Z의 순서로 함수를 실행시키고 싶으면, A에게 B를 실행하는 함수를 전달하고, B에게 C를 실행하는 함수를 전달하고, 계속 꼬리에 꼬리를 물면 되겠죠. 실제로 표현해보면 어떨까요?

const printString = (string, callback) => {
	setTimeout (() => { 
	console.log(string)
	callback() 
	},
	Math.floor(Math.random() * 100) + 1)
};

const printAll = () => {
	printString('A', () => {
		printString('B', () => {
			printString('C', () => {
				printString('D', () => {
					printString('E', () => {
						printString('F', () => {})
					})
				})
			})
		})
	})
};

printAll()

가운데가 무한히 늘어나고 있습니다. 이런 상황이 바로 callback hell, 콜백 지옥입니다. 가독성도 좋지 않을 뿐더러, 동기적인 상황을 추후 수정/보완하기 어렵죠. 다행히 이런 불편함을 해결하기 위해 새로운 문법이 나왔습니다. 바로 Promise와 async/await 입니다.

 


 

참고 사이트