2P by GN⁺ | ★ favorite | 댓글 1개
  • 초기 컴퓨터는 스택과 힙 없이도 함수 호출을 구현해야 했고, 컴파일러는 매개변수·반환 주소·지역 변수에 대응하는 숨은 전역 변수로 호출 상태를 관리함
  • 호출자는 인자를 저장하고 반환 주소 변수에 복귀 위치를 넣은 뒤 함수 시작점으로 점프했으며, 함수는 계산 후 저장된 주소로 다시 점프함
  • 논리적 지역 변수도 실제로는 전역 저장 공간을 썼기 때문에, 겉보기에는 함수처럼 보여도 내부 동작은 고정된 메모리와 goto에 가까웠음
  • 일부 ABI와 프로세서는 인자 전달과 복귀 주소 처리를 레지스터branch with link로 최적화했지만, 기본 제약은 그대로 남아 있었음
  • 같은 함수의 반환 주소가 새 호출로 덮어써지기 때문에 재귀 호출은 불가능했고, 당시 언어들은 재귀를 금지하거나 명시적으로만 허용하는 방식으로 대응함

스택 없이 함수 호출을 구성하는 방식

  • 초기 컴퓨터 환경에서는 오늘날 당연하게 여기는 스택이나 이 없었음
  • 힙이 없는 동적 메모리 할당은 고정 크기 버퍼로 대체할 수 있었음
    • 변수 크기 데이터를 처리할 때도 충분히 큰 고정 버퍼를 미리 예약함
    • 요청 데이터가 버퍼 용량을 넘으면 치명적 오류로 프로그램을 종료함
    • 더 친절한 구현은 컴파일 시 최대 용량을 설정할 수 있게 함
    • 더 정교한 구현은 고정 버퍼 위에 커스텀 할당자를 두고 allocatefree처럼 사용 가능하게 함

숨은 전역 변수 기반 호출 규약

  • 스택 없이 함수 호출을 구현하기 위해 컴파일러는 각 함수마다 여러 숨은 전역 변수를 정의함
    • 입력 매개변수별 전역 변수
    • 함수의 반환 주소를 담는 전역 변수
    • 지역 변수에 대응하는 전역 변수
  • 호출 코드는 다음 순서로 실행됨
    • 매개변수 값을 해당 숨은 전역 변수에 저장함
    • 복귀할 위치를 함수의 반환 주소 변수에 기록함
    • 함수 시작 위치로 goto 점프함
  • 함수는 매개변수와 지역 변수를 모두 숨은 전역 변수에서 읽고 씀
  • 실행이 끝나면 반환 값을 반환 값 레지스터에 넣고, 함수의 반환 주소 변수에 저장된 주소로 점프함

C 비슷한 코드가 goto 기반 코드로 바뀌는 예

  • 예시 함수 add_two_values(int a, int b)는 스택 없이 다음과 같은 저장 공간으로 변환될 수 있음
    • a2v_a, a2v_b는 인자 저장용 전역 변수
    • a2v_c는 지역 변수 c에 대응하는 전역 변수
    • a2v_retaddr는 복귀 주소 저장용 전역 변수
  • 호출자 sample()314152718을 각각 인자 전역 변수에 저장함
  • 이어서 a2v_retaddrresume 위치를 넣고 add_two_values로 점프함
  • add_two_values는 계산 결과를 return_value_register에 저장한 뒤 a2v_retaddr로 복귀함
  • resume 위치로 돌아온 호출자는 반환 값 레지스터의 값을 sample_x에 저장함

레지스터와 branch with link를 이용한 최적화

  • 같은 구조는 ABI 차원에서 레지스터 전달로 더 빠르게 만들 수 있음
  • 많은 프로세서는 특별한 link registerbranch with link 명령을 제공했음
    • branch with link는 분기 명령 다음 명령어의 주소를 자동으로 link register에 저장함
    • 호출자는 첫 두 인자를 argument_register_1, argument_register_2에 넣을 수 있음
    • 피호출 함수는 이 레지스터 값을 자신의 숨은 전역 변수로 옮겨 사용 가능함
  • 반환 주소도 link_register에서 함수의 반환 주소 변수로 저장될 수 있음
  • 이 최적화는 스택 없이도 호출과 반환이 가능하다는 기본 구조를 유지함

재귀가 막히는 이유

  • 이 호출 방식의 핵심 제약은 재귀 호출 불가
  • 재귀 호출이 발생하면 같은 함수의 반환 주소 변수가 새 호출의 반환 주소로 덮어써짐
  • 바깥 호출이 끝날 때 원래 복귀해야 할 위치가 사라져 잘못된 위치로 점프하게 됨
  • 당시 프로그래밍 언어들은 재귀를 지원하지 않는 방식으로 이 문제를 피함
  • FORTRAN은 처음에는 서브루틴도 지원하지 않았고, 서브루틴은 1958년에 추가됨
  • FORTRAN에서 재귀 지원이 표준이 된 것은 1991년이며, 그때도 서브루틴을 RECURSIVE로 명시해야 했음

자기 수정 코드와 초기 프로세서의 서브루틴 명령

  • 일부 컴파일러는 더 교묘하게 자기 수정 코드를 사용함
    • 함수 끝의 점프 명령어 안에 있는 주소 필드가 사실상 반환 주소 변수 역할을 함
  • 이런 방식은 단순한 트릭이 아니라 실용적 필요일 수 있었음
    • 일부 프로세서는 간접 점프를 지원하지 않을 수도 있었음
  • 서브루틴의 실용성이 인정된 뒤 여러 프로세서는 전용 호출 명령을 추가함
    • 반환 주소를 서브루틴의 첫 번째 워드에 저장함
    • 실제 실행은 두 번째 워드에서 시작함
    • 반환할 때는 서브루틴 시작 라벨을 통한 간접 점프를 실행함
  • 예시 어셈블리에서 bsr add_two_valuesadd_two_values의 첫 워드에 반환 주소를 저장하고, 희생용 nop 다음의 실제 명령어부터 실행함

댓글과 토론

Hacker News 의견들
  • 이 주제에서는 The Art of Computer Programming이 정말 좋았음
    겉보기엔 낡아 보이지만, 힙이나 스택 이전 시대에 동적으로 변하는 배열이나 자료구조를 다루는 알고리즘이 엄청 많음
    책은 가비지 컬렉션과 Lisp 리스트 구현까지 차근차근 이어지고, Knuth에게 기대하는 백과사전식 지식이 그대로 들어 있음
    특히 좋아하는 예는 두 배열이 하나의 공간을 동적으로 공유하는 방식임. 한 배열은 location#0에서 앞으로 자라고, 두 번째 배열은 location#End에서 뒤로 자라게 하면 정적으로 할당된 공간을 효율적으로 나눠 씀
    임의 개수의 배열로 확장할 수도 있지만, 그쯤 되면 그냥 MallocRealloc을 쓰는 편이 낫고, 그 기법 자체도 malloc 비슷한 루틴에 꽤 가까움

    • 8비트 컴퓨터의 일부 워드프로세서가 이런 식으로 동작했음. 문서는 사용 가능한 RAM 전체를 차지했고, 커서 앞 텍스트는 RAM 시작 부분에, 커서 뒤 텍스트는 RAM 끝부분에 있었음
      삽입과 붙여넣기는 데이터를 밀어낼 필요가 없었지만, 탐색할 때는 필요했음. 그래도 잘 동작했음
    • 대부분의 명령어 집합 구조와 ABI에서 스택은 높은 주소에서 아래로 자라기 때문에, 단일 스레드 소형 메모리 시스템에서 이 기법으로 힙과 스택 사이의 메모리를 유연하게 나눌 수 있었음
    • 구형 MacOS의 애플리케이션별 리소스 할당이 딱 이런 방식으로 설명됨. 각 앱에는 최소 RAM 요구량과 선호 RAM 요구량이 붙어 있었고, 실행되면 선호 크기만큼 슬롯을 차지했음
      그만큼이 없으면 선호치보다 작게 잡고, 최소치도 못 얻으면 실행에 실패했음
      시스템은 그 물리 RAM 조각의 아래쪽에 힙과 라이브러리를, 위쪽에 스택을 배치했던 것으로 기억함
      System 8쯤에 가상화 계층이 추가되면서 이 접근이 덜 필요해졌고, MacOS X 시기에는 다른 시스템처럼 페이징 메모리를 쓰면서 이런 묘기가 더는 필요 없어졌음
      그래도 Art of Computer Programming의 이런 “이상한 한 가지 트릭”이 여러 동시 실행 앱의 RAM 할당 방식이던 시대를 떠올리면 재미있음
    • 재미있는 사실로, Itanium에는 수동 push/pop용 스택과 레지스터 파일을 순환하는 또 하나의 스택, 총 두 개가 있었음
      하나는 위로 자라고 다른 하나는 아래로 자랐음. 매혹적인 구조였지만 약속한 성능은 결국 내지 못했음
    • SQLite의 디스크 형식도 테이블 B-tree 리프 노드 페이지 내용을 저장할 때 비슷한 배열 기법을 씀
      고정 크기 페이지 안에서 오프셋 배열은 앞으로 자라고, 가변 길이 행 값 배열은 끝에서 뒤로 자람. 행을 삭제하면 뒤쪽 배열에는 구멍이 생길 수 있는 것으로 이해함
      문서가 B-tree 구조 자체에 대해 TAOCP를 인용하니, 직접적인 영감이었어도 놀랍지 않음
  • ALGOL에 재귀 함수를 넣는 일은 꽤 논쟁적이었고, 재미있는 이야깃거리로 남아 있음: https://vanemden.wordpress.com/2014/06/18/how-recursion-got-...

  • SUBLEQ 머신용 Forth 인터프리터(https://github.com/howerj/subleq)와 비트 직렬 머신용 인터프리터(https://github.com/howerj/bit-serial)를 작성했는데, 둘 다 Forth에 필요한 함수 호출 스택이 없었음
    SUBLEQ는 간접 로드/저장도 허용하지 않아서, 조금이라도 복잡한 일을 하려면 자기 수정 코드가 필요함
    두 머신 모두 그런 기능을 수행할 수 있는 가상 머신을 만들고, 협력적 멀티스레딩도 넣는 방식으로 접근했음
    힙이 필요하면 Forth로 작성하고, 부동소수점 워드 집합도 Forth로 작성함. 여러 MCU에는 여전히 부동소수점 명령이 없고, 이를 구현한 소프트웨어 함수 호출로 처리할 수 있음
    다른 컴파일러들도 언급되지는 않았지만 비슷한 방식을 썼을 것 같음. 일부 BASIC 인터프리터도 VM을 구현한 뒤 그것을 대상으로 삼았고, P-Code도 비슷함

    • TI-99/4A에는 CPU가 직접 접근 가능한 주 RAM이 256바이트, 즉 128워드뿐이었음
      기본 시스템 메모리 대부분은 비디오 RAM이었고, 비디오 칩 레지스터를 poke/peek하는 꽤 번거로운 절차로 접근해야 했음
      비디오 칩은 자동 증가하는 현재 메모리 포인터를 유지해서 연속 읽기나 쓰기 때 포인터가 1씩 증가했지만, 시스템 메모리 대부분이 이런 방식으로만 접근 가능하다는 사실 자체가 큰 프로그램 작성을 어렵게 만들었음
      그래서 TI는 GPL이라는 추상 머신을 만들어 이 비디오 RAM 접근을 더 자연스럽게 했음. 다만 TMS9900 위에서 해석 실행되므로 네이티브 코드보다 느렸고, CPU가 비디오 칩 RAM에 접근할 수 있는 시점도 수평/수직 귀선 기간처럼 칩이 화면 스캔아웃을 하지 않을 때뿐이라 더 느렸음
      BASIC 코드와 변수도 전부 이 비디오 메모리에 있었으니, TI-99/4A의 BASIC 인터프리터가 무엇으로 작성됐는지도 뻔함. 전혀 빠르지 않았음
      흥미로운 부분은 TMS9900에는 실제 범용 레지스터가 없었다는 점임. 작업공간 레지스터 WR0~WR15는 메모리 어딘가에 있었고, WP 작업공간 포인터 레지스터가 이를 가리켰음
      CPU의 물리 레지스터는 PC, WP, 상태 레지스터 세 개뿐이었음. 결과적으로 아주 원시적인 레지스터 윈도잉을 할 수 있었고, BLWP 명령으로 분기하면 메모리의 다른 위치에 있는 새 “레지스터” 집합이 활성화되며 반환 주소가 새 작업공간에 저장됐음
      요즘 TI-99/4A 얘기를 자주 하는 건 개인 프로젝트로 이 기종용 어셈블러를 만들고 있기 때문임
    • Forth와 Subleq를 배우고 파고들다가 그 작업들을 봤음. 접근 방식을 읽는 게 좋았고, 책을 사고 싶었는데 Amazon에서 안 된다고 함. 재판이 있을지 궁금함
    • subleq 얘기를 하려 했는데, 거기서는 “Hello world” 하나 쓰기도 정말 어렵다
  • 일부 프로세서가 서브루틴 첫 명령어 바로 앞 워드에 반환 주소를 저장했다는 말은 맞고, PDP-8이 그랬음
    PDP-8의 진화는 재귀를 위한 하드웨어 지원의 여정이라고도 볼 수 있음
    처음에는 JMS 명령이 함수의 첫 워드에 반환 주소를 박아 넣었음. 호출자가 JMS 명령 뒤에 인자를 두고, 피호출자가 반환 명령 기준 오프셋으로 인자를 읽으면서 매번 증가시켜 반환 주소가 다시 코드 위치를 가리키게 하는 경우도 많았음
    이후에는 자동 증가 위치 중 하나를 사용해 간단한 스택을 만드는 방식이 꽤 흔해졌음. PDP-8에는 포인터로 사용할 때마다 증가하는 메모리 위치 8개가 있었고, 함수 프롤로그/에필로그가 이 스택을 직접 관리해 완전한 재귀를 가능하게 했음
    더 나중에는 Harris 6120 같은 마이크로프로세서 구현에 하드웨어 스택이 추가되어 성능이 좋아졌음

    • 1956년의 Librascope LGP-30에는 R 명령, 즉 반환 주소 저장 명령이 있었음
      이 명령은 이미 증가된 PC+1을 대상 위치의 명령어 주소 부분에 저장했고, 관례상 그 대상은 서브루틴 시작 직전의 무조건 분기 명령이었음
      R 명령 뒤에는 해당 서브루틴으로 가는 U 무조건 분기 명령을 뒀음
      서브루틴은 자기 앞 주소로 분기해서 반환했고, 그곳에는 호출 지점 바로 다음으로 돌아가는 무조건 분기가 들어 있었음
      더 발전된 호출 규약을 쓰지 않는 한 재귀는 불가능했음. 그리고 어셈블리 언어의 모든 명령어 코드는 한 글자였음
    • IBM 1800, IBM 1130과 그 시대의 많은 기계들도 그랬음. Xerox Sigma 계열처럼 레지스터가 충분한 기계는 이런 관행을 피할 수 있었음
  • AVR-8용으로 작성하는 프로그램에서는 C 호출 규약을 쓰는 게 미친 짓처럼 느껴질 때가 있음
    어셈블리를 쓰면 내부 루프 변수를 큰 레지스터 파일 안에 계속 들고 있을 수 있고, 아니면 글에서 설명한 방식들을 쓸 수 있음
    이런 앱에서 함수를 “색칠”하는 방식도 좋음. 빨간 함수와 초록 함수가 동시에 활성화되지 않는다는 걸 알면, 둘의 지역 변수나 매개변수를 재사용할 수 있음

    • 제약된 환경에서 작업할 때, 특히 데스크톱 운영체제의 편의에 익숙하면 C의 스택 사용량은 직관적이지 않을 수 있음
      예전에 합류한 마이크로컨트롤러 코드베이스 프로젝트에서 여러 개발자가 몇 주 동안 여러 하위 시스템의 잡기 어려운 버그를 추적하고 있었음
      코드를 옮기면 버그도 따라 옮겨 다녔음. 조금 추적하고 함정을 설치해 보니, 호출 스택이 너무 깊어져 다른 자료구조를 덮어쓰는 코드 위치들을 찾을 수 있었음
  • 처음 프로그래밍을 배울 때 정확히 이런 식으로 강제로 프로그래밍했음. 1970년대가 아니라 2001년에 그랬음
    첫 프로그래밍 경험이 게임 개발 도구 RPG Maker 2000이 제공하던 반그래픽 스크립팅 “언어”였기 때문임
    RM2K 스크립팅을 본 적 없다면 Scratch와 Emacs Paredit 모드를 섞은 것을 떠올리면 됨. 예: https://forums.rpgmakerweb.com/data/attachments/21/21958-f89...
    텍스트처럼 보이지만 텍스트처럼 편집할 수는 없고, 속성 대화상자가 붙은 블록으로만 편집함
    당연히 RPG Maker의 스크립팅 언어에는 스택 같은 멋진 것도 없었음. 재사용 가능한 서브루틴이 필요하면 매개변수용 비밀 전역 변수를 할당해야 했고, 재진입성은 없었음
    돌이켜보면 충분히 고집을 부리면 RPG Maker 2000 안에 레지스터와 런타임 스택을 둘 다 구현할 수도 있었을 것 같음
    처음엔 쉬워 보임. 6502의 zero page 같은 가짜 “레지스터”를 만들 수 있고, 간접 변수 접근(https://rpgmaker.net/tutorials/523/)으로 스택도 만들 수 있음
    문제는 RM2K에 “parallel process” 스크립트 형태의 동시성이 있다는 점임. 이런 추상화를 병렬 프로세스들이 쓰면 서로 다른 “스레드”가 상태를 마구 덮어씀
    따라서 “가상 코어”마다 여러 개의 zero page와 스택이 필요하고, 각 병렬 스크립트에 가상 코어를 할당/바인딩/스케줄해야 함. 즉 각 스크립트가 자기만 아는 스택 포인터를 어떻게든 갖게 해야 함
    경쟁 상태에도 안정적으로 만들려면 보통 뮤텍스 같은 것이 필요함
    RPG Maker 게임 개발자들의 집요함을 생각하면 누군가 런타임 기능 하나를 속여 뮤텍스처럼 동작하게 만드는 방법을 찾았을 것 같지만, 실제로 뭘 했는지는 솔직히 알고 싶지 않을 정도로 무서움

    • 나도 rpgmaker로 시작했고, 이 얘기를 보니 정말 향수가 돋음
      rpgmaker.net에서 custom battle system이 구현된 게임을 내려받은 기억이 있음. 내장 전투 시스템 전체를 당신이 설명한 것 같은 기법으로 갈아엎은 구현이었음
      에디터로 열어 작동 방식을 봤을 때 완전히 압도됐음. 수백 개의 “변수”가 있었고, 기억이 맞다면 i64만 허용됐으며, 수백 개의 “스위치”도 있었음. 스위치는 불리언이었음
      그때는 스택, 힙, 함수 호출 같은 개념이 전혀 없었음
      그걸 만들고 유지보수/디버그하는 데 들어간 에너지가 어느 정도였을지 상상도 안 됨
  • 기억이 맞다면 ZX81에서 BASIC 프로그램을 쓸 때 “스택 없음”에 가까운 방식으로 작성했음
    1 GOTO 30
    10 LET C = A + B
    20 RETURN
    30 LET A = 1
    40 LET B = 2
    50 GOSUB 10
    60 LET A = C
    70 LET B = 3
    80 GOSUB 10
    90 PRINT C
    RUN
    6
    글에서 컴파일러가 하는 일을 직접 하고 있었던 셈임. 줄 번호는 메모리 주소이고, 숨겨진 변수들은 내게 숨겨져 있지 않았음. 내가 컴파일러였기 때문임
    인터프리터가 해준 유일한 일은 GOSUB의 반환 주소를 저장하는 것이었음
    단, 코드는 문법적으로 틀렸거나 기억이 왜곡됐을 수 있음. 40년은 긴 시간이지만 전반적인 아이디어는 맞음
    또 기계 안의 Z80 프로세서에는 스택 관리 기능이 있었음. BASIC 인터프리터는 정말 단순했지만 변명거리는 있음. RAM 1KB와 OS, 인터프리터, 전부를 담은 ROM 8KB뿐이었음

    • 그것도 적어도 호출 스택은 쓰는 것임. GOSUBRETURN이 참조할 줄 번호나 다른 참조를 저장하고, GOSUB 호출을 중첩하면 여러 반환 지점을 기억해야 하므로 어떤 형태로든 스택이 필요함
      다만 일부 BASIC에는 범용 스택 대신 반환 포인터의 고정 배열과 현재 위치 인덱스만 있어서, 예를 들어 호출 깊이가 7로 고정되는 식이었음. 프로그래머 입장에서는 호출 스택처럼 동작함
      물론 누군가 스택이라고 할 때 기대할 법한 지역 변수/매개변수가 있는 “제대로 된” 스택은 아님
      BBC BASIC의 기본 환경에서는 중첩 호출, 재귀 포함, 때 무슨 일이 일어나는지 보여주는 재미있는 데모가 가능했음. 스택 위치를 디스플레이 메모리 맨 위로 잡고 그쪽에 뭔가 그려지지 않게 하면, 작업이 진행되며 스택이 자라는 걸 볼 수 있었음
      화면 해상도가 낮아서 반환 주소 2바이트가 화면 모드 1이나 5에서는 굵은 픽셀 8개로 보였음. 모드 2에서는 4개지만 깜박이는 색이 들어가 덜 좋고, 모드 0, 3, 4, 6에서는 16개지만 비트 단위로 보는 건 색 8개 반복보다 알아보기 어려웠음
  • 임의로 확장 가능한 힙이 있기 전에는 프로그래머들이 적어도 약간의 공학적 판단을 했음
    입력의 확률적 분포를 고려하고, 모든 중간 저장 공간의 크기를 적절히 잡아야 했기 때문임
    그래서 “BUGS AND LIMITATIONS”가 생겼음

    • 그런 옛날 방식은 무엇을 하느냐에 따라 지금도 현재형임. 하드 실시간에서는 동적 메모리를 거의 쓰지 않는데, 주된 이유는 메모리 할당/해제 시간이 결정적이지 않기 때문임
      그래서 전부 컴파일 시점에 정적으로 할당하고, 입력이 얼마나 많은 메모리를 소비할지 알아야 함
      하지만 메모리 소비의 상한을 아는 일은 애플리케이션 프로그래머에게도 예전에는 정상적인 일이었음. 메모리 부족은 절대 원하지 않기 때문임
      요즘은 그냥 메모리 사용량을 YOLO로 두는 건가 싶음
    • 역사적으로 GNU의 큰 목표 중 하나도 그런 것이었음. 핵심 유틸리티의 인위적인 제한을 없애려 했음
      예를 들어 sed의 최대 명령 길이가 유한하고 짧은 식의 제한이 있던 것에 비하면 큰 개선이었음
    • 사실 실수는 인간이 컴퓨터 프로그램에 입력을 제공하게 만든 데 있었음
  • 함수형 프로그래밍을 너무 오래 해서, 재귀 없이 어떻게 코드를 쓸지 진심으로 떠올리기 어렵다
    재귀 알고리즘을 반복 알고리즘으로 바꾸는 방법을 기술적으로는 알고 있고, 자원 제약이 큰 곳에서 해본 적도 있지만 좋아하지는 않음
    보통 재귀 쪽이 더 예쁘고, 99%의 경우 충분히 빠르다고 봄. 컴파일러가 꼬리 재귀를 지원하면 100%에 가깝지만, 더 흥미로운 대부분의 작업에서는 어차피 스택을 직접 유지해야 함
    가끔 내가 태어나기 전에는 어떻게 했는지 배우려고 일부러 그런 작업을 함. Commodore 64 게임을 가끔 만지작거리고 있는데, 빠르고 싸고 쓰기 쉬운 하드웨어에 익숙해진 지금이 얼마나 호사인지 크게 느껴짐

    • 요즘 명령어 집합은 확실히 훨씬 유용함
      그런 오래된 기계에서 재귀를 하려면 직접 스택 메커니즘을 만들어야 했고, 그래도 전역 저장소 말고는 기본적으로 쓸 방법이 없어서 처리해야 할 문제가 남아 있었음
      그 시절을 살아봤지만, 누구에게도 권하고 싶지 않음
  • Enhanced GNU Awk의 @let 기능에서 함수 밖, 예를 들어 BEGIN이나 END 블록 안의 @let 블록은 컴파일러가 비밀 전역 변수를 할당하게 해두었음
    이 변수들은 블록 사이에서 가능한 한 재사용됨
    $ ./gawk --dump-variables 'BEGIN { @let (a, b, c = 1) { } }'
    $ cat awkvars.out
    $let0001: untyped variable
    $let0002: untyped variable
    $let0003: 1
    ARGC: 1
    ARGIND: 0
    ARGV: array, 1 elements
    BINMODE: 0
    [ .. snip many ]
    https://www.kylheku.com/cgit/egawk/about/

    • 그 웹사이트는 내 ISP에서 동작하지 않음. ping도 안 되고 nc -z 104.37.63.7 443도 안 됨
      업데이트: 보안 인프라가 망가진 것 같음. 나는 그게 뭔지도 모르고 Twitter도 쓰지 않음. AS를 확인하면 Google Fiber임
      그리고 내 신상 털기는 하지 않았으면 함