CRUD 분석과 테이블 영향도 - Bottom-Up 분석의 힘

2026. 1. 18. 01:16·토스 러너스하이 2기/기술

"이 테이블 스키마 바꾸면 어떤 API가 영향받아?"
실무에서 이런 질문을 받았다. 기존 Top-Down 분석(URL→SQL)으로는 답할 수 없었다. 테이블에서 코드를 역추적하는 Bottom-Up 분석을 구현한 이야기.


3줄 요약

  • Top-Down(URL→SQL)과 Bottom-Up(테이블→코드)은 상호보완적이다
  • 역방향 인덱싱(Map<테이블명, List<접근정보>>)으로 O(1) 조회 가능하게 구현했다
  • GUI에서 드릴다운 UX는 CardLayout + 브레드크럼 패턴으로 해결한다

1. 들어가며

기술 1편부터 지금까지 Top-Down 분석을 구현했다:

Top-Down 분석:
URL → Controller → Service → DAO → SQL

"이 API는 어떤 테이블을 조회하나?"라는 질문에 완벽하게 답할 수 있었다.

그런데 실무에서 반대 방향 질문을 받았다.


실무 피드백

"DBMS 이관 프로젝트를 하는데, 특정 테이블에 CRUD하는 곳이 어디 있는지 찾는 기능이 필요해요."

시나리오:

  1. DBMS 이관: Oracle → PostgreSQL로 이관할 때, 양방향 동기화 대상 테이블 선정
  2. 스키마 변경: USER 테이블에 컬럼 추가하면, 어떤 API가 영향받나?
  3. 장애 분석: ORDER 테이블 데이터가 이상해. 어떤 코드가 INSERT 하지?

모두 테이블 → 코드 방향의 분석이 필요했다.


Top-Down vs Bottom-Up

관점 Top-Down Bottom-Up
방향 URL → SQL 테이블 → 코드
질문 "이 API는 뭘 하나?" "이 테이블은 어디서 쓰이나?"
시작점 API URL 테이블명
용도 API 영향도 분석 테이블 영향도 분석

비유: 전화번호부

분석 방향 비유
Top-Down 이름으로 전화번호 찾기
Bottom-Up 전화번호로 주인 찾기

둘 다 필요하다. 그래서 테이블 영향도 탭을 추가하기로 했다.


2. 역방향 인덱싱 설계

기존 데이터 구조

Top-Down 분석 결과는 트리 구조다:

FlowResult
└── List<FlowNode>  (각 API 엔드포인트)
    └── children: List<FlowNode>  (호출된 메서드들)
        └── sqlInfo: SqlInfo  (SQL 정보)
            └── tables: List<String>  (접근 테이블들)

이 구조에서 "USER 테이블에 접근하는 API는?"을 찾으려면:

// O(n×m): 모든 API의 모든 노드를 탐색해야 함
for (FlowNode flow : result.getFlows()) {       // n개 API
    for (FlowNode node : getAllNodes(flow)) {    // m개 노드
        if (node.getSqlInfo().getTables().contains("USER")) {
            // 찾음!
        }
    }
}

문제: O(n×m) 복잡도. 매번 전체 탐색은 비효율적이다.


역방향 인덱스 구축

// 역방향 인덱스: 테이블명 → 접근 정보 목록
Map<String, TableImpact> tableIndex = new HashMap<>();

// 테이블명으로 O(1) 조회 가능!
TableImpact impact = tableIndex.get("USER");
List<TableAccess> accesses = impact.getAccesses();
// USER 테이블에 접근하는 모든 정보가 바로 나옴

비유: 책의 색인(Index)

방식 비유 시간
매번 탐색 책 전체를 읽으며 키워드 찾기 O(n)
역방향 인덱스 뒤쪽 색인에서 페이지 번호 확인 O(1)

구현: buildTableIndex

public Map<String, TableImpact> buildTableIndex(FlowResult result) {
    Map<String, TableImpact> tableIndex = new HashMap<>();

    for (FlowNode flow : result.getFlows()) {
        String url = flow.getUrlMapping();
        String httpMethod = flow.getHttpMethod();

        // 트리 구조를 재귀 탐색하며 테이블 접근 정보 수집
        collectTableAccesses(flow, url, httpMethod, tableIndex);
    }

    return tableIndex;
}
private void collectTableAccesses(FlowNode node, String url, String httpMethod,
                                  Map<String, TableImpact> tableIndex) {
    // DAO 노드이고 SQL 정보가 있으면 테이블 접근 기록
    if (node.getClassType() == ClassType.DAO && node.hasSqlInfo()) {
        SqlInfo sqlInfo = node.getSqlInfo();
        List<String> tables = sqlInfo.getTables();

        for (String tableName : tables) {
            // 테이블별로 접근 정보 수집
            TableImpact impact = tableIndex.computeIfAbsent(
                tableName.toUpperCase(), TableImpact::new);

            impact.addAccess(new TableAccess(
                url,                    // /api/user
                httpMethod,             // GET
                node.getClassName(),    // UserDao
                node.getMethodName(),   // selectUser
                sqlInfo.getType(),      // SELECT
                sqlInfo.getSqlId(),     // user.selectUser
                sqlInfo.getFileName(),  // user-mapper.xml
                sqlInfo.getQuery()      // SELECT * FROM USER...
            ));
        }
    }

    // 자식 노드 재귀 처리
    for (FlowNode child : node.getChildren()) {
        collectTableAccesses(child, url, httpMethod, tableIndex);
    }
}

핵심: computeIfAbsent

// 이 한 줄이 핵심
TableImpact impact = tableIndex.computeIfAbsent(tableName, TableImpact::new);
  • 키가 없으면 새 객체 생성해서 넣음
  • 키가 있으면 기존 객체 반환
  • if (map.get(key) == null) { map.put(key, new Value()); } 패턴을 한 줄로

데이터 클래스

public static class TableImpact {
    private final String tableName;
    private final List<TableAccess> accesses = new ArrayList<>();

    public String getTableName() { return tableName; }
    public List<TableAccess> getAccesses() { return accesses; }
    public void addAccess(TableAccess access) { accesses.add(access); }

    // CRUD 타입별 접근 횟수
    public Map<SqlInfo.SqlType, Long> getCrudCounts() {
        Map<SqlInfo.SqlType, Long> counts = new HashMap<>();
        for (TableAccess access : accesses) {
            SqlInfo.SqlType type = access.getSqlType();
            counts.put(type, counts.getOrDefault(type, 0L) + 1);
        }
        return counts;
    }
}

public static class TableAccess {
    private final String url;           // /api/user
    private final String httpMethod;    // GET
    private final String className;     // UserDao
    private final String methodName;    // selectUser
    private final SqlInfo.SqlType sqlType;  // SELECT
    private final String sqlId;         // user.selectUser
    private final String xmlFileName;   // user-mapper.xml
    private final String query;         // SELECT * FROM USER...
}

3. GUI 구현: CardLayout과 브레드크럼

화면 구조

┌──────────────────────────────────────────────────────────────┐
│  [호출 흐름 탭]  [테이블 영향도 탭]                              │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  테이블 목록     │  테이블 상세 (CardLayout)                   │
│  ┌───────────┐ │  ┌─────────────────────────────────────┐   │
│  │ == 전체 == │ │  │ USER > 쿼리                         │   │  ← 브레드크럼
│  │ USER      │ │  ├─────────────────────────────────────┤   │
│  │ ORDER     │ │  │ Card 1: 접근 정보 테이블              │   │
│  │ PRODUCT   │ │  │ ┌────┬─────────┬─────────┬────────┐ │   │
│  │           │ │  │ │CRUD│ URL     │ XML파일  │ SQL ID │ │   │
│  │           │ │  │ ├────┼─────────┼─────────┼────────┤ │   │
│  └───────────┘ │  │ │ S  │ /api/.. │ user.xml│ select │ │   │
│                │  │ └────┴─────────┴─────────┴────────┘ │   │
│                │  ├─────────────────────────────────────┤   │
│                │  │ Card 2: 쿼리 상세 뷰                  │   │
│                │  │ SELECT * FROM USER WHERE id = #{id} │   │
│                │  └─────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘

CardLayout: 같은 공간에 다른 화면

public class TableImpactPanel extends JPanel {

    private static final String CARD_ACCESS_TABLE = "accessTable";
    private static final String CARD_QUERY_DETAIL = "queryDetail";

    private JPanel cardPanel;
    private CardLayout cardLayout;

    private void layoutComponents() {
        cardLayout = new CardLayout();
        cardPanel = new JPanel(cardLayout);

        // Card 1: 접근 정보 테이블
        JPanel accessTablePanel = createAccessTablePanel();
        cardPanel.add(accessTablePanel, CARD_ACCESS_TABLE);

        // Card 2: 쿼리 상세 뷰
        JPanel queryDetailPanel = createQueryDetailPanel();
        cardPanel.add(queryDetailPanel, CARD_QUERY_DETAIL);

        add(cardPanel, BorderLayout.CENTER);
    }

    // 카드 전환
    private void showQueryDetailView() {
        cardLayout.show(cardPanel, CARD_QUERY_DETAIL);
    }

    private void showAccessTableView() {
        cardLayout.show(cardPanel, CARD_ACCESS_TABLE);
    }
}

CardLayout 장점:

  • 같은 공간에 여러 화면을 겹쳐놓고 필요한 것만 표시
  • 탭 전환보다 자연스러운 드릴다운 UX
  • Swing 표준 레이아웃 (별도 라이브러리 불필요)

브레드크럼 패턴

브레드크럼 (Breadcrumb):
USER > 쿼리 전체
 │        └── 현재 위치
 └── 클릭하면 뒤로 이동
// 브레드크럼 컴포넌트
private JLabel breadcrumbTableLabel;   // 테이블명 (클릭 가능)
private JLabel breadcrumbSeparator;    // " > "
private JLabel breadcrumbQueryLabel;   // 쿼리 (현재 위치)

private void updateBreadcrumb(String tableName, String queryInfo) {
    breadcrumbTableLabel.setText(tableName);

    if (queryInfo != null) {
        breadcrumbSeparator.setVisible(true);
        breadcrumbQueryLabel.setVisible(true);
        breadcrumbQueryLabel.setText(queryInfo);
    } else {
        breadcrumbSeparator.setVisible(false);
        breadcrumbQueryLabel.setVisible(false);
    }
}
// 브레드크럼 테이블명 클릭 → 접근 정보 테이블로 돌아가기
breadcrumbTableLabel.addMouseListener(new MouseAdapter() {
    @Override
    public void mouseClicked(MouseEvent e) {
        if (breadcrumbSeparator.isVisible()) {
            showAccessTableView();
        }
    }

    @Override
    public void mouseEntered(MouseEvent e) {
        if (breadcrumbSeparator.isVisible()) {
            // 클릭 가능 표시 (색상 변경)
            breadcrumbTableLabel.setForeground(COLOR_LINK);
        }
    }

    @Override
    public void mouseExited(MouseEvent e) {
        breadcrumbTableLabel.setForeground(COLOR_HEADER);
    }
});

마우스 뒤로가기 버튼 지원

// 마우스 확장 버튼: 4 = 뒤로가기(XBUTTON1), 5 = 앞으로가기(XBUTTON2)
MouseAdapter mouseBackButtonAdapter = new MouseAdapter() {
    @Override
    public void mouseClicked(MouseEvent e) {
        // 버튼 4 = 마우스 뒤로가기 버튼
        if (e.getButton() == 4) {
            if (breadcrumbSeparator.isVisible()) {
                showAccessTableView();
            }
        }
    }
};

// 패널 전체에 리스너 추가
addMouseListener(mouseBackButtonAdapter);
cardPanel.addMouseListener(mouseBackButtonAdapter);
accessTable.addMouseListener(mouseBackButtonAdapter);
queryTextArea.addMouseListener(mouseBackButtonAdapter);

왜 버튼 4인가?

버튼 값 설명
왼쪽 1 BUTTON1
오른쪽 3 BUTTON3
가운데 (휠) 2 BUTTON2
뒤로가기 4 XBUTTON1 (마우스 옆면)
앞으로가기 5 XBUTTON2

웹 브라우저처럼 마우스 옆 버튼으로 뒤로 가기가 된다!


4. JTable 실시간 검색

요구사항

테이블 접근 정보가 수백 개일 수 있다. 원하는 URL이나 SQL ID를 빠르게 찾으려면 실시간 검색이 필요했다.

RowFilter 구현

private JTable accessTable;
private DefaultTableModel accessTableModel;
private TableRowSorter<DefaultTableModel> accessTableSorter;
private JTextField accessSearchField;

private void initializeComponents() {
    // 테이블 모델
    String[] columns = {"CRUD", "URL", "XML 파일", "SQL ID"};
    accessTableModel = new DefaultTableModel(columns, 0);
    accessTable = new JTable(accessTableModel);

    // 정렬 및 필터링 기능
    accessTableSorter = new TableRowSorter<>(accessTableModel);
    accessTable.setRowSorter(accessTableSorter);
}

private void setupEventHandlers() {
    // 검색 필드 실시간 필터링
    accessSearchField.getDocument().addDocumentListener(new DocumentListener() {
        @Override
        public void insertUpdate(DocumentEvent e) { filterAccessTable(); }
        @Override
        public void removeUpdate(DocumentEvent e) { filterAccessTable(); }
        @Override
        public void changedUpdate(DocumentEvent e) { filterAccessTable(); }
    });
}

private void filterAccessTable() {
    String searchText = accessSearchField.getText().trim().toLowerCase();

    if (searchText.isEmpty()) {
        accessTableSorter.setRowFilter(null);  // 필터 해제
    } else {
        // 모든 컬럼에서 검색 (대소문자 무시)
        String regex = "(?i)" + Pattern.quote(searchText);
        accessTableSorter.setRowFilter(RowFilter.regexFilter(regex));
    }
}

핵심: RowFilter.regexFilter()

// (?i) = 대소문자 무시
// Pattern.quote() = 특수문자 이스케이프
RowFilter.regexFilter("(?i)" + Pattern.quote(searchText));
  • "user" 입력 → USER, User, user 모두 매칭
  • "/api" 입력 → 슬래시도 정상 검색 (quote로 이스케이프)

5. 트러블슈팅

Issue #025: CRUD 필터 실시간 적용 불가

증상:

  • 엔드포인트 검색: 타이핑할 때마다 실시간 필터링 ✅
  • CRUD 체크박스: 변경 후 "분석 시작" 버튼을 다시 눌러야 반영 ❌

원인:
CRUD 필터가 분석 파라미터로 전달되어 재분석이 필요한 구조였다.

// 문제의 구조
analyzeButton.addActionListener(e -> {
    List<SqlType> crudFilter = getSelectedCrudTypes();
    FlowResult result = analyzer.analyze(path, crudFilter);  // 재분석!
});

해결: 원본 보존 + 필터 재적용

// 원본 결과 보존
private FlowResult originalResult;
private FlowResult filteredResult;

// CRUD 체크박스 변경 시
crudCheckbox.addActionListener(e -> {
    // 원본에서 필터링만 다시 적용 (재분석 X)
    filteredResult = analyzer.filterByCrudType(originalResult, getSelectedCrudTypes());
    displayResult(filteredResult);
});

재귀 필터링 구현:

public FlowResult filterByCrudType(FlowResult result, List<SqlType> types) {
    if (types == null || types.isEmpty() || types.size() == 4) {
        return result;  // 전체 선택 = 필터 없음
    }

    FlowResult filtered = new FlowResult();
    for (FlowNode flow : result.getFlows()) {
        FlowNode filteredFlow = filterNode(flow, types);
        if (filteredFlow != null) {
            filtered.addFlow(filteredFlow);
        }
    }
    return filtered;
}

private FlowNode filterNode(FlowNode node, List<SqlType> types) {
    // DAO 노드면서 SQL 타입이 필터에 없으면 제외
    if (node.hasSqlInfo()) {
        SqlType type = node.getSqlInfo().getType();
        if (!types.contains(type)) {
            return null;
        }
    }

    // 자식 노드 재귀 필터링
    FlowNode filtered = node.shallowCopy();
    for (FlowNode child : node.getChildren()) {
        FlowNode filteredChild = filterNode(child, types);
        if (filteredChild != null) {
            filtered.addChild(filteredChild);
        }
    }

    // 자식이 모두 필터링된 비-DAO 노드도 유지
    // (Controller→Service 경로를 보존하기 위해)
    return filtered;
}

Issue #026: 세션 저장/복원 미동작

증상:

  • 테이블 영향도 탭에서 테이블 선택 후 앱 종료 → 재시작 시 선택 상태 복원 안 됨

원인:
saveSession()이 분석 완료 후에만 호출됨. 탭 전환, 테이블 선택 시에는 저장되지 않음.

해결:

// 창 닫기 이벤트에서 전체 상태 저장
addWindowListener(new WindowAdapter() {
    @Override
    public void windowClosing(WindowEvent e) {
        sessionManager.saveSession(
            projectPath,
            currentResult,
            urlFilter,
            outputStyle,
            selectedTabIndex,        // 추가
            selectedEndpoint,        // 추가
            selectedTable,           // 추가
            tableDetailViewActive,   // 추가
            selectedQueryRowIndex    // 추가
        );
        System.exit(0);
    }
});

Issue #027: 스크롤 복원 타이밍 문제

증상:

  • 세션 복원 시 엔드포인트 선택은 되지만, 결과 패널이 해당 위치로 스크롤되지 않음

원인:
scrollToEndpoint()가 UI 렌더링 완료 전에 호출됨.

해결: invokeLater 중첩

private void restoreScrollPosition(String selectedEndpoint) {
    // 첫 번째 invokeLater: 현재 이벤트 큐 처리 후
    SwingUtilities.invokeLater(() -> {
        // 두 번째 invokeLater: 렌더링 완료 후
        SwingUtilities.invokeLater(() -> {
            scrollToEndpoint(selectedEndpoint);
        });
    });
}

왜 두 번 감싸나?

이벤트 큐:
[세션 복원] → [UI 업데이트] → [레이아웃 계산] → [스크롤]
                                              └── 여기서 해야 함!

invokeLater는 현재 이벤트가 끝난 후 실행을 예약한다. 두 번 감싸면 모든 렌더링이 끝난 후 실행된다.


Issue #028: "전체" 선택 시 빈 화면

증상:

  • 분석 완료 후 테이블 영향도 탭에서 "전체"가 선택되어 있지만 상세화면이 비어있음

원인: 메서드 호출 순서 문제

// 잘못된 순서
updateTableList(result);              // displayTableAccesses() 호출
tableImpactPanel.updateData(result);  // tableIndex 설정

// displayTableAccesses()가 호출될 때 tableIndex가 아직 null!

해결:

// 올바른 순서
tableImpactPanel.updateData(result);  // 먼저 tableIndex 설정
updateTableList(result);              // 그 다음 화면 업데이트

Issue #029: SQL 필터 변경 시 선택 상태 초기화

증상:

  • 테이블 선택 후 상세화면을 보고 있는 중
  • SQL 필터(SELECT/INSERT...) 변경 시 선택이 "전체"로 리셋됨

원인:
필터 변경 시 테이블 목록을 새로 생성하면서 기존 선택 상태를 저장하지 않음.

해결: 상태 저장/복원

private void applyFiltersAndRefresh() {
    // 1. 현재 상태 저장
    String currentSelectedTable = getSelectedTableName();
    boolean wasDetailViewActive = tableImpactPanel.isQueryDetailViewActive();
    int selectedQueryRow = tableImpactPanel.getSelectedQueryRowIndex();

    // 2. 필터 적용 및 새로고침
    filteredResult = analyzer.filterByCrudType(originalResult, getSelectedCrudTypes());
    updateTableList(filteredResult);

    // 3. 상태 복원
    if (currentSelectedTable != null) {
        selectTableByName(currentSelectedTable);
        if (wasDetailViewActive) {
            tableImpactPanel.restoreQueryView(selectedQueryRow);
        }
    }
}

6. 마치며

요약

기능 구현 방식
역방향 조회 Map<테이블명, TableImpact> 인덱스
드릴다운 UI CardLayout + 브레드크럼
실시간 검색 JTable RowFilter
뒤로가기 마우스 XBUTTON1 (버튼 4)
CRUD 필터 원본 보존 + 재귀 필터링

삽질에서 배운 점

  1. 양방향 분석: Top-Down과 Bottom-Up은 상호보완적. 둘 다 있어야 실무에서 유용하다.
  2. 인덱싱 = 시간 vs 공간 트레이드오프: 매번 O(n)으로 탐색하는 대신, O(n) 한 번으로 인덱스를 만들고 O(1)로 조회.
  3. CardLayout: 같은 공간에 여러 화면이 필요할 때 Swing 표준 솔루션. 드릴다운 UX에 적합.
  4. 브레드크럼 패턴: 현재 위치 표시 + 뒤로 가기. 계층 구조 탐색의 표준 UI 패턴.
  5. invokeLater 타이밍: Swing UI 업데이트는 비동기. 렌더링 완료 후 작업이 필요하면 invokeLater를 중첩.
  6. 상태 저장/복원: 사용자 액션(필터 변경 등)으로 화면이 초기화되면 안 된다. 현재 상태를 저장하고 복원하는 패턴 필수.

시리즈

  • 기술 1편: JavaParser로 Java 코드 분석하기
  • 기술 2편: 호출 흐름을 어떻게 따라가나? - 재귀와 백트래킹
  • 기술 3편: DAO에서 SQL까지 - XML 파싱으로 연결하기
  • 기술 4편: 정적 분석의 한계 - 해결할 수 없는 것들
  • 기술 5편: 분석 결과를 어떻게 보여줄까? - CLI 출력 구현기
  • 기술 6편: 분석 결과를 Excel로 정리하기 - Apache POI 활용기
  • 기술 7편: Swing으로 모던한 GUI 만들기 - CLI를 넘어서
  • 기술 8편: jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기
  • 기술 9편: Gson으로 세션 영속성 구현하기 - 앱을 닫아도 분석 결과가 살아있다
  • 기술 10편: CRUD 분석과 테이블 영향도 - Bottom-Up 분석의 힘 (현재 글)

'토스 러너스하이 2기 > 기술' 카테고리의 다른 글

Gson으로 세션 영속성 구현하기 - 앱을 닫아도 분석 결과가 살아있다  (0) 2026.01.17
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
'토스 러너스하이 2기/기술' 카테고리의 다른 글
  • Gson으로 세션 영속성 구현하기 - 앱을 닫아도 분석 결과가 살아있다
  • jpackage로 배포하기 - 폐쇄망에서 Java 없이 실행하기
  • Swing으로 모던한 GUI 만들기 - CLI를 넘어서
  • 분석 결과를 Excel로 정리하기 - Apache POI 활용기
KBroJ9210
KBroJ9210
  • KBroJ9210
    개발일기
    KBroJ9210
  • 전체
    오늘
    어제
    • 분류 전체보기 (25)
      • 토스 러너스하이 2기 (11)
        • 회고 (1)
        • 기술 (10)
      • Loopers (9)
        • 테크니컬 라이팅 (6)
        • WIL(What I Learned) (3)
      • 두리두리넋두리 (5)
        • 개발일기 (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
KBroJ9210
CRUD 분석과 테이블 영향도 - Bottom-Up 분석의 힘
상단으로
목차

    티스토리툴바