All Articles

소프트웨어의 신뢰성, 확장성, 유지보수성

1장에서는 3가지에 대해서 이야기한다.

  1. 신뢰성(Reliability): 하드웨어 결함, 소프트웨어 오류, 휴먼에러
  2. 확장성(Scalability): 부하 및 성능 테스트와 개선
  3. 유지보수성(Maintainability): 운용성, 단순성, 발전성

요즘 많은 애플리케이션들이 데이터 중심으로 이루어져있다. low-CPU등의 컴퓨터 리소스는 크게 문제가 되지 않고, 데이터 양이 너무 방대하다거나, 데이터가 너무 복잡하다거나, 데이터가 바뀌는 속도가 너무 빠르다거나 등에서 문제가 발생한다. 따라서 수많은 데이터 중심의 애플리케이션들은 아래 방침을 따른다.

  1. 데이터베이스를 활용해서 데이터를 저장하고, 찾을 수 있게 만들고
  2. 캐시를 활용해서 read 속도를 향상시키고
  3. index를 활용해서 검색을 용이하게 하고
  4. streaming을 통해서 다른 프로세스에서 비동기적으로 데이터를 처리하게 하고
  5. 배치방식을 통해서 한번에 여러가지 데이터를 처리하기도 한다.

시스템의 성격에 따라 그 성격에 맞는 데이터베이스를 활용해야하고, 캐싱이나 인덱스도 마찬가지이다. 애플리케이션을 설계할 때 어떤 툴이 가장 적합하고, 어떤 방식으로 이 문제를 접근하는 것이 좋은지에 대한 검토가 필요하다. 여러가지 도구를 동시에 사용해야하는 경우 이는 더 복잡해질 수 있다.

Data Systems

일반적으로 데이터베이스, queue, 캐싱 등에 대해 각각 다른 성격을 가진 도구라고 생각한다. 데어터베이스와 메시징 큐(messaging queue)는 데이터를 저장한다는 측면에서는 비슷하지만, 데이터에 접근하는 방식이 다르다. 따라서 퍼포먼스와 구현방식에서 큰 차이가 있다. 하지만 요즘 그 경계가 조금씩 무너지면서, data systems라고 통칭하는 것이 일반적이다. 예를 들면 Redis의 경우 데이터 저장소이면서 동시에 메시징 큐로도 사용되고, Kafka의 경우에는 메시징 큐이지만 데이터도 꽤 오랫동안 저장할 수 있다.

또한 많은 애플리케이션들이 하나의 툴로는 처리할 수 없는 기능들을 요구한다. 따라서 다양한 툴들을 섞어서 사용할 수밖에 없는 현실이다. 만약 서비스를 운영하는데, 기본 데이터베이스 이외에 캐싱이나 검색 기능을 추가해야 한다면, 에플리케이션 코드에서 메인 데이터베이스와 싱크를 맞추기 위해 추가로 관리가 필요하다. 하지만 외부 툴을 혼합해서 사용한다면, 그런 디테일한 구현들은 뒷단에 숨게된다. 따라서 개발자들은 본인의 목적에 맞는 기능을 구현하는데에 집중할 수 있게 된다. 그리고 다양한 툴들을 혼합해서 서비스를 구현할 때, 신뢰성, 확장성, 유지보수성을 고려해야 한다.

신뢰성(Reliability)

신뢰성은 일반적으로 아래와 같은 것들을 뜻한다.

  1. 에플리케이션이 사용자가 생각한 것 처럼 동작하는 것
  2. 사용자가 실수를 하거나, 프로그램을 잘못된 방식으로 사용되어도 에러가 발생하지 않는 것
  3. 예측 가능한 부하와 데이터양에 대해 적합한 성능을 보여주는 것.
  4. 승인되지 않는 사용자들의 권한을 차단하는 것

요약하면, 뭔가 잘못된다고 하더라도 정상적으로 작동해야 한다는 것이다.

무언가 잘못되는 것을 실수(fault)라고 칭한다면, 그 실수를 용인하는 것—에러가 발생하지 않는 것—을 fault-tolerantresilient라고 표현한다. 여기서 주목할 점은 실수(fault)는 실패(failure)와 다르다는 것이다. fault는 시스템의 일부분이 정상동작 하지 않는 경우를 뜻하고, 실패는 시스템 전체가 무너지는 것을 뜻한다. fault가 zero에 수렴하는 것은 거의 불가능하다. 따라서 시스템을 fault-tolerant하도록 설계해서 failure가 일어나지 않도록 하는 것이 중요하다.

fault-tolerant한 에플리케이션의 경우, process를 죽이는 것처럼 fault의 비율을 늘려서 테스트를 할 수도 있다. 실제로 대부분의 버그들이 error handling을 제대로 하지 못하는 경우에 발생하기 때문에, fault를 발생시키면서, 버그를 잡아낼 수 있다는 장점이 있다. 일반적으로 fault-tolerant한 시스템을 선호하지만, 데이터 보안과 같은 경우에 대해서는 fault를 미연에 방지하는 것이 더 좋다.

Hareware Faults

하드디스크가 죽는다거나, RAM이 제대로 작동하지 않는다거나, 전원이 꺼진다거나 등등으로 인해 하드웨어에 문제가 발생할 수 있다. 하드웨어 문제를 방지하기 위해 일반적으로 하드웨어를 복제한다. 디스크에 RAID설정을 하거나, 서버에 dual power system을 구축하거나, 데이터 센터에는 발전기를 추가한다던지 등이다. 하나가 죽는다면, 백업으로 세워둔 다른 하드웨어가 그를 대체하는 형식이다.

하지만 최근 데이터 양 자체가 늘어나면서, 많은 machine들을 사용하는 서비스들이 증가했고, 당연히 비율상 하드웨어 에러가 증가하기 시작했다. AWS와 같은 클라우드 서비스에서는 Virtual Machine이 아무런 경고도 없이 갑자기 죽어버릴 때도 있다.

이를 방지하기 위해 하드웨어 복제에 추가로, 소프트웨어 fault-tolerant한 기능들을 선호하는 추세이다. 이런 경우 몇몇 장점들이 있는데, single-server시스템의 경우, 서버 재부팅 시 어느정도 다운타임이 발생할 수밖에 없는데, machine failure를 tolerant할 수 있는 시스템의 경우 노드를 하나씩 업데이트 하면서, 전체 시스템이 죽지 않고도 업데이트가 가능하다는 장점이 있다.

Software Errors

하드웨어 에러 이외에, 시스템 내부에서 fault가 발생하는 경우, 여러 node들이 서로 연결되어있기 때문에 예측이 더 어렵다. 그리고, 이런 fault들은 system failure로 이어질 가능성이 훨씬 높다. 그리고 이런 버그들은 상당히 오랜 기간동안 숨어있다가, 평소와는 다른 특이한 행동으로 발생하는 경우가 많다. 일례로 한 게임개발자가 컨퍼런스 연사로 참여했는데, 당시 개발했던 게임이 유저가 던전 구석에서 3000번 점프하면 서버가 죽었다고 한다. 이런 버그를 예측하기는 정말 불가능하다.

하지만 개발자는 늘 이런 fault들을 방지하기위해 최선을 다해야한다. 일반적으로 아래와 같은 방법들을 사용한다.

  1. 시스템 연결성 파악
  2. 철저한 테스트
  3. 프로세스 격리
  4. 프로세스를 껐다가 다시 시작하는 것
  5. 운영중인 서비스 모니터링 및 분석

예를들면 메시징 큐를 사용해서, 들어오는 메시지의 수와 나가는 메세지의 수가 같은 것을 끈임없이 확인하면서, 서비스의 안정성을 모니터링 할 수 있다.

Human Errors

사람이 시스템을 디자인하고, 개발하고, 사용한다. 따라서 최선을 다해서 만든다고 할지라도, 실수가 있을 수밖에 없다. human error에 대응하기 위해서는 아래의 방식들을 사용할 수 있다.

  1. 에러 발생 가능성이 낮은 방식으로 서비스를 디자인하는 것. 추상화, API, admin 페이지 등을 사용하면, 사용자들이 예상하지 못한 행동들을 하는 것을 예방할 수 있다. 하지만 방금 언급한 사항들이 너무 제한적이라면, 사람들이 이상한 짓을 할 수 있기 때문에, 조심해야한다.
  2. 사람들이 실수할 수 있는 영역들을 system failure가 발생할 수 있는 영역들과 격리 시키는 것이다. 운영서버가 아니라면, 실제 데이터를 사용해서, 실사용자에게 영향을 미치지 않는 환경에서 자유롭게 테스트 할 수 있는 환경을 구축해두는 것이 좋다.
  3. 모든 레벨에서 철저하게 테스트한느 것. 유닛테스트, integration test, e2e test, manual test등등 할 수 있는 모든 테스트들을 다 하는 것이다. 테스트 자동화는 요즘 너무 당연하고, 생각하지 못한 에러들을 예방하는데 메우 유용하다.
  4. 빠르고 쉽게 시스템을 복구할 수 있는 방법을 제공하는 것. 예를들면 roll back을 쉽게 할 수 있는 방법을 제공하는 것이다.
  5. 모니터링 시스템을 구축하는 것. 성능 metric이나 에러율 등을 모니터링 하는 것이다. 모니터링을 통해 system failure를 미리 예측해서 선제대응 할 수 있다.

확장성(Scalability)

시스템이 지금은 잘 작동하더라도, 미래에도 정상적으로 작동하는 것을 보장하지는 않는다. 사용자가 10배, 100배 늘어나거나, 처리해야하는 데이터의 양이 방대하게 늘어나는 경우에도 시스템은 잘 작동해야한다. 확장성을 고려할 때는 성장을 어떤식으로 대응할 수 있는지에 대해 다양한 방안을 수립해야 한다.

Describing Load

먼저 현재의 load를 매우 간결하고, 정확하게 알고있어야 한다. load는 load parameter라는 것을 통해서 묘사할 수 있다. 최고의 parameter는 시스템의 아키텍쳐에 따라 다르다. 초당 요청 수, 데이터베이스 read/write의 비율, 채팅방 동시접속자 수, 캐싱 접근율 등등으로 서비스의 성격에 따라 다르다. 일반적으로 하나의 영역이 심각하게 커졌을 때 문제가 발생한다. 예를들면 갑자기 초당 100개의 리퀘스트를 처리하던 엔드포인트에 1억개가 몰린다던지 등이다.

책에서는 트위터를 언급한다.

  1. 사용자는 팔로워들이 볼 수 있도록 트윗을 올릴 수 있다 (평균 초당 4,600개의 리퀘스트, peak에는 12,000개)
  2. 사용자는 본인이 팔로우하는 다른 사용자들의 트윗을 볼 수 있다 (초당 300,000개의 리퀘스트)

12,000개의 write request를 처리하는 것은 별로 어렵지 않다. 트위터가 해결해야하는 확장성은 실시간에 발생하는 트윗의 양이 아니라, 수많은 사용자들이 서로를 팔로우하고 있다는 사실이다. 아래와 같은 방법으로 처리할 수 있다.

  1. 트윗을 하는 것은 global collection에 INSERT를 하는 것이다. 하지만 다른 사용자의 트윗을 보는 것은, 내가 팔로우하는 사람들을 찾고, 그들의 트윗을 찾고, 그 트윗들을 시간순서대로 정리해야 한다. RDB에 쿼리를 날린다면 이런 느낌이다.
SELECT tweets.*, users.* FROM tweets
  JOIN users   ON tweets.sender_id    = users.id
  JOIN follows ON follows.followee_id = users.id
  WHERE follows.follower_id = current_user
  1. 사용자의 타임라인에 있는 쿼리들을 캐싱해야한다. 그렇지 않으면 계속 저것들을 호출해야하기 때문이다. 사용자가 새로운 트윗을 올리면, 그 사용자를 팔로우하는 모든 사람들을 찾아서, 해당 캐시에 새로운 트윗을 추가해줘야한다. 그렇게 되면 팔로잉 하는 다른 사용자들이 read request를 보낼 때 효율적으로 처리할 수 있다.

트위터는 최초에 1번 방식으로 구현되었는데, 사람들이 타임라인을 불러올 때마다 쿼리에 부하가 너무 심하다는 문제가 있었다. 그래서 2번 방식으로 변경했다. 새로운 트윗이 발생하는 load가 타임라인 확인을 위해 다른 사람들의 트윗을 불러오는 load보다 적기 때문에 훨씬 효율적이었다. 이런 경우에는 read보다 write을 할 때 더 많은 일을 처리 하는 것이 더 효율적이다.

하지만 2번 케이스의 단점은, 새로운 트윗이 생길 때마다 너무 많은 일을 해야한다는 것이다. 팔로워가 많은 사용자가 트윗을 한 번 하면 수천만개의 write가 발생한다는 문제가 있다. 따라서 현재 트위터는 팔로워 숫자에따라 1번과 2번 방식을 섞어서 적용하고 있다.

Describing Performance

load를 제대로 파악했다면, 이제 성능을 파악해야 한다. 그러기 위해서 아래 두가지를 고민해야한다.

  1. load parameter가 증가하고, CPU, memory, network bandwidth등의 하드웨어 성능을 유지한다면, 시스템의 성능에 어떤 변화가 있는지?
  2. 시스템 성능을 유지하려면 하드웨어 성능을 어떻게 변경해야 하는지?

이제 performacne number를 어떻게 결정하는지 보자.

하둡과 같은 batch process에서는 일반적으로 한번에 얼마나 많은 데이터를 처리하는지에 대한 throughput이나 특정 사이즈의 데이터를 처리하는데 걸리는 시간을 생각한다. 그런데 온라인 서비스를 운영중이라면 이것들보다 클라이언트가 request를 보내고 response를 받기까지 얼마나 걸렸는지 측정하는 response time이 더 중요하다. 같은 request를 여러번 보내도, 그 때마다 response time이 다를것이다. context switch로 인해 갑자기 내 리퀘스트가 백그라운드에처 처리된다던지, 네트워크 패킷 loss가 발생한다던지, garbage collection이 멈춘다던지, 서버랙에 문제가 생긴다던지 등등으로 인해 response time에 차이가 있을 수 있다. 따라서 이를 고려할 때 다양한 값들을 같이 고려해야 한다.

일반적으로 mean을 사용하지만, percentile을 사용하는 것이 제일 좋다. response time을 시간대로 sorting하고, 그 가운데 값을 찾는 median을 사용하는 것이 적합하다. 가운데 있는 값이기 때문에 50th percentile이라고 칭한다. response를 받는데 특히 오랜 시간이 걸리는 outlier를 찾을 때는 95th, 99th, 99.99th percentile을 보는 것이 좋다. 이런 outlier들을 tail latencies라고도 부르는데, 사용자 경험에 직결되는 값이기 때문에 매우 중요하다.

아마존의 경우, 본인들의 서비스의 response time을 99.9th percentile로 게시한다. 그 이유는, 일반적으로 가장 느린 response time을 받는 사용자가 가장 주문을 많이하는 사용자일 가능성이 높기 때문이다. insert job이 많으니 response time이 상대적으로 오래 걸릴 확률이 높다고 생각하는 것이다. 따라서 늦은 응답을 받는 사람이 서비스를 제공하는 입장에서는 가장 중요한 고객인 것이다. 게시된 response time이 1초인데, 이 중요한 고객이 경험한 response time이 3초라면, 해당 고객에게는 좋지 못한 사용자 경험이 된다. 아마존에 따르면 response time이 100ms증가할 때 매출이 1%정도 떨어진다고 한다. 다른 보고서에 따르면 response time이 1% 떨어질 때마다 고객이 16% 감소한다고 한다.

하지만 이런 99.9th percentile 최적화는 쉽지않다. 따라서 아마존도 실제로 안한다고 한다. 왜냐면 이런 outlier들은 예상치 못한 문제로 발생하는 경우가 많기 때문이다. 특이 이런 예상하지 못하는 문제가 통제할 수 없는 영역에 있는 경우, 이것에 투자를 하는 것이 더 낭비일 수 있다.

또한 response time은 client side에서 측정하는 것이 좋다. 서버의 경우 동시에 처리할 수 있는 일들이 한정되어있기 때문에, 자연스럽게 head-of-line blocking이 발생할 수 있다. 그러면 해당 리퀘스트를 처리하는 것이 서버입장에서는 오래 걸리지 않았지만, 사용자 입장에서는 blocking time까지 고려했을 때 훨씬 길어질 수 있기 때문이다. 따라서 load를 측정할 때, load generating client를 따로 두고, 해당 클라이언트가 지속적으로 리퀘스트를 보내서 response time을 측정하도록 해야한다.

Appproaches for Coping with Load

그렇다면 load가 증가할 때 성능을 보장하려면 어떻게 해야할까? 사용자가 10배 증가한다면 기존의 아키텍쳐는 정상작동하지 않을 가능성이 높다. 따라서 빠르게 성장하는 서비스를 운영중이라면 아키텍쳐에대해 끈임없이 고민해야한다. scale up을 통해 하나의 machine에서 모든 것들을 처리할 수 있다면 편하지만, 그렇다면 비용이 너무 비싸진다는 문제가 있다. 따라서 scale out을 할 수밖에 없는 것이 일반적이다. 좋은 아키텍쳐는 high-performance를 낼 수 있는 machine들과 비교적 저렴한 machine들을 혼합해서 사용한다.

몇몇 시스템은 elastic하다. elastic하다는 것은 필요에 따라 장비를 추가하고, load가 사라지면 장비를 제거하는 것이다. elastic한 시스템이 편할수는 있지만, 사람이 manual하게 scaling하는 시스템이 더 간단하고, 예상치 못한 문제가 적게 발생할 수 있다.

또한, stateless 서비스를 분산시스템에 구축하는 것은 간단하지만, stateful한 서비스를 분산시스템으로 구축하는 것은 어렵다. 따라서 비용이 엄청 비싸지지 않는다면 데이터베이스는 scale up을 통해 하나로 관리하는 것이 좋다.

유지보수성(Maintainability)

소프트웨어의 비용은 개발 초기비용보다, 유지보수에 더 많이 들어간다. 버그 수정, 시스템 작동, failure파악, 새로운 플랫폼 활용 및 적응, 새로운 기능 반영을 위한 수정, 기술부채 청산 등등이 더 비싼 비용이다. 하지만 많은 사람들이 legacy system 유지보수를 꺼린다. 다른사람의 실수를 고친다고 생각하거나, 본인이 생각했던 업무와 다르기 때문이다.

따라서 처음에 아키텍쳐를 잘 설계해서 나중에 유지보수에 용이하도록 해야한다. 이를 위해 3가지 design principle에 대해 이야기한다.

  1. 운용성(Operability) - 서비스 운영에 용이하도록
  2. 단순성(Simplicity) - 새로운 엔지니어들이 시스템을 쉽게 이해할 수 있도록
  3. 발전성(Evolvability) - 엔지니어들이 시스템에 변화를 가져오기 용이하도록

Operability - Making Life Easy for Operations

software의 문제점은 운영으로 보완할 수 있지만, 운영의 문제는 software도 어쩔 수 없다는 말이 있다. 운영의 상당수는 자동화될 수 있지만, 그 자동화를 처음 도입해서 잘 운용되는 것을 확인하는 것은 사람이다. 일반적인 좋은 운영팀은

  1. 시스템 health check를 모니터링 하고, 문제가 발생하면 빠르게 복구한다.
  2. system failure나 degraded performance와 같은 문제점을 파악한다.
  3. 보안기능을 비롯한 소프트웨어와 플랫폼을 업데이트한다.
  4. 다양한 시스템들이 서로 어떻게 연결되어있는지 이해한다.
  5. 발생할 수 있는 문제를 예측하고 미리 예방한다.
  6. 배포관리에 용이한 툴을 적용한다.
  7. 보안 시스템을 유지 및 관리한다.
  8. 서비스 운영 환경의 안전성에 도움을 준다.
  9. 문서화를 통해 서비스에 대해 잘 기록한다.

좋은 서비스 운영이라는 것은, 좋은 루틴이 있다는 것이다. 단순 작업들이 자동화될 수 있다면, 서비스를 운영하는 사람들은 정말 중요한 부분에 대해 집중할 수 있다. 데이터를 활용해서 이런 기능들을 제공할 수 있는데, 예를들면

  1. 모니터링 자료를 시각화해서 보여준다.
  2. 자동화 툴을 제공한다.
  3. 복제나 분산을 통해 single-point-of-failure를 대비한다.
  4. 매뉴얼을 문서화해서 제공한다.
  5. 정상 작동하는 것이 어떤건지에 대해 문서화한다.
  6. 문제가 생기면 자동으로 복구할 수 있는 기능들을 추가한다.

Simplicity - Managing Complexity

작은 소프트웨어 프로젝트는 매우 간단하고 선언적인 코드로 이루어진다. 하지만 프로젝트가 커지면서, 매우 복잡해지고 이해하기 어려워진다. 이로인해 그 프로젝트에 참여하는 모든 사람들이 많은 시간을 투자해야하고, 어려움을 겪고, 유지보수 비용이 증가하게 된다.

복잡도를 나타낼 수 있는 몇몇 항목들이 있다. 우리말로 설명이 어려워 영어 표현을 그대로 적는다

  1. explosion of state space
  2. tight coupling of modules
  3. tangled dependencies
  4. inconsistent naming and terminology
  5. special cases to work around

복잡성이 유지보수를 어렵게 하고, 비용과 일정을 늘 초과하게 만든다. 지속적인 patch는 버그가 발생할 가능성을 높인다. 여기서 오해하면 안되는 것은 시스템을 간단하게 만드는 것은 기능을 축소하는 것이 아니라는 것이다.

복잡성을 줄일 수 있는 가장 좋은 방법은 추상화이다. 추상화를 통해서 디테일한 구현 방법들을 감출 수 있고, 시스템을 조금 더 단순하게 운영할 수 있다. 추상화가 잘되면, 다양한 에플리케이션에서 재사용도 가능하다. 시간을 줄일 수 있을 뿐 아니라, 코드의 퀄리티도 자연스럽게 올라간다.

Evolvability - Making Changes Easy

system requirement는 바뀔 수밖에 없다. 새로운 것을 알게되거나, 생각하지 못했던 사용처가 나타나고, 비지니스 우선순위가 바뀌고, 사용자가 새로운 기능을 교구하고, 새로운 플랫폼이 나오고, 법률이 바뀌고, 시스템이 성장하면서 아키텍쳐를 수정하고. 등등 수많은 이유로 인해 시스템은 바뀔 수밖에 없다. 개인적으로 멘토링을 하면 멘티들에게 가장 많이 하는 말은 일단 작성할 수 있는 최선의 코드를 짜고, 수정이 필요하면 빠르게 바꾸는 것이 좋다라는 것이다. 책에서 하는 말과 어느정도 통하는 부분이 있다.

최근 agile이라는 개념이 부상하면서 Test-Driven Development(TDD)나 리팩토링이라는 개념이 같이 뜨고 있다. 위에서 언급한 system requirement가 바뀔 때, 쉽게 수정하려면 단순성과, 추상화가 매우 중요하다. 간단하고 이해하기 쉬운 문제들이 일반적으로 복잡한 문제들보다 더 해결하기 쉽다. 따라서 우리는 시스템의 발전성에 대해서도 고민해야한다.

Mar 22, 2023

AI Enthusiast and a Software Engineer