개발자 온보딩 가이드 4장 -(1)

운영 환경을 고려한 코드 작성 - 개발 환경과 프로덕션 환경은 엄연히 다르다.

필독 개발자 온보딩 가이드을 읽고 정리하는 글이며, 혹시 문제가 되면 삭제하겠습니다.

1. 들어가기

  • 실 세계의 사용자는 예측이 어려우며, 네트워크도 믿을 수 없다.

  • 프로덕션 소프트웨어는 지속적으로 동작해야 한다. 따라서 운영 가능한 코드를 작성해야 예상하지 못한 상황에 대처할 수 있다.

🍋 운영 가능한 코드

  • 보호 장치, 분석 장치, 제어 장치가 내장된 코드를 말한다.

  • 안전하고 회복성 있는 코딩 기법을 이용해 방어적으로 프로그래밍해서 시스템을 보호해야 한다.

🍋 운영 가능한 코드의 중요성

  1. 안전한 코드는 다양한 장애를 극복하며, 회목성을 갖춘 코드는 장애가 발생해도 복구가 가능하다.

  2. 용이한 분석을 위해 로그, 지표, 호출 추적 정보 등을 수집하자.

  3. 운영 가능한 시스템은 설정 파라미터와 시스템 도구를 갖추고 있다.

2. 장애에 대비하기 위한 방어적 프로그래밍 방안

  • 방어적 코드는 장애가 발생하는 빈도가 낮으며, 장애가 발생하더라도 대부분 복구된다.

  • 코드를 안전하고 회복성 있게 만들자

🍋 안전한 코드

  1. 컴파일 타임의 유효성 검사를 통해 런타임 장애 최소화

  2. 불변 변수 사용

  3. 접근 제어자 이용해서 범위 제한

  4. 정적 타입 검사기를 활용해서 버그 줄이기

  5. 런타임에 예상치 못한 일이 벌어지지 않도록 입력값 검사

🍋 회복성 있는 코드

  • 권장되는 예외 처리 기법을 활용하여 장애를 적절하게 처리한다.

✍️ 불변 변수란?

  • 한 번 값이 할당되면 그 값을 변경할 수 없는 것을 말한다.

private final int value;
  • 장점

  1. 값의 보장

  2. 스레드 안전성

  3. 캐시화 용이

  4. 디버깅 및 테스트 용이

  5. 함수형 프로그래밍과의 조합

✍️ 정적 타입 검사기란?

  • 코드를 실행하지 않고 오류를 감지하는 것을 정적 검사 도구(static checking)

  • 연산되는 값의 타입에 따라 오류 여부를 결정하는 것이 정적 타입 검사

  • 이 도구는 코드의 컴파일 과정이나 빌드 과정에서의 오류를 사전에 발견하고, 타입 관련 오류, 논리 오류 등을 체크하여 신뢰성을 높이고 안전성 확보를 위해 사용

  • 종류: Java의 컴파일러와 정적 분석 도구, TypeScript의 tsc(Typescript Compiler), Kotlin의 컴파일러, C#의 Roslyn

✍️ 입력값 검사 예시

  1. 조건문 사용

if (inputValue >= 0 && inputValue <= 100) {
    // 유효한 범위 내의 입력값 처리
} else {
    // 범위를 벗어난 입력값 처리
}
  1. 예외 처리

try {
    // 입력값 검사
    if (inputValue < 0) {
        throw new IllegalArgumentException("입력값은 음수일 수 없습니다.");
    }
    
    // 유효한 입력값 처리
} catch (IllegalArgumentException e) {
    // 예외 처리
    System.err.println("에러: " + e.getMessage());
}
  1. 정규 표현식 사용

String emailRegex = "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$";
if (inputValue.matches(emailRegex)) {
    // 유효한 이메일 주소 형식
} else {
    // 잘못된 형식
}
  1. 라이브러리 활용

import org.apache.commons.lang3.Validate;

try {
    Validate.isTrue(inputValue > 0, "입력값은 양수여야 합니다.");
    
    // 유효한 입력값 처리
} catch (IllegalArgumentException e) {
    // 예외 처리
    System.err.println("에러: " + e.getMessage());
}

3. null 값 사용은 피하자

🍋 null이란

  • 값이 할당되지 않은 변수의 기본값

🍋 NullPointException

  • 매우 보편적으로 발생

  • 변수가 널값을 갖진 않았는지 검사하거나, 널 객체 패턴이나 옵션 타입 등을 사용해서 널 포인트 예외 방지

✍️ 널 객체 패턴(Null Object Pattern)

디폴트 값을 제공하고, 코드의 가독성을 향상한다.

  1. null 참조 대신에 실제 동작하는 널 객체를 사용해서 예외를 피하고 기본 동작을 제공하는 패턴

  2. 널 값 대신 객체를 사용하는 패턴

  3. 널 객체는 실제 객체와 같은 인터페이스를 구현하며, 메서드 호출 시 아무런 동작을 수행하지 않거나 기본 값 반환

interface Shape {
    double area();
}

class Circle implements Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    public double area() {
        return Math.PI * radius * radius;
    }
}

class NullShape implements Shape {
    public double area() {
        return 0.0; // 널 객체 역할, 디폴트 값 제공
    }
}
  1. 원하는 객체를 찾지 못했을 때 빈 배열을 리턴하는 검색 메소드를 구현

import java.util.Arrays;

public class SearchExample {
    private static final Person[] EMPTY_PERSON_ARRAY = new Person[0];

    public static Person[] searchPersons(String keyword) {
        // 원하는 검색 로직 구현
        // 예시로 항상 빈 배열을 리턴하도록 함
        return EMPTY_PERSON_ARRAY;
    }

    public static void main(String[] args) {
        String searchKeyword = "Alice";
        Person[] foundPersons = searchPersons(searchKeyword);

        if (foundPersons.length == 0) {
            System.out.println("No persons found.");
        } else {
            System.out.println("Found persons:");
            for (Person person : foundPersons) {
                System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
            }
        }
    }
}

class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

✍️ 옵션 타입

  1. 값의 존재 유무를 명시적으로 나타내는 방법, 값이 존재하는 경우는 실제 값을 가지고, 값이 없는 경우는 빈 상태를 나타내는 타입

class Option<T> { //Option 클래스는 제너릭 타입을 사용해서 값의 존재 유무를 나타내며
    private T value;

    private Option(T value) {
        this.value = value;
    }

    public static <T> Option<T> of(T value) {
        return new Option<>(value);
    }

    public T get() {
        if (value == null) { // 값이 없는 경우 예외를 던지며
            throw new IllegalStateException("No value present");
        }
        return value; // 값이 있는 경우 실제값 반환
    }
}

🍋 널 검사는 메소드의 시작 지점에서 해야한다.

  1. NotNull 애노테이션 언어 기능 이용

import javax.validation.constraints.NotNull;

public class Person {
    private String name;
    private int age;

    public Person(@NotNull String name, @NotNull int age) {
        this.name = name;
        this.age = age;
    }

    @NotNull
    public String getName() {
        return name;
    }

    public void setName(@NotNull String name) {
        this.name = name;
    }

    @NotNull
    public int getAge() {
        return age;
    }

    public void setAge(@NotNull int age) {
        this.age = age;
    }

    public static void main(String[] args) {
        // 올바른 사용
        Person person = new Person("Alice", 30);
        System.out.println("Name: " + person.getName());
        System.out.println("Age: " + person.getAge());

        // 잠재적인 오류: null 값 전달
        Person nullPerson = new Person(null, 25); // 컴파일 오류 발생
        System.out.println("Name: " + nullPerson.getName());
        System.out.println("Age: " + nullPerson.getAge());
    }
}

4. 불변 변수를 사용하자

자바에서는 final, 스칼라에서는 val 러스트는 let

  1. 불변 변수는 값이 한 번 대입되고 나면 그 값을 바꿀 수 없다.

  2. 불변 변수는 의도치 않게 변수의 값이 바뀌지 않을 것을 인지

  3. 불변 변수를 사용하면 병렬 프로그래밍은 더 간단해지며, 변수 값이 바뀌지 않을 것임을 인지하는 컴파일러나 런타임은 더 효율적으로 동작 가능하다.

5. 타입 힌트와 정적 타입 검사를 사용하자.

변수가 보관할 수 있는 값을 제한하자. 몇 가지 문자열 값 중 하나만 가질 수 있는 변수는 String보다는 Enum 선언 가능

  1. 예상치 못한 값을 대입해도 버그를 유발하지 않고 그 즉시 실패하게 된다.

  2. 가장 구체적인 타입을 사용하자

🍋 Enum이 더 좋은 이유

  1. Enum은 몇 가지 고정된 값을 나타내는 자료형으로, 한정된 선택지 중 하나만 가질 수 있는 변수

  1. 가독성 및 안정성의 좋음

  2. 제한된 선택지

  3. 코드 유지보수

  4. 타입 안정성

  5. 열거형 상수 사용

public enum DayOfWeek {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

public class Main {
    public static void main(String[] args) {
        DayOfWeek today = DayOfWeek.WEDNESDAY;
        
        switch (today) {
            case MONDAY:
                System.out.println("Today is Monday.");
                break;
            case WEDNESDAY:
                System.out.println("Today is Wednesday.");
                break;
            // ...
        }
    }
}

🍋 파이썬 예제

  • 타입 힌트를 이용해서 메소드가 문자열 인수를 받아 문자열을 리턴한다는 사실

  • 함수의 매개변수와 반환값의 타입을 명시 가능

def say(something: str) -> str: 
	return "You said: " + something
   

✍️ 타입 힌트란

타입 힌트는 기존의 코드베이스에 점진적으로 추가 가능하며, 타입 힌트를 이용해서 코드가 실행되기 전에 버그를 찾아되는 정적 타입 검사기 도입 하면 런타임 에러 해결 가능

  • 타입 힌트(Type Hint)는 프로그래밍 언어에서 변수, 함수 매개변수, 함수 반환값 등에 대한 데이터 타입 정보를 제공하는 것을 말한다.

  • 타입 힌트를 사용하면 코드의 가독성을 높이고 타입 관련 오류를 사전에 방지할 수 있다.

  • 주로 정적 타입 언어에서 사용되며, 파이썬과 같은 동적 타입 언어에서도 타입 힌트를 지원

6. 입력값을 검사하자.

코드로 전달되는 입력값은 훼손 가능성이 높음. **사전 조건, 체크섬, 데이터 유효성 검사, 보안 관련 권장 기법, 보편적인 에러를 찾아주는 도구 **활용

  • 원치 않는 입력값이 전달되면 최대한 이른 시점에 실행을 거부하도록 조치해두자.

  • 최대한 많이 검사하자.

  • **입력 문자열**이 원하는 형식인지 확인하고, 문자열 앞 뒤의 공백도 처리해야 한다는 점

  • 숫자는 적절한 범위 내에 있는지, 파라미터가 0보다 커야 한다면 반드시 파라미터도 확인

  • 파라미터가 IP주소라면 적절한 IP주소인지도 확인

🍋 사전 조건, 사후 조건 이용

유효성 검사란 함수나 메소드의 매개변수에 대한 타입, 범위, 상태 등을 확인하는 과정

  1. 메소드에 전달되는 유효성 검사 필요

  2. 유효성을 검사하는 라이브러리나 프레임워크 사용

🍋 유효성 검사 방법

  1. 타입 힌트 사용

  2. 전처리 과정 추가

  • 메소드 내부에서 인자의 유효성을 검사하는 전처리 과정을 추가할 수 있다.

  • 예를 들어, 인자가 특정 범위 내에 있는지, 특정 조건을 만족하는지 등을 검사할 수 있다. 이때 검사에 실패하면 예외를 던지거나 기본값을 반환하는 등의 처리를 할 수 있다.

  1. 예외 처리

  2. 사전 조건, 사후 조건 설정

  • 사전 조건은 함수가 실행되기 전에 검사해야 할 조건을 나타내며, 사후 조건은 함수가 실행된 후의 조건을 나타낸다.

  • 사전 조건 설정 : b가 0이면 AssertionError 예외 발생

def divide(a, b):
    assert b != 0, "b cannot be zero"
    return a / b
  • 사후 조건 설정 : 합계가 음수가 아닌지 검사 후 사후 조건이 충족되지 않으면 AssertionError 예외 발생

def calculate_sum(numbers):
    result = sum(numbers)
    assert result >= 0, "sum should be non-negative"
    return result
  1. 유효성 검사 라이브러리 사용

  2. 문서화

🍋 유효성 검사 라이브러리, 프레임워크

1. cheackNotNull이라는 메소드 2. @Size(min=0, max=100)

🍋 의도치 않은 데이터 변경이 일어나지 않았는지 체크섬을 이용해서 확인

1. 체크섬이란?

  • 데이터의 특정 부분에서 생성된 해시 값 또는 체크섬 값을 사용하여 데이 터

  • 데이터가 변경되면 체크섬 값도 변경되므로 이를 통해 데이터 변경 여부 확인 가능

  1. 예시

  • 파일의 체크섬을 계산하여 저장하고 이후에 파일을 다시 읽어와서 체크섬을 다시 계산한 후 저장된 체크섬과 비교하는 방식으로 데이터 변경 여부를 확인

  • 예제 python

import hashlib

def calculate_checksum(data):
    # 데이터의 체크섬 값을 계산하는 함수
    sha256 = hashlib.sha256()
    sha256.update(data.encode())
    return sha256.hexdigest()

def save_data_with_checksum(filename, data):
    # 데이터와 체크섬 값을 파일에 저장하는 함수
    checksum = calculate_checksum(data)
    with open(filename, 'w') as file:
        file.write(data + '\n')
        file.write(checksum)

def verify_checksum(filename):
    # 저장된 데이터와 체크섬 값을 확인하는 함수
    with open(filename, 'r') as file:
        lines = file.readlines()
        data = lines[0].strip()
        saved_checksum = lines[1].strip()
        calculated_checksum = calculate_checksum(data)
        if saved_checksum == calculated_checksum:
            print("Checksum verification successful. Data is intact.")
        else:
            print("Checksum verification failed. Data may be corrupted.")

# 예시 데이터를 생성하고 저장
data = "Hello, world!"
filename = "data.txt"
save_data_with_checksum(filename, data)

# 데이터 무결성 검증
verify_checksum(filename)

2. java 예제

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class DataIntegrityExample {

    // 입력된 데이터의 체크섬을 계산하는 메서드
    public static String calculateChecksum(String data) throws NoSuchAlgorithmException {
        // SHA-256 해시 알고리즘을 사용하는 MessageDigest 객체 생성
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        // 데이터의 해시값 계산
        byte[] hash = md.digest(data.getBytes());
        StringBuilder hexString = new StringBuilder();

        // 해시값을 16진수 문자열로 변환하여 반환
        for (byte b : hash) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) {
                hexString.append('0');
            }
            hexString.append(hex);
        }

        return hexString.toString();
    }

    public static void main(String[] args) {
        try {
            // 원본 데이터 설정
            String originalData = "Hello, world!";
            // 원본 데이터의 체크섬 계산
            String checksum = calculateChecksum(originalData);

            System.out.println("Original Data: " + originalData);
            System.out.println("Calculated Checksum: " + checksum);

            // 데이터 변조 시뮬레이션
            String corruptedData = "Hacked, world!";
            // 변조된 데이터의 체크섬 계산
            String corruptedChecksum = calculateChecksum(corruptedData);

            System.out.println("Corrupted Data: " + corruptedData);
            System.out.println("Corrupted Checksum: " + corruptedChecksum);

            // 체크섬 값 비교를 통한 데이터 무결성 검증
            if (checksum.equals(corruptedChecksum)) {
                System.out.println("Checksum verification failed. Data may be corrupted.");
            } else {
                System.out.println("Checksum verification successful. Data is intact.");
            }
        } catch (NoSuchAlgorithmException e) {
            System.err.println("Error: " + e.getMessage());
        }
    }
}

🍋 검증된 라이브러리와 프레임워크를 이용해서 크로스 사이트 스크립팅 방지하자.

  • 크로스 사이트 스크립팅 방지 (Cross-Site Scripting, XSS):

  1. 설명: 크로스 사이트 스크립팅은 악의적인 스크립트를 삽입하여 웹 애플리케이션의 사용자들에게 악성 행위를 유발할 수 있는 공격

  2. 예시: 입력 폼에 스크립트 코드를 삽입하여 다른 사용자의 정보를 획득하는 시도를 차단해야 한다. 대표적으로 HTML 이스케이프 함수를 사용하여 사용자 입력 데이터를 안전하게 처리가 필요

🍋 항상 입력값 검사해서 SQL 주입 공격 예방

  • SQL 주입 공격 방지:

  1. 설명: SQL 주입은 사용자 입력 데이터를 조작하여 데이터베이스 쿼리를 변조하는 공격

  2. 예제: 사용자 입력 데이터를 직접 쿼리에 삽입하지 않고, 프레임워크나 ORM 라이브러리의 매개변수화된 쿼리 기능을 사용하여 SQL 주입 공격을 예방할 수 있다.

🍋 strcpy 같은 명령 이용해서 메모리 조작 시 메모리 크기 파라미터 명시적 설정해서 버퍼 오버플로를 방지

  • 버퍼 오버플로 방지

  1. 설명: 버퍼 오버플로는 할당된 메모리 공간을 초과하여 데이터를 쓰거나 읽는 공격으로 크래시를 유발하거나 실행 흐름을 조작할 수 있다.

  2. 예제: 문자열 복사 함수(strcpy)를 사용할 때 명시적으로 버퍼의 크기를 설정하거나, 메모리 관리 기능이 강화된 함수(strncpy, memcpy)를 사용하여 버퍼 오버플로를 예방할 수 있다.

🍋 보안 암호화 라이브러리나 프로토콜은 직접 작성말고 널리 사용되는 것 사용

  1. 설명: 보안 암호화 라이브러리나 프로토콜을 사용하여 데이터의 기밀성과 무결성을 보호하는 것이 중요

  2. 예제: 사용자 비밀번호 저장 시에는 안전한 해시 함수 (예: BCrypt)를 사용하여 암호화하고, 데이터 전송 시에는 HTTPS 프로토콜을 활용하여 데이터의 보안을 강화

7. 예외를 활용하자

특정한 리턴값으로 에러를 표현하는 것은 금물이다.

  1. 특정한 리턴값은 메소드 시그니처에 명확하게 드러나지 않을 수 있다. 2. 따라서 개발자는 리턴값이 에러를 의미한다는 점을 모를 수 있으며, 어떤 리턴값이 어떤 에러 상태를 의미하는지 기억하기도 어렵다.

  2. 예외는 이름도 있고 스택 추적 정보, 줄 번호, 메세지 등 nujll이나 -1만으로 표현하지 못하는 훨씬 더 많은 정보를 담고 있다.

8. 예외는 구체적으로 사용하자.

예외는 애플리케이션 로직을 제어하는 용도가 아니라, 실패를 처리할 때만 사용해야 한다. 또한 언어에 내장된 예외 타입이 문제를 구체적으로 표현할 수 있다면 임의로 예외는 정하지 않는다.

🍋 가능하면 언어에 내장된 예외를 사용하고 포괄적인 의미를 담는 예외는 만들지 않게 하자.

  1. 너무 포괄적으면 어떤 문제가 발생했는지 파악할 수 없어 예외 처리가 어려워 진다.

  2. 발생한 에러에 대해 정확한 정보를 알지 못하면 개발자는 애플리케이션 실행이 중단되게 할 수 밖에 없으며, 이는 심각한 문제

🍋 예외를 애플리케이션 로직으로 사용해서는 안된다.

  1. 우리는 코드가 예상치 못한 동작을 하지 않길 원할 뿐이지 코드 스스로 똑똑해지길 원하는 게 아니다.

  2. 예외를 이용해서 메소드를 분기하면 이해가 어려울 뿐 더러 디버깅도 어려워진다.

  3. 잘못된 예제

  • 설명: 위의 코드는 OrderProcessingException을 상태 검증용으로 사용하여 애플리케이션 로직을 제어하려고 한다.

  • 이로 인해 예외가 정상적인 흐름 제어에 사용되며, 코드의 가독성과 유지보수가 어려워질 수 있다.

  • 애플리케이션 로직은 예외가 아닌 다른 방법으로 상태 검증 및 처리를 해야 한다. 아래 예제의 경우 if문 등을 사용하여 명시적인 상태 검증과 처리를 구현해야 한다.

public class IncorrectExceptionUsage {

    // 잘못된 예외 사용: 애플리케이션 로직으로 예외 활용
    public static void processOrder(int orderStatus) throws OrderProcessingException {
        try {
            if (orderStatus == 0) {
                throw new OrderProcessingException("Invalid order status"); // 예외를 상태 검증에 사용
            }

            // 주문 처리 로직
            // ...

        } catch (OrderProcessingException ex) {
            // 예외를 처리하는 코드
            logError(ex.getMessage());
        }
    }

    public static void main(String[] args) {
        int orderStatus = 0;
        try {
            processOrder(orderStatus);
        } catch (OrderProcessingException ex) {
            logError("Failed to process order: " + ex.getMessage());
        }
    }

    private static void logError(String message) {
        System.err.println("Error: " + message);
    }
}

8. 예외는 일찍 던지고 최대한 늦게 처리하자.

🍋 일찍 던진다라는 의미

개발자가 관련 코드를 신속하게 찾을 수 있도록 에러가 발생한 지점으로부터 최대한 가까운 지점에서 예외를 던진다는 뜻으로 중간에 위치한 계층은 어설프게 에러를 처리하기 보다는 예외를 상위로 계층하는 게 맞다.

  1. 실제 에러가 발생한 지점에서 멀리 떨어진 곳으로 하면 어디서 문제가 발생했는지 알기 어려워진다.

  2. 진짜 문제는 다른곳에서 발생했다는 사실을 알아내야만 버그를 수정할 수 있기 때문이다.

🍋 예외를 늦게 잡는다라는 의미

예외를 처리할 적절한 위치에 도착할 때까지 계속 호출 스택을 통해 전파시킨다는 뜻이다.

  1. 예를 들어 애플리케이션이 데이터를 기록하려는데 남은 디스크 공간이 없는 경우 생각해보기

  • 대처방안

    • 실행을 중단하고 재시도

    • 비동기적으로 재시도하는 방법

    • 다른 디스크에 기록

    • 사용자에게 경고 메세지를 보낼 수도 있다.

    • 크래시가 발생하도록 놔두는 것도 하나의 방법

    크래시란 컴퓨터 프로그램이나 시스템이 예기치 않게 중단되거나 동작하지 않는 현상을 의미 다시 말해, 프로그램이나 시스템이 오류로 인해 정상적으로 동작하지 않거나 멈추는 상태

  1. 적절한 조치는 애플리케이션에 따라 다르다. 데이터 베이스의 로그 선행 기입은 반드시 기록돼야 하는 반면 워드프로세서의 백그라운드 저장 기능은 조금 늦게 실행되도 무방하다.

🍋 최악의 경우

  • 이 경우 예외는 로그에 기록되지도 않고 다시 던져지지도 않으며 다른 어떤 조치도 취해지지 않았다.

  • 장애가 발생해도 아무도 알 수 없으며, 최악의 결과 초래

try{
//..
}
catch(Exception e){
//에러를 처리할 방법이 없으므로 무시
}

느낌점

예외 처리를 어디서부터 어디까지 해야하는 것을 이 장에서 핵심을 집어주는 것 같다. 이 장은 너무 길어져서 여러 개로 나눠서 정리할 예정이며, 자바와 파이썬의 내장 예외처리에 대해 한 번 정리해야 겠다.

Last updated