I/O 활용 – 회원 관리 시스템

자바의 다양한 I/O 방식을 활용해 실무에서 가장 기본이 되는 회원 관리 시스템을 단계별로 구축해본다. 단순히 데이터를 저장하는 것을 넘어, 어떻게 하면 더 효율적이고 객체지향적으로 데이터를 관리할 것인가?에 초점을 맞춰본다

도메인 모델 설계 – Member와 Interface

모든 저장소 구현체는 MemberRepository 인터페이스를 따른다. 이는 요구사항이 변해 저장 방식이 바뀌어도 클라이언트 코드 (MemberConsoleMain)를 수정하지 않기 위해서다

// Member.java
public class Member implements Serializable { // 4단계 직렬화를 위해 미리 구현
    private String id;
    private String name;
    private Integer age;

    // 생성자, Getter/Setter, toString 생략
}

// MemberRepository.java
public interface MemberRepository {
    void add(Member member);
    List<Member> findAll();
}

1단계 – 메모리 저장 방식 (MemoryMemberRepository)

가장 빠른 방식이지만, 프로그램 종료 시 데이터가 휘발된다. I/O의 기본 구조를 잡는 단계이다

  • 핵심: ArrayList를 사용한 인메모리 관리
  • 특징: 구현이 매우 단순하며 속도가 빠름
public class MemoryMemberRepository implements MemberRepository {
    private final List<Member> members = new ArrayList<>();

    @Override
    public void add(Member member) {
        members.add(member);
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(members); // 방어적 복사 권장
    }
}

2단계 – 텍스트 파일 저장 방식 (FileMemberRepository)

데이터를 영구 보존하기 위해 FileWriterBufferedWriter를 사용한다

public class FileMemberRepository implements MemberRepository {
    private static final String FILE_PATH = "temp/members-txt.dat";
    private static final String DELIMITER = ",";

    @Override
    public void add(Member member) {
        // BufferedWriter 중복 래핑 제거 및 try-with-resources 활용
        try (BufferedWriter bw = new BufferedWriter(new FileWriter(FILE_PATH, StandardCharsets.UTF_8, true))) {
            bw.write(member.getId() + DELIMITER + member.getName() + DELIMITER + member.getAge());
            bw.newLine();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public List<Member> findAll() {
        List<Member> members = new ArrayList<>();
        try (BufferedReader br = new BufferedReader(new FileReader(FILE_PATH, StandardCharsets.UTF_8))) {
            String line;
            while ((line = br.readLine()) != null) {
                String[] data = line.split(DELIMITER);
                members.add(new Member(data[0], data[1], Integer.valueOf(data[2])));
            }
        } catch (FileNotFoundException e) {
            return new ArrayList<>(); // 파일이 없으면 빈 리스트 반환
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return members;
    }
}

3단계 – 자바 데이터 타입 활용 (DataMemberRepository)

DataMemberRepositoryDataInputStream을 사용하여 자바의 기본 타입을 그대로 저장한다

  • 핵심: writeUTF(), writeInt() 메서드를 사용해 타입 정보를 보존한다
  • 원리: writeUTF는 문자열 앞에 2바이트의 길이 정보를 자동으로 기록한다. 따라서 별도의 구분자가 필요 없다
  • 장점: 텍스트 방식보다 저장 용량이 최적화되며(숫자 10억 저장 시 10 바이트 → 4 바이트), 파싱 과정이 단순해진다
public class DataMemberRepository implements MemberRepository {
    private static final String FILE_PATH = "temp/members-data.dat";

    @Override
    public void add(Member member) {
        try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(FILE_PATH, true))) {
            dos.writeUTF(member.getId());
            dos.writeUTF(member.getName());
            dos.writeInt(member.getAge());
        } 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;
    }
}

4단계 – 객체 직렬화 방식 (ObjectMemberRepository)

객체 자체를 바이트 스트림으로 변환하여 통째로 저장한다

  • 핵심: Serializable 마커 인터페이스 구현 필수
  • 특징: 필드 하나하나를 꺼낼 필요 없이 List<Member> 전체를 단 한 줄 (writeObject) 로 저장할 수 있다
  • 주의사항
    • 역직렬화 시 클래스 정보가 일치해야 하므로 serialVersionUID 관리가 필요하다
    • List.of()로 생성된 불변 컬렉션은 역직렬화 후 수정이 불가능하므로 new ArrayList<>()를 사용해야 한다
    • 현업의 시각: 보안 및 호환성 문제로 최근에는 JSON이나 Protobuf 같은 대안을 더 선호하지만, 자바 시스템 간의 빠른 객체 전달 시에는 여전히 유용하지만 99.99%는 사용하지 않는 듯하다
public class ObjectMemberRepository implements MemberRepository {
    private static final String FILE_PATH = "temp/members-obj.dat";

    @Override
    public void add(Member member) {
        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);
        }
    }

    @SuppressWarnings("unchecked")
    @Override
    public List<Member> findAll() {
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH))) {
            return (List<Member>) ois.readObject();
        } catch (FileNotFoundException e) {
            return new ArrayList<>();
        } catch (IOException | ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }
}

추상화의 힘

MemberConsoleMain 코드를 보면 Repository의 구현체만 갈아 끼울 뿐, 나머지 로직은 전혀 건드리지 않았다. 이것이 바로 유연한 설계의 핵심이다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션