LangChain · LangGraph · AI 에이전트 · 닉네임 검토
서비스가 성장하자 닉네임 검토를 AI Agent로 자동화했다
서비스의 공개 랭킹에 노출되는 닉네임을 검사하면서 사용자가 직접 정한 이름은 본인에게 그대로 보여주기 위해, LangChain의 구조화된 분류와 LangGraph의 상태 흐름을 어떻게 사용했는지 기록했다.
최근 서비스에 사용자가 늘면서 닉네임이 예상하지 못한 방식으로 쓰이기 시작했다. 닉네임에 별다른 제한이 없다 보니 누군가의 휴대전화 번호나 욕설을 이름으로 지정하는 경우가 종종 발생했다. 특히 랭킹에서는 그 닉네임이 다른 사용자에게도 보인다.
이런 입력을 내가 매 순간 확인할 수는 없다. 그래서 닉네임을 검사하고, 필요한 부분만 가려서 랭킹에 반영하는 과정을 자동화하는 Agent를 개발했다. 여기서 중요한 조건이 하나 있었다. 사용자가 스스로 정한 닉네임은 본인에게 그대로 보여줘야 한다. 공개 화면에서 일부를 가리더라도, 본인이 입력한 이름까지 조용히 바뀌면 안 된다.
이 글은 그 문제를 풀면서 LangChain과 LangGraph를 어떤 역할로 사용했는지 정리한 기록이다. 아래 예시는 모두 가상의 닉네임이며, 실제 사용자 입력이나 서비스의 저장 구조는 싣지 않았다.
같은 닉네임을 보는 두 가지 시선
처음에는 문제가 있는 닉네임을 다른 문자열로 통째로 바꾸면 간단해 보였다. 하지만 철수시발에서 욕설 때문에 전체를 별표로만 만들면, 사용자는 자신이 어떤 닉네임을 입력했는지 알아보기 어렵다. 김철수처럼 사람 이름만 들어간 닉네임까지 가리면 더 큰 문제다.
원하는 동작은 다음과 같았다.
| 가상의 입력 | 본인에게 보이는 이름 | 랭킹에 보이는 이름 |
|---|---|---|
김철수 | 김철수 | 김철수 |
철수시발 | 철수시발 | 철수시* |
| 휴대전화 번호가 포함된 닉네임 | 입력한 이름 그대로 | 번호의 일부 숫자를 *로 가린 이름 |
이 표는 데이터베이스 설계가 아니라 화면별 표시 원칙이다. 본인에게 보여줄 원문과 다른 사람에게 보여줄 표현을 구분하고, 공개 표현만 검사 결과에 따라 바꾼다. 이름만으로 욕설이라고 판단하지 않고, 현재는 명시적인 욕설과 알아볼 수 있는 전화번호 형태에 집중한다.
LangChain에는 무엇을 맡겼나
욕설은 단순한 금칙어 목록만으로 다루기 어렵다. 짧은 표현은 맥락에 따라 뜻이 달라지고, 단어 일부가 사람 이름이나 무해한 문구에 섞여 있을 수도 있다. 그래서 모델에는 닉네임 전체를 새로 쓰게 하지 않고, 분명한 욕설에 해당하는 부분 문자열만 찾아달라고 요청했다. 애매하면 빈 결과를 반환하도록 했다.
이때 사용한 것이 LangChain의 ChatOpenAI와 withStructuredOutput이다. withStructuredOutput은 모델의 답변을 미리 정한 형태로 받아 후속 단계에서 다루기 쉽게 만든다. 이 사례에서는 “욕설로 판단한 부분 문자열의 목록”이 그 형태다.
const structuredModel = chatModel.withStructuredOutput(schema);여기서 중요한 점은 형식과 판단의 정확성이 다르다는 것이다. 구조화된 출력은 결과를 파싱할 수 있게 돕지만, 모델이 욕설을 놓치거나 무해한 표현을 잘못 지목할 가능성까지 없애지는 않는다. 따라서 모델이 제시한 문자열이 실제 입력에 포함돼 있는지 확인하고, 이름이나 무해한 표현을 불필요하게 가리지 않도록 별도 규칙을 둔다.
휴대전화 번호는 모델의 해석에 맡기지 않았다. 인식할 수 있는 번호 형식은 일반 규칙으로 찾고 일부 숫자를 가린다. 이렇게 하면 모델은 언어적 판단이 필요한 부분에만 관여하고, 실제 별표 치환은 예측 가능한 함수가 수행한다.
LangGraph는 왜 넣었나
LangGraph는 작업을 상태(state), 노드(node), 연결(edge)로 표현한다. 상태는 단계 사이에 전달할 값이고, 노드는 그 값을 읽어 작업한 뒤 결과를 보탠다. 연결은 다음에 실행할 노드를 정한다. 공식 가이드도 이 세 요소를 중심으로 워크플로를 설명한다.
현재 닉네임 검토 그래프는 단순하다. 입력 닉네임을 상태에 담아 분류 노드에 전달하고, 노드가 욕설 후보 목록을 상태에 더한 뒤 끝난다.
입력 상태 → 욕설 분류 노드 → 분류 결과그래프가 닉네임을 직접 수정하거나, 여러 에이전트가 서로 토론하는 구조는 아니다. 지금 필요한 흐름은 한 번의 분류이므로 노드도 하나다. 그 뒤의 전화번호 탐지와 부분 마스킹, 공개 결과 반영은 그래프 밖의 일반 코드가 맡는다. 이 경계를 분명히 해 두면 모델의 판단 결과와 실제 표시 변경을 각각 확인할 수 있다.
LangChain은 모델에 질문하고 정해진 형태로 답을 받는 역할, LangGraph는 그 결과가 상태를 거쳐 다음 단계로 전달되는 흐름을 담당한다. 두 도구를 함께 썼다고 해서 검토 전체가 자율적으로 판단하고 행동하는 복잡한 에이전트가 되는 것은 아니다.
원문을 남기고 공개 표현만 바꾸기
검토 결과가 나오면 해당 욕설의 일부 글자나 전화번호의 일부 숫자를 *로 바꿔 공개 표현을 만든다. 철수시발을 철수시*처럼 처리하는 이유는 무해한 앞부분을 살리면서 문제 부분은 그대로 읽기 어렵게 하기 위해서다. 똥이나 짓처럼 이 서비스에서 욕설로 취급하지 않기로 한 글자는 다른 표현에 섞여 있어도 가리지 않는다.
사용자가 보는 자신의 닉네임 원문은 이 과정에서 유지한다. 닉네임을 새로 입력하거나 바꾸면 검토 대상이 되고, 검토가 끝난 공개 표현이 랭킹에 반영된다. 검사와 반영 사이에는 잠시 입력 원문이 공개 화면에 보일 수 있다. 현재 구현이 게시 전 차단이 아닌 사후 검토라는 점은 이 기능의 중요한 한계다.
검토 도중 사용자가 닉네임을 다시 바꿀 수도 있다. 이전 이름의 검사 결과가 새 이름에 적용되지 않도록, 결과를 반영할 때 검토한 원문과 현재 입력을 다시 대조한다. 모델 호출이나 결과 반영에 실패한 건은 완료된 것으로 취급하지 않고 이후 다시 검토할 수 있도록 남긴다.
만들면서 배운 점
가장 조심해야 했던 부분은 모델 선택보다 잘못 가렸을 때의 복구 가능성이었다. 처음에는 닉네임 전체가 별표로 바뀌는 문제도 겪었다. 원문을 보존하고 공개 표현을 분리해 둔 덕분에 잘못된 표시를 되돌리고 마스킹 범위를 다시 조정할 수 있었다.
남은 숙제도 있다. 구조화된 응답이 나온다는 이유만으로 분류가 맞다고 확신할 수 없다. 확실하지 않은 입력을 빈 목록으로 처리하면 놓친 표현이 그대로 남을 수 있고, 번호 탐지도 지원하는 표기 범위 밖에서는 동작하지 않는다. 따라서 실제 사례를 검토하면서 오탐과 누락을 줄이고, 실패 건을 민감한 원문 없이 확인할 수 있는 방법이 필요하다.
이번 구현에서 LangChain과 LangGraph의 역할은 화려한 자동 판단을 만드는 데 있지 않았다. 모델이 판단할 부분을 좁히고, 그 결과가 어디까지 영향을 주는지 명확하게 만드는 것이 핵심이었다. 사용자에게는 자신이 지은 이름을 그대로 보여주면서, 다른 사람에게 보이는 랭킹에는 필요한 만큼만 가리는 방식이다.
참고 자료
함께 읽을 글