개발자 머피의 법칙

Sep.19.2019 손권남

Culture

안녕하세요, 우아한형제들 광고시스템팀의 손권남입니다.

이 글은 기술적인 내용도 아니고 우아한형제들 회사에 관한 이야기도 아닌, 순전히 저의 개인적인 경험을 다룹니다.

매우 긴 글이므로, 맨 앞과 맨 뒤, 그리고 제목 정도만 읽어도 대략의 핵심 주제는 파악 가능합니다.

본문의 내용은 회사의 의견과 다를 수 있습니다.

본문 내용이 마치 어떤 eCommerce 회사의 특정 팀 얘기 처럼 느껴질 수 있으나 수년에 걸쳐 여러 회사에 다니면서 겪거나 본 것들을 그냥 쉽게 설명하다보니 하나의 시스템으로 통일해서 예로 든 것뿐입니다(저는 약 7개의 회사를 다녔습니다).

머피의 법칙이란

머피의 법칙(Murphy’s Law)는 "항상 나한테만 재수 없는 일이 일어나"라는 의미가 아닙니다.

"어떤 일을 하는 데에 둘 이상의 방법이 있고 그것들 중 하나가 나쁜 결과(disaster)를 불러온다면 누군가가 꼭 그 방법을 사용한다." 에서 시작하여 이를 간략히 하면 "잘못될 수 있는 것은 꼭 잘못되게 마련이다" 라는 의미입니다. 특정 개인의 재수있고 없음과는 상관없는 것이지요.

어차피 잘못될 것이니 자포자기 하라는 의미가 아니라 잘못될 가능성이 있다고 생각된다면 미리미리 대비 하라는 것입니다.

프로그래머로 산지 좀 되다보니 "어 이거 이러면 저러하게 잘못 될 거 같지만, 에이 설마…" 이런 생각을 자주하고 넘어갔다가 호되게 당한 경험이 꽤 많고 다른 사람이 그런 경우도 많이 봤습니다.

주로 백엔드 서버 개발자로서 어떤 일들을 겪었는지 저 자신의 경험과 주위에서 본 것을 한 번 정리해 보았습니다.

사용자는 실수한다, 사용자의 입력은 무조건 검증한다

판매할 상품 정보를 입력하는 화면을 개편하였습니다. 업무 지시는 "몇 % 할인 이벤트 실시!"하고 내려왔으나 정작 데이터 입력은 할인금액으로 받다보니 매번 운영자들이 할인율에 따라 할인 금액을 계산해서 입력해야 하는 불편이 있어서 할인금액을 입력하던 화면을 할인율을 입력하게 변경하기로 하였습니다(할인금액 -> 할인율).

10,000원에 대해 10% 할인 하면 기존에는 1,000 이라고 입력했지만 이제는 10 이라고 입력해야 하지요.

화면의 입력란에 할인율: [ ] % 라고 친절하게 만들어 두었습니다.

머피는 "설마 친절하게 안내도 해놨는데 어떤 바보가 잘못 입력하겠어?" 라고 생각하며 뿌듯해했습니다.

헌데 갑자기 몇몇 상품이 무료로 판매되기 시작하였습니다.

기존 상품 정보를 입력하는 운영자들 몇몇이 하던 습관대로 할인율을 입력하는 곳에 예를들면 10 (%)이 아니라 1000 (원)을 입력하고 있었습니다.

그러나 어떠한 경고도 뜨지 않고 데이터가 반영되었습니다.

많은 상품들이 1000%씩 할인 판매 되었습니다. 천만 다행히도 돈을 고객에게 더 주지는 않았습니다(다행인가?). 각종 커뮤니티에 어디서 물건을 0원에 판다더라 하는 소문이 퍼진 덕분에(?) 서비스의 인기도 급상승하였습니다. 뭔가 좋은 일인 것 같기도 하네요.

사용자의 모든 입력은 검증해야 합니다. 첫째 UI에서 선검증해서 편리성을 높이고(이건 말 그대로 편리성을 위한 것입니다), 그리고 서버측에서도 올바른 범위의 값을 입력했는지 무조건 검증해야만 합니다. 서버측 검증이 안 된 것은 클라이언트에서 어떤 3중 4중 검증을 했더라도 그냥 검증이 안 된 것입니다.

  • 이런 종류의 실수로 가장 많이 하는 것 중에 또 하나는 날짜 입력입니다. 배송 요청일 지정을 아내 생일인 2019년 m월 d일 하고 싶었는데 2029년 으로 한다던가…

사용자는 다 안다, 사용자의 입력은 무조건 검증한다

모든 계산은 서버에서 해야하고, 파라미터로 넘어온 값은 무조건 검증해야한다.

항상 최선을 다하는 머피는 불필요한 DB 접근이 너무도 거슬렸습니다.
그래서 사용자가 상품을 선택하고 주문을 누르면 브라우저에서 FORM으로 상품 ID, 이름, 가격을 서버로 전송하도록 하였습니다.
주문 컨트롤러는 상품을 다시 조회할 필요가 없었습니다. 그냥 파라미터로 넘어온 상품 ID, 이름, 가격을 그대로 결제 시스템으로 넘겨서 결제하고 주문 결과 데이터로 저장만 하면 되게 만들었습니다.

주문시 DB를 조회하면 느려질텐데, 이렇게 빠른 주문 페이지를 만들었다니! 매우 뿌듯했습니다. "설마 주문 폼 HTML을 자동으로 만들어주니까 잘못된 값이 넘어올 일은 없겠지!"

그리고 1년도 넘은 뒤에야 눈치를 채게 되었습니다.

어떤 웹 개발에 대한 간단한 상식을 가진 사람이 900만원어치 상품을 주문하면서 주문 FORM의 결제가격 값을 폼 전송직전에 9,000원으로 바꿔치기 주문하는 대담함을 보이다가 이상하게 여긴 운영자의 확인으로 덜미가 잡혔습니다.

웹 개발을 조금이나마 알고 있는 사람에게 FORM 값을 수정해서 서버에 요청을 전달하는 것은 매우 쉬운일입니다.

그동안 얼마나 많은 사람들이 안들키게 조금씩 가격을 조작해서 주문을 했을지는 감도 안잡혔습니다.

사용자의 입력 유효성 검사는 항상 서버에서 해야하며, 특히 계산 로직은 사용자 편의를 위해 프론트에서 일시적으로 해서 보여줄 수는 있으나 최종 결과는 서버에서 재계산 해야 합니다. DB 조회 한 번 하는거 귀찮다고 안했다가 큰일 납니다.

사용자의 요청에 의해 Query의 모든 조건이 사라지면 안 된다

신입시절 머피는 사수님으로 부터 매우 좋은 SQL 작성 팁을 전수 받았습니다.

(아래 SQL 생성 방식은 따라하지 말 것)

String sql = "SELECT * FROM comments  WHERE 1=1 ";
if (productId != null) {
  sql += " AND productId = :productId";
}

if (username != null) {
  sql += " AND username = :username"
}

// 그후 쿼리 실행 및 결과 반환

그리고는 /comments?productId=123 혹은 /comments?username=<username> 형태의 URL 호출로 상품의 댓글을 전달해주도록 하였습니다.

너무 감동적인 팁이었습니다. WHERE 1=1 덕분에 동적 쿼리 생성시 조회 조건을 추가할 때 항상 AND를 붙여도 문제 없이 작동하는 알아보기 쉬운 깔끔한 쿼리 생성코드가 나왔습니다.

URL은 서버측 애플리케이션이 HTML에 항상 생성해주는 것을 사용자가 클릭만하니까 "설마 사용자가 URL을 직접 쳐보겠어?" 라고 생각했죠.

그런데 어떤 다른 회사 개발자가 머피네 서비스를 살펴보다가 URL 형태를 보더니 ‘잠깐? /comments만 호출하면 무슨 일이 일어날까?’ 궁금했습니다. 그리고 호출해봤더니 응답이 없었습니다? 뭐지? 하면서 계속 연신 /comments 를 호출해대었습니다.

서버에서는 조회 조건이 모두 사라진 SQL SELECT * FROM comments WHERE 1=1 가 지속적으로 호출되었고, 인기 서비스인 덕에 Comment 데이터는 수백만건이 지속적으로 한 번에 조회되어 DB도 바쁘고, 웹 서버는 수백만건 데이터를 메모리에 올리느라 이미 OutOfMemory.

의도치 않게 모든 조회 조건이 사라지게 해서는 안되고, 조회 조건을 생성하는 사용자 요청 데이터는 무조건 서버측 검증을 거쳐야만 합니다.

이런 일은 특히 배타적인 조회 조건에서 많이 발생합니다.
즉, productId 혹은 username 둘 중의 하나의 파라미터만 존재해야 하는 쿼리를 만들때 쉽게 짠다고 이렇게 만듭니다. 배타적인 조회 조건일 경우 두 조건중의 하나는 요청 파라미터에 존재함을 항상 검증해야 합니다.

  • MyBatis 에서 <where> 혹은 <trim> 으로 비슷한 효과를 내는것이 우리나라에서 광범위하게 퍼져있는데 이게 이 문제의 주범이 되는 편입니다.
  • QueryDsl 이나 기타 모든 쿼리 생성 도구에 비슷한게 있으므로 잘 검토해보세요.
  • 여기서 핵심은 WHERE 1=1이 아닙니다. 모든 조회조건이 사라진다 가 핵심 쟁점입니다.
  • 설마… 여기서 SQL Injection 얘기까지는 하지 않겠습니다.

조회 조건 유사 사례 – 믿는 개발자에게 뒤통수 맞는다

머피는 이런 생각도 자주했지요. "설마 우리 회사 다른 개발자가 API를 잘못 호출하겠어?"

  • 다른 개발자들이 호출할 API를 만들어주면서 서비스 종류에 따라 데이터량을 자유롭게 조정할 수 있게 해준다고 pageSize=25 형태로 페이지당 데이터수를 지정 가능하게 해줬는데, 어떤 개발자가 페이징 귀찮다고 pageSize=1억으로 호출했고, 그 개발자는 머피에게 "개발 환경에서는 금방 끝나던데 운영 환경에서는 왜이렇게 느려요?"하고 질문했습니다… 질문을 듣자마자 머피는 장애대응 모드 돌입!
  • 날짜 범위로 데이터 조회하는 API를 만들었는데 범위에 대한 제약 조건을 주지 않았더니 옆 팀 개발자가 나눠서 호출하기 귀찮다고 2000/01/01 ~ 2019/12/31로 10년치 데이터 수억건 조회.

자기 회사의 옆자리 동료 개발자도 믿어서는 안 됩니다. 사람은 누구나 실수하고 착각합니다. 이것은 악의를 가지고 있느냐와는 무관한 문제입니다.

성능 측정 없는 캐시 사용은 성능을 저해시킬 수 있으며 캐시 데이터 구조 변경을 조심스럽게 해야한다

머피는 서비스가 성장하면서 DB만 가지고는 성능을 버틸 수 없음을 알았습니다. 그래서 여러 부분에 원격 분산 캐시를 적용하였습니다.

"아, 오늘도 열심히 일했다. 설마 이렇게 까지 캐시를 했는데 성능이 좋겠지!"

그리고 포털 메인페이지 광고 이벤트를 하는 순간 응답이 오지 않았습니다. 이상한건 그닥 서버 CPU 점유율도 높지 않은거 같았습니다.

Memcached/Redis 같은 원격 분산 캐시를 사용한다면 몇가지 주의할 점이 있었습니다.

원격 분산캐시는 네트워크 대역폭을 먹고 삽니다. 캐시에 너무 많은 데이터를 담으면 비록 캐시 히트율이 매우 높더라도 데이터가 네트워크 대역폭을 잡아먹어서 느려지게 됩니다.

따라서 실제 운영 서비스를 기준으로 성능 테스트를 하고 충분한 대역폭이 확보 돼 있는지 확인했어야 합니다.

또한 데이터의 크기가 너무 크다면, 압축 솔루션으로 전송량을 낮출 수도 있습니다.

하지만 이것도 문제가 있었습니다. 압축된 데이터를 풀고, 직렬화를 하는데 들어가는 CPU 점유율도 충분한지 테스트가 필요합니다. 이 경우는 그나마 낫다고 할 수 있습니다. 서버만 늘리면 되니까요.

그리고 원격 캐시 사용의 커다란 문제 하나가 더 남습니다. 직렬화시 데이터 포맷의 변경 체크문제 입니다.

머피는 중요하게 사용되고 캐시도 하는 어느 데이터에 필드를 하나 추가하고, 또 이상한 이름으로 된 필드의 이름을 바꾸었습니다. "설마, DB도 아니고 캐시 데이터 필드 좀 바꾸는건데 무슨 문제 있겠어?"

네 심각한 문제가 있었습니다. 필드의 변경은 직렬화된 데이터의 변경을 의미합니다. 배포되기 전의 서버에서 캐싱한 데이터와 배포 중인 서버에서 캐싱한 데이터가 서로 다른 필드를 가지고 있다는 얘기이고 둘 중의 한 곳에서는 특정 필드의 데이터를 누락시키고 있는게 됩니다. 비록 직렬화시 존재하지 않는 필드를 무시하는 옵션을 사용해서 잠깐 에러가 안나는 것처럼 보이게 하더라도 비즈니스 로직에 문제를 일으켜서 엉뚱한 결과가 나오게 할 수도 있습니다.

  • 네트워크 대역폭이 충분한지 성능 테스트가 필수입니다(AWS는 인스턴스 타입에 따라 대역폭이 다름. 저장 용량만으로 인스턴스 타입을 결정해서는 안됨).
  • 직렬화/역직렬화시에 충분한 성능이 확보 되는지 성능 테스트는 필수입니다.
  • 원격 캐시 대상 데이터 필드는 사실상 DB 의 컬럼처럼 조심스럽게 전략을 세워서 리팩토링 해야합니다. 아니면 원격 캐시 대신 로컬 캐시를 사용해야 합니다(데이터 동기화 문제 해결필요).
  • 캐시가 아니라 이미지 파일 용량이 너무 큰 경우에도 대규모 이벤트시 대역폭을 차지하고, 성능을 현저히 저하시킵니다. 이벤트 랜딩 페이지는 이미지 사이즈를 최적화하고, CDN 사용 등으로 대역폭 문제가 발생하지 않게 해야합니다.

인증과 권한은 다르다 – 인증 후 권한 검사까지 필수

머피는 고객의 주문 목록을 보여주는 웹 페이지를 개발하기 시작했습니다.

사용자의 주문 정보를 제공해주는 API가 주문 서비스에 이미 있었습니다.
/users/<userId>/orders API가 주문 API 서버에 존재하니까 Front API Gateway 에서 동일 URL로 호출이 들어오면
로그인 했는지 인증 후에 주문 API서버로 요청을 내려보내게 설정(개발 아니죠)했습니다.

세상에~ 난 정말 똑똑한 개발자인가봐. API Gateway 설정만으로 멋지게 기능 개발 완료!

"설마 사용자가 URL을 직접 쳐보겠어?" 라는 생각은 하지도 못했습니다.

그런데 갑자기 보안팀에서 Front 서버의 /users/*/orders에 대한 대량 크롤링 요청이 들어왔다고 확인 좀 해달라고 연락이 왔습니다.

그제서야 아차 했습니다.

로그인을 했는지 여부만 검사했지 그 사용자의 ID가 요청 URL의 userId 의 사용자와 동일인인지 여부를 검사하지 않은 것입니다. 그래서 로그인된 세션을 유지한채 /users/*/orders를 무제한 호출할 수 있게 된 것입니다. 이렇게 고객의 소중한 개인정보가 후루루룩 빠져나갔습니다.

인증(authentication)은 "내가 누군데 말이야~" 라는 확인 절차입니다.

권한(authorization)은 "내가 누군지 아셨을테고, 그래서 나 이거 봐도 되나요? 혹은 이거 수정해도 되나요?" 하는 확인 절차입니다.

이런 실수는 권한이 무엇인지 몰라서도 일어나고, API Gateway 사용시 권한을 끼워넣지 않고 무분별하게 사용할 경우에도 발생하지 않을까 싶습니다.

인증만 하고 내부 API서버로 요청을 바이패스하면 절대 안됩니다. 권한 검사까지 잊지 말고 해야합니다.
좀 더 쉽게 가는 것은 Front 서버에서는 애초에 요청 파라미터를 안 받고, 로그인 사용자의 정보를 인증 세션에서 읽어서 바로 데이터를 읽어야 합니다(파라미터 없는 /user-orders 정도).

사용자의 로그인 실패횟수를 트래킹하고, 일정 횟수 이상 실패시 로그인을 거부하거나 Captcha를 요구해야한다

머피네 서비스가 아직 인기가 없을 때, "설마 누가 우리 서비스 사용자 계정을 털려고 하겠어?"라고 생각하였습니다. 로그인 페이지를 만들면서 아주 단순한게 사용자명과 비밀번호를 계속 입력 받게 만들었습니다.

그런데 서비스의 인기가 올라가면서 사용자 계정의 개인정보가 점점 가치를 가지게 되었습니다.

머피네는 사용자들에게 포인트(가상 재화)를 지급했는데, 그 포인트를 많이 쌓은 사람은 수십만원까지도 되었습니다.

공격자들은 그런 포인트를 노려서 사용자 계정의 비밀번호를 알아내려 했던 것이었습니다.

그리고 여지없이 사용자 계정에 대한 무차별 대입 공격으로 서버가 마비될 지경이 되었고 수많은 사용자들의 계정의 비밀번호가 노출되게 됩니다.

그래서 열심히 캡차를 도입하였습니다. 사용자가 5회이상 비밀번호를 틀리면 캡차를 입력해야만 다시 로그인 시도가 가능하게 하였습니다.

그런데 이럴수가 그래도 계속 공격이 들어오는 것이었습니다! 동일 사용자 ID에 대해서 말이지요. 머피는 머리가 빠질 것 같았습니다.

옆에 있던 팀 동료에게 코드 리뷰를 부탁했더니 금방 찾아줍니다.

머피는 특정 사용자 ID의 로그인 횟수를 계속 증가시켜가며 저장해야하는데, 급하게 만든다고 Browser Cookie에 값을 넣어서 1씩 증가시켰던 것입니다. 계속 나왔던 사용자 입력 검증은 서버에서 하라는 원칙을 또 어기고 클라이언트측 저장소인 쿠키를 사용했기에 공격자가 매우 쉽게 Cookie 값을 항상 1로 조작해서 반복 공격을 했던 것이지요.

대충 만들었던 부정 로그인 방지 시스템을 Redis 등의 원격 저장소를 사용해서 서버측 검증으로 다시 제대로 만들었더니 공격이 멈추었습니다.

  • 비밀번호는 당연히 복호화 불가능하게 암호화를 했겠죠?
  • Captcha 말고, 일정 횟수 로그인 실패시 고객의 이메일과 문자로 통보하게 할 수도 있겠습니다(비용문제도 고려할 것).

사용자의 가상 재화는 별도 결제 수단처럼 독립 인증해야한다

머피는 로그인 Captcha 를 붙이고서 "설마 이정도 했는데, 이래도 사용자 계정이 털리겠어? 만약 계정 비밀번호가 노출돼서 포인트가 사용돼도 그건 고객 책임이지"하며 안심하고 있었으나 고객센터에서는 사용자의 포인트를 털렸다는 불만이 계속 이어졌습니다.

Captcha를 도입했음에도 이런 일이 일어나는 것은 사용자들이 여러 사이트의 비밀번호를 다 기억하지 못하므로 여러 사이트의 ID와 비밀번호를 동일하게 만듭니다. 그리고 그 중에 보안이 허술하고 부정 로그인 방지가 안 된 서비스에서 ID/비밀번호를 알아낸 다음 그것으로 머피네 서비스에 다시 로그인 시도를 해서 성공해 버린 것이지요.

엄밀히 말하면 이것은 사용자의 잘못이지 서비스의 잘못은 아닙니다만, 우리 서비스의 사용자들이 입게 되는 피해와 마음의 상처를 그냥 놔두는 것은 서비스의 신뢰도를 하락시키고 고객의 이탈을 유발할 수 밖에 없게 됩니다.

모든 경우는 아니고, 사용자의 가상재화를 실제 사용자 본인인지 여부 검증없이 우회적 수단으로 현금 혹은 그에 준하는 가치로 전환 가능한 경우에는 가상 재화를 다른 결제수단과 동일하게 별도 인증 절차를 거쳐야했습니다.

  • 가상재화를 제 3자에게 중고 마켓등을 통해 판매 -> 구매자는 정상적으로 중고마켓으로 구매했으므로 책임 없고 실제 판매자는 알 수 없는 경우가 있는데, 주로 온라인 게임 아이템/머니가 이런 과정으로 탈취됩니다.
  • 가상 재화로 우회적으로 온라인 상품권을 구매하고 그 상품권을 중고시장을 통해 본인 검증 없이 제 3자에게 팔아서 현금화할 수 있는 경우도 있습니다. 따라서 즉각 현금화가 안되더라도 우회적으로 가능한지도 따져봐야 합니다.

머피는 포인트를 사용할 때 사용자 로그인 비밀번호와 전혀 다른 비밀번호를 입력하게 했고, 그 상태에서도 인증을 3회 이상 실패시 아예 결제를 불가하게 변경하였습니다.

직원의 PC에서 운영 서버 API, DB 저장소에 접근을 통제해야 한다

머피네 회사는 아직 초창기였습니다. DBA도 부족했고 보안에 신경쓸 여력도 부족했습니다.

서비스용 DB 접근 계정에서는 DDL권한이 제거돼야 한다

개발자들이 수시로 실서비스 DB에 alter table을 날렸고, update/delete/insert도 했습니다. 개발자의 PC에서요.

"설마 개발자들이 바보가 아닌이상 alter table을 잘못 날리겠어?"

다행히도 아직까지 실수한 개발자는 없었긴 했는데…

JPA/Hibernate를 열심히 사용하던 머피네 개발팀은, hibernate.hbm2ddl.auto=create라는 옵션을 알게됩니다. 이 옵션을 사용하면 개발자가 DB 구조를 변경할 때 매번 alter table을 실행하지 않아도 자동으로 기존 Table을 모두 날리고 새로 만들어줍니다.

local 개발환경에서 잘 사용하면 개발이 매우 편리해지지만, 문제는 저 옵션을 켠 채로 운영에 배포했을 때 발생합니다. 모든 table을 날리고 새로 생성하고 데이터가 초기화 되는일이 발생하게 됩니다.

이 실수는 hibernate.hbm2ddl.auto로 검색하면 매우 많이 나옵니다.

단순히 production 프로필 설정에서 이 값을 none으로 한다고 해결되는게 아닙니다. local PC환경에서 production에 접근이 가능하면 똑같은 일이 발생할 수 있습니다.

  • 운영 DB에 접근하는 계정(개발자 계정이든 애플리케이션 계정이든)은 통제된 몇명(DBA)이 아니면 DDL(가급적 DML까지) 권한을 제거해야 합니다.
  • hibernate.hbm2ddl.auto는 그냥 사용하지 말고 flyway 등을 사용하세요.
  • flyway, LiquidBase 혹은 각 언어/프레임워크에서 제공해주는 DB 히스토리 관리 도구를 사용해도 동일한 일이 발생할 수 있습니다.

머피는 이 삭제된 DB를 복구하느라 상당히 애를 먹었습니다, 다행히 복구가 되긴 했습니다.

DB만 저장소인줄 알았지? MQ도 저장소다

머피는 위의 DB 날려먹는 경험을 한 뒤로 모든 DB DDL 권한을 제거하고 local에서는 운영 DB에 접근도 안되게 막았습니다. 그리고는 이제 안심해도 되겠구나 했습니다.

"설마 데이터 저장하는 DB나 NoSQL계열은 다 막았으니 괜찮겠지?"

안심하긴 일렀습니다.

주문 시스템을 맡았던 머피는 옆 팀인 배달팀 팀원으로 부터 갑작스런 호출을 받았습니다. 갑자기 주문 완료 후 배달요청하는 MQ 메시지가 뭔가 매우 적게 오는 것 같다는 겁니다.

무슨일이지? 머피가 주문 완료후 배달쪽으로 MQ 메시지 전송하는 것이 잘 이뤄지는지 확인해보고 잘 된다고 옆 팀에 전달하자마자 옆 팀 개발자 한명이 비명을 지르는 것을 듣게 됩니다.

옆 팀의 다른 개발자가 production에 붙어서 확인할 게 있다고 별 생각없이 local 개발 환경에서 production profile로 배달 애플리케이션 서버를 띄운 것이었습니다. 그 본인은 나름대로 ‘데이터를 수정하는 기능을 호출하지는 않고 잘 붙는지만 볼거니 괜찮겠지?’ 생각했던 것이지요.

이는 local 환경에서 운영 MQ 서버에 접속해서 운영 MQ의 데이터를 읽어서 처리하게 된 것과 같습니다. 원래 MQ는 메시지 전송후 예외가 발생하면 메시지를 다시 MQ에 집어넣게 돼 있는데, 이럴수가! 배달 애플리케이션에서 예외가 발생한 것을 모두 무시하고 로그만 간단히 남기게 했나봅니다.

하필 엄청나게 주문이 몰리는 시간이었네요. 수천건의 주문 완료후 -> 배달 요청데이터가 운영 배달 애플리케이션이 아니라 개발자의 Local PC에 떠 있는 서버로 빨려들어가 버렸습니다.

옆 팀 개발자는 local 에서 띄운 애플리케이션을 종료하고, 그 기간중 주문된 데이터와 배달 요청 메시지가 안 온 것을 뽑아내어 한땀 한땀 데이터를 복원해야만 했습니다. 그 사이 배달은 지연되고….

API 서버도 접근 통제 해야한다

머피네 회사는 앞서의 DB, MQ 관련 사고를 겪으면서 개발자 PC에서 운영 저장소로의 접근을 모두 통제하고, 불필요한 DDL, DML 권한을 모두 제거해버렸습니다.

"이정도 했으면 설마 무슨일 생기겠어??"

생기죠…

머피네 회사는 운영 API 서버의 접근 권한을 회사 망내의 개발자들에게 열어주었습니다.

머피는 데이터 변경요청을 받았는데 API를 호출하면 쉽게 된다는 것을 알았습니다. 그래서 안전하게 테스트 환경 API 서버에 변경 요청을 날려보기로 하였습니다. 그렇게 테스트를 모두 한 뒤에 운영 API 서버를 호출하기로 하였습니다.

요즘 나오는 REST Client 애플리케이션들은 개발, 테스트환경, 운영 환경별 profile을 설정하고 profile 별로 접속 주소를 따로 설정하고, API 요청시에 이 프로필을 선택할 수 있게 하고 있습니다. 즉, 같은 /api/update 라는 API도 어떨때는 local PC에서 어떨때는 운영 API 서버에서 손쉽게 실행하게 해줍니다.

머피는 나름 조심스럽게 프로필을 선택한다고 했는데, 프로필 선택하는 순간 옆에서 누가 잠깐 말을 걸었고, 터치패드의 손이 미끄러졌는지 production 프로필을 선택하고 실행해 버리고 맙니다.

그 뒤는 얘기하지 않겠습니다.

모든 서버는 private 으로, 회사 망에서 조차도 모두 접근 통제

개발자에게 local PC에서 모든 쓰기 권한을 삭제하고 읽기 권한만 지급해서 DB나 API 에 접근하게 하였습니다.

문제는 보안은 평균으로 작동하지 않고 최고 약한 부분에 의해 뚫리게 됩니다. 쇠사슬 처럼요. 100개중 99개가 튼튼해도 1개의 사슬만 고장나 있으면 그 쇠사슬은 끊어집니다.

개발자들은 다른 비개발자들과 동일한 망에서 개발을 했고, 운영 서버에 읽기 권한으로 접근할 수 있었습니다. 즉, 개발자가 아니라 동일 망내의 비개발자 모두도 운영 DB, API에 읽기 권한 접근이 가능합니다.

개발자든 아니든 직원 중 한명이 가짜 자동차 보험료 할인 Email 에 속아서 ActiveX를 깔거나 한다면 회사 망내의 포트를 샅샅히 스캔해서 데이터를 빼갈 수도 있을 것입니다.

  • 모든 서버는 private 망에서만 접근 가능해야하고, 꼭 필요한 것만 Proxy(AWS에서는 ELB/ALB 등)를 통해서만 public 에서 접근 가능해야 합니다.
  • 회사 직원이 사용하는 서비스는 회사 망내에서만 접근 가능해야합니다.
  • 어쩔수 없이 필요하다면, 모든 운영 저장소와 API는 회사망을 기준으로 하지 않고 꼭 필요한 특정 직원을 기준으로 되도록 쓰기 없이 읽기 권한만 접근 통제상태로 권한을 부여해야 합니다.
  • 요즘에는 Active X 대신 *.exe 파일과 Android 앱 수동 설치등으로 보안이 뚫릴 수도 있어보입니다.

사실 보안은 스타트업에게는 너무 어려운 일입니다. 당장 기능 개선하기 바쁘거든요. 그리고 보안의 강화는 개발의 불편을 의미합니다.

하지만 서비스가 정말로 성장할 거 같다면 성장세에 맞추어 계속 보완해나가야 합니다.

중요 Batch 는 실행 여부를 제 3의 시스템에서 검증해야한다

머피네 서비스의 인기가 너무 좋아 데이터량이 너무 많아져서 매일 매일 다음날치 데이터를 미리 생성해주는 배치(Batch) 애플리케이션으로 데이터를 미리 말아 놓는 방식으로 처리하기로 하였습니다.

머피는 열심히 개발했고 배치가 며칠 잘 도는 것도 확인했습니다. 꼭 실행해야 하는 중요한 배치이므로 배치 애플리케이션 코드 내에서 에러 발생시 에러 메시지도 메신저와 Email로 전송해주게 만들었습니다.

"배치 애플리케이션 실패에 관해서도 알림을 걸었으니 설마 실행 안되는 경우는 없겠지. 설마 배치 서버가 죽지는 않겠지."

네, 다행히 배치를 실행하는 서버는 안죽었습니다. 그런데 누가 배치 실행시간이 오래 걸리니 스케줄러를 변경해서 실행 시간을 좀 앞당기자고 했습니다. 그래서 스케줄을 앞당겼는데 cron 식을 약간 잘못 작성한 걸 모른채 지나갔습니다.

그리고 배치 애플리케이션은 아예 실행조차 되지 않았습니다. 실행이 안됐으므로 에러도 안났고, 에러가 안나니 알림도 오지 않았습니다. 아무 문제 없이 다음날 출근했더니 데이터가 없습니다.

중요한 배치 Job 들은 Batch Job 코드내부가 아닌 그와 별개의 모니터링 솔루션이 배치 Job이 특정 시간 범위내에 성공적으로 실행됐는지를 확인하고 그렇지 못할 경우 알림을 주게 만들어야만 합니다.

influxDB와 Grafana 를 사용하면 가능할 것 같네요.

Log를 외부 서버로 수집하는 것은 별도 프로세스에서 비동기로

10년도 전 얘기입니다. 로그 수집 서버 같은게 없을 때요.

로그를 Disk에 잘 쌓고 있었습니다. 그런데 에러가 발생해도 개발자들이 서버에 들어가서 보기전에는 모두 모르고 넘어가고 있었습니다. 좀 더 나은 프로그램을 만들고 싶고 문제를 빨리 인식하고 싶었습니다.

Java Logger 에 보면 이메일로 로그를 발송시켜주는 것이 있습니다(SMTPAppender). 그래서 에러 로그가 발생하면 이메일로 전송하게 했습니다.

하면서 "설마 에러가 그렇게 많이나려고…" 생각했습니다. 그리고 몇달 동안 날라온 에러 로그 이메일을 보면서 열심히 개선했습니다. 뿌듯했습니다.

어느날 전사 Email 이 먹통이 돼버렸습니다. 직원이 3천명은 넘었던거 같은데…

어떤 개발자가 해당 서비스의 메인 페이지에 버그를 심었고, 하필 이벤트로 인해서 메인 페이지는 어마무시한 폭탄급 트래픽을 받게 됩니다.

버그는 Stacktrace가 포함된 수많은 에러를 발생시켰고, 그 에러는 모두 메일로 전송되면서 SMTP 서버를 감당할 수 없는 지경에 빠뜨른 것입니다.

나중에 메일함에서 메일을 30만개 정도 지웠던거 같습니다.

그리고 그 기능을 제거하였습니다.

그 뒤에 머피는 다른 시스템을 맡아 일하면서 또 비슷한 상황을 맞이합니다. 거기서도 개발자들이 똑같이 서버 에러를 잘 안보고 있었습니다.

그래서 에러 로그가 발생하면 그것을 네트워크 통신으로 접속해서 전송해주는 Log Appender 를 설정하였습니다. 또 에러가 많이 날 수도 있다고는 생각했습니다. 하지만 "설마 에러가 나서 엄청나게 에러 로그 전송한다고 뭔일 나겠어?"라고 생각했죠. 즉, "뭔 일이 나는지 모르는 상태"였습니다.

또다시 누군가가 메인 페이지에 버그를 심었고…

당연히 SMTP 서버는 멀쩡했습니다. 그런데 애플리케이션이 멈춰버리고, 서버에 접근을 할 수가 없었습니다.

에러 로그 폭탄은 네트워크를 타고 전송됐고, 네트워크 대역폭을 모두 잡아먹었습니다.

로그 전송에 고갈된 네트워크는 엄청나게 느려져서 장애 대응하러 서버에 접속하는 것이 불가능했습니다. 아마 해당 서버의 Disk IO/CPU 점유율도 문제가 돼서 접속이 안됐었을 것 같습니다.

요즘 처럼 AWS 같은 클라우드를 사용한다면 새로운 서버를 쉽게 띄우고 트래픽을 옮겼겠지만 당시에는 클라우드를 사용하지 않고 고정된 하드웨어에서 애플리케이션 버전을 갈아끼우는 방식으로 배포했기 때문에 다른 신규 서버를 띄워서 트래픽을 전환하는 것도 매우 힘든 상황이었습니다.

여기서는 세가지 문제가 있었습니다.

  • 지금은 좋은 로그 수집 시스템들이 많이 나와있죠. 그런 솔루션을 사용하고, 어설프게 애플리케이션 프로세스에서 네트워크로 전송하는 방식을 사용해서는 안 됩니다. 애플리케이션 프로세스와 독립된 로그 수집/전송 Daemon 을 사용해 로그를 비동기로 CPU와 Disk/Network IO에 영향을 최소화 하는 방식으로 차등 전송해야 합니다.
  • 애플리케이션 자체에서도 비동기로 Disk 쓰기 작업을 하게 AsyncAppender를 설정하는 것이 좋습니다. 부하가 심하면 불필요한 로그를 버리거나, 혹은 쓰기 작업을 늦춥니다.
  • 애플리케이션 자체에서 처리하려고 했다면 성능 테스트를 해야만 했습니다.

Primary Key는 int 가 아니라 long 으로

스타트업 개발자들의 흔한 실수중에 "설마 우리 서비스가 성공할리 없어~"라는게 있는 것 같습니다.

비용을 아낀다는 명목으로 혹은 별 생각없이 순차 증가하는 Primary Key 를 Integer로 만듭니다.

이는 갑자기 폭발적으로 성장하는 서비스의 발목을 잡는 주범이 됩니다. 단순히 순차 증가 값을 Long 으로 하는 것만으로 엄청난 장애를 막고 비용을 아낄 수도 있습니다.

Integer로 매핑된 DB 컬럼을 변경하고, 애플리케이션 코드의 매핑 필드 타입을 변경하는 작업은 엄청난 비용을 초래하며, 수많은 연계 시스템들 중에서 한개만 Integer 매핑 상태로 있어도 전면 장애로 이어질 수 있습니다. 특히 PK값은 외래키로 서비스 곳곳으로 전파되기 때문에 한군데만 고친다고 문제가 해결되는게 아닙니다. 저장소 비용 아끼려다 개발 비용/장애 처리 비용 및 신뢰도 하락이라는 더 큰 비용을 초래합니다. 게다가 성공하지 못할 서비스에게 Long 형 데이터 몇개는 비용으로 볼 수도 없는 수준일겁니다.

너무도 명명백백하게 어떤 상황에서도 Integer 범위를 넘길 수 없는 경우를 빼고는 그냥 순차 증가 PK는 Long 으로 매핑하는게 이득인 거 같습니다. 어려운일도 아니니까요.

여기선 주의할 것이 있습니다. API로 외부 연동을 할경우 클라이언트 애플리케이션에서도 확실히 Long 으로 매핑하고 저장하는지 확인해야만 합니다. 초반 테스트용 데이터의 작은 숫자만 보고 Integer로 API 결과를 매핑하는 일도 부지기수입니다.

  • 새 ‘8자리 자동차 번호판’ 다음달도입 – 기존 자동차 번호판의 숫자값이 자동차 보급 대수의 범위를 모두 포함할 수 없어서 2019년 9월부터 자동차 번호판에 1자리 추가 개편 진행. 이로인한 주차장 번호판 인식시스템 전체 업그레이드, 주차단속 시스템 개편 필요등 사회적 비용이 크게 증가했습니다.
  • ‘강남스타일’ 조회수 뒷걸음질…범인은 ’32비트’ : 싸이 강남 스타일 조회수 32bit 정수 돌파, 유튜브 조회수를 64bit long 으로 전환작업. 이건 그나마 PK가 아니라 크게 어렵지는 않았을 듯하네요.

에러 로그는 일괄 알림을 하면 안되고, 일상적인 것과 Critical 한 것을 구분해

"설마 우리 개발자들이 알람을 놓치는 일은 없을거야!" 모든 알람을 같은 채널에 두자!

모든 알람을 다 중요하게 취급하든, 모든 알람을 다 안 중요하게 취급하든 중요 알람은 놓치게 됩니다.

다소 일상적으로 오는 알람과 중요 이벤트 알람을 구분하고 중요 이벤트는 관련팀의 응답을 필수적으로 체크하게 하는 것이 좋습니다.

어느정도 규모있는 서비스 해보신 분들 중에서 마케팅팀에서 주요 포탈 메인 광고를 붙이는 공유를 못 받아서 서버 증설 대응을 못해서 장애나거나, 에러 로그 알림중 중요한 것을 무시하고 넘어가서 나중에야 알게되는 일은 안 겪어 보신분은 흔치 않을거라 생각합니다.

그때 많이 듣는 말이 ‘우리 회사는 정보가 잘 공유가 안돼’라는 푸념인데, 사실은 공유했지만 중요도를 인지하지 못한 경우가 더 많았던것 같습니다.

롤백 가능한 배포

"설마 나의 배포는 완벽해! 뒤로 되돌릴 일은 없다고!" 라는 자신만만하신 분…
없죠?

  • 웹애플리케이션 뿐만 아니라 Batch 애플리케이션도 롤백 가능하게 만들어야 합니다.

당신의 서버는 무조건 죽는다 – SPoF를 제거하자

"설마 내가 구축한 서버가 죽겠어?"

네, 죽습니다.

안 죽는 서버를 만드는게 아니라 "죽어도 괜찮은 서버"를 구축해야 합니다.
Single Point of Failure를 제거하라는 의미입니다.

또한 내가 만든 애플리케이션이 아니라 내가 호출하는 애플리케이션이 죽는 경우도 염두에 두고 써킷 브레이커 등을 도입하는 것도 고려하면 좋습니다.

마무리

저의 모든 경험을 여기 적을 수도 없고, 그렇다 쳐도 저의 경험은 개발자들이 하는 모든 상황 중 1%도 안되는 한 줌도 안되는 수준에 불과할 것입니다.

그리고 위의 모든 내용은 제가 다 겪은게 아니라 여전히 "설마…"인 상태인 것도 있습니다.

제가 하고 싶은 말의 핵심은 이것입니다. "이런 저런 일이 생길 수도 있겠네, 그러면 큰 문제가 되겠는데?" 라는 생각이 드는 순간 실제로 그 일이 발생했다고 가정해야 한다 는 것입니다.

  • 실제로 그 일이 발생하면 여파가 어느정도될까? 감당할 수준인가? 판단해봅니다.
  • 판단 결과 장애정도가 너무 크다면 그 장애는 이미 발생한 것으로 간주합니다.
  • 그리고 실제 장애가 발생했을 때 처럼 모든 우선순위에서 최상위로 올려서 최우선 처리를 합니다.

그러나 감당할 수 있는 수준이라면 그것은 그것대로 넘어가야 합니다. 세상의 모든일에 미리 대비한다면 아무일도 못하게 되고, 어떤 서비스도 배포해보지 못할 것이기 때문입니다.

저는 서비스의 성장에 맞추어 대비를 해나가야하지 않나 싶습니다. 서비스의 성장 단계에 따라 장애 발생시 여파가 달라지기 때문입니다.

제가 일하면서 제일 많이 한 생각 – "아… 설마 설마 했지만 알고는 있었는데… 계획도 세워뒀었는데…"

다시 한 번 기억을 상기 시켜드립니다.

"잘못 될 수 있는 것은 꼭 잘못되게 마련이다."