김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션 : DataStream, ObjectStream

jkky98·2024년 12월 11일
0

Java

목록 보기
48/51

요구 사항

id, name, age의 정보를 가지는 회원(Member)을 등록하고, 등록이 되었다면 조회가 가능한 간단한 콘솔 입출력 프로그램을 작성

Member

@Getter
@Setter
public class Member {

    private String id;
    private String name;
    private int age;

    public Member() {
    }

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

MemberRepository

public interface MemberRepository {
    void add(Member member) throws IOException;

    List<Member> findAll();
}

위와 같은 인터페이스를 두고 두 가지 버전의 리포지토리 구현체를 만들어 활용한다. 저장시 .dat파일에 저장되도록 하고 findAll()을 통해 읽을 경우 파일에서 읽도록 한다.

DataStream 방식


public class DataMemberRepository implements MemberRepository {

    private static final String FILE_PATH = "temp/members-data.dat";

    @Override
    public void add(Member member) throws IOException {
        try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(FILE_PATH, true))) {
            dos.writeUTF(member.getId());
            dos.writeUTF(member.getName());
            dos.writeInt(member.getAge());
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public List<Member> findAll() {
        List<Member> members = new ArrayList<>();
        try (DataInputStream dis = new DataInputStream(new FileInputStream(FILE_PATH))) {
            while (dis.available() > 0) {
                members.add(new Member(dis.readUTF(), dis.readUTF(), dis.readInt()));
            }
        } catch (FileNotFoundException e) {
            return new ArrayList<>();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        return members;
    }
}

이전 포스팅에서 사용해보았던 FileWriter의 경우, 자바의 어떤 타입이든 이를 String으로 바꾸고 이를 파일에 작성하는 스트림을 활용했다. DataStream은 이와 달리 저장할 데이터의 타입을 반영해준다.

어차피 파일에 기록되는 것인데 이것이 무슨 차이냐 라고 느낄지도 모른다.

double 타입의 3.1453를 파일에 기록한다고 가정해보자.

  • FileWriter의 경우 다음과 같이 파일에 저장된다.(텍스트로)
34324.14534324

데이터를 문자열로 저장하기에 34324.14534324이라는 숫자는 문자 14개로 저장되며, 각 문자는 보통 1바이트(UTF-8 기준)를 차지한다.
따라서, 34324.14534324 문자열은 최소 6바이트를 차지한다.

  • DataStream의 경우 다음과 같이 파일에 저장된다.(바이너리 데이터로)
40 E0 C2 84 A6 A6 DD D0

이때 double형 데이터는 항상 8바이트로 저장이 가능하므로, FileWriter로 저장했을 때 보다 더 적은용량으로 파일에 작성할 수 있다.
다만 단점은 해당파일을 열어 개발자가 직관적으로 어떤 데이터가 적혀있는지 바로 알기가 힘들다는 것이 단점이다.

ObjectStream 방식

분명히 FileWriter보다는 DataStream이 개발자가 코드를 작성하기에도, 저장용량적인 면에서도 훌륭하나 여전히 까다로운 지점들이 존재한다.

단순히 컬렉션에 저장한다면 우리는 Member객체를 통째로 주입했을 것이지만, DataStream을 통한 파일에 작성하기 위해 우리는 필드를 하나하나 꺼내어 타입에 맞는 writeXxx메서드로 하여금 DataStream에 작성했다.

ObjectStream은 이름 그대로 객체를 스트림에 넣어 통째로 파일에 작성이 가능하다는 것이다.(당연히 읽는 것도 가능하다.)

만약 List<Member>를 DataStream으로 저장하려면 리스트를 순회하며 해당 객체의 하나하나의 필드마다 스트림에 입력해야했을 것이지만 ObjectStream을 사용한다면 파일에 그냥 List<Member>를 곧바로 작성할 수 있다.

객체 직렬화

Member 클래스에 Serializable를 구현한다. 이 인터페이스는 깡통 인터페이스로 마커 역할을 한다. 뜻은 이 인터페이스를 구현한 구현체는 직렬화가 가능하다는 뜻이다.

@Getter
@Setter
public class Member implements Serializable {

    private String id;
    private String name;
    private int age;

    public Member() {
    }

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

구현

public class ObjectMemberRepository implements MemberRepository {

    private static final String FILE_PATH = "temp/members-obj.dat";

    @Override
    public void add(Member member) throws IOException {
        List<Member> members = findAll();
        members.add(member);

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_PATH))) {
            oos.writeObject(members);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public List<Member> findAll() {
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH))) {
            Object findObject = ois.readObject();
            return (List<Member>) findObject;
        } catch (FileNotFoundException e) {
            return new ArrayList<>(); // 처음 파일에 데이터가 없을 때 빈 리스트 반환
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }
}

add 메서드 코드를 보면 기존과 다르게 우선 findAll()로 하여금 데이터를 읽어오고 있다.(동기화를 위해) 데이터가 없는 파일일 경우 빈 리스트를 받는다.

확보된 리스트에 Member를 추가하고 해당 리스트를 다시 스트림에 주입하여 파일에 작성한다.

객체 직렬화 방식의 오브젝트 스트림이 잘 사용되지 않는 이유

ObjectOutputStreamObjectInputStream을 사용하는 객체 직렬화는 자바에서 오래전부터 제공되던 기능이지만, 현대 애플리케이션에서는 잘 사용되지 않는 방식이다. 그 이유는 다음과 같다.

  1. 비효율적인 저장 형식
    객체 직렬화는 데이터를 바이너리 형식으로 저장하지만, 그 구조는 자바의 JVM 내부 형식에 종속적이다. 이로 인해 저장된 데이터의 크기가 크고, 효율적이지 않다. JSON이나 Protobuf 같은 경량화된 데이터 표현 방식에 비해 데이터 용량과 읽기 성능 면에서 비효율적이다.

  2. 데이터 포맷의 비호환성
    직렬화된 객체는 JVM 및 클래스 정의에 강하게 의존적이다. 클래스의 버전이 변경되면 직렬화된 데이터가 더 이상 호환되지 않을 가능성이 크다. 이를 방지하기 위해 serialVersionUID를 명시적으로 정의해야 하지만, 이는 유지보수 부담을 증가시킨다.

  3. 보안 문제
    직렬화된 데이터는 그대로 파일에 저장되므로, 이를 악의적으로 조작할 경우 시스템에 위협이 될 수 있다. 특히 역직렬화 과정에서 보안 취약점이 자주 발생하며, 이는 자바 애플리케이션에서 지적되는 대표적인 문제다.

  4. 표준 데이터 교환 방식과의 비호환성
    JSON, XML, Protobuf 등은 다양한 언어에서 사용 가능한 표준화된 데이터 포맷이다. 반면, 자바의 객체 직렬화는 자바 언어에 한정적이므로 다른 플랫폼과 데이터 교환이 어렵다. 현대 애플리케이션은 멀티플랫폼 환경이 많아 객체 직렬화는 이러한 요구에 부적합하다.

  5. 유지보수성과 가독성 부족
    직렬화된 데이터는 사람이 읽을 수 없으므로, 문제가 발생했을 때 디버깅이 어렵다. 반면, JSON이나 XML은 사람이 읽을 수 있는 형태로 저장되므로 디버깅과 유지보수가 훨씬 용이하다.

대안적인 데이터 저장 방식

  1. JSON
    가볍고 사람이 읽을 수 있는 데이터 포맷이다. 다양한 라이브러리(Gson, Jackson 등)를 통해 자바 객체와 JSON 간 변환이 간단하며, 언어와 플랫폼에 독립적이다.

  2. XML
    사람이 읽을 수 있는 계층적 데이터 포맷이다. JSON에 비해 무겁지만 구조적 데이터 표현에는 적합하다.

객체 직렬화는 자바 기반의 단순한 애플리케이션에서만 적합하며, 현대적인 멀티플랫폼 환경에서는 잘 사용되지 않는 방식이다. JSON, Protobuf, XML 같은 범용적이고 경량화된 데이터 포맷이 더 널리 사용되며, 이러한 방식을 활용하면 성능, 확장성, 보안성에서 모두 이점을 가질 수 있다. 객체 직렬화 방식은 가능한 대체하는 것이 바람직하다.

profile
자바집사의 거북이 수련법

0개의 댓글