27장. Serializavble과 NIO도 살펴 봅시다

공부하는 감자·2023년 12월 21일
0

자바의 신 3판

목록 보기
27/30

들어가기 전

『자바의 신 3판』 을 읽고 내용 정리 및 공부한 내용을 정리한 글입니다.
서적: 자바의 신 3판 구입처

Serializable 인터페이스

Serializable 인터페이스의 API에는 선언된 변수나 메소드가 없다. 이 인터페이스를 구현하면 JVM에서 해당 객체를 저장하거나 다른 서버로 전송할 수 있도록 해 준다.

아래와 같은 경우 필수로 구현해야 한다.

  • 생성한 객체를 파일로 저장 및 읽을 경우
  • 객체를 다른 서버로 전송 및 받을 경우

serialVersionUID

Serializable 인터페이스를 구현한다면 serialVersionUID 라는 값을 지정하는 것을 권장한다.

static final long serialVersionUID = 1L;
  • 별도로 지정하지 않으면 컴파일 시 자동 생성된다.
  • static final long 으로 선언해야 한다.
  • 값은 아무 값이나 선언하면 된다.

serialVersionUID을 선언하는 이유

이 값은 해당 객체의 버전을 명시하는 데 사용된다.

다른 서버로 객체를 전송할 경우, 전송하는 서버와 전송받는 서버 모두 동일한 클래스가 있어야만 그 클래스의 객체임을 알고 데이터를 받을 수 있다.

  • 각 서버가 쉽게 해당 객체가 같은지 다른지 확인할 수 있도록 도와준다.
    • 예를 들어, A 서버의 DTO 객체와 B 서버의 DTO 객체의 변수 개수가 다를 경우 자바에서는 제대로 처리를 못하게 된다.
  • 클래스 이름이 같더라도 이 ID가 다르면 다른 클래스로 인식한다.
  • 같은 UID여도 변수의 개수나 타입 등이 다르면 다른 클래스로 인식한다.

Serializable 객체가 변경되었을 경우

serialVersionUID를 자동 생성했을 경우

  • serialVersionUID가 다르다는 InvalidClassException 예외 메시지를 출력한다.
    • 예를 들어, 객체에 변수를 추가하여 저장한 객체와 달라졌을 때
    • 객체의 형태가 달라지면 컴파일 시 serialVersionUID가 다시 생성되므로 문제 발생

serialVersionUID를 선언했을 경우

  • 정상 동작한다.
  • 만약 객체의 형태가 달라지면, 추가된 변수는 null로 처리된다.

serialVersionUID 사용 시 주의점

객체의 내용이 바뀌었음에도 아무런 예외가 발생하지 않는다면 운영 상황에서 데이터가 꼬일 수 있기 때문에, 권장하지 않는다.

따라서 데이터가 바뀌면 serialVersionUID를 바꿔줘야 한다.

객체를 저장하고 읽는 방법

아래 클래스를 사용하면 객체를 저장하거나 읽을 수 있다.

클래스설명
ObjectOutputStream객체를 저장할 수 있다.
ObjectInputStream저장해 놓은 객체를 읽을 수 있다.

저장 시

객체는 바이너리 파일로 저장된다.

public static main(String[] args) {
	String fullPath = separator + "testPath" + separator + "serial.obj";
	SerialDTO dto = new SerialDTO("테스트DTO");
	
	FileOutputStream fos = null;
	ObjectOutputStream oos = null;
	try {
		fos = new FileOutputStream(fullPath);
		oos = new ObjectOutputStream(fos);
		oos.writeObject(dto);
	} catch (Exception e) {
		...
	} finally {
		...
	}
}

읽을 시

객체는 바이너리 파일로 저장된다.

public static main(String[] args) {
	String fullPath = separator + "testPath" + separator + "serial.obj";

	FileInputStream fis= null;
	ObjectInputStream ois = null;
	try {
		fos = new FileInputStream(fullPath);
		oos = new ObjectInputStream(fis);
		// Object로 반환하기 때문에 형 변환
		SerialDTO dto = (SerialDTO) ois.readObject();
	} catch (Exception e) {
		...
	} finally {
		...
	}
}

transient 예약어

transient 예약어를 사용하여 선언한 변수는 Serializable 대상(저장 대상)에서 제외된다.

  • 패스워드와 같이 보안 상 중요하거나 꼭 저장해야 할 필요가 없는 변수에 대해 사용한다.
transient private int saveValue;

자바 NIO

New IO를 뜻하며, 속도 개선을 위해 JDK 1.4에서부터 추가되었다.

아래와 같은 경우 사용된다.

  • 파일을 쓰고 읽을 경우
  • 파일 복사를 할 경우
  • 네트워크로 데이터를 주고 받을 경우

특징

  • 스트림이 아닌 채널(Channel)과 버퍼(Buffer)를 사용한다.
    • 채널: 물건을 중간에서 처리하는 도매상
    • 버퍼: 도매상에서 물건을 사고, 소비자에게 물건을 파는 소매상
    • 데이터를 주고 받을 때 버퍼를 통해 처리한다.

Buffer 클래스

NIO에서 제공하는 Buffer는 java.nio.Buffer 클래스를 확장하여 사용한다.

버퍼의 종류

  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

버퍼의 position

버퍼는 CD처럼 위치가 있다. 버퍼에 데이터를 담거나 읽는 작업을 수행하면 현재의 위치가 이동하고, 다음 위치에 있는 데이터를 바로 쓰거나 읽을 수 있다.

0positionlimitcapacity0 ≤ position ≤ limit ≤ capacity

메소드

리턴 타입메소드설명
intcapacity()버퍼에 담을 수 있는 크기(capacity) 리턴
intlimit()버퍼에서 읽거나 쓸 수 없는 첫 위치 리턴 (기본값: capacity 값)
intposition()현재 버퍼의 위치 리턴
Bufferflip()limit 값을 현재 position으로 지정한 후, position을 0(가장 앞)으로 이동
Buffermark()현재 position을 mark
Bufferreset()버퍼의 position을 mark한 곳으로 이동
Bufferrewind()현재 버퍼의 position을 0으로 이동. limit 값은 변경하지 않는다.
intremaining()limitpositionlimit-position 계산 결과를 리턴
booleanhasRemaining()positon와 limit 값에 차이가 있을 경우 true 리턴
Bufferclear()버퍼를 지우고 현재 position을 0으로 이동하며, limit 값을 버퍼의 크기로 변경
Bufferget()현재 position의 데이터(한 바이트)를 읽는다.

사용

// 데이터를 저장할 경우
public void writeFile() throws Exception{
	String fileName= separator + "testPath" + separator + "nio.txt";
	String data = "test save data";
	byte[] byteData = data.getBytes();

	// 1. getChannel()로 FileChannel 생성
	FileChannel channel =new FileOutputStream(fileName).getChannel();
	// 2. wrap() 이라는 static 메소드를 호출해 ByteBufer 생성
	ByteBuffer buffer = ByteBuffer.wrap(byteData);
	// 3. buffer 객체를 넘겨주면 파일에 쓴다.
	channel.write(buffer);
	channel.close();
}

public void readFile() throws Exception {
	String fileName= separator + "testPath" + separator + "nio.txt";

	// 1. getChannel()로 FileChannel 생성
	FileChannel channel = new FileInputStream(fileName).getChannel();
	// 2. allocate() 메소드로 buffer 객체 생성
	// 매개 변수는 데이터가 저장되는 크기이다.
	ByteBuffer buffer = ByteBuffer.allocate(1024);
	// 3. 데이터를 buffer에 담는다.
	channel.read(buffer);
	// 4. buffer에 담겨있는 데이터의 가장 앞으로 이동
	buffer.flip();

	// 5. 데이터가 남아있는지 확인
	while(buffer.hasRemaining()) {
		// 6. 한 바이트씩 데이터를 읽는다.
		System.out.print((char)buffer.get());
	}
	channel.close();
}

정리해 봅시다.

Q. java.io.Serializable을 import 하는 이유는 무엇인가요?

Me: JVM에서 해당 객체를 파일로 저장하거나 다른 서버로 전송할 수 있도록 해준다.

Q. java.io.Serializable의 serialVersionUID 를 지정하는 이유는 무엇인가요?

Me: 해당 객체의 버전을 명시하는 것이다. 다른 서버로 객체를 전송할 때 같은 객체인지 확인할 수 있도록 해준다.

Q. 자바에서 객체를 파일로 읽거나 쓸 때 사용하는 Stream 클래스 이름은 무엇인가요?

Me: FileInputStream, FileOutputStream, ObjectInputStream, ObjectOutputStream

Q. transient 예약어의 용도는 무엇인가요?

Me: 해당 예약어로 선언한 변수는 Serializable (저장) 대상에서 제외된다.

Q. NIO가 생긴 이유는 무엇인가요?

Me: 속도 성능 개선을 위해 추가되었다.

Q. NIO에서 Channel의 용도는 무엇인가요?

Me: 물건을 중간에서 처리하는 도매상의 역할을 한다.

Q. NIO에서 Buffer의 용도는 무엇인가요?

Me: 도매상에서 물건을 사고, 소비자에게 물건을 파는 소매상의 역할을 한다. 버퍼에 데이터를 담을 수 있다.

Q. NIO에서 Buffer의 상태를 확인하기 위한 메소드들에는 어떤 것들이 있나요?

Me: position(), limit(), capacity()

Q. NIO에서 Buffer의 position을 변경하기 위한 메소드들에는 어떤 것들이 있나요?

Me: flip(), mark(), reset(), rewind(), remaining(), hasRemaining(), clear()

질문

💡 책에 있는 내용이 아닙니다.

책을 읽으며 설명이 더 필요하거나, 추가로 궁금한 점에 대해 질문 형식으로 작성 후, 답을 구해보고 있습니다.
참고한 사이트나 영상은 [출처]로 달아두었으며, 오류 지적은 언제나 환영합니다.

Q. 빌더 패턴과 DTO

기존 패턴의 문제

객체에 많은 속성이 포함되어 있는 경우, 인스턴스를 생성할 때 기존 패턴에는 문제가 있었다.

  • 점층적 생성자 패턴 (Telescoping Constructor Pattern)
    • 생성자의 매개 변수로 속성의 값을 설정
    • 속성이 많을 수록 생성자의 인자 수가 늘어난다.
    • 무조건 값을 전달해야 한다. 즉, 속성을 선택적으로 생략할 수 있는 방법이 없다.
  • 자바 빈 패턴 (Java Bean Pattern)
    • Setter 메소드를 사용해 속성의 초기 값을 설정
    • 속성을 선택적으로 생략 가능하다.
    • 객체 생성 시점에 모든 값들을 주입하지 않아 일관성 문제와 불변성 문제가 생긴다.
    • 일관성 (consistency): 필수 매개 변수가 초기화될 때 설정되지 않은 상태로 다른 곳에서 사용 시, 런타임 예외가 발생할 수 있다.
    • 불변성 (immutable): 객체 생성 후에도 다른 누군가 Setter 메소드를 호출해 객체를 조작할 수 있게 된다.

Builder Pattern

객체를 생성할 때 인스턴스를 만드는 과정이 복잡하거나 매개 변수의 수가 많을 때 사용하는 디자인 패턴 중 하나이다.

  • 객체 생성을 가독성 있게 만들어 준다.
  • 많은 수의 선택적 매개 변수가 있는 클래스의 인스턴스를 생성하는데 유용하다.

빌더 패턴은 다음과 같은 주요 구성 요소로 이루어져 있다.

  1. Product (제품): 빌더 패턴으로 생성하려는 복잡한 객체의 클래스
  2. Builder (빌더): 제품의 생성 과정을 담당하는 인터페이스를 정의.
    • 여러 단계로 나누어져 있어야 한다
  3. ConcreteBuilder (구체적인 빌더): Builder 인터페이스를 구현하며, 제품을 실제로 생성하는 역할을 한다.
  4. Director (감독자): 제품의 생성 과정을 조정하는 클래스로, 클라이언트 코드에서 이를 사용하여 제품을 생성한다.
  5. Client (클라이언트): 빌더 패턴을 사용하여 제품을 생성하는 클래스 또는 코드이다.

💡 빌더 패턴은 GOF의 디자인 패턴에서 소개하는 빌더 패턴과 이펙티브 자바 책에서 소개하는 빌더 패턴 구조로 나뉜다고 하는데, 위 구성 요소는 GOF의 디자인 패턴이며 아래 코드는 이펙티브 자바에서 소개하는 빌더 패턴의 예이다.

사용법

```java
// Product
public class Person {
    private String name;
    private int age;
    private String address;

    // Constructor is private to enforce the use of the builder
    private Person() {}

    // Getters

    public static class Builder {
        private String name;
        private int age;
        private String address;

        public Builder(String name) {
            this.name = name;
        }

        public Builder age(int age) {
            this.age = age;
            return this;
        }

        public Builder address(String address) {
            this.address = address;
            return this;
        }

        public Person build() {
            Person person = new Person();
            person.name = this.name;
            person.age = this.age;
            person.address = this.address;
            return person;
        }
    }
}

// Client
public class Client {
    public static void main(String[] args) {
        Person person = new Person.Builder("John")
                            .age(30)
                            .address("123 Main St")
                            .build();
    }
}
```

빌더 패턴의 장단점

장점

  • 객체 생성 과정이 명시적이고 가독성이 높아진다.
    • 필요한 매개 변수들을 메서드 체인 형태로 호출
  • 많은 수의 선택적인 매개 변수를 가지고 있을 때 유용하다.
    • 필수 매개 변수가 아니라면 기본 값이나 null 등으로 놓을 수 있다.
  • 객체의 일관성과 불변성 보장
    • 단, 불변성의 경우 추가적인 규칙이 필요하다.

      public class Person {
          private final String name;
          private final int age;
          private final String address;
      
          private Person(String name, int age, String address) {
              this.name = name;
              this.age = age;
              this.address = address;
          }
      
          public static class Builder {
      				...
      
              public Person build() {
                  // 여기서 불변성을 강제
                  return new Person(name, age, address);
              }
          }
      }

단점

  • 코드의 복잡성 증가
    • 빌더 클래스를 만들어야 하므로, 관리해야 할 클래스가 많아지고 구조가 복잡해질 수 있다.
    • 여느 디자인 패턴이 가지는 단점
  • 생성자 보다는 성능이 떨어진다.
    • 매번 메서드를 호출하여 빌더를 거쳐 인스턴스화 하기 때문이다
  • 완전한 불변성을 보장하기 어려울 수 있다.
    • 빌더 패턴을 사용하면서도 객체의 상태를 변경할 수 있다.
    • 불변성을 강제하려면 추가적인 규칙을 적용해야 한다.
  • 객체가 덜 초기화될 가능성이 있다.
    • 모든 필수 매개 변수를 설정하지 않은 채로 객체를 생성할 수 있다.
    • 빌더의 생성자를 통해 필수 매개 변수를 꼭 받도록 추가적으로 설정해야 한다.

DTO와 빌더 패턴의 차이

빌더 패턴이 객체 생성의 복잡성을 다루기 위한 것이라면, DTO는 데이터를 저장하고 전송하는 데 중점을 두고 있다.

DTO에 빌더 패턴 사용하기

DTO가 불변성을 가져야 하거나, 가독성을 높이기 위해 빌더 패턴을 사용하고는 한다. lombok을 사용하면 빌더 패턴을 쉽게 구현할 수 있다.

그러나, 모든 DTO에 빌더 패턴을 사용해야 하는 것은 아니다. 오히려 오버 스펙이라는 의견도 있으며, 이건 취향의 문제라는 사람도 있었다.

빌더 패턴은 코드 복잡도가 증가하는 이슈도 있으니, DTO에 꼭 빌더 패턴이 필요한 지 고민해보고 적용하도록 하자.

Q. DTO의 Setter() 메소드는 문제가 있지 않은가?

문제점

  • setter 메소드로 객체의 상태를 언제든 변경할 수 있으므로, 불변 객체의 특성을 상실할 수 있다.
  • 모든 필드를 따로 설정할 수 있으므로 객체의 일관성이 깨질 수 있다.

대안

  • 생성자로 데이터를 모두 받은 후, 생성 후 상태를 변경할 수 없도록 한다.
  • 빌더 패턴을 사용하여 객체 생성을 유연하게 다룰 수 있다.
  • 의미를 분명하게 보여주는 메서드명을 사용하고, 메소드 내부에서 값 변경(setter 메서드 호출) 이전에 유효성 검사 로직을 호출하도록한다.
    • 데이터의 일관성과 규칙이 깨지지 않도록 한다.

다른 의견

지양해야 한다는 의견도 있지만, DTO는 단순히 데이터를 전달하는 용도로 사용하기 때문에 자유롭게 사용해도 된다는 의견도 있다.

결국 빌더 패턴 때와 마찬가지로 데이터의 일관성 및 불변성을 고려해 작성할 것이냐 말 것이냐가 주요 포인트가 되는 거 같다.

Q. 왜 NIO는 스트림을 쓰지 않는가?

여러 이유가 있겠지만, 결국 효율을 위해서 그런 것이다. 아래는 ChatGPT의 대답을 기반으로 찾아본 내용이다.

  • 스트림은 블로킹(Blocking) 방식으로 동작하므로 입출력 작업이 완료될 때까지 대기해야 한다.
    • NIO는 비동기 및 논블로킹 모델을 지원하므로, 입출력 작업의 효율을 향상시킬 수 있다.
  • 채널은 스트림과 달리 양방향으로 데이터를 읽고 쓸 수 있다.
    • 입력과 출력을 위한 별도의 채널을 만들 필요가 없다.

NIO에서 스트림을 사용하는 클래스

NIO에서도 채널과 함께 스트림을 사용할 수 있는 클래스들이 있다. 그 중에서도, 특히 Channels 클래스를 사용해 스트림과 채널을 연결할 수 있다.

  • Channels.newInputStream(Channel)
    • Channel을 InputStream으로 변환
  • Channels.newOutputStream(Channel)
    • Channel을 OutputStream으로 변환

비동기 입출력을 지원하기 위한 Selector 클래스

Selector는 비동기 입출력을 지원하기 위한 핵심 클래스 중 하나다.

  • 여러 개의 채널을 모니터링하고, 각 채널에서 발생한 이벤트에 대해 응답하는 역할을 한다.
  • 단일 스레드에서 여러 개의 채널을 효율적으로 관리하면서 비동기 입출력을 구현할 수 있도록 해준다.

Q. ByteBuffer 클래스의 버퍼 할당 방식

allocate()와 allocateDirect()

두 메소드의 차이는 메모리 할당 방식에 있다.

  • allocate() 메소드는 VM의 힙 영역에 버퍼를 할당한다.
  • allocateDirect() 메소드는 자바의 힙이 아닌 외부(운영체제 시스템)에 할당한다.
    • 운영체제가 제공하는 입출력 기능을 직접 사용하기 때문에 성능 면에서 이점을 가질 수 있다.
    • 네이티브 메모리를 사용하기 때문에 GC의 영향을 받지 않는다.
    • 자바 API를 보면 크고 오래 지속되는 버퍼에 대해 할당하는 것을 권장한다.

일반적으로는 allocate()를 사용하는 것이 편리하고 안전하며, allocateDirect()는 성능 최적화를 위해 사용한다.

참고 사이트

Builder Method Design Pattern in Java - GeeksforGeeks

💠 빌더(Builder) 패턴 - 완벽 마스터하기

Should i use builder pattern in DTO?

How to use builders efficiently when mapping Dto, Entity

setter 쓰지 말라고만 하고 가버리면 어떡해요

DTO 사용에대해 궁금합니다. - 인프런

[Java] IO와 NIO의 차이점? / IO와 NIO의 선택

ByteBuffer (Java Platform SE 8 )

profile
책을 읽거나 강의를 들으며 공부한 내용을 정리합니다. 가끔 개발하는데 있었던 이슈도 올립니다.

0개의 댓글