이 글 요약
- 1~8편에서 지나가듯 등장한 개념 11가지를 시리즈의 실제 장면에서 출발해 정리했다
- 주제마다 "시리즈 장면 → 개념 → 비유 → 면접에서는" 순서로 쓴다
- 네트워크 4개, 서버와 보안 4개, DB와 운영 3개. 이번 편만 특별히 길다
8편 동안 서버를 옮기면서 많은 명령어를 쳤다. 그런데 명령어가 왜 그렇게 동작하는지는 대부분 한 줄로 넘어갔다. CNAME을 바꿨더니 서버가 바뀌었고, 포트를 안 열었는데 접속이 됐고, --single-transaction을 붙였더니 덤프가 안전해졌다. 결과는 봤지만 이유는 건너뛰었다.
이번 부록은 그 이유들을 모은 편이다. 주제는 열한 개이고, 모두 시리즈에 실제로 나온 장면에서 출발한다. 교과서 순서가 아니라 이사 순서다.
각 주제는 같은 틀로 쓴다.
- 시리즈 장면: 몇 편의 어떤 장면에서 이 개념이 나왔는지
- 개념: 그 장면 뒤에서 실제로 일어나는 일
- 비유: 한 번에 기억하기 위한 그림
- 면접에서는: 이 개념을 물어볼 때 나오는 질문과 한 줄 답
길다. 한 번에 다 읽지 않아도 된다. 필요한 주제만 골라 읽어도 되게 썼다.
Part 1. 네트워크
1. DNS와 도메인
시리즈 장면
5편에서 서버를 맥북에서 오라클로 넘긴 순간은 DNS 레코드 한 줄을 고친 순간이었다. powerup이라는 이름의 CNAME 대상을 옛 터널 ID에서 새 터널 ID로 바꿨고, 저장하자마자 사용자는 새 서버로 갔다. 앱도, 앱 사용자도 아무것도 바꾸지 않았다.
개념
컴퓨터는 서로를 IP 주소로 찾는다. 사람은 powerup.example.com 같은 이름을 기억한다. 이 이름을 IP 주소로 바꿔 주는 시스템이 DNS(Domain Name System)다.
도메인은 계층 구조다. 오른쪽에서 왼쪽으로 읽는다.
powerup . example . com . (루트)
↑ ↑ ↑
서브도메인 도메인 최상위 도메인(TLD)
맨 오른쪽에 보이지 않는 점(.)이 루트다. 루트 아래에 com, kr, uk 같은 최상위 도메인이 있고, 그 아래에 내가 산 도메인이 있고, 그 아래에 내가 마음대로 만드는 powerup, lovetoday 같은 서브도메인이 있다.
브라우저가 주소를 찾아가는 순서는 대략 이렇다.
- 내 컴퓨터의 캐시를 본다. 최근에 찾아본 적이 있으면 여기서 끝난다.
- 없으면 리졸버(보통 통신사나 8.8.8.8, 1.1.1.1 같은 공용 DNS)에 물어본다.
- 리졸버도 모르면 루트 서버에 묻는다. 루트는 "
com은 저 서버에 물어봐"라고 알려 준다. - TLD 서버(
com담당)에 묻는다. "example.com은 저 네임서버가 담당한다"고 알려 준다. - 권한 있는 네임서버(내 도메인을 실제로 관리하는 서버, 내 경우 Cloudflare)에 묻는다. 여기서 최종 답이 나온다.
- 리졸버는 답을 내 컴퓨터에 돌려주고, 둘 다 한동안 기억해 둔다.
레코드 종류는 이 정도만 알아도 충분하다.
| 레코드 | 뜻 | 예 |
|---|---|---|
| A | 이름 → IPv4 주소 | powerup → 123.45.67.89 |
| AAAA | 이름 → IPv6 주소 | |
| CNAME | 이름 → 다른 이름 (별명) | powerup → <터널ID>.cfargotunnel.com |
| MX | 이 도메인의 메일 서버 | |
| TXT | 아무 글자. 도메인 소유 확인 등에 쓴다 | |
| NS | 이 도메인을 관리하는 네임서버 |
내 서버 이전이 레코드 한 줄로 끝난 건 CNAME 덕분이다. powerup이 IP를 직접 가리키지 않고 "터널의 이름"을 가리키고 있었으니, 그 이름만 바꾸면 됐다.
TTL(Time To Live)은 "이 답을 몇 초 동안 기억해도 되는지"다. 리졸버와 내 컴퓨터는 TTL 동안 같은 질문을 다시 하지 않는다. 그래서 DNS를 고쳐도 TTL이 길면 한동안 옛 주소로 가는 사용자가 생긴다. 서버를 옮기기 전에 TTL을 짧게 줄여 두는 이유다.
내 경우는 조금 특별했다. Cloudflare에서 프록시(주황 구름)를 켠 레코드는 외부에 Cloudflare의 IP를 알려 준다. 실제로 뒤에서 어디로 보낼지는 Cloudflare 안에서 정한다. 그래서 대상을 바꾸자 첫 요청부터 새 서버로 갔다. 바깥에서 기억하고 있던 주소(Cloudflare IP)는 바뀌지 않았기 때문이다.
비유
DNS는 전화번호부다. 이름을 찾으면 번호가 적혀 있다. CNAME은 번호 대신 "이 사람은 총무팀으로 연결"이라고 적어 둔 항목이다. 담당자가 바뀌어도 번호부는 그대로 두고 총무팀 안에서만 바꾸면 된다. TTL은 번호를 메모해 둔 쪽지의 유효기간이다. 쪽지가 남아 있는 동안은 번호부를 다시 보지 않는다.
면접에서는
Q. 브라우저에
www.naver.com을 입력하면 어떤 일이 일어나나요? 캐시 확인 → 리졸버 → 루트 → TLD → 권한 있는 네임서버 순서로 IP를 찾고, 그 IP로 TCP 연결과 TLS 핸드셰이크를 한 뒤 HTTP 요청을 보낸다. 이 질문은 DNS, TCP, TLS, HTTP를 한 번에 묻는 질문이라 다음 주제들과 이어서 답하면 좋다.
2. HTTPS와 TLS 인증서
시리즈 장면
5편에서 서버에 인증서를 설치하지 않았는데도 앱은 HTTPS로 동작했다. Cloudflare가 대신 처리했다. 7편에서는 R2를 구독하자마자 접속했다가 TLS handshake failure를 만났다.
개념
HTTP는 내용을 그대로 보낸다. 같은 와이파이에 있는 누군가가 중간에서 볼 수 있다. HTTPS는 HTTP를 TLS로 감싼 것이다. TLS가 하는 일은 세 가지다.
- 암호화: 중간에서 봐도 내용을 알 수 없다.
- 무결성: 중간에서 내용을 바꾸면 바로 들킨다.
- 인증: 내가 접속한 서버가 진짜 그 도메인의 주인이 맞는지 확인한다.
이걸 위해 두 종류의 암호를 같이 쓴다.
| 대칭키 암호 | 잠그는 열쇠와 여는 열쇠가 같다. 빠르다 | 실제 데이터 전송 (AES 등) |
| 비대칭키(공개키) 암호 | 공개키와 개인키 한 쌍. 느리다 | 상대가 진짜인지 확인, 대칭키를 안전하게 정하기 |
대칭키는 빠르지만 문제가 있다. 처음 만난 상대와 같은 열쇠를 어떻게 나눠 가질까. 열쇠를 보내는 순간 중간에서 훔쳐볼 수 있다. 그래서 처음에만 비대칭키 방식으로 안전하게 대칭키를 합의하고, 그 뒤로는 빠른 대칭키로 데이터를 주고받는다. 이 처음 과정이 TLS 핸드셰이크다.
핸드셰이크를 아주 단순하게 줄이면 이렇다.
- 브라우저: "TLS 1.3으로 하자. 내가 쓸 수 있는 암호 방식은 이것들이야." (키 합의용 값도 같이 보낸다)
- 서버: "좋아, 이 방식으로 하자. 이건 내 인증서야." (서버 쪽 키 합의 값과 서명도 같이 보낸다)
- 브라우저: 인증서가 믿을 만한지 확인한다. 둘 다 같은 대칭키를 계산해 낸다.
- 이후의 모든 데이터는 그 대칭키로 암호화된다.
인증서에는 도메인 이름, 서버의 공개키, 유효기간, 그리고 인증기관(CA)의 서명이 들어 있다. 브라우저와 운영체제에는 믿을 만한 최상위 인증기관 목록이 처음부터 들어 있다. 브라우저는 "이 인증서에 믿을 만한 기관이 서명했나"를 따라 올라가며 확인한다. 이걸 신뢰 체인이라고 한다.
TLS handshake failure는 이 악수 과정이 중간에 끊겼다는 뜻이다. 서로 지원하는 방식이 안 맞거나, 서버 쪽 설정이 아직 준비되지 않았을 때 난다. R2는 구독 직후라 준비가 덜 끝났던 것으로 보이고, 몇 분 뒤 그대로 해결됐다.
내 구조에서 TLS는 두 구간에 따로 있다.
[사용자] ──TLS(Cloudflare 인증서)──> [Cloudflare] ──터널(암호화)──> [cloudflared] ──http──> [앱]
사용자 쪽 인증서는 Cloudflare가 발급하고 갱신한다. Cloudflare와 내 서버 사이는 터널 자체가 암호화된다. 서버 안의 마지막 구간만 평문 HTTP인데, 같은 서버 안이라 밖으로 나가지 않는다.
비유
비대칭키는 우체통이다. 넣는 구멍(공개키)은 누구나 쓸 수 있지만, 여는 열쇠(개인키)는 주인만 갖고 있다. 대칭키는 둘만 아는 금고 비밀번호다. 처음 만난 두 사람이 우체통을 이용해 금고 비밀번호를 몰래 정하고, 그다음부터는 금고로 물건을 주고받는 셈이다. 인증서는 신분증이고, 인증기관은 신분증을 발급하는 주민센터다. 아무나 만든 신분증은 받아 주지 않는다.
면접에서는
Q. HTTPS는 어떻게 안전하게 통신하나요? TLS 핸드셰이크에서 인증서로 서버를 확인하고 비대칭키 방식으로 대칭키를 합의한 뒤, 실제 데이터는 빠른 대칭키로 암호화해서 주고받는다. "왜 처음부터 끝까지 비대칭키를 쓰지 않나요?"가 이어서 나오면 "느려서"가 답이다.
3. 포트, 방화벽, 인바운드와 아웃바운드
시리즈 장면
5편의 핵심은 "포트를 하나도 열지 않았는데 앱이 인터넷에서 동작한다"였다. 서버의 방화벽 규칙을 열어 보면 22번만 허용되어 있었다. 그런데 그 규칙 맨 위에 이런 줄이 있었다.
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT
-A INPUT -j REJECT --reject-with icmp-host-prohibited
개념
IP 주소가 건물 주소라면 포트는 호수다. 한 서버 안에서 여러 프로그램이 동시에 통신하니, 어느 프로그램에게 온 데이터인지 구분하는 번호가 필요하다. 0번부터 65535번까지 있다.
| 22 | SSH |
| 80 | HTTP |
| 443 | HTTPS |
| 3306 | MySQL |
| 8080, 8083 | 내 앱들 (개발자가 정한 번호) |
프로그램이 "이 포트로 오는 연결을 받겠다"고 기다리는 걸 리슨(listen)이라고 한다. 이때 어느 주소에서 기다리느냐가 중요하다.
127.0.0.1:3306: 서버 자기 자신에서 오는 연결만 받는다. 6편에서 MySQL을 이렇게 띄웠다.0.0.0.0:8080또는*:8080: 모든 네트워크에서 오는 연결을 받는다. 내 앱은 이렇게 떠 있었다.
앱이 모든 주소에서 기다리는데도 밖에서 접속이 안 되는 건 방화벽 덕분이다. 방화벽은 들어오고 나가는 연결을 규칙에 따라 허용하거나 막는다.
- 인바운드: 밖에서 안으로 들어오는 연결
- 아웃바운드: 안에서 밖으로 나가는 연결
대부분의 방화벽은 상태를 기억(stateful)한다. 위 규칙의 첫 줄 RELATED,ESTABLISHED -j ACCEPT가 그 뜻이다. "내가 먼저 시작한 연결에 대한 응답은 들여보낸다." 그래서 아웃바운드로 나간 연결의 응답은 들어오고, 모르는 쪽에서 새로 시작한 연결(NEW)은 22번이 아니면 막힌다.
Cloudflare Tunnel이 포트 없이 동작하는 이유가 여기 있다. 서버의 cloudflared가 Cloudflare로 먼저 나가는 연결을 맺어 두고, 사용자의 요청은 그 연결을 타고 응답처럼 들어온다. 방화벽 입장에서는 내가 시작한 연결의 일부라 허용된다.
하나 더 있다. 오라클 서버의 실제 IP는 10.0.0.x 같은 사설 IP다. 인터넷에서 보이는 공인 IP는 오라클의 네트워크 장비가 사설 IP와 짝지어 바꿔 준다. 이렇게 주소를 바꿔 주는 걸 NAT라고 한다. 집 공유기도 같은 일을 한다. 공유기 안의 기기들은 192.168.x.x 같은 사설 IP를 쓰고, 밖에서는 공유기의 공인 IP 하나로 보인다. 맥북 시절에도 터널이 됐던 건, 공유기 뒤의 맥북이 밖으로 먼저 나가는 연결을 만들었기 때문이다. 같은 원리다.
비유
IP는 아파트 주소, 포트는 호수다. 방화벽은 경비실이다. 경비실은 "택배 시킨 집에 오는 택배 기사는 들여보낸다(ESTABLISHED)"는 규칙을 갖고 있다. 아무도 부르지 않은 방문객(NEW)은 관리사무소(22번)만 갈 수 있다. 터널은 내가 택배 기사를 미리 불러 두고, 손님이 그 기사 편으로 오게 하는 방식이다.
면접에서는
Q. 방화벽에서 인바운드를 모두 막았는데 어떻게 응답을 받을 수 있나요? 상태 기반 방화벽은 내부에서 시작한 연결을 기억하고 그 응답 패킷은 허용하기 때문이다. Cloudflare Tunnel이나 대부분의 원격 접속 에이전트가 아웃바운드 연결만으로 동작하는 원리가 이것이다.
4. 프록시와 리버스 프록시
시리즈 장면
5편의 DNS 화면에서 레코드마다 "프록시 상태: 프록시됨"과 주황 구름이 있었다. 이걸 꺼 두면 터널이 동작하지 않는다고 적었다. 그리고 일반적인 서버 구성 그림에는 항상 nginx가 앱 앞에 서 있었다.
개념
프록시는 "대신 요청해 주는 중간자"다. 누구를 대신하느냐에 따라 두 가지로 나뉜다.
| 포워드 프록시 | 클라이언트(사용자) 편 | 사용자 쪽 | 회사 프록시, 학교 필터링 |
| 리버스 프록시 | 서버 편 | 서버 앞 | nginx, Cloudflare |
포워드 프록시는 사용자의 요청을 대신 내보낸다. 서버는 진짜 사용자가 누군지 모르고 프록시만 본다. 회사가 직원들의 인터넷 접속을 한 출구로 모으고 검사하는 게 이것이다.
리버스 프록시는 반대로 서버 앞에 서서 요청을 대신 받는다. 사용자는 진짜 서버가 어디 있는지 모르고 프록시만 본다. 리버스 프록시가 맡는 일은 이렇다.
- TLS 처리: 인증서를 들고 HTTPS를 풀어서 뒤의 앱에는 평문으로 넘긴다. 앱은 인증서를 몰라도 된다.
- 라우팅: 주소나 경로를 보고 어느 앱으로 보낼지 정한다. 내 터널의
powerup → :8080,lovetoday → :8083이 정확히 이것이다. - 로드 밸런싱: 같은 앱 여러 대에 요청을 나눠 준다.
- 캐시: 자주 요청되는 응답을 저장해 두고 대신 돌려준다.
- 보호: 진짜 서버 IP를 숨기고, 공격성 요청을 앞에서 거른다.
Cloudflare의 주황 구름은 "이 도메인에 대해 Cloudflare가 리버스 프록시 노릇을 하라"는 스위치다. 켜면 DNS 조회 결과로 내 서버가 아니라 Cloudflare의 IP가 나간다. 회색(DNS만)으로 끄면 DNS가 진짜 주소를 그대로 알려 준다. 터널은 Cloudflare가 요청을 받아서 터널로 넘겨 주는 구조라, 프록시가 꺼져 있으면 받을 사람이 없다.
비유
포워드 프록시는 심부름센터다. 내가 직접 가게에 가지 않고 심부름꾼이 대신 사 온다. 가게는 심부름꾼만 본다. 리버스 프록시는 회사 안내 데스크다. 방문객은 안내 데스크에만 말하고, 안내 데스크가 알맞은 부서로 연결해 준다. 방문객은 그 부서가 몇 층에 있는지 몰라도 된다.
면접에서는
Q. 포워드 프록시와 리버스 프록시의 차이는? 포워드 프록시는 클라이언트를 대신해서 클라이언트를 숨기고, 리버스 프록시는 서버를 대신해서 서버를 숨긴다. 리버스 프록시는 TLS 처리, 라우팅, 로드 밸런싱, 캐시를 맡는다고 덧붙이면 좋다.
Part 2. 서버와 보안
5. 프로세스와 데몬
시리즈 장면
1편과 5편에서 맥북의 터널은 설정 파일이 지워진 상태로도 몇 달 동안 돌고 있었다. 6편에서 본 systemd 설정은 본문에서 줄였지만, 실제 서버 파일에는 SuccessExitStatus=143이라는 줄이 하나 더 있다. 7편에서는 비밀번호를 -p비밀번호 대신 환경 변수로 넘겼다.
개념
프로그램은 디스크에 있는 실행 파일이다. 프로세스는 그 프로그램이 실행되어 메모리에 올라간 상태다. 같은 프로그램을 두 번 실행하면 프로세스가 두 개 생긴다. 각 프로세스는 고유 번호(PID), 자기만의 메모리 공간, 환경 변수, 열어 둔 파일 목록을 갖는다.
맥북 터널의 미스터리가 여기서 풀린다. cloudflared는 시작할 때 한 번 설정 파일을 읽어 메모리에 올린다. 그 뒤에 디스크의 파일이 지워져도, 프로세스의 메모리에 있는 설정은 그대로다. 프로세스가 살아 있는 한 아무 문제가 없다. 문제는 프로세스가 다시 시작될 때다. 재부팅, 업데이트, 충돌 후 재시작. 그 순간 디스크에서 설정을 다시 읽으려다 실패한다. "돌아가는 것"과 "다시 돌아갈 수 있는 것"은 다르다.
데몬은 사용자와 대화하지 않고 뒤에서 계속 도는 프로세스다. 웹 서버, DB, cloudflared가 모두 데몬이다. 이름 끝에 d가 붙는 경우가 많다(sshd, mysqld, systemd).
데몬을 관리하는 관리자가 따로 있다. 리눅스의 systemd, 맥의 launchd다. 둘 다 운영체제가 켜질 때 가장 먼저 뜨는 프로세스(PID 1)이고, 다른 데몬을 켜고, 죽으면 다시 살리고, 로그를 모은다. 6편의 Restart=always, After=docker.service가 이 관리자에게 주는 지시다.
시그널은 운영체제가 프로세스에게 보내는 짧은 신호다. systemctl stop이나 restart는 앱에 SIGTERM(15번, "정리하고 종료해 줘")을 보낸다. 자바 앱은 SIGTERM을 받으면 정리하고 종료하는데, 이때 종료 코드가 143(128 + 15)이다. systemd는 기본적으로 0이 아닌 종료 코드를 실패로 본다. 그래서 SuccessExitStatus=143을 적어 "이건 정상 종료다"라고 알려 준 것이다. 끝까지 안 죽으면 SIGKILL(9번, 강제 종료)을 보낸다. SIGKILL은 프로세스가 거부할 수 없다.
환경 변수는 프로세스가 시작될 때 물려받는 이름=값 목록이다. 7편에서 비밀번호를 -p비밀번호 대신 MYSQL_PWD 환경 변수로 넘긴 이유가 있다. 명령줄 인자는 ps 명령으로 서버의 다른 사용자도 볼 수 있다. 환경 변수는 리눅스에서 그 프로세스의 주인과 root만 볼 수 있다. 다만 환경 변수도 완벽하지는 않다. 같은 사용자 권한을 얻은 공격자는 볼 수 있다. 더 엄격하게 하려면 권한을 좁힌 설정 파일에서 읽게 한다. 보안은 "안전/위험"이 아니라 "누구에게까지 보이느냐"의 문제다.
비유
프로그램은 레시피 책, 프로세스는 그 레시피로 지금 요리 중인 요리사다. 요리사는 레시피를 한 번 읽고 머릿속에 넣은 뒤 요리를 계속한다. 그사이 누가 레시피 책을 버려도 요리는 계속된다. 다음 날 출근해서 다시 만들려고 할 때 비로소 책이 없다는 걸 안다. systemd는 주방장이다. 요리사를 배치하고, 쓰러지면 대신할 사람을 세운다. SIGTERM은 "마감이니 정리하세요", SIGKILL은 "지금 당장 나가세요"다.
면접에서는
Q. 프로세스와 스레드의 차이는? 프로세스는 독립된 메모리 공간을 가진 실행 단위이고, 스레드는 한 프로세스 안에서 메모리를 공유하며 실행되는 흐름이다. 프로세스끼리는 서로의 메모리를 직접 볼 수 없어서 하나가 죽어도 다른 프로세스에 영향이 적고, 스레드는 가볍지만 공유 메모리 때문에 동기화 문제가 생긴다.
6. 암호화, 해시, HMAC
시리즈 장면
7편에서 restic은 백업을 올리기 전에 내 서버에서 암호화했다. 비밀번호를 잃으면 데이터가 "irrecoverably lost"라고 경고했다. 6편에서는 서명 없는 사진 주소가 403을 냈고, 서명을 붙이자 열렸다. 3편에서는 비밀번호 대신 SSH 키로 서버에 들어갔다.
개념
셋은 비슷해 보이지만 목적이 다르다.
| 암호화 | 키가 있으면 된다 | 필요 | 내용을 숨긴다 |
| 해시 | 안 된다 | 없음 | 내용의 지문을 만든다 |
| HMAC | 안 된다 | 비밀키 필요 | 위조되지 않았음을 증명한다 |
암호화는 열쇠로 잠그고 같은(또는 짝이 되는) 열쇠로 연다. restic은 비밀번호에서 암호화 키를 만들어 백업을 잠근다. 암호화가 제대로 됐다는 건, 비밀번호 없이는 나도 못 연다는 뜻이다. 그래서 비밀번호를 서버 밖에 따로 보관했다.
해시는 어떤 크기의 데이터든 정해진 길이의 값으로 바꾼다. SHA-256이면 항상 64자리 16진수가 나온다.
- 같은 입력은 항상 같은 결과가 나온다.
- 입력이 한 글자만 바뀌어도 결과가 완전히 달라진다.
- 결과에서 원래 입력을 되돌려 알아낼 수 없다.
그래서 해시는 "같은 파일인가"를 확인할 때 쓴다. 다운로드한 파일이 원본과 같은지, 백업 데이터가 손상되지 않았는지. 7편의 restic check도 저장된 조각의 해시를 다시 계산해 비교한다. 비밀번호를 DB에 저장할 때도 원문 대신 해시를 저장한다. 이때는 같은 비밀번호가 같은 해시가 되지 않게 무작위 값(솔트)을 섞고, 일부러 느린 해시(bcrypt, Argon2 등)를 써서 무작위 대입을 어렵게 만든다.
HMAC은 해시에 비밀키를 섞은 것이다. LoveToday의 사진 주소는 이렇게 생겼다.
/files/사진이름.jpg?exp=만료시각&sig=서명값
서명값은 서버만 아는 비밀키로 사진이름|만료시각의 HMAC을 계산한 값이다. 누가 주소의 만료시각을 바꾸거나 다른 사진 이름을 넣으면, 비밀키를 모르니 맞는 서명을 만들 수 없다. 서버는 받은 주소로 서명을 다시 계산해서 비교하고, 다르면 403을 준다. 데이터를 숨기는 게 아니라 위조를 막는 것이 목적이다. 로그인할 때 받는 JWT 토큰의 서명도 같은 원리다.
SSH 키는 공개키 암호를 로그인에 쓴다. 서버에는 공개키가, 내 컴퓨터에는 개인키가 있다. 접속할 때 서버가 문제를 내면 내 컴퓨터가 개인키로 서명해서 답한다. 서버는 공개키로 그 서명이 맞는지만 확인한다. 개인키는 한 번도 네트워크를 건너가지 않는다. 비밀번호는 서버로 보내야 하니 중간에서 노출되거나 무작위 대입으로 뚫릴 수 있지만, 키 방식은 그럴 여지가 없다.
비유
암호화는 자물쇠 달린 상자다. 열쇠가 있으면 연다. 해시는 지문이다. 사람을 보면 지문을 뜰 수 있지만, 지문만 보고 사람을 만들어 낼 수는 없다. HMAC은 도장 찍힌 영수증이다. 영수증 내용은 누구나 읽을 수 있지만, 가게 도장 없이 금액을 고치면 바로 위조인 걸 안다. 도장은 가게(서버)만 갖고 있다.
면접에서는
Q. 비밀번호를 DB에 어떻게 저장해야 하나요? 원문이나 되돌릴 수 있는 암호화로 저장하지 않고, 솔트를 섞은 느린 해시(bcrypt, Argon2 등)로 저장한다. 로그인할 때는 입력값을 같은 방식으로 해시해서 비교한다. "왜 암호화가 아니라 해시인가요?"에는 "서버도 원문을 알 필요가 없고, DB가 털려도 되돌릴 수 없어야 하니까"가 답이다.
7. 권한과 최소 권한 원칙
시리즈 장면
3편에서 SSH 키 파일에 chmod 400을 했다. 안 하면 SSH가 키를 거부한다. 6편에서는 앱을 root가 아닌 powerup 계정으로 돌렸고, 7편에서는 R2 키를 버킷 하나에만 쓸 수 있게 좁혔다.
개념
리눅스의 모든 파일에는 주인, 그룹, 나머지 사람 세 부류에 대해 각각 읽기(r), 쓰기(w), 실행(x) 권한이 붙는다. 숫자로 쓰면 r=4, w=2, x=1을 더한 값이다.
-rw------- → 600 (주인만 읽고 쓰기)
-r-------- → 400 (주인만 읽기)
-rw-r--r-- → 644 (주인은 읽고 쓰기, 나머지는 읽기만)
-rwxr-x--- → 750 (주인은 전부, 그룹은 읽기와 실행, 나머지는 없음)
시리즈에 나온 권한을 다시 보면 이유가 보인다.
| SSH 개인키 | 400 | 다른 사람이 읽을 수 있으면 SSH가 "보호되지 않은 키"라며 거부한다 |
| 비밀값 환경 파일 | 600 | 앱 비밀번호, API 키. root만 읽는다 |
| 백업 스크립트 | 750 | root와 지정 그룹만 실행. 아무나 백업을 돌리거나 고치면 안 된다 |
| 덤프 임시 폴더 | 700 | 회원 데이터가 잠깐 놓이는 곳이다 |
root는 모든 권한을 가진 관리자 계정이다. 아무 파일이나 읽고, 아무 프로세스나 죽인다. 그래서 앱을 root로 돌리면 안 된다. 앱에 보안 구멍이 하나만 있어도 공격자는 root 권한을 얻는다. 앱 전용 계정(powerup, lovetoday)으로 돌리면, 앱이 뚫려도 공격자가 할 수 있는 일은 그 계정이 할 수 있는 일까지다.
이 모든 걸 관통하는 원칙이 최소 권한 원칙이다. "필요한 일을 할 수 있을 만큼만 권한을 준다." 시리즈 곳곳에 이 원칙이 있었다.
- 앱은 전용 계정으로 돌린다. 그 계정은 로그인도 못 한다(
nologin). - DB 계정은 앱마다 따로 만들고, 자기 DB에만 권한을 준다.
- MySQL은 서버 안(
127.0.0.1)에서만 접속을 받는다. - R2 키는 버킷 하나에 "객체 읽기·쓰기"만 된다. 버킷을 만들거나 지울 수는 없다.
- 인바운드 포트는 SSH 하나만 연다.
어느 하나가 뚫려도 피해가 그 칸 안에서 멈추게 하는 것이다.
비유
최소 권한은 호텔 카드키다. 손님 카드키는 자기 방과 엘리베이터만 연다. 청소 직원 카드키는 객실 층만 열고, 금고실은 못 연다. 마스터키(root)는 지배인 서랍 안에 있다. 손님 카드키를 잃어버려도 그 방 하나만 바꾸면 된다. 모두에게 마스터키를 나눠 준 호텔은 카드키 하나 잃어버릴 때마다 건물 전체를 바꿔야 한다.
면접에서는
Q. 최소 권한 원칙이란 무엇이고, 실무에서 어떻게 적용하나요? 사용자, 프로세스, API 키에 작업에 꼭 필요한 권한만 주는 원칙이다. 앱 전용 계정으로 서비스 실행, DB 계정 분리, 범위를 좁힌 클라우드 키, 필요한 포트만 열기 같은 예를 들면 된다. 핵심은 "뚫리는 걸 막는 것"보다 "뚫렸을 때 피해를 가두는 것"이다.
8. CPU 아키텍처(ARM vs x86)와 가상화
시리즈 장면
3편에서 고른 서버는 Ampere A1, ARM CPU였다. 서버에서 uname -m을 치면 aarch64가 나왔고, 5편에서는 cloudflared를 arm64 패키지로 받았다. 그런데 맥북에서 빌드한 자바 앱(jar)은 아무것도 고치지 않고 그대로 돌았다. MySQL은 Docker로 띄웠다.
개념
CPU는 정해진 명령어만 알아듣는다. 이 명령어 목록을 명령어 집합 구조(ISA)라고 한다. 대표적인 두 계열이 있다.
| x86-64 | amd64, x86_64 | 대부분의 PC, 인텔 맥, 많은 서버 |
| ARM 64비트 | arm64, aarch64 | 스마트폰, 애플 실리콘 맥, Ampere 서버 |
arm64와 aarch64는 같은 것을 부르는 다른 이름이다. 패키지마다 이름 짓는 습관이 달라서 둘 다 보인다.
C나 Go로 만든 프로그램은 특정 CPU의 명령어로 컴파일된다. x86용 실행 파일은 ARM CPU에서 돌지 않는다. 그래서 cloudflared도 arm64용을 따로 받아야 했다.
그런데 자바 jar는 그대로 돌았다. 자바는 바이트코드라는 중간 언어로 컴파일되고, 실제 CPU 명령어로 바꾸는 건 각 컴퓨터에 설치된 JVM이 한다. 맥에는 맥용 JVM이, 서버에는 ARM 리눅스용 JVM이 깔려 있으니 같은 jar가 양쪽에서 돈다. "한 번 쓰면 어디서나 실행된다"는 자바의 오래된 약속이 이것이다. 자바스크립트나 파이썬도 같은 이유로 대부분 그대로 돈다. 다만 내부에 C로 만든 부품(네이티브 모듈)이 있으면 CPU마다 따로 준비해야 한다.
가상화는 물리 컴퓨터 하나를 여러 대처럼 나눠 쓰는 기술이다.
| 가상 머신(VM) | 하이퍼바이저가 가짜 하드웨어를 만들고, 그 위에 운영체제가 통째로 올라간다 | 각자 자기 커널을 가진다. 완전히 분리된다. 무겁다 |
| 컨테이너 | 하나의 운영체제 커널을 공유하고, 프로세스만 칸막이로 나눈다 | 가볍고 빠르게 뜬다. 커널은 공유한다 |
내 오라클 서버(VM.Standard.A1.Flex)는 VM이다. 오라클의 거대한 물리 서버 위에서 나눠 받은 한 칸이다. 그 VM 안에서 MySQL은 컨테이너(Docker)로 돈다. VM 안에 컨테이너가 들어 있는 구조다. Docker 이미지도 CPU 계열별로 따로 만들어지는데, mysql:8.4처럼 공식 이미지는 여러 계열을 함께 제공해서 ARM 서버에서는 자동으로 ARM용을 받는다.
비유
CPU 계열은 언어다. x86 CPU는 영어를, ARM CPU는 한국어를 알아듣는다. C 프로그램은 처음부터 영어로 쓴 책이라 한국인에게는 번역본이 따로 필요하다. 자바는 에스페란토로 쓴 책이고, JVM은 각 나라에 있는 동시통역사다. 책은 한 권이면 된다. VM은 아파트 한 채를 통째로 빌리는 것이다. 부엌도 화장실도 각자 있다. 컨테이너는 셰어하우스 방 하나다. 방은 따로지만 부엌(커널)은 같이 쓴다.
면접에서는
Q. 가상 머신과 컨테이너의 차이는? VM은 하이퍼바이저 위에서 게스트 운영체제를 통째로 띄워 커널까지 분리하고, 컨테이너는 호스트 커널을 공유하면서 네임스페이스와 cgroup으로 프로세스를 격리한다. 그래서 컨테이너가 더 가볍고 빨리 뜨지만, 격리 수준은 VM이 더 강하다.
Part 3. DB와 운영
9. 트랜잭션과 일관된 스냅샷
시리즈 장면
6편에서 DB를 덤프할 때 --single-transaction 옵션을 붙였다. "덤프하는 동안의 데이터를 한 시점으로 고정해서 읽는다"고 한 줄로 설명했다. 그런데 덤프하기 전에 앱부터 멈췄다. 한 시점으로 고정된다면서 왜 앱을 멈췄을까.
개념
트랜잭션은 "전부 되거나 전부 안 되거나" 해야 하는 작업 묶음이다. 계좌 이체가 대표적이다. 내 계좌에서 빼는 것과 상대 계좌에 넣는 것은 둘 다 되거나 둘 다 안 되어야 한다. 하나만 되면 돈이 사라지거나 생긴다.
트랜잭션이 지켜야 할 네 가지 성질을 ACID라고 한다.
| 원자성(Atomicity) | 전부 되거나 전부 취소된다 |
| 일관성(Consistency) | 규칙(제약 조건)을 깨는 상태로 끝나지 않는다 |
| 격리성(Isolation) | 동시에 도는 트랜잭션끼리 서로의 중간 상태를 보지 않는다 |
| 지속성(Durability) | 완료된 결과는 서버가 꺼져도 남는다 |
이 중 덤프와 관련된 건 격리성이다. 완벽하게 격리하면 느려지기 때문에, DB는 격리를 어디까지 할지 단계를 나눠 둔다. 이걸 격리 수준이라고 한다.
| READ UNCOMMITTED | 거의 없음. 남이 아직 확정 안 한 값도 보인다 |
| READ COMMITTED | 확정된 값만 본다 |
| REPEATABLE READ | 트랜잭션 안에서 같은 행을 다시 읽으면 같은 값이 나온다 (MySQL InnoDB 기본값) |
| SERIALIZABLE | 줄 서서 하나씩 한 것과 같은 결과. 가장 느리다 |
MySQL(InnoDB)은 이걸 MVCC(다중 버전 동시성 제어)로 구현한다. 누가 행을 고치면 옛 값을 바로 버리지 않고 따로 남겨 둔다(언두 로그). 먼저 시작한 트랜잭션은 자기가 시작한 시점의 버전을 계속 본다. 쓰는 쪽은 쓰고, 읽는 쪽은 자기 시점의 사진을 본다. 서로 기다리지 않는다.
--single-transaction은 덤프 전체를 하나의 REPEATABLE READ 트랜잭션으로 묶는다. 덤프가 몇 분 걸려도, 첫 테이블부터 마지막 테이블까지 덤프를 시작한 순간의 데이터를 읽는다. 앞쪽 테이블은 10시 데이터, 뒤쪽 테이블은 10시 3분 데이터가 섞이는 일이 없다. 테이블을 잠그지 않으니 앱은 그동안 계속 쓸 수 있다. (InnoDB 테이블에서만 이렇게 동작한다.)
그럼 왜 앱을 멈췄나. 덤프가 일관된 것과 덤프가 최신인 것은 다른 문제이기 때문이다. 덤프는 10시 정각의 완벽한 사진이지만, 10시 1분에 사용자가 남긴 운동 기록은 사진에 없다. 그 기록은 맥북 DB에만 남았다가 서버를 옮기는 순간 사라진다. 일관성은 --single-transaction이, 유실 방지는 쓰기를 멈추는 것이 맡았다.
비유
MVCC는 사진 찍기다. 덤프는 10시 정각에 단체 사진을 찍는 것이다. 사진을 현상하는 동안 사람들이 자리를 옮겨도 사진 속 배치는 그대로다. 그런데 10시 1분에 새로 온 사람은 사진에 없다. 그 사람까지 담으려면 사진 찍는 동안 문을 잠가야 한다. 이사 날 앱을 멈춘 게 그 문 잠그기였다.
면접에서는
Q. 트랜잭션 격리 수준과 MySQL의 기본값은? READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 네 단계가 있고, 각각 더티 리드, 반복 불가능한 읽기, 팬텀 리드를 어디까지 막느냐가 다르다. MySQL InnoDB의 기본값은 REPEATABLE READ이고, MVCC로 잠금 없이 일관된 읽기를 제공한다.
10. 백업 이론
시리즈 장면
7편에서 백업을 오라클 밖 R2에 두었고, 일별 7개, 주별 4개, 월별 12개를 남기게 했다. restic은 매일 사진 60MB를 통째로 올리지 않고 바뀐 부분만 올렸다. 마지막으로 실제 복원을 해 봤다.
개념
3-2-1 규칙은 백업의 기본 원칙이다.
- 데이터 사본을 3개 둔다 (원본 포함).
- 2가지 다른 종류의 저장 매체에 둔다.
- 그중 1개는 다른 장소에 둔다.
솔직하게 내 상태를 대 보면, 사본은 서버 원본과 R2 백업 두 개다. 다른 장소 하나는 지켰지만 3개는 아직 못 채웠다. 규칙을 아는 것과 지키는 것은 다르다. 다음 숙제다.
백업 방식은 세 가지로 나뉜다.
| 전체 백업 | 매번 전부 복사 | 하나만 있으면 된다. 대신 용량과 시간이 많이 든다 |
| 증분 백업 | 지난 백업 이후 바뀐 것만 | 전체 + 모든 증분을 차례로 적용해야 한다 |
| 차등 백업 | 마지막 전체 백업 이후 바뀐 것 전부 | 전체 + 마지막 차등 하나 |
restic은 이 구분을 조금 다르게 푼다. 파일을 내용에 따라 작은 조각(chunk)으로 자르고, 각 조각의 해시를 계산한다. 같은 해시의 조각은 한 번만 저장한다(중복 제거). 그래서 매일의 백업이 겉으로는 "그날의 전체 백업"처럼 보이는데, 실제로는 새 조각만 저장된다. 복원할 때는 어느 날의 백업이든 하나만 고르면 된다. 전체 백업의 편리함과 증분 백업의 용량을 같이 얻는 셈이다.
보관 정책 7·4·12는 GFS(할아버지-아버지-아들) 방식이다. 최근 것은 촘촘하게(매일), 오래된 것은 듬성듬성(매주, 매달) 남긴다. 어제 실수는 어제로 돌아가면 되지만, 석 달 전부터 조용히 망가진 데이터를 발견했을 때는 석 달 전 백업이 필요하다.
백업 계획을 세울 때 두 숫자를 먼저 정한다.
| RPO (복구 시점 목표) | 최대 얼마만큼의 데이터를 잃어도 되나 | 하루 (매일 1번 백업) |
| RTO (복구 시간 목표) | 장애 후 얼마 안에 다시 돌아가야 하나 | 복원 명령 몇 분 + 서버 준비 시간 |
RPO를 1시간으로 줄이려면 백업을 1시간마다 하거나, DB 변경 기록(바이너리 로그)을 계속 내보내야 한다. 개인 앱에 하루는 받아들일 만한 숫자라고 판단했다.
마지막으로, 복제는 백업이 아니다. 실시간으로 똑같이 따라 하는 복제 서버는 서버 고장에는 강하지만, 실수로 DROP TABLE을 치면 그 명령도 충실히 따라 한다. 백업의 가치는 과거로 돌아갈 수 있다는 데 있다.
비유
3-2-1은 중요한 서류 보관법이다. 원본은 책상에, 복사본 하나는 서랍에, 하나는 부모님 댁에. 집에 불이 나도 부모님 댁에 하나가 남는다. 중복 제거는 앨범 정리다. 매일 찍은 단체 사진에서 배경은 한 번만 보관하고, 바뀐 사람만 따로 붙여 두면, 언제든 그날의 사진을 완성해서 꺼낼 수 있다. 복제는 거울이다. 내가 넘어지면 거울 속 나도 넘어진다.
면접에서는
Q. RPO와 RTO의 차이는? RPO는 "어느 시점까지의 데이터를 되살려야 하나", 즉 허용 가능한 데이터 손실량이고, RTO는 "얼마나 빨리 서비스를 되살려야 하나", 즉 허용 가능한 중단 시간이다. RPO는 백업 주기를, RTO는 복구 절차와 자동화 수준을 정한다.
11. 배포 전략과 헬스체크
시리즈 장면
8편의 deploy.sh는 새 jar로 바꾸고, 재시작하고, 헬스체크가 200이 될 때까지 기다렸다. 실패하면 직전 jar로 되돌렸다. 그리고 6편에서 서버를 옮길 때는 새 서버를 완전히 준비해 둔 뒤 DNS 한 줄로 넘겼다.
개념
새 버전을 운영에 올리는 방법은 여러 가지다.
| 재생성(Recreate) | 옛 버전을 끄고 새 버전을 켠다 | 있음 | 다시 배포 | 서버 1대면 충분 |
| 롤링 | 여러 대를 한 대씩 차례로 바꾼다 | 거의 없음 | 다시 차례로 | 서버 여러 대 |
| 블루-그린 | 새 환경(그린)을 따로 다 띄우고, 트래픽을 한 번에 넘긴다 | 거의 없음 | 트래픽을 다시 블루로 | 환경 두 벌 |
| 카나리 | 새 버전에 사용자 일부(예: 5%)만 먼저 보낸다 | 거의 없음 | 그 일부만 되돌림 | 트래픽을 나눌 장치 |
내 deploy.sh는 재생성 방식이다. 서버가 한 대라 다른 선택지가 없다. 그래서 솔직히 말하면 배포할 때마다 짧은 중단이 있다. Spring Boot가 다시 뜨는 10여 초 동안 들어온 요청은 실패한다. 개인 앱이고 새벽에 배포하면 거의 아무도 모르는 수준이라 받아들였다. 사용자가 늘면 서버를 두 대로 늘리고 롤링이나 블루-그린으로 바꿔야 한다.
재미있는 건 6편의 서버 이전이 블루-그린이었다는 점이다. 블루(맥북)가 서비스하는 동안 그린(오라클)을 완전히 준비했고, DNS로 트래픽을 한 번에 넘겼고, 문제가 생기면 DNS를 블루로 되돌릴 계획을 세워 뒀다. 데이터를 옮기는 몇 분이 있었을 뿐 구조는 정확히 같다.
헬스체크는 "앱이 정상인지"를 확인하는 주소다. systemctl restart가 성공했다는 건 "재시작 명령이 전달됐다"는 뜻일 뿐이다. 앱이 실제로 요청을 받을 수 있는지는 헬스체크로 확인해야 한다. 쿠버네티스 같은 곳에서는 이걸 둘로 나눈다.
- Liveness(살아 있나): 앱이 멈춰 버리지 않았나. 실패하면 재시작한다.
- Readiness(받을 준비가 됐나): 지금 요청을 받아도 되나. 실패하면 트래픽만 보내지 않는다. DB 연결이 아직 안 됐거나, 막 뜨는 중일 때다.
내 헬스체크는 둘을 합친 단순한 버전이다. 200이면 "살아 있고 받을 준비도 됐다"로 본다.
CI/CD도 정리해 두면 좋다.
- CI(지속적 통합): 코드를 올릴 때마다 자동으로 빌드하고 테스트한다. 8편의 GitHub Actions가 이것이다.
- CD(지속적 전달): 언제든 배포할 수 있는 상태의 결과물을 자동으로 만들어 둔다. 실제 배포 버튼은 사람이 누른다.
- CD(지속적 배포): 테스트를 통과하면 사람 손 없이 운영까지 자동으로 나간다.
내 구성은 CI + 사람이 실행하는 배포 스크립트다. CD까지는 아직 아니다. 테스트를 통과했다고 바로 운영에 나가게 하려면, 지금의 재생성 방식 중단을 먼저 없애는 게 순서다.
비유
재생성은 가게 문 닫고 인테리어 바꾸기다. 블루-그린은 옆 건물에 새 가게를 다 꾸며 놓고 간판만 옮기기다. 손님이 이상하다고 하면 간판을 다시 옛 가게로 옮긴다. 카나리는 신메뉴를 단골 몇 명에게만 먼저 내놓기다. 반응이 나쁘면 메뉴판에 올리지 않는다. 헬스체크는 개점 전 점검이다. 불이 켜졌다고 개점이 아니다. 주방이 돌아가는지까지 봐야 한다.
면접에서는
Q. 무중단 배포는 어떻게 하나요? 서버를 여러 대 두고 롤링, 블루-그린, 카나리 같은 방식으로 일부를 바꾸는 동안 나머지가 요청을 받게 한다. 새 인스턴스는 헬스체크(특히 readiness)를 통과한 뒤에만 트래픽을 받게 하고, 문제가 생기면 트래픽을 이전 버전으로 돌린다.
마치며
열한 개를 다시 한 줄씩 줄이면 이렇다.
| DNS | 서버 이전은 레코드 한 줄이었다 |
| HTTPS·TLS | 인증서는 Cloudflare가, 암호화는 두 구간에서 따로 |
| 포트·방화벽 | 먼저 나간 연결의 응답은 들어온다. 터널은 그걸 이용한다 |
| 프록시 | 주황 구름은 "Cloudflare가 안내 데스크를 맡는다"는 스위치 |
| 프로세스·데몬 | 파일이 지워져도 메모리 속 설정으로 버틸 수 있다. 재시작 전까지는 |
| 암호화·해시·HMAC | 숨기기, 지문, 도장. 사진 링크는 도장이었다 |
| 권한 | 뚫리는 걸 막는 것보다 뚫렸을 때 가두는 것 |
| ARM·가상화 | jar는 통역사(JVM) 덕분에 그대로 돌았다 |
| 트랜잭션 | 덤프는 한 시점의 사진, 앱을 멈춘 건 문 잠그기 |
| 백업 | 3-2-1 중 아직 2개. 복제는 거울일 뿐 |
| 배포 전략 | 내 배포는 재생성, 서버 이전은 블루-그린이었다 |
이번 편의 초안도 Claude Code가 썼다. 시리즈에 나온 장면을 되짚어 주제를 고르게 하고, 주제마다 실제 서버에서 확인한 것(방화벽 규칙, 리슨 주소, 설정 파일)을 근거로 썼다. 나는 주제 열한 개를 다 고르고 "분량 신경 쓰지 말고 길게"라고 했다. 그래서 길다.
오늘의 교훈 명령어는 검색하면 나온다. 그 명령어가 왜 통하는지는 검색창에 넣을 단어조차 모를 때 필요하다.
맥북 서버 탈출기는 여기까지다. 읽어 줘서 고맙다.
'맥북 서버 탈출기' 카테고리의 다른 글
| [맥북 서버 탈출기 8편·완] 배포는 한 줄, 검사는 자동, 비용은 0원: deploy.sh와 CI, 그리고 영수증 (0) | 2026.10.16 |
|---|---|
| [맥북 서버 탈출기 7편] 무료로 만드는 자동 백업: restic + Cloudflare R2, 그리고 복원 테스트 (0) | 2026.10.15 |
| [맥북 서버 탈출기 6편] 운영 중인 앱, 3분 만에 서버 옮기기: DB 덤프부터 행 개수 대조까지 (0) | 2026.10.15 |
| [맥북 서버 탈출기 5편] 포트 하나 안 열고 서비스하기: Cloudflare Tunnel로 도메인 연결 (0) | 2026.10.15 |
| [맥북 서버 탈출기 4편] 인스턴스가 0개인데 쿼터 초과? 오라클 서버 생성 오류 해결기 (0) | 2026.10.15 |