자바의 다양한 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)
데이터를 영구 보존하기 위해 FileWriter와 BufferedWriter를 사용한다
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)
DataMemberRepository과 DataInputStream을 사용하여 자바의 기본 타입을 그대로 저장한다
- 핵심:
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의 구현체만 갈아 끼울 뿐, 나머지 로직은 전혀 건드리지 않았다. 이것이 바로 유연한 설계의 핵심이다