GROWTH LOG STORIES

함께 만든 성장을,
기록으로 연결합니다.

AI와 함께 배우고, 직접 만들고, 서로 나눈
그로스로그 멤버들의 생생한 성장 기록을 만나보세요.

성장 기록
5기+이어진 커뮤니티
HEALTHIO
PROJECT

사진 한 장으로 시작하는 건강 루틴, Health IO

대표 이야기 읽기

ALL STORIES

성장의 순간을
한곳에서 만나보세요.

프로젝트의 시작부터 모임에서 나눈 인사이트까지,
그로스로그가 함께 만든 기록을 모았습니다.
개의 성장 기록

성장일지

[GL 4기 3회차 ①] 스펙을 읽고 장애를 부검하기 — 웹소켓 재구현, 커넥션 풀 고갈, LLM 퀴즈 준비 🌱

GROWTH LOG🌱 2026. 8. 11. 00:08

안녕하세요! 그로스로그 입니다!😊 🌱

내가 직접 할 일과 남에게 맡길 일. 그 선을 어디에 그을지 각자 다른 방식으로 답한 세 편입니다.

성장일지는 멤버들이 2주에 한 번씩 자신의 성장을 기록하는 활동이에요. 매 회차 운영진이 모든 글을 읽고, 그중 특히 마음에 남은 글을 함께 골라 큐레이션합니다.

3회차는 밀도가 높습니다. 스펙 문서 정독기, 실제 장애 부검 리포트, 그리고 스터디 운영 자동화. 셋 다 길고 구체적이에요.

성장일지 큐레이션 커버
 

01

🔌 웹소켓 클라이언트를 다시 구현해야 한다면

오세명 님

이런 내용이에요
웹소켓 클라이언트를 처음부터 다시 만든다면 무엇을 챙길 것인가. 실무 경험을 스펙 문서와 대조해가며 정리한 글입니다.
세 덩어리예요. Ping/Pong — 살아 있는지 확인하는 메커니즘. Closing — 어떻게 끝내야 하는가. 재연결 — 언제 다시 붙어야 하는가.
인상적인 건 근거를 전부 명시한다는 점입니다. RFC 프로토콜 문서와 WHATWG 스펙을 직접 인용하고, 각주 네 개를 달았어요.

3회차 연속으로 소개하게 된 저자입니다. 1회차는 코딩 습관, 2회차는 6시간짜리 만들기, 이번엔 스펙 정독기예요. 결이 매번 다릅니다.

조금 더 들어가 보면

이 글의 첫 문장에 이미 태도가 들어 있습니다.

"유저 랜드에서 ping/pong 메커니즘을 구현한다면 그래야만 하는 이유가 분명히 있어야 할 것이다."

"구현하기 전에 왜 구현하는지부터"입니다. 그리고 이어지는 조사 결과가 통념을 뒤집어요.

🏓 Ping/Pong — 이미 브라우저가 하고 있다

웹소켓 연결이 끊겼는지 확인하려고 하트비트를 직접 만드는 코드는 아주 흔합니다. 몇 초마다 "ping"이라는 문자열을 보내고 서버가 "pong"을 돌려주는 식이죠.

그런데 글은 프로토콜에 이미 그게 있다고 짚습니다.

"프로토콜에서는 어느 한쪽이 Ping 프레임(opcode가 0x9)을 보내면 다른 한쪽이 반드시 Pong 프레임(opcode가 0xA)을 보내야 한다고 규정하였다."

여기서 프레임의 opcode"이 데이터가 무슨 종류인지"를 나타내는 표시입니다. 텍스트인지, 바이너리인지, 종료 신호인지, 핑인지. 프로토콜 차원에서 핑퐁이 정의돼 있는 거예요.

그런데 다음 문장이 중요합니다.

"WebSocket 구현체에서는 현재 Ping/Pong 프레임을 보내는 API를 노출시키지 않는다고 규정하였다."

브라우저의 WebSocket 객체에는 핑을 보내는 메서드가 없습니다. 일부러 안 만들었어요. 스펙이 그렇게 규정했습니다.

왜냐하면 브라우저가 알아서 하고 있기 때문입니다. 서버가 Ping 프레임을 보내면 브라우저가 Pong으로 답합니다. 우리 코드가 관여하지 않는 곳에서요.

그러니까 하트비트를 직접 만드는 코드는 이미 있는 걸 한 층 위에서 또 만드는 셈입니다. 그리고 이건 프로토콜의 진짜 핑퐁이 아니라, 그냥 평범한 텍스트 메시지예요. 서버가 그걸 알아듣게 따로 짜줘야 합니다.

🧭 그런데 왜 실무에서는 여전히 만드는가

글이 짚은 지점이 흥미롭습니다.

"유저 에이전트가 Ping/Pong 프레임을 주고받을 때에는 서버가 Ping 프레임을 보내는 것을 가정한다는 것이다."

핑을 먼저 보내는 쪽이 서버로 상정돼 있습니다. 그래서 서버가 안 보내면 아무 일도 안 일어나요. 그리고 서버 라이브러리들은 이 설정이 기본으로 꺼져 있거나 옵션인 경우가 많습니다.

여기서 실무 조언이 나옵니다.

"만약 유저가 켜놓고 가만히 있었는데 웹소켓이 자동으로 끊겼다면 중간에 프록시를 해주는 툴의 설정을 봐보자."

아주 자주 겪는 증상입니다. 개발 환경에서는 멀쩡한데 배포하면 1분쯤 뒤 연결이 끊기는 현상이요. 원인은 대개 앞단의 프록시입니다. 아무 데이터도 안 흐르면 죽은 연결로 보고 끊어버리거든요. 흔히 쓰는 웹서버들의 기본 유휴 타임아웃이 60초 안팎입니다.

그래서 글의 처방이 정확해요. "적어도 Ping 프레임을 보내는 인터벌보다는 길게 timeout 설정이 되어 있어야."핑 간격 < 프록시 타임아웃이어야 합니다.

⏰ 타이머 쓰로틀링이라는 함정

그리고 마지막 경고가 실전적입니다.

"어쩔수 없이 타이머 API를 사용해서 수동으로 구현해야 한다면 타이머 쓰로틀링을 겪을 수 있다는 점도 염두해주자."

이게 왜 무서운 함정이냐면요.

브라우저는 배터리를 아끼려고 백그라운드 탭의 타이머를 느리게 만듭니다. 탭이 보이지 않으면 setInterval(fn, 5000)이 5초마다 안 돌아요. 1분에 한 번으로 늘어질 수 있습니다.

그러면 어떤 일이 생기냐면 — 탭을 다른 데 두고 있으면 하트비트가 멈춥니다. 프록시는 유휴로 판단하고 끊고요. 사용자가 탭으로 돌아왔을 때 연결이 죽어 있습니다.

하트비트를 직접 만들었기 때문에 생긴 문제입니다. 브라우저가 관리하는 프로토콜 핑퐁은 자바스크립트 타이머와 무관하니까 이 영향을 안 받아요.

🚪 Closing — 문을 제대로 닫는다는 것

두 번째 덩어리는 종료입니다.

"스펙에서는 Document 객체가 정리되는 시점(Goes away)에 웹소켓 연결이 되어있으나 아직 종료하지 않았을 때에는 1001 코드와 함께 종료 절차를 진행하도록 규정한다."

그리고 그 경우를 나열합니다.

· 사용자가 새로고침을 하는 경우

· 사용자가 탭을 닫는 경우

· 사용자가 브라우저를 닫는 경우

· 프로젝트가 MPA이고 사용자가 다른 페이지로 이동하는 경우

· Document가 discarded 되는 경우

"이런 케이스들에 대해서는 유저 에이전트에서 코드를 보내도록 하는게 가장 깔끔할 것이다!"

브라우저에 맡기라는 결론입니다. beforeunloadsocket.close()를 붙이는 코드를 종종 보는데, 위 다섯 가지를 전부 잡아내기가 어렵고 브라우저가 이미 처리하고 있어요.

📝 원문에서는 이렇게 예외를 다룹니다

다만 예외가 하나 있습니다. bfcache입니다.

function onPageHide(event: PageTransitionEvent) {
  if (event.persisted) {
    // closing..
  }
}

function onPageShow(event: PageTransitionEvent) {
  if (event.persisted) {
    // reconnecting..
  }
}

bfcache는 뒤로 가기를 빠르게 만드는 브라우저 기능입니다. 페이지를 버리지 않고 통째로 얼려뒀다가, 돌아오면 그대로 되살려요. 그래서 스크롤 위치도 입력값도 남아 있습니다.

문제는 웹소켓도 같이 얼어붙는다는 겁니다. 얼어 있는 동안 서버는 응답 없는 연결로 보고 끊고, 되살아났을 때 클라이언트만 "연결됐다"고 착각하는 상태가 됩니다.

그래서 얼기 직전(pagehide)에 닫고, 깨어난 뒤(pageshow)에 다시 붙는 처리가 필요해요. event.persistedbfcache로 들어가는/나오는 경우인지 구분해주는 값입니다.

🔁 재연결 — 세 가지 경우로 나누기

마지막 덩어리가 가장 실용적입니다. 재연결 판단을 세 갈래로 나눠요.

첫째, 깨끗하지 않게 닫힌 경우. CloseEventwasCleanfalse고, 코드는 대개 1006입니다.

"이 문제가 일시적인 문제인지 아닌지를 판단할 수 없으니, 나는 재연결을 시도해보기를 권장한다."

각주에 붙은 설명이 이 글에서 가장 흥미로운 대목이었습니다.

"1006 코드는 Abnormal Closure으로, Close Frame에 넣는 코드가 아니다. (…) 유저 에이전트는 이 코드를 동봉해서 보낼 때 reason에 왜 1006이 전달되었는지 구체적인 맥락을 전달하지 않는다. 즉 이 코드를 받았을 때 왜 받았는지를 유저랜드에서 알 수 없다. 이 이유를 노출하면 로컬 네트워크 구조를 탐지할 수 있는 보안 취약점을 만들 수 있기 때문이다."

"왜 끊겼는지 알 수 없다"가 버그가 아니라 의도된 설계라는 겁니다.

이유를 알려주면 어떻게 되냐면, 악의적인 페이지가 여러 주소로 연결을 시도해보면서 에러 메시지 차이로 내부망 구조를 알아낼 수 있습니다. "이 주소는 연결 거부, 저 주소는 타임아웃" 같은 차이가 지도가 되거든요. 그래서 브라우저가 전부 똑같이 1006으로 뭉뚱그립니다.

디버깅할 때 답답한 이 동작에 이유가 있다는 걸 알아낸 게 이 글의 성실함입니다.

둘째, 깨끗하게 닫혔지만 다시 붙어야 하는 경우. wasCleantrue인데 codereason이 재연결을 요구하는 상황이죠. "이건 팀마다 정한 규칙에 따라"라고 정직하게 남겨둡니다. 정답이 없는 영역이니까요.

셋째, 처음부터 연결이 실패한 경우. 재연결 로직은 보통 "붙었다가 끊긴" 경우만 생각하는데, 처음부터 못 붙은 경우도 챙기라고 짚습니다. 놓치기 쉬운 지점이에요.

큐레이터 노트

스펙을 근거로 삼은 글이라 뽑았습니다.

웹소켓 관련 글은 대부분 "이렇게 하면 됩니다" 형태의 코드 모음입니다. 이 글은 "스펙이 이렇게 규정했다"로 말해요. RFC 프로토콜 문서와 WHATWG 스펙을 각각 인용하고, 둘이 어떻게 다른지도 구분합니다.

그래서 결론이 남과 다릅니다. 대부분의 글은 "하트비트를 구현합시다"라고 하는데, 이 글은 "이미 브라우저가 하고 있으니 굳이 하지 마라"고 해요. 그리고 그 근거를 스펙 문장으로 댑니다.

각주 문화도 좋았습니다. 본문 흐름을 끊지 않으면서 깊은 이야기를 따로 담아뒀어요. 1006의 보안 배경 같은 건 각주였기에 들어갈 수 있었던 내용입니다.

제목도 정확합니다. "다시 구현해야 한다면" — 이미 한 번 해본 사람이 쓴 글이라는 뜻이에요. 처음 배우며 쓴 정리와 한 바퀴 돌고 나서 쓴 정리는 다릅니다. 이 글은 후자예요.

원문 읽으러 가기  ↗semyung.dev/blogs/my-think-of-web-socket-client/
 

02

🔥 6/26 DB Connection Pool 고갈 장애 분석 및 해결 방안 리포트

이준호 님

이런 내용이에요
실제로 서비스가 멈춘 날의 부검 리포트입니다. 오전 9시 14분 최초 보고, 하루 동안 멤버십 가입과 취소·교환·반품이 안 됐어요.
에러는 한 줄이었습니다. Knex: Timeout acquiring a connection. The pool is probably full
그런데 원인은 네 가지가 겹친 결과였습니다. 루프 안의 flush, em.fork() 남용, 롤백 누락, 동시성 제어 없는 웹훅. 각각을 코드와 함께 짚고 단기·장기 대응을 나눠 제시합니다.

블로그 글이라기보다 사내 문서에 가깝습니다. 그래서 더 귀합니다. 이런 문서는 보통 밖으로 안 나오거든요.

조금 더 들어가 보면

먼저 커넥션 풀이 뭔지부터요.

애플리케이션은 DB에 연결할 때마다 새 연결을 만들지 않습니다. 비싸거든요. 그래서 미리 몇 개(예: 50개)를 만들어두고 돌려씁니다. 요청이 오면 하나 빌려 쓰고 끝나면 반납해요. 이게 커넥션 풀입니다.

"Timeout acquiring a connection"빌릴 게 없어서 기다리다 포기했다는 뜻이에요.

그러면 두 가지 중 하나입니다. 너무 오래 빌리고 있거나, 반납을 안 하고 있거나. 이 리포트가 밝힌 건 둘 다였습니다.

🕵️ 가장 좋은 단서 — "재배포하면 1시간 뒤 재발"

리포트에서 추리가 가장 빛나는 대목이 여기입니다.

"서버 재배포 시 일시적으로 정상화되었던 이유는, 재배포 과정에서 기존에 쌓여있던 유휴(Idle) 또는 비정상 커넥션들이 모두 초기화되었기 때문입니다. 하지만 근본 원인이 해결되지 않았기에, 약 1시간에 걸쳐 서서히 커넥션이 다시 고갈되는 현상이 반복된 것입니다."

이 패턴 자체가 증거입니다.

만약 단순히 트래픽이 몰려서 부족한 거라면, 재배포해도 트래픽이 그대로니 금방 다시 터져야 합니다. 그런데 1시간에 걸쳐 서서히 돌아왔어요. 이건 뭔가가 조금씩 새고 있다는 뜻입니다.

"재배포하면 잠깐 낫는다"를 임시방편으로 넘기지 않고 단서로 읽은 것 — 이게 부검의 자세입니다.

1️⃣ 루프 안의 flush

첫 번째 원인이 가장 흔한 안티패턴입니다.

for (const ruleset of rulesets) {
  await this.createRulesetRecordWithEm(em, /* ... */); // 내부적으로 flush 발생
}

리포트가 persistflush의 차이를 아주 정확히 설명해요.

"em.persist(entity)의 동작은 매우 가벼운 인메모리(in-memory) 작업입니다. DB와는 전혀 통신하지 않습니다. (…) 반면, em.flush()는 persist 등으로 등록된 모든 변경사항을 모아서 실제로 DB에 동기화하는 무거운 작업입니다."

persist는 메모장에 적는 것, flush는 우체국에 가는 것입니다. 100건을 처리한다면 메모는 100번 해도 되지만 우체국은 한 번만 가면 돼요.

그런데 위 코드는 100번 갑니다. 갈 때마다 커넥션을 빌리고 반납하고요. 리포트가 flush 한 번에 일어나는 일을 여섯 단계로 풀어놓은 것도 좋았습니다. 변경 감지 → SQL 생성 → 트랜잭션 시작 → DB 왕복 → 커밋 → 결과 대기.

고친 형태는 이렇습니다.

// TO-BE: 루프 밖 단일 flush
const entitiesToPersist = [];
for (const item of items) {
  const entity = new Entity();
  // ...
  entitiesToPersist.push(entity);
}
await em.persistAndFlush(entitiesToPersist); // 훨씬 효율적

모아뒀다가 한 번에. 이 패턴은 ORM 종류를 가리지 않고 통합니다.

2️⃣ em.fork() 남용 — 커넥션이 두 배가 된다

두 번째 원인이 이 리포트에서 가장 배울 게 많은 부분이었습니다.

em.fork()독립된 작업 공간을 새로 만드는 기능입니다. 문제는 그 공간이 자기 커넥션을 따로 쓴다는 것.

"만약 동시에 50개의 API 요청이 들어왔는데, 각각의 요청을 처리하는 로직 안에서 em.fork()를 한 번씩 호출한다고 가정해 보겠습니다. 원본 EntityManager 50개 + fork된 EntityManager 50개 = 최대 100개의 DB 커넥션이 동시에 필요하게 될 수 있습니다."

풀 크기가 50이면 나머지 50은 기다리다 타임아웃입니다. 그리고 루프 안에서 부르면 더 나빠집니다.

그런데 리포트는 성능보다 더 위험한 문제를 따로 짚어요.

"동일한 데이터, 다른 객체 인스턴스"

ORM에는 원칙이 하나 있습니다. 한 작업 공간 안에서 같은 DB 행은 항상 같은 객체라는 것. fork()는 이 원칙을 깹니다.

리포트가 든 시나리오가 명확해요.

원본:   userA.name  = '김영희'
복제본: userB.name  = '김철수'

forkedEm.flush()  →  DB에 '김철수'
em.flush()        →  DB에 '김영희'   ← '김철수'가 사라짐

아무 에러도 안 납니다. 그냥 한쪽 수정이 조용히 없어져요. 이걸 업데이트 유실(Lost Update)이라고 합니다.

에러가 나는 문제보다 안 나는 문제가 무섭다는 걸 잘 보여주는 예시입니다.

대안은 @EnsureRequestContext()입니다. 리포트의 설명이 깔끔해요. 컨텍스트가 없으면 만들어주고, 있으면 기존 걸 쓴다. 개발자가 생성·해제 시점을 신경 쓸 필요가 없어집니다.

3️⃣ 롤백 부재 — idle in transaction

세 번째가 직접적인 누수 지점입니다.

try {
  await em.begin();
  // ... 비즈니스 로직 ...
  await em.commit();
} catch (error) {
  // 에러 발생 시 rollback 처리가 없음!
}

catch가 비어 있습니다. 에러를 잡기만 하고 트랜잭션을 정리하지 않아요.

그러면 DB 입장에서 그 커넥션은 "BEGIN은 했는데 COMMIT도 ROLLBACK도 안 온 상태"로 남습니다. PostgreSQL에서 idle in transaction이라고 부르는 상태예요. 무기한 대기합니다.

한 번 새면 그 커넥션은 영영 안 돌아옵니다. 그래서 서서히 줄어들다가 한 시간쯤 뒤에 바닥나요. 아까 본 그 패턴과 정확히 맞아떨어집니다.

리포트의 장기 처방이 좋았습니다.

"em.begin()의 사용을 가급적 지양하고, 대신 em.transactional(async (em) => { ... }) 콜백 함수 스타일 사용을 권장합니다. 이 방식은 에러 발생 시 자동으로 롤백을 처리해주어 개발자의 실수를 방지합니다."

실수할 수 없게 만드는 쪽으로 API를 바꾸는 것입니다. begin/commit을 손으로 쓰면 언젠가 누가 빠뜨려요. 콜백 스타일은 빠뜨릴 자리가 없습니다.

🛡️ "개인의 주의력에 의존하기보다"

장기 방안에 나오는 이 문장이 리포트 전체를 관통합니다.

"개인의 주의력에 의존하기보다, 시스템과 규칙을 통해 잠재적인 문제를 코딩 단계에서부터 원천적으로 차단하는 것이 중요합니다."

그리고 구체적인 장치를 붙입니다.

· ESLint 규칙으로 em.fork() 사용 시 경고

· PR 템플릿fork() 사용 타당성 설명란 추가

· 루프 안 flush 호출을 잡는 커스텀 린트 규칙 검토

"다음부터 조심합시다"로 끝내지 않았어요. 조심하지 않아도 걸리게 만들었습니다.

관측 쪽도 마찬가지입니다. 리포트에는 커넥션 풀 상태를 1분마다 남기는 코드가 통째로 들어 있어요.

@Cron(CronExpression.EVERY_MINUTE)
logConnectionPoolStatus() {
  const knex = this.orm.em.getKnex();
  const pool = knex.client.pool;

  const poolStats = {
    totalCount: pool.getPoolSize(),           // 풀의 전체 사이즈
    usedCount: pool.numUsed(),                // 현재 사용 중인 커넥션 수
    freeCount: pool.numFree(),                // 유휴 상태(사용 가능)인 커넥션 수
    pendingAcquires: pool.numPendingAcquires(), // 커넥션을 기다리는 요청 수
    pendingCreates: pool.numPendingCreates(),
  };

  this.logger.info({ db_pool: poolStats }, 'DB Connection Pool Stats');
}

특히 pendingAcquires가 핵심 지표입니다. 커넥션을 기다리는 요청 수니까요. 이 값이 0보다 커지기 시작하면 타임아웃이 나기 전에 알 수 있습니다.

그리고 왜 이렇게까지 하는지도 설명합니다. Neon DB는 앞단에 커넥션 풀러를 두기 때문에 DB 콘솔에서는 애플리케이션 풀 상태가 안 보인다고요. 보이지 않는 것을 보이게 만드는 작업입니다.

"추측하지 말고 측정하라(Don't guess, measure!)"

큐레이터 노트

실제 장애가 그대로 담긴 문서라서 뽑았습니다.

장애 회고는 밖으로 잘 안 나옵니다. 부끄럽기도 하고, 내부 정보라서요. 그런데 이런 문서가 가장 배울 게 많습니다. 잘 만든 시스템 이야기보다 무너진 시스템의 단면이 더 많은 걸 알려주거든요.

문서 구조가 뛰어납니다. 원인을 1~4순위로 매겨놨어요. 넷 다 문제지만 무엇을 먼저 고쳐야 하는지가 정해져 있습니다. 그리고 단기와 장기를 나눴어요. 급한 불부터 끄되 체질 개선 계획도 같이 세웠습니다.

동료 이름을 밝힌 것도 좋았습니다. "@최승준_Commerce 님이 PR #531에서", "@이준엽_Commerce 님이 마지막에 수정한 PR #534". 누가 무엇을 고쳤는지 기록하고 있어요. 웹훅 개선은 제환 님 몫이었다고 따로 적기도 했고요.

근거를 다는 습관도 눈에 띕니다. 마지막 장이 통째로 레퍼런스예요. 공식 문서 링크, API 문서 인용, 그리고 GitHub 이슈에서 메인테이너가 뭐라고 답했는지까지. 팀을 설득하려면 이만큼의 근거가 필요하다는 걸 아는 문서입니다.

마지막 결론이 특히 좋았어요.

"fork()는 '하지 말아야 할 것'이 아니라 '정확히 알고 써야 하는 것'입니다."

도구를 탓하지 않았습니다. 장애 회고가 빠지기 쉬운 함정 하나를 잘 피해갔어요.

원문 읽으러 가기  ↗joonfluence.tistory.com/897
 

03

⏱️ Particify와 LLM으로 2시간 걸릴 퀴즈 준비 10분 만에 끝내기

김종진 님

이런 내용이에요
2025년 하반기에 스터디 세 개를 동시에 운영하게 된 상황에서, 퀴즈 플랫폼을 고르고 문제 준비를 자동화한 기록입니다.
요구사항을 먼저 정하고 후보를 하나씩 떨어뜨려요. Kahoot은 글자 수, Slido는 줄바꿈, AhaSlides는 문제 수와 인원 제한. 남은 게 독일의 Particify였습니다.
그리고 CSV 가져오기/내보내기 + LLM을 붙여 1~2시간 걸리던 준비를 10분으로 줄입니다.

기술 구현 글은 아닙니다. 그런데 도구를 고르고 붙이는 방식에서 배울 게 많은 글이에요.

조금 더 들어가 보면

이 글이 잘한 첫 번째는 순서입니다.

보통은 반대로 합니다. 유명한 도구를 먼저 써보고, 안 맞으면 다른 걸 찾아보고요. 이 글은 요구사항을 먼저 적었습니다.

· 인원: 매주 5명 이상

· 문제: 문제당 700~1,000자의 긴 지문

· 분량: 20개 이상 문제 등록

· 기능: 문제별 타이머

· 비용: 무료

이 다섯 줄이 있으니 판단이 빨라집니다. 써보지 않고도 탈락시킬 수 있어요.

📏 무료 플랜의 제한은 저마다 다른 축에 있다

떨어진 이유들이 흥미롭습니다.

도구 걸린 제한
Kahoot 문제 100자, 답안 70자
Slido 255자, 게다가 줄바꿈 불가
AhaSlides 퀴즈당 10문제, 참가자 3명

제한을 거는 축이 다 다릅니다. 글자 수, 문제 수, 인원 수.

이게 왜 중요하냐면 — 내 요구사항이 어느 축에 걸리는지에 따라 답이 달라지기 때문입니다. 이 스터디는 AWS 자격증 문제를 다뤘어요. 자격증 문제는 지문이 깁니다. 상황 설명이 몇 줄씩 붙거든요. 그러니 글자 수 축이 결정적이었습니다.

같은 도구라도 짧은 퀴즈를 하는 사람에게는 Kahoot이 최선일 수 있어요. 글이 도구를 깎아내리지 않고 "내 조건에 안 맞았다"로 적은 게 정확합니다.

Slido의 줄바꿈 불가를 짚은 것도 눈에 띄었어요. 글자 수만 보면 255자로 부족하지 않아 보이는데, 줄바꿈이 안 되면 코드나 목록이 한 줄로 뭉개집니다. 직접 써보지 않으면 모르는 종류의 제약이에요.

🔑 CSV — 이 글의 진짜 발견

Particify를 고른 뒤에 찾아낸 기능이 판을 바꿉니다.

"스터디 시작전에는 존재를 인지하지 못했었지만, 발견하고나서 제일 유용하게 사용하고 있는 기능이 문제세트 내보내기/들여오기 기능이다."

CSV로 내보내고 들여올 수 있다는 것. 평범해 보이는데 실제로는 두 가지가 딸려옵니다.

첫째, 백업입니다. 글이 짚은 대로 "기간만료로 삭제 또는 실수로 문제세트를 제거하여도 다시 import하여 복원할 수 있다." 무료 서비스는 보관 기간에 제한이 있는 경우가 많은데, 내 손에 파일이 있으면 상관없어요.

둘째, 그리고 이게 더 큽니다 — 입구가 열립니다. 웹 화면에서 하나씩 입력하는 대신, 파일을 만들어서 밀어 넣을 수 있게 됩니다. 그러면 그 파일을 누가 만드느냐가 새로운 질문이 돼요.

🤖 LLM에게 구조를 가르치기

여기서 자동화가 붙습니다.

"LLM에게 CSV 예시를 학습시킨 뒤 문제 텍스트를 주면, 자동으로 완성된 CSV를 생성해준다."

방법이 구체적입니다.

1. ParticifyCSVImportGuide.md예시 CSV 파일을 먼저 전달해 구조를 이해시킨다

2. 과목별 문제 자료를 준비한다

3. 마크다운 파일이나 이미지를 주고 CSV를 생성시킨다

4. import해서 결과를 확인한다

1번이 핵심입니다. "CSV로 만들어줘"라고만 하면 열 이름도 순서도 제멋대로 나와요. 가이드 문서와 실제 예시 파일을 먼저 주면 그 형식을 따라갑니다. 정답 표시 방식, 타이머 값 형식 같은 세부까지요.

그리고 이 팁이 재미있었습니다.

"응답이 필요없고 LLM 세션 환경설정할때는 1만 찍게 하는편 선호"

파일을 여러 개 전달하는 동안 매번 요약이나 감상이 돌아오면 거추장스럽습니다. 그래서 "이해했으면 1만 답해"라고 시켜두는 거예요. 준비 단계와 작업 단계를 분리하는 작은 요령입니다.

결과가 명확합니다.

"기존에는 약 20개의 문제 직접 입력하고 잘 설정했는지 확인까지 1~2시간이 소요되었는데 파일 던져서 csv 생성한 뒤 import하면 되어서 10분 내외로 셋팅 가능"

스터디 세 개를 매주 돌리는 상황이었으니, 주당 서너 시간이 30분으로 줄어든 셈입니다. 자동화가 성립하는 조건이 명확해요. 반복되니까요.

🧩 아쉬운 점을 대하는 방식

마지막 장이 이 글에서 가장 좋았습니다. 아쉬운 기능 세 가지를 적어요.

· 문제/답안 순서 섞기 — 반복하면 이해가 아니라 위치를 외우게 됨

· 문제 즐겨찾기 — 틀린 문제만 다시 풀기

· 문제별 질문/답변 — 지금은 주차 단위 Q&A만 가능

그런데 여기서 멈추지 않습니다.

"현재로서는 csv로 추출 가능하니 다시 LLM 돌려서 문제/선택지 순서만 바꿔줘 라고 한뒤 재입력하여 풀던가, 응답값을 통해 틀린문제만도 추출 가능할것같은데"

플랫폼에 없는 기능을 CSV 왕복으로 만들어내는 발상입니다.

내보내기 → LLM에게 섞으라고 시키기 → 다시 들여오기. 순서 섞기 기능이 생긴 것과 결과가 같아요. 틀린 문제 모으기도 응답 데이터를 뽑으면 됩니다.

이게 아까 말한 "입구가 열린다"의 진짜 의미입니다. 데이터를 꺼내고 넣을 수 있으면, 그 사이에 무엇이든 끼워넣을 수 있어요.

그리고 이 한 문장이 이 글의 태도를 요약합니다.

"완벽한 플랫폼은 없다. 다만 Particify는 무료임에도 실전 스터디 운영에 충분한 수준의 기능을 제공한다."

큐레이터 노트

고르는 과정 전체를 남긴 글이라 뽑았습니다.

도구 추천 글은 대개 "이거 좋아요"로 시작합니다. 그런데 읽는 사람 입장에서는 "내 상황에도 좋은가"를 알 수 없어요. 이 글은 요구사항 다섯 줄탈락 사유를 밝힙니다. 그래서 조건이 다른 사람도 자기 답을 계산할 수 있어요. 짧은 퀴즈면 Kahoot을, 세 명 이하면 AhaSlides를요.

떨어진 도구들을 기록한 게 특히 값집니다. 보통 최종 선택만 적잖아요. 그런데 "Kahoot은 100자 제한" 같은 정보는 직접 가입해서 써봐야 알 수 있는 것들입니다. 남이 쓴 시간을 아껴주는 기록이에요.

퀴즈 링크를 전부 공개한 것도 인상적이었습니다. AWS SAA 2~6주차, 방통대 Unix 시스템. "어떤식으로 작동하는지 궁금할 사람들을 위해"라고요. 말로 설명하는 대신 직접 눌러보게 했습니다.

그리고 이 글은 문제를 자동화한 게 아니라 문제 준비를 자동화했어요. 어느 쪽이 반복인지 정확히 골랐습니다. 스터디의 본질(무엇을 배울지 정하고, 함께 풀고, 이야기하는 것)은 그대로 두고 손이 많이 가는 옮겨 적기만 덜어냈습니다.

원문 읽으러 가기  ↗www.logonme.click/activities/etc/particify/

💡 세 편을 겹쳐 읽으며

세 글의 영역이 완전히 다릅니다. 브라우저 스펙, 서버 장애, 스터디 운영.

그런데 셋 다 같은 일을 했어요. 경계를 읽고, 선을 그었습니다.

오세명 님은 스펙을 읽고 선을 그었습니다. 핑퐁은 브라우저가 이미 하니 손대지 않는다. 종료 코드도 브라우저에 맡긴다. 대신 bfcache와 재연결은 내 몫이다.

이준호 님은 공식 문서와 메인테이너 답변을 읽고 선을 그었습니다. 컨텍스트 관리는 프레임워크(@EnsureRequestContext)에 맡긴다. 롤백은 콜백 스타일에 맡긴다. 대신 린트 규칙과 모니터링은 우리가 만든다.

김종진 님은 무료 플랜의 제한을 읽고 선을 그었습니다. 문제 형식 변환은 LLM에 맡긴다. 대신 무엇을 물을지, 어떤 도구를 쓸지는 내가 정한다.

세 번 다 방향이 같습니다. 이미 잘 되어 있는 건 맡기고, 아무도 안 해주는 것에 시간을 쓰는 쪽으로요.

그리고 이 선을 그으려면 먼저 읽어야 합니다. 스펙을, 공식 문서를, 요금제 안내를요. 안 읽으면 두 가지 실수가 나옵니다. 이미 있는 걸 또 만들거나(직접 만든 하트비트), 맡겨도 되는 걸 손으로 쥐고 있거나(em.fork()를 직접 관리).

세 글 다 읽는 데 먼저 시간을 썼습니다. 그게 셋 다 깊이 있는 이유예요.

이번 회차에서 가져갈 것

1. 웹소켓 하트비트를 직접 만들기 전에 확인하기.

프로토콜에 Ping/Pong 프레임이 이미 있고 브라우저가 관리합니다. 직접 만든 하트비트는 그냥 텍스트 메시지고, 백그라운드 탭에서 타이머가 느려지면 멈춥니다.

2. 연결이 자꾸 끊기면 프록시 타임아웃을 보라.

유휴 연결을 끊는 기본값이 보통 60초 안팎입니다. 핑 간격이 프록시 타임아웃보다 짧아야 합니다.

3. 1006은 이유를 알려주지 않는다 — 의도된 설계다.

이유를 노출하면 내부 네트워크 구조를 탐지할 수 있어서요. wasClean: false면 일단 재연결을 시도하는 게 실용적입니다. 처음부터 연결이 실패한 경우도 챙기세요.

4. persist는 메모장, flush는 우체국.

루프 안에서 flush를 부르면 반복 횟수만큼 DB를 왕복합니다. 모아뒀다가 루프 밖에서 한 번에 persistAndFlush(배열) 하세요.

5. catch 블록에 롤백이 있는지 확인하기.

없으면 그 커넥션은 idle in transaction으로 영영 안 돌아옵니다. 아예 em.transactional(async (em) => {...}) 콜백 스타일을 쓰면 빠뜨릴 자리가 없어져요.

6. "재배포하면 잠깐 낫는다"는 누수의 신호다.

트래픽 문제라면 재배포해도 금방 재발합니다. 서서히 나빠지는 패턴은 뭔가가 반납되지 않고 있다는 뜻이에요.

7. 주의력 대신 장치를 만들라.

"다음부터 조심합시다"는 다음에 또 일어납니다. 린트 규칙, PR 템플릿, 자동 롤백 API — 실수할 자리를 없애는 쪽으로 가세요.

8. 도구는 요구사항을 적고 나서 고른다.

인원·글자 수·문제 수·기능·비용을 먼저 적으면 써보지 않고도 탈락시킬 수 있습니다. 그리고 떨어진 이유를 기록해두면 조건이 다른 사람에게도 쓸모가 있어요.

9. 데이터를 꺼내고 넣을 수 있으면 기능을 만들 수 있다.

CSV 내보내기/들여오기 하나로 백업, 순서 섞기, 오답 모으기가 다 가능해집니다. 도구를 고를 때 "내 데이터를 가져올 수 있는가"를 꼭 보세요.

10. LLM에게는 예시 파일을 먼저 준다.

"CSV로 만들어줘"보다 가이드 문서 + 실제 예시 파일을 주고 시작하면 형식이 안 흔들립니다. 준비 단계에서는 "이해했으면 1만 답해"로 잡담을 줄이세요.


좋은 글 남겨주신 오세명 님, 이준호 님, 김종진 님께 감사드립니다! 😊

다음 큐레이션으로 다시 찾아오겠습니다! 🌱

- 에디터 · 성장일지 큐레이터 -


※ 본 큐레이션은 각 저자가 공개한 글을 소개하는 것이며, 모든 원문의 저작권은 저자에게 있습니다. 저자 본인의 요청이 있을 경우 즉시 수정 또는 삭제합니다.

WRITE YOUR GROWTH

오늘의 배움을
내일의 성장으로.

기록은 나를 돌아보게 하고, 공유는 우리를 연결합니다.
당신의 다음 성장 이야기도 그로스로그에서 시작해보세요.

커뮤니티 둘러보기