콘텐츠로 이동

Ch 4. 가상 코드 관리 — 신규 메뉴 만들기

이 챕터를 마치면

  • hera-v4에서 신규 CRUD 메뉴를 만드는 데 필요한 파일 목록과 순서를 말할 수 있습니다.
  • 각 레이어(Entity → Mapper → Service → Controller → Client → ViewRouter → HTML/JS)의 역할과 연결 방식을 설명할 수 있습니다.
  • 실제 코드를 작성하고 브라우저에서 화면을 확인할 수 있습니다.

1. 시나리오 — PT0101 가상 코드 관리

이 챕터에서는 가상의 PT 모듈에 코드 관리 화면을 만듭니다. 운영 코드는 그대로 두고, hera-v4 패턴을 따라 만드는 연습용 메뉴입니다.

항목
화면 코드 PT0101
URL /pt/0101
DB 테이블 pt_code
PK pt_code_id (UUID v7)
주요 컬럼 code (코드), name (코드명), use_yn (사용여부)
화면 구성 단일 그리드 (조회/등록/수정/삭제)
API 경로 /api/v1/sys/pt-codes

왜 SYS API를 재사용하나?

hera-v4의 API Gateway 라우팅 설정에서 /api/v1/sys/**는 이미 hera-sys 서비스로 연결됩니다. PT 모듈이 독립 서비스를 가질 만큼 크지 않다면, 기존 sys 인프라를 재사용해서 Gateway 설정 변경 없이 새 API를 추가합니다.


2. 파일 구성 전체 조감

만들 파일은 12개, 수정 파일은 1개입니다. 의존성 순서대로 작성합니다.

[1] hera-commons        Domain.java           ← PT_0101 상수 추가 (수정)

[2] hera-domain-data    PtCodeVO.java         ← ViewObject
[3] hera-domain-data    PtCodeSP.java         ← SearchParams

[4] hera-system         PtCode.java           ← Entity
[5] hera-system         PtCodeMapper.java     ← Mapper (MinuBaseMapper 상속)
[6] hera-system         PtCodeService.java    ← Service interface
[7] hera-system         PtCodeServiceImpl.java ← ServiceImpl
[8] hera-system         PtCodeApiController.java ← System API Controller

[9] hera-api-client     PtCodeClient.java     ← Feign Client

[10] hera-webapp        Pt0101ViewRouter.java ← View Router
[11] hera-webapp        pt0101.html           ← Thymeleaf 템플릿
[12] hera-webapp        pt0101.js             ← 화면 JS
[13] hera-webapp        Pt0101ApiController.java ← Webapp API Controller

아래 섹션에서 각 파일의 코드를 순서대로 작성합니다.


3. [1] Domain.java 수정

hera-commons 모듈의 Domain.javaPT_0101 상수를 추가합니다.

// hera-commons/.../code/Domain.java

public enum Domain {
    // ... 기존 상수들 ...
    SYS_0101("SYS0101"),
    SYS_0102("SYS0102"),
    // ...

    PT_0101("PT0101"),   // ← 추가
    ;

    private final String value;
    Domain(String value) { this.value = value; }
    public String getValue() { return value; }
}

Domain 상수 역할

Domain.PT_0101은 ViewRouter에서 prepareBaseInfo(model, PT_0101) 형태로 사용합니다. 이 메서드가 내부적으로 화면 코드 → 메뉴 정보 → 권한 정보를 모델에 주입합니다. 상수를 빠뜨리면 화면이 올바른 권한·메뉴 정보를 받지 못합니다.


4. [2] PtCodeVO.java

hera-domain-data 모듈에 ViewObject를 작성합니다. VO는 화면과 API 레이어 사이에서 데이터를 주고받는 객체로, AuditVO를 상속해 감사 필드를 포함합니다.

// hera-domain-data/.../pt/PtCodeVO.java
package com.dandisoft.hera.domain.data.pt;

import com.dandisoft.hera.core.audit.vo.AuditVO;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = false)
public class PtCodeVO extends AuditVO {

    private String ptCodeId;
    private String code;
    private String name;
    private String useYn;
}

AuditVO vs AuditModel

  • AuditVO : 화면/Feign 전달용. 직렬화 가능. DB 어노테이션 없음.
  • AuditModel : DB Entity. @Table, @Column 등 MyBatis-Flex 어노테이션 사용.

5. [3] PtCodeSP.java

SearchParams는 조회 조건을 담는 객체입니다. BaseSP를 상속하면 페이징·정렬 파라미터가 자동으로 따라옵니다.

// hera-domain-data/.../pt/PtCodeSP.java
package com.dandisoft.hera.domain.data.pt;

import com.dandisoft.hera.core.model.BaseSP;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = false)
public class PtCodeSP extends BaseSP {

    private String code;
    private String name;
    private String useYn;
}

6. [4] PtCode.java (Entity)

hera-system 모듈에 DB Entity를 작성합니다. MyBatis-Flex의 @Table, @Column 어노테이션으로 테이블·컬럼을 매핑합니다.

// hera-system/.../pt/PtCode.java
package com.dandisoft.hera.system.pt;

import com.dandisoft.hera.core.audit.model.AuditModel;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.Table;
import com.dandisoft.hera.core.audit.listener.AuditingEntityListener;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = false)
@Table(
    value = "pt_code",
    onInsert = AuditingEntityListener.class,
    onUpdate = AuditingEntityListener.class
)
public class PtCode extends AuditModel<String> {

    @Id
    @Column(value = "pt_code_id", onInsertValue = "uuidv7()")
    private String ptCodeId;

    @Column("code")
    private String code;

    @Column("name")
    private String name;

    @Column("use_yn")
    private String useYn;
}

PK — uuidv7()

onInsertValue = "uuidv7()" 를 지정하면 INSERT 시 DB 함수 uuidv7()이 자동 호출됩니다. Java 코드에서 PK를 생성해 주입하지 않아도 됩니다.

AuditModel 타입 파라미터

AuditModel<String>의 타입 파라미터는 PK 타입입니다. pt_code_id 가 VARCHAR(UUID)이므로 String을 사용합니다.


7. [5] PtCodeMapper.java

Mapper는 MyBatis-Flex의 MinuBaseMapper를 상속하기만 하면 됩니다. 기본 CRUD(findAll, findById, insert, update, deleteById 등)가 자동 제공됩니다.

// hera-system/.../pt/PtCodeMapper.java
package com.dandisoft.hera.system.pt;

import com.dandisoft.hera.core.mapper.MinuBaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface PtCodeMapper extends MinuBaseMapper<PtCode> {
}

커스텀 SQL이 필요한 경우

복잡한 조인이나 집계가 필요하면 이 인터페이스에 메서드를 추가하고, PtCodeMapper.xml에 SQL을 작성합니다. 단순 CRUD만 사용하는 지금은 추가 코드 없이 끝입니다.


8. [6] PtCodeService.java

Service 인터페이스는 AuditService를 상속해 기본 CRUD 메서드 시그니처를 확보합니다.

// hera-system/.../pt/PtCodeService.java
package com.dandisoft.hera.system.pt;

import com.dandisoft.hera.core.service.AuditService;
import com.dandisoft.hera.domain.data.pt.PtCodeSP;
import com.dandisoft.hera.domain.data.pt.PtCodeVO;

public interface PtCodeService
        extends AuditService<PtCode, PtCodeVO, PtCodeSP> {
}

9. [7] PtCodeServiceImpl.java

AuditServiceImpl을 상속하고 searchCondition() 하나만 구현하면 됩니다. 나머지 CRUD 로직은 부모 클래스가 처리합니다.

// hera-system/.../pt/PtCodeServiceImpl.java
package com.dandisoft.hera.system.pt;

import com.dandisoft.hera.core.service.AuditServiceImpl;
import com.dandisoft.hera.domain.data.pt.PtCodeSP;
import com.dandisoft.hera.domain.data.pt.PtCodeVO;
import com.mybatisflex.core.query.If;
import com.mybatisflex.core.query.QueryWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PtCodeServiceImpl
        extends AuditServiceImpl<PtCode, PtCodeVO, PtCodeSP, PtCodeMapper>
        implements PtCodeService {

    @Override
    protected QueryWrapper searchCondition(PtCodeSP sp) {
        return QueryWrapper.create()
                .like(PtCode::getCode,   sp.getCode(),   If::hasText)
                .like(PtCode::getName,   sp.getName(),   If::hasText)
                .eq(PtCode::getUseYn,    sp.getUseYn(),  If::hasText);
    }
}

If::hasText 조건

If::hasText는 SP 필드가 null 또는 빈 문자열이면 해당 조건을 쿼리에서 뺍니다. 조건 필드가 비어 있으면 자동으로 전체 조회가 되므로, null 체크 코드를 따로 쓰지 않아도 됩니다.


10. [8] PtCodeApiController.java (hera-system)

System 레이어의 API Controller입니다. AuditApiController를 상속하고 @RequestMapping과 Security 어노테이션만 추가하면 CRUD API가 완성됩니다.

// hera-system/.../pt/PtCodeApiController.java
package com.dandisoft.hera.system.pt;

import com.dandisoft.hera.core.controller.AuditApiController;
import com.dandisoft.hera.core.model.PageRequest;
import com.dandisoft.hera.core.model.PageResponse;
import com.dandisoft.hera.domain.data.pt.PtCodeSP;
import com.dandisoft.hera.domain.data.pt.PtCodeVO;
import com.dandisoft.hera.hera_api_client.config.UrlConstant;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(UrlConstant.SYS_API_V1)
@RequiredArgsConstructor
public class PtCodeApiController
        extends AuditApiController<PtCode, PtCodeVO, PtCodeSP, PtCodeService> {

    @PreAuthorize("hasRole('ROLE_PT0101_READ')")
    @GetMapping("/pt-codes/pageable")
    public PageResponse<PtCodeVO> findAllOnPageable(
            @RequestBody PageRequest<PtCodeSP> pageRequest) {
        return super.doFindAllOnPageable(pageRequest);
    }

    @PreAuthorize("hasRole('ROLE_PT0101_CREATE')")
    @PostMapping("/pt-codes")
    public PtCodeVO save(@RequestBody PtCodeVO vo) {
        return super.doSave(vo);
    }

    @PreAuthorize("hasRole('ROLE_PT0101_UPDATE')")
    @PutMapping("/pt-codes/{id}")
    public PtCodeVO update(@PathVariable String id, @RequestBody PtCodeVO vo) {
        return super.doUpdate(id, vo);
    }

    @PreAuthorize("hasRole('ROLE_PT0101_DELETE')")
    @DeleteMapping("/pt-codes/{id}")
    public void delete(@PathVariable String id) {
        super.doDelete(id);
    }
}

권한 코드 패턴 — ROLE_{도메인코드}_{액션}

hera-v4의 권한 코드는 ROLE_PT0101_READ 형태입니다. 도메인 코드(PT0101)와 액션(READ/CREATE/UPDATE/DELETE)을 조합합니다. 이 권한은 Ch 5에서 시스템 권한 관리 화면을 통해 DB에 등록합니다.

GET + @RequestBody

hera-v4는 페이징 조회에 GET 메서드와 @RequestBody를 함께 사용합니다. 페이징·정렬·검색조건이 섞인 복잡한 조회 조건을 쿼리스트링이 아니라 요청 본문에 실어서 URL 길이 제한 문제를 피하려는 의도적 패턴입니다.


11. [9] PtCodeClient.java (hera-api-client)

hera-api-client 모듈에 Feign Client를 작성합니다. hera-webapp이 System 서비스를 호출할 때 이 Client를 거칩니다.

// hera-api-client/.../pt/PtCodeClient.java
package com.dandisoft.hera.hera_api_client.pt;

import com.dandisoft.hera.core.model.PageRequest;
import com.dandisoft.hera.core.model.PageResponse;
import com.dandisoft.hera.domain.data.pt.PtCodeSP;
import com.dandisoft.hera.domain.data.pt.PtCodeVO;
import com.dandisoft.hera.hera_api_client.config.UrlConstant;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;

@FeignClient(
    name    = "msa-api-gateway",
    path    = UrlConstant.SYS_API_V1,
    url     = "${api-gateway.sys:}",
    contextId = "ptCodeClient"           // ← 필수: 동일 서비스를 향하는 Feign Client가 복수일 때 충돌 방지
)
public interface PtCodeClient {

    @GetMapping("/pt-codes/pageable")
    PageResponse<PtCodeVO> findAllOnPageable(
            @RequestBody PageRequest<PtCodeSP> pageRequest);

    @PostMapping("/pt-codes")
    PtCodeVO save(@RequestBody PtCodeVO vo);

    @PutMapping("/pt-codes/{id}")
    PtCodeVO update(@PathVariable("id") String id, @RequestBody PtCodeVO vo);

    @DeleteMapping("/pt-codes/{id}")
    void delete(@PathVariable("id") String id);
}

contextId는 생략 불가

hera-api-client에는 msa-api-gateway를 향하는 Feign Client가 여러 개 있습니다. contextId가 없으면 Spring 컨텍스트 로드 시 ConflictingBeanDefinitionException이 발생합니다. Client 이름에 맞춰 ptCodeClient처럼 고유한 이름을 지정합니다.


12. [10] Pt0101ViewRouter.java

hera-webapp 모듈에 View Router를 작성합니다. 브라우저의 /pt/0101 요청을 받아 Thymeleaf 템플릿을 반환합니다.

// hera-webapp/.../pt/Pt0101ViewRouter.java
package com.dandisoft.hera.webapp.pt;

import com.dandisoft.hera.webapp.core.controller.BaseController;
import static com.dandisoft.hera.domain.code.Domain.PT_0101;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/pt")
@RequiredArgsConstructor
public class Pt0101ViewRouter extends BaseController {

    @GetMapping("/0101")
    public String view(ModelMap model) throws Exception {
        prepareBaseInfo(model, PT_0101);
        return "pt/pt0101";
    }
}

prepareBaseInfo(model, domain)

이 한 줄이 화면 코드 → DB 메뉴 정보 조회 → 권한 정보 → Thymeleaf 모델 주입까지 전 과정을 처리합니다. 반환 문자열 "pt/pt0101"templates/pt/pt0101.html에 대응합니다.


13. [11] pt0101.html

Thymeleaf 템플릿입니다. hera-v4의 단일 그리드 화면은 대부분 동일한 구조를 따릅니다.

<!-- hera-webapp/src/main/resources/templates/pt/pt0101.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/base}">

<head>
    <title th:text="${pageTitle}">PT0101</title>
</head>

<body>
<section layout:fragment="content">

    <!-- 검색 영역 -->
    <div class="card card-search">
        <div class="card-body">
            <div class="row g-2">
                <div class="col-md-3">
                    <label class="form-label">코드</label>
                    <input type="text" id="searchCode" class="form-control form-control-sm">
                </div>
                <div class="col-md-3">
                    <label class="form-label">코드명</label>
                    <input type="text" id="searchName" class="form-control form-control-sm">
                </div>
                <div class="col-md-3">
                    <label class="form-label">사용여부</label>
                    <select id="searchUseYn" class="form-select form-select-sm">
                        <option value="">전체</option>
                        <option value="Y">사용</option>
                        <option value="N">미사용</option>
                    </select>
                </div>
                <div class="col-md-3 d-flex align-items-end">
                    <button id="btnSearch" class="btn btn-primary btn-sm">
                        <i class="bi bi-search"></i> 조회
                    </button>
                </div>
            </div>
        </div>
    </div>

    <!-- 그리드 영역 -->
    <div class="card mt-2">
        <div class="card-header d-flex justify-content-between align-items-center">
            <span>코드 목록</span>
            <div>
                <button id="btnAdd"    class="btn btn-sm btn-success">추가</button>
                <button id="btnSave"   class="btn btn-sm btn-primary">저장</button>
                <button id="btnDelete" class="btn btn-sm btn-danger">삭제</button>
            </div>
        </div>
        <div class="card-body p-0">
            <div id="grid"></div>
        </div>
    </div>

</section>

<th:block layout:fragment="script">
    <script th:src="@{/js/pt/pt0101.js}"></script>
</th:block>

</body>
</html>

14. [12] pt0101.js

화면 동작을 담당하는 JS 파일입니다. API 호출 URL, 그리드 컬럼 정의, 버튼 이벤트를 작성합니다.

// hera-webapp/src/main/resources/static/js/pt/pt0101.js

const apiUrl = contextPath + '/api/sys';    // Webapp API Controller 경로

/* ── 조회 ─────────────────────────────── */
const findAll = (params) =>
    $.get(apiUrl + '/pt-codes/pageable', params, function (res) {
        grid.resetData(res.data);
    });

/* ── 그리드 초기화 ───────────────────── */
const grid = new tui.Grid({
    el        : document.getElementById('grid'),
    rowHeaders: ['checkbox'],
    columns   : [
        { header: '코드ID',  name: 'ptCodeId',  hidden: true },
        { header: '코드',    name: 'code',       editor: 'text' },
        { header: '코드명',  name: 'name',       editor: 'text' },
        { header: '사용여부', name: 'useYn',
          editor: { type: 'select', options: { listItems: [
              { text: '사용', value: 'Y' },
              { text: '미사용', value: 'N' }
          ]}}
        },
    ],
    pageOptions: { useClient: false, perPage: 20 },
});

/* ── 버튼 이벤트 ─────────────────────── */
$('#btnSearch').on('click', function () {
    const params = {
        'searchParam.code'   : $('#searchCode').val(),
        'searchParam.name'   : $('#searchName').val(),
        'searchParam.useYn'  : $('#searchUseYn').val(),
        page  : 1,
        size  : 20,
    };
    findAll(params);
});

$('#btnAdd').on('click', function () {
    grid.addRow({ useYn: 'Y' }, { at: 0 });
});

$('#btnSave').on('click', function () {
    const rows = grid.getModifiedRows();

    rows.createdRows.forEach(row => {
        $.post(apiUrl + '/pt-codes', JSON.stringify(row),
            () => findAll({}), 'json');
    });

    rows.updatedRows.forEach(row => {
        $.ajax({
            url    : apiUrl + '/pt-codes/' + row.ptCodeId,
            method : 'PUT',
            data   : JSON.stringify(row),
            contentType: 'application/json',
            success: () => findAll({}),
        });
    });
});

$('#btnDelete').on('click', function () {
    grid.getCheckedRows().forEach(row => {
        $.ajax({
            url    : apiUrl + '/pt-codes/' + row.ptCodeId,
            method : 'DELETE',
            success: () => findAll({}),
        });
    });
});

/* ── 페이지 로드 시 초기 조회 ─────────── */
$(document).ready(function () {
    findAll({});
});

15. [13] Pt0101ApiController.java (hera-webapp)

hera-webapp 안의 API Controller입니다. 브라우저 JS → 이 Controller → PtCodeClient (Feign) → API Gateway → hera-system 순으로 연결됩니다.

// hera-webapp/.../pt/Pt0101ApiController.java
package com.dandisoft.hera.webapp.pt;

import com.dandisoft.hera.core.model.PageRequest;
import com.dandisoft.hera.core.model.PageResponse;
import com.dandisoft.hera.domain.data.pt.PtCodeSP;
import com.dandisoft.hera.domain.data.pt.PtCodeVO;
import com.dandisoft.hera.hera_api_client.pt.PtCodeClient;
import com.dandisoft.hera.hera_api_client.config.UrlConstant;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(UrlConstant.SYS_API)
@RequiredArgsConstructor
public class Pt0101ApiController {

    private final PtCodeClient ptCodeClient;

    @GetMapping("/pt-codes/pageable")
    public PageResponse<PtCodeVO> findAllOnPageable(
            PageRequest<PtCodeSP> pageRequest) {
        return ptCodeClient.findAllOnPageable(pageRequest);
    }

    @PostMapping("/pt-codes")
    public PtCodeVO save(@RequestBody PtCodeVO vo) {
        return ptCodeClient.save(vo);
    }

    @PutMapping("/pt-codes/{id}")
    public PtCodeVO update(@PathVariable String id, @RequestBody PtCodeVO vo) {
        return ptCodeClient.update(id, vo);
    }

    @DeleteMapping("/pt-codes/{id}")
    public void delete(@PathVariable String id) {
        ptCodeClient.delete(id);
    }
}

Webapp Controller vs System Controller — API 경로 차이

레이어 @RequestMapping 실제 경로 호출 주체
hera-webapp SYS_API = /api/sys /api/sys/pt-codes/... 브라우저 JS
hera-system SYS_API_V1 = /api/v1/sys /api/v1/sys/pt-codes/... Feign (API Gateway 경유)

브라우저는 /api/sys로 호출하고, Feign은 /api/v1/sys로 재전달합니다. API Gateway는 /api/v1/sys/**hera-sys 서비스로 라우팅합니다.


16. 전체 요청 흐름 확인

작성한 코드를 따라 전체 흐름을 한 번 더 짚어 봅니다.

sequenceDiagram
    participant B as 브라우저 (pt0101.js)
    participant W as Pt0101ApiController
(hera-webapp) participant F as PtCodeClient
(Feign) participant G as API Gateway participant S as PtCodeApiController
(hera-system) participant SV as PtCodeServiceImpl participant M as PtCodeMapper B->>W: GET /api/sys/pt-codes/pageable W->>F: findAllOnPageable(pageRequest) F->>G: GET /api/v1/sys/pt-codes/pageable G->>S: 라우팅 (lb://hera-sys) S->>SV: doFindAllOnPageable(pageRequest) SV->>M: selectPageByQuery(searchCondition) M-->>SV: Page SV-->>S: PageResponse S-->>G: JSON G-->>F: JSON F-->>W: PageResponse W-->>B: JSON

17. DB 테이블 생성

로컬 PostgreSQL(사내 개발 서버)에서 테이블을 생성합니다.

CREATE TABLE pt_code (
    pt_code_id  VARCHAR(36)  NOT NULL DEFAULT uuidv7(),
    code        VARCHAR(50)  NOT NULL,
    name        VARCHAR(200) NOT NULL,
    use_yn      CHAR(1)      NOT NULL DEFAULT 'Y',

    -- AuditModel 공통 컬럼 (AuditingEntityListener가 자동 채움)
    created_at  TIMESTAMP,
    created_by  VARCHAR(36),
    updated_at  TIMESTAMP,
    updated_by  VARCHAR(36),

    CONSTRAINT pk_pt_code PRIMARY KEY (pt_code_id)
);

COMMENT ON TABLE  pt_code           IS '가상 코드';
COMMENT ON COLUMN pt_code.pt_code_id IS 'PK';
COMMENT ON COLUMN pt_code.code       IS '코드';
COMMENT ON COLUMN pt_code.name       IS '코드명';
COMMENT ON COLUMN pt_code.use_yn     IS '사용여부 (Y/N)';

18. 빌드 및 동작 확인

18.1 빌드

./gradlew build -x test

빌드가 끝나면 hera-webapp을 실행합니다.

./gradlew :hera-webapp:bootRun

18.2 브라우저 확인

https://localhost:8000/pt/0101 에 접속합니다.

확인 항목 기대 결과
화면 표시 검색 영역 + 그리드 렌더링
초기 조회 그리드에 데이터 표시 (비어 있어도 정상)
추가/저장 행 추가 후 저장 → 그리드 갱신
삭제 체크 후 삭제 → 행 제거

18.3 Network 탭으로 흐름 확인

브라우저 개발자 도구 → Network 탭에서 다음 요청을 확인합니다.

  1. GET /pt/0101 → 200, HTML 응답 (ViewRouter 정상)
  2. GET /api/sys/pt-codes/pageable → 200, JSON 응답 (Feign → System 정상)

404 응답 시 체크리스트

증상 원인 확인 위치
/pt/0101 404 ViewRouter 미등록 Pt0101ViewRouter.java @GetMapping
/api/sys/pt-codes/pageable 404 Webapp Controller 미등록 Pt0101ApiController.java @GetMapping
/api/v1/sys/pt-codes/pageable 404 System Controller 미등록 PtCodeApiController.java @GetMapping
500 응답 Feign Bean 충돌 PtCodeClient.java contextId 누락 확인
403 응답 권한 미등록 Ch 5에서 DB에 권한 등록 필요

19. 이 챕터 요약

파일 모듈 역할
Domain.java hera-commons 화면 코드 상수 등록
PtCodeVO.java hera-domain-data 화면 ↔ API 데이터 전달 객체
PtCodeSP.java hera-domain-data 조회 조건 객체
PtCode.java hera-system DB Entity
PtCodeMapper.java hera-system DB 접근 (MinuBaseMapper 상속)
PtCodeService.java hera-system Service 인터페이스
PtCodeServiceImpl.java hera-system 조회 조건 구현 (searchCondition)
PtCodeApiController.java hera-system System REST API
PtCodeClient.java hera-api-client Feign Client
Pt0101ViewRouter.java hera-webapp URL → Thymeleaf 매핑
pt0101.html hera-webapp 화면 템플릿
pt0101.js hera-webapp 화면 동작 (그리드/CRUD)
Pt0101ApiController.java hera-webapp 브라우저 ↔ Feign 브릿지

다음 챕터 예고

Ch 5에서는 만든 화면을 메뉴에 노출하고 권한을 적용하기 위해 DB에 무엇을 등록해야 하는지 다룹니다. 시스템 관리 화면(sys0101 ~ sys0105)을 통해 메뉴·권한·메시지를 등록하는 과정을 따라갑니다.