계층형 CTE 한 줄이 주석 처리된 코드 세 줄을 살린다

2026. 3. 8. 14:10·dev.log

개요

계층 구조 설계(인접 리스트 + CTE)를 공부하던 중, 문득 이런 생각이 들었습니다.

 

"우리 사내 프로젝트의 부서 엔티티는 어떻게 설계돼 있더라...?"

 

궁금해서 바로 열어봤고, 공부한 내용을 바탕으로 들여다보니 이렇게 바꾸면 더 좋겠다 싶은 부분들이 눈에 들어왔습니다. 익숙하게 봐왔던 코드인데, 방금 공부한 내용으로 다시 읽으니 새롭게 보이는 것들이 있었습니다.

 

"이 엔티티도 계층 구조로 설계할 수 있지 않을까?"

 

잘못 만들어진 코드라는 것이 아니라, "이렇게 했으면 더 좋았을 것 같다" 는 회고의 기록입니다.

현재 엔티티

@Entity
@Table(name = "tb_department")
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "parent_code")
    private String parentCode;  // 👈 String 코드로 부모를 참조

    @Column(name = "code", nullable = false, unique = true)
    private String code;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "serial_number")
    private Long serialNumber;

    // ... 생략
}

주석에서 발견한 흔적들

엔티티뿐 아니라 서비스 레이어 코드에서도 흥미로운 단서를 발견했습니다.

@Transactional(readOnly = true)
public List<DepartmentsTree> getAllDepartmentsNames() {
    return departmentService.getDepartments()
        .stream().map(DepartmentsTree::new)
        .toList();

// 👈 주석 처리된 트리 조립 시도
//  return departmentService.getDepartmentsTree(departments);
}

public record DepartmentsTree(
    Long departmentId,
    String code,
    String name,
    String parentCode
//  List<DepartmentsTree> children  // 👈 주석 처리된 children
) { ... }

주석 처리된 두 줄이 이 코드의 맥락을 말해줍니다.

 

List<DepartmentsTree> children 주석 — 트리 구조를 시도했던 흔적입니다. children을 넣으면 재귀 구조가 되는데, 엔티티에 children이 없으니 서비스 레이어에서 직접 조립해야 했고, 그게 잘 안 풀려서 결국 빠진 것 같습니다.

getDepartmentsTree() 주석 — 트리 조립 메서드를 별도로 만들었지만 호출이 막혔습니다. 지금도 서비스 어딘가에 그 메서드가 남아 있을 것입니다.

이름은 DepartmentsTree인데 실제론 평탄 리스트입니다. 트리로 응답하고 싶었던 의도가 분명히 있었는데, 결국 parentCode를 내려주고 프론트에서 조립하는 구조로 타협된 것입니다.

돌이켜보면 이렇습니다. 트리를 만들고 싶었는데, 엔티티에 children도 없고 CTE도 몰랐으니 막혔던 것입니다.

개선해 보고 싶은 것들

1. parentCode가 String이다

제가 공부한 가이드에서는 아래 코드처럼 parent_id를 @ManyToOne으로 자기 참조하는 패턴을 권장합니다.

// 가이드의 패턴
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;

현재 엔티티는 부모를 String parentCode로 참조하고 있습니다. 이 구조에는 몇 가지 아쉬운 점이 있습니다.

 

1. JPA가 관계를 모릅니다. 부모 정보가 필요할 때마다 parentCode로 별도 조회가 필요하고, 지연 로딩이나 페치 조인 같은 JPA의 관계 최적화를 전혀 활용할 수 없습니다.

2. DB 무결성이 보장되지 않습니다. 외래키 제약이 없으니 부모 부서가 삭제돼도 자식 레코드가 그대로 남는 고아 데이터가 생길 수 있습니다.

3. 조인 성능이 떨어집니다. CTE나 조인 쿼리에서 문자열로 비교하게 되는데, PK 기반 정수 비교보다 인덱스 효율이 낮습니다.

 

@ManyToOne으로 바꾸면 이 세 가지가 한 번에 해결됩니다. 다만 현재 프로젝트에서 code는 비즈니스 식별자로 여러 곳에서 쓰이고 있어서 당장 parentCode를 제거하기보다는 둘을 함께 들고 가는 방향이 현실적입니다.

 

해보고 싶은 것:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Department parent;

parentCode는 비즈니스 식별자로 남기고, JPA 관계는 id 기반으로 별도로 맺는 방향도 고려해볼 수 있습니다.

2. children이 없다

@Builder.Default
@OneToMany(mappedBy = "parent", orderBy = "serialNumber ASC")
private List<Department> children = new ArrayList<>();

실제로 엔티티에 children이 없다 보니, DTO의 children도 주석 처리된 채로 남아있었습니다. 조직도를 그리려면 서비스 레이어에서 직접 트리를 조립해야 했고, 결국 그것도 주석 처리됐습니다.

@OneToMany 자기 참조를 추가하면 이 문제의 실마리가 풀립니다. 단, 가이드에서 경고한 것처럼 N+1 문제를 조심해야 합니다.

FETCH JOIN으로는 안 되나요?

FETCH JOIN은 바로 아래 자식까지만 1번 쿼리로 가져올 수 있습니다. 손자 노드부터는 다시 N+1이 터지고, JPQL은 재귀를 지원하지 않아 깊이를 늘릴 수도 없습니다.

전체 트리 조회 시에는 반드시 Native CTE를 사용해야 합니다. (N+1 문제)
children을 Lazy로 설정하고, 반복 탐색은 하지 않는 것이 원칙입니다.

3. 자손/조상 개별 조회 쿼리도 없다

조직도 전체 출력 외에, 특정 부서 클릭 시 하위 부서 전체 또는 브레드크럼(어느 팀 > 어느 파트) 표시가 필요하다면 별도 쿼리가 필요합니다.

 

해보고 싶은 것:

// 특정 부서의 전체 하위 부서 조회
@Query(value = """
    WITH RECURSIVE sub_departments AS (
        SELECT id, code, name, parent_code, serial_number, 1 AS depth
        FROM tb_department WHERE id = :id
        UNION ALL
        SELECT d.id, d.code, d.name, d.parent_code, d.serial_number, s.depth + 1
        FROM tb_department d
        JOIN sub_departments s ON d.parent_code = s.code
    )
    SELECT * FROM sub_departments
    ORDER BY depth, serial_number
    """, nativeQuery = true)
List<Department> findAllSubDepartments(@Param("id") Long id);

// 브레드크럼용 조상 전체 조회 [EX] 경영지원본부 > IT솔루션부 > IT운영팀
@Query(value = """
    WITH RECURSIVE ancestors AS (
        SELECT id, code, name, parent_code, 1 AS depth
        FROM tb_department WHERE id = :id
        UNION ALL
        SELECT d.id, d.code, d.name, d.parent_code, a.depth + 1
        FROM tb_department d
        JOIN ancestors a ON d.code = a.parent_code
    )
    SELECT * FROM ancestors ORDER BY depth DESC
    """, nativeQuery = true)
List<Department> findAllAncestors(@Param("id") Long id);

그래서 DTO는 어떻게 바뀌어야 할까

현재 DepartmentsTree는 parentCode만 들고 있어서 프론트가 직접 트리를 조립해야 합니다. 서버에서 트리 구조로 응답한다면 이렇게 바뀔 수 있습니다.

// AS-IS : 평탄(Flat) 리스트, 프론트에서 parentCode 보고 트리 조립
public record DepartmentsTree(
    Long departmentId,
    String code,
    String name,
    String parentCode
) { }

// TO-BE : 해보고 싶은 것 (개선) — 서버에서 트리 구조로 응답
public record DepartmentsTree(
    Long departmentId,
    String code,
    String name,
    List<DepartmentsTree> children
) {
    public DepartmentsTree(Department department) {
        this(
            department.getId(),
            department.getCode(),
            department.getName(),
            new ArrayList<>()  // 초기엔 빈 리스트, 서비스에서 조립
        );
    }
}

프론트는 더 이상 트리를 직접 조립할 필요가 없고, children을 따라 내려가기만 하면 됩니다.

서비스에서는 CTE로 전체 부서를 1번 조회한 뒤 Java에서 트리로 조립합니다.

public List<DepartmentsTree> getAllDepartmentsNames() {
    List<Department> flat = departmentRepository.findAllAsTree(); // CTE, 쿼리 1번

    // 1단계: 전체를 Map으로
    Map<String, DepartmentsTree> map = flat.stream()
        .collect(toMap(Department::getCode, DepartmentsTree::new));

    // 2단계: 부모-자식 연결
    flat.stream()
        .filter(d -> d.getParentCode() != null)
        .forEach(d ->
            map.get(d.getParentCode())
               .children()
               .add(map.get(d.getCode()))
        );

    // 3단계: 루트만 반환
    return flat.stream()
        .filter(d -> d.getParentCode() == null)
        .map(d -> map.get(d.getCode()))
        .toList();
}

주석 처리됐던 getDepartmentsTree()가 바로 이런 역할을 하려 했던 것입니다. 응답 구조는 다음과 같습니다.

[
  {
    "departmentId": 1,
    "code": "HQ",
    "name": "경영지원본부",
    "children": [
      {
        "departmentId": 2,
        "code": "IT",
        "name": "IT솔루션부",
        "children": [
          {
            "departmentId": 3,
            "code": "IT_OPS",
            "name": "IT운영팀",
            "children": []
          }
        ]
      }
    ]
  }
]

개선하고 싶은 방향 요약

항목 현재 해보고 싶은 것

부모 참조 parentCode: String @ManyToOne parent: Department 추가 고려
자식 참조 없음 @OneToMany children 추가
하위 부서 조회 없음 Native CTE findAllSubDepartments 추가
브레드크럼 조회 없음 Native CTE findAllAncestors 추가
DTO 응답 구조 평탄 리스트 (프론트 조립) children 포함한 트리 구조로 응답

마무리

계층 구조 가이드를 정리하고 나서 사내 코드를 다시 보니, 이론이 실무와 동떨어진 것이 아니었습니다. 오히려 주석 처리된 코드들이 "그때 이걸 몰라서 못 했던 것" 임을 보여주고 있었습니다.

 

당장 리팩토링하겠다는 것은 아닙니다. 이 코드가 지금도 잘 돌아가고 있고, 섣불리 건드리면 생기는 리스크도 있습니다. 다만 다음에 이 엔티티를 손댈 일이 생기면, 오늘 정리한 내용을 꺼내서 하나씩 적용해보고 싶습니다.

'dev.log' 카테고리의 다른 글

PUT vs PATCH, 팩트만 정리합니다  (0) 2026.03.01
도메인 모델이 뚱뚱해지고 있다면, Read Model을 도입할 때입니다  (0) 2026.02.25
AI가 코드를 짜는 시대, 개발자로서 느낀 것들  (0) 2026.02.17
'dev.log' 카테고리의 다른 글
  • PUT vs PATCH, 팩트만 정리합니다
  • 도메인 모델이 뚱뚱해지고 있다면, Read Model을 도입할 때입니다
  • AI가 코드를 짜는 시대, 개발자로서 느낀 것들
kyeongwoo.ryu
kyeongwoo.ryu
꾸준함이야말로 가장 가치 있는 능력이라고 생각하며, 하루하루 배우고 흘러가는 과정을 기록합니다.
  • kyeongwoo.ryu
    Rio.dev
    kyeongwoo.ryu
    • 분류 전체보기 (10) N
      • dev.log (4)
      • life.log (6) N
  • 링크

    • GitHub
  • 최근 글

kyeongwoo.ryu
계층형 CTE 한 줄이 주석 처리된 코드 세 줄을 살린다
상단으로

티스토리툴바