
@Data를 사용하지 않고 필요한 애너테이션만 선택적으로 사용했다. 도메인 객체는 보통 불변(immutable)으로 만드는 게 좋고, 나중에 연관관계가 복잡해지면 toString에서 순환참조로 StackOverflow가 발생할 수 있기 때문이다.
Tag는 성능 최적화를 위해 popularDisplayName과 usageCount 필드를 추가했다. 이 값들은 스케줄러가 주기적으로 계산해서 캐싱한다. 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;
}
void 대신 int로 영향받은 행 수를 반환하게끔 하여 추후에 조용한 실패가 일어나는 상황을 제어하고자 했다.
동시성 문제를 해결하기 위해 insertTag 대신 upsertTag를 사용한다. MySQL의 ON DUPLICATE KEY UPDATE 구문을 활용하여 INSERT와 UPDATE를 하나의 atomic operation으로 처리한다.
스케줄러를 위한 deleteUnusedTags와 updateTagStatistics 메서드를 추가했다.
@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);
}
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
<?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>
<?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>
스케줄러를 사용하여 사용되지 않는 태그를 자동으로 정리하고, 태그 통계를 주기적으로 갱신한다.
@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);
}
}
TagService는 태그와 게시글 간의 관계를 관리하는 핵심 서비스다.
주요 기능:
@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();
}
}
자동완성 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);
}
}
태그 검색 성능을 개선하기 위해 인덱스를 추가했다.
-- 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);
초기에는 자동완성 요청마다 GROUP BY 연산이 발생했다. 태그가 수만 개로 늘어나면 성능 문제가 될 수 있어서, TAG 테이블에 POPULAR_DISPLAY_NAME과 USAGE_COUNT 컬럼을 추가하고 스케줄러로 주기적으로 갱신하는 방식을 도입했다.
-- TAG 테이블에 컬럼 추가
ALTER TABLE TAG ADD COLUMN POPULAR_DISPLAY_NAME VARCHAR(50);
ALTER TABLE TAG ADD COLUMN USAGE_COUNT BIGINT DEFAULT 0;
스케줄러가 매 시간 정각에 통계를 갱신하므로, 자동완성 API는 이미 계산된 값을 조회만 하면 된다. 캐시가 없는 경우(새로 생성된 태그)에는 실시간 조회로 fallback한다.
@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();
}
}
@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());
}
}
}
초기에는 try-catch-재조회 패턴을 사용했으나, 트랜잭션 가시성 문제로 서버 환경에서 실패했다. MySQL의 ON DUPLICATE KEY UPDATE를 활용한 UPSERT 패턴으로 변경하여 atomic operation으로 처리했다.
기존 방식 (문제):
SELECT → miss
INSERT → UNIQUE 충돌
SELECT → 재조회 실패 (트랜잭션 가시성 문제)
총 3번 쿼리
UPSERT 방식 (해결):
UPSERT → 성공
총 1번 쿼리