Theme:

로그에서 IP 주소를 추출하거나, 이메일 형식을 검증할 때 정규식을 쓰면 되는 건 아는데, 제대로 쓰고 있는 걸까요?

정규 표현식(Regular Expression)은 강력하지만, 잘못 쓰면 성능 문제나 보안 취약점까지 발생할 수 있습니다. Java의 PatternMatcher를 중심으로 문법부터 성능까지 정리합니다.

기본 사용법

Pattern과 Matcher

JAVA
// 1. 패턴 컴파일 (비용이 크므로 재사용 권장)
private static final Pattern EMAIL_PATTERN =
    Pattern.compile("[\\w.+-]+@[\\w-]+\\.[\\w.]+");

// 2. Matcher 생성 (스레드마다 새로 만들어야 함)
Matcher matcher = EMAIL_PATTERN.matcher("contact@example.com");

// 3. 매칭 확인
if (matcher.matches()) {       // 전체 문자열이 패턴과 일치하는가?
    System.out.println("유효한 이메일");
}

if (matcher.find()) {          // 문자열 내에서 패턴을 찾는가?
    System.out.println("발견: " + matcher.group());
}

if (matcher.lookingAt()) {     // 문자열 시작 부분이 패턴과 일치하는가?
    System.out.println("시작 부분 일치");
}

간편 메서드

JAVA
// String.matches() — 매번 Pattern을 컴파일하므로 반복 사용 시 비효율
boolean valid = "test@email.com".matches("[\\w.+-]+@[\\w-]+\\.[\\w.]+");

// String.replaceAll()
String cleaned = "Hello   World".replaceAll("\\s+", " ");
// "Hello World"

// String.split()
String[] parts = "a,b,,c".split(",", -1);
// ["a", "b", "", "c"]

자주 쓰는 정규식 문법

문자 클래스

패턴의미
.줄바꿈 제외 모든 문자
\d숫자 [0-9]
\D숫자가 아닌 문자
\w단어 문자 [a-zA-Z0-9_]
\W단어 문자가 아닌 것
\s공백 문자
\S공백이 아닌 문자
[abc]a, b, c 중 하나
[^abc]a, b, c가 아닌 문자
[a-z]a부터 z까지

반복

패턴의미
*0회 이상
+1회 이상
?0 또는 1회
{n}정확히 n회
{n,}n회 이상
{n,m}n회 이상 m회 이하

앵커

패턴의미
^문자열(또는 줄) 시작
$문자열(또는 줄) 끝
\b단어 경계

캡처 그룹

번호 그룹

JAVA
Pattern datePattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = datePattern.matcher("2026-03-19");

if (m.matches()) {
    String full = m.group(0);  // "2026-03-19" (전체 매칭)
    String year = m.group(1);  // "2026"
    String month = m.group(2); // "03"
    String day = m.group(3);   // "19"
}

명명된 그룹

JAVA
Pattern pattern = Pattern.compile(
    "(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})");
Matcher m = pattern.matcher("2026-03-19");

if (m.matches()) {
    String year = m.group("year");   // "2026"
    String month = m.group("month"); // "03"
    String day = m.group("day");     // "19"
}

비캡처 그룹

매칭은 하되 캡처하지 않으려면 (?:...)를 사용합니다.

JAVA
// 캡처 그룹: group(1)에 "http" 또는 "https"가 잡힘
Pattern p1 = Pattern.compile("(https?)://(.+)");

// 비캡처 그룹: group(1)에 바로 호스트가 잡힘
Pattern p2 = Pattern.compile("(?:https?)://(.+)");

역참조

캡처한 그룹을 같은 패턴 안에서 다시 참조할 수 있습니다.

JAVA
// 연속 중복 단어 찾기 (예: "the the")
Pattern duplicateWord = Pattern.compile("\\b(\\w+)\\s+\\1\\b");
Matcher m = duplicateWord.matcher("This is is a test test.");

while (m.find()) {
    System.out.println("중복: " + m.group()); // "is is", "test test"
}

\\1은 첫 번째 캡처 그룹의 값을 참조합니다.

탐욕적 vs 게으른 vs 소유적 매칭

탐욕적 (Greedy) — 기본

JAVA
String html = "<b>bold</b> and <i>italic</i>";
Pattern greedy = Pattern.compile("<.+>");
// 매칭: "<b>bold</b> and <i>italic</i>"
// 가능한 많이 매칭

게으른 (Lazy/Reluctant)

JAVA
Pattern lazy = Pattern.compile("<.+?>");
// 매칭: "<b>", "</b>", "<i>", "</i>"
// 가능한 적게 매칭

소유적 (Possessive)

JAVA
Pattern possessive = Pattern.compile("<.++>");
// 매칭 실패 — 한 번 소비한 문자를 돌려주지 않음
// 백트래킹을 하지 않으므로 성능이 좋지만 매칭이 안 될 수 있음

실전 예제

로그 파싱

JAVA
private static final Pattern LOG_PATTERN = Pattern.compile(
    "(?<timestamp>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3})" +
    "\\s+(?<level>\\w+)" +
    "\\s+\\[(?<thread>[^]]+)]" +
    "\\s+(?<logger>\\S+)" +
    "\\s+-\\s+(?<message>.+)"
);

public record LogEntry(String timestamp, String level, String thread,
                       String logger, String message) {}

public static LogEntry parseLog(String line) {
    Matcher m = LOG_PATTERN.matcher(line);
    if (!m.matches()) return null;
    return new LogEntry(
        m.group("timestamp"), m.group("level"),
        m.group("thread"), m.group("logger"), m.group("message")
    );
}

IP 주소 추출

JAVA
private static final Pattern IP_PATTERN = Pattern.compile(
    "\\b(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})\\b");

public static List<String> extractIPs(String text) {
    List<String> ips = new ArrayList<>();
    Matcher m = IP_PATTERN.matcher(text);
    while (m.find()) {
        ips.add(m.group(1));
    }
    return ips;
}

문자열 치환

JAVA
// 카멜케이스를 스네이크케이스로
private static final Pattern CAMEL_PATTERN =
    Pattern.compile("([a-z])([A-Z])");

public static String toSnakeCase(String camel) {
    return CAMEL_PATTERN.matcher(camel)
        .replaceAll(mr -> mr.group(1) + "_" + mr.group(2).toLowerCase());
}
// "getUserName" → "get_user_name"

전방/후방 탐색 (Lookahead/Lookbehind)

매칭은 하되 결과에 포함하지 않는 패턴입니다.

JAVA
// 전방 긍정 탐색: 뒤에 "원"이 오는 숫자
Pattern price = Pattern.compile("\\d+(?=원)");
// "1000원" → "1000" 매칭 (원은 결과에 미포함)

// 전방 부정 탐색: 뒤에 "원"이 오지 않는 숫자
Pattern notPrice = Pattern.compile("\\d+(?!원)");

// 후방 긍정 탐색: 앞에 "$"가 있는 숫자
Pattern dollar = Pattern.compile("(?<=\\$)\\d+");
// "$100" → "100" 매칭

// 후방 부정 탐색: 앞에 "$"가 없는 숫자
Pattern notDollar = Pattern.compile("(?<!\\$)\\d+");

성능과 ReDoS

Pattern 컴파일 캐싱

JAVA
// 나쁜 예 — 매번 컴파일
public boolean validate(String input) {
    return input.matches("\\d{4}-\\d{2}-\\d{2}"); // 매번 Pattern.compile 호출
}

// 좋은 예 — 컴파일 결과 재사용
private static final Pattern DATE_PATTERN =
    Pattern.compile("\\d{4}-\\d{2}-\\d{2}");

public boolean validate(String input) {
    return DATE_PATTERN.matcher(input).matches();
}

ReDoS (백트래킹 폭발)

JAVA
// 위험한 패턴 — 중첩된 반복
Pattern dangerous = Pattern.compile("(a+)+$");
// "aaaaaaaaaaaaaaaaaX"를 매칭하면 백트래킹이 지수적으로 증가

// 위험한 패턴 — 겹치는 대안
Pattern dangerous2 = Pattern.compile("(a|a)+$");

ReDoS 방지 전략

  1. 중첩된 반복 피하기: (a+)+a+
  2. 소유적 수량자 사용: (a+)+$(a++)++$
  3. 원자적 그룹 사용: (?>a+)+$
  4. 입력 길이 제한: 정규식 적용 전에 입력 길이를 검증
  5. 타임아웃 설정: Java에서는 별도 스레드로 타임아웃 구현
JAVA
// 타임아웃이 있는 매칭
public static boolean matchWithTimeout(Pattern pattern, String input,
                                        long timeoutMs) {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    Future<Boolean> future = executor.submit(() ->
        pattern.matcher(input).matches());

    try {
        return future.get(timeoutMs, TimeUnit.MILLISECONDS);
    } catch (TimeoutException e) {
        future.cancel(true);
        return false;
    } catch (Exception e) {
        return false;
    } finally {
        executor.shutdownNow();
    }
}

유용한 플래그

JAVA
Pattern.compile("hello", Pattern.CASE_INSENSITIVE); // 대소문자 무시
Pattern.compile("^line$", Pattern.MULTILINE);        // ^$가 줄 단위로 매칭
Pattern.compile("hello . world", Pattern.DOTALL);    // .이 줄바꿈도 매칭
Pattern.compile(
    "\\d{4}  # 연도\n" +
    "-\\d{2} # 월\n" +
    "-\\d{2} # 일",
    Pattern.COMMENTS  // 주석과 공백 무시
);

정리

  • Pattern은 불변이고 스레드 안전하므로 static final로 캐싱하세요.
  • Matcher는 상태를 가지므로 스레드마다 새로 생성해야 합니다.
  • 명명된 캡처 그룹((?<name>...))을 사용하면 코드 가독성이 크게 향상됩니다.
  • 탐욕적/게으른/소유적 매칭의 차이를 이해하고, 상황에 맞게 사용하세요.
  • 중첩된 반복 패턴은 ReDoS 위험이 있으니, 사용자 입력에 적용할 때 특히 주의가 필요합니다.
댓글 로딩 중...