db06c95724
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m8s
backend-architecture-summary.md 는 옛 Node 백엔드(VEX 1세대) 기준이라 현재 Spring 백엔드의 컨벤션이 문서화돼있지 않음. 신규 작업자가 합의된 패턴(3-layer, Map<String,Object>, 네이밍 규칙, 표준 응답, common SQL 단편) 을 한 곳에서 빠르게 파악할 수 있게 정리. 다른 아키텍처 문서(MULTI_TENANCY_ARCHITECTURE / DOMAIN_MAPPING) 와 상호 링크. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.2 KiB
7.2 KiB
INVYONE REST API 아키텍처
3-layer (Controller → Service → XML), DTO 없음, Map<String, Object> 일원화. 로우코드 ERP 특성상 테이블/컬럼이 런타임에 결정되므로 사전 정의 DTO를 두지 않고 모든 데이터를 키-값 맵으로 흘린다.
1. 기술 스택
언어 Java 21
프레임워크 Spring Boot 3.x
DB 접근 MyBatis (XML) + sqlSession 직접 호출
DB PostgreSQL 16 (DB-per-tenant)
인증 JWT (Authorization: Bearer …)
빌드 Gradle 8.10
멀티테넌시·서브도메인 라우팅은 별도 문서: MULTI_TENANCY_ARCHITECTURE.md.
2. 3-layer 구조
[HTTP 요청]
↓
@RestController ← 입력 추출, 검증, 응답 래핑
↓ Map<String, Object>
Service extends BaseService ← 비즈니스 로직, sqlSession 호출
↓ namespace.queryId
mapper/{module}.xml ← SQL (UPPER_SNAKE), 동적 OGNL
↓
PostgreSQL (테넌트 자동 라우팅)
절대 규칙
@Mapper인터페이스 만들지 않는다. Service 가sqlSession.selectList("namespace.queryId", params)직접 호출.- DTO·엔티티 클래스 만들지 않는다. 파라미터·응답 모두
Map<String, Object>또는List<Map<String, Object>>. - 유일한 DTO는
ApiResponse(성공/실패 + payload 래퍼). - Service 는 반드시
BaseService상속 +@Slf4j+@Autowired CommonService.
3. 네이밍 컨벤션 (전 영역 일관)
| 위치 | 컨벤션 | 예 |
|---|---|---|
| Java 코드 (변수/메서드/클래스) | camelCase |
getOrderList(), String companyCode |
Map 키 (params.put, row.get) |
snake_case |
params.put("company_code", ...) |
MyBatis #{파라미터} |
snake_case |
#{company_code}, #{table_name} |
| SQL 키워드/테이블/컬럼 | UPPER_SNAKE |
SELECT COMPANY_CODE FROM TEMPLATES |
| SELECT 쉼표 | 앞에 | , COLUMN_NAME |
| XML 파일명 | 소문자, Mapper 접미사 없음 |
meta.xml, template.xml |
| XML namespace | 파일명과 동일 | namespace="meta" |
OGNL test 속성 |
바깥 작은따옴표 | test='company_code != "*"' |
Java는 camelCase, DB·맵은 snake_case. 경계는 Service 안에서만 발생 — Controller·XML 내부엔 변환 코드 없음.
4. 메서드·엔드포인트 패턴
| 조작 | 메서드명 | HTTP |
|---|---|---|
| 목록 | get{Module}List |
GET /api/{module}/list |
| 카운트 | get{Module}ListCnt |
GET /api/{module}/list/count |
| 단건 | get{Module}Info |
GET /api/{module}/{id} |
| 등록 | insert{Module} |
POST /api/{module} |
| 수정 | update{Module} |
PUT /api/{module}/{id} |
| 삭제 | delete{Module} |
DELETE /api/{module}/{id} |
List API는 반드시 Count 쿼리를 동반한다. getXxxList + getXxxListCnt 한 세트로 페이지네이션을 보장.
5. 표준 응답 (ApiResponse)
// 성공
ResponseEntity.ok(ApiResponse.success(data));
ResponseEntity.ok(ApiResponse.success(data, "메시지"));
// 실패
ResponseEntity.badRequest().body(ApiResponse.error("사용자 ID와 비밀번호를 입력해주세요."));
JSON:
{
"success": true,
"message": "조회 완료",
"data": [...]
}
프론트는 항상 success + data만 보면 충분.
6. common 레이어 (공용 SQL 조각)
mapper/common.xml 의 <sql> 단편을 모든 모듈에서 <include>.
<select id="getTemplateList" resultType="map">
SELECT
T.TEMPLATE_CODE,
T.TEMPLATE_NAME
FROM TEMPLATES T
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
</select>
companyCodeFilter— 멀티 회사 데이터 분리 (COMPANY_CODE = #{company_code}).dynamicOrderBy— 프론트에서 받은 정렬 필드/방향 동적 적용.pagination—LIMIT #{page_size} OFFSET #{offset}.
7. 인증 (JWT)
- 로그인:
POST /api/auth/loginbody{ "user_id": "...", "password": "..." }. - 응답에
access_token포함. 이후 요청은Authorization: Bearer {token}헤더 필수. - 검증은
JwtAuthenticationFilter가 일괄. 토큰 페이로드의company_code·user_id·role이SecurityContext로 흐름. SubdomainResolverFilter다음에 동작 → 테넌트 컨텍스트 + 사용자 컨텍스트 모두 갖춘 채 컨트롤러 진입.
화이트리스트(공개 엔드포인트): /api/auth/login, /api/auth/refresh, 정적 리소스.
8. 예시: 템플릿 목록 조회
컨트롤러
@RestController
@RequestMapping("/api/template")
@RequiredArgsConstructor
public class TemplateController {
private final TemplateService templateService;
@GetMapping("/list")
public ResponseEntity<ApiResponse> getTemplateList(@RequestParam Map<String, Object> params) {
return ResponseEntity.ok(ApiResponse.success(templateService.getTemplateList(params)));
}
}
서비스
@Service
@Slf4j
public class TemplateService extends BaseService {
@Autowired CommonService commonService;
public Map<String, Object> getTemplateList(Map<String, Object> params) {
params.put("company_code", currentCompanyCode()); // BaseService 헬퍼
List<Map<String, Object>> list = sqlSession.selectList("template.getTemplateList", params);
Integer total = sqlSession.selectOne("template.getTemplateListCnt", params);
return Map.of("list", list, "total", total);
}
}
매퍼 (resources/mapper/template.xml)
<mapper namespace="template">
<select id="getTemplateList" resultType="map">
SELECT
T.TEMPLATE_CODE
, T.TEMPLATE_NAME
, T.CREATED_AT
FROM TEMPLATES T
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<if test='keyword != null and keyword != ""'>
AND T.TEMPLATE_NAME ILIKE '%' || #{keyword} || '%'
</if>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
</select>
<select id="getTemplateListCnt" resultType="int">
SELECT COUNT(*)
FROM TEMPLATES T
WHERE 1=1
<include refid="common.companyCodeFilter"/>
<if test='keyword != null and keyword != ""'>
AND T.TEMPLATE_NAME ILIKE '%' || #{keyword} || '%'
</if>
</select>
</mapper>
9. 프론트 연동 (요약)
- 호출은 항상
frontend/lib/api/client.ts(axios 래퍼) 또는 모듈별 클라이언트 (flow.ts,dashboard.ts등).fetch직접 사용 금지. - 응답 타입은 별도 인터페이스 정의 없이
Record<string, any>사용. (예외:frontend/types/invyone-component.ts의 시스템 핵심 계약 타입만 명시 정의.) - 베이스 URL 분기 (
*.invyone.com↔localhost) 는 클라이언트가 호스트 헤더 보고 알아서 해결 → 컴포넌트는 상대경로만 신경 쓰면 됨.
10. 관련 문서
MULTI_TENANCY_ARCHITECTURE.md— 서브도메인 라우팅, DB-per-tenant, 회사 프로비저닝 흐름.DOMAIN_MAPPING.md— 환경별 도메인·포트.notes/gbpark/2026-04-08-lowcode-platform-spec.md— 플랫폼 SPEC (왜 DTO를 두지 않는지의 배경).