Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
48 changes: 41 additions & 7 deletions .vitepress/plugins/markdown-picture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,36 @@ function convertImgToPicture(
additionalAttrs: string = "",
): string {
const safeAlt = alt || "";
const safeAttrs = additionalAttrs && additionalAttrs.trim() ? " " + additionalAttrs.trim() : "";
let safeAttrs = additionalAttrs && additionalAttrs.trim() ? " " + additionalAttrs.trim() : "";
let pictureStyle = "";

// width 속성 처리: 퍼센트나 단위가 있는 경우 picture의 스타일로 이동하여 중첩 계산 방지
const widthMatch = /width=["']([^"']+)["']/i.exec(safeAttrs);
if (widthMatch) {
const widthVal = widthMatch[1];
if (widthVal.includes("%") || widthVal.includes("px")) {
pictureStyle = `width: ${widthVal}; display: inline-block;`;
// img 태그에서는 원본 width 속성을 제거하고 내부적으로 100%를 가지도록 함
safeAttrs = safeAttrs.replace(/width=["'][^"']*["']/gi, "").trim();

if (safeAttrs.includes("style=\"")) {
safeAttrs = safeAttrs.replace(/style=["']([^"']*)["']/i, (m, s) => {
const baseStyle = s.trim();
const separator = baseStyle && !baseStyle.endsWith(";") ? ";" : "";
return `style="${baseStyle}${separator} width: 100%;"`;
});
} else {
safeAttrs += " style=\"width: 100%;\"";
}
} else {
// 숫자만 있는 경우(HTML5 표준) px 제거 로직 유지
safeAttrs = safeAttrs.replace(/width="(\d+)(px)?"/gi, "width=\"$1\"");
}
}

// 외부 URL이거나 svg, gif는 picture 태그로 변환하지 않음
if (src.startsWith("http") || src.endsWith(".svg") || src.endsWith(".gif")) {
return `<img src="${src}" alt="${safeAlt}"${safeAttrs} loading="lazy" />`;
return `<img src="${src}" alt="${safeAlt}"${safeAttrs ? " " + safeAttrs : ""} loading="lazy" />`;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The src, alt, and safeAttrs variables are inserted directly into the HTML string without any escaping or sanitization. This can lead to Cross-Site Scripting (XSS) if these values contain malicious content (e.g., a double quote followed by an event handler like " onerror="alert(1)"). Since these values originate from markdown files, they should be properly escaped before being rendered into HTML.

}

// 이미지 포맷별 경로 생성
Expand All @@ -82,13 +107,14 @@ function convertImgToPicture(

// 변환된 이미지가 없으면 원본만 사용
if (sources.length === 0) {
return `<img src="${src}" alt="${safeAlt}"${safeAttrs} loading="lazy" />`;
return `<img src="${src}" alt="${safeAlt}"${safeAttrs ? " " + safeAttrs : ""} loading="lazy" />`;
}

// picture 태그 생성 (원본을 최종 fallback으로 사용)
return `<picture>
const pictureAttr = pictureStyle ? ` style="${pictureStyle}"` : "";
return `<picture${pictureAttr}>
Comment on lines +114 to +115

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The pictureAttr (which contains pictureStyle) is injected directly into the <picture> tag. pictureStyle is derived from the width attribute in additionalAttrs. If an attacker provides a malicious width value (e.g., width="100%; background-image: url(javascript:alert(1))"), it could lead to XSS or other injection attacks. CSS values should be sanitized or strictly validated.

${sources.join("\n ")}
<img src="${src}" alt="${safeAlt}"${safeAttrs} loading="lazy" />
<img src="${src}" alt="${safeAlt}"${safeAttrs ? " " + safeAttrs : ""} loading="lazy" />
</picture>`;
}

Expand Down Expand Up @@ -117,6 +143,7 @@ function processHtmlImages(html: string, markdownPath: string): string {
.replace(/src=["'][^"']*["']/gi, "")
.replace(/alt=["'][^"']*["']/gi, "")
.replace(/loading=["'][^"']*["']/gi, "") // loading은 우리가 추가할 것이므로 제거
.replace(/\s+/g, " ") // 중복 공백 제거
.trim();

// 속성이 비어있거나 공백만 있으면 빈 문자열로
Expand All @@ -142,16 +169,23 @@ export function markdownPicturePlugin(md: MarkdownIt) {
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const srcIndex = token.attrIndex("src");
const altIndex = token.attrIndex("alt");

if (srcIndex < 0) {
return defaultRender(tokens, idx, options, env, self);
}

const src = token.attrs![srcIndex][1];
const alt = token.content;
const alt = altIndex >= 0 ? token.attrs![altIndex][1] : token.content || "";
const markdownPath = env.path || "";

return convertImgToPicture(src, alt, markdownPath);
// src, alt를 제외한 나머지 속성들 추출 (width, height 등)
const additionalAttrs = token.attrs!
.filter(([name]) => name !== "src" && name !== "alt")
.map(([name, value]) => `${name}="${value}"`)
.join(" ");
Comment on lines +183 to +186

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Attributes extracted from the markdown token are concatenated into a string without escaping their values. This allows an attacker to inject arbitrary HTML attributes or break out of the attribute context. All attribute values should be escaped before being included in the additionalAttrs string.

Suggested change
const additionalAttrs = token.attrs!
.filter(([name]) => name !== "src" && name !== "alt")
.map(([name, value]) => `${name}="${value}"`)
.join(" ");
const additionalAttrs = token.attrs!
.filter(([name]) => name !== "src" && name !== "alt")
.map(([name, value]) => `${name}="${md.utils.escapeHtml(value)}"`)
.join(" ");


return convertImgToPicture(src, alt, markdownPath, additionalAttrs);
};

// 2. HTML inline 이미지 (<img>) 처리
Expand Down
57 changes: 57 additions & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# ToothlessDev Blog

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

GEMINI.md 파일은 프로젝트의 README와 내용이 동일해 보입니다. 이번 PR의 주된 내용인 새 블로그 포스트와는 관련이 없어 보이는데, 의도적으로 추가된 파일이 맞나요? 레포지토리의 깔끔한 관리를 위해 불필요한 파일이라면 제거하는 것을 고려해볼 수 있겠습니다.


이 프로젝트는 VitePress를 기반으로 구축된 기술 블로그 및 포트폴리오 사이트입니다.

## 프로젝트 개요
- **프레임워크:** [VitePress](https://vitepress.dev/) (Vue 기반 정적 사이트 생성기)
- **주요 기술:** TypeScript, Vue 3, Vanilla CSS
- **콘텐츠 관리:** `contents/` 디렉토리 내의 마크다운 파일
- **주요 기능:**
- 이미지 자동 최적화 (Sharp 사용)
- KaTeX를 이용한 수식 렌더링
- Giscus를 이용한 댓글 시스템
- 포스트 목록 및 사이드바 자동 생성 플러그인

## 디렉토리 구조
- `.vitepress/`: VitePress 설정 및 커스텀 플러그인 (`plugins/`)
- `contents/`: 마크다운 콘텐츠
- `posts/`: 기술 블로그 포스트
- `projects/`: 프로젝트 소개
- `archive/`: 아카이브 페이지
- `src/`: 테마 커스터마이징을 위한 Vue 컴포넌트 및 유틸리티
- `scripts/`: 포스트 데이터 생성을 위한 스크립트
- `data/`: 자동 생성된 포스트 데이터 (`posts.json`)

## 개발 및 빌드 명령
- **의존성 설치:** `yarn install`
- **로컬 개발 서버 실행:** `npm run docs:dev`
- **프로젝트 빌드:** `npm run docs:build`
- **빌드 결과물 미리보기:** `npm run docs:preview`
- **포스트 데이터 수동 생성:** `npm run generate-posts`

## 개발 컨벤션
### 포스트 작성 (Markdown)
모든 포스트는 `contents/posts/` 하위 디렉토리에 위치해야 하며, 다음의 frontmatter 형식을 반드시 준수해야 합니다:
```yaml
---
title: "포스트 제목"
description: "포스트 요약 설명"
createdAt: "2024-03-20"
category: "카테고리명"
comment: true # 댓글 활성화 여부
---
```
- `index.md` 파일은 포스트 목록 생성에서 제외됩니다.
- 이미지는 각 포스트 디렉토리 내의 `img/` 폴더에 위치시키는 것을 권장합니다.

### 스타일링
- 컴포넌트별로 전용 `.css` 파일을 생성하여 관리합니다 (`src/components/` 참조).
- 전역 스타일은 `.vitepress/theme/style.css`에서 관리합니다.

### 아키텍처 및 플러그인
- **플러그인 시스템:** `.vitepress/plugins/`에 위치한 커스텀 Vite 플러그인을 통해 빌드 시 포스트 목록 생성, 이미지 최적화 등을 자동화합니다.
- **데이터 흐름:** 마크다운 파일 수정 -> Vite 플러그인 감지 -> `generate-posts.mjs` 실행 -> `data/posts.json` 업데이트 -> UI 반영

## 배포
- GitHub Actions를 통해 `main` 브랜치 푸시 시 자동 배포됩니다.
- 배포 설정은 `.github/workflows/deploy.yml`에 정의되어 있습니다.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ comment: true
---

:::info
이 글은 [응~ 이미지 키워봐~ 최적화 하면 그만이야 🤡 (pt1. 이미지 포맷)](../Web/image-format.md) 글의 속편입니다.
이 글은 [응~ 이미지 키워봐~ 최적화 하면 그만이야 🤡 (pt1. 이미지 포맷)](../../Web/image-format/index.md) 글의 속편입니다.
지난 글에서 이미지 포맷 선택이 얼마나 중요한지 살펴봤다면, <br/>
이번 글에서는 **AWS Lambda와 S3 이벤트를 활용해 업로드 시점에서 자동으로 이미지 최적화**하는 방법을 다룹니다.
:::
Expand All @@ -17,7 +17,7 @@ comment: true
디자인학과 졸업전시 웹사이트를 만들면서, 이미지 때문에 꽤나 고생을 했습니다.
학생들이 주는 작품 사진은 대부분 몇 MB가 넘는 고화질의 원본이었고, 저는 그걸 웹에 맞게 리사이즈해서 올려야 했습니다.

![최적화 해야한다.... vs 딸깍!](./img/lambda-img-optimization/optimize.jpg)
![최적화 해야한다.... vs 딸깍!](./img/optimize.jpg)

처음에는 로컬에서 일일이 줄여 업로드했지만, 작품 수가 수십 개, 이미지가 수백 장을 넘어가자 상황은 달라졌습니다.
리사이즈 작업만으로도 반나절이 날아가고, 썸네일·포맷별 파일까지 관리하려니 점점 감당하기 힘들어졌습니다.
Expand Down Expand Up @@ -50,11 +50,11 @@ AWS Lambda는 서버를 직접 관리하지 않고도 코드를 실행할 수

그 다음, AWS Lambda 콘솔에서 새 Lambda 함수를 생성합니다.

![Lambda 함수 생성](./img/lambda-img-optimization/setup-lambda.png)
![Lambda 함수 생성](./img/setup-lambda.png)

아래쪽에 `구성` > `트리거` > `트리거 추가` 를 클릭해 S3 트리거를 설정합니다.

![S3 Trigger 설정](./img/lambda-img-optimization/setup-trigger.png)
![S3 Trigger 설정](./img/setup-trigger.png)

## Presigned URL 로 이미지 업로드 시점에 Lambda 트리거하기

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ CAS는 간단히 말해서 내용기반 식별입니다 <br>
여기서, CAS 가 주는 불변성이 매우 중요합니다

<center>
<img src="./img/content-addressable-store/hash.png" alt="Content Addressable Store 의 핵심 아이디어" width="600"/>
<img src="./img/hash.png" alt="Content Addressable Store 의 핵심 아이디어" width="600"/>
</center>

### 1️⃣ 동일내용 = 동일 키 (Deduplication)
Expand Down Expand Up @@ -61,7 +61,7 @@ const contentAddressableStore = new Map<HashKey, Content>();
```

<center>
<img src="./img/content-addressable-store/base-cas.png" alt="해시맵 구조의 Content Addressable Store" width="400"/>
<img src="./img/base-cas.png" alt="해시맵 구조의 Content Addressable Store" width="400"/>
</center>

이것만으로도 CAS 의 기본 기능은 구현할 수 있습니다 <br>
Expand All @@ -85,7 +85,7 @@ const contentAddressableStore = new Map<HashKey, Content>();
가 있습니다.

<center>
<img src="./img/content-addressable-store/metadata-disk.png" alt="디스크 기반 인덱싱 구조" width="600"/>
<img src="./img/metadata-disk.png" alt="디스크 기반 인덱싱 구조" width="600"/>
</center>

해시맵의 키로 Content 의 해시값을 사용하고, <br>
Expand Down Expand Up @@ -117,7 +117,7 @@ ab12cd... -> ab/12cd...

Chunking 과 Prefix Sharding 을 함께 사용하면 다음과 같은 구조가 됩니다

<img src="./img/content-addressable-store/chunking-sharding.png" alt="Chunking 과 Sharding" />
<img src="./img/chunking-sharding.png" alt="Chunking 과 Sharding" />

## Hash Tree 를 이용한 내용 무결성 검증

Expand All @@ -131,7 +131,7 @@ Content Addressable Store 는 데이터를 `내용 기반 주소` 로 식별하
예를들어 데이터를 저장할때, metadata 는 메모리 (해시 맵) 에 저장됐지만, content 는 디스크 (BlobStorage) 에 저장이 안된 경우가 있을 수 있습니다 <br>

<center>
<img src="./img/content-addressable-store/hashmap-inconsistent.png" alt="저장 과정의 일관성 문제" width="600" />
<img src="./img/hashmap-inconsistent.png" alt="저장 과정의 일관성 문제" width="600" />
</center>

이런 경우 CAS 는 `주소 - 내용` 매핑이 깨지게 됩니다. <br>
Expand Down Expand Up @@ -177,7 +177,7 @@ TCP 는 Checksum 을 두고 TLS 는 MAC(Message Authentication Code) 을 사용
- 리프 노드는 실제 데이터 블록의 해시값을 저장하고, <br>
- 내부 노드는 자식 노드들의 해시값을 조합하여 해시값을 계산합니다 <br>

![alt text](./img/content-addressable-store/hash-tree.png)
![alt text](./img/hash-tree.png)

#### 🤔 Merkle Tree 로 무결성 검증이 어떻게 이루어질까 ?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const cartItem2 = {
> 같은 입력 = 샌드위치 id + 옵션 조합 <br/>
> 같은 결과 = 장바구니 항목의 고유키

![alt text](./img/idempotency-hash-table/cart-hash-func.png)
![alt text](./img/cart-hash-func.png)

### 1️⃣ 단순 순회하면서 비교하기

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ head:

토스뱅크는 단순히 가기 어려운 회사가 아니라, 내가 되고 싶은 개발자의 모습이 담긴 회사였다

[대학 시절 창업을 도전했다가 실패한 경험](./roomfit-failure.md) 이 있었는데, 그 과정에서 토스 이승건 대표님의 영상을 보고 많은 깨달음을 얻었다
[대학 시절 창업을 도전했다가 실패한 경험](../roomfit-failure/) 이 있었는데, 그 과정에서 토스 이승건 대표님의 영상을 보고 많은 깨달음을 얻었다

그 과정에서 토스가 추구하는 가치관과 개발 문화가 나와 잘 맞는다는 생각이 들었고, 언젠가 꼭 토스에서 일하고 싶다는 목표를 가지게 되었다 <br/>
특히 "사용자를 위해 불편을 부수는 집요함" 이라는 철학을 접했고, 내가 생각했던 개발자의 모습과도 일치했다.
Expand All @@ -32,8 +32,8 @@ head:
토스는 이걸 진짜 현실로 만들어낸 팀이라고 생각했다.

<div style="display: flex; width:100%; align-items:center;">
<img src="./img/2025-toss-interview/toss-auth.png" alt="토스뱅크가 혁신적으로 인증절차를 바꾼 이야기" style="width:50%; object-fit:cover;"/>
<img src="./img/2025-toss-interview/toss-cta.png" alt="토스뱅크가 IOS 에서 BottomCTA 버튼이 사라지는 현상을 해결한 이야기" style="width:50%; object-fit:cover;" />
<img src="./img/toss-auth.png" alt="토스뱅크가 혁신적으로 인증절차를 바꾼 이야기" style="width:50%; object-fit:cover;"/>
<img src="./img/toss-cta.png" alt="토스뱅크가 IOS 에서 BottomCTA 버튼이 사라지는 현상을 해결한 이야기" style="width:50%; object-fit:cover;" />
</div>

:::details [개발자를 성장시키는 토스의 개발 문화 3가지 | 박서진 토스 Head of Frontend | Square of Toss](https://www.youtube.com/watch?v=RzVABPt9f-g)
Expand Down Expand Up @@ -79,7 +79,7 @@ UX, 유지보수성, 아키텍쳐 각각에서 개발자경험과 사용자 경

모든 레포에 Gemini Code Assist를 붙여놓은 상태였는데, PR을 제출하고 나니 얘가 갑자기 크리티컬한 문제가 있다고 소리를 지르는것이었다

![](./img/2025-toss-interview/gemini.png)
![](./img/gemini.png)

하... 떨어졌구나

Expand All @@ -90,7 +90,7 @@ UX, 유지보수성, 아키텍쳐 각각에서 개발자경험과 사용자 경
근데 며칠 뒤, 과제합격 메일이 도착했다 <br/>
현실을 잠깐 의심했다. 진짜?? 아니 이게 진짜라고?

<img src="./img/2025-toss-interview/toss-passed.png" alt="토스뱅크 프론트엔드 과제 합격 메일"/>
<img src="./img/toss-passed.png" alt="토스뱅크 프론트엔드 과제 합격 메일"/>

진짜 하루종일 이렇게 기쁠 수가 없었다.

Expand All @@ -101,7 +101,7 @@ UX, 유지보수성, 아키텍쳐 각각에서 개발자경험과 사용자 경
너무가고싶은 마음에 면접보기 전까지 쥐죽은듯이 공부했다. <br/>
근 몇달간 가장 집중을 많이하고 공부를 많이 했지 않나.. 싶다

<img src="./img/2025-toss-interview/study-1.webp" height="300" style="object-fit:clip;"/>
<img src="./img/study-1.webp" height="300" style="object-fit:clip;"/>

## 😵 대망의 면접날 고해성사를 시작합니다

Expand Down Expand Up @@ -288,7 +288,7 @@ UX, 유지보수성, 아키텍쳐 각각에서 개발자경험과 사용자 경

## 🥲 다음 기회를 기약하며..

<img src="./img/2025-toss-interview/toss-failed.png" alt="토스뱅크 프론트엔드 면접 불합격 메일"/>
<img src="./img/toss-failed.png" alt="토스뱅크 프론트엔드 면접 불합격 메일"/>

뭐... 결국은 불합격했다.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ head:
졸업을 위한 현장실습 대체 학점을 채워야 했고, 마침 카카오 테크캠퍼스가 그걸 채워줄 수 있었고, "어차피 할 거면 카카오 이름 달린 데서 하면 좋지" 정도의 마음가짐이었습니다

<center>
<img src="./img/kakao-tech-campus-record/pass.png" width="600px"/>
<img src="./img/pass.png" width="600px"/>
</center>

부트캠프에 대한 이미지도 솔직히 그렇게 좋진 않았습니다. <br/>
Expand Down Expand Up @@ -73,8 +73,8 @@ PR 을 올릴 때, 질문을 진짜 많이 적었고, 고민한 흔적 설계
리뷰해주시는 멘토님들이 꽤 고생을 많이 하셨을 것 같습니다 ㅋㅋㅋ

<div style="display:flex">
<img src="./img/kakao-tech-campus-record/pr-1.png" style="flex: 1; width:50%"/>
<img src="./img/kakao-tech-campus-record/pr-2.png" style="flex: 1; width:50%"/>
<img src="./img/pr-1.png" style="flex: 1; width:50%"/>
<img src="./img/pr-2.png" style="flex: 1; width:50%"/>
</div>

:::details 💬 멘토님과 함께한 코드리뷰 PR 모음
Expand Down Expand Up @@ -172,7 +172,7 @@ PR 을 올릴 때, 질문을 진짜 많이 적었고, 고민한 흔적 설계
실무에서 `generator` 많이 쓰시나요? 라고 여쭤봤는데 멘토님이 이렇게 답해주셨습니다.

<center>
<img src="./img/kakao-tech-campus-record/feedback.png" width="600px"/>
<img src="./img/feedback.png" width="600px"/>
</center>

좋은 코드는 단순히 변수명이 깔끔하고, 여러 설계원칙을 잘 지키는 그런 코드가 아니라 <br/>
Expand All @@ -191,8 +191,8 @@ PR 을 올릴 때, 질문을 진짜 많이 적었고, 고민한 흔적 설계
아직 취업은 못했지만, 이런 사소해보이는 습관들이 저를 조금은 더 준비된 상태로 만들어줬다고 생각합니다 ㅎㅎ

<div style="display:flex">
<img src="./img/kakao-tech-campus-record/lec-1.webp" style="flex: 1; width:50%"/>
<img src="./img/kakao-tech-campus-record/lec-2.webp" style="flex: 1; width:50%"/>
<img src="./img/lec-1.webp" style="flex: 1; width:50%"/>
<img src="./img/lec-2.webp" style="flex: 1; width:50%"/>
</div>

특히 취업관련 특강, 기술특강, 서류작성 포트폴리오 방향성 등 <br/>
Expand All @@ -208,6 +208,6 @@ PR 을 올릴 때, 질문을 진짜 많이 적었고, 고민한 흔적 설계
쫌 힘들고 아프고 그래야 성장하는 타입인것같습니다.. 약간 ~~마조히스트~~ 기질이 있는것 같기도 하고요

<div style="display:flex">
<img src="./img/kakao-tech-campus-record/ideathon-1.jpg" style="flex: 1; width:50%"/>
<img src="./img/kakao-tech-campus-record/ideathon-2.jpg" style="flex: 1; width:50%"/>
<img src="./img/ideathon-1.jpg" style="flex: 1; width:50%"/>
<img src="./img/ideathon-2.jpg" style="flex: 1; width:50%"/>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ head:

힘든것은 사실이지만, 그 이후에 어떤 태도를 선택할지는 결국 각자의 몫이다

![도망쳐서 도착한 곳에 낙원이란 있을 수 없는 거야](./img/overcoming-difficult-times/image.png)
![도망쳐서 도착한 곳에 낙원이란 있을 수 없는 거야](./img/image.png)

<center>

Expand Down
Loading