태그 시스템 구현

김소희·2025년 11월 26일

폴더구조

Domain

@Data를 사용하지 않고 필요한 애너테이션만 선택적으로 사용했다. 도메인 객체는 보통 불변(immutable)으로 만드는 게 좋고, 나중에 연관관계가 복잡해지면 toString에서 순환참조로 StackOverflow가 발생할 수 있기 때문이다.

Tag는 성능 최적화를 위해 popularDisplayNameusageCount 필드를 추가했다. 이 값들은 스케줄러가 주기적으로 계산해서 캐싱한다. FreeboardTag는 완전한 불변 객체로 만들었고, Freeboard는 조회 후 태그 리스트를 설정해야 하는 요구사항이 있어 클래스 레벨에 @Setter를 추가했다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Tag {
    private Long tagId;
    private String tagName;
    private String popularDisplayName;  // 캐시된 인기 표기
    private Long usageCount;            // 캐시된 사용 횟수
}

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FreeboardTag {
    private Long freeboardId;
    private Long tagId;
    private String tagDisplayName;
}

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Freeboard {
    private Long freeboardId;
    private Long userId;
    private String freeboardTitle;
    private String freeboardContent;
    private String freeboardPlainText;
    private Long freeboardClick;
    private String freeboardRepresentImage;
    private LocalDateTime freeboardCreatedAt;
    private String freeboardDeletedYn;
    private List<String> tags;
}

Mapper

void 대신 int로 영향받은 행 수를 반환하게끔 하여 추후에 조용한 실패가 일어나는 상황을 제어하고자 했다.

동시성 문제를 해결하기 위해 insertTag 대신 upsertTag를 사용한다. MySQL의 ON DUPLICATE KEY UPDATE 구문을 활용하여 INSERT와 UPDATE를 하나의 atomic operation으로 처리한다.

스케줄러를 위한 deleteUnusedTagsupdateTagStatistics 메서드를 추가했다.

@Mapper
public interface TagMapper {
    
    @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE)
    Optional<Tag> findByTagName(@Param("tagName") String tagName);
    
    // 동시성을 위해 insert를 upsert로 변경
    void upsertTag(Tag tag);
    
    List<Tag> findByTagNameStartingWith(@Param("keyword") String keyword);
    
    String findMostUsedDisplayName(@Param("tagId") Long tagId);
    
    Long countByTagId(@Param("tagId") Long tagId);
    
    // 스케줄러가 사용되지 않는 태그 삭제
    int deleteUnusedTags();
    
    // 스케줄러가 태그 통계 갱신
    int updateTagStatistics();
}

@Mapper
public interface FreeboardTagMapper {
    List<FreeboardTag> findByFreeboardId(@Param("freeboardId") Long freeboardId);
    List<FreeboardTag> findByFreeboardIdIn(@Param("freeboardIds") List<Long> freeboardIds);
    List<FreeboardTag> findByTagId(@Param("tagId") Long tagId);
    int insert(FreeboardTag freeboardTag);
    int deleteByFreeboardId(@Param("freeboardId") Long freeboardId);
    Long countByTagId(@Param("tagId") Long tagId);
}

application.yml

map-underscore-to-camel-case: true를 설정하여 데이터베이스의 대문자와 언더스코어를 코드상의 카멜케이스와 자동으로 매핑했다. 이를 통해 각 mapper xml마다 작성해야 했던 resultMap 코드 중복을 제거할 수 있었다.

# 기존 방식 (각 mapper.xml마다 작성)
<resultMap id="tagResultMap" type="kr.or.kosa.backend.tag.domain.Tag">
    <id property="tagId" column="TAG_ID"/>
    <result property="tagName" column="TAG_NAME"/>
</resultMap>

# application.yml에서 한 번에 처리
mybatis:
  mapper-locations: classpath:mappers/**/*.xml
  type-aliases-package: kr.or.kosa.backend
  configuration:
    map-underscore-to-camel-case: true

Mapper XML

TagMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="kr.or.kosa.backend.tag.mapper.TagMapper">

    <!-- 태그명으로 조회 (대소문자 구분 없음, 캐시 비활성화) -->
    <select id="findByTagName"
            resultType="kr.or.kosa.backend.tag.domain.Tag"
            useCache="false"
            flushCache="true">
        SELECT TAG_ID, TAG_NAME, POPULAR_DISPLAY_NAME, USAGE_COUNT
        FROM TAG
        WHERE LOWER(TAG_NAME) = LOWER(#{tagName})
        LIMIT 1
    </select>

    <!-- UPSERT: 동시성 문제 해결 -->
    <insert id="upsertTag" parameterType="kr.or.kosa.backend.tag.domain.Tag"
            useGeneratedKeys="true" keyProperty="tagId" keyColumn="TAG_ID">
        INSERT INTO TAG (TAG_NAME)
        VALUES (#{tagName})
        ON DUPLICATE KEY UPDATE
            TAG_ID = LAST_INSERT_ID(TAG_ID)
    </insert>

    <!-- 태그명으로 시작하는 태그 검색 (자동완성) -->
    <select id="findByTagNameStartingWith" resultType="kr.or.kosa.backend.tag.domain.Tag">
        SELECT TAG_ID, TAG_NAME, POPULAR_DISPLAY_NAME, USAGE_COUNT
        FROM TAG
        WHERE TAG_NAME LIKE CONCAT(#{keyword}, '%')
        ORDER BY USAGE_COUNT DESC, TAG_NAME
    </select>

    <!-- 가장 많이 사용된 표기 찾기 (fallback용) -->
    <select id="findMostUsedDisplayName" resultType="string">
        SELECT TAG_DISPLAY_NAME
        FROM (
            SELECT TAG_DISPLAY_NAME, COUNT(*) as cnt
            FROM CODEBOARD_TAG
            WHERE TAG_ID = #{tagId}
            GROUP BY TAG_DISPLAY_NAME
            UNION ALL
            SELECT TAG_DISPLAY_NAME, COUNT(*) as cnt
            FROM FREEBOARD_TAG
            WHERE TAG_ID = #{tagId}
            GROUP BY TAG_DISPLAY_NAME
        ) combined
        GROUP BY TAG_DISPLAY_NAME
        ORDER BY SUM(cnt) DESC
        LIMIT 1
    </select>

    <!-- 태그 사용 횟수 (fallback용) -->
    <select id="countByTagId" resultType="long">
        SELECT COUNT(*)
        FROM (
            SELECT TAG_ID FROM CODEBOARD_TAG WHERE TAG_ID = #{tagId}
            UNION ALL
            SELECT TAG_ID FROM FREEBOARD_TAG WHERE TAG_ID = #{tagId}
        ) combined
    </select>

    <!-- 사용되지 않는 태그 삭제 -->
    <delete id="deleteUnusedTags">
        DELETE FROM TAG
        WHERE TAG_ID NOT IN (
            SELECT DISTINCT TAG_ID FROM CODEBOARD_TAG
            UNION
            SELECT DISTINCT TAG_ID FROM FREEBOARD_TAG
        )
    </delete>

    <!-- 태그 통계 갱신 -->
    <update id="updateTagStatistics">
        UPDATE TAG t
        SET
            POPULAR_DISPLAY_NAME = (
                SELECT TAG_DISPLAY_NAME
                FROM (
                    SELECT TAG_DISPLAY_NAME, COUNT(*) as cnt
                    FROM CODEBOARD_TAG
                    WHERE TAG_ID = t.TAG_ID
                    GROUP BY TAG_DISPLAY_NAME
                    UNION ALL
                    SELECT TAG_DISPLAY_NAME, COUNT(*) as cnt
                    FROM FREEBOARD_TAG
                    WHERE TAG_ID = t.TAG_ID
                    GROUP BY TAG_DISPLAY_NAME
                ) combined
                GROUP BY TAG_DISPLAY_NAME
                ORDER BY SUM(cnt) DESC
                LIMIT 1
            ),
            USAGE_COUNT = (
                SELECT COUNT(*)
                FROM (
                    SELECT TAG_ID FROM CODEBOARD_TAG WHERE TAG_ID = t.TAG_ID
                    UNION ALL
                    SELECT TAG_ID FROM FREEBOARD_TAG WHERE TAG_ID = t.TAG_ID
                ) combined
            )
        WHERE EXISTS (
            SELECT 1 FROM CODEBOARD_TAG WHERE TAG_ID = t.TAG_ID
            UNION
            SELECT 1 FROM FREEBOARD_TAG WHERE TAG_ID = t.TAG_ID
        )
    </update>

</mapper>

FreeboardTagMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="kr.or.kosa.backend.tag.mapper.FreeboardTagMapper">

    <resultMap id="FreeboardTagMap"
               type="kr.or.kosa.backend.tag.domain.FreeboardTag">
        <id property="freeboardId" column="FREEBOARD_ID"/>
        <id property="tagId" column="TAG_ID"/>
        <result property="tagDisplayName" column="TAG_DISPLAY_NAME"/>
    </resultMap>

    <!-- 게시글의 태그 조회 -->
    <select id="findByFreeboardId" resultMap="FreeboardTagMap">
        SELECT FREEBOARD_ID, TAG_ID, TAG_DISPLAY_NAME
        FROM FREEBOARD_TAG
        WHERE FREEBOARD_ID = #{freeboardId}
    </select>

    <!-- 여러 게시글의 태그 한번에 조회 (N+1 방지) -->
    <select id="findByFreeboardIdIn" resultMap="FreeboardTagMap">
        SELECT FREEBOARD_ID, TAG_ID, TAG_DISPLAY_NAME
        FROM FREEBOARD_TAG
        WHERE FREEBOARD_ID IN
        <foreach collection="freeboardIds" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>

    <!-- 태그로 게시글 검색 -->
    <select id="findByTagId" resultMap="FreeboardTagMap">
        SELECT FREEBOARD_ID, TAG_ID, TAG_DISPLAY_NAME
        FROM FREEBOARD_TAG
        WHERE TAG_ID = #{tagId}
    </select>

    <!-- 태그 저장 -->
    <insert id="insert">
        INSERT INTO FREEBOARD_TAG (FREEBOARD_ID, TAG_ID, TAG_DISPLAY_NAME)
        VALUES (#{freeboardId}, #{tagId}, #{tagDisplayName})
    </insert>

    <!-- 게시글의 모든 태그 삭제 -->
    <delete id="deleteByFreeboardId">
        DELETE FROM FREEBOARD_TAG
        WHERE FREEBOARD_ID = #{freeboardId}
    </delete>

    <!-- 태그 사용 횟수 -->
    <select id="countByTagId" resultType="long">
        SELECT COUNT(*)
        FROM FREEBOARD_TAG
        WHERE TAG_ID = #{tagId}
    </select>

</mapper>

Scheduler

스케줄러를 사용하여 사용되지 않는 태그를 자동으로 정리하고, 태그 통계를 주기적으로 갱신한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class TagScheduler {

    private final TagMapper tagMapper;

    // 매일 새벽 3시에 실행
    @Scheduled(cron = "0 0 3 * * *")
    @Transactional
    public void cleanupUnusedTags() {
        log.info(">>> 사용되지 않는 태그 정리 시작");
        
        try {
            int deletedCount = tagMapper.deleteUnusedTags();
            log.info(">>> 사용되지 않는 태그 정리 완료: 삭제된 태그 수={}", deletedCount);
        } catch (Exception e) {
            log.error(">>> 태그 정리 중 오류 발생", e);
        }
    }

    // 매 시간 정각에 실행 (태그 통계 갱신)
    @Scheduled(cron = "0 0 * * * *")
    @Transactional
    public void updateTagStatistics() {
        log.info(">>> 태그 통계 갱신 시작");
        
        try {
            int updatedCount = tagMapper.updateTagStatistics();
            log.info(">>> 태그 통계 갱신 완료: 갱신된 태그 수={}", updatedCount);
        } catch (Exception e) {
            log.error(">>> 태그 통계 갱신 중 오류 발생", e);
        }
    }
}

Application 클래스에 @EnableScheduling 어노테이션을 추가한다.

@SpringBootApplication
@EnableScheduling
public class BackendApplication {
    public static void main(String[] args) {
        SpringApplication.run(BackendApplication.class, args);
    }
}

Service

TagService는 태그와 게시글 간의 관계를 관리하는 핵심 서비스다.

주요 기능:

  • getOrCreateTag: 태그 조회 또는 생성 (UPSERT로 동시성 처리)
  • attachTagsToFreeboard: 자유게시판에 태그 저장
  • getFreeboardTags: 게시글의 태그 조회
  • updateFreeboardTags: 게시글 수정 시 태그 업데이트
  • searchTagsForAutocomplete: 자동완성 검색 (캐시 활용)
  • searchFreeboardByTag: 태그로 게시글 검색
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TagService {

    private final TagMapper tagMapper;
    private final CodeboardTagMapper codeboardTagMapper;
    private final FreeboardTagMapper freeboardTagMapper;

    @Transactional
    public Tag getOrCreateTag(String tagInput) {
        String normalizedTagName = tagInput.trim().toLowerCase();

        Tag tag = Tag.builder()
                .tagName(normalizedTagName)
                .build();

        // UPSERT 실행 - MyBatis가 tag 객체의 tagId 필드에 값을 주입
        tagMapper.upsertTag(tag);

        return tag;
    }

    @Transactional
    public void attachTagsToCodeboard(Long codeboardId, List<String> tagInputs) {
        if (tagInputs == null || tagInputs.isEmpty()) {
            return;
        }

        for (String tagInput : tagInputs) {
            Tag tag = getOrCreateTag(tagInput.trim());

            CodeboardTag codeboardTag = CodeboardTag.builder()
                    .codeboardId(codeboardId)
                    .tagId(tag.getTagId())
                    .tagDisplayName(tagInput.trim())
                    .build();

            int result = codeboardTagMapper.insert(codeboardTag);
            if (result == 0) {
                throw new CustomBusinessException(TagErrorCode.TAG_SAVE_FAILED);
            }
        }

        log.info("코드게시판 태그 저장 완료: codeboardId={}, 태그 수={}", codeboardId, tagInputs.size());
    }

    @Transactional
    public void attachTagsToFreeboard(Long freeboardId, List<String> tagInputs) {
        if (tagInputs == null || tagInputs.isEmpty()) {
            return;
        }

        // 정규화된 이름 기준으로 중복 제거
        Map<String, String> uniqueTags = new LinkedHashMap<>();
        for (String tagInput : tagInputs) {
            String normalizedName = tagInput.toLowerCase().trim();
            if (!uniqueTags.containsKey(normalizedName)) {
                uniqueTags.put(normalizedName, tagInput.trim());
            }
        }

        for (Map.Entry<String, String> entry : uniqueTags.entrySet()) {
            String originalInput = entry.getValue();

            Tag tag = getOrCreateTag(originalInput);

            FreeboardTag freeboardTag = FreeboardTag.builder()
                    .freeboardId(freeboardId)
                    .tagId(tag.getTagId())
                    .tagDisplayName(originalInput)
                    .build();

            freeboardTagMapper.insert(freeboardTag);
        }
    }

    public Map<Long, List<String>> getFreeboardTagsMap(List<Long> freeboardIds) {
        if (freeboardIds == null || freeboardIds.isEmpty()) {
            return new HashMap<>();
        }

        List<FreeboardTag> freeboardTags = freeboardTagMapper.findByFreeboardIdIn(freeboardIds);

        if (freeboardTags == null || freeboardTags.isEmpty()) {
            return new HashMap<>();
        }

        return freeboardTags.stream()
                .filter(tag -> tag != null && tag.getTagDisplayName() != null)
                .collect(Collectors.groupingBy(
                        FreeboardTag::getFreeboardId,
                        Collectors.mapping(FreeboardTag::getTagDisplayName, Collectors.toList())
                ));
    }

    public Map<Long, List<String>> getCodeboardTagsMap(List<Long> codeboardIds) {
        if (codeboardIds == null || codeboardIds.isEmpty()) {
            return new HashMap<>();
        }

        List<CodeboardTag> codeboardTags = codeboardTagMapper.findByCodeboardIdIn(codeboardIds);

        if (codeboardTags == null || codeboardTags.isEmpty()) {
            return new HashMap<>();
        }

        return codeboardTags.stream()
                .filter(tag -> tag != null && tag.getTagDisplayName() != null)
                .collect(Collectors.groupingBy(
                        CodeboardTag::getCodeboardId,
                        Collectors.mapping(CodeboardTag::getTagDisplayName, Collectors.toList())
                ));
    }

    public List<String> getCodeboardTags(Long codeboardId) {
        List<CodeboardTag> tags = codeboardTagMapper.findByCodeboardId(codeboardId);

        if (tags == null || tags.isEmpty()) {
            return Collections.emptyList();
        }

        return tags.stream()
                .filter(tag -> tag != null && tag.getTagDisplayName() != null)
                .map(CodeboardTag::getTagDisplayName)
                .toList();
    }

    public List<String> getFreeboardTags(Long freeboardId) {
        List<FreeboardTag> tags = freeboardTagMapper.findByFreeboardId(freeboardId);

        if (tags == null || tags.isEmpty()) {
            return Collections.emptyList();
        }

        return tags.stream()
                .filter(tag -> tag != null && tag.getTagDisplayName() != null)
                .map(FreeboardTag::getTagDisplayName)
                .toList();
    }

    @Transactional
    public void updateCodeboardTags(Long codeboardId, List<String> tagInputs) {
        codeboardTagMapper.deleteByCodeboardId(codeboardId);

        if (tagInputs != null && !tagInputs.isEmpty()) {
            attachTagsToCodeboard(codeboardId, tagInputs);
        }
    }

    @Transactional
    public void updateFreeboardTags(Long freeboardId, List<String> tagInputs) {
        freeboardTagMapper.deleteByFreeboardId(freeboardId);

        if (tagInputs != null && !tagInputs.isEmpty()) {
            attachTagsToFreeboard(freeboardId, tagInputs);
        }
    }

    public List<TagAutocompleteDto> searchTagsForAutocomplete(String keyword, int limit) {
        if (keyword == null || keyword.trim().isEmpty()) {
            return new ArrayList<>();
        }

        if (limit < 1 || limit > 20) {
            limit = 10;
        }

        String lowerKeyword = keyword.toLowerCase().trim();
        List<Tag> tags = tagMapper.findByTagNameStartingWith(lowerKeyword);

        return tags.stream()
                .limit(limit)
                .map(tag -> {
                    // 캐시된 인기 표기 사용
                    String popularDisplay = tag.getPopularDisplayName();
                    if (popularDisplay == null || popularDisplay.isEmpty()) {
                        // 캐시가 없으면 실시간 조회 (fallback)
                        popularDisplay = tagMapper.findMostUsedDisplayName(tag.getTagId());
                        if (popularDisplay == null || popularDisplay.isEmpty()) {
                            popularDisplay = tag.getTagName();
                        }
                    }

                    // 캐시된 사용 횟수 사용
                    Long count = tag.getUsageCount();
                    if (count == null) {
                        // 캐시가 없으면 실시간 조회 (fallback)
                        count = tagMapper.countByTagId(tag.getTagId());
                    }

                    return TagAutocompleteDto.builder()
                            .tagId(tag.getTagId())
                            .tagDisplayName(popularDisplay)
                            .count(count != null ? count : 0L)
                            .build();
                })
                .sorted(Comparator.comparing(TagAutocompleteDto::getCount).reversed())
                .toList();
    }

    public List<Long> searchCodeboardByTag(String tagDisplay) {
        if (tagDisplay == null || tagDisplay.trim().isEmpty()) {
            return new ArrayList<>();
        }

        String normalizedTag = tagDisplay.toLowerCase().trim();

        Optional<Tag> tag = tagMapper.findByTagName(normalizedTag);
        if (tag.isEmpty()) {
            return new ArrayList<>();
        }

        return codeboardTagMapper.findByTagId(tag.get().getTagId())
                .stream()
                .map(CodeboardTag::getCodeboardId)
                .toList();
    }

    public List<Long> searchFreeboardByTag(String tagDisplay) {
        if (tagDisplay == null || tagDisplay.trim().isEmpty()) {
            return new ArrayList<>();
        }

        String normalizedTag = tagDisplay.toLowerCase().trim();

        Optional<Tag> tag = tagMapper.findByTagName(normalizedTag);
        if (tag.isEmpty()) {
            return new ArrayList<>();
        }

        return freeboardTagMapper.findByTagId(tag.get().getTagId())
                .stream()
                .map(FreeboardTag::getFreeboardId)
                .toList();
    }
}

Controller

자동완성 API에서 limit 파라미터는 결과 개수를 제한한다. 자동완성 결과가 너무 많으면 화면이 복잡해지기 때문이다.

예를 들어 "spr"을 검색했을 때 spring 관련 태그가 수십 개 나온다면 사용자 경험이 좋지 않다. 따라서 기본값을 5개로 설정하고, 필요에 따라 조정할 수 있도록 했다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/tag")
public class TagController {

    private final TagService tagService;

    @GetMapping("/autocomplete")
    public ResponseEntity<List<TagAutocompleteDto>> autocomplete(
            @RequestParam String keyword,
            @RequestParam(defaultValue = "5") int limit
    ) {
        List<TagAutocompleteDto> results = tagService.searchTagsForAutocomplete(keyword, limit);
        return ResponseEntity.ok(results);
    }

    @GetMapping("/codeboard/{codeboardId}")
    public ResponseEntity<List<String>> getCodeboardTags(@PathVariable Long codeboardId) {
        List<String> tags = tagService.getCodeboardTags(codeboardId);
        return ResponseEntity.ok(tags);
    }

    @GetMapping("/freeboard/{freeboardId}")
    public ResponseEntity<List<String>> getFreeboardTags(@PathVariable Long freeboardId) {
        List<String> tags = tagService.getFreeboardTags(freeboardId);
        return ResponseEntity.ok(tags);
    }

    @GetMapping("/search/codeboard")
    public ResponseEntity<List<Long>> searchCodeboardByTag(@RequestParam String tag) {
        List<Long> codeboardIds = tagService.searchCodeboardByTag(tag);
        return ResponseEntity.ok(codeboardIds);
    }

    @GetMapping("/search/freeboard")
    public ResponseEntity<List<Long>> searchFreeboardByTag(@RequestParam String tag) {
        List<Long> freeboardIds = tagService.searchFreeboardByTag(tag);
        return ResponseEntity.ok(freeboardIds);
    }
}

성능 최적화

1. 인덱스 추가

태그 검색 성능을 개선하기 위해 인덱스를 추가했다.

-- CODEBOARD_TAG 인덱스
CREATE INDEX IDX_CODEBOARD_TAG_TAG_ID ON CODEBOARD_TAG(TAG_ID, CODEBOARD_ID);
CREATE INDEX IDX_CODEBOARD_TAG_CODEBOARD_ID ON CODEBOARD_TAG(CODEBOARD_ID);

-- FREEBOARD_TAG 인덱스
CREATE INDEX IDX_FREEBOARD_TAG_TAG_ID ON FREEBOARD_TAG(TAG_ID, FREEBOARD_ID);
CREATE INDEX IDX_FREEBOARD_TAG_FREEBOARD_ID ON FREEBOARD_TAG(FREEBOARD_ID);

-- TAG 테이블 인덱스
CREATE INDEX IDX_TAG_NAME ON TAG(TAG_NAME);
CREATE INDEX IDX_TAG_USAGE_COUNT ON TAG(USAGE_COUNT DESC);

2. 캐싱 전략

초기에는 자동완성 요청마다 GROUP BY 연산이 발생했다. 태그가 수만 개로 늘어나면 성능 문제가 될 수 있어서, TAG 테이블에 POPULAR_DISPLAY_NAMEUSAGE_COUNT 컬럼을 추가하고 스케줄러로 주기적으로 갱신하는 방식을 도입했다.

-- TAG 테이블에 컬럼 추가
ALTER TABLE TAG ADD COLUMN POPULAR_DISPLAY_NAME VARCHAR(50);
ALTER TABLE TAG ADD COLUMN USAGE_COUNT BIGINT DEFAULT 0;

스케줄러가 매 시간 정각에 통계를 갱신하므로, 자동완성 API는 이미 계산된 값을 조회만 하면 된다. 캐시가 없는 경우(새로 생성된 태그)에는 실시간 조회로 fallback한다.

자유게시글에 태그 기능 적용

Request DTO

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FreeboardCreateRequest {

    @NotBlank(message = "제목은 필수입니다.")
    @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.")
    private String freeboardTitle;

    @NotNull(message = "내용은 필수입니다.")
    @Valid
    private List<BlockDto> blocks;

    @Size(max = 500, message = "대표 이미지 URL은 500자를 초과할 수 없습니다.")
    private String freeboardRepresentImage;

    @Size(max = 10, message = "태그는 최대 10개까지 가능합니다.")
    private List<@ValidTag String> tags;

    public FreeboardDto toDto() {
        return FreeboardDto.builder()
                .freeboardTitle(this.freeboardTitle)
                .blocks(this.blocks.stream()
                        .map(block -> FreeboardBlockResponse.builder()
                                .id(block.getId())
                                .type(block.getType())
                                .content(block.getContent())
                                .language(block.getLanguage())
                                .order(block.getOrder())
                                .build())
                        .toList())
                .freeboardRepresentImage(this.freeboardRepresentImage)
                .tags(this.tags)
                .build();
    }
}

FreeboardService

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FreeboardService {

    private final FreeboardMapper mapper;
    private final ObjectMapper objectMapper;
    private final TagService tagService;

    @Transactional
    public Freeboard detail(Long id) {
        mapper.increaseClick(id);

        Freeboard freeboard = mapper.selectById(id);
        if (freeboard == null) {
            throw new CustomBusinessException(FreeboardErrorCode.NOT_FOUND);
        }

        List<String> tags = tagService.getFreeboardTags(id);
        freeboard.setTags(tags);

        return freeboard;
    }

    @Transactional
    public Long write(FreeboardDto dto, Long userId) {
        String jsonContent;
        String plainText;
        try {
            jsonContent = dto.toJsonContent(objectMapper);
            plainText = dto.toPlainText(objectMapper);
        } catch (Exception e) {
            log.error("JSON 변환 실패", e);
            throw new CustomBusinessException(FreeboardErrorCode.JSON_PARSE_ERROR);
        }

        Freeboard freeboard = Freeboard.builder()
                .userId(userId)
                .freeboardTitle(dto.getFreeboardTitle())
                .freeboardContent(jsonContent)
                .freeboardPlainText(plainText)
                .freeboardRepresentImage(dto.getFreeboardRepresentImage())
                .freeboardDeletedYn("N")
                .build();

        int inserted = mapper.insert(freeboard);
        if (inserted == 0) {
            throw new CustomBusinessException(FreeboardErrorCode.INSERT_ERROR);
        }

        Long freeboardId = freeboard.getFreeboardId();

        if (dto.getTags() != null && !dto.getTags().isEmpty()) {
            tagService.attachTagsToFreeboard(freeboardId, dto.getTags());
        }

        return freeboardId;
    }

    @Transactional
    public void edit(Long id, FreeboardDto dto, Long userId) {
        Freeboard existing = mapper.selectById(id);
        if (existing == null) {
            throw new CustomBusinessException(FreeboardErrorCode.NOT_FOUND);
        }
        if (!existing.getUserId().equals(userId)) {
            throw new CustomBusinessException(FreeboardErrorCode.NO_EDIT_PERMISSION);
        }

        String jsonContent;
        String plainText;
        try {
            jsonContent = dto.toJsonContent(objectMapper);
            plainText = dto.toPlainText(objectMapper);
        } catch (Exception e) {
            log.error("JSON 변환 실패: freeboardId={}", id, e);
            throw new CustomBusinessException(FreeboardErrorCode.JSON_PARSE_ERROR);
        }

        Freeboard freeboard = Freeboard.builder()
                .freeboardId(id)
                .freeboardTitle(dto.getFreeboardTitle())
                .freeboardContent(jsonContent)
                .freeboardPlainText(plainText)
                .freeboardRepresentImage(dto.getFreeboardRepresentImage())
                .build();

        if (mapper.update(freeboard) == 0) {
            throw new CustomBusinessException(FreeboardErrorCode.UPDATE_ERROR);
        }

        if (dto.getTags() != null) {
            tagService.updateFreeboardTags(id, dto.getTags());
        }
    }
}

핵심 개선사항

1. 동시성 문제 해결

초기에는 try-catch-재조회 패턴을 사용했으나, 트랜잭션 가시성 문제로 서버 환경에서 실패했다. MySQL의 ON DUPLICATE KEY UPDATE를 활용한 UPSERT 패턴으로 변경하여 atomic operation으로 처리했다.

기존 방식 (문제):

SELECT → miss
INSERT → UNIQUE 충돌
SELECT → 재조회 실패 (트랜잭션 가시성 문제)
총 3번 쿼리

UPSERT 방식 (해결):

UPSERT → 성공
총 1번 쿼리

2. 성능 최적화

  • 인덱스 추가로 태그 검색 성능 향상
  • 스케줄러 기반 통계 캐싱으로 자동완성 성능 개선
  • N+1 쿼리 방지를 위한 IN 쿼리 활용

3. 유지보수성 향상

  • 사용되지 않는 태그 자동 정리
  • 캐시 fallback 로직으로 안정성 확보

전체코드

https://github.com/BillionDollarSohee/CodeNemsy_backend

profile
백엔드 개발자의 노트

0개의 댓글