목차
서버를 세 대로 늘렸더니 사용자들이 이상한 제보를 한다. 새로고침할 때마다 로그인이 풀린다는 것이다.
새로고침할 때마다 로그아웃된다
앞 글에서 본 그림이다. 세션이 서버 메모리에 있고, 요청이 매번 다른 서버로 가면 이런 일이 난다.
그런데 이 증상은 다른 사실도 알려준다. 누군가가 요청을 서버들에 나눠주고 있다. 그 누군가가 이 글의 주인공이다.
서버를 여러 대 두는 순간, 사용자는 여전히 주소 하나만 안다. 그 하나의 입구에서 뒤의 여러 대로 요청을 나눠주는 장치가 **로드 밸런서(load balancer)**다.
이 그림만 보면 “고르게 나눠주면 끝” 같다. 실제로는 여기서 정해야 할 게 여럿이고, 그 결정들이 서비스의 동작을 바꾼다. 하나씩 본다.
갈림 하나: 어느 층에서 보는가
첫 결정은 로드 밸런서가 요청의 무엇까지 읽느냐다.
전송 계층에서 나누면(L4) 빠르다. 연결을 열어 어느 서버로 보낼지만 정하고, 그 뒤로는 그냥 통과시킨다. 대신 내용을 모르니 “이 경로는 저 서버로” 같은 판단을 못 한다.
애플리케이션 계층에서 나누면(L7) 요청을 읽고 판단한다. 그래서 할 수 있는 게 는다.
/api는 API 서버로,/images는 정적 서버로- 특정 헤더가 붙은 요청만 새 버전 서버로
- 응답을 압축하거나 공통 헤더를 붙이기
대신 요청을 읽어야 하니 그만큼 일을 더 한다. 그리고 암호화된 요청은 풀어야 읽을 수 있다. 이게 뒤에 나올 TLS 이야기로 이어진다.
웹 서비스에서는 대개 L7을 쓴다. 경로별 라우팅과 헤더 기반 판단이 실무에서 계속 필요해지기 때문이다.
방금 나열한 것들은 사실 나눠주는 일이 아니라 앞단이 공통으로 하는 일이다. 같은 소프트웨어가 둘 다 하는 경우가 많아서 한 물건처럼 보이는데, 분배 말고 앞단이 하는 나머지 일은 리버스 프록시 글이 따로 본다.
갈림 둘: 누구에게 보낼 것인가
이제 서버 세 대 중 어디로 보낼지 정한다. 방식은 여럿이지만, 고를 때 실제로 묻는 건 하나다. 요청 하나하나의 비용이 균일한가.
| 방식 | 정하는 법 | 잘 맞는 경우 |
|---|---|---|
| 라운드 로빈 | 차례대로 돌아가며 | 요청 비용이 대체로 균일하다 |
| 최소 연결 | 지금 붙어 있는 연결이 가장 적은 곳으로 | 요청마다 걸리는 시간이 크게 다르다 |
| 가중치 | 서버 사양에 비례해서 | 서버 사양이 서로 다르다 |
| 해시 | 특정 값(IP 등)으로 계산해 고정 | 같은 대상이 같은 서버로 가야 한다 |
라운드 로빈은 이해하기 쉽고 대체로 잘 동작한다. 문제는 요청 비용이 균일하지 않을 때다. 어떤 요청은 10ms에 끝나고 어떤 요청은 파일을 만드느라 몇 초 걸린다면, 차례대로 나눠도 어떤 서버는 무거운 것만 계속 받게 된다. 요청 수는 같은데 부하가 안 같다.
그럴 때 최소 연결 방식이 낫다. 지금 실제로 바쁜 정도를 보고 보내기 때문이다.
방식 이름을 외우는 것보다 중요한 게 있다. 어떤 방식이든 뒤의 서버들이 서로 대체 가능하다고 가정한다. 앞 글에서 무상태를 전제라고 한 이유가 이것이다. 서버들이 서로 다른 것을 들고 있으면 무슨 방식으로 나눠도 결과가 달라진다.
갈림 셋: 죽은 서버를 어떻게 아는가
로드 밸런서가 하는 일 중 분배만큼 중요한 게 죽은 서버로 안 보내기다. 이게 없으면 서버를 세 대로 늘린 결과가 “요청의 3분의 1이 실패한다”가 된다.
그래서 로드 밸런서는 주기적으로 각 서버에 물어본다. 이걸 헬스 체크라고 한다. 답이 없거나 이상하면 그 서버를 분배 대상에서 뺀다. 다시 정상이 되면 넣는다.
여기서 결정이 하나 더 나온다. 무엇을 확인해야 “살아 있다”인가.
너무 얕으면 일 못 하는 서버에 계속 보낸다. 프로세스는 살아 있는데 커넥션 풀이 말라서 모든 요청이 타임아웃되는 상태여도, TCP 연결은 잘 된다.
그런데 너무 깊으면 더 위험하다. 헬스 체크에서 DB를 조회하게 만들었다고 하자. DB가 잠깐 느려지면 모든 서버의 헬스 체크가 동시에 실패한다. 로드 밸런서는 전부를 뺀다. 이때 어떻게 되는지는 제품마다 다르다. 보낼 곳이 없다고 전부 거절하는 쪽도 있고, 전부 죽었으면 헬스 체크를 무시하고 그냥 보내는 쪽도 있다. 어느 쪽이든 헬스 체크는 그 순간 아무 판단도 못 하는 상태가 된다. DB의 짧은 문제 하나가 전면 장애가 되는 것이다.
헬스 체크에 공유 의존성(DB, 외부 API)을 넣으면 그 하나가 흔들릴 때 전 서버가 동시에 제외된다. 헬스 체크는 **“이 서버가 요청을 받을 수 있나”**만 답해야지, “시스템 전체가 건강한가”를 답하면 안 된다.
앞 글에서 남겨둔 두 가지가 여기에 붙는다.
뜨는 중인 서버. 방금 시작한 서버는 아직 준비가 안 됐다. 헬스 체크를 통과하기 전까지는 요청을 안 받으므로, 준비가 끝났을 때만 통과하도록 만들어야 한다.
내려가는 서버. 배포나 축소로 서버를 내릴 때, 먼저 헬스 체크를 실패시켜 분배에서 빠지고, 처리 중인 요청을 마친 뒤에 종료한다. 이 순서를 안 지키면 진행 중이던 요청이 끊긴다.
세션 고정은 문제를 미루는 것이다
이제 첫 장면으로 돌아간다. 새로고침할 때마다 로그아웃되는 문제.
로드 밸런서에는 이걸 즉시 없애주는 기능이 있다. **세션 고정(sticky session)**이다. 같은 사용자의 요청을 항상 같은 서버로 보낸다. 쿠키를 심거나 클라이언트 IP를 해시해서 고정한다.
증상은 사라진다. 그런데 원인은 그대로 남는다. 쿠키와 세션 글에서 본 것처럼 세션은 여전히 그 서버의 메모리 안에 있고, 이제 그 사실에 시스템 전체가 의존하게 된다.
그래서 값이 따라온다.
- 그 서버가 죽으면 거기 붙은 사용자들의 세션이 통째로 사라진다. 한 대가 죽어도 견디자고 여러 대를 뒀는데, 그 이점을 세션에 대해서는 잃는다.
- 부하가 안 고르다. 무거운 사용자들이 우연히 한 서버에 몰리면 그 서버만 바쁘다. 로드 밸런서는 이미 고정해버려서 옮길 수가 없다.
- 배포할 때마다 세션이 날아간다. 서버를 교체하면 그 서버의 메모리도 새로 시작한다.
- 새로 넣은 서버가 한가하다. 기존 사용자들은 이미 다른 서버에 고정돼 있어서 새 서버로 안 온다.
진짜 해법은 앞 글의 그 표다. 세션을 서버 밖 공용 저장소로 꺼낸다. 그러면 어느 서버로 가도 같은 세션을 읽으니 고정이 필요 없다.
세션 고정이 늘 틀린 것은 아니다. 당장 장애를 멈춰야 하는데 구조를 바꿀 시간이 없다면 유효한 임시 조치다. 다만 임시라는 걸 알고 써야 한다. 붙여놓고 잊으면, 위 네 가지 대가를 매일 조금씩 치르면서 원인은 그대로인 시스템이 된다.
갈림 넷: TLS를 어디서 푸는가
로드 밸런서가 요청 내용을 읽으려면 암호화를 풀어야 한다. 그래서 결정이 하나 더 생긴다. 암호화를 어디서 풀 것인가. (HTTPS 글이 “끝나는 지점이 어디인지 안다”로 다룬 그 이야기이고, 여기서는 그 지점을 로드 밸런서에 둘 때 무엇이 갈리는지를 본다.)
가장 흔한 방식은 로드 밸런서에서 푸는 것이다. 사용자와 로드 밸런서 사이는 암호화되고, 로드 밸런서와 뒤의 서버들 사이는 평문으로 간다.
이렇게 하면 얻는 게 있다.
- 인증서를 한 곳에서만 관리한다. 서버를 늘려도 인증서 작업이 없다.
- 암호화·복호화 비용을 앱 서버가 안 낸다.
- 요청 내용을 읽을 수 있으니 경로 기반 라우팅 같은 L7 기능이 가능해진다.
대신 두 가지가 따라온다.
내부망이 평문이다. 로드 밸런서 뒤의 구간을 신뢰한다는 전제가 깔린다. 그 전제가 성립하지 않는 환경이면 뒷구간도 다시 암호화해야 한다.
앱이 원래 요청을 못 본다. 앱 입장에서 요청은 로드 밸런서에서 온 것이다. 클라이언트 IP도, “원래 HTTPS로 왔다”는 사실도 사라진다. 그래서 로드 밸런서가 그 정보를 헤더로 적어 보낸다.
X-Forwarded-For: 203.0.113.7
X-Forwarded-Proto: httpsSpring에서는 이 헤더를 반영하도록 설정할 수 있다.
server.forward-headers-strategy=framework이걸 안 하면 조용히 어긋난다. 접속 로그에 모든 IP가 로드 밸런서 주소로 찍히고, 앱이 만든 리다이렉트 URL이 http://로 나가서 무한 리다이렉트가 생긴다. 그리고 IP 기준으로 무언가를 제한하는 기능이 전 사용자를 한 명으로 착각한다.
X-Forwarded-For는 클라이언트가 마음대로 붙일 수 있는 헤더다. 신뢰할 수 있는 것은 우리가 통제하는 로드 밸런서가 붙인 값뿐이다. 앞단을 거치지 않고 앱에 직접 닿을 수 있는 경로가 있다면, 이 헤더를 그대로 믿는 순간 IP를 위조당한다.
로드 밸런서 자신은 어떻게 되나
여기서 마음에 걸리는 게 있어야 정상이다. 서버가 죽어도 되게 하려고 로드 밸런서를 뒀는데, 로드 밸런서가 죽으면 어떻게 되나. 입구가 하나니 전부 끊긴다.
그래서 실제 구성에서는 로드 밸런서도 혼자 두지 않는다. 여러 대를 두고, 하나가 죽으면 다른 하나가 같은 주소를 받아 이어받게 만든다. 클라우드에서 제공하는 로드 밸런서는 이 이중화가 안에 들어 있는 경우가 많다.
일반화하면 첫 글의 규칙과 같은 모양이다. 한 곳을 없애면 그 역할이 다른 곳으로 옮겨 간다. 단일 장애점도 마찬가지여서, 앱 서버의 단일 장애점을 없애면 그 자리가 로드 밸런서로 이동한다. 확장은 문제를 없애는 게 아니라 옮기면서 다루기 쉬운 형태로 만드는 일이다.
그래서 실무에선 어떻게 쓰이나
헬스 엔드포인트를 따로 만든다. 사용자용 API를 헬스 체크에 쓰면 인증에 걸리거나 무거운 조회가 주기적으로 돈다. 가볍고 인증 없는 전용 경로를 둔다. Spring Boot는 /actuator/health를 제공한다.
준비와 생존을 나눈다. “프로세스가 살아 있나”와 “요청을 받을 준비가 됐나”는 다른 질문이다. 앞 글에서 본 차가운 서버 문제가 여기서 갈린다. 캐시 예열이나 초기화가 끝나야 준비된 것이라면, 준비 상태를 따로 노출해서 그때부터 요청을 받게 한다.
앞단에서 붙는 응답도 우리 계약이다. 로드 밸런서가 뒤에 보낼 서버가 없을 때 내려주는 502·503은 앱이 만든 응답이 아니라 그 장비의 기본 형식이다. JSON을 기대하는 클라이언트가 HTML 에러 페이지를 받는다. 상태 코드 글이 지적하는 그 자리다.
로드 밸런서는 미들웨어의 자리이기도 하다. 압축·공통 헤더·접근 로그처럼 모든 요청이 똑같이 겪는 일은 앱이 아니라 여기서 처리하는 경우가 많다. 앱 코드를 아무리 봐도 없는 헤더가 응답에 붙어 있다면 앞단을 봐야 한다.
정리
| 하는 일 | 하나의 입구에서 여러 서버로 요청을 나눈다 |
| 어느 층 | 전송 계층은 빠르고 내용을 모른다. 애플리케이션 계층은 읽고 판단한다 |
| 분배 방식 | 요청 비용이 균일하면 차례대로, 들쭉날쭉하면 지금 바쁜 정도를 본다 |
| 헬스 체크 | 죽은 서버로 안 보내기. 얕으면 못 잡고, 깊으면 전 서버가 함께 빠진다 |
| 세션 고정 | 증상은 없애고 원인은 남긴다. 세션은 밖으로 꺼내는 게 답 |
| TLS 종료 | 앞에서 풀면 인증서가 한 곳. 대신 내부망은 평문, 클라이언트 IP는 헤더로 |
| 전달 헤더 | 앞단이 붙인 값만 믿는다. 클라이언트가 위조할 수 있다 |
| 자기 자신 | 로드 밸런서도 단일 장애점. 이중화한다 |
앞에서 나눠줘도 안 줄어드는 게 하나 있다. 사용자와 서버 사이의 거리다. 서버를 몇 대로 늘리든 지구 반대편의 사용자에게는 오가는 시간이 그대로 든다. 다음 글은 서버를 사용자 쪽으로 가져가는 이야기다.