- FreeMarker와 MinIO를 활용한 HTML 파일 생성
개념 및 흐름
文章 ID를 기준으로 데이터베이스의 content 테이블에서 conteúdo 정보를 조회합니다. content에 포함된 JSON과 FreeMarker 템플릿(tpl)을 파싱하여 HTML을 생성하고, 생성된 HTML 파일을 MinIO 스토리지에 업로드합니다. 업로드 완료 후 반환된 MinIO 접근 주소를 ap_article 테이블의 static_url 필드에 저장합니다.
구현 코드
package com.heima.article.test;
import com.alibaba.fastjson.JSONArray;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.heima.article.ArticleApplication;
import com.heima.article.mapper.ApArticleContentMapper;
import com.heima.article.mapper.ApArticleMapper;
import com.heima.file.service.FileStorageService;
import com.heima.model.article.pojos.ApArticle;
import com.heima.model.article.pojos.ApArticleContent;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import freemarker.template.Configuration;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@SpringBootTest(classes = ArticleApplication.class)
@RunWith(SpringRunner.class)
public class ArticleHtmlGeneratorTest {
@Autowired
private Configuration freemarkerConfig;
@Autowired
private FileStorageService storageService;
@Autowired
private ApArticleContentMapper contentMapper;
@Autowired
private ApArticleMapper articleMapper;
@Test
public void generateArticleHtml() throws TemplateException, IOException {
Long targetArticleId = 1302862387124125698L;
ApArticleContent articleContent = contentMapper.selectOne(
Wrappers.<ApArticleContent>lambdaQuery()
.eq(ApArticleContent::getArticleId, targetArticleId)
);
if (articleContent != null && StringUtils.isNotBlank(articleContent.getContent())) {
// 템플릿 로드 및 데이터 처리
Template template = freemarkerConfig.getTemplate("article.ftl");
Map<String, Object> dataModel = new HashMap<>();
dataModel.put("content", JSONArray.parseArray(articleContent.getContent()));
StringWriter writer = new StringWriter();
template.process(dataModel, writer);
// MinIO에 HTML 파일 업로드
InputStream inputStream = new ByteArrayInputStream(
writer.toString().getBytes(StandardCharsets.UTF_8)
);
String staticUrl = storageService.uploadHtmlFile(
"",
articleContent.getArticleId() + ".html",
inputStream
);
// 데이터베이스에 정적 URL 업데이트
ApArticle article = new ApArticle();
article.setId(articleContent.getArticleId());
article.setStaticUrl(staticUrl);
articleMapper.updateById(article);
}
}
}
- Interceptor에서 ThreadLocal을 활용하여 사용자 정보 관리
단계 1: Filter에서 사용자 ID를 Header에 저장
// JWT 토큰에서 추출한 사용자 ID를 HTTP Header에 추가
Object userIdClaim = claimsBody.get("id");
ServerHttpRequest mutatedRequest = request.mutate()
.headers(headers -> headers.add("userId", userIdClaim.toString()))
.build();
exchange.mutate().request(mutatedRequest).build();
단계 2: ThreadLocal 유틸리티 클래스 구현
package com.heima.utils.thread;
import com.heima.model.wemedia.pojos.WmUser;
public class WmThreadLocalHolder {
private static final ThreadLocal<WmUser> USER_CONTEXT = new ThreadLocal<>();
public static void setCurrentUser(WmUser user) {
USER_CONTEXT.set(user);
}
public static WmUser getCurrentUser() {
return USER_CONTEXT.get();
}
public static void clear() {
USER_CONTEXT.remove();
}
}
단계 3: HandlerInterceptor 구현
package com.heima.wemedia.interceptor;
import com.heima.model.wemedia.pojos.WmUser;
import com.heima.utils.thread.WmThreadLocalHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
public class WmAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String userIdHeader = request.getHeader("userId");
if (userIdHeader != null) {
WmUser user = new WmUser();
user.setId(Integer.valueOf(userIdHeader));
WmThreadLocalHolder.setCurrentUser(user);
log.info("사용자 인증 완료: {}", userIdHeader);
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
// 요청 처리 완료 후 ThreadLocal 정리
WmThreadLocalHolder.clear();
}
}
단계 4: Interceptor 등록
package com.heima.wemedia.config;
import com.heima.wemedia.interceptor.WmAuthInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WmWebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new WmAuthInterceptor())
.addPathPatterns("/**");
}
}
- 이미지 업로드 기능 구현
인터페이스 정의
| 항목 | 설명 |
|---|---|
| 인터페이스 경로 | /api/v1/material/upload_picture |
| 요청 방식 | POST |
| 파라미터 | MultipartFile |
| 응답 결과 | ResponseResult |
Controller 구현
package com.heima.wemedia.controller.v1;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.wemedia.service.WmMaterialService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/v1/material/")
public class WmMaterialController {
@Autowired
private WmMaterialService materialService;
@PostMapping("/upload_picture")
public ResponseResult uploadImage(MultipartFile imageFile) {
return materialService.uploadImage(imageFile);
}
}
Mapper 정의
package com.heima.wemedia.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.heima.model.wemedia.pojos.WmMaterial;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WmMaterialMapper extends BaseMapper<WmMaterial> {
}
Service 구현
package com.heima.wemedia.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.heima.file.service.FileStorageService;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import com.heima.model.wemedia.pojos.WmMaterial;
import com.heima.utils.thread.WmThreadLocalHolder;
import com.heima.wemedia.mapper.WmMaterialMapper;
import com.heima.wemedia.service.WmMaterialService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Date;
import java.util.UUID;
@Service
@Transactional
@Slf4j
public class WmMaterialServiceImpl extends ServiceImpl<WmMaterialMapper, WmMaterial>
implements WmMaterialService {
@Autowired
private FileStorageService fileStorageService;
@Override
public ResponseResult uploadImage(MultipartFile imageFile) {
if (imageFile == null || imageFile.getSize() == 0) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
// 고유 파일명 생성
String uniqueId = UUID.randomUUID().toString().replace("-", "");
String originalName = imageFile.getOriginalFilename();
String extension = originalName.substring(originalName.lastIndexOf("."));
// MinIO에 이미지 업로드
String storagePath = "";
try {
storagePath = fileStorageService.uploadImgFile(
"",
uniqueId + extension,
imageFile.getInputStream()
);
} catch (IOException e) {
log.error("이미지 업로드 실패: {}", e.getMessage());
throw new RuntimeException("파일 업로드 중 오류가 발생했습니다.", e);
}
// 자료 정보 저장
WmMaterial material = new WmMaterial();
material.setUserId(WmThreadLocalHolder.getCurrentUser().getId());
material.setUrl(storagePath);
material.setType((short) 0);
material.setCreatedTime(new Date());
material.setIsCollection((short) 0);
save(material);
log.info("이미지 업로드 완료: {}", storagePath);
return ResponseResult.okResult(material);
}
}
- 자료 목록 조회 기능
인터페이스 정보
| 항목 | 설명 |
|---|---|
| 인터페이스 경로 | /api/v1/material/list |
| 요청 방식 | POST |
| 파라미터 | WmMaterialDto |
| 응답 결과 | ResponseResult |
Controller
@PostMapping("/list")
public ResponseResult getMaterialList(@RequestBody WmMaterialDto dto) {
return materialService.queryMaterialList(dto);
}
DTO 클래스
package com.heima.model.wemedia.dtos;
import com.heima.model.common.dtos.PageRequestDto;
import lombok.Data;
@Data
public class WmMaterialDto extends PageRequestDto {
/**
* 1:收藏, 0:未收藏
*/
private Short isCollection;
}
Service 로직
@Override
public ResponseResult queryMaterialList(WmMaterialDto dto) {
dto.checkParam();
IPage page = new Page<>(dto.getPage(), dto.getSize());
LambdaQueryWrapper<WmMaterial> queryWrapper = new LambdaQueryWrapper<>();
//收藏 여부 필터
if (dto.getIsCollection() != null && dto.getIsCollection() == 1) {
queryWrapper.eq(WmMaterial::getIsCollection, dto.getIsCollection());
}
// 현재 로그인한 사용자의 자료만 조회
queryWrapper.eq(WmMaterial::getUserId, WmThreadLocalHolder.getCurrentUser().getId());
// 생성일시 기준 내림차순 정렬
queryWrapper.orderByDesc(WmMaterial::getCreatedTime);
page = page(page, queryWrapper);
PageResponseResult result = new PageResponseResult(
dto.getPage(),
dto.getSize(),
(int) page.getTotal()
);
result.setData(page.getRecords());
return result;
}
- 채널 목록 조회
인터페이스 정보
| 항목 | 설명 |
|---|---|
| 인터페이스 경로 | /api/v1/channel/channels |
| 요청 방식 | GET |
| 응답 결과 | ResponseResult |
Controller
package com.heima.wemedia.controller.v1;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.wemedia.service.WmChannelService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/channel/")
public class WmChannelController {
@Autowired
private WmChannelService channelService;
@GetMapping("/channels")
public ResponseResult getAllChannels() {
return channelService.findAll();
}
}
POJO 클래스
package com.heima.model.wemedia.pojos;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("wm_channel")
public class WmChannel implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField("name")
private String name;
@TableField("description")
private String description;
@TableField("is_default")
private Boolean isDefault;
@TableField("status")
private Boolean status;
@TableField("ord")
private Integer ord;
@TableField("created_time")
private Date createdTime;
}
Service 구현
package com.heima.wemedia.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.wemedia.pojos.WmChannel;
import com.heima.wemedia.mapper.WmChannelMapper;
import com.heima.wemedia.service.WmChannelService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
@Slf4j
public class WmChannelServiceImpl extends ServiceImpl<WmChannelMapper, WmChannel>
implements WmChannelService {
@Override
public ResponseResult findAll() {
return ResponseResult.okResult(list());
}
}
- 文章 목록 조회
인터페이스 정보
| 항목 | 설명 |
|---|---|
| 인터페이스 경로 | /api/v1/news/list |
| 요청 방식 | POST |
| 파라미터 | WmNewsPageReqDto |
| 응답 결과 | ResponseResult |
Controller 구현
package com.heima.wemedia.controller.v1;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.wemedia.dtos.WmNewsPageReqDto;
import com.heima.wemedia.service.WmNewsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/news")
public class WmNewsController {
@Autowired
private WmNewsService newsService;
@PostMapping("/list")
public ResponseResult getNewsList(@RequestBody WmNewsPageReqDto dto) {
return newsService.findList(dto);
}
}
Mapper 정의
package com.heima.wemedia.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.heima.model.wemedia.pojos.WmNews;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WmNewsMapper extends BaseMapper<WmNews> {
}
Service 구현
package com.heima.wemedia.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.heima.model.common.dtos.PageResponseResult;
import com.heima.model.common.dtos.ResponseResult;
import com.heima.model.common.enums.AppHttpCodeEnum;
import com.heima.model.wemedia.dtos.WmNewsPageReqDto;
import com.heima.model.wemedia.pojos.WmNews;
import com.heima.utils.thread.WmThreadLocalHolder;
import com.heima.wemedia.mapper.WmNewsMapper;
import com.heima.wemedia.service.WmNewsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
@Slf4j
public class WmNewsServiceImpl extends ServiceImpl<WmNewsMapper, WmNews>
implements WmNewsService {
@Override
public ResponseResult findList(WmNewsPageReqDto dto) {
// 사용자 인증 확인
if (WmThreadLocalHolder.getCurrentUser() == null || dto == null) {
return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID);
}
dto.checkParam();
IPage page = new Page<>(dto.getPage(), dto.getSize());
LambdaQueryWrapper<WmNews> queryWrapper = new LambdaQueryWrapper<>();
// 상태 조건查询
if (dto.getStatus() != null) {
queryWrapper.eq(WmNews::getStatus, dto.getStatus());
}
// 채널 조건查询
if (dto.getChannelId() != null) {
queryWrapper.eq(WmNews::getChannelId, dto.getChannelId());
}
//发布日期 범위 查询
if (dto.getBeginPubDate() != null && dto.getEndPubDate() != null) {
queryWrapper.between(WmNews::getPublishTime, dto.getBeginPubDate(), dto.getEndPubDate());
}
// 키워드 검색
if (dto.getKeyword() != null) {
queryWrapper.like(WmNews::getTitle, dto.getKeyword());
}
// 현재 로그인한 사용자의 文章만 조회
queryWrapper.eq(WmNews::getUserId, WmThreadLocalHolder.getCurrentUser().getId());
// 发布일시 내림차순 정렬
queryWrapper.orderByDesc(WmNews::getPublishTime);
page = page(page, queryWrapper);
PageResponseResult result = new PageResponseResult(
dto.getPage(),
dto.getSize(),
(int) page.getTotal()
);
result.setData(page.getRecords());
return result;
}
}
본篇文章에서는 SpringCloud 프로젝트의 핵심 기능들인 HTML 정적 파일 생성, 사용자 정보 관리, 이미지 업로드, 자료 및 文章 목록 조회 기능을 상세히 살펴보았습니다. 각 기능은 Microservice 아키텍처에서 독립적으로 동작하면서 서로 협업하는 구조로 설계되었습니다.