[요즘 우아한 개발] 백엔드 개발자로 성장하기 : 개발자 머피의 법칙 ❶

이 글은 《우아한 요즘 개발》에서 발췌했습니다.

골든래빗 출판사

#개발문화        #데이터베이스        #보안  

손권남 2019.09.19

머피의 법칙은 ‘항상 나한테만 재수 없는 일이 일어난다’는 의미가 아닙니다. ‘어떤 일을 하는 데에 둘 이상의 방법이 있고 그것 중 하나가 나쁜 결과(disaster)를 불러온다면 누군가가 꼭 그 방법을 사용한다’에서 시작해 ‘잘못되는 것은 꼭 잘못되게 마련이다’라는 의미입니다. 특정한 개인의 불행과는 상관없는 것이지요. 어차피 잘못될 것이니 자포자기 하라는 의미가 아니라 잘못될 가능성이 있다고 생각된다면 미리 대비하라는 말입니다.

프로그래머로 산 지 좀 되다보니 ‘어, 이거 이렇게 하면 저렇게 잘못될 거 같지만, 에이 설마…’ 이런 생각을 하고 넘어 갔다가 호되게 당한 경험이 꽤 많고 다른 사람이 그런 경우도 많이 봤습니다. 주로 백엔드 서버 개발자로서 어떤 일들을 겪었는지 저 자신의 경험과 주위에서 본 것을 정리해보았습니다.

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

판매할 상품 정보를 입력하는 화면을 개편했습니다. 업무 지시는 ‘몇% 할인 이벤트 실시!’라고 내려왔으나 정작 데이터 입력은 할인 금액으로 받다 보니 매번 운영자들이 할인율에 따라 할인 금액을 계산해서 입력해야 하는 불편이 있었습니다. 그래서 할인 금액 대신 할인율을 입력하게 변경하기로 했습니다. 10,000원에 대해 10% 할인하면 기존에는 ‘1,000’이라고 입력했지만 이제는 ‘10’이라고 입력해야 하지요.

화면의 입력란에 ‘할인율: %’라고 친절하게 만들어두었습니다. 개발자 머피는 ‘이렇게 친절하게 안내도 해놨는데 설마 어떤 바보가 잘못입력하겠어?’라고 생각하며 뿌듯해했습니다.

그런데 갑자기 몇몇 상품이 무료로 판매되기 시작했습니다. 기존 상품 정보를 입력하는 몇몇 운영자들이 하던 습관대로 할인율을 입력하는 곳에 예를 들어 10(%)이 아니라 1000(원)을 입력합니다.

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

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

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

 

모든 계산은 서버에서 한다

 

항상 최선을 다하는 머피는 불필요한 DB 접근이 너무도 거슬렸습니다. 그래서 사용자가 상품을 선택하고 주문을 누르면 브라우저에서 주문 폼으로 상품 ID, 이름, 가격을 서버로 전송하도록 했습니다.

주문 컨트롤러는 상품을 다시 조회할 필요가 없었습니다. 그냥 매개변수로 넘어온 상품 ID, 이름, 가격을 그대로 결제 시스템으로 넘겨서 결제하고 주문 결과 데이터로 저장만 하면 되게 만들었습니다.

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

그렇게 넘어간 뒤 1년이 훌쩍 지나서 눈치를 채게 되었습니다. 웹 개발에 대한 간단한 지식을 가진 어떤 사람이 900만 원어치 상품을 주문하면서 주문 폼의 결제 가격 값을 폼 전송 직전에 9,000원으로 바꿔치기 주문하는 대담함을 보이다가 이상하게 여긴 운영자가 확인해서 덜미가 잡혔습니다.

웹 개발을 조금이나마 안다면 폼 값을 수정해서 서버에 요청을 전달하는 것은 매우 쉬운 일입니다.

그동안 얼마나 많은 사람이 안 들키게 조금씩 가격을 조작해서 주문을 했을지 가늠할 수도 없었습니다. 사용자의 입력 유효성 검사는 항상 서버에서 해야 하며, 특히 계산 로직은 사용자 편의를 위해 프론트엔드에서 일시적으로 해서보여줄 수는 있으나 최종 결과는 서버에서 재계산해야 합니다. DB조회 한 번 하는 거 귀찮다고 안 했다가 큰일 납니다.

 

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

 

신입 시절 머피는 사수님으로부터 매우 좋은 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를 호출해봤어요. 서버에서는 조회 조건이 모두 사라진 SQLSELECT * 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 점유율도 그다지 높지 않은 것 같았습니다.

멤케시드/레디스 같은 원격 분산 캐시를 사용한다면 몇 가지 주의할 점이 있습니다. 원격 분산 캐시는 네트워크 대역폭을 먹고 삽니다. 캐시에 너무 많은 데이터를 담으면 비록 캐시 히트율이 매우 높더라도 데이터가 네트워크 대역폭을 잡아먹어서 느려집니다. 따라서 실제 운영 서비스를 기준으로 성능 테스트를 하고 충분한 대역폭이 확보되어 있는지 확인했어야 합니다.

또한 데이터 크기가 너무 크다면, 압축 솔루션으로 전송량을 낮출 수도 있습니다. 하지만 이것도 문제가 있습니다. 압축된 데이터를 풀고, 직렬화를 하는데 들어가는 CPU 점유율도 충분한지 테스트가 필요합니다. 이 경우는 그나마 낫다고 할 수 있습니다. 서버만 늘리면 되니까요.

그리고 원격 캐시 사용의 커다란 문제 하나가 더 남습니다. 직렬화 시 데이터 포맷의 변경 체크 문제입니다. 머피는 중요하게 사용되고 캐시도 하는 어느 데이터에 필드를 하나 추가하고, 또 이상한 이름으로 된 필드의 이름을 바꾸었습니다. ‘설마, DB도 아니고 캐시 데이터 필드 좀 바꾸는 건데 무슨 문제 있겠어?’ 네, 심각한 문제가 있습니다. 필드의 변경은 직렬화된 데이터의 변경을 의미합니다. 즉, 배포되기 전의 서버에서 캐싱한 데이터와 배포 중인 서버에서 캐싱한 데이터가 서로 다른 필드를 가지고 있다는 얘기인데, 둘 중의 한 곳에서는 특정 필드의 데이터를 누락시키고 있다는 말이 됩니다. 비록 직렬화를 할 때 존재하지 않는 필드를 무시하는 옵션을 사용해서 잠깐 오류가 나지 않는 것처럼 보이게 하더라도 비즈니스 로직에 문제를 일으켜서 엉뚱한 결과가 나오게 할 수도 있습니다.

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

인증과 권한은 다르다

머피는 고객의 주문 목록을 보여주는 웹페이지를 개발하기 시작했습니다. 사용자의 주문 정보를 제공하는 API가 주문 서비스에 이미 있습니다. /users/<userId>/orders API가 주문 API 서버에 존재하니까 프론트엔드 API 게이트웨이에서 동일한 URL로 호출이 들어오면 로그인했는지 인증한 후에 주문 API 서버로 요청을 내려보내도록 설정했습니다.

‘세상에~ 난 정말 똑똑한 개발자인가봐. API 게이트웨이 설정만으로 멋지게 기능 개발을 완료했어. 설마 사용자가 URL을 직접 쳐보겠어?’라고 생각했습니다. 그런데 갑자기 보안팀에서 프론트엔드 서버의 /users/*/orders에 대한 대량 크롤링 요청이 들어왔다고 확인 좀 해달라는 연락이 왔습니다.

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

인증(authentication)은 “내가 누군데 말이야~”라는 확인 절차입니다. 권한(authorization)은 “내가 누군지 아셨을 테고, 그래서 나 이거 봐도 되나요?” 혹은 “이거 수정해도 되나요?” 하는 확인 절차입니다. 이런 실수는 권한이 무엇인지 몰라도 일어나고, API 게이트웨이에 권한을 끼워넣지 않고 무분별하게 사용할 때에도 발생합니다. 인증만 하고 내부 API 서버로 요청을 바이패스하면 절대 안 됩니다. 권한 검사까지 잊지 말고 해야 합니다. 프론트엔드 서버에서는 애초에 요청 매개변수를 받지 않고, 로그인 사용자의 정보를 인증 세션에서 읽어서 바로 데이터를 읽어야 합니다(매개변수 없는 /user-orders 정도).


 

사용자의 로그인 실패 횟수를 트래킹해야 한다

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

그런데 서비스의 인기가 올라가면서 사용자 계정의 개인정보가 점점 가치를 가지게 되었습니다. 머피네는 사용자들에게 포인트(가상 재화)를 지급했는데, 포인트를 수십만 원까지 쌓은 사람도 나왔습니다. 공격자들은 그런 포인트를 노려서 사용자 계정의 비밀번호를 알아내려 했습니다. 여지없이 사용자 계정에 대한 무차별 대입 공격으로 서버가 마비될 지경이 되었고 수많은 사용자 계정의 비밀번호가 노출되었습니다.

그래서 캡차를 도입했습니다. 사용자가 5회 이상 비밀번호를 틀리면 캡차를 입력해야만 다시 로그인 시도가 가능하게 했습니다. 그런데 이럴 수가, 그래도 계속 공격이 들어오는 것이었습니다! 동일한 사용자 ID에 대해서 말이지요.

머피는 머리가 아팠습니다. 옆에 있던 팀 동료에게 코드 리뷰를 부탁했더니 금방 찾아줍니다. 머피는 특정 사용자 ID의 로그인 횟수를 계속 증가시켜가며 저장해야 하는데, 급하게 만든다고 브라우저 쿠키에 값을 넣어서 1씩 증가시켰던 겁니다. 계속 나왔던 사용자 입력 검증은 서버에서 하라는 원칙을 또 어기고 클라이언트 측 저장소인 쿠키를 사용했기에 공격자가 매우 쉽게 쿠키 값을 항상 1로 조작해서 반복 공격을 했던 것이지요.

대충 만든 부정 로그인 방지 시스템을 레디스 등의 원격 저장소를 사용해서 서버 측 검증으로 다시 제대로 만들었더니 공격이 멈추었습니다. 이 때 비밀번호는 당연히 복호화 불가능하게 암호화했습니다. 또한 캡차 말고, 일정 횟수 로그인 실패 시 고객의 이메일과 문자로 통보하는 방법을 사용할 수도 있습니다. 이 경우 비용 문제도 고려해야 하겠지만요.

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

머피는 로그인 캡차를 붙이고서 ‘설마 이 정도 했는데, 이래도 사용자 계정이 털리겠어? 만약 계정 비밀번호가 노출돼서 포인트가 사용돼도 그건 고객 책임이지’하며 안심하고 있었습니다. 그런데 고객센터에서 사용자의 포인트가 털렸다는 불만이 계속 이어졌습니다. 캡차를 도입했음에도 이런 일이 일어나는 이유는 사용자들이 여러 사이트의 비밀번호를 다 기억하지 못하므로 여러 사이트의 ID와 비밀번호를 동일하게 만들기 때문입니다. 그리고 그중에 보안이 허술하고 부정 로그인 방지가 되지 않은 서비스에서 ID와 비밀번호를 알아낸 다음 그것으로 머피네 서비스에 다시 로그인 시도를 해서 성공해버린 것이지요.

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

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

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

머피는 포인트를 사용할 때 사용자 로그인 비밀번호와 전혀 다른 비밀번호를 입력하게 했고,그 상태에서도 인증을 3회 이상 실패 시 아예 결제가 불가하게 변경했습니다. 또 다른 비밀번호를 기억하는 것이 사용자에게 번거로울 수있기 때문에, 사용자 계정이 본인인증이 돼 있는 것이라면, 포인트 사용 시 다시 한번 본인인증 수단을 사용하는 방법도 있겠습니다. 추가적으로 일정 횟수 이상 로그인 실패 시 무조건 고객에게 알림을 보내도록 처리했고, 포인트 결제 시에도 외부 결제 시스템이 그러하듯 고객에게 알림을 보내게 했습니다.

◆◆◆

백엔드 개발자로 성장하기 : 개발자 머피의 법칙 은 2편에서 이어집니다.

저자 우아한 형제들

우아한형제들은 배달이 일상을 조금 더 행복하게 하도록 오늘도 달리고 있습니다. 평범한 사람들이 모여 비범한 성과를 만들어 내는 곳이될 수 있도록 건강한 조직문화를 만드는 일에 진심을 다합니다. 2016년부터 ‘우아한형제들 기술블로그’를 운영하며 개발 조직의 성장 과정을 기록하고 있습니다.

Leave a Reply

©2020 GoldenRabbit. All rights reserved.
상호명 : 골든래빗 주식회사
(04051) 서울특별시 마포구 양화로 186, 5층 512호, 514호 (동교동, LC타워)
TEL : 0505-398-0505 / FAX : 0505-537-0505
대표이사 : 최현우
사업자등록번호 : 475-87-01581
통신판매업신고 : 2023-서울마포-2391호
master@goldenrabbit.co.kr
개인정보처리방침
배송/반품/환불/교환 안내