JSON DTO Converter (4) - Exception 분석
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가지 예외 테스트 시나리오》)에서 정리한
- --input 생략
- 존재하지 않는 파일
- 디렉터리/읽을 수 없는 파일
- 깨진 JSON ( broken.json )
- 루트가 숫자인 JSON ( root_number.json )
- 루트가 배열인 JSON ( root_array.json )
- --root-class 빠짐
- --package 빠짐
- --inner-classes 값이 true/false가 아닌 경우
이 케이스들은 모두 UserException 계열이다.
즉, 이 9가지 케이스를 명시적으로 테스트해봄으로써:
- "사용자가 실수할 수 있는 부분"을 대부분 커버했는지
- 모든 경우에 친절한 에러 메시지 + exit code 1로 떨어지는지
를 검증한 셈이다.
6. 이 예외 설계가 주는 장점 정리
- 책임 분리
- 로직에서 예외를 던질 뿐, "프린트/종료 방식"은 main에서만 결정
- 각 계층은 자기 도메인에만 집중 가능
- 사용자 경험
- 사용자가 잘못한 경우와,
- 도구 내부의 버그를 명확히 구분
→ "내가 뭘 잘못했지?"라는 불필요한 혼란 감소
- 디버깅 용이성
- InternalException일 때만 스택 트레이스를 출력
- 로그/콘솔에서 버그 지점을 빠르게 찾을 수 있음
- 테스트하기 좋은 구조
- 특정 입력 → UserException 발생 예상
- 잘못된 SchemaNode 타입 주입 → InternalException 발생 예상
같은 테스트 케이스를 만들기 쉬움
7. 마무리 — 작은 도구에도 "예외 설계"는 중요하다
JSON DTO Converter는 규모가 큰 애플리케이션은 아니지만,
이 프로젝트를 통해 다음과 같은 것들을 직접 설계해볼 수 있었다.
- 사용자 오류와 내부 버그를 예외 계층에서 분리하는 전략
- main()에서 예외를 한 번에 처리하는 정책
- exit code에 의미를 부여하여 CLI 도구답게 동작시키는 방식
- 9가지 예외 시나리오를 직접 정의하고 테스트해보는 경험
단순히 "예외가 나면 터진다"가 아니라,
"예외가 나더라도, 사용자는 정확히 이유를 알고,
나는 내부 버그를 빠르게 추적할 수 있도록 설계하는 것"
이 이 프로젝트에서 예외를 설계할 때 가장 신경 썼던 부분이다.