"분석 완료! 결과 확인! ...앱 종료. 다시 실행하면? 처음부터 다시?"
그게 싫었다. 분석 결과를 저장해서 다시 열어도 그대로 보고 싶었다.
Gson으로 세션 영속성을 구현하면서 배운 것들.
3줄 요약
- Gson은 한 줄로 Java 객체를 JSON으로 변환하는 가벼운 라이브러리다
LocalDateTime같은 Java 8+ 타입은 TypeAdapter를 직접 구현해야 한다- 저장 방식을 통합하지 않으면 이중 저장이라는 기술 부채가 생긴다
1. 들어가며
이전 글에서 Swing GUI를 완성했다. 폴더 선택, 분석 실행, 결과 확인까지 깔끔하게 동작했다.
그런데 하나 빠진 게 있었다.
사용 시나리오:
1. 앱 실행
2. 프로젝트 폴더 선택
3. 분석 실행 (30초 소요)
4. 결과 확인
5. 앱 종료
6. 다음 날, 앱 다시 실행
7. "분석 결과가 없습니다" ← 어제 분석한 건?!
분석에 30초가 걸리는 프로젝트라면? 매번 다시 분석하는 건 말이 안 된다.
비유: 게임 세이브
| 구분 | 세이브 없는 게임 | 세이브 있는 게임 |
|---|---|---|
| 종료 후 | 처음부터 다시 | 이어하기 |
| 사용자 경험 | 절망 | 편안 |
세션 영속성은 게임의 세이브 기능과 같다. 앱을 닫아도 상태가 유지되어야 한다.
뭘 저장해야 하나?
세션에 저장해야 할 데이터를 정리했다:
| 항목 | 설명 | 예시 |
|---|---|---|
| projectPath | 마지막 분석한 프로젝트 경로 | C:\workspace\legacy-app |
| analyzedAt | 분석 시간 | 2025-12-30T14:30:00 |
| flowResult | 분석 결과 (핵심!) | Controller→Service→DAO 호출 트리 |
| recentPaths | 최근 열었던 프로젝트 목록 | 최대 10개 |
| urlFilter | URL 필터 값 | /api/* |
| outputStyle | 출력 스타일 | compact, normal, detailed |
핵심은 flowResult다. 이 객체 하나에 전체 분석 결과가 담겨있다.
2. 라이브러리 선택: Gson vs Jackson
Java에서 JSON 직렬화 라이브러리는 크게 두 가지가 있다:
| 항목 | Gson | Jackson |
|---|---|---|
| 개발사 | FasterXML | |
| 크기 | ~300KB | ~1.5MB (core만) |
| 설정 | 거의 없음 | 많음 (ObjectMapper 설정) |
| 성능 | 보통 | 빠름 |
| 학습 곡선 | 낮음 | 높음 |
Gson을 선택한 이유:
- 목적에 적합: 세션 저장은 고성능이 필요 없다. 앱 시작/종료 시 한 번씩만 호출.
- 단순함: 설정 없이 바로 사용 가능.
new Gson().toJson(obj)끝. - 가벼움: 300KB면 충분. Jackson은 의존성이 복잡하다.
// build.gradle
dependencies {
implementation 'com.google.code.gson:gson:2.10.1'
}
3. 기본 사용법
직렬화 (객체 → JSON)
Gson gson = new Gson();
// 간단한 객체
User user = new User("Kim", 25);
String json = gson.toJson(user);
// {"name":"Kim","age":25}
// 컬렉션도 가능
List<String> paths = Arrays.asList("/path1", "/path2");
String pathsJson = gson.toJson(paths);
// ["/path1","/path2"]
역직렬화 (JSON → 객체)
String json = "{\"name\":\"Kim\",\"age\":25}";
User user = gson.fromJson(json, User.class);
// user.getName() → "Kim"
정말 간단하다. 한 줄이면 끝.
보기 좋게 출력 (PrettyPrinting)
// 기본: 한 줄로 압축
{"projectPath":"/path","analyzedAt":"2025-12-30T14:30:00"}
// PrettyPrinting 적용
Gson gson = new GsonBuilder()
.setPrettyPrinting()
.create();
{
"projectPath": "/path",
"analyzedAt": "2025-12-30T14:30:00",
"flowResult": {
"flows": [...]
}
}
세션 파일을 텍스트 에디터로 열어볼 때 보기 좋다. 디버깅에 필수.
4. 트러블슈팅: LocalDateTime 직렬화 문제
문제 발생
세션 데이터에 분석 시간을 저장하려고 했다:
public class SessionData {
private LocalDateTime analyzedAt; // 분석 시간
// ...
}
저장은 됐는데... 불러올 때 에러가 발생했다.
java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING
원인: Gson은 Java 8 날짜를 모른다
Gson은 Java 8 이전에 만들어졌다. LocalDateTime, LocalDate 같은 Java 8 날짜 API를 기본 지원하지 않는다.
// Gson이 LocalDateTime을 직렬화하면
{
"analyzedAt": {
"date": { "year": 2025, "month": 12, "day": 30 },
"time": { "hour": 14, "minute": 30, ... }
}
}
// 복잡한 중첩 객체로 변환됨!
역직렬화할 때 이 구조를 다시 LocalDateTime으로 복원하지 못한다.
해결: TypeAdapter 구현
private static class LocalDateTimeAdapter extends TypeAdapter<LocalDateTime> {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@Override
public void write(JsonWriter out, LocalDateTime value) throws IOException {
if (value == null) {
out.nullValue();
} else {
out.value(FORMATTER.format(value)); // "2025-12-30T14:30:00"
}
}
@Override
public LocalDateTime read(JsonReader in) throws IOException {
String value = in.nextString();
if (value == null || value.isEmpty()) {
return null;
}
return LocalDateTime.parse(value, FORMATTER);
}
}
핵심 아이디어:
- 직렬화:
LocalDateTime→ ISO 문자열 ("2025-12-30T14:30:00") - 역직렬화: ISO 문자열 →
LocalDateTime.parse()
Gson gson = new GsonBuilder()
.setPrettyPrinting()
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
.create();
이제 깔끔하게 저장된다:
{
"projectPath": "/workspace/legacy-app",
"analyzedAt": "2025-12-30T14:30:00",
"flowResult": { ... }
}
5. 저장 경로 설계
어디에 저장할까?
| 위치 | 장점 | 단점 |
|---|---|---|
설치 폴더 (Program Files) |
앱과 함께 | 권한 문제, UAC |
| 현재 작업 폴더 | 간단 | 어디서 실행하냐에 따라 달라짐 |
| 사용자 홈 폴더 | 권한 없음, 일관됨 | 직접 찾아야 함 |
사용자 홈 폴더를 선택했다:
// ~/.code-flow-tracer/session.json
private static final Path SESSION_DIR = Paths.get(
System.getProperty("user.home"), ".code-flow-tracer");
private static final Path SESSION_FILE = SESSION_DIR.resolve("session.json");
왜 홈 폴더인가?
- 권한 문제 없음:
Program Files는 관리자 권한 필요 - 크로스 플랫폼: Windows, macOS, Linux 모두
user.home사용 가능 - 관례:
.config/,.local/,.앱이름/형태가 일반적
Windows: C:\Users\사용자\.code-flow-tracer\session.json
macOS: /Users/사용자/.code-flow-tracer/session.json
Linux: /home/사용자/.code-flow-tracer/session.json
6. SessionManager 구현
전체 구조
public class SessionManager {
private static final Path SESSION_DIR = Paths.get(
System.getProperty("user.home"), ".code-flow-tracer");
private static final Path SESSION_FILE = SESSION_DIR.resolve("session.json");
private final Gson gson;
public SessionManager() {
this.gson = new GsonBuilder()
.setPrettyPrinting()
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
.create();
}
// 세션 저장
public boolean saveSession(SessionData data) { ... }
// 세션 로드 (분석 결과 포함)
public SessionData loadSession() { ... }
// 설정만 로드 (분석 결과 없어도 됨)
public SessionData loadSettings() { ... }
}
저장 메서드
public boolean saveSession(SessionData data) {
if (data == null) {
log.warn("세션 데이터가 null입니다.");
return false;
}
try {
// 디렉토리 생성 (없으면)
if (!Files.exists(SESSION_DIR)) {
Files.createDirectories(SESSION_DIR);
log.info("세션 디렉토리 생성: {}", SESSION_DIR);
}
// JSON으로 직렬화 및 저장
String json = gson.toJson(data);
Files.writeString(SESSION_FILE, json, StandardCharsets.UTF_8);
log.info("세션 저장 완료: {} ({} flows)",
SESSION_FILE,
data.getFlowResult() != null ? data.getFlowResult().getFlows().size() : 0);
return true;
} catch (IOException e) {
log.error("세션 저장 실패: {}", e.getMessage(), e);
return false;
}
}
포인트:
- 디렉토리가 없으면 자동 생성 (
createDirectories) - UTF-8 인코딩 명시 (한글 경로 대비)
- 실패해도 앱이 죽지 않게 try-catch
로드 메서드
public SessionData loadSession() {
if (!Files.exists(SESSION_FILE)) {
log.debug("세션 파일 없음: {}", SESSION_FILE);
return null;
}
try {
String json = Files.readString(SESSION_FILE, StandardCharsets.UTF_8);
SessionData data = gson.fromJson(json, SessionData.class);
if (data != null && data.isValid()) {
log.info("세션 로드 완료: {} ({} flows)",
data.getProjectPath(),
data.getFlowResult().getFlows().size());
return data;
} else {
log.warn("유효하지 않은 세션 데이터");
return null;
}
} catch (Exception e) {
log.error("세션 로드 실패: {}", e.getMessage(), e);
return null;
}
}
7. GUI에서 세션 사용
앱 시작 시 복원
public class MainFrame extends JFrame {
private SessionManager sessionManager = new SessionManager();
private void initializeSession() {
// 세션 복원 시도
SessionData session = sessionManager.loadSession();
if (session != null && session.isValid()) {
// 이전 분석 결과 복원
currentResult = session.getFlowResult();
projectPathField.setText(session.getProjectPath());
// 필터 설정 복원
if (session.getUrlFilter() != null) {
urlFilterField.setText(session.getUrlFilter());
}
// 결과 화면 표시
displayResult(currentResult);
log.info("세션 복원: {} ({} flows)",
session.getProjectPath(),
currentResult.getFlows().size());
}
}
}
분석 완료 시 저장
private void onAnalysisComplete(FlowResult result) {
currentResult = result;
displayResult(result);
// 세션 저장
sessionManager.saveSession(
projectPathField.getText(),
result,
urlFilterField.getText(),
outputStyleCombo.getSelectedItem().toString()
);
}
앱 종료 시 저장
// 창 닫기 이벤트에서 저장
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
// 현재 상태 저장
saveCurrentSession();
System.exit(0);
}
});
8. Issue #020: 이중 저장 문제
발견: 테스트 중 이상한 점
테스트 코드를 작성하다가 발견했다:
@Test
void 설정_저장_테스트() {
// Registry에서 읽기
String urlFilter1 = prefs.get("urlFilter", "");
// JSON에서 읽기
SessionData session = sessionManager.loadSession();
String urlFilter2 = session.getUrlFilter();
// 둘 다 같은 값이 있다?!
}
원인: 점진적 기능 추가의 부작용
┌─────────────────────────────────────────────────────────────┐
│ 설정 저장 구조 (문제) │
├─────────────────────────────────────────────────────────────┤
│ Registry (Preferences API) │ JSON 파일 │
│ • 최근 경로 │ • 분석 결과 │
│ • URL 필터 ◄─────────────────┼──► URL 필터 (중복!) │
│ • 출력 스타일 ◄───────────────┼──► 출력 스타일 (중복!) │
└─────────────────────────────────────────────────────────────┘
왜 이렇게 됐나?
- Session 13: GUI 구현 시 설정 저장 필요 → Preferences API 선택 (Java 표준)
- Session 18: 분석 결과 저장 필요 → Gson JSON 추가
- 문제: Session 18에서 기존 Preferences 코드를 제거하지 않고 JSON에도 같은 필드 추가
기능은 동작했지만, 기술 부채가 생겼다.
해결: JSON 단일 저장으로 통합
의사결정 과정:
- "얼마나 걸릴까?" → 코드 훑어보니 30분이면 될 것 같다
- "지금 할까, 나중에 할까?" → 지금 하자 (컨텍스트가 머릿속에 있을 때)
비교:
| 항목 | Registry (Preferences) | JSON 파일 |
|---|---|---|
| 플랫폼 | Windows만 레지스트리, 다른 OS는 파일 | 모든 OS 동일 |
| 복잡한 객체 | ❌ 문자열/숫자만 | ✅ 객체 직렬화 가능 |
| 백업/이동 | ❌ regedit 필요 | ✅ 파일 복사 |
| 디버깅 | ❌ 레지스트리 편집기 | ✅ 텍스트 에디터로 바로 확인 |
결론: JSON이 모든 면에서 우수
// 통합 전
Preferences prefs = Preferences.userNodeForPackage(MainFrame.class);
prefs.put("urlFilter", filter); // Registry
sessionManager.saveSession(...); // JSON
// 통합 후
sessionManager.saveSession(...); // JSON만 사용
// Preferences 코드 전부 삭제
배운 점
- "나중에" vs "지금": 어렵지 않으면 지금 하자. 컨텍스트 손실 방지.
- 이중 저장 = 기술 부채: 동작해도 문제. 언젠가 동기화 버그로 돌아온다.
- 설계 단계에서 통일: 새 기능 추가 시 기존 구조와의 충돌 검토 필요.
9. 최종 세션 데이터 구조
public class SessionData {
private String projectPath; // 분석한 프로젝트 경로
private LocalDateTime analyzedAt; // 분석 시간
private FlowResult flowResult; // 분석 결과 (핵심!)
private String urlFilter; // URL 필터
private String outputStyle; // 출력 스타일
private List<String> recentPaths; // 최근 프로젝트 경로 (최대 10개)
private String endpointFilter; // 엔드포인트 검색 필터
private List<String> sqlTypeFilter; // SQL 타입 필터
private int selectedTabIndex; // 선택된 탭 인덱스
private String selectedEndpoint; // 선택된 엔드포인트
private String selectedTable; // 선택된 테이블명
// 유효한 세션인지 확인
public boolean isValid() {
return projectPath != null && !projectPath.isEmpty()
&& flowResult != null
&& flowResult.getFlows() != null;
}
// 최근 경로 추가 (중복 제거, 최대 10개)
public void addRecentPath(String path) {
if (recentPaths == null) {
recentPaths = new ArrayList<>();
}
recentPaths.remove(path); // 이미 있으면 제거
recentPaths.add(0, path); // 맨 앞에 추가
while (recentPaths.size() > 10) {
recentPaths.remove(recentPaths.size() - 1);
}
}
}
포인트:
isValid(): 분석 결과가 있어야 유효한 세션addRecentPath(): MRU(Most Recently Used) 패턴
10. 마치며
요약
| 문제 | 해결 |
|---|---|
| 앱 종료 시 분석 결과 소실 | Gson JSON으로 세션 저장 |
| LocalDateTime 직렬화 실패 | TypeAdapter 커스텀 구현 |
| 저장 경로 권한 문제 | 사용자 홈 폴더 (~/.code-flow-tracer/) |
| 이중 저장 (Registry + JSON) | JSON 단일 저장으로 통합 |
삽질에서 배운 점
- 라이브러리 선택은 목적에 맞게: 고성능이 필요 없으면 단순한 게 좋다. Jackson보다 Gson.
- Java 8+ 날짜 타입 주의:
LocalDateTime,LocalDate는 대부분의 JSON 라이브러리에서 기본 지원 안 함. TypeAdapter 필요. - 저장 경로는 홈 폴더:
Program Files는 권한 문제, 현재 폴더는 일관성 문제. 홈 폴더가 정답. - 기술 부채는 빨리 갚자: "동작하니까 괜찮아"는 위험. 발견 즉시 정리하는 게 나중보다 낫다.
다음 글 예고
다음 글에서는 CRUD 분석과 테이블 영향도를 다룬다. "이 테이블을 수정하면 어떤 API가 영향받지?"라는 질문에 답하기 위해 Bottom-Up 분석을 구현한 이야기다.
시리즈
- 기술 1편: JavaParser로 Java 코드 분석하기
- 기술 2편: 호출 흐름을 어떻게 따라가나? - 재귀와 백트래킹
- 기술 3편: DAO에서 SQL까지 - XML 파싱으로 연결하기
- 기술 4편: 정적 분석의 한계 - 해결할 수 없는 것들
- 기술 5편: 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기
- 기술 6편: 분석 결과를 Excel로 정리하기 - Apache POI 활용기
- 기술 7편: Swing으로 모던한 GUI 만들기 - CLI를 넘어서
- 기술 8편: jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기
- 기술 9편: Gson으로 세션 영속성 구현하기 - 앱을 닫아도 분석 결과가 살아있다 (현재 글)
'토스 러너스하이 2기 > 기술' 카테고리의 다른 글
| CRUD 분석과 테이블 영향도 - Bottom-Up 분석의 힘 (0) | 2026.01.18 |
|---|---|
| jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기 (0) | 2026.01.12 |
| Swing으로 모던한 GUI 만들기 - CLI를 넘어서 (1) | 2026.01.12 |
| 분석 결과를 Excel로 정리하기 - Apache POI 활용기 (0) | 2026.01.10 |
| 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기 (1) | 2026.01.07 |