acceptance_test_cases.md
# Acceptance Test Cases
이 문서는 접근 방식 증명용입니다. 실제 운영 데이터는 포함하지 않습니다.
| ID | 시나리오 | 기대 결과 |
| --- | --- | --- |
| AT-01 | 어드민에서 메인 배너 등록 | 목록에 저장되고 메인 readback에서 노출 |
| AT-02 | 프로모션 노출 기간 수정 | 지정 기간 안에서만 노출 |
| AT-03 | 상품 진열 순서 변경 | 메인 화면 순서와 어드민 목록 순서 일치 |
| AT-04 | 노출 상태 N 처리 | 메인 화면에서 제외되고 이력 기록 |
| AT-05 | 캐싱 갱신 실행 | 저장 후 캐시 key evict 및 재조회 |
| AT-06 | 잘못된 입력 저장 시도 | validation 메시지 표시, DB 변경 없음 |
| AT-07 | 쿼리 최적화 후보 확인 | 주요 조회 쿼리 실행 계획 기록 |
| AT-08 | 롤백 리허설 | 이전 상태로 복구 가능함을 확인 |
| AT-09 | 인수인계 검수 | 운영자가 등록/수정/노출 상태를 설명 없이 수행 |
cache_invalidation_flow.md
# 캐시 갱신 흐름
이 문서는 접근 방식 증명용입니다. 실제 운영 정책 확정 전 바로 적용하지 않습니다.
1. 어드민 저장 요청이 들어오면 Spring Controller에서 권한과 입력값을 검증합니다.
2. Service Transaction 안에서 MyBatis Mapper 저장과 Audit Log 기록을 완료합니다.
3. 저장 성공 후 `MAIN_CONTENT` 캐시 key를 evict합니다.
4. 첫 메인 조회 시 DB fallback으로 읽고 새 캐시를 구성합니다.
5. 오픈 전에는 주요 메인 콘텐츠를 warm-up하여 첫 요청 지연을 줄입니다.
6. 장애 시에는 캐시를 우회하고 DB 조회로 fallback합니다.
7. 롤백 시에는 이전 데이터 기준으로 캐시를 다시 만들고 메인 readback을 확인합니다.
8. 인수인계 문서에는 수동 캐싱 갱신 위치와 확인 URL을 함께 남깁니다.
deployment_rollback_checklist.md
# 배포/롤백 체크리스트
이 문서는 접근 방식 증명용입니다. 실제 운영 배포 명령이나 접속정보를 포함하지 않습니다.
## 배포 전
1. 변경 파일 목록과 before hash 확인
2. JSP, Java, MyBatis XML syntax 확인
3. 관리자 저장/수정/삭제/노출 상태 테스트
4. 캐싱 갱신 후 메인 readback 확인
5. 쿼리 실행 계획과 인덱스 영향도 확인
## 배포 중
1. 승인된 배포 창에서만 반영
2. 로그 모니터링 담당과 롤백 담당 분리
3. 메인 배너, 프로모션, 상품 진열 순서 확인
## 롤백
1. 변경 전 파일과 설정으로 복구
2. 캐시 evict 또는 이전 캐시 key 복구
3. 메인 readback과 어드민 목록 재확인
4. 인수인계 문서에 원인과 조치 기록
main_content_admin.jsp
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!-- 샘플 전용 JSP입니다. 실제 운영 소스가 아닙니다. -->
<section class="admin-main-content">
<h1>자사몰 메인 콘텐츠 관리</h1>
<form method="post" action="${pageContext.request.contextPath}/admin/main-content/save">
<label>콘텐츠 유형
<select name="contentType">
<option value="BANNER">메인 배너</option>
<option value="PROMOTION">프로모션</option>
<option value="DISPLAY">상품 진열</option>
</select>
</label>
<label>제목 <input type="text" name="title" maxlength="200" required></label>
<label>노출 여부 <input type="checkbox" name="displayYn" value="Y"></label>
<label>정렬 순서 <input type="number" name="displayOrder" min="0"></label>
<label>예약 시작 <input type="datetime-local" name="startAt"></label>
<label>예약 종료 <input type="datetime-local" name="endAt"></label>
<button type="submit">저장 전 미리보기</button>
</form>
<table>
<thead><tr><th>유형</th><th>제목</th><th>상태</th><th>정렬</th><th>수정일</th></tr></thead>
<tbody>
<c:forEach var="item" items="${items}">
<tr>
<td>${item.contentType}</td>
<td>${item.title}</td>
<td>${item.displayYn}</td>
<td>${item.displayOrder}</td>
<td>${item.updatedAt}</td>
</tr>
</c:forEach>
</tbody>
</table>
</section>
main_content_schema.sql
-- 샘플 전용 schema 초안입니다. 실제 운영 DB에 바로 적용하지 않습니다.
CREATE TABLE main_content_admin (
content_id BIGINT NOT NULL PRIMARY KEY,
content_type VARCHAR(30) NOT NULL,
title VARCHAR(200) NOT NULL,
image_url VARCHAR(500),
link_url VARCHAR(500),
display_order INT NOT NULL DEFAULT 0,
display_yn CHAR(1) NOT NULL DEFAULT 'N',
start_at TIMESTAMP NULL,
end_at TIMESTAMP NULL,
created_by VARCHAR(80) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_by VARCHAR(80) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_main_content_active
ON main_content_admin (display_yn, start_at, end_at, display_order);
MainAdminController.java
package example.selfmall.admin;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* 샘플 전용 Controller입니다. 실제 운영 소스가 아니며 접근 방식 설명용입니다.
*/
@Controller
@RequestMapping("/admin/main-content")
public class MainAdminController {
private final MainContentService mainContentService;
public MainAdminController(MainContentService mainContentService) {
this.mainContentService = mainContentService;
}
@RequestMapping
public String list(Model model) {
model.addAttribute("items", mainContentService.findAdminItems());
return "admin/main_content_admin";
}
@PostMapping("/save")
public String save(MainContentCommand command) {
mainContentService.save(command);
return "redirect:/admin/main-content";
}
@PostMapping("/reorder")
@ResponseBody
public String reorder(MainContentOrderCommand command) {
mainContentService.reorder(command);
return "{"result":"OK"}";
}
@PostMapping("/cache/refresh")
@ResponseBody
public String refreshCache() {
mainContentService.refreshMainCache();
return "{"result":"CACHE_REFRESHED"}";
}
}
MainContentMapper.java
package example.selfmall.admin;
import java.util.List;
/**
* 샘플 전용 MyBatis Mapper interface입니다.
*/
public interface MainContentMapper {
List<MainContentDto> selectActiveMainContents();
List<MainContentDto> selectAdminMainContents();
int insertMainContent(MainContentCommand command);
int updateMainContent(MainContentCommand command);
int updateDisplayOrder(MainContentOrderCommand command);
int updateDisplayStatus(MainContentStatusCommand command);
}
MainContentMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="example.selfmall.admin.MainContentMapper">
<!-- 샘플 전용 XML입니다. 실제 운영 DB에 바로 적용하지 않습니다. -->
<select id="selectActiveMainContents" resultType="example.selfmall.admin.MainContentDto">
SELECT content_id, content_type, title, image_url, link_url,
display_order, display_yn, start_at, end_at, updated_at
FROM main_content_admin
WHERE display_yn = 'Y'
AND (start_at IS NULL OR start_at <= CURRENT_TIMESTAMP)
AND (end_at IS NULL OR end_at >= CURRENT_TIMESTAMP)
ORDER BY display_order ASC, content_id DESC
</select>
<select id="selectAdminMainContents" resultType="example.selfmall.admin.MainContentDto">
SELECT content_id, content_type, title, image_url, link_url,
display_order, display_yn, start_at, end_at, updated_by, updated_at
FROM main_content_admin
ORDER BY content_type ASC, display_order ASC
</select>
<insert id="insertMainContent" parameterType="example.selfmall.admin.MainContentCommand">
INSERT INTO main_content_admin
(content_type, title, image_url, link_url, display_order, display_yn,
start_at, end_at, created_by, created_at, updated_by, updated_at)
VALUES
(#{contentType}, #{title}, #{imageUrl}, #{linkUrl}, #{displayOrder}, #{displayYn},
#{startAt}, #{endAt}, #{updatedBy}, CURRENT_TIMESTAMP, #{updatedBy}, CURRENT_TIMESTAMP)
</insert>
<update id="updateMainContent" parameterType="example.selfmall.admin.MainContentCommand">
UPDATE main_content_admin
SET title = #{title},
image_url = #{imageUrl},
link_url = #{linkUrl},
display_order = #{displayOrder},
display_yn = #{displayYn},
start_at = #{startAt},
end_at = #{endAt},
updated_by = #{updatedBy},
updated_at = CURRENT_TIMESTAMP
WHERE content_id = #{contentId}
</update>
</mapper>
MainContentService.java
package example.selfmall.admin;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 샘플 전용 Service입니다. validation, transaction, cache invalidation,
* audit logging 위치를 보여주기 위한 초안입니다.
*/
@Service
public class MainContentService {
private final MainContentMapper mapper;
private final MainContentCache cache;
private final AuditLogWriter auditLogWriter;
public MainContentService(MainContentMapper mapper, MainContentCache cache, AuditLogWriter auditLogWriter) {
this.mapper = mapper;
this.cache = cache;
this.auditLogWriter = auditLogWriter;
}
public List<MainContentDto> findAdminItems() {
return mapper.selectAdminMainContents();
}
@Transactional
public void save(MainContentCommand command) {
command.validateForAdmin();
if (command.isNew()) {
mapper.insertMainContent(command);
} else {
mapper.updateMainContent(command);
}
auditLogWriter.write("MAIN_CONTENT_SAVE", command.getContentId(), command.getUpdatedBy());
cache.evict("MAIN_CONTENT");
}
@Transactional
public void reorder(MainContentOrderCommand command) {
mapper.updateDisplayOrder(command);
auditLogWriter.write("MAIN_CONTENT_REORDER", command.getGroupKey(), command.getUpdatedBy());
cache.evict("MAIN_CONTENT");
}
public void refreshMainCache() {
cache.evict("MAIN_CONTENT");
cache.put("MAIN_CONTENT", mapper.selectActiveMainContents());
}
}
read_only_legacy_analysis_checklist.md
# 레거시 read-only 분석 체크리스트
이 문서는 접근 방식 증명용입니다. 실제 운영 소스를 포함하지 않습니다.
## 확인 순서
1. Spring/JSP/MyBatis 버전과 배포 구조 확인
2. 기존 어드민 권한, 세션, CSRF 정책 확인
3. 메인 배너, 프로모션, 상품 진열 관련 Controller/JSP/Mapper 이름만 목록화
4. DB 테이블 구조와 인덱스 후보를 read-only 쿼리로 확인
5. 캐싱 key, TTL, 수동 갱신 여부 확인
6. 이미지 업로드 경로와 운영 CDN/NAS 연동 방식 확인
7. 변경 대상 파일, 제외 파일, 롤백 대상 파일 목록 작성
## 산출물
- 변경 영향도 표
- 개발 범위 확정표
- 쿼리 최적화 후보
- 인수인계용 어드민 흐름 초안