소프트웨어 사색

실패/실수에 대처하는 다양한 방법들

[하마] 이승현 (wowlsh93@gmail.com) 2021. 11. 9. 11:06

누구나 실수/실패의 상황을 마주하게 되며, 이것은 당신의 실수일 경우도 있고, 타인의 실수 있을 수 도 있으며, 서버/네트워크상에서 일어나는 실패(장애)일 수 도 있다. 이런 다양한 실수/실패는 개발자의 삶과 항상 함께 하는 것이기 때문에, 어떻게 이것을 다루는지가 관건이 된다. 이 글에서는 어떻게 실수/실패등 의도치 않은 상황을 처리하는지, 다양한 패턴들과 함께 살펴보겠다.  

1. 디폴트값 처리 

val value = getValue() 
val gretting = value?: "hi"

정상적인 상황하에서 값을 얻지 못하였을 경우 우리는 디폴트값을 할당하여 사용 할 수가 있다. 디폴트을 사용할 수 없는 경우에는 대개 예외를 던지거나 실패값을 리턴해서 상위에서 처리하길 기대 할 수 밖에 없다.

2. Require / Check / Assert  

보통 의도치 않은 결과를 받았을때 무시하고 조용히 넘어가기도 하나 "나 문제 있어요" 라고 공격적으로 알리기도 한다. 코틀린언어로 설명하면 (대부분의 언어가 동일한 기능을 제공한다) 아래와 같은 키워드들이 존재 한다.

require : 주로 파라미터가 기대하는 값인지에 대한 검사 
check : 주로 객체가 기대하는 상태를 가지고 있는지에 대한 검사 
assert : 테스팅 모드에서 어떤 값이 true 인지를 체크하는 검사 (JVM에서는 -ea옵션을 줬을때 작동) 

fun pop(num: Int = 1): List<T> {
	require(num <= size) { "Cannot remove more elements than current size" }
    
    check(isOpen)

	val ret = collection.take(num) 
    collection = collection.drop(num) 
	assert(ret.size == num) 
	return ret 
}


3. 예외 상황

무엇인가 원하는 결과가 발생되지 않았을 경우 당장 적절한 해법이 없을 때,  우리는 예외 처리를 하게 되는데, 예외 처리라는 것은 굉장히 까다롭다. 해당 예외를 발생한 가장 가까운 곳에서 처리 할 수도 있으며, (가장 잘아니깐) 가장 먼곳까지 거슬러 올라가서 처리 될 수도 있다. 또한 예외 발생시 프로그램을 죽여야 하는 경우도 있고, 살리되 로그라든지 클라이언트측에 잘 설명해야 하는 경우도 있다. 

젤 어려운 부분 중 하나는 모두 되돌릴 것인가? 이미 벌어진 일은 무시,포기할 것인가? 인데 
그냥 살리는게 중요 포인트라면 예외 발생시 그냥 로그정도 출력하든지 클라이언트에게 알려주고 지나치면 된다. 그 메소드를 호출한 사람은 아마 한번 더 호출해 보겠지. 아니면 그 사람은 망하더라도 다른 모든 사람들에게는 서비스가 진행 될 수도 있고.. 하지만 전체 흐름을 되돌려야한다는 문제의식을 갖게되는 어플리케이션이라면 정말 거대한 도전이 된다. 트랜잭션정합성, Redo,Undo,커맨드패턴 ,사가패턴 같은 단어들을 떠올려야 하기때문이다.

자 갤럭시 7 을 생산하는 공장에서 갤럭시 7 에 들어가는 전자기판(pcb)을 검사하는 프로그램이 있다고 해보자.  그 프로그램은 기판을 검사하기 위해 많은 지식이 사용자에 의해 학습되어야 한다. 그 기판에는 어떤 부품들이 있고 어떻게 생겼고 어떤 방식으로 불량이 아닌지를 확인하는등.. 이것을 학습된 검사조건들 이라고 하자.

이 검사조건 중 하나의 값을 30-> 50 으로 올릴때  발생하는  시나리오가 아래와 같이  흘러 간다고 하자.

a. 50을 입력받아서 
b. 50을 포함한 새로운 임시 객체를 만들어서 적용해 나간다.
c. 변경 된 데이터를 많은 뷰에 적용시키고 (즉 화면뷰에도 50으로 바뀌고 , 리스트뷰에도 바뀌고) 
d. 네트워킹을 통해 중앙저장소에도 바꾸어주고 
e. 검사조건을 모아놓은 메모리 자료구조를 수정한다. 
f. 로컬 xml 파일에 새로운 50을 써준다.
g.완료 (버튼 활성화)

이렇게 메소드들이 호출된다고 할때 갑자기  d에서 문제가 생기면 어떻게 될까?  메모리에는 이 어플리케이션이 갖는 최종적인 마지막 값이 50으로 갱신되지 못한 채 예외가 생겨서 30으로 남겨져 있다. 그리고 검사조건들을 저장해놓은 파일도 갱신되지 못하여 나중에 프로그램을 시작할때 30으로 읽어들일 것이다. 하지만 화면뷰와 중앙저장소의 값은 50인데??

먼가 문제가 생겼을때 돌이키는 방법으로는 메멘토패턴 혹은 커맨드패턴을 주로 사용하는데 커맨드 패턴인 경우 undo / redo 를 처리 할때 execute 와 unexecute 함수를 가지고 내부에 이전정보,이후정보를 속성으로 가진 커맨드 객체를 이용한다. 근데 이때 unexecute 에서 처리해 줘야할 것들이 간단치 않다는게 문제이다. 저 전체를 한 묶음으로 묶어서 처리하는 트랜잭션 처리시  데이타베이스처럼 상대적이고 일관되고 한정된 로직에 있는 것도 아니고 메소드의 역할에 따라 다른 행동에 대해 어떻게 트랜잭션 처리를 일관되게 할 수 있을까?  맨붕이다..

4. 예외를 던질 것인가? 리턴 값으로 fail을 던질 것인가?  

함수에서 문제가 생겼을때 문제 상황을 외부로 전파하는 방식은 대표적으로 2가지가 있다. 

- 리턴을 null이나 Failure/Option/Try 객체를 리턴한다. (가까운곳에서 처리하길 기대)
- 예외를 던진다. (먼곳에서 처리되길 기대) 

예외를 통한 처리 보다는 리턴을 통해 처리하는 것을 우선시 하며, 예외는 좀 더 처리하기 힘든 상황에 대한 핸들링을 하면 된다. (애매할 수 있는데, 가장 윗단에서 처리되길 기대하는 상황? 예로 백엔드의 controller나 UI프로그램의 시작지점), 예외는 사용자의 가독성이 약하며, (자바처럼 강제하지 않는다면 더더욱), 프로그래머가 무심코 넘기기 쉽다. 그리고 정확한 진단을 내리는데 두리뭉실 할 수가 있으며, try~catch문은 컴파일러가 최적화하는데도 방해하곤 한다. 


5. 커맨드패턴 

커맨드 패턴은 보통 메소드(동사)를 클래스(명사)화 하여 사용하여, 행위를 수집 할 수 있게 한다.
보통 실행/역실행의 2가지 메소드를 포함하는데 무슨 말인고 하니... 아래 코드를 보자. 

class Painter {
  fun draw(){ .. }
  fun remove(){ .. }
}

보통 클래스는 위처럼 명사이고 메소드는 동사임을 알 수 있다.
여기서 draw라는 동사(메소드)를 명사(클래스) 화 해보면 

interface Command {
  fun execute()
  fun unexecute() 
} 

class DrawCommand (val start: Point, val end: Point) : Command {
 fun execute() {
    // draw start to end 
 }
 
 fun unexecute() {
   // remove start to end 
 } 
}

위처럼 DrawCommand라는 클래스가 생기고, 메소드로는 실행/실행취소 2가지 주요 메소드가 만들어진다. 
이렇게 DrawCommand라는 클래스를 만들게 되면, 그림판에서 무엇을 열심히 그리는 동안 그려지는 과정을 내부적으로 Undo 스택을 만들어서 push해 놓게 하였다면, 나중에 내가 잘못 드로잉을 했구나 라고 깨닫게 되었을때, 스택을 되돌리며 unexecute를 실행해서 다시 실수를 "원상복구" 할 수 있게 된다. 이때 unexecute의 구현은 어떤 command냐에 따라서 천차만별 달라 질 수 있을 것이다. 이런 어려움은 위의 3번에서 설명한 부분이다.


6. 서킷브레이커

외부의 서비스에 요청 콜을 날리고 나서 대개는 바로 응답을 받으나, 외부 서비스의 문제 혹은 네트워크의 문제로 응답이 안오거나 늦어질 경우가 있다. 이때 아무런 조치를 안 해 놓는다면 매번 느려진 응답을 기다리느라 장애가 전파되며 전체 시스템에 문제가 생길 수 있으며,  클라이언트측을 화나게 할 수 있다. 문제가 있으면 바로 문제가 있다고 알려주는게 나을 것이며, 1번의 디폴트값 처리식으로 처리 할 수도 있을 것이다. 이런 상황에서 사용 하는게 서킷브레이커인데, 문제가 생기면 더 이상 호출하지 않도록 중간에서 차단시켜 주는 방식이다. 문제가 생기자마다 차단 한 후에 n번까지는 기다려 줄 수 도 있겠으며, wait 인터벌을 다양하게 가져갈 수 도 있을 것이다. 

문제가 해결되었음을 감지한 경우에도 처리하는 방식이 달라질 수 있는데, 모든 리퀘스트를 정상상태와 같이 처리 하게 할 수도 있겠고, 이게 또 무슨 문제가 일어날 지 모르니 살살 간을 보면서 시나브로 오픈하게 만들 수도 있겠다.  
 

7. Consistent Hashing 

어떤 데이터들을 N개의 노드에 분산시켜서 저장한다고 해보자. 혹은 리퀘스트를 분산시켜서 처리 한다고 해보자.
이때 가장 먼저 쉽게 떠오르는 방식은 데이터 d를  hash(d) mod n 해서 나온 노드에서 처리하게 만드는 것이다. 근데 이런 방식은 노드 하나 이상이 실패(장애)가 생기는 경우 큰 비용을 초래하게 되는데, 기존 노드에 있던 데이터들이 모두 재계산되어야 하기 때문이다. (hash(d) mod n <-- 이 값이 달라지니깐)

Consistent Hashing 은 이런 경우를 최소비용으로 해결하는 멋진 방법이다. 이것은 일련의 해시 값을 가상의 링(ring)에 순서대로 나열했다고 가정하고 각 노드는 링에서 각자 맡은 범위만 처리하는 방법이다. 만약 노드를 추가하면 특정 노드(많은 데이터를 가진 노드)가 맡고 있던 범위를 분할하여 새 노드에 할당한다. 노드를 삭제할 때는 링에서 삭제된 노드가 맡고 있던 범위를 인접 노드가 맡는다. 따라서 서비스 중에 노드의 추가/삭제로 인해 영향을 받는 노드 수를 최소화할 수 있다. 

8. Saga패턴

 
마이크로서비스 도입의 환상을 깨주는 가장 큰 문제는 트랜잭션 처리일 것이다. 하나의 단일 RDBMS를 사용 할 경우, 신경 쓰지 않아도 되는 ACID문제가 , 여러 서비스에서 여러 DB를 사용하며, 해당 서비스간에 강력한 의존성을 가지고 있다면 지옥으로 다가오게 된다. CID는 어느정도 참아줄 수 있다고 해도, A(원자성)에 문제가 생기다면 큰 손실을 입을 것이기 때문이다. 즉 A가 B에게 돈을 1억 줄때, 양쪽이 모두 변화가 있어야지, 한쪽만 변화가 있다면?? OTL ..이때 해결방법으로 분산트랜잭션이라는 허상을 사용 할 수 도 있는데, two way commit은 제대로 작동하기엔 너무 부족하며 좀 더 나은 방식으로 우리는 Saga패턴을 고려 할 수 있다.  (그래서 마이크로 서비스를 설계 할 때, 서비스끼리 너무 많은 상태를 공유/의존하는 것을 지양해야 한다) 

사가 패턴에서는 보상이라는 개념으로 (위에 커맨드패턴에서 말하는 것 처럼, unexecute()) 이벤트의 흐름 중 전체가 완결되지 않았다면, 뒤로 롤백하면서 하나씩 Undo(보상)하는 것이다.이 흐름의 조율을 중앙에서 매니징 할 수 도 있고, 각 서비스들이 서로 이벤트를 전달하며 분산해서 처리 할 수도 있다 


9. 이중화,복제   

동일한 것(서비스,데이터)을 n개로 두고,  사용하게 하는 모든 것을 총칭.
듀얼디바이스(RAID,PSU등) , L4/L7 스위치, HAProxy, NginX, Zuul, ELB, API Gateway 등등