프로젝트에서 TipTap과 Monaco Editor를 결합한 하이브리드 에디터를 구현한 후, 작성된 콘텐츠를 조회 페이지에서 렌더링하는 과정에서 여러 문제를 만났다. 이 글에서는 환경 설정 문제부터 웹 에디터 콘텐츠 렌더링 이슈까지 해결한 전체 과정을 기록한다.
프로젝트를 배포하기 전 환경 변수 관리와 관련된 두 가지 문제를 먼저 해결해야 했다.
민감한 API Key를 application.yml에 직접 적어두면 GitHub에 그대로 노출된다. 개인 정보와 서비스 Key가 유출되면 외부에서 마음대로 호출할 수 있어 위험하다. Spring Boot는 환경변수를 자동으로 읽을 수 있으므로 반드시 ENV로 분리해야 한다.

로컬에서는 .env 또는 OS 환경변수로 관리하고 Git에는 제외한다. 해결 방법은 다음과 같다.
먼저 .gitignore에 민감한 파일들을 추가했다.
# application.yml 중 민감 정보가 포함된 파일
application-local.yml
application-prod.yml
.env
그리고 application.yml에서 환경변수를 참조하도록 수정했다.
jwt:
secret-key: ${JWT_SECRET_KEY}
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
이제 실제 값은 시스템 환경변수나 .env 파일에만 존재하고, Git에는 구조만 올라간다.
코드에서 @Value("${api.key}")로 참조하고 있었는데, application.yml에는 API_KEY로 정의되어 있었다. Spring Boot는 kebab-case(api.key)와 UPPER_SNAKE_CASE(API_KEY)를 자동으로 매핑해주지만, 이는 @ConfigurationProperties를 사용할 때만 적용된다. @Value를 사용할 때는 정확히 일치해야 한다.
코드와 yml의 변수 이름이 다르면 Spring이 값을 주입하지 못하고 "Could not resolve placeholder" 오류가 발생하며 애플리케이션이 부팅되지 않는다.

JwtProvider에서 다음과 같이 수정했다.
// 수정 전
@Value("${jwt.secret.key}")
private String secretKey;
// 수정 후
@Value("${jwt.secret-key}")
private String secretKey;
application.yml의 정의와 정확히 일치시켰다.
jwt:
secret-key: ${JWT_SECRET_KEY}

수정 후 @Value가 정상적으로 주입되고 JwtProvider와 AI 설정이 모두 정상 동작했다.
Spring Boot의 main 클래스는 Bean 스캔, 자동설정, 프로젝트 시작 기준이 된다. @SpringBootApplication 어노테이션이 붙은 main 클래스가 2개 이상 존재하면 스캔 기준이 여러 개로 나뉘며 Bean 충돌과 Config 중복 적용이 발생한다.
실제로 SecurityConfig, JwtProvider, DatabaseTest가 모두 꼬여 서버가 기동되지 않았다. 원인을 추적해보니 .env 파일을 로딩하기 위해 별도로 만들어둔 EnvLoading 클래스에 @SpringBootApplication이 붙어 있었다.
// 문제가 있던 코드
@SpringBootApplication
public class EnvLoading {
public static void main(String[] args) {
// .env 로딩 로직
}
}
환경변수 로딩은 별도의 main 클래스가 필요하지 않다. Spring Boot는 기본적으로 시스템 환경변수를 자동으로 읽기 때문이다. EnvLoading 클래스를 삭제하고 BackendApplication 하나만 남기자 모든 Bean이 정상적으로 로딩되었다.
참고로 여러 개의 Configuration 클래스는 문제가 되지 않는다. @Configuration이 붙은 클래스는 여러 개 존재해도 되며, 이는 설정을 모듈화하는 일반적인 방식이다. 문제가 되는 것은 @SpringBootApplication이 붙은 진입점 클래스가 여러 개인 경우다.
환경 설정 문제를 해결한 후, 본격적으로 에디터로 작성한 콘텐츠를 조회 페이지에서 렌더링하는 작업을 시작했다. 이 과정에서 네 가지 문제가 순차적으로 발견되었다.
게시글 목록 페이지에서 미리보기 영역을 구현하던 중, 다음과 같은 JSON 문자열이 그대로 화면에 표시되는 문제가 발생했다.
[{"id":"block-1763980407381","type":"tiptap","content":"<h1>제목</h1><p>내용...</p>","order":0}]
백엔드에서 freeboardContent 필드에 blocks 배열을 JSON 문자열로 직렬화하여 저장하고 있었다. 프론트엔드에서는 이 JSON 문자열을 파싱하지 않고 그대로 렌더링했기 때문에 JSON 구조가 화면에 텍스트로 노출되었다.
// 문제가 있던 코드
<div dangerouslySetInnerHTML={{ __html: b.freeboardContent }}></div>
JSON 문자열을 파싱한 후 HTML 태그를 제거하고 순수 텍스트만 추출하는 함수를 작성했다.
const extractTextFromHTML = (htmlString) => {
if (!htmlString) return "내용 없음";
try {
// freeboardContent가 JSON 배열 문자열인 경우 파싱
let content = htmlString;
if (htmlString.startsWith('[')) {
const blocks = JSON.parse(htmlString);
if (blocks.length > 0 && blocks[0].content) {
content = blocks[0].content;
}
}
// HTML 태그 제거
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
// 텍스트만 추출
const text = tempDiv.textContent || tempDiv.innerText || "";
// 150자까지만 표시
return text.trim().slice(0, 150) || "내용 없음";
} catch (e) {
console.error("텍스트 추출 실패:", e);
return "내용을 불러올 수 없습니다";
}
};
이 함수는 다음 단계로 동작한다. JSON 문자열 여부를 확인하고, JSON을 파싱한 후 첫 번째 블록의 content를 추출한다. 그리고 임시 DOM 요소를 생성해 HTML 태그를 제거하고, 순수 텍스트만 150자까지 반환한다.
목록 페이지에서는 미리보기만 보여주면 되므로 이 방식으로 충분했다. 하지만 상세 페이지에서는 실제 HTML 구조를 유지하면서 렌더링해야 했고, 이 과정에서 다른 문제들이 나타났다.

게시글 상세 페이지를 구현할 때, HTML 태그가 <h1>, <p> 같은 텍스트 형태로 그대로 화면에 표시되었다. React의 기본 렌더링 방식이 XSS 공격을 방지하기 위해 모든 문자열을 이스케이프 처리하기 때문에 발생한 문제였다. 일반적으로 {변수} 형태로 렌더링하면 HTML이 아닌 순수 텍스트로 처리된다.
// 문제가 있던 코드
<div>{board.freeboardContent}</div>
dangerouslySetInnerHTML 속성을 사용하여 HTML을 직접 렌더링하도록 수정했다. 이 속성은 이름에서 알 수 있듯이 XSS 공격에 취약할 수 있으므로, 신뢰할 수 있는 데이터에만 사용해야 한다. 우리 프로젝트는 로그인한 사용자만 글을 작성할 수 있고, 백엔드에서 태그 검증을 수행하므로 안전하다고 판단했다.
// 해결된 코드
<div
className="freeboard-content"
dangerouslySetInnerHTML={{ __html: getRenderedContent(board.freeboardContent) }}
/>
이제 HTML이 정상적으로 렌더링되어 제목과 본문이 의도한 대로 표시되었다. 하지만 Monaco 에디터로 작성한 코드 블록은 여전히 보이지 않았다.
TipTap으로 작성한 일반 텍스트는 정상적으로 보였지만, Monaco 에디터로 작성한 코드 블록이 빈 공간으로만 보이거나 매우 작은 박스로만 표시되었다.
Monaco 코드 블록은 다음과 같은 HTML 구조로 저장되었다.
<pre data-type="monaco-code-block"
data-code="const hello = 'world';"
data-language="javascript">
</pre>
data-code 속성에 실제 코드가 저장되어 있지만, 브라우저는 HTML 속성 값을 화면에 표시하지 않는다. 따라서 빈 <pre> 태그만 렌더링되어 아무것도 보이지 않는 것이었다.
useEffect를 사용하여 DOM이 렌더링된 후 Monaco 코드 블록을 찾아 innerHTML을 재구성했다.
useEffect(() => {
if (!contentRef.current) return;
// Monaco 코드 블록 찾기
const monacoBlocks = contentRef.current.querySelectorAll(
'pre[data-type="monaco-code-block"]'
);
monacoBlocks.forEach(block => {
const code = block.getAttribute('data-code');
const language = block.getAttribute('data-language');
if (code) {
// HTML 엔티티 디코딩
const decodedCode = code
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/&/g, '&');
// 코드 블록 재구성
block.innerHTML = `
<div class="monaco-code-header">
<span class="monaco-language">${language || 'code'}</span>
</div>
<code class="language-${language || 'plaintext'}">${decodedCode}</code>
`;
}
});
}, [board]);
이 코드는 다음 과정을 수행한다. data-type="monaco-code-block" 속성을 가진 모든 <pre> 태그를 검색하고, data-code와 data-language 속성에서 코드와 언어 정보를 추출한다. 그리고 HTML 엔티티를 디코딩하고, 헤더와 코드를 포함한 새로운 HTML 구조를 생성한다.
이제 코드 블록이 화면에 표시되었지만, 모든 텍스트가 하얀색으로만 보였다. 구문 강조가 적용되지 않아 키워드, 함수, 문자열 등이 구분되지 않았다.
다른 기술 커뮤니티들의 접근 방식을 살펴보면, GitHub Gist는 작성할 때 Monaco 스타일 에디터를 사용하지만 조회할 때는 highlight.js로 변환하여 보여준다. Stack Overflow도 작성은 고급 에디터를 사용하지만 조회는 변환된 HTML과 함께 구문 강조 라이브러리를 적용한다. 우리 프로젝트도 같은 방식으로 접근했다.
highlight.js 라이브러리를 설치하고, 각 코드 블록에 하이라이팅을 적용했다.
먼저 라이브러리를 설치했다.
npm install highlight.js
컴포넌트 상단에 import 구문을 추가하고 VS Code 다크 테마를 적용했다.
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
기존 useEffect 코드에 구문 강조 로직을 추가했다.
useEffect(() => {
if (!contentRef.current) return;
const monacoBlocks = contentRef.current.querySelectorAll(
'pre[data-type="monaco-code-block"]'
);
monacoBlocks.forEach(block => {
const code = block.getAttribute('data-code');
const language = block.getAttribute('data-language');
if (code) {
const decodedCode = code
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/&/g, '&');
block.innerHTML = `
<div class="monaco-code-header">
<span class="monaco-language">${language || 'code'}</span>
</div>
<code class="language-${language || 'plaintext'}">${decodedCode}</code>
`;
// Syntax Highlighting 적용
const codeElement = block.querySelector('code');
if (codeElement) {
hljs.highlightElement(codeElement);
}
}
});
}, [board]);
기능적으로는 해결되었으나, Monaco Editor의 느낌을 살리기 위해 CSS로 스타일링을 개선했다.
.freeboard-content pre[data-type="monaco-code-block"] {
background-color: #1e1e1e;
border-radius: 0.5rem;
overflow: hidden;
margin: 2rem 0;
border: 1px solid #2d2d2d;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.freeboard-content .monaco-code-header {
background: linear-gradient(to bottom, #2d2d2d, #252526);
padding: 0.75rem 1rem;
border-bottom: 1px solid #1e1e1e;
}
.freeboard-content .monaco-language {
color: #858585;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.freeboard-content pre[data-type="monaco-code-block"] code {
display: block;
padding: 1.25rem !important;
background: transparent !important;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.7;
overflow-x: auto;
}
이제 코드 블록이 VS Code와 유사한 스타일로 표시되며, Java는 초록색과 보라색, JavaScript는 노란색과 파란색 등 언어별로 다른 색상으로 구분되어 가독성이 크게 향상되었다.
환경 설정부터 에디터 콘텐츠 렌더링까지 여러 문제를 해결하며 다음과 같은 점을 배웠다. 민감한 정보는 반드시 환경변수로 관리해야 하며, Spring Boot에서 @Value를 사용할 때는 정확한 변수명 매칭이 필요하다. 하나의 Spring Boot 프로젝트에는 @SpringBootApplication이 하나만 있어야 하고, 웹 에디터 콘텐츠는 저장 형식과 렌더링 방식을 명확히 분리해서 설계해야 한다. 특히 Monaco Editor처럼 동적으로 생성되는 콘텐츠는 조회 시점에 DOM 조작과 구문 강조 라이브러리를 활용하는 것이 효과적이다.