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>
214 lines
7.2 KiB
Markdown
214 lines
7.2 KiB
Markdown
# 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`](./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`)
|
|
|
|
```java
|
|
// 성공
|
|
ResponseEntity.ok(ApiResponse.success(data));
|
|
ResponseEntity.ok(ApiResponse.success(data, "메시지"));
|
|
|
|
// 실패
|
|
ResponseEntity.badRequest().body(ApiResponse.error("사용자 ID와 비밀번호를 입력해주세요."));
|
|
```
|
|
|
|
JSON:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"message": "조회 완료",
|
|
"data": [...]
|
|
}
|
|
```
|
|
|
|
프론트는 항상 `success` + `data`만 보면 충분.
|
|
|
|
---
|
|
|
|
## 6. `common` 레이어 (공용 SQL 조각)
|
|
|
|
`mapper/common.xml` 의 `<sql>` 단편을 모든 모듈에서 `<include>`.
|
|
|
|
```xml
|
|
<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/login` body `{ "user_id": "...", "password": "..." }`.
|
|
- 응답에 `access_token` 포함. 이후 요청은 `Authorization: Bearer {token}` 헤더 필수.
|
|
- 검증은 `JwtAuthenticationFilter` 가 일괄. 토큰 페이로드의 `company_code`·`user_id`·`role` 이 `SecurityContext` 로 흐름.
|
|
- `SubdomainResolverFilter` 다음에 동작 → 테넌트 컨텍스트 + 사용자 컨텍스트 모두 갖춘 채 컨트롤러 진입.
|
|
|
|
화이트리스트(공개 엔드포인트): `/api/auth/login`, `/api/auth/refresh`, 정적 리소스.
|
|
|
|
---
|
|
|
|
## 8. 예시: 템플릿 목록 조회
|
|
|
|
### 컨트롤러
|
|
```java
|
|
@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)));
|
|
}
|
|
}
|
|
```
|
|
|
|
### 서비스
|
|
```java
|
|
@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`)
|
|
```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`](./MULTI_TENANCY_ARCHITECTURE.md) — 서브도메인 라우팅, DB-per-tenant, 회사 프로비저닝 흐름.
|
|
- [`DOMAIN_MAPPING.md`](./DOMAIN_MAPPING.md) — 환경별 도메인·포트.
|
|
- `notes/gbpark/2026-04-08-lowcode-platform-spec.md` — 플랫폼 SPEC (왜 DTO를 두지 않는지의 배경).
|