반응형

개요

InnoDB의 잠금에 대해 알아보기 전 비관적 잠금과 낙관적 잠금에 대해 알아보았다.

 

낙관적 잠금

각 트랜잭션이 같은 레코드를 변경할 가능성이 낮다고 낙관적으로 가정하는 상황에서의 잠금이다. 리소스를 잠그지 않고 데이터를 업데이트 한 후, 마지막 커밋 시 충돌을 확인하는 방식이다.

충돌이 감지되면(트랜잭션이 시작되고 끝나는 사이, 다른 트랜잭션이 데이터를 수정) 트랜잭션이 롤백되고 오류를 반환한다.

 

충돌을 어떻게 확인할까?

테이블에 수정 시점을 추적할 수 있는 버전 필드 또는 타임스탬프를 추가하는 것이다.

버전 필드 (Version Field)
낙관적 잠금에서 각 데이터 레코드에 추가되는 필드로, 해당 레코드가 마지막으로 수정된 시점을 추적하는 용도로 사용된다. 레코드가 업데이트 될 때마다 이 버전 번호가 증가하는 형태이다.

 

트랜잭션이 데이터를 읽을 때 버전 필드를 함께 읽고, 트랜잭션이 커밋할 때 버전 번호를 확인하여 일치하는지를 확인한다. 만약 버전 필드 값이 달라졌다면, 다른 트랜잭션에서 데이터를 수정한 것이라 판단하고 트랜잭션을 롤백 또는 재시작한다.

 타임스탬프를 사용할 경우 해당 레코드의 수정 시간을 기재한다. 마찬가지로 트랜잭션이 시작, 종료될 때 레코드의 수정 시간이 달라졌다면 롤백을, 같다면 커밋을 하는 방식이다.

 

-- 1. 데이터 읽을 때 버전 정보도 함께 읽음
SELECT product_id, product_name, price, version
FROM products
WHERE product_id = 1;

-- 2. 수정 시 버전 확인
UPDATE products
SET price = 200.00, version = version + 1
WHERE product_id = 1 AND version = 2;  -- '2'는 읽은 버전 번호

 

낙관적 잠금 장단점

자원에 대한 잠금이 없기때문에, 대기 시간이 짧고, 처리 속도가 향상될 수 있다. 특히 데이터 충돌이 드문 경우에는 트랜잭션이 빠르게 처리된다. 또한 여러 트랜잭션이 동시에 데이터를 읽고 수정할 수 있으므로, 데이터 충돌이 발생하는 상황이 적다면 그만큼 높은 동시성을 유지하게된다.

 

하지만 데이터 충돌이 발생할 경우 트랜잭션을 다시 시도해야 한다.  충돌이 자주 발생하거나 처리 비용이 높은 트랜잭션이라면, 재시도 비용도 증가하게 된다. 또한 충돌에 대한 예외 로직이 필요하다. 변경을 다시 시도하거나, 사용자에게 알림을 주는 등의 별도 처리가 필요하다. 이러한 예외 코드를 잘못 구현하기라도 한다면 데이터 무결성이 손상될수 있으므로 주의해야한다.

 

 

비관적 잠금

각 트랜잭션이 같은 레코드를 변경할 가능성이 높다고 비관적으로 가정하는 상황에서의 잠금이다.즉, 하나의 트랜잭션이 데이터를 조회하는 시점에 락을 걸고, 트랜잭션이 끝날때까지 유지한다.

조회 시점에 잠금을 획득하는 대표적인 방법은 SELECT FOR UPDATE 이다.

 

비관적 잠금의 장단점

트랜잭션의 동시 접근을 확실하게 방지할 수 있기에 데이터의 무결성이 보장된다.

 

하지만 동시성이 떨어져 처리 속도가 느리다. 트랜잭션이 각각의 자원을 점유하는 상태에서 서로의 자원을 요청한다면, 데드락이 발생하게 된다.

 

비관적 잠금 데드락 재현

users 테이블

 

1) 1번 트랜잭션이 users 테이블의 id = 1인 레코드에 대해 비관적 잠금을 설정한다. 트랜잭션은 끝나지 않고, 다른 작업들을 처리하고 있다.

 

select * from users where id = 1 FOR UPDATE;

 

2) 도중 2번 트랜잭션이 시작되었고, users 테이블의 id = 10인 레코드에 대해 비관적 잠금을 설정한다. 마찬가지로 트랜잭션은 끝나지 않고, 다른 작업들을 처리하고 있다.

 

select * from users where id = 10 FOR UPDATE;

 

3) 1번 트랜잭션의 작업 중 유저 정보 일괄 수정 위해 업데이트 쿼리가 나갔지만, 2번 트랜잭션에서 id = 10인 레코드에 대한 잠금으로 인해 '대기상태'에 들어간다.

 

update users set name = '이름';

 

4) 2번 트랜잭션에서도 마찬가지로 유저 정보 일괄 수정 위해 업데이트 쿼리가 나갔지만, 1번 트랜잭션에서 id = 1인 레코드에 대한 잠금으로 인해 '대기상태'에 들어간다.

 

5) 트랜잭션 1, 2번은 데드락 상태에 빠지게 된다.

 

하지만 실제 테스트를 해보니 무기한 데드락이 걸리지 않고 한쪽 트랜잭션을 롤백시키는 것을 볼 수 있는데, 이는 InnoDB에서 제공하는 자동 데드락 탐지 기능에 의해 한쪽 트랜잭션이 롤백되는 것이다.

데드락 상황을 인지하고, 트랜잭션 재시작

 

또한 락에 대한 타임아웃도 설정되어있기에, 락이 걸린 자원을 획득하지 못한다면 무한정 대기상태에 들어가지 않고, 에러 및 롤백 후 트랜잭션 재시작을 시도한다.

Lock wait timeout 초과 시 트랜잭션 재시작

 

잠금에 대한 대기 시간 변수는 innodb_lock_wait_timeout이며 , 기본 50초로 설정되어 있다.

innodb_lock_wait_timeout

 

 

반응형

'DB > MySQL' 카테고리의 다른 글

[MySQL] MySQL 엔진 / 실행 구조 / 버퍼  (0) 2024.10.16
반응형

개요

  도커를 이해하기 위해서 필요한 사전지식인 운영체제, 프로그램, 스레드, 네임스페이스의 개념을 알아보자. 

Docker

운영체제

 컴퓨터는 하드웨어와 소프트웨어로 구성된다. 좁은 의미에서 하드웨어는 기계, 소프트웨어는 프로그램들을 말하는데 소프트웨어에는 '운영체제'라 부르는 시스템 소프트웨어가 존재한다. 운영체제는 하드웨어와 소프트웨어 자원을 관리하고, 프로그램이 실행될 수 있는 환경을 제공하는 역할을 한다.

 

프로그램

 실행 가능한 명령어의 집합을 의미한다. 디스크에 저장되어 있으나, 메모리에는 올라가지 않은 정적인 상태로 존재한다. 컴퓨터에 설치된 게임, 파워포인트, 우리가 개발하여 jar로 빌드한 파일 모두 프로그램이다.

 

프로세스

 실행중인 프로그램을 의미한다. 프로그램이 실행되면 메모리에 올라간다. 즉, 프로세스는 메모리에 올라간 상태로 존재한다.

 

스레드

 프로세스 내에 실행되는 여러 흐름의 단위를 의미한다. 예를들어 워드 프로그램을 실행시킨다면, 하나의 스레드는 화면상에 글자를 보여주는 일을, 다른 스레드는 사용자의 키 입력에 대응하는 일을, 또 다른 스레드는 오탈자와 문법을 확인하는 일을 한다.

 

 

정리하면, 컴퓨터를 구성하는 소프트웨어 중 '프로그램을 실행할 수 있는 환경을 제공'하는게 운영체제이고, '프로그램을 실행하면 프로세스'가 되며, '프로세스 내부에서 여러 스레드들이 동작' 하며 기능들을 수행한다.

 


네임스페이스

 프로세스를 실행할 때 시스템 리소스를 분리해서 실행할 수 있도록 도와주는 기능을 의미한다. 예를들어 여러 프로세스를 실행했을때, 각각의 프로세스가 모든 파일 시스템에 접근할 수 있는게 아닌, 첫번째 프로세스는 A 디렉토리에만, 두번째 프로세스는 B 디렉토리에만 접근할 수 있도록 하면 어떨까? 각각의 프로세스 입장에서 보면 하나는 A 디렉토리, 다른 하나는 B 디렉토리만을 할당받은 체로 동작하게 된다. 즉, 네임 스페이스를 잘 활용하면 같은 운영체제 내에 실행되는 프로세스들이 논리적으로 분리된 환경에서 실행 될 수 있는 것이다.

 

개념적으로는 이해되나, 네임스페이스들이 어떻게 상호작용하는지는 따로 공부가 필요할 것 같다. 잘 알려진 네임스페이스는 다음과 같다.

네임 스페이스 의미 역할
pid PID: ProcessID 리눅스 커널의 프로세스 ID를 분리합니다.
net NET: Networking 네트워크 인터페이스를 관리합니다.
ipc IPC: Inter Process Communication 프로세스 간 통신(IPC) 접근을 관리합니다.
mnt MNT: Mount 파일 시스템의 마운트를 관리합니다.
uts UTS: Unix Timesharing System 커널과 버전 식별자를 관리합니다.

 


도커란?

 운영체제 수준의 가상화 방식을 통해 소프트웨어를 배포하는 가상환경을 제공하는 소프트웨어이다. 사용자 입장에서는 OS 까지의 가상 환경을 제공받아 어플리케이션만 관리/배포하면 되니 도커를 PaaS 제품이라고도 부른다.

출처 - https://www.redhat.com/

 

 운영체제 수준의 가상화 방식은 뭘까? 운영체제가 가상화 즉, '논리적으로 분리된 환경'을 제공하는 방식을 말한다. 운영체제가 논리적으로 분리된 환경을 제공할 수 있을까? 있다! 시스템 리소스들을 분리한 후 환경별로 다른 시스템 리소스들을 제공하면 된다. 네임스페이스들을 통해서 말이다. 

 

도커를 활용한 어플리케이션 실행

 

위와 같이 운영체제에서 네임 스페이스를 통해 시스템 리소스들을 분리한 후, 서로 다른 네임 스페이스를 도커가 띄운 프로세스에게 제공하는 것이다. 그럼 이 프로세스는 운영체제 위에서 다른 프로세스들과는 격리된 환경으로 구성된 하나의 '패키지' 역할을 하게 되는데, 바로 이를 컨테이너라 한다.

 

컨테이너란?

 컨테이너는 소프트웨어를 배포할 때 필요한 코드, 라이브러리, 환경 설정 파일들을 한데 모아 격리시킨 후, 실행 가능한 패키지로 만든 것을 의미한다. 

반응형
반응형

1. 개요

 readObject 메서드의 역할을 이해하고, 방어적으로 작성해야하는 이유를 알아보자.

 

2. 직렬화, 역직렬화 예제

 일반적으로 Object를 직렬화할 땐 ObjectOutputStream을, 역직렬화 할땐 ObjectOutputStream 인스턴스 인스턴스를 사용하고, 각각 writeObject(), readObject() 메서드를 호출하여 실제 데이터를 처리한다. 간단한 예제를 통해 직렬화와 역직렬화의 흐름을 먼저 이해해보자.

1) Period 인스턴스에 대한 직렬화

 

 

public final class Period implements Serializable {

   private Date start;
   private Date end;

   public Period(Date start, Date end) {
       this.start = new Date(start.getTime()); // 방어적 복사
       this.end = new Date(end.getTime());
       if (this.start.compareTo(this.end) > 0) { // 유효성 검사
           throw new IllegalArgumentException(start + " after " + end);
       }
   }

   public Date start() {
       return new Date(start.getTime());
   }

   public Date end() {
       return new Date(end.getTime());
   }
}

 

SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
Date startDate = df.parse("2024-11-14");
Date endDate = df.parse("2024-11-15");
try(
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos)){

    oos.writeObject(new Period(startDate, endDate));

    byte[] serialized = baos.toByteArray();
    for(byte b : serialized){
        System.out.print(b +" ");
    }
}

 

결과화면은 다음과 같다.

 

직렬화되어 byte[] 타입으로 변환된 Period 인스턴스

 

 

2) 직렬화된 byte[] 데이터에 대한 역직렬화

    public static void main(String[] args) throws IOException, ParseException {

        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
        Date startDate = df.parse("2024-11-14");
        Date endDate = df.parse("2024-11-15");
        try(
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                ObjectOutputStream oos = new ObjectOutputStream(baos)){

            oos.writeObject(new Period(startDate, endDate));  // 직렬화

            byte[] serialized = baos.toByteArray();

            Period period = deserializePeriod(serialized); // 역직렬화
            System.out.println(period.start());
            System.out.println(period.end());
        }
    }
    
    public static Period deserializePeriod(byte[] bytes) throws IOException {

        try(
                ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
                ObjectInputStream ois = new ObjectInputStream(bais)){

                Object object = ois.readObject();
                return (Period) object;
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

 

결과 화면은 다음과 같다.

역직렬화 후 메서드 출력 결과

 

 

3. readObject() 메서드의 문제 #1

readObject() 메서드가 수행하는 역할을 보면 byte[] 데이터를 기반으로 인스턴스를 생성하는 역할을 한다. 즉, 생성자의 역할을 하는 것이다. 일반적으로 생성자에는 validation을 통해 데이터의 불변식을 보장한다. 하지만 바이트 데이터가 조작되어 불변식이 깨진 Object에 대한 byte[] 데이터가 들어온다면 readObject() 메서드를 호출했을 때 별다른 validation 체크를 하지 않기때문에 불변식을 보장하지 못하게 된다.

public class BogusPeriod {
    
    // 정상적이지 않은 바이트스트림
    private static final byte[] serializedForm = {
        (byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
        0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
        0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
        0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
        0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
        0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
        0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
        0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
        0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
        (byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
        0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
        0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
        0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
        0x00, 0x78
    };

    public static void main(String[] args) {
        Period p = (Period) deserialize(serializedForm);
        System.out.println(p);
    }
    
    static Object deserialize(byte[] sf) {
        try {
            return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }
}

 

출력하면 다음과 같이 start보다 end가 더 이르다는 것을 확인할 수 있다. 생성자를 사용하지 않기 때문에 validation 체크가 이루어지지 않고, 최종적으로 생성된 Period 객체는 불변식이 깨져버렸다.

 


4. readObject() 메서드 커스텀

 

이를 해결하기 위해 readObject() 메서드를 생성해야한다. 역직렬화할 클래스 내에 private 접근 제어자로 readObject() 메서드가 정의되어 있을 경우 ObjectInputStream.readObject() 메서드 호출 시 새로 정의한 메서드를 호출하기 때문이다. (리플렉션 기반)

 단, 반드시 privcate 접근 제어자로 선언해야하고, 메서드 내에서 defaultWriteObject(),  defaultReadObject()와 같은 기본 직렬화, 역직렬화 메서드를 한번 호출해줘야한다.

 

public final class Period implements Serializable {
    private Date start;
    private Date end;

    public Period(Date start, Date end) {
        this.start = new Date(start.getTime()); // 방어적 복사
        this.end = new Date(end.getTime());
        if (this.start.compareTo(this.end) > 0) { // 유효성 검사
            throw new IllegalArgumentException(start + " after " + end);
        }
    }

    public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException{
        s.defaultReadObject();
        if (this.start.compareTo(this.end) > 0) { // 유효성 검사
            throw new InvalidObjectException(start + " after " + end);
        }
    }
}

 

그 후, 불변식이 깨진 byte[] 데이터를 넣어 역직렬화를 해보면 유효하지 않는 오브젝트로 판단하여 역직렬화에 대한 예외를 발생시킨다.

역직렬화 실패

 


5. readObject() 메서드의 문제 #2

하나의 문제가 더 있다. 직렬화된 스트림에 객체 참조 관련 바이트코드를 추가하면, 가변 Period 인스턴스를 만들 수 있다. Period 인스턴스를 가변 인스턴스로 사용할 수 있게 하는 MutablePeriod 클래스를 만든다.

public class MutablePeriod {
   public final Period period;

   public final Date start;

   public final Date end;

   public MutablePeriod() {
       try {
           ByteArrayOutputStream bos = new ByteArrayOutputStream();
           ObjectOutputStream out = new ObjectOutputStream(bos);

           // 불변식을 유지하는 Period 를 직렬화.
           out.writeObject(new Period(new Date(), new Date()));

           /*
            * 악의적인 start, end 로의 참조를 추가.
            */
           byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // 악의적인 참조
           bos.write(ref); // 시작 필드
           ref[4] = 4; // 악의적인 참조
           bos.write(ref); // 종료 필드

           // 역직렬화 과정에서 Period 객체의 Date 참조를 훔친다.
           ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
           period = (Period) in.readObject();
           start = (Date) in.readObject();
           end = (Date) in.readObject();
       } catch (IOException | ClassNotFoundException e) {
           throw new AssertionError(e);
       }
   }

 

여기서 직렬화한 바이트 코드에 [0x71, 0, 0x7e, 0, 5], [0x71, 0, 0x7e, 0, 4] 코드를 추가한다. 이 값은 Java 직렬화 프로토콜에서 객체 참조를 나타내는 바이트 코드이다.

 

    public static void main(String[] args) {
        MutablePeriod mp = new MutablePeriod();
        Period mutablePeriod = mp.period; // 불변 객체로 생성한 Period
        Date pEnd = mp.end; // MutablePeriod 클래스의 end 필드
        
        pEnd.setYear(78); // MutablePeriod 의 end 를 바꿨는데 ?
        System.out.println(mutablePeriod.end()); // Period 의 값이 바뀐다.
        
        pEnd.setYear(69);
        System.out.println(mutablePeriod.end());
    }

 

결과적으로 이러한 방법을 사용하게 되면, 불변 객체로 생성되어야할 Period 인스턴스의 end 값이 수정됨을 알 수 있다.

이를 해결하기 위해서는 readObject() 메서드 재정의 시 방어적 복사 코드를 추가해야한다.

 

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
   s.defaultReadObject();

   // 방어적 복사를 통해 인스턴스의 필드값 초기화
   start = new Date(start.getTime());
   end = new Date(end.getTime());

   // 유효성 검사
   if (start.compareTo(end) > 0)
       throw new InvalidObjectException(start +" after "+ end);
}

 

 

정리

 이런 문제는 누군가 고의적으로 바이트코드를 위조한 상황을 가정했을 때 발생하는 문제들이다. 직렬화한 코드를 아무나 접근할 수 있는 외부 서비스에 보냈을 때 불변식이 깨지고, 객체 참조까지 되는 것을 확인할 수 있다.

 앞선 챕터에서 Serializable은 신중하게 작성하라라는 내용이 있었다. 직렬화된 클래스가 외부로 나갔을 때 발생할 수 있는 여러 문제들이 있기 때문이다.

 이번 챕터 역시 이러한 문제를 겪지 않으려면 직렬화를 신중히 하고, 만약 직렬화가 필요하다면, 외부에서 접근할 수 없는 환경에 저장하고, 로드할 수 있도록 하는 것이 중요하다는 것을 다시 한번 느끼게 되었다.

반응형
반응형

1. 개요

직렬화(Serializable)의 개념을 이해하고, Serializable을 구현할 때 주의해야할 점을 알아보자.

 

2. 직렬화(Serializable)란?

 

인스턴스 >> Byte

 

인스턴스를 Byte로 변환하는 것을 말한다. 간혹 클래스를 Byte로 변환하는 것이라고도 하는데 목적 측면에서 보거나, 일반적으로 사용하는 방식을 보면 인스턴스를 직렬화하는게 더 맞는 표현이라고 생각한다.

 

 

3. 직렬화를 하는 이유

그렇다면 직렬화를 하는 이유는 뭘까? 도대체 뭘 위해서 직렬화를 하는걸까?

 

 

 

바로 인스턴스를 저장하거나, 전송하기 위함이다.

 

그럼 인스턴스를 저장하거나 전송하는 이유는 뭘까?

 

 

 

객체의 상태를 저장해야할 상황이 있기 때문이다. 아래 예시를 통해 알아보자.

 

4. 객체의 상태를 저장해야할 상황 

1) HTTP 세션 관리

 Spring 웹 어플리케이션에서는 HTTP 세션을 사용해 사용자 상태를 유지한다. 사용자의 ID나 이름, 국적 등과 같은 '상태값'을 세션에 저장해두고 필요할때마다 가져온다.

 만약 세션 클러스터링 환경이라면 어떨까? 세션을 다른 서버와 공유해야한다. 정확히 말하면 상태값을 갖고 있는 인스턴스를 다른 서버에 전달해야한다. 이를 위해 직렬화하고, Byte 데이터를 Stream 방식으로 전달하는 것이다.

 

2) 인메모리 저장소 활용(ex. Redis)

spring Session이나 캐싱기능을 위해 저장소로 Redis를 사용하고자 한다면, Redis에 저장할 인스턴스의 클래스에 Serializable을 구현했을 것이다. 상태 값을 가진 인스턴스를 Redis라는 외부 저장소에 전달해야하기 때문이다. 이때도 마찬가지로 외부로 전달하기 위해 직렬화를 한다.

 

3) 영속적인 저장 / 데이터 복원

 HTTP 세션, 클러스터링 환경, 인메모리 저장소는 서버가 재시작되면 저장된 데이터가 날아간다. 서버가 재시작되도 상태값을 유지하고 싶다면 상태 값을 필드로 구분하여 DB에 저장하는 방법도 있지만, 인스턴스 자체를 DB에 저장하는 방법도 있다. 디스크에 저장하는 방식이다. 이 경우 서버에 문제가 생기거나 재시작이 되어도 데이터를 쉽게 복원할 수 있다.

 

5. Serializable 주의사항

 

주의할 점이 많은 Serializable

1) 릴리스 한 뒤에는 수정이 어렵다.

클래스가 Serializable을 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API가 된다. 누군가 역직렬화를 하면 클래스 구조와 상태값들을 확인할 수 있기 때문이다. 그래서 이 클래스가 널리 퍼지게 된다면, 그 직렬화 형태도 영원히 지원해야 한다. 만약 특정 필드를 제거하거나, 타입을 바꾸는 등의 수정을 한다면, 타 시스템의 역직렬화 과정에서 에러가 발생할 수 있기 때문이다.

직렬화 가능 클래스를 만들고자 한다면, 길게 보고 수정이 일어나지 않는 형태로 주의해서 설계해야한다.

 

2) 버그와 보안 구멍

객체는 생성자를 사용해 만드는게 기본이다. 역직렬화는 기본 방식을 우회하는 생성 기법이다. 마치 숨은 생성자와 같다. 생성자를 우회한다는건 객체 초기화에 필요한 모든 내부 코드를 무시한다는 측면에서 버그를 유발 가능성을 재기한다.

 또한 공격자가 객체 내부를 들여다 볼 수 있다면, 직렬화된 코드를 조작하여 서버로 전달할 수 있다. 해당 서버에서 조작된 메서드를 호출이라도 한다면 공격자가 의도한 작업을 서버 내에서 실행하게 될것이다.

따라서 공격자가 이러한 데이터를 볼 수 없는 환경, 즉 private한 환경에서 사용해야한다.

 

3) 신버전을 릴리스할 때 테스트가 늘어난다.

 직렬화 가능 클래스가 수정되면 신버전 인스턴스를 직렬화 한 후 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지 테스트 해야한다. 직렬화 가능 클래스의 수가 많을수록 테스트할 횟수도 증가한다.

 

4) 상속용으로 설계된 클래스나 인터페이스는 Serializable을 구현하거나 확장해선 안된다.

해당 클래스를 클래스를 상속하거나 구현하는 모든 클래스는 앞선 문제들에 위험을 안고가게 된다.

 

 

그런데... 직렬화 클래스를 수정하면 역직렬화가 무조건 실패하는게 아닐까? 필자의 경우도 그랬던 기억이 있었다. 

 

6. 직렬화 클래스를 수정하면 역직렬화가 무조건 실패하는거 아냐?

직렬화 클래스를 수정해도 구버전 데이터를 역직렬화할 수 있는 조건이 있다. 아래 두 조건을 모두 만족해야한다.

 

첫째, SerialVersionUID 가 일치할때

 클래스가 변경되었지만 serialVersionUID가 동일하다면 역직렬화가 가능하다. 클래스 구조가 약간 달라져도 serialVersionUID가 같다면 역직렬화를 시도할 수 있다.

 

둘째, 새로운 필드가 추가되거나 제거됐을 때

 - 직렬화된 클래스에 신규 필드를 추가할 경우, 역직렬화 시 해당 필드는 기본값 (null, 0, false) 등으로 설정된다.

 - 기존 필드가 삭제되어도 역직렬화가 가능하다. 역직렬화 시 해당 필드는 무시된다.

 

위 두 조건을 만족하면 직렬화 클래스가 수정되도 역직렬화가 가능하다.

단, 클래스의 인터페이스 혹은 상속 구조를 변경하거나, 필드 타입, 클래스 이름이 변경되면 역직렬화에 실패하게 된다.

 

serialVersionUID
클래스의 직렬 버전을 나타내는 ID 로 모든 직렬화된 클래스는 이 값을 부여받는다. 만약 필드에 명시한다면 그 값이 ID가 되겠지만, 명시하지 않는다면 클래스의 정보를 기반으로 해시함수를 적용해 UID가 자동으로 부여된다.

 

즉, 추후 조금의 수정 가능성을 염두해둔다면 SerialVersionUID의 값을 지정해주는 것이 좋다.

 

 

7. 정리

 Serializable은 구현하기는 아주 쉽지만, 주의해야할 사항이 많다. 때문에 클래스의 여러 버전이 상호작용할 일이 없고, 서버가 신뢰할 수 없는 데이터에 노출될 가능성이 없는 등, 보호된 환경에서만 쓰일 클래스가 아니라면 Serializable 구현은 아주 신중하게 이뤄져야한다. 상속할 수 있는 클래스라면 주의사항이 더욱 많아진다.

 

그럼 2만

퇴긘~

반응형
반응형

1. 개요

 우리가 설치하고, 쿼리를 작성하는 MySQL 서버는 크게 두 엔진으로 구성된다. 하나는 MySQL 엔진, 하나는 스토리지 엔진. 스토리지 엔진은 데이터를 저장하는 하드웨어와 관련된 엔진이고, MySQL 엔진은 그 외 알쏭달쏭한 모든것이라고 생각하면 된다. 

MySQL 엔진과 스토리지 엔진

 

뭔 개소린가 싶겠지만 일단은 이렇게 이해하고 시작해보도록 하자. 이게 정신건강에는 좋은것같다.


2. MySQL 전체 구조 핥아보기

 상당히 친해보이는 두 엔진이 MySQL 어디에 속하는지 알아보자. 아래는 Real MySQL8.0 에 기재된 MySQL 서버 구조이다.

 

MySQL 서버의 전체 구조

 

 우리의 몸에 여러 기관들이 있듯이 하나의 엔진에도 여러 요소들이 존재한다. 각 요소들을 살펴보기 전에 그림에 나온 MySQL 서버의 전체 구조를 이해해보자.

 


3. MySQL 서버 전체 구조

MySQL 서버 전체 구조는 크게 프로그래밍 API, MySQL 서버, 운영체제 하드웨어로 나뉜다.

1) 프로그래밍 API 

MySQL 서버로 들어오는 요청에 대한 API를 말한다. Java 진영의 백엔드 개발자 입장에서는 JPA, MyBatis 를 사용해 MySQL 서버로 연동하는 부분이라 생각하면 된다.

2) MySQL 서버

우리가 배워야할 MySQL 엔진과 스토리지 엔진으로 구성된 서버이다. 생소한 용어들이 대부분이나 InnoDB는 뭔가 익숙하다. 일단 넘어가자.

3) 운영체제 하드웨어

DB 데이터는 운영체제 내 디스크에 저장된다. 단순 DB 데이터 뿐 아니라 로그파일도 저장된다.


4. MySQL 엔진 요소

1) 커넥션 핸들러 

 MySQL을 설치하고 가장 먼저 MySQL 서버에 접속한다. 접속에 성공하면 MySQL 서버와 접속한 클라이언트 간 커넥션이 생성된다. 커넥션 핸들러는 이러한 커넥션을 생성하고 클라이언트의 쿼리 요청, 즉, 커넥션에 관한 처리들을 핸들링한다.

 여기서 중요한건 '쿼리 요청을 핸들링'하는 것이다. 커넥션 핸들러가 쿼리를 분석하여 '처리'하진 않는다. 쿼리 요청이 오면 쿼리를 처리하는 어떤 녀석에게 "select 쿼리 처리해주세요!, insert 쿼리 처리해주세요!" 처럼 요청할 뿐이다.

 

2) SQL 인터페이스

DML, DDL, 프로시져, 뷰, 커서, 트리거, 함수 등의 지원을 위한 인터페이스를 말한다.

 

3) 쿼리 파서 (SQL 파서)

 커넥션 핸들러로부터 들어온 요청을 처리하며, 요청받은 쿼리 문장을 토큰(MySQL이 인식할 수 있는 최소 단위의 어휘나 기호)으로 분리해 트리 형태의 구조로 만들어준다. 이러한 트리형태의 데이터를 '파서 트리'라고 하며, 이 과정에서 문법 오류를 체크한다.

 

4) SQL 옵티마이저

 파서 트리를 효율적으로 처리하기 위한 실행 계획을 결정한다. 여기서 결정된 실행 계획에 따라 쿼리 처리 속도나 비용이 달라지게 된다. MySQL 뿐만 아니라 모든 DBMS에서도 쿼리의 실행계획을 수립하는 옵티마이저를 갖고 있다. 실행 계획에 대해 잘 이해하여 쿼리의 불합리한 부분을 찾아낸다면 옵티마이저의 최적화에 도움을 줄 수 있을것이다.

 추후 실행계획에 대해서 공부한 후 포스팅하도록 하겠다.

5) 캐시

MySQL 에서는 InnoDB Buffer Pool, Table Open Cache, Thread Cache 캐시가 사용된다. 캐시에 대한 자세한 내용은 다음에 다루도록 하겠다.

 

* 제거된 쿼리 캐시
 MySQL 8.0 부터 쿼리 캐시 기능이 제거됐다. 쿼리 캐시는 빠른 응답을 필요로 하는 웹 기반의 응용 프로그램에서 매우 중요한 역할을 담당했었다. SQL의 실행 결과를 메모리에 캐시하고, 동일 SQL 쿼리가 실행되면 테이블을 읽지 않고 즉시 결과를 반환하도록 동작했다. 하지만 테이블의 데이터가 변경되면 캐시에 저장된 결과 중에서 변경된 테이블과 관련된 것들을 모두 삭제해야 했다. 이는 심각한 동시 처리 성능 저하, 버그를 유발해 결국 제거됐다.

6) 버퍼

Join, Order By, Group By, Select 등의 쿼리 실행 시 사용되는 임시 메모리 공간을 의미한다. 종류로 Sort Buffer, Join Buffer, Read Buffer 가 있다. 각각에 대해 알아보자.

 

- Sort Buffer

 ORDER BY나 GROUP BY를 사용할 때 사용되는 버퍼로, 정렬에 필요한 데이터를 메모리에 저장하고, 이 안에서 정렬을 수행한다. 만약 정렬할 데이터가 Sort Buffer에 담기지 않을 경우 일부 데이터를 디스크의 임시 파일에 저장하고, 메모리와 디스크를 함께 사용해 정렬한다.

 

* ORDER BY 시 Select 절에는 꼭 필요한 컬럼만 기재하자
Order By 사용 시 Select 절 "*" 를 넣거나 굳이 필요없는 컬럼 추가한다면 Sort Buffer 의 사이즈를 초과할 수 있다. 이 경우 디스크에 임시 파일 형태로 데이터들이 저장될것이고 많은 I/O 비용을 들이는 메모리&디스크 정렬이 수행되기 때문이다.

- Join Buffer

인덱스가 없는 조인 작업을 수행할 때 사용하는 버퍼이다. 두 테이블을 조인할 때 보다 작은 테이블의 데이터를 Join Buffer에 저장하고, 큰 테이블의 데이터를 반복해서 비교하는 방식이다. 만약 테이블의 데이터가 Join Buffer에 담기지 않을 경우 여러번에 나누어 조인을 수행한다. 이는 성능 저하를 초래한다.

 

* Join Buffer에 의존하기보단 인덱스 기반 조인을 사용하자
일반적으로 Join Buffer에 의존한 조인보다 인덱스 기반의 조인이 성능면에서 우수하다. 키나 인덱스로 설정되지 않는 컬럼에 대한조인 시, 속도가 느리다면 Join Buffer를 추가하는것보다 인덱스 설정을 고려하자. 

- Read Buffer

 테이블의 데이터를 순차적(Full Scan)으로 읽을 때 사용되는 버퍼이다. 규모가 큰 테이블일수록 버퍼의 효율이 높아진다.

* 규모가 클수록 효율이 높은 이유
 Full Scan이 발생하면 MySQL은 디스크에서 테이블 데이터를 한번에 읽어오는게 아닌 조금씩 읽어온다. 이 과정에서 I/O 작업이 발생하는데, 읽어야할 테이블의 데이터가 많을 수록 I/O 작업은 늘어나게 된다. Buffer를 사용하게 된다면 조금씩이 아닌 버퍼 사이즈만큼 읽어올 수 있으므로 버퍼의 효율이 높은것이다. read_buffer_size 옵션으로 조절 가능하며 기본 값은 128KB이다.

5. MySQL 쿼리 실행 구조

스토리지 엔진을 알아보기 전 MySQL 엔진에서 일어나는 쿼리 실행 프로세스에 대해 알아보자. 여기서 사용되는 개념은 앞선 MySQL 서버 전체 구조의 요소에 중복된 내용이 있을 수 있으니 복습한다 생각하고 참고하도록 하자.

 

MySQL 쿼리 실행 구조

1) 클라이언트 접속

 클라이언트가 MySQL 서버에 접속하기 위해 ID/PW를 입력한다. 입력하는 시점에  MySQL 네이티브 프로토콜을 통해 MySQL 서버와 TCP/IP 기반의 커넥션이 맺어지게 된다. 이때 커넥션이 생성된다는 뜻인데, 정확히는 '임시 커넥션'이 생성되며, ID/PW 인증이 성공할 경우 완전한 커넥션이 맺어지게 된다.

 

2) 쿼리 요청 처리

 클라이언트가 쿼리를 입력하면 커넥션 핸들러가 쿼리 요청을 처리하기 위해 쿼리를 쿼리 파서에게 전달한다.

 

3) 파서 트리 생성 및 문법 오류 체크

 쿼리 파서는 받은 쿼리를 토큰으로 분리해 파서 트리를 생성한다. 추가로 이 과정에서 문법 오류를 체크한다. 문법에 이상이 없으면 전처리기에게 파서 트리를 전달한다.

 

4) 개체 검사 및 권한 체크

 전처리기는 파서 트리를 분석해 쿼리 문장에 구조적인 문제점이 있는지 확인한다. 각 토큰을 테이블 명, 컬럼 명, 내장 함수 명 등과 같은 개체에 매핑하여 유효성을 검사하고, 접근 권한을 확인한다. 실제 존재하지 않거나, 권한 상 접근할 수 있는 개체의 토큰은 이 단계에서 체크한다. 그 후 옵티마이저에게 전달한다.

 

5) 실행 계획 수립

 옵티마이저는 쿼리를 분석해 실행 계획을 수립한다. 하지만 계획을 할당하고, 이행하는 주체는 따로 있다. 실행 엔진과 핸들러인데, 이들의 관계를 회사로 비유하면 아래와 같다.

 

 - 옵티마이저 : 회사의 경영진 (회사의 계획 수립)

 - 실행 엔진 : 중간 관리자 (계획을 받아 업무를 할당)

 - 핸들러 : 실무자 (업무를 이행)

 

 이 단계에서는 옵티마이저가 실행 엔진에게 실행 계획을 전달한다.

 

6) 핸들러에게 처리 요청

 실행 엔진은 (옵티마이저가 만든) 계획대로 각 핸들러에게 요청해서 받은 결과를 또 다른 핸들러의 요청의 입력으로 연결하는 역할을 수행한다.

 

7) 핸들러의 작업 처리

 핸들러는 MySQL 서버의 가장 밑단에서 MySQL 실행 엔진의 요청에 따라 데이터를 디스크로 저장하거나 읽어 오는 역할을 담당한다. 즉, 핸들러는 스토리지 엔진을 의미하며, MyISAM 테이블을 조작하는 경우에는 핸들러가 MyISAM 스토리지 엔진이 되고, InnoDB 테이블을 조작하는 경우에는 핸들러가 InnoDB 스토리지 엔진이 된다.

반응형

'DB > MySQL' 카테고리의 다른 글

[MySQL] 비관적 잠금과 낙관적 잠금  (0) 2025.01.14
반응형

1. 개요

 하나의 자바 프로세스 안에서 여러 스레드이 동작한다. 이 스레드들은 스레드 스케줄러에 실행된다. 그렇다면 프로그램의 동작을 스레드 스케줄러에 기대지 말라는 뜻이 뭘까?

 

스레드 스케줄러
운영체제가 프로세스 내 스레드들 사이에서 CPU 시간을 어떻게 분배할지를 결정하는 주체이다. 자바 코드 레벨에서 스레드를 생성해도 마찬가지이다. 스레드 스케줄러의 정책에 따라 실행된다. 정책으로는 라운드 로빈 방식, 우선순위 기반 방식, 최소 남은 시간 우선 방식 등이 있다.

 


2. 운영체제마다 다른 스케줄링 정책

 스레드 스케줄러의 정책은 운영체제마다 다를 수 있다. 프로그램의 동작이 스레드 스케줄러에 기댄다면 운영체제의 스케줄링 정책에 영향을 받게 되는것이다. 즉, '스레드 스케줄러에 기대는 프로그램은 정확성이나 성능이 운영 체제에 따라 달라지게 된다' 는 점에서 프로그램을 다른 플랫폼에 이식하기 어렵게 된다.

 


3. 스레드 스케줄러에 영향을 받지 않으려면 어떻게하나요?

 

1) 실행 가능한 스레드의 수를 지나치게 만들지 않기

 

실행 가능한 스레드
실행 가능한 스레드는 스레드 큐에 위치하여 언제든 CPU의 선택을 받을 수 있는 스레드를 말한다.

 

 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다. 프로세서는 CPU인데, 멀티쓰레드를 지원하는 JVM 에서 스레드 수를 프로세서 수보다 지나치게 많아지지 않도록 하라는 게 무슨 의미인지 이해되지 않았다. 예를들어 필자의 컴퓨터는 멀티코어로 두개의 CPU가 있다. 그럼 스레드 수를 2개보다 지나치게 많아지지 않도록해라? 아무리 생각해도 100개의 스레드는 지나치게 많은 개수같은데, 일반적으로 JVM 스레드가 100개를 넘는 일은 허다하지 않나라는 의문이 들었다.

 

 위 내용에 대해 고민을 많이 했는데, 책을 다시 보니 필자가 핀트를 잘못 짚은것이었다. 실행 가능한 스레드의 수가 많으면 좋지 않다는게 아니라 단순히 스레드 스케줄러의 영향을 받지 않는 방법을 명시한 것이었다.  스레드 개수가 적어 스케줄러가 고민할 거리가 줄어든다면 정책의 영향을 덜받지 않겠는가. 당연한 얘기였다.

 

 다시 돌아가 필자의 컴퓨터는 한 코어에 6개의 스레드를 동작시킬 수 있다. 멀티 코어이니 최대 12개의 스레드를 동시처리 할 수 있다. 이보다 지나치게 많아졌을 때 스레드 스케줄러에 의해 스레드들이 제어될것이고, 이 프로그램이 '스레드 스케줄러에 기대도록' 구현이 되어있다면 문제가 생길 수 있다는 것이 포인트였다.

 

2) 스레드는 맡은 작업을 완료할 때까지 계속 실행되도록 하자

 스레드가 작업을 완료하기 전에 대기 상태로 가는 코드가 없도록 코드를 작성해야한다. 대기한다는 뜻은 곧 스레드 스케줄러의 정책에 의해 제어된다는 의미이기 때문이다. 또한 스레드는 당장 처리해야할 작업이 없다면 실행되게 해서도 안된다.

 


4. 당장 처리해야할 작업이 없다면 실행되게 해선 안된다?

 당장 처리할 작업이 없다면 대기 상태에 들어갈것이다. 그리고 다시 실행되기 위해 지속적인 상태체크를 한다. 즉, 스레드가 바쁜 대기(busy waiting) 상태가 되는것이다.

 당장 처리해야할 작업이 없지만 상태를 검사하기 위해 스레드가 실행된다. 의미없이 CPU를 선점하고 있는 격이기에 의미있는 작업을 맡은 스레드들이 당장 실행될 기회를 박탈당하게 된다.

 


5. Busy Waiting 예시

CountDownLatch 클래스의 기능을 가진 커스텀 클래스를 만들었다. Busy Waiting 상태가 나타나는 코드로CountDownLatch 클래스를 삐딱하게 구현한 코드이다.

 

await() 메서드 내 while(true)에 의해 해당 스레드는 계속해서 CPU를 점유할것으로 보이지만, 실제로는 스케줄링 정책에 대기/실행 상태를 왔다갔다하며 다른 스레드들도 중간 중간 실행될것이다. 그런데 이 메서드가 하는 일이 뭔가? 그냥 대기하는것이다. while 문에 의해 count 값만 계속 체크하는 로직인데, countDown()을 수행하는 스레드와 바쁘게 경쟁하게 된다. 경쟁자가 많다보니 컨텍스트 스위칭이 빈번하게 일어날테고, countDown() 호출이 늦어지는 결과를 초래하게 된다.

 

 이런 프로그램이 프로그램의 동작을 스레드 스케줄러에게 기댄 코드라고 할 수 있다.

 

1) SlowCountDownLatch.java

public class SlowCountDownLatch {

    private int count;

    public SlowCountDownLatch(int count){
        if(count < 0){
            throw new IllegalArgumentException(count + " < 0");
        }
        this.count = count;
    }

    public void await(){
        while(true){
            synchronized (this){
                if (count == 0)
                    return;
            }
        }
    }

    public synchronized void countDown(){
        if (count != 0){
            count --;
        }
    }
}

 

1) Main.java

public static void main(String[] args) {

    SlowCountDownLatch ready = new SlowCountDownLatch(1000);
    SlowCountDownLatch start = new SlowCountDownLatch(1);
    SlowCountDownLatch end = new SlowCountDownLatch(1000);
    ExecutorService executorService = Executors.newFixedThreadPool(1000);

    for(int i =0; i< 1000; i++){
        executorService.submit(() -> {
            ready.countDown();

            start.await();
            System.out.println("끼요옥");

            end.countDown();
        });
    }
    ready.await();
    System.out.println("준비완료. 시작!");
    start.countDown();
    long StartNanos = System.nanoTime();
    end.await();
    System.out.println("걸린시간 : " + ( System.nanoTime() - StartNanos));
}

 

스레드 1000개를 생성하여 SlowCountDownLatch를 테스트한 결과, Java에서 제공하는 CountDownLatch보다 2, 3배 느린 것을 확인할 수 있었다. 책에서는 약 10배가 느렸다고 한다.

 

6. 실행을 양보한다면?

그럼 await() 내 상태 체크가 실패했을 경우 현재 스레드를 해제하고 다른 스레드가 작업할 수 있도록 '양보'하는 건 어떨까? 현재는 운영체제의 스레드 스케줄링 정책에 의해 대기상태로 가고 있는데, 당장 스레드 큐로 보내버리는 것이다.

 Thread.yield() 메서드를 이용하면 현재 스레드를 실행 가능한 상태로 보낼 수 있다.

public void await(){
    while(true){
        synchronized (this){
            if (count == 0)
                return;
            else{
                Thread.yield();
            }
        }
    }
}

 

 여기서 중요한 점은 Thread.yield()는 무작정 양보 되는 것이 아니라, 운영체제에게 '요청'하는 메서드이다. 운영체제의 스레드 스케줄러가 이를 무시할 수 있다. 거절당할 수 있는 것이다. 또한, 운영체제마다 yield()의 동작이 달라질 수도 있다는 점에서 OS에 대한 이식성이 좋지 않으며. 호출이 너무 많아지면 컨텍스트 스위칭이 많아져 성능 저하를 초래할 수 있다. 좋지 않은 방법이다.

 

 스레드의 우선순위를 조절하는 것도 방법인데, 이것도 OS에 대한 이식성이 좋지 않다. 특히 심각한 문제를 스레드 우선순위로 해결하려는 시도는 합리적이지 않다.

 


7. 정리

 프로그램의 동작을 스레드 스케줄러에 기대지 말자. 성능과 속도, 이식성을 모두 해치는 행위이다. 같은 이유로 Thread.yield() 와 스레드 우선순위에 의존해서도 안된다.

 스레드 우선순위는 이미 잘 동작하는 프로그램의 서비스 품질을 높이기 위해 드물게 쓰일 수는 있지만, 간신히 동작하는 프로그램을 '고치는 용도'로 사용해서는 안된다.

반응형
반응형

1.1. 개요

 스레드 안전성 수준을 명시해야하는 이유와, 스레드 안전성 수준에 대해 알아보자.

 

1.2. 멀티 스레드 환경에서의 메서드 호출

 멀티 스레드 환경에서 메서드를 호출했을 때, 메서드 내에서 사용하는 변수 및 인스턴스들의 동기화가 필요한지 알아야한다. 이에 따라 클라이언트에서 이 메서드를 사용하는 방식이 달라지기 때문이다.

 여러 스레드가 동시에 호출할 때 메서드가 어떻게 동작하느냐는 해당 클래스와 이를 사용하는 클라이언트 사이의 중요한 계약 사항 중 하나라고 할 수 있다.

 만약 이러한 내용이 없을 경우 클라이언트는 나름의 가정과 함께 사용할것인데, 이 과정에서 심각한 오류를 발생시킬 수 있다.

 

1.3. synchronized == 스레드 안전하다?

 API 문서에 synchronized 한정자가 붙어 있다고 모두 스레드 안전하다고 할 수 없다. 한정자를 스레드 안전한 API 동작을 목적으로 사용했을 수도 있지만 단순 구현 이슈로 인해 사용했을 수도 있기 때문이다. "synchronized == 멀티 스레드 안전" 이라고 단정짓는 것은 위험하다.

 

1.4. 스레드 안전에 대한 명시

 어떤 메서드를 구현했을 때 스레드 안전성 여부를 명시해야한다. 하지만 내부 구현에 따라 멀티 스레드 환경에서 무조건 안전할수도, 조건부 안전할수도, 클라이언트가 동기화 메커니즘을 적용했을 때 안전할 수도, 무슨 짓을 해도 안전하지 않을수도 있다. 결국 안전성 수준을 명시해야 한다.

 

2. 스레드 안전성 수준

2.1. 불변(immutable) - @Immutable

 클래스의 인스턴스가 마치 상수와 같아서 외부 동기화가 필요없는 수준이다. 대표적으로 String, Long이 있다.

 

2.2. 무조건적 스레드 안전(unconditionally thread-safe) - @ThreadSafe

 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화가 없이 동시에 사용해도 안전한 수준이다. 대표적으로 AtomicLong, ConcurrentHashMap이 있다.

 

*  ConcurrentHashMap

HashMap에서 세분화된 락을 제공하는 Map 자료형을 말한다. 전체 맵을 잠그는 대신, 내부 데이터를 여러 버킷으로 나누고 개별적인 락을 적용한다. 이를 통해 여러 스레드에서 동시에 다른 버킷에 접근 가능하다.

읽기 작업은 락 없이 수행하며, compute(), merge()와 같은 원자적 연산도 제공한다.

이를 잘 활용하면 멀티 스레드에서 정합성을 유지할 수 있다.

 

2.3. 조건부 스레드 안전(conditionally thread-safe) - @ThreadSafe

 무조건적 스레드 안전과 같으나, 일부 메서드는 외부 동기화가 필요한 수준이다. Collections.synchronized 래퍼 메서드가 반환한 컬렉션들이 여기 속한다.

 

* 조건부 스레드 문서화 시에는 어떤 순서로 호출할 때 외부 동기화가  필요한지, 어떤 락을 얻어야 하는지를 알려줘야한다. 예를들어 Collections.synchronizedMap의 API 문서에는 다음과 같이 써 있다.

 

synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵을 락으로 사용해 수동으로 동기화하라.

	Map<K, V> m= Collections.synchronizedMap(new HashMap<>());
        Set<K> s = m.keySet();
        
        synchronized (m){ // s가 아닌 m을 사용해 동기화해야한다
            for (K key : s){
                key.f();
            }
        }

 

2.4. 스레드 안전하지 않음(not thread-safe) - @NotThreadSafe

 클래스의 인스턴스는 수정될 수 있다. 하지만 멀티 쓰레드 환경에서 사용하려면 클라이언트가 외부 동기화 메커니즘을 사용해야한다. 대표적으로 ArrayList, HashMap이 있다.

 

2.5. 스레드 적대적(thread-hostile)

 외부 동기화로 감싸더라도 안전하지 않은 수준을 말한다.

 

 

3. 정리

클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하지만, Collections.synchronizedMap과 같이 조건을 명시해야할 경우엔 코드 레벨의 주석에 기재하는 것이 좋다.

 추가로 클래스가 외부에서 사용할 수 있는 락을 제공하면 클라이언트에서 일련의 메서드 호출을 원자적으로 수행할 수 있다.

반응형
반응형

1. 개요

 수행하려는 일과 관련 없어 보이는 예외가 튀어나올 수 있을까? 있다. 바로 체인된 메서드가 저수준의 예외를 처리하지 않고 바깥으로 던져버릴때이다.

 

예외를 찾아서...

 

 예외 발생의 원인을 찾기 위해 내부 구현을 모조리 뒤져야한다는 것이다. 자연스레 결합도가 높아질것이다.

 만약 buy() 메서드에서 클라이언트에게 정확한 예외 메시지를 주고싶다는 등의 이유로 이 예외를 잡아 처리한다면 어떨까? 내부 구현을 분석한 후 적절한 예외 메시지를 던져야할것이다. 

 최종적으로 IllegalStatusException에 대한 catch 문을 추가하고 "상품 유효성 검사에 실패했습니다. 관리자에게 문의해주세요" 라는 예외를 던지도록 예외를 전환했다. 하지만 이것도 문제가된다.

 

2. checkStock() 에서 발생한 IllegalStatusException

 며칠 뒤 buy() 메서드 호출과 함께 어디선가 IllegalStatusException이 발생했다. 클라이언트에게는 "상품 유효성 검사에 실패했다"라는 예외 메시지 던져주고 있다. 그런데 확인해보니 이번 예외는 productValidation()가 아닌 checkStock() 에서 발생했고, 재고 관련 인스턴스를 생성할 때 발생했다.

 

재고 관련 문제가 발생했는데 "상품 유효성 검사에 실패했다"는 에러 메시지는 사용자와 개발자 모두를 혼란에 빠지게했다.

결론은 예외를 무작정 던져도 문제이지만, 무작정 던진 걸 상위 수준에서 무작정 catch 해서 처리하는 것도 문제라는 것이다.

 

자, 그럼 어떻게 했어야할까? productValidation()에서 예외를 잡아 본인의 추상화 수준에 맞는 예외로 전환해서 던졌어야한다. ProductValidationException과 같이 말이다. 예외를 밖으로 던지는 것 자체가 책임을 전가하는 것이기에 결합도 측면에서도 좋지 않다.

 

try{
    // 제품 유효성 체크 로직
}catch(IllegalStatusException e){
    throw new ProductValidationException("제품 유효성 체크에 실패하였습니다. 관리자에게 문의해주세요!")
}

 

이처럼 자신의 추상화 수준에 맞는 예외 클래스로 바꿔 던지는 것을 예외 번역(exception translation) 또는 예외 전환 이라고 한다.

 

3.  예외 체이닝

예외를 전환하려 했더니, 저수준 예외가 디버깅에 도움이 될것같아 예외를 살리고 싶다면 어떨까? 문제의 근본 원인인 저수준 예외를 고수준 예외에 실어보내는 방법을 사용해야 한다. 이를 예외 체이닝(exception chaining)이라고 한다.

 

try{
    // 제품 유효성 체크 로직
}catch(IllegalStatusException e){
    throw new ProductValidationException(e) // 예외 체이닝
}

 

class ProductValidationException extends Exception{
    ProductValidationException(Throwable cause){
        super(cause);
    }
}

 

대부분의 표준 예외는 예외 체이닝 생성자를 갖추고 있다. 예외 연쇄는 문제의 원인을 프로그램에서 접근(getCause 메서드)할 수 있게 해주며, 원인과 고수준 예외의 스택 추적 정보를 통합해준다.

 

정리

아래 계층의 예외를 예방하거나 스스로 처리할 수 없고, 그 예외를 상위 계층에 그대로 노출하기 곤란하다면 예외 전환을 사용하자. 무턱대고 예외를 전파하는 것보다 예외 전환이 우수한 방법이다. 하지만 남용해서는 안된다. 가능하다면 저수준 메서드가 반드시 성공하도록 해야한다. 예를들어 전달하는 하위 메서드에 매개변수를 전달하기 전에 매개변수에 대한 체크를 하는 방법이 있을것이다.

반응형
반응형

1. NAT Gateway란?

 네트워크 주소 변환 서비스를 말하며, 프라이빗 서브넷의 인스턴스이 외부 인터넷망으로 나갈 수 있도록 함과 동시에 Public IP를 부여하는 역할을 한다.

 

NAT (Network Address Translation)
 네트워크 주소 변환의 줄임말인 NAT는 IP패킷의 TCP/UDP 포트 숫자와 출발지 및 목적지의 IP 주소 등을 재기록하면서 라우터를 통해 네트워크 트래픽을 주고 받는 기술을 뜻한다. IP 를 변환하겠다는 뜻이다.
 NAT를 이용하는 이유는 대개 사설 private Network에 속한 여러개의 호스트가 하나의 공인 IP를 사용하여 인터넷에 접속하기 위함이다.

 


 

2. 인터넷 게이트웨이랑 똑같은데요??

 인터넷 게이트웨이는 서브넷을 인터넷 망과 직접적으로 연결시켜 외부로 나갈수도, 외부에서 접근할 수도 있는 구조이다. 이에 반에 NAT GateWay는 서브넷 내의 인스턴스가 외부, 즉 인터넷망으로 나갈 순 있지만, 외부에서 내부 즉, 해당 인스턴스로의 접근은 불가능하다. 이유는 외부 인터넷망으로 나갈 때에만 Public IP를 부여하기 때문이다.

 


3. AWS NAT Gateway 설정 방법

 

1) 퍼블릭 서브넷에서 NAT 게이트웨이 생성

 프라이빗 서브넷의 인스턴스는 퍼블릭 서브넷에 위치한 NAT 게이트웨이를 통해 인터넷에 연결할 수 있다. 퍼블릭 서브넷에서 NAT 게이트웨이를 생성하자. 인터넷 망으로 나갈 때 설정할 공인 IP가 필요하기에 탄력적 IP 를 새로 할당하거나, 기존에 있는 탄력적 IP를 사용해야한다.

NAT 게이트웨이 생성

 

2) 프라이빗 서브넷 라우팅 테이블에 NAT Gateway 추가

 NAT Gateway 생성이 완료되면, 프라이빗 서브넷의 라우팅 테이블에 NAT Gateway를 추가해주자. 외부로 나갈 때 NAT Gateway로 라우팅되어야 하므로 대상을 0.0.0.0/0으로 설정한다.

NAT Gateway 라우팅

 

3) 테스트

 이제 프라이빗 서브넷의 인스턴스에 SSH 접속 후 아래 명령어를 입력하여 외부와 통신이 되는지 확인하자.

curl https://tlatmsrud.tistory.com

 

통신이 된다면 NAT Gateway를 통해 외부로 나간것임을 알 수 있다.

 

반응형
반응형

1. 개요

 비지니스 코드를 재사용 하는 것이 좋은 것처럼, 예외도 재사용 하는 것이 좋다. 자바 라이브러리는 대부분 API에서 쓰기 충분한 수의 예외를 제공한다. 커스텀 예외를 사용해도 되지만 표준 예외를 사용했을 때 사용자 입장에서 익숙한 예외가 발생하므로 예외 상황 파악과 코드 이해가 쉬워진다.

 

 재사용할만한 표준 예외들을 알아보자.

 

2. 재사용할만한 예외

 

IllegalArgumentException

 호출자가 인수로 부적절한 값을 넘길 때 던지는 예외이다. 예를 들어 반복 횟수를 지정하는 매개변수에 음수를 건넬 때 쓸 수 있다.

 

public class Call {

    private static final String HI = "hello";

    public void sayHi(int cnt){

        if(cnt < 0){
            throw new IllegalArgumentException("cnt 값은 0보다 커야 합니다.");
        }

        for(int i =0; i<cnt; i++){
            System.out.println(HI);
        }
    }
}

 

 

IllegalArgumentException

 

IllegalStateException

 대상 객체의 상태가 호출된 메서드를 수행하기에 적합하지 않을 때 주로 던진다. 예를 들어 제대로 초기화되지 않은 객체를 사용하려 할 때 던질 수 있다.

 

public class Toy {

    private String weapon;

    public Toy(String weapon){
        this.weapon = weapon;
    }

    public void attack(){
        if(weapon == null){
            throw new IllegalStateException("weapon 값이 초기화되지 않았습니다. 초기화시켜주세요.");
        }

        System.out.println(weapon +" 공격!");
    }
}

 

IllegalStateException

 

NullPointerException

 일반적으로 null 값을 갖고 있는 객체나 변수를 호출할 때 발생하지만, 특정 상황에선 이를 재사용할 수 있다. null 값을 허용하지 않는 메서드에 null을 건네면 관례상 IllegalArgumentException이 아닌 NullPointerException을 던진다.

 아래와 같이 attack() 메서드를 실행하기 전, 생성자 메서드 레벨에서 NullPointerException을 사용하는 방법이다. 인스턴스 생성 시점에 멤버필드에 대한 유효성이 보장된다는 점에서 더 좋은 방법이라 생각한다.

 

public class Toy {

    private String weapon;

    public Toy(String weapon){
        if(weapon == null){
            throw new NullPointerException("weapon 을 null 로 설정할 수 없습니다.");
        }
        this.weapon = weapon;
    }

    public void attack(){
        System.out.println(weapon +" 공격!");
    }
}

NullPointerException

 

UnsupportedOperationException

 클라이언트가 요청한 동작을 대상 객체가 지원하지 않을 때 던진다. 보통 구현하려는 인터페이스의 메서드 일부를 구현할 수 없을 때 쓰는데, 예를 들어 원소를 넣을 수만 있는 List 구현체에 누군가 remove 메서드를 호출하면 이 예외를 던질 것이다.

 

public class NoRemoveList<E> extends ArrayList<E> {

    @Override
    public boolean remove(Object o) {
        throw new UnsupportedOperationException("remove는 지원하지 않습니다.");
    }
}

 

UnsupportedOperationException

 

이 외에도 어떤 시퀀스의 허용 범위를 넘을 때 사용하는 IndexOutOfBoundException, 단일 스레드에서 사용하려고 설계한 객체를 여러 스레드가 동시에 수정하려 할 때 사용하는 ConcurrentModificationException 등이 있다.

 

3. 재사용하지 말아야할 예외

 Exception, RuntimeException, Throwable, Error는 재사용하지 말자. 이 예외들은 다른 예외들의 상위 클래스이기 때문에 예외 상황에 대한 정보를 명확하게 전달하기 어렵다.

 

4. 정리

 상황에 부합한다면 항상 표준 예외를 재사용하자. 단, 표준 예외를 사용하기 전 API 문서를 참고해 그 예외가 어떤 상황에서 던져지는지 꼭 확인해야 한다. 예외의 이름 뿐 아니라 예외가 던져지는 맥락도 부합할 때만 재사용한다. 더 많은 정보를 제공하길 원한다면 표준 예외를 확장해도 좋다.

 

반응형

+ Recent posts