개발이군고구마

자바 I/O (2) - (문자) 파싱 구현체 만들기 본문

SERVER/Java

자바 I/O (2) - (문자) 파싱 구현체 만들기

김구황 2026. 1. 7. 07:18
728x90

1. 학습 내용

데이터가 들어오면, 메모리(Buffer)에 올리고, 쪼개고(Split), 적절한 **그릇(Map)**에 담는다

텍스트 파일
 ↓
문자 스트림 (Reader)
 ↓
토큰 단위 해석
 ↓
구조화된 데이터 (Map / List / Object)
 ↓
매핑 / 조합 로직

 

  • Java I/O: FileInputStream -> InputStreamReader -> BufferedReader로 이어지는 보조 스트림
  • String 조작: split, substring, indexOf 만으로 원하는 문자열을 발라내기
    • CSV → 구조화 (row, column)
    • JSON → 구조화 (object, array, key-value)
  • 자료구조 개념
    • 두 구조를 어떤 공통 모델로 표현하고
    • 어디서 매핑/조합할지 사고할 수 있는가

 

 

2. 주어진 상황 

구현 결과  

- 리스트에 담진 전화번호부 정보에 이름일 때 번호를 출력하고 싶음 
- 이름 중복 허용하지 않는 전화번호 리스트의 총 개수를 구하고 싶음 

public class MainTest {
    public static void main(String[] args) {
        PhoneList phoneList = new PhoneList();

        // CSV 로드
        phoneList.loadData(new CsvParser(), "data.csv");
        
        // JSON 로드 (같은 리스트에 추가됨, 중복 이름은 자동 갱신)
        phoneList.loadData(new JsonParser(), "data.json");

        System.out.println("총 인원: " + phoneList.getTotalCount());
    }
}

 

주어진 자료

 

 - csv 자료 

name : john, phone: 010-1234-1234, 
name : peter, age: 010-1234-1234
....


- json 자료 

[
    {
    name : "jeeho",
    phone: "010-1234-1234"
    },
    {
    name : "jeesu",
    phone: "010-1234-1234"
    },
	....
]

 

구체 설계 포인트 

Parser는 구현체로 분리하여 csv/json 파서에 공통으로 적용한다. 

 


3. 핵심 원리 

 

[A. 대용량 처리를 위한 I/O: BufferedReader]

대용량 파일은 한 번에 메모리에 올리면 OutOfMemoryError가 발생할 수 있음. 따라서 **버퍼(Buffer)**를 사용하여 데이터를 조각내어 읽어와야 함.

  • Scanner: 사용이 쉽지만, 정규식을 사용하고 파싱 과정이 있어 대용량 처리에 느림.
  • BufferedReader: 버퍼링을 통해 HDD/SSD 접근 횟수를 줄여 훨씬 빠름. (필수 선택)

[B. 검색과 중복 제거를 위한 자료구조: HashMap]

문제의 요구사항은 "이름으로 번호 찾기"와 "이름 중복 불허".

  • ArrayList: 검색 시 처음부터 끝까지 훑어야 하므로 속도가 느림 ($O(N)$)
  • HashMap: Key(이름)에 대한 해시값을 통해 즉시 접근하므로 검색 속도가 매우 빠름 ($O(1)$). 또한, Key는 중복될 수 없으므로 중복 제거 요구사항을 자동으로 만족

[C. 파싱 (Parsing) 전략]

라이브러리 없이 파싱하려면 문자열을 '토큰(Token)' 단위로 잘라야 함. 

  • String 메서드: split(), trim(), replace(), substring()
  • 전략: 의미 없는 기호({, }, [, ], ")를 제거하고, 구분자(:, ,)를 기준으로 데이터를 추출

▶ (파일) CSV와 JSON의 공통점 / 차이점

  CSV JSON
기본 단위 줄(Line) 토큰(Token)
구조 행/열 객체/배열
표현 위치 기반 키 기반
내부 표현 List<String> Map<String, Object>

 

 

 

4.전략 패턴(Strategy Pattern)으로 파서 구현하기 

 

[코드 설계]

  • 핵심 개념 : 역할과 책임의 분리과 다형성 → 유지보수 쉬운 코드  
    • PhoneList : 데이터를 저장하고 조회하는 역할에만 집중
    • Parser : 파일 형식이 CSV/JSON에 따라 달라지는 파싱 로직 

 

1️⃣ Parser 인터페이스 

PhoneList는 구체적으로 어떻게 파싱하는지 몰라도 되며, 주입받은 Parser에게 일을 **위임** 하기만 하면 됨

import java.util.Map;

// 파싱 기능을 추상화한 인터페이스
public interface Parser {
    // 대용량 처리를 위해 Map을 인자로 받아 직접 채워넣는 방식 (Call by Reference 활용)
    // 반환값을 List로 할 경우, 중간 단계 메모리 소모가 커지므로 Map을 전달받아 채우는 것이 효율적
    void parse(String filePath, Map<String, String> phoneBook);
}

▶ 왜 인터페이스를 사용하였나? 👉 https://sweetjhpotato.tistory.com/530

 

 

2️⃣ 구현체: CsvParser, JsonParser

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.Map;

// CSV 파싱 구현체
public class CsvParser implements Parser {
    @Override
    public void parse(String filePath, Map<String, String> phoneBook) {
        // try-with-resources 구문 (AutoCloseable)
        try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
            String line;
            while ((line = br.readLine()) != null) {
                // 한 줄 예시: "name : john, phone: 010-1234-1234"
                parseLine(line, phoneBook);
            }
        } catch (IOException e) {
            throw new RuntimeException("CSV 파일 읽기 실패: " + filePath, e);
        }
    }

    private void parseLine(String line, Map<String, String> phoneBook) {
        String[] parts = line.split(","); // 쉼표로 1차 분리
        String name = null;
        String phone = null;

        for (String part : parts) {
            String[] keyValue = part.split(":"); // 콜론으로 2차 분리
            if (keyValue.length < 2) continue;

            String key = keyValue[0].trim();
            String value = keyValue[1].trim();

            if ("name".equals(key)) name = value;
            else if ("phone".equals(key)) phone = value;
        }

        if (name != null && phone != null) {
            phoneBook.put(name, phone);
        }
    }
}

 

// JSON 파싱 구현체
public class JsonParser implements Parser {
    @Override
    public void parse(String filePath, Map<String, String> phoneBook) {
        try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
            String line;
            String currentName = null;
            String currentPhone = null;

            while ((line = br.readLine()) != null) {
                line = line.trim();

                // 1. 이름 추출
                if (line.contains("\"name\"")) {
                    currentName = extractValue(line);
                }
                // 2. 전화번호 추출
                else if (line.contains("\"phone\"")) {
                    currentPhone = extractValue(line);
                }

                // 3. 객체 닫힘(},) 감지 -> 저장
                if (line.startsWith("}")) {
                    if (currentName != null && currentPhone != null) {
                        phoneBook.put(currentName, currentPhone);
                    }
                    // 상태 초기화
                    currentName = null;
                    currentPhone = null;
                }
            }
        } catch (IOException e) {
            throw new RuntimeException("JSON 파일 읽기 실패: " + filePath, e);
        }
    }

    // "key" : "value" 형태에서 value만 뽑아내는 헬퍼 메서드
    private String extractValue(String line) {
        int colonIndex = line.indexOf(":");
        if (colonIndex == -1) return null;

        String valuePart = line.substring(colonIndex + 1).trim();
        // 앞뒤 따옴표(")와 뒤쪽 콤마(,) 제거
        return valuePart.replaceAll("[\",]", ""); 
    }
}


✴ BufferedReader의 readLine():

  • 파일 전체를 String 변수 하나에 담으면 메모리가 터짐
  • readLine()은 한 줄씩 읽어서 처리하고 버리므로, 수 기가바이트(GB)의 로그 파일도 처리할 수 있는 방식 (스트림 처리 방식)

  JSON 수동 파싱 (State Machine 아이디어):

  • JSON 라이브러리 없이 구현할 때는 **'한 줄씩 읽으면서 상태를 기억'**하는 것이 요령
  • 위 코드에서는 currentName, currentPhone이라는 임시 변수를 두고, }(닫는 괄호)가 나올 때 Map에 저장하고 변수를 비우는 방식을 사용. 이것이 simple-json이나 Jackson 같은 라이브러리가 내부적으로 동작하는 기초 원리

  Map<String, String> 사용 이유:

  • 리스트(List)에 담고 매번 contains로 중복을 체크하면 데이터가 쌓일수록 속도가 급격히 느려짐
  • Map의 Key는 유일성이 보장되므로, put("john", ...)을 두 번 호출해도 알아서 마지막 데이터로 갱신되어 중복 처리를 별도 로직 없이 해결

 

3️⃣ PhoneList 클래스 (사용자)

import java.util.HashMap;
import java.util.Map;

public class PhoneList {
    private Map<String, String> phoneBook;

    public PhoneList() {
        this.phoneBook = new HashMap<>(); // 검색 속도 O(1) 및 중복 방지
    }

    // 다형성 활용: 어떤 Parser가 들어오든 상관없음
    public void loadData(Parser parser, String filePath) {
        parser.parse(filePath, this.phoneBook);
    }

    public String getPhoneNumber(String name) {
        return phoneBook.getOrDefault(name, "번호 없음");
    }
    
    public int getTotalCount() {
        return phoneBook.size();
    }
}

 

 

 

 

5. JSON 파싱 로직 심층 분석

 

[State Machine 원리]

  • 라이브러리 없이 JSON을 파싱할 때 가장 중요한 개념은 **"상태(State)를 기억하는 것"**. 이것은 컴파일러 이론의 어휘 분석(Lexical Analysis) 기초와 닿아 있음
  • BufferedReader로 읽는 것은 한 번에 한 줄뿐. 따라서 이전 줄에서 읽은 정보를 어딘가에 저장해둬야 온전한 데이터 하나를 만들 수 있음

 

[논리 흐름]

임시 변수에 값을 모으고 닫는 괄호가 나올 때 맵에 저장 후 변수를 플러시(Flush)하는 방식

  1. 변수 선언 (바구니 준비):
    • currentName, currentPhone이라는 변수를 루프 밖듬. 이 변수들은 **"지금 조립 중인 사람의 부품"**을 임시 보관하는 역할.
  2. 데이터 발견 (재료 손질):
    • line.contains("\"name\"")이 참이면, 값을 발라내어 currentName에 담음. 아직 phone이 없으므로 Map에 넣을 수 없음. (대기 상태)
  3. 객체 종료 감지 (조립 완료):
    • } (닫는 중괄호)를 만나는 순간이 바로 **"한 사람의 데이터가 끝났다"**는 신호
    • 이때 비로소 Map.put(currentName, currentPhone)을 수행하여 확정
  4. 초기화 (다음 사람 준비):
    • 저장이 끝났으므로 currentName, currentPhone을 다시 null로 만들어 다음 데이터를 받을 준비. 만약 초기화하지 않으면, 다음 데이터에 이름이 누락되었을 때 이전 사람의 이름이 섞여 들어가는 버그가 발생

 

 

 

6. 예외처리

자바 I/O는 Checked Exception을 강제하므로 처리가 필수

단순히 try-catch를 썼느냐보다 **"리소스를 안전하게 닫았는가?"**가 중요함 

 

[ A. try-with-resources (Java 7+)]

// 권장 방식
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
    // ... 작업 수행 ...
} catch (IOException e) {
    // 예외 처리
}
// 여기서 br.close()가 자동으로 호출됨 (성공하든 실패하든)

원리: BufferedReader가 AutoCloseable 인터페이스를 구현하고 있기 때문에, 블록을 빠져나갈 때 자동으로 자원을 반납

 

 

[ B. 예외의 종류와 처리 전략]

  • FileNotFoundException: 파일 경로가 틀렸거나 파일이 없을 때 발생. IOException의 하위 클래스이므로, 별도로 잡고 싶다면 catch 블록을 분리해서 위에 작성해야
  • IOException: 읽기 권한이 없거나, 디스크 오류 등으로 읽다 끊겼을 때 발생
  • 파싱 예외 (논리적 오류): 파일은 읽었으나 형식이 엉망인 경우(예: JSON인데 닫는 괄호가 없음).
    • 실무: 로그를 남기고 해당 라인만 스킵(Skip)한 뒤 계속 진행하는 것이 좋음. (100만 개 중 1개가 깨졌다고 프로그램이 죽으면 인되기 때문)

 

[C. 예외 전환]

위 코드의 catch 블록을 보면 IOException을 잡아서 RuntimeException으로 감싸서 던지고 있음.

catch (IOException e) {
    throw new RuntimeException("파일 읽기 실패: " + filePath, e);
}

 

  • 이유: Parser 인터페이스의 메서드 시그니처에 throws IOException을 덕지덕지 붙이면, 이를 호출하는 PhoneList와 Main까지 모두 예외 처리를 강제받아 코드가 지저분해짐.
  • 전략: 복구 불가능한 시스템 오류(파일 없음 등)는 **Unchecked Exception(런타임 예외)**으로 포장(Wrap)하여 던져서, 최상단에서 한 번에 에러 로그를 출력하는 방식이 깔끔. 이를 **"예외 전환(Exception Translation)"**이라고 함

 

'SERVER > Java' 카테고리의 다른 글

build gradle의 상세 과정  (0) 2026.05.13
응집도/결합도 분석으로 본 '인터페이스를 사용하는 이유'  (0) 2026.01.09
자바 I/O (1) - 원리  (0) 2025.12.29
[자료구조] LIST 원리  (0) 2025.12.26
[자료구조] MAP 원리  (0) 2025.12.19