본 포스팅은 구글 프로젝트 제로의 멤버인 Ivan Fratric이 'So you want to work in security?'라는 제목으로 정보보안 직종에 종사하고 싶은 후배들에게 조언한 블로그 포스팅을 번역한 포스팅입니다.


번역 상에 오역이 있을 수 있으므로, 원문도 같이 첨부합니다.


원문 링크 : http://ifsec.blogspot.kr/2018/02/so-you-want-to-work-in-security-and-for.html?m=1


그래서 보안쪽 일을 하고 싶다고?


많은 사람들(내 구글 직장동료인 Parisa와 Michal을 포함해서)이 이미 이 주제에 대해 좋은 포스팅들을 많이 썻고 나도 그 포스팅들을 읽어보는 것을 추천한다. 내생각에는 내가 이제부터 할 말들은 걔내들이 이미 말한것들과 많이 겹치겠지만, 사람들이 이러한 주제로 나한태 물어볼때마다 채팅으로 치고, 앞서 말한 사람들의 포스팅들 링크를 던져주는 것 보다는 내가 직접 내 의견을 담은 내 포스팅을 쓰는게 낫다고 생각해서 쓰게 되었다.


일단, 나는 응용 보안을 하고, 취약점 연구부터 보안 리뷰, 버그헌팅, 해킹 등 너가 perspective라고 부르는 것들을 한다. 그리고 이쪽 보안바닥에 다른 커리어도 있는데, 보안개발이나 악성코드 분석, 인프라 보안과 기타 내가 잘 모르는 분야들, 내가 잘 몰라서 조언해주기 어려운 분야들도 있다.


그래서 내가 누구이고, 너가 내가 하는말을 왜 믿어야하냐? 음, 일단 나는 너가 내 말을 무조건 믿어야한다고 생각하지 않는다. 왜냐면 각각의 사람들은 각각의 경험이 있고 각각의 사람들이 걸어온 길은 다 다르니깐. 하지만 아마 너가 이쪽에 관심이 있을수 있으니 설명하자면, 나는 지금 구글 프로젝트 제로의 멤버이고, 구글 보안팀에서 일했었다. 그리고 너가 아마 이 블로그의 옛날 글들을 잘 뒤적거렸을 경우 발견할 수 있는 몇개의 보안툴들을 직접 만들었다.(근데 좀 업데이트가 안되서, 확인해보려면 GPZ 블로그의 가장 최신 포스팅을 봐라.) 그러면 너는 내가 10년넘개 보안관련 일들을 했다는것을 알 수 있을거다.


근데  나는 원래 이쪽바닥이랑은 좀 다른 전공지식을 갖고있는데, 내가 아는 보안하는 인간들은 정말 다양한 전공 출신이다. 예를 들어 나는 다양하게 학술적 기반을 갖고 있다.(전산학 박사, 그리고 대학에서 오랜기간 일했다) 하지만 내가 전산학 박사를 땃다는 것은 내가 같이 일하는 동료들 사이에서는 좀 특이한 케이스이고, 보안을 하기 위해서 이러한 조건이 필요한 것도 아니다. 이건 물론 학위를 따지 말라는 소리는 아니며, 내 전산학 지식이 나중에 내 기술 기반을 쌓아올리는데 많은 도움이 됬다고도 생각한다. 하지만 너가 이미 선택했거나 가지고 있는 학벌과는 관계없이, 이 보안 바닥에 종사하는 사람들이 공통적으로 가지고 있는 특징이 있는데, 이 특징에서 내가 주는 첫번째 팁이 있다.


너만의 일을 해라

내가 이쪽 바닥에서 알고있는 대부분의 사람들은, 보안은 일이기 이전에 취미로 시작했다. 당연히 너가 보안에 갓 입문하려고 할때, 너한태 어떻게 입문하는지도 알려주지 않고 "너 만의 일을 해라"라고 말하는건 별로 도움이 안되겠지. 계속 읽어봐라, 왜냐면 나중에 답이 나오니까. 하지만 명심해라(너를 기죽일려고하는게 아니라, 나중에 대처법도 알려줄거다)



지금을 보지마라, 하지만 10년전에 입문하는건 지금 입문하는 것 보다 훨신 어려웠다.


모든 사람들이 인정하진 않겠지만, 보안은 시간이 지날수록 매우 크게 발달되었다. 물론, 니가 적당히 잘 파보면 10년전의 기술이 여전히 동작하는 하드웨어와 소프트웨어들을 찾을 수 있을것이다. 하지만 예를들어 웹브라우저를 봐라. 내가 맨 처음 윈도우 익스플로잇을(힙 오버플로) 했을때, 마이크로소프트가 Safe Unlinking 기술을 도입해서 잘 알려진 힙 익스플로잇 테크닉들을 막아버렸을때 기죽었었다. 10년 뒤에 브라우저에서 익스를 하는 사람은 Safe Unlinking과 스택 쿠키 뿐만 아니라, SafeSEH/SEHOP, DEP, ASLR, CFG, ACG와 샌드박스 등에 대해 잘 알고 그걸 우회해야 한다. 그리고 이런것은 웹 브라우저에만 적용되는 것이 아니다. 만약에 너가 10년전의 웹 어플리케이션 프레임워크와 지금의 것과 비교를 한다면 보안 부분에서 매우 큰 차이가 있다는 것을 알 것이다.


겁내지마라, 윗 단락에서 나온 단어들은 너에게 아무런 의미가 없다(아직까진)


그래서 어떻게 저렇게 빨리 발전하는 방어기법들과 싸울것인가?


학습자료들을 이용해라


일반적으로 진입장벽이 계속 높아지고 있지만, 팩트는 공부할 자료들도 이전보다 매우 많아졌다는 것이다.


그렇다고 안심할 순 없는게, 너가 나가서 스스로 공부할 수 있는 능력이 있어야 한다. 그 누구도 너의 손을 잡고 하나하나 알려주는 멘토가 되주지 않을 것이다(Sith에게 견습생으로 있을 수 있지만, 해커들이 그런식으로 일하는 경우는 거의 없다). 만약에 너가 잘 짜여진 커리큘럼을 따라가는 것을 좋아한다면(대부분의 공부를 그런식으로 한다면) 너는 보안을 하기는 힘들것이다.


너가 괜찮은 공부 자료를 구하기 전에, 너는 괜찮은 질문들을 던져야 한다. "해킹하는법"과 같은 단어로 구글링을 하면 지금까지 봐왔던 거지같은 결과만 나올 것이다. 대신 더 괜찮은 질문들을 해야 한다. 다음과 같은,


내가 관심있는 하드웨어/소프트웨어가 어떻게 동작하는가? 어떤 기술이 기반이 되는가? 내가 볼 수 있는 소스코드가 있는가? 튜토리얼? 서적?



내가 대상으로 하는 하드웨어/소프트웨어를 누군가가 공격시도한 적이 있는가? 그들이 write-up을 올려놓았는가? 익스플로잇을 올렸는가? 컨퍼런스 발표자료가 있는가? 내가 정말로 그사람들이 한 것들을 이해하는가?



따라서 누군가 실제로 만든 소프트웨어나 하드웨어가 어떻게 작동하는지 이해하려면 기술적으로 정통해야 합니다. 코드를 작성하는 것과 읽는 것은 정확히 같은 능력이 아닙니다. 만약 당신이 코딩을 쉽게쉽게 할 수 없다면 보안 공부를 깊게 하기 전에 이것부터 먼저 해야 합니다.



두번째 포인트를 잊지 마시오. 나는 예전에도 기술적으로 아는 것이 꽤나 많았지만, 내가 실제로 사람들이 공개한 취약점 연구와 익스플로잇들을 보기 시작하기 전에는 보안에 대한 내 이해도가 낮았다.



그리고 조언을 하나 더 하자면, 너가 모르는 무언가를 맞닥드렸을때 절때 포기하지 마라. 특히 너가 공부를 시작하고 다양한 자료들을 읽을때, 너가 모르는 무언가를 만날 경우가 매우 많을 것이다. 그 내용을 이해하지 않고 넘어가는 것은 쉬운 방법이지만, 한편으론느 잘못된 방법이기도 하다. 너가 모르는 무언가를 맞닥드렸을때, 모른다고 어렵다고 하기보다는 새로운 것을 배울 수 있는 단서가 된다고 생각해라.



내가 아무도 너의 손을 잡고 이끌어주지 않는다고 썻긴 했지만, 이 말이 너가 질문을 하지 말아야한다는 것은 아니다. 너는 편하게 생각해야 한다. 사람들은 너의 일을 대신해주지 않겠지만, 너가 멘붕에 빠졌을때 적절한 방향으로 가도록 도와줄 것이다.

리눅스에서 부팅 시 특정 작업을 하거나 특정 프로그램이 돌도록 하고 싶을 때가 있다. 그럴 경우 어떻게 해야하는지 차근차근 알아보자. 

본 포스팅은 우분투 16.04 기준으로 작성되었다. 하지만 다른 리눅스 배포판도 비슷한 구조를 가지고 있을 것이다.


리눅스 부팅 과정

일단 리눅스 부팅 과정을 간략하게 알아보자.


BIOS

메인보드에 있는 BIOS라고 불리는 펌웨어가 컴퓨터 부팅 시 필요한 처음 동작들을 처리한다. 이 BIOS는 POST(Power On Self Test)라고 불리는 동작을 통해서 부팅에 필요한 하드웨어들이 잘 연결되어 있는지, 하드웨어들이 잘 동작하는지 등을 확인한다. 그리고 부트 가능한 장치들을 사전에 설정된 부팅 우선순위(Periperial Booting Priority)에 따라 차례대로 각각의 장치의 MBR(Master Boot Record) 혹은 부트섹터라고 불리는 첫번째 섹터의 마지막 2바이트를 확인해서 부트섹터의 시그니쳐가 맞는지 확인한 후 시그니쳐(0x55AA)가 맞을 경우 해당 장치로 부팅을 시도한다. 그리고 MBR의 첫번째 바이트로 점프해서 기계어 Instruction을 실행한다.
최근에는 구식의 BIOS를 대체한 UEFI라는 녀석이 여기에 위치하는 경우도 있다.

MBR

부팅하도록 설정된 장치의 첫번째 섹터를 MBR(Master Boot Record)라고 하며, 여기에 나머지 부팅 절차들을 밟기 위한 명령어들이 있다. 하드웨어에서 한 섹터는 512바이트이며, 컴퓨터들은 하위 호환성을 유지하면서 발전했기 때문에, 저장장치의 용량이 커져도 이는 동일하다. 이 MBR에 있는 코드에는 리눅스 부트로더인 GRUB(혹은 레가시 시스템에서는 LILO)이 들어있다.

GRUB

GRUB은 Grand Unified Bootloader의 약자이며, MBR에 GRUB의 기계어 Instruction들이 있다. 하나의 저장장치에 여러개의 OS가 설치되어 있을 수 있으므로, GRUB은 어떤 OS로 부팅을 할 것인지 사용자가 설정할 수 있도록 UI를 제공한다. 사용자가 원하는 OS를 선택하면, 이 부트로더는 저장장치의 다른 섹터에 있는 커널 코드들을 메모리에 로드한 뒤, 커널 코드로 점프하여 커널의 기계어 Instruction들을 실행하도록 한다.

커널

드디어 리눅스 운영체제의 핵심부인 커널이 메모리에 올라오게 되었고, 이제 커널은 부팅을 마무리 짓기 위한 마무리를 지어야 한다.  grub 설정파일에 정의된 루트 경로에 부분에 루트 파일시스템을 마운트하고, /sbin/init 프로그램을 실행한다. init 프로세스는 해당 OS에서 실행된 첫번째 프로세스로 pid가 1이다. 우분투 최신 버전의 경우 init 프로세스 대신 systemd이라는 시스템 데몬 프로세스가 그 자리를 대신하기도 한다. 하지만 전체적인 맥락은 비슷하다.


Init

Init 프로세스는 해당 시스템에 저장된 런 레벨에 맞게 동작한다. 런 레벨은 보통 0부터 6까지의 숫자로 이루어져 있으며 시스템을 어떤 방식으로 부팅하는지를 결정한다. systemd로 제어되는 시스템의 경우 더 많은 종류의 런 레벨을 갖고 있으며, 리눅스 메뉴얼 페이지에서 man systemd.special명령어로 확인이 가능하다.


0 - 시스템 종료


1 - 싱글 유저 모드.

 계정이 하나만 존재한다. 시스템 복원모드라고도 불리며, 기본적으로 관리자 권한의 쉘을 얻게 된다. 파일시스템을 점검하거나 관리자 암호 변경 시 사용한다. S 모드와의 차이점은, 이 모드는 멀티 유저 모드에서 싱글 유저 모드로 스위치 된 경우 이 모드이다. 점검을 위해 싱글 유저 모드로 들어간 경우라고 인식된다.

S - 싱글 유저 모드

1번 모드와 동일하나, 부팅 시 부터 싱글 유저 모드 인 경우이다.

2 - 멀티유저모드, NFS비활성화. 

여러개의 계정을 사용할 수 있으며, NFS(Network File System)사용이 불가능하다. 3번 모드와 같지만 네트워크 사용이 불가능하다.

3 - 풀 멀티유저 모드.

CLI를 가지며 멀티 유저를 지원한다.

4 - 사용안함. 

기본적으료 사용하지 않는 모드이며, 사용자 정의하여 원하는 모드로 사용 가능하다.

5 - X11

3번 모드와 같이 멀티 유저를 지원하는 일반모드이지만 GUI를 지원한다는게 큰 차이점이다.

6 - 재시작

시스템을 재시작한다.


런레벨 프로그램

Init 프로세스가 런 레벨을 결정하면, 각각의 런 레벨에 맞는 서비스들을 실행해야 한다. 이때 각각 런 레벨 별 실행해야하는 서비스들의 디렉토리 경로는 다음과 같다.

레벨 S - /etc/rcS.d
레벨 0 - /etc/rc0.d
레벨 1 - /etc/rc1.d
레벨 2 - /etc/rc2.d
레벨 3 - /etc/rc3.d
레벨 4 - /etc/rc4.d
레벨 5 - /etc/rc5.d
레벨 6 - /etc/rc6.d

여기서 rc는 Run Command의 약자라고 한다.

/etc/rc0.d 디렉토리를 보면 다음과 같이 보인다.
 

root@ubuntu:/etc# tree rc1.d/
rc1.d/
├── K01alsa-utils -> ../init.d/alsa-utils
├── K01bluetooth -> ../init.d/bluetooth
├── K01cups-browsed -> ../init.d/cups-browsed
├── K01docker -> ../init.d/docker
├── K01irqbalance -> ../init.d/irqbalance
├── K01kerneloops -> ../init.d/kerneloops
├── K01lightdm -> ../init.d/lightdm
├── K01mysql -> ../init.d/mysql
├── K01saned -> ../init.d/saned
├── K01speech-dispatcher -> ../init.d/speech-dispatcher
├── K01thermald -> ../init.d/thermald
├── K01ubuntu-fan -> ../init.d/ubuntu-fan
├── K01ufw -> ../init.d/ufw
├── K01uuidd -> ../init.d/uuidd
├── K01vmware-tools-thinprint -> ../init.d/vmware-tools-thinprint
├── K01whoopsie -> ../init.d/whoopsie
├── K01xinetd -> ../init.d/xinetd
├── K02avahi-daemon -> ../init.d/avahi-daemon
├── K02cgroupfs-mount -> ../init.d/cgroupfs-mount
├── K02cups -> ../init.d/cups
├── K04rsyslog -> ../init.d/rsyslog
├── README
├── S01killprocs -> ../init.d/killprocs
└── S02single -> ../init.d/single


K로 시작하는 녀석들은 해당 서비스들을 종료하기 위한 스크립트로, 심볼릭 링크 원본의 스크립트를 stop인자와 함께 실행한다.
S로 시작하는 녀석들은 해당 서비스들을 구동하기 위한 스크립트로, 심볼릭 링크 원본의 스크립트를 start 인자와 함께 실행한다.

뒤에 있는 숫자는 순서를 맞추기 위함이다.


이런식으로 rc?.d 디렉토리들을 확인해보면, 런레벨 2~5와 S레벨의 경우 마지막에 /etc/init.d/rc.local 이라는 스크립트를 실행시킨다.

/etc/init.d/rc.local 스크립트를 살펴보면 내용은, /etc/rc.local 이라는 파일이 있을 경우 실행시킨다고 되어있다.


즉 런레벨 2~5, S의 경우 /etc/rc.local 이라는 스크립트를 부팅 과정 마지막에 실행시킨다는 것이다.


/etc/rc.local 스크립트 실행

Ubuntu16.04의 경우 런 레벨 2~5, S의 경우 /etc/rc.local를 최종적으로 실행시킨다. 해당 파일이 없을 경우 실행하지 않는다. 런레벨 0은 시스템 종료, 6는 재부팅이므로 관계 없고, 런레벨 1의 경우 시스템 복구 모두이기 때문에 복구를 위해서 해당 스크립트를 실행하지 않는것으로 보인다.



리눅스 부팅 스크립트 등록

리눅스 부팅 과정을 알아보면서 알 수 있듯이, 부팅때마다 실행되어야 할 스크립트는 /etc/rc.local에 입력하면 된다.


단, 주의할 사항은 PATH 같은 환경변수가 일반 쉘과 같은 환경으로 설정되지 않을수도 있기 때문에, 환경변수를 설정하고 명령어를 실행하거나, 절대경로로 실행하는 것을 추천한다.


또한 스크립트 실행 도중 에러가 발생했을 때, 표준 출력과 표준 에러를 직접 터미널로 확인하기 어려우므로 파일로 리디렉션(Redirection)하되, 표준 에러도 같이 리디렉션(Redirection)하도록 하는 것을 추천한다.


-------------------------------------

참조

https://www.thegeekstuff.com/2011/02/linux-boot-process/

http://kateee.tistory.com/51

http://nuitstory.tistory.com/500

https://wiki.debian.org/BootProcess

http://cherub.sungkyul.edu/~web/jinboard/files/63_boot.pdf

https://ko.wikipedia.org/wiki/%EC%8B%9C%EB%8F%99_%EC%9E%90%EC%B2%B4_%EC%8B%9C%ED%97%98

http://forensic-proof.com/archives/435

자바스크립트 동시성 모델


자바스크립트는 이벤트 루프에 기반한 동시성(Concurrency) 모델이라는 것을 가지고 있습니다. 이 모델은 다른 C나 Java와 같은 언어가 실행되는 방식과 다릅니다.


본디 자바스크립트는 웹 브라우저 상에서 동작하는 스크립트 언어로 만들어졌습니다. 그리고 자바스크립트로 DOM API에 접근해서 브라우저의 UI를 제어하기 위해 UI스레드를 공유할 수 밖에 없는 환경입니다.


만약 자바스크립트가 일반적인 C나 Java언어 처럼 동작하고, 동시성을 제공하지 않는다면 다음과 같은 상황이 벌어질 수 있습니다.


서버에서 비동기적으로 데이터를 받아오기 위해 XHR을 통해 Ajax요청을 보내고, 해당 데이터를 받아오기 까지 시간이 소요됩니다. 그러면 해당 데이터를 받아오기 전 까지 UI를 제어하는 자바스크립트는 멈추게 되므로 사용자는 UI를 제어할 수 없습니다.


이러한 경우가 Ajax가 잦은 페이지에서는 클라이언트 리소스문제가 아니라도 브라우저가 매우 자주 버벅일 것이고 이는 사용자에게 매우 나쁜 UX를 제공합니다.


이러한 상황을 피하기 위해서 자바스크립트는 이벤트 기반 동시성 모델을 제공합니다. Ajax요청을 보내는 것과 응답을 받는 것, 각각을 다 이벤트로 처리하며 Ajax와 같이 I/O 요청 이후 결과를 계속 기다리는 것이 아니라 곧바로 다른 처리할 수 있는 작업을 처리하게 되며 UI 쓰레드가 멈추는 것을 막을 수 있습니다.

동시성과 병렬성

동시성(Concurrent)는 병렬성(Parallel)과는 다릅니다. 둘 다 여러개의 실행 흐름이 동시에 일어나는 것이라는 공통점이 있습니다.


동시성

동시성(Concurrent)는 실제 물리적으로 동시에 일어나는 것이 아니라, 흐름을 실행시키는 것은 하나(e.g. CPU 코어 혹은 쓰레드)지만 작은 타임 슬라이스(Time slice, Time quantum)단위로 다른 흐름을 돌아가면서 실행시켜서 동시에 일어나는 것 처럼 보이게 하는 방식입니다. 논리적인 의미에 동시 실행으로 볼 수 있습니다.


병렬성

병렬성(Parallel)은 실제로 흐름을 실행시키는 것(e.g. CPU 코어 혹은 쓰레드)이 복수개여서, 각각 실행 흐름을 할당받아 동시에 실행시킵니다. 물리적인 의미에 동시 실행입니다.




자바스크립트는 언어 스펙이 싱글쓰레드이므로, 병렬성(Parallel)을 지원할 수 없습니다. 대신 동시성(Concurrent)는 지원할 수 있습니다.


자바스크립트 런타임 모델

자바스크립트는 브라우저에서 더 좋은 UX를 제공하기 위해 동시성 모델을 제공한다. 하지만 이 동시성 모델은 자바스크립트 언어 자체의 스펙이 아니라, 자바스크립트를 구동하는 환경인 웹 브라우저의 자바스크립트 엔진에서 구현한 모델이다. 또한 범용 언어로서 자바스크립트 런타임인 Node.js도 비슷한 방식의 동시성 모델이 구현되어 있다.

다음 그림은 런타임의 구성요소들을 시각화 한 것이다.



image/svg+xml Stack Heap Queue


스택, 힙, 큐로 이루어져 있다.


스택

스택은 C와 같은 다른 프로그래밍 언어에서 사용하는 함수 호출시 할당, 리턴 시 해제되는 그 콜 스택이다.

함수가 호출될 때 마다 해당 함수에 대한 스택 프레임이 생성되고, 해당 스택 프레임은 해당 함수의 인자와 지역변수들을 저장한다.

함수가 리턴될 때 해당 함수의 스택 프레임은 해제된다.


일반적으로 힙은 동적으로 할당된 변수나 메모리 등을 포함한다. 자바스크립트는 인터프리터형 스크립트 언어이므로 선언된 것들을 저장할 수 있지만, 지역변수나 함수 인자들은 스택에 저장되므로, 전역 객체와 같은 것들이 힙에 저장되게 된다.

자바스크립트 런타임에서 큐는 메시지 큐이다. 이 큐를 이용해서 자바스크립트는 동시성 모델을 지원하며, 큐에는 처리해야 할 메시지들이 저장되어 있다.
메시지는 처리해야할 함수들의 셋이라고 볼 수 있으며, 큐는 메시지 단위로 처리한다. 큐가 메시지를 처리하는 로직은 다음과 같다.

1. 콜 스택이 비었으면 큐에서 메시지를 꺼내서 처리한다.
2. 콜 스택이 비지 않았으면 콜 스택이 빌 때 까지 처리하던 함수를 계속 처리한다.

메시지 처리는 콜 스택에 처음 비어서 해당 메시지를 큐에서 꺼내어서 해당 메시지에 대한 함수를 호출하면서 메시지 처리가 시작이 된다.
콜 스택에 다시 비워질 경우 해당 메시지 처리는 종료된다.

메시지 큐 동작 방식을 확인할 수 있는 간단한 예제를 보자.

 

function foo(a) {

	while(true) {
		a += bar(5);
	}
}

function bar() {
	var a = 1;
	return a;
}

function baz() {
	console.log('Another task');
}

foo();
baz();


브라우저에서 위 코드를 실행하면 Another task라는 메시지는 보이지 않는다. foo함수가 맨 처음 메시지 큐에 들어가서 처리되게 되는데, foo 함수는 bar함수를 호출, 리턴을 무한 반복하게 되며 콜 스택에 비워지지 않기 때문이다. 그러므로 다음 메시지 큐에 baz함수가 있지만 계속해서 실행이 되지 않게 된다.
 

function foo(a) {
	setTimeout(0, bar);
}

function bar(x) {
	setTimeout(0, foo);
}

function baz() {
	console.log('Another task');
}

foo();
baz();


브라우저에서 위 코드를 실행하게 되면 Another task라는 메시지는 잘 보인다. setTimeout(time, function); 함수를 호출하면, time시간 이후에 function 함수를 메시지 큐에 추가하게 된다.

메시지 큐에는 다음과 같은 방식으로 메시지가 들어가고, 처리되게 된다.

foo 

 baz

 bar

 foo

 bar



이러한 이벤트 루프의 특성을 잘 파악해야지, 여러개의 테스크를 동시성있게 실행되게 자바스크립트 코드를 작성할 수 있다. 이러한 이벤트 루프 모델에 반하는 방식으로 코드를 작성하면 나쁜 UX를 사용자에게 제공하게 되며, 이는 거의 버그나 마찬가지이다.



------------------------------------------------------

참고

https://developer.mozilla.org/ko/docs/Web/JavaScript/EventLoop

+ Recent posts