[TIL] BE-08 - IO Stream, 직렬화, 파일 저장·로드

W L·2026년 8월 21일

TIL

목록 보기
16/32

1. 오늘 학습한 주제

  • java 구현 web component — Servlet, JSP
  • Stream — 단방향, 1byte / 2byte 구분
  • Stream 예외처리와 close() — try-catch-finally, try-with-resources
  • 콘솔 입출력 — System.in.read(), BufferedReader
  • 파일 입출력 — FileReader, FileWriter
  • Serializable과 직렬화 — ObjectOutputStream, ObjectInputStream
  • 블로그 CRUD 마무리 — save(), update(), delete()
  • 블로그 파일 저장·로드 — file.inspire, FileController

2. 배운 내용

java와 HTTP — web component

지금까지 만든 java 프로그램은 콘솔에서 Scanner로 입력을 받는 구조라, HTTP 요청을 받아 처리하는 자리가 없다. 그래서 HTTP 통신은 브라우저를 통해 이루어진다고 정리했다.

java에서 web 요청을 처리하기 위해 만들어진 것이 web component다.

  • Servlet
  • JSP

Servlet은 지금 실습의 view 자리에 들어간다. factory에 controller, service, repository 객체를 요구해서 받아 쓴다.

Servlet -> factory에 객체(Controller, Service, Repository) 요구
-> 객체 받아서 접근

지금 블로그 실습에서 BlogReactView가 BlogFrontController를 통해 factory의 객체를 받아 쓰는 흐름과 같은 자리다.

Stream

Stream은 단방향이다. 읽는 쪽과 쓰는 쪽이 나뉘어 있다.

파일 입출력을 다루는 이유는 서버가 내려갈 때 메모리에 있던 데이터를 로컬 파일에 저장해 두고, 서버가 다시 뜰 때 그 파일에서 정보를 가져오기 위해서다.

클래스 이름으로 처리 단위가 갈린다.

이름단위
xxxStream1byte
xxxReader, xxxWriter2byte(char)

Stream이 제공하는 메서드는 대부분 예외를 발생시키므로 예외처리를 고려해야 한다. 그리고 resource를 다 쓴 후에는 close()가 필수다. close() 자체도 예외를 던지기 때문에 finally 안에서 다시 try-catch로 감싸는 형태가 된다.

try - catch - finally - try - catch(tcftc)

콘솔 입출력 — 1byte의 한계

System.in.read()로 입력을 받아 보면 결과가 이렇게 나온다.

debug >>>> io stream 이용한 데이터 입출력 : medtate
109
debug >>>> io stream 이용한 데이터 입출력 : m
109

medtate를 넣든 m을 넣든 109가 찍힌다. 1byte밖에 못 읽기 때문에 첫 글자만 읽고, 그 값도 문자가 아니라 코드값이다.

System.out.print("debug >>>> io stream 이용한 데이터 입출력 : ");
try {
    int input = System.in.read();
    System.out.println(input);
} catch (IOException e) {
    e.printStackTrace();
}

char 단위로 읽으려면 InputStreamReader로 감싸고, 줄 단위로 읽기 위해 BufferedReader를 한 번 더 씌운다.

System.out.print("debug >>>> io stream 이용한 데이터(char) 입출력 : ");

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

try {
    String input = br.readLine();
    System.out.println(input);
} catch (IOException e) {
    e.printStackTrace();
} finally {
    try {
        if (br != null) {br.close();}
    } catch (Exception e) {
        e.printStackTrace();
    }
}
debug >>>> io stream 이용한 데이터(char) 입출력 : 후두다닥
후두다닥

finally에서 close()를 부르는 부분이 위에서 말한 tcftc다. br이 null인지 확인하고, close()가 던지는 예외를 다시 잡는다.

같은 동작을 try-with-resources로 줄여 쓸 수 있다. try 괄호 안에서 선언한 resource는 블록을 벗어날 때 자동으로 닫히므로 finally가 사라진다.

System.out.print("debug >>>> autocloseable : ");
try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
    System.out.println(br.readLine());
} catch (Exception e) {
    e.printStackTrace();
}

파일 입출력

읽는 대상만 System.in에서 파일로 바뀐다. 감싸는 구조는 그대로다.

InputStream -> InputStreamReader, BufferedReader(FileReader(File()));

읽을 때는 readLine()이 null을 반환할 때까지 반복한다.

String path = "./text.txt";
try (BufferedReader br = new BufferedReader(new FileReader(new File(path), StandardCharsets.UTF_8))) {
    String input = null;
    while ((input = br.readLine()) != null) {
        System.out.println(input);
    }
} catch (Exception e) {
    e.printStackTrace();
}

쓸 때는 BufferedWriter에 FileWriter를 넣는다. 이 형태는 overwrite다.

// overwrite
String path = "./text.txt";
try (BufferedWriter bw = new BufferedWriter(new FileWriter(new File(path), StandardCharsets.UTF_8))) {
    bw.write("메세지\n");
} catch (Exception e) {
    e.printStackTrace();
}

Serializable

객체를 파일에 저장하려면 직렬화가 필요하다. 수업에서는 이렇게 설명했다.

Serializable
- 자기가 태어나서 부여받은 주소값을 버리고 새로운 곳으로 이주
- 새로운 주소는 다른 시스템의 VM일수도 있고 파일일수도 있음
- 객체를 byte(1byte 단위)로 풀어냄

객체는 heap의 주소값으로 참조되는데, 그 주소는 지금 실행 중인 VM 안에서만 의미가 있다. 파일이나 다른 VM으로 옮기려면 주소가 아니라 내용 자체를 byte로 풀어내야 한다.

풀어내는 단위가 byte이므로 Reader, Writer가 아니라 Stream 계열을 쓴다.

// 직렬화는 무조건 1byte
// 직렬화된 객체를 저장할 거라 stream을 씀
// 그걸 저장하는 객체는 Fileoutputstream

대상 클래스에는 Serializable을 구현해 둬야 한다.

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
@ToString
public class BlogResponseDTO implements Serializable {

    private int     status ;
    private String  message ;

    ////////////////////////////////////////
    private Integer blogId ;
    private String  title, content, email ;
    private Integer viewCnt ;

}

ObjectOutputStream에 FileOutputStream을 넣고 writeObject()로 저장한다. List 하나를 통째로 넘긴다.

String path = "./object.txt";
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(path)))) {
    oos.writeObject(blogs);
    System.out.println("debug >>>> object.txt save ok");
} catch (Exception e) {
    e.printStackTrace();
}

읽을 때는 ObjectInputStream의 readObject()를 쓴다. 반환 타입이 Object라서 원래 타입으로 casting해야 한다.

String path = "./object.txt";
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File(path)))) {
    List<BlogResponseDTO> list = (List<BlogResponseDTO>)ois.readObject();
    list.forEach(System.out::println);
} catch (Exception e) {
    e.printStackTrace();
}

블로그 CRUD 마무리 — save, delete, update

BE-07에서 0만 반환하던 save()를 채웠다. BlogRequestDTO가 내려오는데 blogs는 List<BlogResponseDTO>라, toEntity()로 타입을 바꾼 뒤 blogId를 채워 넣는다.

blogId는 database의 시퀀스 대신 stream으로 만든다. 가장 큰 blogId에 1을 더한다.

// 타입 변경
public int save(BlogRequestDTO request) {
    System.out.println("debug >>>> blog dao save params : " + request);

    /*
    blogId stream 이용한 generator
    hint)
    Q) blogId = stream sotrd blogId reversed + 1
    */

    int blogId = blogs.stream()
                    .map(BlogResponseDTO::getBlogId)
                    .sorted(Comparator.reverseOrder())
                    .findFirst()
                    .orElse(0) + 1;

    BlogResponseDTO response = BlogRequestDTO.toEntity(request);
    response.setBlogId(blogId);
    blogs.add(response);

    return 1;
}

orElse(0)이 붙어 있어서 목록이 비어 있을 때도 blogId가 1부터 시작한다.

삭제는 removeIf()로 조건에 맞는 것을 지우고, 지워졌는지 여부를 1 / 0으로 바꿔 반환한다.

public int delete(int blogId) {
    System.out.println("debug >>>> blog dao delete param : " + blogId);
    // 이건 blogId를 참조하는 게 아니라 실제 index를 참조하기 때문에 부적절
    // blogs.remove(blogId - 1);
    boolean isFlag = blogs.removeIf(blog -> blog.getBlogId() == blogId);
    return (isFlag) ? 1 : 0;
}

수정은 SQL의 update ... where와 같은 자리다. filter()로 대상을 찾고 map() 안에서 값을 바꾼다. 찾지 못하면 orElse(0)으로 실패가 된다.

public int update(BlogRequestDTO request) {
    System.out.println("debug >>>> blog dao update param : " + request);

    // Q) SQL : update title = request.getTitle(), content = request.getContent()
    //          where blogId = request.getBlogId()

    return blogs.stream()
                .filter(blog -> blog.getBlogId() == request.getBlogId())
                .findAny()
                .map(blog -> {
                    blog.setTitle(request.getTitle());
                    blog.setContent(request.getContent());
                    return 1;
                })
                .orElse(0);
}

controller는 조회 때와 같이 파라미터를 BlogRequestDTO로 묶어 내린다. 삭제는 키 하나뿐이라 그대로 넘긴다.

public int update(int blogId, String title, String content) {
    System.out.println("debug >>>> update controller update params : " + title + "\t" + content + "\t" + content);

    return service.update(BlogRequestDTO.builder()
                                        .blogId(blogId)
                                        .title(title)
                                        .content(content)
                                        .build());
}

블로그 파일 저장·로드 — file.inspire

앞에서 배운 직렬화를 블로그에 붙였다. 기능 하나를 추가할 때 손대는 지점은 지금까지와 같다.

service 인터페이스에 두 메서드를 선언한다. 반환 타입은 성공 여부라 boolean이다.

// file save, load
public boolean                  saveToFile();
public boolean                  loadToFile();

구현은 IOStreamApp에서 연습한 것과 같은 형태다. 저장은 dao의 목록을 그대로 writeObject()에 넘긴다.

@Override
public boolean saveToFile() {
    System.out.println("debug >>>> blog service file save");

    String path = "./blogs.txt";
    try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(path)))) {
        oos.writeObject(dao.findByAll());
        return true;
    } catch (Exception e) {
        e.printStackTrace();
    }

    return false;
}

로드는 읽어온 List를 dao에 넣어준다.

@Override
public boolean loadToFile() {
    System.out.println("debug >>>> blog service file load");

    String path = "./blogs.txt";
    try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File(path)))) {
        dao.setBlogs((List<BlogResponseDTO>)ois.readObject());
        return true;
    } catch (Exception e) {
        e.printStackTrace();
    }

    return false;
}

dao에는 목록을 통째로 갈아끼우는 setBlogs()를 열어 뒀다. 생성자에서 만든 초기 5건 대신 파일에서 읽은 목록을 들게 된다.

// 참조복사(deep copy)
public void setBlogs(List<BlogResponseDTO> blogs) {
    this.blogs = blogs;
}

controller는 저장과 로드를 각각 메서드로 둔다.

public class FileController {
    private BlogReactService service ;
    public FileController() {
    }
    public FileController(BlogReactService service ) {
        this.service = service ;
    }

    public boolean save() {
        System.out.println("debug >>>> file controller save : ");
        return service.saveToFile();
    }

    public boolean load() {
        System.out.println("debug >>>> file controller load : ");
        return service.loadToFile();
    }
}

factory에 file.inspire로 등록한다.

map.put("file.inspire",     new FileController(service));

front controller에서는 endPoint 하나에 동작이 둘이라 action을 같이 받아 분기한다. 다른 메서드와 달리 Object로 받지 않고 바로 FileController로 casting해 두었다.

public boolean file(String endPoint, String action) {
    System.out.println("debug >>>> front controller endPoint : " + endPoint + ", action : " + action);

    FileController controller = (FileController)factory.getBean(endPoint);
    if (action.equals("save")) {
        return controller.save();
    } else {
        return controller.load();
    }
}

view에서는 프로그램이 뜰 때 로드하고, 종료할 때 저장한다. 필기에 있던 "서버가 다운될 때 저장하고, 가동될 때 다시 가져온다"가 이 두 자리다.

public void landingPage() {

    boolean isLoad = front.file("file.inspire", "load");
    System.out.println(isLoad ? "데이터 로딩완료!!" : "데이터 로딩 실패");

종료 시에는 저장 여부를 먼저 묻는다.

public void exit() {
    System.out.println();
    System.out.println(">>>> 변경사항을 저장하고 시스템을 종료하시겠습니까?(y/n).");
    String yesOrNo = sc.nextLine();
    if (yesOrNo.equalsIgnoreCase("y")) {
        String endPoint = "file.inspire";
        System.out.println(front.file(endPoint, "save") ? "데이터 저장 완료!!" : "데이터 저장 실패");
    }
    System.exit(1);
} // exit block end

이제 입력·수정·삭제한 내용이 종료 후에도 남는다.

3. 헷갈렸던 부분

remove()에 넘긴 값이 blogId가 아님

삭제를 구현하면서 blogId로 지우면 된다고 생각하고 이렇게 썼다.

blogs.remove(blogId - 1);

List.remove(int)는 index를 받는다. blogId가 1부터 순서대로 붙어 있는 초기 상태에서는 blogId - 1이 우연히 index와 맞아서 동작하는 것처럼 보이지만, 중간에 하나를 지우고 나면 뒤쪽 요소들의 index가 당겨져 blogId와 어긋난다.

찾는 기준이 index가 아니라 blogId라는 조건이므로 removeIf()로 바꿨다.

// 이건 blogId를 참조하는 게 아니라 실제 index를 참조하기 때문에 부적절
// blogs.remove(blogId - 1);
boolean isFlag = blogs.removeIf(blog -> blog.getBlogId() == blogId);
return (isFlag) ? 1 : 0;

removeIf()는 지워진 것이 있으면 true를 반환하므로, 존재하지 않는 키를 넣었을 때 view에 "삭제실패"를 돌려줄 수 있다.

profile
한량

0개의 댓글