『오브젝트』 공부를 시작하고 객체의 자율성과 캡슐화, 책임과 협력에 대해 배우고 있습니다. 프로젝트 코드를 작성하고 팀원들과 치열하게 리뷰를 주고받다 보니, 책에서 읽은 내용을 자연스럽게 우리가 사용하는 컴포넌트와 훅에 대입해보게 됐어요. 그 과정에서 자주 떠오른 질문이 있습니다. 훅은 정말 하나의 일만 해야 할까요? 흔히 단일 책임 원칙(SRP)을 ‘하나의 모듈은 하나의 일만 해야 한다’는 뜻으로 이해하곤 하죠. 그 말대로라면 훅도 하나의 일만 해야 할 것 같지만, 실제 훅을 열어보면 상태를 읽는 코드도, 값을 계산하는 코드도, 다른 훅을 부르는 코드도 함께 들어 있습니다. 어디까지 함께 두고 어디서 나눠야 할지, 막상 판단하려면 쉽지 않죠. 이 글은 그 답을 찾아가는 과정의 일부입니다. 『오브젝트』가 말하는 자율적인 객체 를 훅에 대입해보고, 프로젝트 “knot”의 녹음 화면 PR에서 나눈 논의를 따라가며 훅의 책임에 대한 제 생각이 어떻게 달라졌는지 정리해보려 합니다. 객체는 스스로 일한다 『오브젝트』를 읽는 내내 가장 자주 마주친 단어는 자율성 이었습니다. 좋은 객체지향 설계에서는 모든 객체가 스스로 판단하고 행동해야 한다고 하죠. 그렇다면 스스로 일한다는 건 어떤 모습일까요? 자기 상태는 자기가 관리하고, 요청을 받으면 어떻게 처리할지도 자기가 정하는 것입니다. 그러려면 필요한 정보와 그 정보로 하는 행동이 같은 곳에 있어야 해요. 책의 영화 예매 예제로 비교해보면 차이가 바로 보입니다. // 바깥에서 요금을 꺼내 대신 계산한다 fee = movie.getFee().minus(discountAmount).times(audienceCount); // Movie에게 계산을 요청한다 fee = movie.calculateMovieFee(screening); 첫 번째 코드에서 Movie 는 요금을 담아두는 상자에 가깝습니다. 할인 규칙이 바뀌면 Movie 가 아니라, 요금을 꺼내 대신 계산하던 바깥 코드를 고쳐야 하죠. 두 번째 코드에서는 Movie 가 스스로 계산합니다. 요금이 몇 가지든, 어떻게 계산되든 바뀌는 곳은 Movie 내부뿐이에요. 변경의 주체가 바깥이 아니라 자기 자신일 때 , 그 객체를 자율적이라고 부를 수 있습니다. 그렇다고 자율적인 객체가 모든 일을 혼자 하는 건 아니에요. 두 번째 코드의 calculateMovieFee(screening) 처럼 스스로 할 수 없는 일은 다른 객체에게 메시지 를 보내 부탁합니다. 이렇게 자율적인 객체들이 메시지를 주고받으며 하나의 기능을 완성하는 것을 협력 이라고 부릅니다. 자율성을 지키는 방법, 캡슐화 객체를 자율적으로 만드는 방법은 내부 구현을 캡슐화 하는 것입니다. 바뀔 가능성이 높은 부분은 안에 숨기고, 비교적 안정적인 부분만 밖에 공개하는 거예요. 캡슐화라고 하면 ‘그냥 감추는 것 아닌가?’ 싶을 수 있어요. 하지만 캡슐화는 무엇을 하는지만 보여주고 어떻게 하는지는 내부에 두는, 추상화의 한 종류입니다. 호출부가 내부 구현을 모를수록 객체는 호출부를 신경 쓰지 않고 내부를 바꿀 수 있으니, 캡슐화는 사실상 자율성을 지키는 가장 기본적인 장치라고 볼 수 있죠. 여기서 오해하기 쉬운 부분이 있습니다. 캡슐화는 필드를 private 으로 막는 것만을 뜻하지 않아요. fee 를 private 으로 막아도 getFee() 로 그대로 꺼내준다면, 호출부는 여전히 ‘Movie 안에 요금이 있다’는 사실에 기대어 직접 계산합니다. 그래서 요금을 저장하는 방식이 바뀌는 순간, getFee() 로 요금을 꺼내 계산하던 호출부도 모두 고쳐야 하죠. 필드는 숨겼지만, 내부 구조는 그대로 드러내고 있었던 셈이에요. 캡슐화가 지키려는 건 필드가 아니라 내부가 바뀌어도 바깥은 그대로인 상태 입니다. 한마디로, 변경의 영향을 객체 안에 가두는 일이에요. 응집도와 결합도 캡슐화가 잘 되었는지는 응집도와 결합도, 두 가지 척도로 확인할 수 있습니다. 응집도 : 하나의 모듈 안에 서로 관련된 코드만 모여 있는 정도 → 저는 이걸 “같은 이유로 함께 바뀌는가?” 라는 질문으로 이해하고 있어요. 결합도 : 하나의 모듈이 다른 모듈의 내부를 알고 있는 정도 → 한 모듈을 고쳤을 뿐인데 그 모듈을 쓰던 코드까지 줄줄이 고쳐야 한다면, 결합도가 높은 거예요. 캡슐화를 지키면 바뀔 이유가 모듈 안으로 모여 응집도는 높아지고, 모듈을 쓰는 코드가 알아야 할 것이 줄어 결합도는 낮아집니다. 정리하면 자율성, 캡슐화, 응집도와 결합도는 모두 같은 곳을 가리키고 있어요. 변경을 한곳에 가두는 것. 그렇다면 훅도 자율적일 수 있을까요? 책을 읽다 보면 자꾸 ‘객체’ 자리에 컴포넌트와 훅을 넣어 읽게 됩니다. 컴포넌트와 훅도 상태와 로직을 함께 가지고, 서로 값을 주고받으며 협력하니까요. 그중에서도 훅은 일반 함수와 조금 다릅니다. 일반 함수 는 불렸을 때만 실행되고, 결과를 받은 컴포넌트가 다음 일을 정합니다. 훅 은 React 생애주기 안에서 상태를 기억하고, 이펙트로 스스로 일을 시작할 수도 있어요. 그래서 저는 로직을 훅으로 묶어 둔다는 걸, 그 훅을 하나의 자율적인 존재로 본다 는 뜻으로 이해했어요. 일반 함수로만 묶으면, 다음 일을 정하는 책임의 일부가 여전히 호출하는 컴포넌트에 남으니까요. 물론 반대도 성립합니다. 상태도 생애주기도 필요 없는 순수한 계산이라면 굳이 훅으로 감쌀 이유가 없죠. React 공식 문서 도 훅을 쓰지 않는 함수에는 use 를 붙이지 말라고 안내합니다. 그래서 컴포넌트의 로직을 훅으로 꺼낼지는 ‘몇 줄이나 되나’보다 ‘스스로 기억하거나 시작해야 하는 일인가’ 로 판단할 수 있어요. 그렇다면 훅으로 꺼내 컴포넌트는 그리기만 하게 두고, 아니라면 함수로 충분하죠. 이렇게 자율적인 객체를 훅으로 옮겨 보니, 자율적인 훅이 갖춰야 할 조건을 세 가지로 정리할 수 있었어요. 자기 일은 스스로 한다. 컴포넌트가 일일이 지시하지 않아도, 필요한 상태와 생애주기를 알아서 다룬다. 모르는 일은 맡긴다. 스스로 할 수 없는 일은 다른 훅이나 함수에 부탁하며 협력한다. 어떻게 하는지는 감춘다. 사용하는 쪽은 무엇을 해주는지만 알면 되고, 내부 구현을 몰라도 반환값만 보고 협력할 수 있다. 그런데 이 기준을 들고 실제 코드를 읽어보니, 새로운 고민이 생겼습니다. 녹음 바의 훅을 살펴봅니다 녹음 화면의 RecorderBar 는 경과 시간을 보여주고, 녹음을 일시 정지하거나 끝낼 수 있는 컴포넌트입니다. 필요한 값과 동작은 모두 useRecorderBar 에서 가져와요. 스타일과 일부 버튼을 생략하면 다음과 같습니다. function RecorderBar() { const { elapsedTime, handlePause, handleEnd } = useRecorderBar(); return ( <div> <span>{elapsedTime}</span> /* 경과 시간 */ <button onClick={handlePause}>일시 정지</button> <button onClick={handleEnd}>녹음 끝내기</button> </div> ); } 컴포넌트는 받은 값을 그리기만 합니다. 그렇다면 useRecorderBar 안에서는 무슨 일이 일어날까요? 설명에 필요한 부분만 남기면 이렇습니다. export const useRecorderBar = () => { // 1. 공통 녹음 기능과 화면 이동 기능을 가져온다 const { elapsedSeconds, startRecording, pauseRecording, endRecording } = useRecording(); const { navigateToHome } = useNavigateToHome(); // 2. 화면에 들어오면 녹음을 시작한다 useEffect(() => { startRecording(); }, [startRecording]); // 3. 녹음을 끝내면 홈으로 이동한다 const handleEnd = () => { endRecording(); navigateToHome(); }; // 4. 화면에 보여줄 형태로 반환한다 return { elapsedTime: formatRecordingTime(elapsedSeconds), // 65 → "01:05" handlePause: pauseRecording, handleEnd, }; }; 주석 순서대로 따라가 보면 흐름이 보입니다. 공통 훅 useRecording 에서 녹음 상태 조작 함수를, useNavigateToHome 에서 홈 이동 함수를 가져오고 화면에 들어오면 녹음을 시작하고 끝내기 버튼을 누르면 녹음을 멈춘 뒤 홈으로 이동하고 65 같은 초 단위 숫자를 "01:05" 같은 문자열로 바꿔 화면에 건넵니다. 하나하나 필요한 동작이고, 코드도 자연스럽게 읽혔습니다. 그런데 책에서 배운 ‘책임’을 떠올리니 궁금해졌어요. 시간 형식 바꾸기, 버튼 동작 연결하기, 종료 후 이동하기까지. 이 훅은 적어도 세 가지 일을 하고 있으니까요. 이렇게 여러 일을 하는 훅을, 하나의 책임을 가진 훅이라고 볼 수 있을까요? 제 머릿속의 ‘훅은 하나의 일만 해야 한다’ 는 공식대로라면, 쪼개야 할 훅이었죠. 그래도 바로 단정하기보다는, 여러 동작을 굳이 한곳에 둔 이유부터 묻고 싶었습니다. 그래서 팀원의 코드에 리뷰 코멘트 로 이렇게 질문했어요. “훅의 책임을 꼭 하나로 제한하기보다, 하나의 역할을 수행하기 위해 여러 책임을 모아둔 걸로 이해하면 될까요?” 같은 기능에 속하면 응집도가 높은 걸까요? 질문을 남겨두고도 생각은 계속 이어졌어요. ‘하나의 역할을 위해 모아뒀다’는 건, 결국 같은 기능에 속한다는 뜻이잖아요. 그런데 같은 기능에 속하기만 하면 응집도가 높은 걸까요? 답을 기다리는 동안, 『오브젝트』의 예제로 먼저 확인해봤어요. 책의 영화 예매 시스템에는 두 종류의 할인 조건이 나와요. 순번 조건 : 하루 중 몇 번째 상영인지로 판단 (예: 첫 회 상영) 기간 조건 : 요일과 시간대로 판단 (예: 월요일 10시~12시) 책에서 처음 설계한 DiscountCondition 은 이 두 조건을 한 클래스에서 판단합니다. 세부 구현을 조금 덜어내면 다음과 같아요. public class DiscountCondition { private DiscountConditionType type; // 순번 조건인지, 기간 조건인지 private int sequence; // 순번 조건에서만 사용 private DayOfWeek dayOfWeek; // 기간 조건에서만 사용 private LocalTime startTime; // 기간 조건에서만 사용 private LocalTime endTime; // 기간 조건에서만 사용 public boolean isSatisfiedBy(Screening screening) { if (type == DiscountConditionType.PERIOD) { return isSatisfiedByPeriod(screening); } return isSatisfiedBySequence(screening); } // 순번 할인 규칙 private boolean isSatisfiedBySequence(Screening screening) { return sequence == screening.getSequence(); } // 기간 할인 규칙 private boolean isSatisfiedByPeriod(Screening screening) { // 상영 요일이 같고, 상영 시각이 startTime ~ endTime 사이인지 확인하는 코드 (복잡해서 생략) ... } } 처음엔 이렇게 생각했어요. “둘 다 ‘할인 조건’이니까, 한 클래스에 잘 모여 있는 것 아닌가?” 하지만 책에서는 무엇 때문에 이 코드를 고치게 되는지 를 기준으로 이 클래스를 봅니다. 메서드 수정하게 되는 이유 isSatisfiedBy() 새로운 할인 조건 종류 추가 isSatisfiedBySequence() 순번 할인 규칙 변경 isSatisfiedByPeriod() 기간 할인 규칙 변경 순번 조건은 sequence 만, 기간 조건은 dayOfWeek , startTime , endTime 만 사용합니다. 순번 규칙이 바뀌어도 기간 조건은 그대로고, 기간 규칙이 바뀌어도 순번 조건은 그대로예요. ‘할인 조건’이라는 이름은 같지만, 서로 따로 바뀌는 규칙과 데이터가 한 클래스에 모여 있는 것 입니다. 책이 이 클래스의 응집도가 낮다고 말하는 이유예요. 같은 이름 아래 모여 있다고 응집도가 높은 건 아니었습니다. 응집도는 앞에서 적어둔 대로 “같은 이유로 함께 바뀌는가?” 의 관점에서 봐야 했죠. 로딩 화면과 빈 결과 화면은 같은 이유로 바뀔까요? 프론트엔드에서 자주 쓰는 코드에도 같은 질문을 던져봤습니다. 문서 검색 결과를 보여주는 컴포넌트예요. function DocumentSearchResult() { const { isPending, isError, data } = useSearchDocuments(); if (isPending) return <p>문서를 찾는 중입니다.</p>; if (isError) return <p>문서를 불러오지 못했습니다.</p>; if (data.length === 0) return <p>검색 결과가 없습니다.</p>; return <DocumentList documents={data} />; } 네 분기 모두 ‘검색 결과 화면’이라는 공통점이 있습니다. 그런데 바뀌는 이유를 따라가 보면 이야기가 달라져요. 로딩 화면 디자인이 바뀌면 → 이 컴포넌트를 연다 에러 안내 문구가 바뀌면 → 또 이 컴포넌트를 연다 검색 결과가 없을 때의 안내가 바뀌면 → 또 이 컴포넌트를 연다 서로 관계없는 이유로 같은 코드를 고치게 되니, 이 코드는 응집도가 낮다고 봤어요. DiscountCondition 이 두 규칙을 한곳에서 직접 구현하던 모습과 닮았습니다. 그런데 마지막 목록 화면은 조금 다릅니다. 목록을 어떻게 그릴지는 DocumentList 에 맡겼기 때문에, 목록 디자인이 바뀌어도 이 컴포넌트는 그대로예요. 그렇다면 나머지 화면도 각자의 컴포넌트에 맡기면 어떨까요? if (isPending) return <DocumentSearchLoading />; if (isError) return <DocumentSearchError />; if (data.length === 0) return <DocumentSearchEmpty />; return <DocumentList documents={data} />; 이제 이 컴포넌트에 남은 일은 지금 상태에 맞는 화면을 고르는 것 하나입니다. 여전히 네 가지 경우를 다루지만, 어느 화면의 디자인이 바뀌어도 이 코드는 그대로예요. 각 화면을 어떻게 그리는지는 맡은 컴포넌트 안에 감춰져 있으니, 디자인이 바뀌어도 그 변경이 여기까지 번지지 않는 거죠. 앞에서 본 캡슐화가 응집도를 지켜주는 모습이에요. 여기서 중요한 구분 하나를 얻었습니다. 여러 일의 세부 구현까지 모두 떠안는 것과, 조합만 하고 실제 일은 맡기는 것은 다르다. 스스로 하는 일과 맡기는 일 이제 이 구분을 들고 useRecorderBar 로 돌아가 볼게요. 사실 이 훅도 서로 다른 이유로 바뀝니다. 시간 표시를 01:05 에서 1분 5초 로 바꾸는 요구사항 → formatRecordingTime 과 관련 종료 후 홈이 아닌 다른 화면으로 보내는 요구사항 → handleEnd 와 관련 이것만 보면 DiscountCondition 처럼 응집도가 낮아 보여요. 하지만 앞에서 얻은 구분대로라면, 먼저 확인해야 할 게 있습니다. 이 훅은 이 일들의 세부 구현까지 떠안고 있을까요, 조합만 하고 실제 일은 각자에게 맡기고 있을까요? 코드를 다시 읽으며, 각 줄이 일을 맡기는지 , 직접 결정하는지 를 주석으로 표시해봤어요. export const useRecorderBar = () => { // 맡김: 녹음 상태와 조작은 useRecording에게 const { elapsedSeconds, startRecording, pauseRecording, endRecording } = useRecording(); // 맡김: 실제 화면 이동은 useNavigateToHome에게 const { navigateToHome } = useNavigateToHome(); // 결정: 이 화면에 들어오면 녹음을 시작한다 useEffect(() => { startRecording(); }, [startRecording]); // 결정: 녹음을 끝내면 홈으로 간다 const handleEnd = () => { endRecording(); navigateToHome(); }; return { // 맡김: 시간 형식은 formatRecordingTime에게 elapsedTime: formatRecordingTime(elapsedSeconds), handlePause: pauseRecording, handleEnd, }; }; 맡김 이라고 적은 곳부터 볼게요. useRecorderBar 는 녹음 상태를 직접 바꾸지 않습니다. useRecording() 에서 받은 endRecording() 을 부를 뿐이죠. 경과 시간도 직접 계산하지 않습니다. 전달받은 elapsedSeconds 를 쓸 뿐이에요. 반대로 결정 이라고 적은 곳이 이 훅이 직접 정하는 부분입니다. 화면에 들어오면 녹음을 시작하고, 끝내면 홈으로 간다. 결국 이 훅이 정하는 건 “이 화면에서 녹음이 어떻게 흘러가는가” , 그 흐름 하나예요. 컴포넌트가 “이제 녹음 시작해”라고 지시하지 않아도, 화면에 들어오면 훅이 알아서 녹음을 시작하죠. 자율적인 훅은 자기 일은 스스로 한다 고 했죠. 바로 이 부분이에요. 시간 표시도 마찬가지입니다. formatRecordingTime(elapsedSeconds) 라는 이름만 봐도 무슨 일을 하는지 알 수 있죠. 초를 분으로 바꾸고 앞에 0 을 붙이는 방법까지 이 훅에서 읽을 필요는 없어요. 이 차이는 코드를 고칠 때 드러납니다. 표시 형식이 바뀌면 → formatRecordingTime 안만 고치면 된다. useRecorderBar 는 그대로. 종료 후 이동할 화면이 바뀌면 → handleEnd 를 고친다. 녹음 바의 흐름을 정하는 곳이기 때문에. 그래서 useRecorderBar 에 함수 호출이 여러 개 있다는 것만으로 응집도가 낮다고 말하긴 어려웠습니다. 동작을 연결할 뿐, 각 동작을 어떻게 처리할지는 해당 함수와 훅에 맡기고 있으니까요. 그리고 마침, 기다리던 리뷰에도 답변이 달렸어요. 답변 에서 팀원은 이렇게 설명했어요. 작은 책임으로 나누더라도 결국 어딘가에서는 다시 조합해야 하고, useRecorderBar 는 녹음 바가 녹음을 보여주고 조작할 수 있도록 필요한 동작을 연결하는 자리라고요. 코드를 읽으며 제가 확인한 것과 같은 이야기였죠. 처음의 저는 일이 몇 개인지만 세고 있었는데, 그 일들을 이 훅이 떠안고 있는지, 맡기고 있는지 부터 봐야 했던 거예요. 결국 봐야 할 건 두 가지였어요. 이 훅을 고치게 만드는 이유는 무엇이고, 서로 다른 이유가 얼마나 있는가? → 응집도 함께 쓰는 각 동작의 구현이 잘 감춰져 있는가? → 캡슐화 몇 개의 일을 하느냐보다, 그 일을 떠안느냐 맡기느냐 가 더 중요한 질문이었습니다. 정보를 가장 잘 아는 곳에 맡기기 그렇다면 무엇을, 누구에게 맡겨야 할까요? 『오브젝트』 5장은 그 첫 번째 원칙으로 정보 전문가(INFORMATION EXPERT) 패턴 을 소개합니다. 책임은 그 일에 필요한 정보를 가장 잘 아는 객체에 맡긴다. 그리고 스스로 할 수 없는 일은 메시지를 보내 다른 객체에 부탁한다. 그 메시지는 다시 받는 쪽의 책임이 되고요. 앞에서 맡김 과 결정 으로 표시한 코드를 이 기준으로 다시 정리하면 다음과 같아요. 책임 필요한 정보 맡은 곳 화면에 들어오면 녹음 시작, 끝나면 홈으로 녹음 바 화면의 흐름 useRecorderBar 녹음 시작 / 정지 / 종료 앱 전체의 녹음 상태 useRecording 경과 시간을 01:05 형식으로 바꾸기 시간 표시 규칙 formatRecordingTime 홈 화면으로 이동하기 라우터 사용법 useNavigateToHome useRecorderBar 는 녹음 상태도, 라우터도 직접 알지 못합니다. 대신 녹음 바에서 무엇이 어떤 순서로 일어나야 하는지 는 누구보다 잘 알죠. 그래서 그 흐름만 직접 책임지고, 나머지는 endRecording() , navigateToHome() 이라는
1. LobbyState 기반 매칭 상태 관리 매칭 과정에서 UI 오브젝트를 개별적으로 켜고 끄는 방식 대신, 현재 로비 상태를 기준으로 UI와 사용자 입력을 제어하도록 구성했습니다. Lobby ↓ MatchRequesting ↓ MatchWaiting ├─ MatchCancelling ├─ MatchFound ├─ EnteringGame └─ Error 이를 통해 다음과 같은 상황에서 중복 입력이나 잘못된 상태 전환이 발생하지 않도록 했습니다. 매칭 버튼 연타 매칭 중 재요청 취소 버튼 연타 매칭 취소 도중 성공 이벤트 수신 중복 MatchFound 처리 Scene 중복 전환 특히 매칭 요청과 서버 응답을 별개의 상태로 구분하여, 실제 서버 연동 시 비동기 응답 지연에도 대응할 수 있도록 설계했습니다. 2. 패킷 설계서 기반 Mock Matchmaking 구현 기존의 임의적인 “몇 초 후 성공/실패” Mock 대신 팀의 WebSocket 패킷 설계서와 동일한 흐름을 따르도록 Mock을 재구성했습니다. 실제 매칭 프로토콜은 다음 흐름을 따릅니다. MATCH_JOIN ↓ MATCH_QUEUED ↓ ├─ MATCH_CANCEL │ ↓ │ MATCH_CANCELLED │ └─ MATCH_FOUND MATCH_QUEUED 에서는 서버의 큐 등록 시각과 제한 시간을 받아 대기 시간을 표시하고, 매칭 대기 제한은 현재 60초로 정의되어 있습니다. :chatgpt-content-reference{index="0"} 이를 Mock에서도 동일하게 재현하기 위해 다음 데이터를 구성했습니다. MatchQueuedInfo - QueuedAt - TimeoutSec MatchInfo - GameId - Opponent - ReadyTimeoutSec Mock과 실제 서버 구현 사이에 MatchmakingClientBase 라는 최소한의 연결 경계를 두어, 이후 실제 WebSocket 연동 시 LobbyManager 와 UI 로직을 다시 작성하지 않도록 했습니다. 현재 LobbyManager ↓ MockMatchmakingClient 실제 서버 연동 이후 LobbyManager ↓ Network Matchmaking 구현 ↓ PacketRouter / Dispatcher ↓ WebSocket 3. 서버 기준 매칭 취소 사유 처리 패킷 명세의 매칭 취소 사유를 클라이언트에서도 구분했습니다. USER_REQUEST TIMEOUT READY_TIMEOUT OPPONENT_DISCONNECTED 이를 통해 단순히 “매칭 실패” 하나로 처리하지 않고 상황에 따라 다른 UI 메시지와 상태 복귀 처리가 가능하도록 했습니다. 예를 들어: TIMEOUT → "매칭 시간이 초과되었습니다." READY_TIMEOUT → "게임 준비 시간이 초과되어 매칭이 취소되었습니다." OPPONENT_DISCONNECTED → "상대방의 연결이 종료되어 매칭이 취소되었습니다." 패킷 설계에서도 매칭 대기 중 취소뿐 아니라 게임 준비 단계 실패까지 MATCH_CANCELLED 의 사유로 구분하도록 정의되어 있습니다. :chatgpt-content-reference{index="1"} 4. 매칭 취소와 MatchFound 경합 처리 오늘 구현에서 가장 중요하게 검증한 예외 상황 중 하나입니다. 사용자가 취소 버튼을 눌렀더라도 서버에서 이미 매칭이 먼저 확정된 경우 다음과 같은 상황이 발생할 수 있습니다. MATCH_CANCEL 전송 ↓ MATCH_FOUND 수신 이 경우 클라이언트가 “취소 버튼을 눌렀으니 성공 이벤트를 무시”하도록 만들면 서버와 클라이언트 상태가 달라질 수 있습니다. 따라서 MatchCancelling 상태에서도 MATCH_FOUND 를 정상적으로 받아들이도록 처리했습니다. MatchWaiting → 취소 버튼 → MatchCancelling → MATCH_FOUND → MatchFound 현재 패킷 설계에서도 취소 요청과 매칭 성사가 동시에 발생하면 서버가 먼저 처리한 결과를 따르며, 클라이언트가 MATCH_CANCEL 을 보낸 뒤라도 MATCH_FOUND 를 수신하면 게임 진입을 계속하도록 정의되어 있습니다. :chatgpt-content-reference{index="2"} Mock에서 응답 시간을 조절하여 실제 경합 상황도 테스트했습니다. 5. 매칭 대기 UI 및 타이머 구현 Lobby UI를 일반 로비 상태와 매칭 상태로 나누었습니다. LobbyContent ├─ Nickname ├─ Rating └─ MatchButton MatchingContent ├─ MatchingStatusText ├─ WaitingTimeText └─ CancelButton 매칭 상태에 따라 다음과 같이 화면을 갱신합니다. MatchRequesting → 매칭을 요청하는 중입니다... MatchWaiting → 상대를 찾는 중입니다... MatchCancelling → 매칭을 취소하는 중입니다... MatchFound → 매칭 성공! 타이머 역시 클라이언트가 임의로 시작 시간을 정하는 대신 MATCH_QUEUED 에서 전달되는 queuedAt 을 기준으로 계산하도록 구성했습니다. 이는 실제 서버와 클라이언트의 대기 시간 표시가 어긋나는 문제를 줄이기 위한 구조입니다. :chatgpt-content-reference{index="3"} 6. MatchFound 이후 Game Scene 전환 구현 매칭 성공 이후 인게임 진입에 필요한 MatchInfo 를 MatchSession 에 저장한 뒤 Game Scene으로 전환하도록 구현했습니다. MATCH_FOUND ↓ MatchInfo 저장 ↓ MatchFound 상태 ↓ Game Scene Load 저장되는 정보는 현재 패킷 명세에 맞춰 다음과 같이 구성했습니다. GameId Opponent Profile ReadyTimeoutSec MATCH_FOUND 패킷에는 상대 정보와 함께 게임 준비 제한 시간이 전달되며, 현재 준비 제한은 15초입니다. :chatgpt-content-reference{index="4"} Scene Index에 의존하지 않고 Scene 이름으로 전환하도록 구현하여 팀원들이 Scene을 추가하거나 순서를 변경하더라도 코드 영향이 최소화되도록 했습니다. 7. Scene 로드 완료 후 GAME_READY 처리 매칭 성공 즉시 서버에 준비 완료를 보내는 것이 아니라, 인게임 Scene이 실제로 로드된 후 GAME_READY 를 보내도록 구성했습니다. MATCH_FOUND ↓ Game Scene Load ↓ GameEntryController.Start() ↓ GAME_READY 이를 위해 다음 구조를 추가했습니다. GameEntryClientBase └─ MockGameEntryClient GameEntryController 현재 Mock에서는: [MOCK] GAME_READY 전송 - gameId: ... 로그를 통해 정상 전송 여부를 검증합니다. 실제 서버 연동 후에는 Mock 구현만 네트워크 송신 코드로 교체할 수 있습니다. GAME_READY 는 Scene 로드 완료 후 전송해야 하며, 서버는 두 플레이어 모두 준비된 후에만 GAME_START 를 전달하도록 설계되어 있습니다. 이는 Scene 로드 이전에 인게임 패킷이 도착하는 문제를 방지하기 위한 구조입니다. :chatgpt-content-reference{index="5"} 8. PlayerProfile 서버 명세 반영 초기에는 닉네임과 레이팅만 사용하던 PlayerProfile 을 최신 패킷 설계에 맞춰 확장했습니다. UserId Nickname Wins Losses Rating 현재 로그인 Mock 코드와의 호환성을 유지하기 위해 기존 생성 방식도 그대로 사용할 수 있도록 구성했습니다. 패킷 설계서의 공통 PlayerProfile 역시 동일한 데이터를 기준으로 합니다. :chatgpt-content-reference{index="6"} 테스트한 주요 시나리오 오늘 구현 후 다음 흐름을 직접 검증했습니다. 1. 정상 매칭 Lobby → MatchRequesting → MatchWaiting → MatchFound 2. 사용자 매칭 취소 MatchWaiting → MatchCancelling → MATCH_CANCELLED(USER_REQUEST) → Lobby 3. 매칭 Timeout MatchWaiting → MATCH_CANCELLED(TIMEOUT) → Lobby 4. 취소 / 성공 경합 MatchWaiting → Cancel → MatchCancelling → MATCH_FOUND → MatchFound 5. 게임 Scene 진입 MATCH_FOUND → MatchInfo 저장 → Game Scene → GAME_READY 테스트 과정에서 Scene 이름과 로드 대상 문자열이 일치하지 않아 Scene 로드가 실패하는 문제도 확인했으며, 실제 Scene 이름으로 수정하여 정상 동작을 검증했습니다. 현재까지 완성된 전체 흐름 현재 클라이언트에서는 서버 없이 다음 흐름까지 검증할 수 있습니다. Login ↓ PlayerProfile 생성 ↓ Lobby ↓ MATCH_JOIN Mock ↓ MATCH_QUEUED ↓ 매칭 대기 ├─ 사용자 취소 ├─ Timeout └─ MATCH_FOUND ↓ MatchInfo 저장 ↓ Game Scene Load ↓ GAME_READY 즉, 로그인부터 서버의 게임 시작 직전까지 로비/매칭 클라이언트의 주요 사용자 흐름을 Mock 환경에서 독립적으로 실행할 수 있는 상태 까지 구현했습니다.
Next.js 공식 튜토리얼(Next.js Learn)을 따라가면서 마주친 경로 설정, 컴포넌트 개념, 그리고 개발 중 발생했던 주요 에러들과 해결 방법을 정리해 둡니다. 1. <AcmeLogo /> 컴포넌트의 정체 Next.js 튜토리얼 예제 프로젝트에 포함된 가상의 서비스 'Acme'의 로고 컴포넌트입니다. app/ui/acme-logo.tsx 파일 내부에 정의되어 있으며, 아이콘( Heroicons )과 폰트( Lusitana ) 스타일이 입혀진 브랜드 요소를 렌더링합니다. 2. 데스크톱 히어로 이미지 추가하기 ( next/image ) 튜토리얼에서 반응형 히어로 이미지를 추가할 때 요구하는 핵심 작업입니다. 작업 내용 /app/page.tsx 파일에서 next/image 를 import하고 주석 위치에 컴포넌트를 배치합니다. import Image from 'next/image'; // ... {/* Add Hero Images Here */} <Image alt="Screenshots of the dashboard project showing desktop version" className="hidden md:block" height="{760}" src="/hero-desktop.png" width="{1000}"/> 주요 속성 정리 src="/hero-desktop.png" : Next.js는 public 폴더를 루트( / )로 바로 서빙합니다. width={1000} , height={760} : 레이아웃 이동(CLS) 현상을 방지하기 위해 원본 비율을 명시합니다. className="hidden md:block" : Tailwind CSS를 이용해 모바일에서는 숨기고( hidden ), 데스크톱 크기( md:block ) 이상에서만 노출시킵니다. 3. [Error] Export lusitana doesn't exist in target module 에러 원인 app/ui/acme-logo.tsx 에서 lusitana 폰트를 가져오려 했으나, 정작 app/ui/fonts.ts 에 해당 폰트가 export되어 있지 않아 발생한 빌드 에러입니다. 해결 방법 app/ui/fonts.ts 파일에 Lusitana 설정을 추가하여 export해 줍니다. import { Inter, Lusitana } from 'next/font/google'; export const inter = Inter({ subsets: ['latin'] }); export const lusitana = Lusitana({ weight: ['400', '700'], subsets: ['latin'], }); 4. [Error] Hydration failed (lang="ko" vs lang="en") 에러 증상 화면에 Hydration failed because the server rendered text didn't match the client 에러가 발생하며, Diff 확인 시 다음과 같이 출력됩니다. + lang="en" - lang="ko" className="translated-ltr" 에러 원인 Next.js(React)가 서버에서 렌더링한 초기 HTML과 클라이언트 브라우저가 첫 렌더링한 DOM 트리가 일치해야 하이드레이션이 정상 완료됩니다. 하지만 크롬 등 브라우저의 웹페이지 자동 번역 기능 이 실행되면서 React가 준비되기 전에 <html> 태그와 내부 텍스트를 강제로 수정해 불일치가 일어난 것입니다. 해결 방법 방법 1 (즉시 해결) : 브라우저 주소창의 Google 번역 아이콘을 눌러 번역을 해제하고 원본 언어(영어)로 전환한 뒤 새로고침( F5 )합니다. 방법 2 (코드 레벨 방지) : 개발 중 번역기 간섭을 방지하려면 app/layout.tsx 의 <html> 태그에 번역 방지 속성을 지정합니다. <html lang="en" translate="no"> ```
1. app , app/lib , app/ui , public 폴더의 역할과 Starter 프로젝트 구조 /app : Next.js App Router의 모든 라우트(routes), 컴포넌트, 로직 이 위치하는 최상위 디렉터리입니다. 폴더 기반 라우팅을 지원하며, page.tsx (고유 UI 화면), layout.tsx (여러 페이지 간 공유되는 UI 프레임) 등의 특수 파일들이 포함됩니다. /app/lib : 애플리케이션 전반에서 사용되는 유틸리티 함수, 비즈니스 로직, 데이터 타입 정의, 데이터베이스 쿼리 함수 ( definitions.ts , data.ts , utils.ts 등)를 보관하는 폴더입니다. /app/ui : 카드, 버튼, 내비게이션 바, 폼 등과 같이 애플리케이션의 재사용 가능한 모든 UI 컴포넌트 ( buttons.tsx , invoices/table.tsx 등)를 모아두는 곳입니다. /public : 이미지( hero-desktop.png , logo.png ), 아이콘( favicon.ico ), 폰트 파일 등 정적 에셋(Static Assets)을 호스팅하는 폴더입니다. 빌드 후 루트 URL( / ) 기준으로 직접 접근 가능합니다 (예: /public/logo.png $\rightarrow$ src="/logo.png" ). 2. 개발 서버 실행 과정과 pnpm dev , 프로덕션 빌드의 의미 pnpm dev (Development Mode) : 개발용 로컬 서버(기본 포트: 3000)를 구동합니다. 빠른 개발 피드백을 위해 Fast Refresh (HMR)를 활성화하며, 소스 코드가 수정될 때마다 해당 모듈만 즉시 다시 컴파일합니다. TypeScript 타입 체크, 친절한 디버깅용 에러 오버레이가 제공됩니다. 프로덕션 빌드 ( pnpm build & pnpm start ) : pnpm build : 배포 가능한 최적화된 프로덕션 번들을 생성합니다. React Server Components(RSC) 컴파일, 정적 라우트 프리렌더링(SSG), 자바스크립트/CSS 압축 및 트리쉐이킹(Tree-shaking)이 수행됩니다. pnpm start : 생성된 프로덕션 빌드 아티팩트( .next 폴더)를 활용해 실제 서비스 환경과 동일한 고성능 서버를 구동합니다. 3. Global CSS, Tailwind CSS, CSS Modules의 차이 Next.js는 다양한 스타일링 방식을 기본 지원하며 각 도구의 성격과 적용 방식이 다릅니다: 구분 적용 방식 및 특징 장점 및 주의점 Global CSS 최상위 레이아웃( app/layout.tsx )에서 import '@/app/ui/global.css' 형태로 임포트하여 전체 앱에 일괄 적용 리셋 CSS나 타이포그래피 기본값 등 전역 스타일에 적합하지만, 전역 클래스명 충돌(Class Collision) 가능성이 있음 Tailwind CSS 유틸리티 퍼스트(Utility-First) CSS 프레임워크. HTML/JSX 태그의 className 에 유틸리티 클래스( flex , text-blue-500 등)를 직접 작성 별도의 CSS 파일을 왕복하지 않고 인라인 형태로 빠르게 작성 가능하며, 빌드 시 사용된 클래스만 추출해 번들 크기가 매우 작음 CSS Modules [name].module.css 명명 규칙을 사용하며, 파일 단위로 CSS를 컴포넌트에 한정( Scoped ) 빌드 시 각 클래스명에 고유한 해시(예: title_abc123 )가 자동 부여되어 클래스명 충돌을 완벽히 방지 4. clsx 를 이용한 상태별 조건부 클래스 적용 배경 : 요소의 상태(예: pending , paid , 활성/비활성 등)에 따라 적용해야 할 클래스명이 동적으로 달라지는 경우, 템플릿 리터럴( )로만 작성하면 코드가 복잡해지고 공백 처리가 번거로워집니다. 동작 원리 : clsx 는 객체 또는 인자 기반으로 조건식이 참( truthy )일 때만 클래스 문자열을 결합해 반환하는 유틸리티 라이브러리입니다. 공식 튜토리얼 예시 : import clsx from 'clsx'; export default function InvoiceStatus({ status }: { status: string }) { return ( <span className={clsx( 'inline-flex items-center rounded-full px-2 py-1 text-sm', { 'bg-gray-100 text-gray-500': status === 'pending', 'bg-green-500 text-white': status === 'paid', }, )} > {status} ); } ``` 5. 웹 폰트가 레이아웃 이동을 발생시키는 이유와 CLS (Cumulative Layout Shift) CLS (누적 레이아웃 이동) 발생 원인 : 외부 웹 폰트(Google Fonts 등)를 브라우저에서 네트워크로 다운로드할 때, 브라우저는 폰트가 로드되기 전까지 시스템 대체 폰트(Fallback Font)를 먼저 렌더링(FOUT - Flash of Unstyled Text)하거나 텍스트를 숨깁니다(FOIT - Flash of Invisible Text). 폰트 다운로드가 완료되어 웹 폰트가 적용되는 순간, 대체 폰트와 실제 웹 폰트 간의 글자 폭, 높이, 자간 차이 로 인해 주변 텍스트와 UI 요소들의 위치가 갑자기 밀려나는 레이아웃 이동이 발생합니다. 영향 : 사용자가 버튼을 잘못 클릭하게 만드는 등 UX를 해치며, Google Core Web Vitals의 핵심 성능 지표인 CLS 점수를 악화 시킵니다. 6. next/font 의 빌드 시점 폰트 최적화 Next.js는 next/font 모듈(예: next/font/google , next/font/local )을 통해 폰트를 완전히 최적화합니다: 빌드 시점 다운로드 & 호스팅 (Zero Extra Network Request) : 빌드 시점에 외부 폰트 파일(예: Google Fonts)을 다운로드하여 다른 정적 에셋과 함께 애플리케이션 번들 내에 저장합니다. 브라우저가 Google 서버 등 외부 네트워크로 추가 폰트 요청을 보내지 않으므로 프라이버시가 보호되고 다운로드 지연이 없습니다. 레이아웃 이동 제거 (Zero Layout Shift) : CSS size-adjust 속성 등을 활용하여 대체 폰트의 크기와 비율을 웹 폰트와 정확히 일치하도록 자동 보정해 CLS를 0으로 만듭니다 . 서브셋(Subsets) 최적화 : 필요한 문자 집합( subsets: ['latin'] 등)만 추출하여 폰트 파일의 용량을 크게 줄입니다. 7. 일반 이미지 요소( <img> )와 next/image 의 차이, 크기 지정과 반응형 이미지 Next.js의 <Image> 컴포넌트는 HTML <img> 태그의 확장판으로 자동 이미지 최적화(Automatic Image Optimization)를 제공합니다. 비교 항목 일반 <img> 태그 Next.js <Image/> 레이아웃 이동(CLS) 이미지 로드 전 영역 크기가 확보되지 않으면 아래 콘텐츠가 밀려남 width , height 를 강제 지정하거나 fill 을 사용하여 자리 표시 영역을 확보, CLS 원천 방지 포맷 최적화 원본 포맷(PNG, JPEG 등) 그대로 다운로드 브라우저 지원 여부에 따라 최신 포맷인 WebP, AVIF로 자동 변환 반응형 리사이징 화면 크기와 상관없이 동일한 크기의 원본 파일 전달 가능성 높음 디바이스 해상도 및 뷰포트 크기에 맞춰 적절한 크기로 서버에서 실시간 리사이징 로딩 전략 기본값 즉시 로딩 (별도 수동 loading="lazy" 속성 필요) 기본적으로 뷰포트에 들어올 때 Lazy Loading 처리 ( priority 속성 지정 시 LCP 대상 이미지를 우선 사전 로드) 크기 지정 원칙 : 원격/로컬 이미지 상관없이 화면에 렌더링될 때의 비율을 Next.js가 미리 파악할 수 있도록 width 와 height 를 명시 해야 합니다. 동적 크기나 부모 컨테이너 크기에 맞춰야 할 때는 fill 속성을 사용하고, 부모 요소에 position: relative 와 sizes 속성을 지정합니다. 데스크톱/모바일별 반응형 이미지를 보여줄 때는 Tailwind의 반응형 클래스(예: hidden md:block , block md:hidden )를 조합하여 디바이스별로 최적화된 이미지를 분기 렌더링합니다.
NVIDIA B300(Blackwell Ultra, 단일 GPU당 HBM3e 288GB 탑재) 8장으로 구성된 단일 노드는 총 VRAM만 약 2.3TB 에 달하며, 5세대 NVLink(양방향 1.8TB/s per GPU)로 풀메시(Full-mesh) 연결되어 있습니다. 단일 노드 내에서 여러 모델(예: DeepSeek 계열, Qwen3-32B, 경량 임베딩/리랭커 등)을 동시에 서빙할 때 NVLink 토폴로지 유지, VRAM/KV Cache 고립, 엔진 서빙 아키텍처 및 메트릭 관측성 관점에서의 베스트 프랙티스는 다음과 같습니다. 1. GPU 토폴로지 기반 자원 파티셔닝 (Tensor Parallelism 분할) B300 8-way 노드는 NVLink Switch를 통해 8장이 단일 패브릭으로 묶여 있지만, 여러 모델을 동시에 쪼개어 서빙할 때는 NUMA 노드와 PCIe/NVLink 도메인 에 맞춰 2의 거듭제곱 단위( TP=1 , 2 , 4 , 8 )로 격리해야 All-Reduce 병목이 발생하지 않습니다. 권장 모델 배치 시나리오 케이스 A: 초대형 MoE 1개 + 중소형 모델 병행 (가장 흔한 멀티모델 패턴) GPU 0~3 (TP=4): DeepSeek-R1-Distill-70B 또는 중대형 MoE 모델 (4장 × 288GB = 1.15TB VRAM 풀) GPU 4~5 (TP=2): Qwen3-32B / Llama 계열 메인 LLM 추론 (2장 × 288GB = 576GB VRAM) GPU 6 (TP=1): 경량 7B~14B 모델 또는 코드 전용 LLM GPU 7 (TP=1): 임베딩(Embedding) + 리랭커(Reranker) 전용 인스턴스 (vLLM 또는 TEI) 케이스 B: DeepSeek-R1 Full (FP8/FP4) 전용 서빙 DeepSeek 671B MoE 가중치(FP8 기준 약 700GB)는 단일 노드 8장(TP=8, EP=8)으로 VRAM에 완전히 올릴 수 있으므로, 멀티 테넌트보다는 단일 노드 전체를 전용으로 할당하는 것이 성능상 유리합니다. CPU & NUMA Affinity 바인딩 (필수) 8장 노드는 통상 듀얼 소켓 CPU(NUMA 0, NUMA 1) 구조입니다. 모델 서빙 프로세스가 GPU뿐만 아니라 대응하는 NUMA 노드의 CPU 코어/메모리에 정확히 고정되어야 인터커넥트 병목이 없습니다. # 예: GPU 0~3번은 NUMA node 0번에 고정하여 vLLM 인스턴스 기동 numactl --cpunodebind=0 --membind=0 vllm serve /models/Qwen-70B \ --tensor-parallel-size 4 \ --gpu-memory-utilization 0.90 \ --port 8001 2. 추론 엔진(Engine) 및 메모리 격리 전략 여러 추론 프로세스가 동일 물리 노드에 공존할 때 OOM(Out of Memory) 연쇄 장애를 막는 설정입니다. CUDA_VISIBLE_DEVICES 하드웨어 격리: 각 서빙 컨테이너(또는 프로세스)에는 사용할 GPU 인덱스만 노출시킵니다. Container A: CUDA_VISIBLE_DEVICES=0,1,2,3 Container B: CUDA_VISIBLE_DEVICES=4,5 --gpu-memory-utilization 고정 및 KV Cache 버퍼 제어: vLLM/SGLang은 기본적으로 GPU 메모리의 90% 이상을 가중치 + KV Cache 블록으로 사전 선점(Pre-allocate)합니다. 멀티 프로세스 환경에서는 프레임워크 오버헤드, PyTorch CUDA 컨텍스트, PagedAttention 관리 버퍼를 고려하여 0.85 ~ 0.90 수준으로 설정해 메모리 스파이크를 방지합니다. MIG(Multi-Instance GPU) vs 프로세스 기반 분할: B300에서는 가급적 MIG 대신 vLLM 단일 컨테이너 단위 분할 권장: MIG를 활성화하면 NVLink 대역폭 활용이 제한되고 Tensor Parallelism 제약이 생깁니다. LLM 추론은 NVLink를 온전히 활용할 수 있도록 베어메탈/컨테이너 레벨에서 GPU 카운트 단위( TP=1, 2, 4 )로 물리 카드를 분배하는 것이 좋습니다. 3. 라우팅 및 동적 부하 분산 (Front Gateway) 노드 1대 내부에서 모델별로 각기 다른 포트(8001, 8002, 8003...)로 엔진이 뜨게 되므로, 앞단에 경량 AI 게이트웨이를 배치해야 클라이언트 관리가 단순해집니다. Reverse Proxy / Gateway: Litellm , Envoy , 또는 Traefik 포트 8000 단일 엔드포인트 노출 후 요청 헤더/바디의 model 필드에 따라 로컬 포트로 라우팅: "model": "qwen3-32b" $\rightarrow$ localhost:8001 "model": "deepseek-r1-distill-70b" $\rightarrow$ localhost:8002 "model": "bge-reranker" $\rightarrow$ localhost:8003 Prefix Caching 활성화: 동일 모델에 반복되는 시스템 프롬프트(System Prompt)가 많다면 --enable-prefix-caching 을 켜서 중복 토큰 연산과 KV Cache 소비를 40~60% 절감합니다. 4. 모니터링 및 관측성(Observability) 파이프라인 단일 노드 멀티 모델 환경에서는 "어느 모델이 어느 GPU 대역폭을 잠식하고 있는지"를 즉각 분리해 관측해야 합니다. [B300 GPUs] ──> DCGM Exporter (포트 9400) ──┐ [vLLM #1] ──> Prometheus 메트릭 (8001) ──┼──> Prometheus Server ──> Grafana Dashboard [vLLM #2] ──> Prometheus 메트릭 (8002) ──┘ A. 하드웨어 레벨: NVIDIA DCGM Exporter (Blackwell 지원 버전) 포트: 기본 9400 필수 감시 메트릭: DCGM_FI_DEV_GPU_UTIL : GPU 연산 코어 가동률 DCGM_FI_DEV_MEM_COPY_UTIL : HBM3e 메모리 대역폭 포화 여부 DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL : GPU 간 All-Reduce 교환량 (TP 분할 모델 정상 동작 검증) DCGM_FI_DEV_POWER_USAGE / DCGM_FI_DEV_GPU_TEMP : B300 노드 고전력/열 부하 감시 B. 서빙 엔진 레벨: vLLM / SGLang 내장 Prometheus 메트릭 각 vLLM 인스턴스는 자체 포트(예: :8001/metrics , :8002/metrics )로 상세 메트릭을 노출합니다. KV Cache 포화도: vllm:gpu_cache_usage_factor : 가장 중요. 0.85 이상 지속 시 새 요청이 큐에 쌓이며 TTFT(Time to First Token) 지연 급증. 대기열 병목: vllm:num_requests_waiting : 요청이 처리되지 못하고 큐에 머무는 수. 0보다 크면 동시 요청 처리 한계 도달을 의미. vllm:num_requests_running : 현재 PagedAttention으로 동시 디코딩 중인 배치 크기. 처리 성능(Latency & Throughput): vllm:time_to_first_token_seconds : 초기 Prefill 지연 시간. vllm:time_per_output_token_seconds : 디코딩 토큰 생성 간격 (사용자 체감 속도). 5. 핵심 운용 체크리스트 점검 영역 권장 설정값 / 액션 목적 NUMA 바인딩 numactl --cpunodebind=X --membind=X 소켓 간 PCIe 크로스오버 통신 억제 GPU 격리 컨테이너별 CUDA_VISIBLE_DEVICES 명시 프로세스 간 VRAM 침범 및 충돌 방지 KV Cache --gpu-memory-utilization 0.85~0.90 PagedAttention 할당 버퍼 확보로 OOM 방지 멀티 모델 분할 TP 크기는 2의 제곱수( 1, 2, 4, 8 ) 유지 NVLink 전송 효율 최적화 및 비대칭 링 방지 로그 수집 vLLM Engine 에러 로그와 DCGM XID 수집 하드웨어 ECC 에러 및 엔진 Crash 빠른 격리 == 네. B300 8장짜리 단일 GPU 노드에서 여러 LLM을 동시에 서비스 하려면, 단순히 모델마다 GPU를 1~2장씩 고정 배정하는 것보다 GPU partitioning + inference scheduler + QoS + observability 를 함께 설계하는 게 좋습니다. 특히 지금 말씀하신 환경처럼 B300 8-GPU 서버에서 여러 모델의 inference benchmark → 실제 서비스 로 가져가려는 경우라면, 저는 아래 구조를 권합니다. 권장 기본 구조 Client │ ┌──────▼──────┐ │ API Gateway │ │ / Router │ └──────┬──────┘ │ ┌────────────┴────────────┐ │ │ Model A / 70B Model B / 8B BF16/FP8 FP8/FP4 │ │ ┌──────▼──────┐ ┌──────▼──────┐ │ Triton / │ │ Triton / │ │ vLLM / TRT │ │ vLLM / TRT │ └──────┬──────┘ └──────┬──────┘ │ │ GPU 0-3 GPU 4 GPU 4-7 GPU 5 GPU 6/7 ... 핵심은 "모델 → GPU"를 정적으로만 매핑하지 말고, 모델별 QoS를 정의한 후 그에 맞춰 GPU를 배치 하는 것입니다. 1. 가장 먼저 GPU를 모델별로 논리적으로 나누는 게 좋습니다 B300 8장이라면 처음부터 모든 GPU를 하나의 거대한 pool로 보고 스케줄링하기보다는 다음처럼 GPU pool 을 만드는 것을 추천합니다. 예를 들어: Pool GPU 용도 Pool-A 0-3 70B급 모델 Pool-B 4 8B/14B Pool-C 5 8B/14B Pool-D 6-7 실험/Batch/대형 모델 또는 모델이 많다면: GPU 0-3 : Large Model Pool GPU 4 : Small Model A GPU 5 : Small Model B GPU 6 : Small Model C GPU 7 : Experimental / overflow 이렇게 하면 작은 모델 하나 때문에 4~8 GPU짜리 모델의 inference가 영향을 받는 상황을 줄일 수 있습니다. 2. 모델별 GPU 개수는 "모델 크기"보다 TP scaling으로 결정 예를 들어: 70B TP=4 GPU 0-3 8B TP=1 GPU 4 32B TP=2 GPU 5-6 이런 식입니다. 특히 B300에서는 TP=1/2/4/8을 모두 측정한 후 결정 하는 게 중요합니다. 현재 진행하시는 benchmark matrix: TP = 1 / 2 / 4 / 8 Precision = BF16 / FP8 / FP4 Context = ... Concurrency = ... 결과를 이용해서 각 모델의 "최적 GPU 수" 를 결정하면 됩니다. 예: Model TP GPU Max Throughput ------------------------------------------------ Llama-8B 1 1 1,200 tok/s Qwen-32B 2 2 700 tok/s Llama-70B 4 4 520 tok/s DeepSeek-R1-70B 4 4 480 tok/s GPT-OSS-120B 4 4 430 tok/s 그러면 8 GPU에서 어떤 모델을 동시에 돌릴지 계산할 수 있습니다. 3. 중요한 것은 GPU utilization이 아니라 "GPU memory + KV cache" LLM 여러 개를 동시에 돌릴 때 흔히: GPU utilization 70% 만 보고 판단하는데, 이것만 보면 상당히 위험합니다. 반드시 다음을 같이 봐야 합니다. GPU memory used GPU memory free KV cache usage KV cache hit rate KV cache eviction SM utilization Tensor Core utilization HBM bandwidth PCIe/NVLink/NVSwitch traffic 특히 inference에서는: Weights + KV Cache + CUDA Graph + Runtime workspace + Activation 이 전부 GPU memory를 사용합니다. 그래서 모델별로 memory budget을 명시적으로 설정 하는 게 좋습니다. 예: GPU 0-3 Model A weights 280 GB KV cache 80 GB workspace 20 GB reserve 20 GB total 400 GB 그리고 절대로 100%까지 채우지 않습니다. 실서비스라면 대략: Target: GPU memory 70~80% Warning: 80~85% Critical: >90% 정도로 운영하는 것이 안전합니다. 정확한 threshold는 B300의 실제 HBM 용량과 사용하는 inference engine에 맞춰 잡아야 합니다. 4. 작은 모델은 GPU 하나에 여러 모델을 넣을 수도 있음 이 부분이 상당히 중요합니다. 예를 들어: GPU 4 Model B 8B Model C 7B Model D embedding 처럼 할 수 있습니다. 하지만 이것을 무작정 하면 안 됩니다. 특히: Model B → high concurrency Model C → high concurrency Model D → batch 가 동시에 들어오면 서로 GPU를 잡아먹습니다. 그래서 GPU sharing은 workload class까지 같이 나눠야 합니다. 추천: GPU 4 ┌────────────────────────────┐ │ Model B │ │ latency-sensitive │ │ priority = 100 │ ├────────────────────────────┤ │ Model C │ │ normal │ │ priority = 50 │ ├────────────────────────────┤ │ Embedding │ │ batch │ │ priority = 10 │ └────────────────────────────┘ 5. 가능하면 MIG보다 먼저 "process/container isolation"을 검토 B300에서 GPU partitioning을 생각하면 MIG가 먼저 떠오를 수 있습니다. 하지만 LLM inference에서는 무조건 MIG가 좋은 것은 아닙니다. 특히: 70B TP=4 NVLink/NVSwitch KV cache large batch 같은 workload에서는 GPU 전체를 사용하는 편이 훨씬 유리할 수 있습니다. 따라서 저는: Large model Full GPU Small model Full GPU + multiple inference processes Strict isolation이 필요한 경우 MIG 순서로 검토하겠습니다. 6. Kubernetes에서는 "GPU를 누가 소유하는가"를 명확히 해야 함 지금 환경이 Kubespray + NVIDIA GPU Operator이므로 Kubernetes로 운용한다면 특히 중요합니다. 기본적으로: Pod └── GPU resource request 를 사용합니다. 예: resources: limits: nvidia.com/gpu: 4 그러면 Kubernetes가 GPU 4장을 할당합니다. 하지만 여러 모델을 한 GPU에 공유하려면 별도의 전략이 필요합니다. 그래서 모델별 GPU allocation policy 를 먼저 정하는 게 좋습니다. 예: large-model gpu: 4 priority: high medium-model gpu: 2 priority: normal small-model gpu: 1 priority: normal batch gpu: 1 priority: low 7. Triton을 중심으로 잡는 것도 좋은 선택 현재 B300 benchmark를 하고 있으니 저는 실제 서비스 구조에서는 Triton을 상당히 유력하게 봅니다. 구조는: API Gateway │ ▼ Model Router │ ┌────────────┼────────────┐ ▼ ▼ ▼ Model A Model B Model C Triton Triton Triton │ │ │ GPU0-3 GPU4 GPU5 Triton의 장점은: model repository dynamic batching concurrent execution metrics model lifecycle Kubernetes integration 여러 backend 지원 등입니다. 다만 LLM 자체의 scheduler/KV cache 최적화는 vLLM/TensorRT-LLM 등 engine의 특성이 더 중요 하기 때문에, Triton + TensorRT-LLM 또는 Triton + vLLM backend 같은 형태를 모델에 따라 비교하는 것이 좋습니다. 8. 모델별로 "서비스 등급"을 만드는 것을 추천 예를 들어: Class A Interactive SLA 중요 latency 우선 Class B General inference throughput 우선 Class C Batch 비용/throughput 우선 그리고 GPU도: A → GPU 0-3 B → GPU 4-6 C → GPU 7 처럼 배치합니다. 이게 실제 운영에서 상당히 중요합니다. 9. Monitoring은 GPU만 보면 안 됨 제가 추천하는 dashboard는 최소한 다음 구조입니다. GPU dashboard GPU utilization HBM utilization HBM bandwidth Tensor Core utilization Power Temperature PCIe throughput NVLink/NVSwitch throughput ECC/XID LLM dashboard Requests/sec Concurrency TTFT ITL E2E latency Input tokens/sec Output tokens/sec Total tokens/sec Queue time Batch size KV cache utilization KV cache hit ratio OOM Request rejection 특히 TTFT와 ITL 을 반드시 따로 봐야 합니다. TTFT = Time To First Token ITL = Inter Token Latency 예를 들어: TTFT = 2.1 sec ITL = 20 ms 이면 prompt processing 쪽이 문제일 수 있고, TTFT = 100 ms ITL = 200 ms 이면 generation/concurrency 문제가 더 클 수 있습니다. 10. Prometheus metric을 모델별로 분리 현재 Prometheus/Grafana를 사용하고 있으니: model="llama-70b" model="qwen-32b" model="llama-8b" 같은 label을 반드시 넣는 것을 추천합니다. 예: llm_requests_total{ model="llama-70b", precision="fp8", tp="4" } 그리고: llm_ttft_seconds llm_itl_seconds llm_input_tokens_total llm_output_tokens_total llm_kv_cache_usage_ratio llm_queue_depth llm_active_requests 등을 모델별로 수집합니다. 11. 가장 중요한 운영 지표는 "GPU utilization"이 아니라 GPU당 throughput 예를 들어: Model A GPU utilization = 95% throughput = 300 tok/s 과 Model A GPU utilization = 75% throughput = 420 tok/s 라면 후자가 훨씬 좋은 configuration일 수 있습니다. 따라서 모델별로: tokens/sec/GPU 를 만들어야 합니다. 예: Model GPU Throughput tok/s/GPU 8B 1 1,000 1,000 32B 2 700 350 70B 4 520 130 120B 4 430 108 이 지표가 GPU allocation 의사결정의 핵심 이 됩니다. 12. Concurrency별 operating point를 정해야 함 모델마다 무한정 concurrency를 올리면 안 됩니다. 예: Llama-70B Concurrency 1 100 tok/s