kwondroid의 개발 세계

Exception Safe Programming 본문

개발

Exception Safe Programming

kwondroid 권오철 2023. 4. 27. 15:30

Exception은 생각을 깊게 할수록 꽤 머리가 아픈 주제이다.

최근 이에 대해 짧은 이야기를 할 기회가 있었고 이에 대해서 정리를 해보고자 한다.

 

고민 1 : Exception의 종류는 어떤것이 있을까?

자바의 경우 아래와 같은  Exception Class를 제공해준다.

 

 

Java Platform SE 8

 

docs.oracle.com

갯수를 보니 Exception에 대하여 모든 케이스를 머리속에 두는건 어렵다. 또한 이것은 JAVA가 정의한 Exception일뿐 언어, 도메인, 코드 구조별로 발생할 수 있는 Exception이 다르다는 것을 생각해보면 모든 Exception에 대해 파악하는 것은 사실상 불가능하다.

 

고민 2: Exception이 자주 발생할 수 있는 동작은 무엇일까?

  • file io
  • networking
  • null pointer access
  • array index out of bounds
  • run networking on ui thread
  • during context switch
  • etc...

실무를 진행하면서 한 번쯤 겪어봤을 Exception 들이다.

Null Pointer Access 나 Array index out of bounds 같은 건 개발자의 실수로 많이 발생했을 것이고 file io나 networking 같은 것은 개발자의 실수도 있겠지만 코드로 완벽히 컨트롤할 수 없는 케이스라서 손가락만 빨고 있는 상황을 겪어봤을 것이다.

 

고민 3 : 다른 언어들의 Exception 처리 문법들은?

c

만들어서 쓰세요. (이건 현실적으로 쓰일것같지는 않다)

https://stackoverflow.com/a/5721380

 

Error handling in C code

What do you consider "best practice" when it comes to error handling errors in a consistent way in a C library. There are two ways I've been thinking of: Always return error code. A typical funct...

stackoverflow.com

 

kotlin

import java.io.*

fun main() {
    println("first print") // 출력됨
    
    // 코틀린은 try를 식으로 사용할 수 있음
    val num = try {
        Integer.parseInt(BufferedReader(StringReader("앙!")).readLine())
    } catch(e: NumberFormatException) {
        return // 여기서 프로그램 종료
    }
    println("her") // 출력 안됨
    println(num) // 출력 안됨
}

python

try:
    4 / 0
except ZeroDivisionError as e:
    print(e)

javascript, typescript

try {
  throw new Error('Something bad happened');
}
catch(e) {
  console.log(e);
}

go

func main() {
	result, err := divider(1, 0)
	if err != nil {
		fmt.Println(err.Error())
	} else {
		fmt.Println("결과는:", result)
	}
	fmt.Println("프로세스 끝")
}

func divider(a int, b int) (int, error) {
	if b == 0 {
		return -1, errors.New("0으로 나눌수 없습니다.")
	}
	response := a / b

	return response, nil
}

이건 번외이긴 한데 go를 좋아하는 사람들 중에 ts를 이렇게 쓰는 사람들도 있다.

function doSomeThing(param: number): { results: unknown, err: null | unknown } {
  try {
    if (param === 0) throw Error("case 0")
    if (param === 1) throw Error("case 1")
    return { results: "success", err: null }
  } catch (e) {
    if (e instanceof Error) return { results: "fall", err: e.message }
    else return { results: "fall", err: "unknown error" }
  }
}

function main() {
  const { results, err } = doSomeThing(0)

  if (err) {
    alert("에러요!")
    return
  }
  console.log(results)
}

main()

고민 4 : 그럼 Exception을 어떻게 처리해야 할까?

1. 런타임을 터뜨린다.

Exception 발생 순간 이미 실행 중인 workflow는 깨진 것이고, 깨진 workflow를 다시 주워 담아 진행하는 것은 의미가 없다.

이미 workflow와 그에 필요한 state는 망가졌고, 이를 정상적으로 되돌리는 것은 100% 확률을 보장할 수 없다.

선행 조건이 망가진 상태에서 이후 동작을 진행하는 건 프로그램을 더 망가뜨리는 행위이기 때문에 차라리 그 순간 runtime을 터뜨리는 게 이상적이다.

let a = 0;
let b = 0;
let c = 0;
let d = 0;
let e = 0;
let f = 0;

try {
  console.log(a)
  a = 1;
  if (Math.random() < 0.5) throw Error();
  console.log(b)
  b = 2;
  if (Math.random() < 0.5) throw Error();
  console.log(c)
  c = 3;
  if (Math.random() < 0.5) throw Error();
  console.log(d)
  d = 4;
  if (Math.random() < 0.5) throw Error();
  console.log(e)
  e = 5;
  if (Math.random() < 0.5) throw Error();
  console.log(f)
  f = 6;
  if (Math.random() < 0.5) throw Error();
} catch (e) {
	// 무슨수로 a,b,c,d,e,f를 다시 초기화하나?
	// 만약 외부에서 불러오는 값이거나 매번 달라지는 값이라면?
	// 설령 위의 문제가 해결이 된 다 한들 어떻게 '정상적으로' 처리 하지?
	// 이러나 저러나 현실적으로 정상적이고 아름다운 예외 처리는 불가능하다.
}

 

차라리 Exception Catch를 하여 로그를 남기는 것이 현실적이고 미래를 위해서도 좋다. 프로그램의 잘못된 state로 인한 오작동으로 프로그램에 미치는 영향을 최소화할 수 있고, 디버깅을 위한 단서도 존재하기 때문이다.

try {
	throw Error("누진세의 Hype boy요!")
} catch(e) {
	winston.debug(e.message, time, type, request body, request path)
	process.exit(1) // 펑!
}

2. error code 반환

function canBeException(){
	// do something
	return { code : "ES6", description: "ts 최고" }
}

function func () {
	const result = canBeException()
	if (result.code === "ES6") somethingElse()
}

error code를 반환하면 후속 동작에서 최소한의 대비가 가능할 수 있다. 완벽한 방법은 아니지만 런타임을 종료시키는 극단적 상황은 피할 수 있다.

핵심은 State!

런타임 종료, 로깅이 중요한 게 아니다. 해당 명령어 실행 이전의 State를 유지하는 게 중요하다!

이런 점에서 각종 orm이 제공하는 transaction 기능은 DB 동작에 대해서 최고의 Exception Safe Programming method를 제공하는 기능 아닐까…?

// sequelize

const t = await sequelize.transaction();

try {
  // Then, we do some calls passing this transaction as an option:

  const user = await User.create({
    firstName: 'Bart',
    lastName: 'Simpson'
  }, { transaction: t });

  await user.addSibling({
    firstName: 'Lisa',
    lastName: 'Simpson'
  }, { transaction: t });

  // If the execution reaches this line, no errors were thrown.
  // We commit the transaction.
  await t.commit();

} catch (error) {

  // If the execution reaches this line, an error was thrown.
  // We rollback the transaction.
  await t.rollback();
}
// typeorm on nestjs

async createMany(users: User[]) {
  const queryRunner = this.dataSource.createQueryRunner();

  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await queryRunner.manager.save(users[0]);
    await queryRunner.manager.save(users[1]);

    await queryRunner.commitTransaction();
  } catch (err) {
    // since we have errors lets rollback the changes we made
    await queryRunner.rollbackTransaction();
  } finally {
    // you need to release a queryRunner which was manually instantiated
    await queryRunner.release();
  }
}

3. 방어적 프로그래밍

방어적 프로그래밍에서의 핵심은 다른 루틴의 잘못으로 인한 것이라도 루틴에 잘못된 데이터가 들어왔을 때 작성한 루틴에 아무런 문제가 발생하지 않도록 하는 것이다.

좋은 프로그램은 쓰레기값을 입력받았다고 하더라도 절대로 쓰레기를 내보내지 않는다. 차라리 ‘쓰레기를 넣으면 아무것도 안 나온다’. 혹은 ‘쓰레기를 넣으면 오류 메시지를 출력한다.’ 또는 ‘어떤 쓰레기도 허용되지 않는다’ 가 되어야 한다.

‘쓰레기를 넣으면 쓰레기가 나온다’ 로 동작하는 프로그램은 엉성하고 안전하지 못한 프로그램이다.

쓰레기값 입력을 처리하기 위한 일반적인 세 가지 방법

  • 외부로부터 들어오는 모든 데이터의 값을 검사하라
더보기
  1. 파일이나 사용자, 네트워크, 그 밖의 다른 외부적인 인터페이스로부터 데이터를 전달받을 때 데이터가 허용 가능한 범위 안에 있는지 확인하는 검사를 한다.
  2. 숫자값은 허용 범위 내에 있고 문자열은 처리할 수 있을 정도의 길이인지 확인한다.
  3. 보안 응용 프로그램을 작성하고 있다면 시스템을 공격하려는 데이터(buffer overflow, sql injection, html / xml 코드 주입, integer overflow, 시스템 호출에 전달되는 데이터 (log4j)) 등을 특히 조심해야 한다.
  4. 문자열이 제한된 범위의 값(email, phone number 등등)을 표현하기 위한 것이라면 문자열이 목적에 부합되는 타당한 값인지 검사하여 그렇지 않으면 거부한다.
  • 루틴의 모든 입력 매개변수 값을 검사하라
  • 잘못된 입력을 어떻게 처리할 것인지를 결정하라
더보기
  • 중립적인 값을 반환한다.
    1. 잘못된 데이터에 대한 가장 좋은 대응은 작업을 계속 수행하더라도 아무런 문제가 없다고 알려진 값을 반환하는 방법
    2. 수식이라면 0, 문자열 연산에선 빈 문자열, 포인터 연산은 빈 포인터 (null)을 반환한다,
    3. 비디오 게임에서 색상 값을 잘못 입력받은 그리기 루틴은 기본 배경, 전경색을 사용할 것이다. 그러나 암 환자에 대한 x-ray를 보여주는 그리기 루틴은 ‘중립적인 값’을 보여줘서는 안 된다. 이 경우엔 부정확한 환자 데이터를 보여주는 것보다 프로그램을 종료하는 것이 더 나을것이다.
  • 다음에 오는 유효한 데이터로 대체한다.
    1. 데이터 스트림을 처리할 때 어떤 환경에서는 간단하게 다음에 오는 유효한 데이터를 반환해도 되는 경우가 있다. 데이터베이스에서 데이터를 읽고 있을 때 깨진 레코드를 만나게 된다면 아마도 유효한 레코드를 찾을때까지 계속해서 읽을 것이다. 온도계로부터 초당 100번 읽고 있는데 유효한 값을 한번 얻지 못한다면 그냥 100 분의 1초를 기다린 후 다음 값을 읽을것이다.
  • 이전과 같은 값을 반환한다.
    1. 온도를 읽는 소프트웨어가 온도를 한번 읽지 못한다면 아마도 마지막에 읽었던 값과 같은 값을 반환할 것이다. 응용 프로그램에 따라 다르겠지만, 온도가 100분의 1초 만에 그렇게 많이 변화하지 않을것이다.
  • 가장 가까운 유효값으로 대체한다.
    1. 온도계는 0부터 100도 사이에 눈금이 매겨진다. 0보다 작은 값을 감지 한다면 가장 가까운 유효값 0을 반환, 100보다 큰 값을 감지한다면 100을 반환한다.
  • 경고 메시지를 파일에 기록한다.
    1. 잘못된 데이터를 감지하면 경고 메시지를 파일에 기록한 다음 계속해서 실행하는 방법을 선택할 수도 있다. 이 접근 방법은 가장 가까운 유효한 값으로 대체하거나 다음에 오는 유효한 값으로 대체하는 것과 같은 다른 기법과 함께 사용될 수 있다. 로그를 사용한다면 공개할 수 있는지, 암호화해서 보호해야 하는지 고려하도록 한다.
  • 종료한다.
    1. 이 접근 방법은 안전성이 매우 중요한 응용 프로그램에서 유용하다.
    2. 예를 들어 암 환자를 치료하기 위한 방사선 치료 기구를 제어하는 소프트웨어가 방사선량에 대한 잘못된 입력 데이터를 받는다면 오류 처리에 가장 좋은 응답은 무엇일까? 마지막 값과 같은 값을 사용해야 할까? 가장 가까운 유효한 값을 사용해야 할까? 중립적인 값을 사용해야할까?
    3. 이런 경우에는 차라리 종료하는 것이 가장 좋은 방법이다.

예외를 사용해 무시되어서는 안되는 오류를 프로그램의 다른 부분에 알린다.

  1. Exception의 가장 큰 이득은 절대로 무시하지 못할 방법으로 오류가 발생한 상황을 알릴 수 있는 능력이다. 오류를 처리하는 다른 접근 방법은 오류 조건이 감지되지 않은 코드를 통해 오류가 통과할 가능성을 만들어낸다. Exception은 그런 가능성을 제거한다.
  2. 정말로 예외적인 조건인 경우에만 Exception을 던져라. 예외는 정말로 예외적인 경우 즉, 다른 코드 작성 습관으로는 해결할 수 없는 경우에만 사용해야 한다.
  3. 책임을 전가하기 위해서 예외를 사용하지 않는다. 오류를 발생한 코드에서 처리할 수 있다면 직접 처리하는게 좋다. 오류를 처리 할 수 있는데도 처리되지 않은 예외를 던지지 않도록 한다.
  4. c 항목은 sw의 복잡도 문제와도 관련이 있는 항목이다!!!!!

Exception을 상위 함수에서 처리하는 것을 남용하게 된다면 이렇게 된다.

마치 자바스크립트의 callback hell과 비슷한 그림이다.

 

지금까지 이야기한 내용을 짧게 정리해놓은 글이 위키피디아에 존재한다.

이 글만 보면 이해가 어려울 수 있지만 지금까지 이야기한 글을 보고 아래의 글을 보면 이해하기 쉬울것이다.

 

Exception safety - Wikipedia

From Wikipedia, the free encyclopedia State of code working correctly when exceptions are thrown Exception safety is the state of code working correctly when exceptions are thrown.[1] To aid in ensuring exception safety, C++ standard library developers hav

en.wikipedia.org

Exception Safety 프로그래밍은 언어 디자인 측면이 아닌 코드 디자인 측면의 이야기이다. Rust, go 처럼 try catch 문법이 없는 언어에서도 근본적으로 크게 벗어나지 않는다.

Exception handling는 ms에서도 상당히 까다로워하는 주제라고 들은 적이 있다. Exception safety 프로그래밍이 결코 쉬운 내용이 아니라는 의미이다.

 

위에서 이야기한 것처럼 Exception Handling을 수행하는 건 사실상 불가능하다고 이야기하는 사람들이 많다. 나 역시 아름답고 완벽한 Exception Handling은 불가능하다고 확신한다.

 

완벽한 Exception Handling이 불가능한 상황에서 우리가 할 수 있는 것은 무엇일까? 코드 패턴, Exception Catch를 잘하기 위한 코드 패턴 구축을 떠나서 Exception을 잘 터뜨리고 Runtime를 잘 터뜨리는 게 근본적인 방법 아닐까? 

 

이 글을 단 하나의 사진으로 정리하면?

 

출처

1. 내 머리

2. code complete 2 / Steven C. McConnell (방어적 프로그래밍 부분 참고)

3. https://sequelize.org/docs/v6/other-topics/transactions/

4. https://docs.nestjs.com/techniques/database#typeorm-transactions

5. https://docs.nestjs.com/techniques/validation

6. https://en.wikipedia.org/wiki/Exception_safety

Comments