SpringCloud 프로젝트 3단계: HTML 파일 생성 및 자료 CRUD 구현

  1. 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);
        }
    }
}
  1. 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("/**");
    }
}
  1. 이미지 업로드 기능 구현

인터페이스 정의

항목 설명
인터페이스 경로 /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);
    }
}
  1. 자료 목록 조회 기능

인터페이스 정보

항목 설명
인터페이스 경로 /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;
}
  1. 채널 목록 조회

인터페이스 정보

항목 설명
인터페이스 경로 /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());
    }
}
  1. 文章 목록 조회

인터페이스 정보

항목 설명
인터페이스 경로 /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 아키텍처에서 독립적으로 동작하면서 서로 협업하는 구조로 설계되었습니다.

태그: SpringCloud FreeMarker MinIO ThreadLocal Interceptor

5월 29일 05:24에 게시됨