• pom.xml에 디펜더시 추가(maven 기준)
    • 버전은 maven repo나 git에서 확인
		<!-- https://mvnrepository.com/artifact/io.sentry/sentry-logback -->
        <dependency>
            <groupId>io.sentry</groupId>
            <artifactId>sentry-logback</artifactId>
            <version>3.2.0</version>
        </dependency>

 

  • logback.properties에 설정 추가
    • logback-spring.xml에서 참조하는 properties파일
#sentry에 전송시 environment 구분 값(ex. local, alpha, beta, qa, real)
sentry.environment=입력필요

#sentry 에서 dsn정보 확인 후 입력
sentry.dsn=입력필요
  • logback-spring.xml 에 설정 추가
    • 아래 appender추가 후 전송할 logger에 꼭 appender-ref ref를 추가해야함
<!-- Configure the Sentry appender -->
    <appender name="SENTRY" class="io.sentry.logback.SentryAppender">
        <!-- Optionally change minimum Event level. Default for Events is ERROR -->
        <minimumEventLevel>ERROR</minimumEventLevel>
        <!-- Optionally change minimum Breadcrumbs level. Default for Breadcrumbs is INFO -->
        <minimumBreadcrumbLevel>DEBUG</minimumBreadcrumbLevel>
        <options>
            <!-- NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard -->
            <dsn>${sentry.dsn}</dsn>
            <environment>${sentry.environment}</environment>
        </options>
    </appender>

 

 

2026-06 간단히 내용 몇 가지 업데이트

 - 원래 작성했던 글은 메모 및 간단히 동료에게 공유해줄 목적이어서 실제 구현대비 누락된 내용이 많았습니다.

 - 시간될때마다 내용 업데이트를 해보겠습니다.(but, 요즘에는 AI가 워낙 좋아서 요구사항을 잘 프롬프트하면 더 좋은 결과가 나와서 효용성이 없을지도..)


1. 정규표현식(Regex)의 성능 한계

정규표현식은 유연하지만 텍스트를 검사할 때 백트래킹(Backtracking) 이라는 연산 과정을 거치기 때문에 기본적으로 무거운 작업입니다.

  • 검사 방식: 금칙어가 1,000개라면, 사용자가 글을 하나 쓸 때마다 1,000개의 정규식 패턴을 순회하며 매칭을 시도해야 합니다.
  • 시간 복잡도: 대략 O(N * M) (N: 입력 텍스트 길이, M: 금칙어 개수)
  • 문제점: 금칙어 사전(Dictionary)의 크기가 커질수록 성능이 선형적으로 하락합니다. 악의적인 사용자가 매우 긴 텍스트를 입력하면 CPU 사용량이 급증하는 병목 지점이 될 수 있습니다.

2. 텍스트 정규화 + Aho-Corasick의 압도적 성능

Aho-Corasick 알고리즘은 애초에 '다중 문자열 검색'을 위해 탄생한 알고리즘입니다.

  • 검사 방식: 금칙어 1,000개를 하나의 거대한 트리(Trie) 구조와 실패 링크(Failure Link)를 가진 오토마타로 미리 만들어 둡니다. 사용자의 텍스트를 단 한 번만 쭉 읽으면서(1 Pass) 1,000개의 금칙어 포함 여부를 동시에 찾아냅니다.
  • 시간 복잡도: O(N + P) (N: 텍스트 길이, P: 찾은 금칙어 개수)
  • 장점: 금칙어가 10개든 10만 개든 검색 속도가 거의 똑같습니다. 정규화(특수문자 제거) 과정 역시 O(N)으로 매우 빠르기 때문에 전체적인 성능이 정규식과 비교할 수 없을 정도로 뛰어납니다.

💡 실무 적용 꿀팁: "성능도 챙기고 마스킹(***)도 하고 싶다면?"

정규화는 인덱스가 틀어져서 마스킹(*** 치환)이 까다로운데, 실무에서는 성능을 위해 Aho-Corasick을 쓰면서도, 이 문제를 '인덱스 매핑(Index Mapping)' 기법으로 해결합니다.

  1. 매핑 배열 생성: 원본 텍스트를 정규화하면서, 정규화된 글자가 원본 텍스트의 몇 번째 인덱스인지 기록해 둡니다.
    • 원본: 욕 1설 (인덱스: 0 1 2 3)
    • 정규화: 욕설
    • 매핑 배열: [0, 3] ('욕'은 원본의 0번, '설'은 원본의 3번)
  2. Aho-Corasick 검색: 정규화된 욕설을 검사해서 금칙어를 찾습니다.
  3. 원본 치환: 찾아낸 금칙어의 시작/끝 글자를 매핑 배열에서 조회한 뒤, 원본 텍스트의 0번부터 3번 인덱스까지를 ***로 치환합니다.

최종 권장: 성능이 중요한 서비스(트래픽이 많거나 텍스트 길이가 긴 게시판, 실시간 채팅 등)라면 무조건 텍스트 정규화 + Aho-Corasick (필요시 인덱스 매핑 추가) 조합으로 개발하시는 것을 강력히 추천합니다!

 

CREATE TABLE `banned_word` (
    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자 (PK)',
    `word` VARCHAR(100) NOT NULL COMMENT '금칙어 (다국어 및 이모지 지원)',
    `category` VARCHAR(50) DEFAULT 'GENERAL' COMMENT '금칙어 유형 (예: 욕설, 도박, 혐오, 광고 등)',
    `is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '적용 여부 (1: 활성, 0: 비활성)',
    `created_by` VARCHAR(50) DEFAULT NULL COMMENT '등록자 ID 또는 이름',
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '등록 일시',
    `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_banned_word_word` (`word`),
    KEY `idx_banned_word_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='금칙어 관리 테이블';

 

테이블 설계 포인트

  • CHARSET=utf8mb4 & COLLATE=utf8mb4_0900_ai_ci: 다국어와 이모지 지원을 위한 핵심 설정이야. 대소문자 구분 없이(Case-Insensitive), 악센트 구분 없이(Accent-Insensitive) 유연하게 텍스트를 처리해 줘.
  • UNIQUE KEY (word): 똑같은 금칙어가 중복으로 등록되는 것을 데이터베이스 단에서 원천 차단해. Aho-Corasick 트리를 메모리에 올릴 때 중복 데이터가 있으면 낭비니까 미리 막는 게 좋아.
  • is_active: 금칙어를 당장 삭제하지 않고 '잠시 사용 안 함' 처리할 때 유용해. Spring Boot 서버가 뜰 때(또는 주기적으로 캐시를 갱신할 때) SELECT word FROM banned_word WHERE is_active = 1 쿼리로 활성화된 단어만 가져와서 Aho-Corasick 오토마타를 구성하면 돼.
  • idx_banned_word_is_active 인덱스: 앞서 말한 대로 서버가 구동되거나 금칙어 트리를 메모리에서 리로드할 때, 활성화된 금칙어만 빠르게 조회하기 위해 인덱스를 걸어두었어.

 

 

💻 Aho-Corasick 금칙어 필터링 구현체 (Java 21)

import java.util.*;

public class BannedWordFilter {

    // 1. Trie 노드 정의 (내부 클래스)
    private static class Node {
        // 자식 노드들 (검색 속도를 위해 HashMap 사용)
        final Map<Character, Node> children = new HashMap<>();
        // 매칭 실패 시 돌아갈 링크 (성능의 핵심!)
        Node fail;
        // 이 노드에서 끝나는 금칙어가 있는지 여부
        boolean isOutput;
    }

    private final Node root = new Node();

    /**
     * 금칙어 목록을 받아 Aho-Corasick 오토마타를 구성합니다.
     * (이 작업은 무거우므로 Spring Boot 실행 시 한 번만 수행하여 캐싱하는 것을 권장)
     */
    public void buildAutomaton(List<String> bannedWords) {
        // [Step 1] Trie 구조 만들기
        for (String word : bannedWords) {
            Node current = root;
            for (char c : word.toCharArray()) {
                current.children.computeIfAbsent(c, k -> new Node());
            }
            current.isOutput = true;
        }

        // [Step 2] 실패 링크(Failure Link) 구성 (BFS 방식)
        Queue<Node> queue = new ArrayDeque<>();
        for (Node child : root.children.values()) {
            child.fail = root;
            queue.offer(child);
        }

        while (!queue.isEmpty()) {
            Node current = queue.poll();

            for (Map.Entry<Character, Node> entry : current.children.entrySet()) {
                char c = entry.getKey();
                Node child = entry.getValue();

                // 실패 링크 찾기
                Node failNode = current.fail;
                while (failNode != null && !failNode.children.containsKey(c)) {
                    failNode = failNode.fail;
                }

                if (failNode == null) {
                    child.fail = root;
                } else {
                    child.fail = failNode.children.get(c);
                }

                // 부모나 실패 링크에 금칙어가 포함되어 있다면, 자식 노드도 금칙어 포함으로 간주
                child.isOutput |= child.fail.isOutput;

                queue.offer(child);
            }
        }
    }

    /**
     * 사용자가 입력한 텍스트에 금칙어가 포함되어 있는지 검사합니다.
     */
    public boolean containsBannedWord(String text) {
        if (text == null || text.isBlank()) {
            return false;
        }

        // [핵심] 특수문자, 숫자, 공백 제거 (욕1설, 욕!설 -> 욕설)
        String normalizedText = text.replaceAll("[^가-힣a-zA-Z]", "");

        Node current = root;
        for (char c : normalizedText.toCharArray()) {
            // 현재 문자가 자식 노드에 없으면 실패 링크를 따라감 (백트래킹 방지)
            while (current != null && !current.children.containsKey(c)) {
                current = current.fail;
            }

            if (current == null) {
                current = root; // 루트까지 와도 없으면 다시 처음부터
                continue;
            }

            current = current.children.get(c);

            // 금칙어를 발견한 경우 즉시 true 반환
            if (current.isOutput) {
                return true;
            }
        }
        return false;
    }

    // --- 테스트용 메인 메서드 ---
    public static void main(String[] args) {
        BannedWordFilter filter = new BannedWordFilter();
        
        // 1. 금칙어 트리 구성 (서버 시작 시 1회 실행)
        List<String> bannedWords = List.of("욕설", "도박", "광고");
        filter.buildAutomaton(bannedWords);

        // 2. 텍스트 검사
        String input1 = "이런 욕1설, 진짜 나쁜 놈이네";
        String input2 = "도!!!박 사이트 오세요";
        String input3 = "안녕하세요 반갑습니다";

        System.out.println("input1 검사 결과: " + filter.containsBannedWord(input1)); // true
        System.out.println("input2 검사 결과: " + filter.containsBannedWord(input2)); // true
        System.out.println("input3 검사 결과: " + filter.containsBannedWord(input3)); // false
    }
}

 

 

💡 핵심 자료구조 설명 및 성능 포인트

  1. Trie (접두사 트리):
    • 여러 개의 단어를 공통된 앞글자(접두사) 기준으로 합쳐서 트리를 만들어.
    • "도박", "도둑" 이라는 두 개의 금칙어가 있다면, '도'라는 하나의 노드 아래에 '박', '둑'이 갈라져 나오게 돼. 메모리도 절약되고 앞글자가 다르면 일찍 검색을 포기할 수 있어 속도가 빨라져.
  2. Failure Link (실패 링크):
    • 검색을 하다가 중간에 매칭이 끊겼을 때, "처음부터 다시 검사하는 대신, 어디로 돌아가면 가장 효율적인가?" 를 미리 계산해 둔 포인터야.
    • 이 덕분에 텍스트의 처음부터 끝까지 단 한 번만(O(N) 시간 복잡도) 훑고 지나가면 금칙어 포함 여부를 100% 잡아낼 수 있어.
  3. Spring Boot 연동 시 주의사항:
    • buildAutomaton 메서드는 DB에 있는 모든 금칙어를 가져와서 트리를 만드는 무거운 작업이야.
    • 따라서 @Service 클래스 내에서 @PostConstruct를 활용해 스프링 빈(Bean) 초기화 시점에 딱 한 번만 실행되도록 구성하고, 금칙어 추가/삭제 시에만 트리를 재구성(Re-build)하도록 캐싱 전략을 짜는 것을 권장해.

 

서비스를 개발하다보면 항상 금칙어처리가 필요하게 됩니다.

금칙어 갯수가 적으면 상관 없는데 갯수가 많으면(특히 중국 서비스하면..;) 성능을 잘 생각해서 처리해야 합니다.

 

관련해서 참고용 TC를 만들어봤습니다.

간단히 만들어서 TC종류는 많지 않고 부족한 부분이 있을 수 있습니다.


1. 금칙어 저장테이블 DDL(참고용)

-- 금칙어 테이블 DDL샘플(Mysql). 글로벌 다국어를 감안하여 금칙어 컬럼은 'utf8mb4_bin'로 정의
CREATE TABLE `bad_word` (
  `pk` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'pk',
  `use_yn` ENUM('Y','N') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Y' COMMENT '사용여부',
  `bad_word` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '금칙어',
  PRIMARY KEY (`pk`),
  UNIQUE KEY `UNQ_badWord` (`bad_word`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='금칙어'

 

2. maven dependency

 

<!-- https://mvnrepository.com/artifact/org.ahocorasick/ahocorasick -->
<dependency>
    <groupId>org.ahocorasick</groupId>
    <artifactId>ahocorasick</artifactId>
    <version>0.4.0</version>
</dependency>

3. test case

import org.ahocorasick.trie.Emit;
import org.ahocorasick.trie.Trie;
import org.apache.commons.lang3.RandomStringUtils;
import org.junit.Assert;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;

import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.concurrent.TimeUnit;

/**
 * 금칙어 성능 테스트
 *  - 대량(10만개 이상)의 금칙어 키워드 존재시 금칙어 여부 판단에 성능 이슈가 없도록 처리하는 테스트(샘플) 소스
 *  - 아호코라식 알고리즘을 활용: https://en.wikipedia.org/wiki/Aho%E2%80%93Corasick_algorithm
 *
 * @author
 */
class BadWordPerformanceTest {

	private static int initDummyBadwordCnt = 100_000; //더미용 금칙어 초기화 갯수

	private static final String findBadword = "개새끼"; //테스트용 금칙어
	private static final String findBadword2 = "소새끼"; //테스트용 금칙어2

	private static LinkedHashSet<String> badwords = new LinkedHashSet<>(); //linkedhaset이 contain 성능이 가장 좋음: https://dzone.com/articles/java-collection-performance
	private static Trie badwordsTrie; //아호코라식용

	@BeforeAll
	static void init() {

		for (int i = 1; i <= initDummyBadwordCnt; i++) {

			//String randomBadWord = RandomStringUtils.random(30, false, false);
			String randomBadWord = RandomStringUtils.randomAlphanumeric(30);
			badwords.add(randomBadWord);
		}

		System.out.println(String.format("init 금칙어 갯수(컬렉션용): %d", badwords.size()));

		//아호코라식용 초기화
		long startInitAho = System.currentTimeMillis();
		badwordsTrie = Trie.builder().addKeywords(badwords).addKeyword(findBadword).addKeyword(findBadword2).build(); //시간이 많이걸리니까 가능하면 초기화 후 재 사용
		//badwordsTrie = Trie.builder().addKeywords(badwords).addKeyword(findBadword).addKeyword(findBadword2).onlyWholeWords().build(); //시간이 많이걸리니까 가능하면 초기화 후 재 사용
		//badwordsTrie = Trie.builder().ignoreCase().ignoreOverlaps().addKeywords(badwords).build(); //아호코라식용 초기화

		long endInitAho = System.currentTimeMillis();
		System.out.println("아호코라식 초기화 소요시간(ms): " + (endInitAho - startInitAho));
	}

	/**
	 * 아호코라식으로도 완전일치 테스트가 가능하지만 java컬렉션을 이용해서도 구현
	 */
	@Test
	@Timeout(value = 20, unit = TimeUnit.MILLISECONDS)
	public void 금칙어_완전일치_테스트() {

		badwords.add(findBadword); //테스트용 금칙어를 금칙어 셋에 추가해둠(성능 테스트를 위해 만든 대량의 금칙어에 추가)

		final String notExistBadword = findBadword + System.currentTimeMillis(); //확률적으로 존재할 수 없는 금칙어

		long startExactNano = System.nanoTime();
		long startExactms = System.currentTimeMillis();

		Assert.assertTrue(badwords.contains(findBadword));
		Assert.assertFalse(badwords.contains(notExistBadword));

		long endExactNano = System.nanoTime();
		long endExactMs = System.currentTimeMillis();

		System.out.println("\n\n완전일치 금칙어 find 소요시간(nano): " + (endExactNano - startExactNano));
		System.out.println("완전일치 금칙어 find 소요시간(ms): " + (endExactMs - startExactms));

	}

	/**
	 * 성능을 위해서 포함여부 체크는 아호코라식 알고리즘을 사용
	 *  - 구현 java 라이브러리: https://github.com/robert-bor/aho-corasick (maven mvnrepository에는 배포를 안하니 참고해서 직접 구현하거나 소스 내려받아서 빌드 후 사용)
	 */
	@Test
	@Timeout(value = 20, unit = TimeUnit.MILLISECONDS)
	public void 금칙어_포함여부_아호코라식알고리즘기반_테스트() {

		String targetText_1 = "개새끼들이 뛰어놀고 있어요. 소 는 없어요";
		Collection<Emit> emits_1 = excuteAho(targetText_1);
		Assert.assertTrue(emits_1.size() == 1);

		String targetText_2 = "개새끼들이 뛰어놀고 있어요. 옆에는 소새끼들이 있어요";
		Collection<Emit> emits_2 = excuteAho(targetText_2);
		Assert.assertTrue(emits_2.size() == 2);

		String targetText_3 = "개가 뛰어놀고 있어요. 옆에는 소도 있어요";
		Collection<Emit> emits_3 = excuteAho(targetText_3);
		System.out.println(emits_3);
		Assert.assertTrue(emits_3.size() == 0);

	}

	private Collection<Emit> excuteAho(String targetText) {

		System.out.println("\n===== excuteAho: Start ");
		System.out.println("금칙어가 존재하는지 검사할 텍스트:==>" + targetText);

		long startNano = System.nanoTime();
		long startMs = System.currentTimeMillis();

		Collection<Emit> emits = badwordsTrie.parseText(targetText);
		System.out.println("검출된 금칙어 갯수: " + emits.size());
		for (Emit emit : emits) {
			System.out.println(String.format("  금칙어 '%s'에 매칭됨", emit.getKeyword()));
		}

		long endNano = System.nanoTime();
		long endMs = System.currentTimeMillis();

		long duNano = endNano - startNano;
		long duMs = endMs - startMs;

		System.out.println(String.format("아호코라식 기반 금칙어 판별 소요시간. '%d(nano)' | '%d(ms)'", duNano, duMs));
		System.out.println("===== excuteAho: End ");

		return emits;

	}

}

/**
	 * 간단한 컬렉션 contains 성능테스트
	 *  -  https://dzone.com/articles/java-collection-performance
	 *
	 * @param args
	 */
	public static void main(String[] args) {

		long min = 999999999;
		long max = 0;
		for (int t = 0; t < 200; t++) {

			int cnt = 30000;
			LinkedHashSet<String> badwords = new LinkedHashSet<String>(); //min:500 ||  max:15200
			//ArrayList<String> badwords = new ArrayList<String>(); min:148100 ||  max:1110400

			//TreeSet<String> badwords = new TreeSet<String>(); //min:3000 ||  max:31000
			for (int i = 1; i <= cnt; i++) {
				badwords.add(String.valueOf(i));
			}

			long startMs = System.currentTimeMillis();

			long start = System.nanoTime();
			badwords.contains(String.valueOf(cnt));

			long end = System.nanoTime();
			long endMs = System.currentTimeMillis();

			long du = end - start;
			System.out.println(du);

			if (du > max) {
				max = du;
			}

			if (du < min) {
				min = du;
			}

			System.out.println("ms: " + (endMs - startMs));
		}

		System.out.println("\n\n");
		System.out.println("min:" + min + " ||  max:" + max);

	}

전에는 Guava cache, Ehcache등을 많이 사용했는데, 최근에는 Caffeine가 권장되고 있음

 

  1. Java 8 이상에서만 사용 가능
  2. Spring에서도 지원하는 구현체가 추가되었고 5.0에서는 Guava Cache 지원이 없어짐
  3. Guava cache 개발자가 다시 만든 라이브러리
  4. 앞으로 발전 가능성도 높은편

 

추후 여유되면 

spring webflux의 websocket관련 소스 분석

 

 

 

  1. WebSocketSession

    1. 인터페이스

    2. 용도/목적

  2. WebSocketHandler

 

 

netty 스레드 확인

 

 

  1. 구성 후 서버(netty)를 시작 후 thread를 확인해보면 reactor-http-nio-x 스레드를 확인할 수 있음

    1. netty 기본 설정에 의해 스레드 갯수는 CPU코어 갯수만큼 생김

    2. 아래 그림은 JMC(Oracle Java Mission Control)로 확인한 내용

       

 

 

  1. 아래 그림은 로컬PC 의 CPU 코어 갯수

 

 

 

java 로컬캐시

 -Guava cache보다 좋음

- https://github.com/ben-manes/caffeine

그냥하면 에러가 발생하기에 수정이 필요함

-----

 

@ComponentScan(basePackages = {"com.biz"}, includeFilters = {@Filter(value = org.springframework.stereotype.Controller.class)})

//@EnableCaching /* 캐쉬관련 */

//@EnableWebMvc //2020-08-04 기준 spring-admin과 lombok 관련 문제로 EnableWebMvc 사용하면 안됨. 참고: https://github.com/codecentric/spring-boot-admin/issues/777

@Configuration

public class MvcConfiguration implements WebMvcConfigurer {

 

 

   /**

    * spring-admin client에게 요청시 http basic auth로 요청하기 위한 커스텀

    *  - 참고 basic auth 생성기: https://www.blitter.se/utils/basic-authentication-header-generator/

    *

    * @return

    */

   @Bean

   public HttpHeadersProvider customHttpHeadersProvider() {

      return instance -> {

         HttpHeaders httpHeaders = new HttpHeaders();

         httpHeaders.add("Authorization", " Basic 블라블라");

         return httpHeaders;

      };

   }

 

 

}

 

 

+ Recent posts