우아한테크코스 8기 precourse

JSON DTO Converter (4) - Exception 분석

hwangsoojin 2025. 11. 24. 18:25

UserException · InternalException · main() 예외 처리 흐름까지

이 글은 JSON DTO Converter 프로젝트의 예외 처리 계층(exception 패키지)를 정리한 글이다.

앞선 글들에서

  • CLI 계층: 잘못된 옵션/경로를 어떻게 막는지
  • JSON 분석 계층: JSON 구조를 어떻게 스키마로 바꾸는지
  • Generator 계층: 설계도를 실제  .java  파일로 만드는지

를 다뤘다면,

이 글은 "그 과정에서 발생하는 오류를 어떻게 분류하고, 사용자에게 어떻게 보여줄 것인가"에 초점을 둔다.


0. 왜 예외 설계가 중요한가?

 

CLI 도구 특성상, 예외 설계는 곧 사용자 경험(UX) 이다.

  • 잘못된 인자, 없는 파일, 깨진 JSON 같은 "사용자가 고칠 수 있는 오류"
  • 버그, 구현 누락, 설계 상 발생하면 안 되는 상태 같은 "개발자가 고쳐야 하는 오류"

같은 방식으로 출력하면:

  • 사용자는 "내가 뭘 잘못한 건지" 알 수 없고
  • 내부 버그가 발생했을 때도 조용히 묻힐 수 있다.

그래서 이 프로젝트에서는 예외를 두 축으로 나눴다.

1. 사용자가 고칠 수 있는 오류 →  UserException 
2. 코드/설계 버그 →  InternalException 

 

이 둘을 분리해두면,  main()  에서 처리 전략도 명확해진다.


1. exception 패키지 구조

 org.example.exception  패키지는 단 두 개의 클래스로 이루어진다.

org.example.exception
 ├─ UserException.java
 └─ InternalException.java
 

심플하지만, 이 두 클래스가 예외 설계 방향을 완전히 갈라 놓는다.


2. UserException — "사용자가 수정할 수 있는 오류"

2-1. 개념

UserException은 말 그대로

"사용자가 입력을 바꾸면 해결할 수 있는 오류"

 

를 나타낸다.

예를 들면:

  •  --input  에 존재하지 않는 파일 경로를 넣었다
  • JSON 문법이 잘못된 파일을 입력했다
  •  --inner-classes maybe  처럼 허용되지 않는 값을 넣었다
  •  --out  디렉터리가 쓰기 권한이 없다

이런 것은 사용자가 명령을 고쳐서 다시 실행하면 해결되는 문제다.

 

따라서:

  • 내부 스택 트레이스를 장황하게 보여줄 필요가 없고
  • 짧고 맥락 있는 오류 메시지만 써도 충분하다.

예시 메시지:

[ERROR] 입력 JSON 파일을 찾을 수 없습니다: input/not-exists.json
[ERROR] --inner-classes 옵션은 true/false만 허용합니다: maybe
[ERROR] JSON 파싱에 실패했습니다. 파일 형식을 다시 확인해주세요: broken.json
 

2-2. 구현 형태

UserException은 보통 이렇게 구현한다(실제 구현과 개념적으로 동일):

package org.example.exception;

public class UserException extends RuntimeException {

    public UserException(String message) {
        super(message);
    }

    public UserException(String message, Throwable cause) {
        super(message, cause);
    }
}
 

특징:

  •  RuntimeException  을 상속 → 체크 예외처럼 매번 throws 선언 강제하지 않음
  • message는 사용자에게 그대로 보여줄 전용 메시지로 사용
  • 필요하면 cause를 함께 넘겨 내부 디버깅에 활용할 수 있음

2-3. 어디서 UserException을 던지는가?

프로젝트 전반에서 "사용자가 잘못 입력하면 발생할 만한 곳"은 전부 UserException의 후보이다.

대표적으로:

  •  ArgumentParser 
    • 필수 옵션 누락
    • 옵션 이름 오타
    •  --inner-classes  값이 true/false가 아닌 경우
  •  JsonValidator 
    • 입력 파일이 없음
    • 디렉터리를 파일로 건넴
    • JSON 파싱 실패
    • 루트가 객체/배열이 아닌 경우
  •  FileWriter 
    • 출력 디렉터리 권한 없음
    • 파일 쓰기 실패

예를 들어 FileWriter에서는 이런 식으로 예외를 래핑할 수 있다.

public void write(Path outDir, String className, String content) {
    Path file = outDir.resolve(className + ".java");
    try {
        Files.writeString(file, content, StandardCharsets.UTF_8);
    } catch (IOException e) {
        throw new UserException(
            "[ERROR] Java 파일을 저장하는 중 오류가 발생했습니다: " + file, e
        );
    }
}
 

여기서 IOException은 사용자가 경로/권한을 바꾸면 해결될 수도 있는 문제이므로
UserException으로 감싸는 쪽에 가깝다.


3. InternalException — "이건 버그다, 사용자가 아니라 개발자의 책임"

3-1. 개념

InternalException은 설계 상 "여기까지 오면 안 된다" 수준의 문제에 사용한다.

 

예를 들면:

  •  SchemaPrimitive  에 정의되지 않은 PKind가 들어왔다
  •  SchemaUnion  에서 처리해야 할 케이스를 빼먹었다
  • TypeInferencer가 지원하지 않는 SchemaNode 타입을 만났다
  • ModelGraph에서 null이 되어선 안 되는 값이 null이 됐다

이건 사용자가 뭘 잘못한 게 아니라,
내가(개발자)가 코드를 잘못 짠 것이다.

 

따라서:

  • 사용자에게는 "내부 오류입니다"라고 알려야 하고
  • 동시에 스택 트레이스도 출력해서 개발자가 디버깅할 수 있어야 한다.

3-2. 구현 형태

InternalException도 UserException과 비슷하게 구현하되, 의미만 다르다.

package org.example.exception;

public class InternalException extends RuntimeException {

    public InternalException(String message) {
        super(message);
    }

    public InternalException(String message, Throwable cause) {
        super(message, cause);
    }
}
 

핵심은 의미적 차이다:

  • UserException: 사용자 입력 잘못
  • InternalException: 프로그램 내부 상태/설계 오류

3-3. 어디서 InternalException을 던지는가?

예를 들어 TypeInferencer가 SchemaNode를 처리할 때:

TypeRef infer(SchemaNode node) {
    if (node instanceof SchemaPrimitive primitive) {
        // ...
    } else if (node instanceof SchemaObject object) {
        // ...
    } else if (node instanceof SchemaArray array) {
        // ...
    } else if (node instanceof SchemaUnion union) {
        // ...
    } else {
        // 여기 오면 안 된다 = 새로운 서브타입이 추가됐는데 처리 안 한 것
        throw new InternalException(
            "[INTERNAL] 지원하지 않는 SchemaNode 타입입니다: " + node.getClass()
        );
    }
}
 

이런 예외는 사용자에게 책임을 돌릴 수 없다.
"새로운 타입이 추가됐는데, 내가 처리 코드를 안 쓴 것"일 뿐이다.

이렇게 InternalException을 활용하면:

  • 개발 중에 놓친 케이스를 빨리 발견할 수 있고
  • 예외 메시지를 보고 "어디 부분이 설계 미스인지" 바로 알 수 있다.

4. main()에서의 예외 처리 전략

모든 예외 설계의 끝은  main()  에서 어떻게 처리하느냐로 귀결된다.

이 프로젝트에서는 대략 이런 구조를 가진다(의사코드):

 
public class Main {

    public static void main(String[] args) {
        try {
            // 1. CLI 인자 파싱
            ArgumentParser parser = new ArgumentParser();
            ParsedArguments parsed = parser.parse(args);

            // 2. JSON 파일 로드 및 검증
            JsonValidator.Result result =
                JsonValidator.validateAndLoad(parsed.getInputPath());

            // 3. JSON 구조 분석 → SchemaNode
            JsonAnalyzer analyzer = new JsonAnalyzer();
            SchemaNode rootSchema = analyzer.analyze(result.root());

            // 4. 타입 추론
            TypeInferencer inferencer = new TypeInferencer();
            // 타입 추론 + ModelGraph 구성
            ModelGraph graph = ModelGraph.from(rootSchema, parsed, inferencer);

            // 5. ClassSpec 리스트를 얻고, 각 클래스 생성
            ClassGenerator classGenerator = new ClassGenerator();
            CodeFormatter formatter = new CodeFormatter();
            FileWriter fileWriter = new FileWriter();

            for (ClassSpec spec : graph.getAllClasses()) {
                String raw = classGenerator.generate(spec);
                String formatted = formatter.format(raw);
                fileWriter.write(parsed.getOutDirPath(), spec.className(), formatted);
            }

        } catch (UserException e) {
            System.err.println(e.getMessage());
            System.exit(1);
        } catch (InternalException e) {
            System.err.println("[INTERNAL ERROR] 예기치 못한 오류가 발생했습니다.");
            e.printStackTrace();
            System.exit(2);
        }
    }
}

여기서 중요한 포인트 3가지:

4-1. UserException → 메시지만 출력, exit code 1

  • 사용자 잘못이므로 message만 보여주면 충분
  • exit code 1은 "정상은 아니지만, 사용자가 입력을 고쳐서 다시 시도할 수 있음" 정도의 의미

예를 들어,

[ERROR] 입력 JSON 파일을 찾을 수 없습니다: input/not-exists.json
 

이 출력만 보고도 사용자는 "아, 경로가 잘못됐구나"를 알 수 있다.


4-2. InternalException → 안내 문구 + 스택 트레이스, exit code 2

  • 사용자에게는 "내부 오류입니다" 정도만 안내
  • 하지만  e.printStackTrace()  를 통해 개발자 입장에서는 디버깅 정보를 확인 가능
  • exit code 2는 “내부 버그”라는 의미로 구분

콘솔 출력 예:

[INTERNAL ERROR] 예기치 못한 오류가 발생했습니다.
org.example.exception.InternalException: [INTERNAL] 지원하지 않는 SchemaNode 타입입니다: class org.example.json.SchemaSomethingNew
    at org.example.json.TypeInferencer.infer(TypeInferencer.java:85)
    ...
 

이렇게 하면:

  • 사용자는 "내가 뭘 잘못한 건 아니구나"를 알 수 있고
  • 나는 스택 트레이스를 보고 어디를 고쳐야 할지 파악할 수 있다.

4-3. 왜 Exception을 전부 main()까지 올렸는가?

중간 계층에서 예외를 잡고 silent fail을 하면:

  • 어디서 터졌는지
  • 무슨 종류의 문제인지
  • 사용자에게 뭐라고 알려야 하는지

를 놓치기 쉽다.

 

그래서 설계 방향을 이렇게 잡았다:

  • 각 계층에서 "뜻이 있는 예외 타입(User/Internal)"을 던진다
  • 최종적으로 main()에서 종류에 따라 딱 두 갈래로 처리한다

이 덕분에:

  • 예외 흐름이 단순하고 예측 가능
  • exit code에 의미를 부여할 수 있음
  • 나중에 스크립트/CI에서 exit code를 기준으로 실패 종류를 구분하기도 쉬움

5. 예외 설계와 9가지 예외 테스트 시나리오의 연결

별도의 글(《실행 가이드 & 9가지 예외 테스트 시나리오》)에서 정리한

  1.  --input  생략
  2. 존재하지 않는 파일
  3. 디렉터리/읽을 수 없는 파일
  4. 깨진 JSON ( broken.json )
  5. 루트가 숫자인 JSON ( root_number.json )
  6. 루트가 배열인 JSON ( root_array.json )
  7.  --root-class  빠짐
  8.  --package  빠짐
  9.  --inner-classes  값이 true/false가 아닌 경우

이 케이스들은 모두 UserException 계열이다.

즉, 이 9가지 케이스를 명시적으로 테스트해봄으로써:

  • "사용자가 실수할 수 있는 부분"을 대부분 커버했는지
  • 모든 경우에 친절한 에러 메시지 + exit code 1로 떨어지는지

를 검증한 셈이다.


6. 이 예외 설계가 주는 장점 정리

  1. 책임 분리
    • 로직에서 예외를 던질 뿐, "프린트/종료 방식"은 main에서만 결정
    • 각 계층은 자기 도메인에만 집중 가능
  2. 사용자 경험
    • 사용자가 잘못한 경우와,
    • 도구 내부의 버그를 명확히 구분
      → "내가 뭘 잘못했지?"라는 불필요한 혼란 감소
  3. 디버깅 용이성
    • InternalException일 때만 스택 트레이스를 출력
    • 로그/콘솔에서 버그 지점을 빠르게 찾을 수 있음
  4. 테스트하기 좋은 구조
    • 특정 입력 → UserException 발생 예상
    • 잘못된 SchemaNode 타입 주입 → InternalException 발생 예상
      같은 테스트 케이스를 만들기 쉬움

7. 마무리 — 작은 도구에도 "예외 설계"는 중요하다

JSON DTO Converter는 규모가 큰 애플리케이션은 아니지만,
이 프로젝트를 통해 다음과 같은 것들을 직접 설계해볼 수 있었다.

  • 사용자 오류와 내부 버그를 예외 계층에서 분리하는 전략
  • main()에서 예외를 한 번에 처리하는 정책
  • exit code에 의미를 부여하여 CLI 도구답게 동작시키는 방식
  • 9가지 예외 시나리오를 직접 정의하고 테스트해보는 경험

단순히 "예외가 나면 터진다"가 아니라,

"예외가 나더라도, 사용자는 정확히 이유를 알고,
나는 내부 버그를 빠르게 추적할 수 있도록 설계하는 것"

 

이 이 프로젝트에서 예외를 설계할 때 가장 신경 썼던 부분이다.