Reducing Hit Time Hit time is the time to find data that is in the cache: use the index to pick a set, read its tag(s) and data, compare tags, then choose the right block and send it to the CPU. Both techniques here make that path shorter. 1. Small and Simple Caches Small helps because a smaller memory array has shorter wires and fewer rows to decode, so indexing and reading are faster. This is why L1 size often stays flat across processor generations (as in the AMD K6, Athlon, Opteron example). Making L1 bigger would slow every access, and hit time sets the clock cycle. Simple (direct-mapped) helps because each address has only one possible location. The cache can send the data to the CPU right away, while the tag check runs in parallel. If the tag turns out not to match, the data is thrown away. A set-associative cache can't do this. It must finish all its tag comparisons before the multiplexer knows which way's data to send. 2. Way Prediction The problem it solves There are two options, and each has a weakness: Direct-mapped: fast hit, because there is one possible location and the data can be sent before the tag check finishes. But it has more conflict misses. 4-way set-associative: fewer conflict misses, but slower hit, because it must compare 4 tags and then use the mux to choose the data. Way prediction keeps the set-associative cache, and its low miss rate, but guesses which way holds the data. If the guess is right, the access behaves like a direct-mapped one. "Use load PC or XOR the load src reg and load offset to index the prediction table" The prediction table is a small memory that remembers which way each load found its data in last time. It has to give an answer before the cache access starts, but the real memory address (base register + offset) isn't finished computing yet. So the table is indexed by something available earlier: the PC of the load instruction. The same load instruction tends to hit the same way again, for example inside a loop. or base register XOR offset, a quick stand-in for the address. XOR has no carry chain, so it's much faster than the full addition. correct prediction Wrong way, hit elsewhere Miss in all ways Random access(85% accurate) With 85% accuracy, most hits run at direct-mapped speed. You can estimate the average hit time like this: Average hit time ≈ 0.85 × 1 cycle + 0.15 × 2 cycles = 1.15 cycles The miss rate stays that of a 4-way cache, because every way is still checked before declaring a miss. Only the order of checking changes. "Any other benefit of way prediction?" — power savings If you click random a few times, the average tag compares per access drops far below 4. A normal 4-way cache powers up all 4 tag comparators and reads all 4 data blocks on every access, then throws away 3 of them. With way prediction, a correct guess needs only 1 tag compare and 1 data array read. That is roughly a quarter of the dynamic energy per access in a 4-way cache. The saving matters a lot in L1 caches, which are accessed almost every cycle, and in battery-powered chips. Some designs go further with way selection: they read the data array of only the predicted way. Example just read, not in the exame (a) For a 1024 KB L2 cache with 64-byte blocks and 8-way set associativity, how many way prediction table entries are needed? 1024 KB, 64-byte blocks, 8-way Blocks = 1024 KB ÷ 64 B = 220 ÷ 26 = 214 = 16,384 blocks Sets = 16,384 ÷ 8 = 211 = 2,048 sets Bits per entry = log2 8 = 3 bits Answer: 2,048 entries, each 3 bits, for a total of 2,048 × 3 = 6,144 bits (6 Kb). (b) For an 8 MB L2 cache with 128-byte blocks and 2-way set associativity, how many way prediction table entries are needed? (b) 8 MB, 128-byte blocks, 2-way Blocks = 8 MB ÷ 128 B = 223 ÷ 27 = 216 = 65,536 blocks Sets = 65,536 ÷ 2 = 215 = 32,768 sets Bits per entry = log2 2 = 1 bit Answer: 32,768 entries, each 1 bit, for a total of 32,768 bits (32 Kb). (c) What is the difference in the way that the processor with only 8Kb way prediction table will support the cache in part (a) versus the cache in part (b)? Cache (a) is fully supported. 6,144 bits fits in 8,192, so every one of the 2,048 sets gets its own 3-bit predictor, with about 2 Kb left unused. Prediction accuracy is as good as the predictor allows. Cache (b) is only partially supported. The table can hold only 8,192 one-bit entries, but there are 32,768 sets. The table is indexed with only the low 13 bits of the 15-bit set index, so 4 different sets share each entry (aliasing). When those sets are used in the same period and their blocks sit in different ways, they overwrite each other's prediction. This causes more mispredictions, and each one costs an extra cycle to check the other way, so the average hit time rises. The cache still works correctly in (b). A prediction is only a hint, and the tag comparison always checks it. A wrong guess costs time, not correctness. If your course means "8K entries" rather than 8K bits, the conclusion is the same. Cache (a) needs only 2,048 of the 8,192 entries (with 3-bit entries), so it fits. Cache (b) needs 32,768 entries, so each entry is still shared by 4 sets.
시작 얼마 전 성시경이 나오는 유튜브 쇼츠를 봤다 언어를 독학하시면서 "어제 배웠던 내용을 오늘이면 까먹는다"고 하셨다 그래도 계속 공부한다고 하시면서 "계속하면 된다는 것을 안다" 라는 걸 느꼈다고 하셨다 나도 지금 이 공부를 하면서 당장은 어제 배웠던 내용을 기억하지 못하기도 하지만 계속하다보면 되겠지..라는 생각으로 일단 공부를 한다 key 리액트에서 배열을 이용해 여러 컴포넌트를 렌더링할 때는 각 요소에 key를 지정해야 한다 const users = [ { id: 1, name: "Lee" }, { id: 2, name: "Kim" }, { id: 3, name: "Park" }, ]; function UserList() { return ( <ul> {users.map((user) => ( <li key={user.id}> {user.name} </li> ))} </ul> ); } 리액트가 이전 렌더링 결과와 새로운 렌더링 결과를 비교할 때 각 요소가 이전의 어떤 요소와 같은 요소인지 판단하는 기준 으로 사용한다 재조정 리액트에서는 상태가 props가 변경되면 새로운 ui를 계산한다 이전 UI ↓ 상태 변경 ↓ 새로운 UI 계산 ↓ 이전 UI와 새로운 UI 비교 ↓ 필요한 부분만 변경 이렇게 이전 렌더링 결과와 새로운 렌더링 결과를 비교하여 실제로 무엇을 변경해야 하는지 결정하는 과정을 재조정 이라고 한다 예를 들어 다음과 같은 목록이 있다고 해보자 Lee Kim Park 새로운 사용자가 중간에 추가되었다 Lee Choi Kim Park 리액트는 새로운 목록을 보면서 Lee는 기존 Lee인가? Choi는 새로운 요소인가? Kim은 기존 Kim인가? Park는 기존 Park인가? 를 판단해야 한다 이때 key가 사용된다 <li key={1}>Lee</li> <li key={4}>Choi</li> <li key={2}>Kim</li> <li key={3}>Park</li> 리액트는 key를 이용해 기존 요소와 새 요소의 관계를 파악할 수 있다 key 1 → 기존 Lee key 4 → 새롭게 추가 key 2 → 기존 Kim key 3 → 기존 Park 따라서 목록의 순서가 변경되더라도 요소의 정체성을 유지할 수 있다 key가 중요한 이유 리액트는 같은 위치에 있는 요소만 보는 것이 아니라 key를 이용해 어떤 요소가 이전 요소와 같은 것인지 판단한다 예를 들어 const users = [ { id: 1, name: "Lee" }, { id: 2, name: "Kim" }, ]; 이 목록이 const users = [ { id: 2, name: "Kim" }, { id: 1, name: "Lee" }, ]; 처럼 순서가 바뀌었다고 해보자 적절한 key가 있다면 리액트는 key 1 → Lee가 이동 key 2 → Kim이 이동 이라고 판단할 수 있다 즉 새로운 요소 두 개가 생겼다고 생각하는 것이 아니라 기존 요소가 위치를 변경한 것으로 판단할 수 있다 key와 상태 key는 단순히 DOM 변경 최적화에만 영향을 주는 것이 아니다 리액트는 컴포넌트의 상태를 렌더 트리에서의 위치와 key를 기준으로 연결한다 예를 들어 function App({ user }) { return ( <Profile key={user.id} user={user} /> ); } 사용자가 변경되어 key가 달라지면 리액트는 기존 Profile 과 같은 컴포넌트라고 보지 않는다 Profile key=1 ↓ 사용자 변경 ↓ Profile key=2 리액트는 기존 컴포넌트를 제거하고 새로운 컴포넌트를 만든 것으로 취급할 수 있다 따라서 내부 state도 초기화된다 이 특성을 이용해 폼 상태 등을 의도적으로 초기화할 수도 있다 <Chat key={selectedUser.id} user={selectedUser} /> 사용자가 변경될 때마다 채팅 엽력 상태를 새롭게 초기화하는 식으로 사용할 수 있다 리액트는 state를 컴포넌트 자체가 아니라 렌더 트리의 위치와 연결해서 관리하며 key는 같은 위치에서도 컴포넌트의 정체성을 구분하는 데 사용할 수 있다 배열의 index를 key로 사용함녀 안 되는 이유 다음과 같이 작성할 수도 있다 users.map((user, index) => ( <User key={index} user={user} /> )); 목록이 절대 변경되지 않는다면 문제가 드러나지 않을 수도 있다 하지만 요소가 추가되거나 삭제되거나 순서가 변경되면 문제가 발생할 수 있다 예를 들어 index 0 → Lee index 1 → Kim index 2 → Park 여기에서 맨 앞에 Choi 가 추가되면 index 0 → Choi index 1 → Lee index 2 → Kim index 3 → Park 이전에는 index 0 이 Lee 였지만 이제는 Choi 가 되었다 리액트 입장에서는 key 0 이전 → Lee 현재 → Choi 인데 key는 동일하다 따라서 실제 데이터의 정체성과 리액트가 판단하는 요소의 정체성이 어긋날 수 있다 특히 리스트 아이템 내부에 state가 있다면 잘못된 항목에 기존 state가 유지되는 문제가 발생할 수 있다 따라서 일반적으로 데이터 자체를 식별할 수 있는 안정적인 값을 사용하는 것이 좋다 key={user.id} key는 전역적으로 유일해야 할까? key가 애플레케이션 전체에서 유일할 필요는 없다 같은 부모 아래에 있는 형제 요소들 사이에서만 구분할 수 있으면 된다 리액트 공식 문서에서도 key는 전역적으로 유일할 필요가 없고 부모 안에서의 위치를 구분한다고 설명한다 key의 핵심 key는 단순한 식별자처럼 보이지만 리액트 재조정 과정에서 중요한 역할을 한다 이전 렌더 트리 ↓ key 비교 ↓ 새로운 렌더 트리 같은 key → 같은 요소로 판단 가능 새로운 key → 새로운 요소로 판단 사라진 key → 제거된 요소로 판단 따라서 key는 목록에서 요소의 정체성을 리액트에게 알려주는 값 이라고 이해할 수 있다 왜? 1. key가 바뀌면 왜 state가 초기화가 되는 거지? 리액트의 state는 컴포넌트 함수 자체에 저장되는 것이 아니라 렌더 트리에서 해당 컴포넌트의 정체성과 연결되어 관리된다 재조정 과정에서 같은 위치에 같은 컴포넌트가 존재하고 key도 동일하다면 리액트는 기존 컴포넌트라고 판단하여 state를 유지할 수 있다 반대로 key가 변경되면 리액트는 이전 컴포넌트와 다른 컴포넌트라고 판단한다 따라서 기존 컴포넌트가 제거되고 새로운 컴포넌트가 생성되면서 기존 state도 함께 사라지고 새로운 state가 초기값부터 만들어진다 즉 key가 state를 직접 초기화하는 것이 아니라 key 변경으로 컴포넌트의 정체성이 달라지고 새로운 컴포넌트로 처리되기 때문에 state가 초기화 되는 것 이다 나만의 정리 리액트에서는 반복적인 컴포넌트를 구성하는 경우에는 key 속성을 사용합니다. key 속성은 해당 컴포넌트의 정체성을 의미하며 이 정체성을 기준으로 리액트는 재조정 과정에서 요소를 재사용하게 됩니다. 따라서 불필요한 DOM 생성이나 삭제를 피하고 실제로 변경된 부분만 최소한으로 업데이트해 성능 최적화가 가능합니다. 이런 정체성을 유지시키기 위해서 key에는 고유한 값을 설정해야 합니다. Next.js 리액트는 ui를 만들기 위한 라이브러리이다 리액트만으로도 애플리케이션을 만들 수는 있지만 실제 서비스를 만들기 위해서는 ui 이외에도 여러 기능이 필요하다 예를 들어 라우팅 데이터 패칭 코드 분할 서버 렌더링 정적 렌더링 서버 코드 이미지 최적화 메타데이터 빌드 설정 등을 함께 구성해야 한다 Next.js는 이런 기능을 리액트 위에 제공하는 리액트 프레임워크 다 현재 Next.js 공식 문서에서도 full-stack web application을 만들기 위한 리액트 프레임워크로 설명하고 있으며 리액트 컴포넌트를 ui에 사용하면서 Next.js가 추가 기능과 최적화를 제공한다고 설명한다 리액트와의 관계 둘의 관계는 다음처럼 생각할 수 있다 React → UI를 구성하는 라이브러리 Next.js → React를 기반으로 실제 웹 애플리케이션을 구성하기 위한 여러 기능을 제공하는 프레임워크 Next.js를 사용하는 이유 1. 라우팅 리액트 자체에는 애플리케이션 페이지를 관리하는 라우터가 포함도어 있지 않다 Next.js는 파일 구조를 기반으로 라우팅을 제공한다 App Router에서는 app/ ├─ page.tsx ├─ login/ │ └─ page.tsx └─ posts/ └─ page.tsx 가 / ↓ app/page.tsx /login ↓ app/login/page.tsx /posts ↓ app/posts/page.tsx 처럼 URL과 연결된다 라우팅을 별도의 라이브러리와 설정으로 직접 구성하지 않아도 된다 2. 다양한 렌더링 방식 리액트를 브라우저에서만 실행하면 전통적인 CSR 형태가 된다 Next.js에서는 서버에서 ui를 렌더링하거나 미리 정적으로 생성하는 방식 등을 함께 사용할 수 있다 Client Rendering Server Rendering Static Rendering Streaming 등을 페이지와 데이터 특성에 따라 조합할 수 있다 현재 App Router에서는 정적 렌더링과 동적 렌더링을 지원하며 정적 렌더링은 빌드 시점이나 재검증 시 서버에서 생성되고 동적 렌더링은 요청 시점에 서버에서 렌더링된다 3. 서버에서 데이터 가져오기 Next.js App Router에서는 서버 컴포넌트를 이용해 서버에서 직접 데이터를 가져올 수 있다 export default async function Page() { const posts = await getPosts(); return ( <PostList posts={posts} /> ); } 이 코드는 브라우제어서 직접 실행할 필요가 없다 Server ↓ 데이터 조회 ↓ UI 계산 ↓ 결과 전달 따라서 데이터베이스나 내부 API처럼 서버에 접근해야 하는 리소스를 다루기 편리하다 4. 서버 컴포넌트 App Router에서는 리액트 서버 컴포넌트를 사용할 수 있다 서버 컴포넌트에서는 서버에서 실행되기 때문에 해당 컴포넌트의 자바스크립트를 브라우저에서 실행할 필요가 없는 경우 클라이언트 자바스크립트 양을 줄이는 데 도움이 될 수 있다 Server Component → 서버에서 실행 → 데이터 조회 가능 → 클라이언트 JS 부담 감소 가능 Client Component → 브라우저에서 실행 → 상태 / 이벤트 / 브라우저 API 사용 가능 따라서 모든 컴포넌트를 클라이언트에서 실행하는 대신 필요한 부분만 클라이언트 컴포넌트로 만들 수 있다 5. 코드 분할 큰 애플리케이션의 모든 자바스크립트를 한 번에 내려받으면 초기 로딩 비용이 증가할 수 있다 Next.js는 라우트를 기준으로 필요한 자바스크립트를 나누어 제공하는 기능을 기본적으로 제공한다 전체 애플리케이션 코드 ↓ 한 번에 모두 다운로드 X 현재 페이지에 필요한 코드 ↓ 우선 다운로드 리액트만 이용해 애플리케이션을 구성한다면 번들링과 코드 분할 같은 설정을 별도로 고려해야 할 수 있지만 Next.js는 이러한 하위 도구들을 기본적으로 구성한다 6. Streaming 서버에서 페이지를 렌더링한다고 해서 모든 데이터를 기다린 다음 한 번에 응답할 필요는 없다 Next.js는 리액트의 Suspense를 이용한 스트리밍을 지원한다 Page Header → 준비 완료 Sidebar → 준비 완료 Dashboard → 느린 데이터 스트리밍을 이용하면 Header + Sidebar ↓ 먼저 전달 Dashboard ↓ 준비되면 나중에 전달 처럼 페이지를 여러 부분으로 나누어 전달할 수 있다 Next.js 공식 문서에서도 스트리밍은 route를 작은 chunk로 나누어 준비된 부분부터 클라이언트에 전달하여 느린 데이터가 전체 화면을 막지 않게 할 수 있다고 설명한다 7. 개발 환경과 최적화 기능 Next.js는 웹 애플리케이션을 만들 때 자주 필요한 여러 기능을 기존 제공한다 예를 들어 Image 최적화 Font 최적화 Metadata 관리 Route Handler Server Actions Caching Prefetching 등을 프레임워크 안에서 사용할 수 있다 따라서 개발자가 애플리케이션 구조를 처음부터 모두 직접 구성해야 하는 부담을 줄일 수 있다 Next.js의 장점 장점을 정리하면 React 기반의 통합된 개발 구조 파일 기반 라우팅 서버 렌더링과 정적 렌더링 지원 Server Component 사용 가능 Streaming 지원 코드 분할과 여러 최적화 기능 기본 제공 서버 코드와 클라이언트 코드를 하나의 프로젝트에서 구성 가능 등이 있다 특히 페이지마다 하나의 렌더링 방식만 강제하는 것이 아니라 요구사항에 따라 서버와 클라이언트 렌더링을 조합할 수 있다는 점이 큰 특징이다 Next.js의 단점 1. 리액트보다 학습해야 할 개념이 많다 리액트만 사용할 때는 주로 컴포넌트와 상태 관리에 집중할 수 있다 Next.js에서는 추가적으로 Server Component Client Component Static Rendering Dynamic Rendering Caching Revalidation Streaming Server Actions Route Handler 등을 이해해야 한다 특히 서버와 클라이언트의 경계를 구분해야 하기 때문에 처음에는 복잡하게 느껴질 수도 있다 2. 캐싱과 렌더링 구조가 복잡해질 수 있다 Next.js에서는 데이터나 페이지의 특성에 따라 캐싱과 렌더링 방식이 달라질 수 있다 이 데이터는 언제 갱신되는가? 서버에서 처리되는가? 클라이언트에서 다시 요청하는가? 정적으로 생성되는가? 요청마다 새로 렌더링되는가? 를 고려해야 한다 단순한 리액트 CSR 애플리케이션보다 데이터 흐름을 이해해야 할 범위가 넓어진다 3. 서버 운영을 고려해야 할 수도 있다 모든 페이지가 정적 파일로만 만들어지는 것이 아니라 SSR이나 Server Action, Route Handler 등을 사용한다면 서버 실행 환경이 필요하다 Static Site → CDN 중심으로 제공 가능 Dynamic Next.js → 서버 실행 환경 필요 따라서 배포 구조와 서버 비용도 고려해야 할 수도 있다 4. 프레임워크에 대한 의존성이 커진다 Next.js는 라우팅부터 데이터 처리, 캐싱, 빌드까지 많은 부분을 담당한다 편리한 만큼 애플리케이션 구조가 Next.js의 규칙과 기능에 크게 의존할 수 있다 따라서 단순히 React 문법만 아는 것보다 Next.js의 렌더링 및 캐싱 모델까지 이해해야 한다 Next.js는 언제 사용하면 좋을까 Next.js는 다음과 같은 요구사항이 있을 때 특히 유용하다 검색 노출이 중요한 페이지 초기 콘텐츠를 서버에서 제공하고 싶은 경우 정적 페이지와 동적 페이지가 함께 존재하는 서비스 서버와 클라이언트 코드를 하나의 React 프로젝트에서 관리하고 싶은 경우 페이지 단위로 다양한 렌더링 전략이 필요한 서비스 반대로 매우 단순한 내부 도구나 SEO가 필요 없는 작은 CSR 애플리케이션이라면 Next.js의 모든 기능이 반드시 필요한 것은 아니다 결국 Next.js를 사용하는 이유는 단순히 리액트보다 성능이 좋기 때문이 아니라 웹 애플리케이션에 필요한 여러 기능과 렌더링 전략을 리액트 위에서 통합적으로 제공하기 때문 이라고 이해할 수 있다 왜? 1. 스트리밍 방식과 클라이언트 패칭으로 인한 로딩이 사용자에게는 무슨 차이가 있는 거지? 두 방식 모두 사용자에게 로딩 ui를 보여줄 수 있지만 데이터 요청과 ui 생성이 시작되는 시점에 차이가 있다 클라이언트 패칭은 브라우저에서 자바스크립트와 리액트가 실행된 이후 에 데이터 요청을 시작하는 경우가 많이 초기 로딩에서 추가적인 네트워크 워터폴이 발생할 수 있다 반면 서버 스트리밍은 서버에서 데이터 요청과 렌더링을 진행 하면서 먼저 준비된 ui를 클라이언트로 전달하고 느린 영역은 Suspense의 fallback을 먼저 보여준 뒤 준비되는 대로 추가 ui를 전달할 수 있다 따라서 사용자는 느린 데이터 하나 때문에 전체 페이지를 기다리지 않고 준비된 부분부터 확인할 수 있다 다만 사용자 상호작용 이후 데이터를 갱신하는 경우에는 클라이언트 패칭이 더 자연스러운 경우도 있기 때문에 두 방식은 상황에 따라 함께 사용할 수 있다 2. API Route가 뭐고 서버리스 함수는 뭐지? API Route는 Next.js 애플리케이션 내부에서 HTTP API endpoint를 만드는 기능이다 Pages Router에서는 API Route라고 부르며 현재 App Router에서는 비슷한 역할을 Route Handler가 담당한다 반면 Serverless Function은 API를 작성하는 방법이 아니라 서버를 직접 관리하지 않고 요청이 발생할 때 서버 코드를 실행하도록 하는 클라우드 실행 방식이다 따라서 API Route나 Route Handler가 배포 환경에 따라 Serverless Function으로 실행될 수는 있지만 두 개념이 같은 것은 아니다 3. Server Actions는 뭐지? API Route와의 차이는? 서버 액션은 서버에서 실행되는 비동기 함수를 리액트 컴포넌트에서 함수처럼 호출할 수 있도록 하는 기능이다 내부적으로 클라이언트와 서버 사이의 요청은 여전히 발생하지만 개발자가 별도의 API endpoint와 fetch 요청을 직접 작성하지 않아도 된다 Route Handler는 GET, POST와 같은 HTTP endpoint 자체를 제공하기 때문에 외부 서비스, 다른 클라이언트, 웹훅 등에서도 사용할 수 있다 반면 서버 액션은 현재 리액트 ui에서 발생하는 데이터 생성, 수정, 삭제와 같은 작업을 서버 로직과 연결할 때 사용하기 자연스럽고 Next.js의 캐시 재검증 기능과도 쉽게 연동할 수 있다 일반적으로 스프링과 같이 별도의 백엔드 서버가 이미 구성되어 있고 해당 서버가 CRUD API를 제공한다면 단순한 데이터 조회나 생성, 수정, 삭제를 위해 Route Handler나 서버 액션을 반드시 추가할 필요는 없다 클라이언트에서는 백엔드 API를 직접 호출하고 서버 컴포넌트에서는 서버에서 백엔드 API를 직접 호출할 수 있다 다만 인증 처리, BFF 역할, 여러 API 응답의 조합, 외부에 노출할 HTTP endpoint, Next.js의 캐시 재검증과 Mutation을 연결해야 하는 경우처럼 Next.js 서버 계층이 해결해야 할 명확한 목적이 있다면 Route Handler나 서버 액션을 추가로 사용할 수 있다 따라서 외부에서도 사용할 명시적인 HTTP API가 필요하다면 Route Handler를 사용하고 현재 Next.js ui에서 발생하는 서버 Mutation을 처리한다면 서버 액션을 고려할 수 있으며 별도의 백엔드가 존재하는 구조에서는 필요한 이유가 있을 때만 이러한 Next.js 서버 계층을 추가하는 것이 좋다 나만의 정리 Next.js는 리액트에 추가로 실제 서비스에 필요한 부분들을 기본 기능으로 제공하는 프레임워크입니다. 현재 next.js는 app router를 기본으로 하여 파일 구조를 기반으로 라우팅을 제공하기 때문에 별도의 라우팅 설정을 하지 않아도 됩니다. 또한 다양한 렌더링 방식을 지원하여 사용자 경혐을 개선할 수 있습니다. next.js는 풀스택 프레임워크로 서버에서 실행되는코드와 API도 함께 작성할 수 있기 때문에 서비스에 따라 별도의 백엔드 구성 없이 하나의 next.js 애플리케이션으로 개발이 가능합니다. 이외에도 이미지 최적화, 스트리밍, 코드 분할 등 웹 애플리케이션 개발에 필요한 여러 최적화 기능을 제공합니다. 렌더링 방식 웹 페이지를 사용자에게 보여주기 위해서는 HTML이 만들어져야 한다 CSR, SSR, SSG의 가장 큰 차이는 이 HTML을 언제 어디서 만들어 사용자에게 전달하는가 에 있다 CSR → 브라우저에서 렌더링 SSR → 요청이 들어올 때 서버에서 렌더링 SSG → 요청 이전에 서버에서 미리 렌더링 CSR CSR(Client Side Rendering)은 브라우저에서 자바스크립트를 실행해 ui를 만드는 방식이다 서버에서 기본 HTML과 자바스크립트를 전달한다 Browser ↓ 페이지 요청 Server ↓ HTML + JavaScript 전달 Browser ↓ JavaScript 다운로드 ↓ JavaScript 실행 ↓ UI 렌더링 전통적인 리액트 SPA에서 많이 사용되는 방식이다 예를 들어 서버에서 다음과 같은 HTML을 내려줄 수 있다 <div id="r
소집해제도 다가오고 어떻게 될진 모르겠지만 알고리즘을 한번 풀어보자. 요즘 문제 푸는 재미가 꽤나 쏠쏠하다 그리고 Riv Animation을 잘만들어주는 스킬을 요즘 계속 다듬고있는데 점점 할수록 퀄리티가 올라가는게 보여서 재밌다. 다음에 시간이 되면 Rive 공식 커뮤니티에 기고해볼 예정이다. Skill을 비대하게 만들지 않는것이 중요하다 생각했는데, 의도치않은 결과물이 생기면 단순히 그 문제 케이스에 대해서만 지침을 추가해 구멍을 메꾸는게 아니라 AI가 어떤 점을 미처 생각하지 못해서 이런식의 결과물이 나올 수 있는지 먼저 얘기하는 시간을 가지니까 스킬을 만들면서 나까지 학습이 되는거 같다. 뭐 어쨌든 본격적으로 문제를 풀어보자 대략 한 40분 정도 소모된거같다. 문제 링크 : https://school.programmers.co.kr/learn/courses/30/lessons/43164# 문제 설명 시작 지점 a 공항에서 b 공항으로 가는 항공권들인 tickets 를 받고 주어진 항공권을 모두 사용하여 여행 경로를 짜야하는 문제이다. 제약은 다음과 같다. 공항은 최소 3개 이상 10,000개 이하이다. 이때 항공권을 모두 사용한 가능한 여행 경로가 2개 이상이라면 알파벳 순서에 앞서는 경로를 return한다. 아이디어 방향이 있는 그래프 구조라고 생각했고 DFS를 이용해서 완전탐색을 하면 쉽게 풀릴 문제다. 순서도를 구해보자면 ICN에서 시작하면서 순회를 시작한다. 사용하지않은 티켓들중에 현재 startPoint로부터 갈 수 있는 티켓들을 getCanBeNextDestination 로 구한다 이를 candidates 라고 한다. candidates 만큼 for문을 돌면서 dfs 순회 시작한다. 그리고 남은 티켓에서 해당 candidate 티켓을 제거한다. candidates 가 더이상 없으면 종료하고 남은 티켓이 없다면 한번 다 순회했다는 의미이므로 가능한 경로를 의미하는 allTravelRoutes 에 해당 경로를 넣는다. 가능한 경로가 여러개일 경우 알파벳에 우선하는 경로를 return 해야하므로 sort를 이용해서 정렬 후 값을 return한다. 시간 복잡도 하나를 각각 선택하고 candidates의 개수에 따라 dfs 실행을 반복하기때문에 O(N!)이다 출발지와 도착지가 정해져있기때문에 무분별하게 탐색을 위한 분기하진 않지만 최악의 경우에는 O(N!)이다 문제 유형 완전탐색 문제다 문제를 다 풀고나서 시간 복잡도가 O(N!)이 나와버리니, GPT한테 다른방식으로 풀 수 있냐고 물어보니까 오일러 경로와 Hierholzer 알고리즘을 사용할 수 있다고 했는데 이건 다음 포스팅에서 알아보자. 전체 코드 function solution(tickets) { var answer = []; const allTravelRoutes = []; const getCanBeNextDestination = (startPoint, unusedTickets) => { const candidates = unusedTickets.filter(([startAirport, destination])=>{ return startAirport === startPoint }) return candidates; } const dfs = (originPort, travelRoutes, unusedTickets) => { if (unusedTickets.length === 0) { allTravelRoutes.push([...travelRoutes, originPort]); }else{ const candidates = getCanBeNextDestination(originPort,unusedTickets); if(candidates.length === 0) return; for(let i =0; i<candidates.length; i++){ const index = unusedTickets.findIndex(ticket => ticket[0] === candidates[i][0] && ticket[1] === candidates[i][1]); const newUnusedTickets = unusedTickets.filter((_, i) => i !== index); dfs(candidates[i][1], [...travelRoutes, originPort],newUnusedTickets ) } } } dfs("ICN",[],tickets) allTravelRoutes.sort((a,b)=>{ for(let i=0; i<a.length; i++){ if(a[i] < b[i]) return -1 if(a[i] > b[i]) return 1 } return 0; }) return (allTravelRoutes[0]) };
Java로 코딩테스트 문제를 풀다 보면 Stream을 사용하면 상당히 간결하게 작성할 수 있는 코드가 많다. 예를 들어 int[] 을 Integer[] 로 변환해야 한다면 다음처럼 작성할 수 있다. Integer[] arr = Arrays.stream(numlist) .boxed() .toArray(Integer[]::new); 반대로 Integer[] 을 int[] 으로 변환하는 것도 간단하다. int[] result = Arrays.stream(arr) .mapToInt(Integer::intValue) .toArray(); 코드만 보면 반복문보다 훨씬 간결하다. 하지만 코딩테스트에서 직접 여러 방식으로 제출해 보면 Stream 대신 반복문을 사용했을 때 실행 시간이 더 짧게 나오는 경우가 있다. 그래서 이런 의문이 생겼다. Stream이 느리다면 왜 Java 실무 코드에서는 Stream을 많이 사용할까? 이를 이해하려면 먼저 Stream이 어떤 문제를 해결하기 위해 만들어졌는지, 그리고 Stream을 사용할 때 어떤 비용이 발생하는지를 구분해서 볼 필요가 있다. 1. 반복문과 Stream은 같은 일을 다른 방식으로 표현한다 먼저 간단한 예제를 살펴보자. 숫자 목록에서 짝수만 골라 2배로 만든다고 해보자. 반복문을 사용하면 다음과 같이 작성할 수 있다. List<Integer> result = new ArrayList<>(); for (Integer number : numbers) { if (number % 2 == 0) { result.add(number * 2); } } Stream을 사용하면 다음과 같다. List<Integer> result = numbers.stream() .filter(number -> number % 2 == 0) .map(number -> number * 2) .toList(); 결과는 같다. 하지만 코드가 표현하는 방식에는 차이가 있다. 반복문은 다음 과정을 직접 작성한다. numbers를 하나씩 순회한다. → 짝수인지 검사한다. → 2를 곱한다. → result에 추가한다. 반면 Stream에서는 다음과 같이 읽을 수 있다. numbers에서 → 짝수만 남기고 → 각각 2배로 변환한 뒤 → List로 만든다. 전자는 어떻게 처리할 것인가 를 자세히 작성하고, 후자는 어떤 결과를 만들 것인가 에 집중한다. 이 차이가 Stream을 사용하는 가장 큰 이유 중 하나다. 2. 명령형과 선언형 반복문 방식은 보통 명령형(Imperative) 스타일이라고 한다. 개발자가 처리 순서를 직접 제어한다. List<String> names = new ArrayList<>(); for (User user : users) { if (user.isActive()) { names.add(user.getName()); } } 여기에는 다음 정보가 모두 들어 있다. 컬렉션 생성 반복 조건 검사 값 추출 결과 추가 Stream은 보다 선언형(Declarative) 인 코드에 가깝다. List<String> names = users.stream() .filter(User::isActive) .map(User::getName) .toList(); 코드를 읽으면 처리 의도가 바로 보인다. 활성 사용자만 골라서 → 이름으로 변환하고 → List로 만든다. Stream의 핵심적인 장점은 단순히 코드를 짧게 만드는 것이 아니다. 데이터 처리 과정을 높은 수준의 연산으로 표현할 수 있다는 것이다. 3. 그렇다면 Stream은 왜 반복문보다 느릴 수 있을까? 단순 반복문은 JVM 입장에서 매우 단순하다. for (int i = 0; i < arr.length; i++) { result[i] = arr[i] * 2; } 해야 할 일은 사실상 다음뿐이다. 배열 접근 → 연산 → 배열 저장 → 다음 원소 반면 Stream을 사용하면 Arrays.stream(arr) .map(value -> value * 2) .toArray(); 개발자가 직접 반복문을 작성하지 않는 대신 Stream이 데이터 처리 과정을 관리한다. 개념적으로 보면 다음과 같은 구조가 추가된다. Stream 생성 ↓ 중간 연산 구성 ↓ 람다 실행 ↓ Stream pipeline 처리 ↓ 최종 연산 실행 JVM이 이런 코드를 상당 부분 최적화해 주지만, 단순 반복문과 비교하면 추가적인 추상화 계층이 존재한다. 따라서 작은 연산을 매우 많이 반복하는 상황에서는 그 추가 비용이 눈에 띌 수 있다. 4. Stream의 문제와 Boxing의 문제는 구분해야 한다 코딩테스트에서 Stream이 느리다고 느끼게 만드는 대표적인 원인 중 하나가 Boxing/Unboxing 이다. 예를 들어 다음 배열이 있다고 하자. int[] numbers = {1, 2, 3, 4, 5}; 이를 Integer[] 로 변환하려고 다음과 같이 작성했다. Integer[] arr = Arrays.stream(numbers) .boxed() .toArray(Integer[]::new); 여기서 중요한 부분은 .boxed() 다. 원래 배열의 값은 primitive 타입인 int 다. 하지만 boxed() 이후에는 각각 Integer 객체로 취급된다. 즉 int → Integer 라는 Boxing이 발생한다. 반대로 Arrays.stream(arr) .mapToInt(Integer::intValue) .toArray(); 를 수행하면 Integer → int 형태의 Unboxing이 필요하다. 따라서 다음 코드는 단순히 "Stream이라서 느리다"라고만 해석하면 정확하지 않다. Arrays.stream(numbers) .boxed() .sorted(...) .mapToInt(Integer::intValue) .toArray(); 여기에는 Stream 처리 비용 + Boxing + 객체 기반 정렬 + Unboxing 이 함께 포함되어 있다. 5. 반복문으로 변환해도 Boxing 자체는 사라지지 않는다 그렇다면 다음과 같이 반복문을 사용하면 Boxing이 없어질까? Integer[] arr = new Integer[numbers.length]; for (int i = 0; i < numbers.length; i++) { arr[i] = numbers[i]; } 그렇지는 않다. 왼쪽은 Integer , 오른쪽은 int 이기 때문에 이 코드에서도 Auto Boxing이 발생한다. int → Integer 즉 Stream 버전과 반복문 버전의 차이를 Stream → Boxing 발생 for문 → Boxing 없음 으로 이해하면 잘못된 것이다. 둘 다 Integer[] 을 만드는 이상 Boxing 자체는 필요하다. 반복문이 줄이는 것은 주로 배열을 변환하기 위한 Stream pipeline의 추가적인 처리 비용 이다. 6. 모든 Stream이 Boxing을 발생시키는 것은 아니다 Java에는 primitive 타입을 위한 별도의 Stream이 있다. 대표적으로 IntStream LongStream DoubleStream 이다. 예를 들어 다음 코드는 int[] numbers = {1, 2, 3, 4, 5}; int sum = Arrays.stream(numbers) .filter(number -> number % 2 == 0) .sum(); Stream<Integer> 가 아니다. Arrays.stream(int[]) 의 반환형은 IntStream 이다. 따라서 각 숫자를 Integer 객체로 변환하지 않고 primitive int 상태로 처리할 수 있다. 이 차이는 중요하다. IntStream → int를 그대로 처리 Stream<Integer> → Integer 객체를 처리 따라서 Arrays.stream(numbers) 와 Arrays.stream(numbers).boxed() 를 성능 관점에서 똑같이 취급해서는 안 된다. 7. Stream은 중간 연산마다 새로운 컬렉션을 만들까? 처음 Stream을 접하면 다음 코드가 numbers.stream() .filter(...) .map(...) .filter(...) .toList(); 마치 다음처럼 동작한다고 생각하기 쉽다. filter 결과 List 생성 ↓ map 결과 List 생성 ↓ filter 결과 List 생성 ↓ 최종 List 생성 하지만 Stream은 기본적으로 그렇게 동작하지 않는다. Stream의 중간 연산은 지연 평가(Lazy Evaluation) 된다. 대표적인 중간 연산은 filter map sorted distinct limit 등이다. 이들은 호출되는 순간 데이터를 전부 처리하는 것이 아니라 처리 방법을 pipeline에 등록한다. 실제 처리는 toList() collect() sum() count() forEach() 같은 최종 연산(Terminal Operation)이 호출되었을 때 시작된다. 예를 들어 numbers.stream() .filter(n -> n > 10) .map(n -> n * 2) .toList(); 는 개념적으로 각 원소가 원소 하나 ↓ filter ↓ map ↓ 결과 를 통과하는 방식으로 처리된다. 중간 단계마다 새로운 List를 만드는 것은 아니다. 8. 그렇다면 실무에서는 왜 Stream을 많이 사용할까? 실무에서는 실행 시간만큼이나 중요한 것이 있다. 가독성 유지보수성 코드의 의도 변경 용이성 버그 발생 가능성 예를 들어 Spring 애플리케이션에서 Entity 목록을 Response DTO 목록으로 변환하는 코드를 생각해 보자. 반복문으로 작성하면 다음과 같다. List<UserResponse> responses = new ArrayList<>(); for (User user : users) { responses.add(UserResponse.from(user)); } Stream을 사용하면 List<UserResponse> responses = users.stream() .map(UserResponse::from) .toList(); 로 표현할 수 있다. 여기에 활성 사용자만 반환한다는 조건이 추가되면 List<UserResponse> responses = users.stream() .filter(User::isActive) .map(UserResponse::from) .toList(); 처럼 자연스럽게 확장된다. Stream 코드의 장점은 반복한다 새로운 리스트를 만든다 리스트에 추가한다 같은 구현 세부사항이 사라지고 활성 사용자만 선택 → Response로 변환 → List 생성 이라는 비즈니스 로직이 강조된다는 것이다. 9. 실무에서 Stream과 잘 어울리는 작업 Stream은 특히 컬렉션을 다른 형태의 데이터로 변환하는 작업 과 잘 맞는다. 필터링 List<User> activeUsers = users.stream() .filter(User::isActive) .toList(); 변환 List<UserResponse> responses = users.stream() .map(UserResponse::from) .toList(); 특정 값 추출 List<Long> userIds = users.stream() .map(User::getId) .toList(); 합계 계산 long totalPrice = orders.stream() .mapToLong(Order::getPrice) .sum(); 그룹핑 Map<String, List<User>> usersByDepartment = users.stream() .collect(Collectors.groupingBy(User::getDepartment)); 이런 코드는 Stream이 무엇을 하려는 코드인지 쉽게 드러난다. 10. Stream을 사용하지 않는 편이 나은 경우도 있다 Stream이 항상 더 좋은 코드는 아니다. 특히 상태를 계속 변경해야 하는 로직에서는 오히려 반복문이 읽기 쉽다. 예를 들어 다음처럼 Stream 내부에서 여러 Side Effect를 발생시키는 코드는 좋지 않다. users.stream() .filter(user -> { count++; user.setActive(true); log.info("user={}", user); return user.getAge() >= 20; }) .forEach(...); filter() 는 원래 조건을 만족하는 데이터를 선택한다. 라는 의미를 가진다. 그 안에서 count 증가 객체 변경 로그 출력 조건 검사 를 모두 수행하면 Stream의 선언적인 장점이 사라진다. 이런 경우에는 차라리 반복문이 명확하다. for (User user : users) { count++; user.setActive(true); log.info("user={}", user); if (user.getAge() >= 20) { // ... } } 11. 성능이 중요한 반복 작업이라면 반복문이 더 적합할 수 있다 다음과 같이 매우 많은 primitive 값을 반복 처리해야 한다고 해보자. for (int i = 0; i < 100_000_000; i++) { // 단순 계산 } 이런 코드에서는 각 연산 자체가 매우 작기 때문에 Stream pipeline의 추가 비용이 전체 실행 시간에서 차지하는 비중이 커질 수 있다. 특히 코딩테스트에서는 시간 제한 메모리 제한 이 명확하게 존재한다. 따라서 단순 반복문으로 쉽게 해결할 수 있다면 Stream을 굳이 사용하는 것이 이득이 아닐 수 있다. 12. 코딩테스트와 실무에서는 최적화 기준이 다르다 코딩테스트에서는 보통 다음을 중요하게 생각한다. 정답 ↓ 시간 제한 ↓ 메모리 제한 ↓ 구현 안정성 코드가 한 번 실행되고 정답과 실행 시간이 평가된다. 반면 실무에서는 코드가 한 번 작성된 뒤 오랜 기간 유지된다. 그래서 다음 요소의 중요성이 훨씬 커진다. 정확성 가독성 유지보수성 변경 용이성 적절한 성능 예를 들어 두 방식의 처리 시간이 다음과 같다고 가정해 보자. for문 : 0.03ms Stream : 0.05ms 수치만 보면 Stream은 꽤 느리다. 하지만 웹 요청 하나를 처리하는 전체 과정이 다음과 같다면 어떨까? DB 조회 25ms 외부 API 호출 100ms JSON 직렬화 2ms 비즈니스 로직 0.05ms 여기에서 0.02ms 를 줄이는 것보다 코드의 의미를 명확하게 만드는 것이 더 가치 있을 수 있다. 즉 실무에서의 성능 최적화는 무조건 가장 빠른 문법을 선택한다. 가 아니라 실제 병목이 되는 부분을 측정하고 필요한 곳을 최적화한다. 에 가깝다. 13. 온라인 코딩테스트의 실행 시간만으로 Java 성능을 단정하면 안 된다 코딩테스트 사이트에 같은 코드를 여러 번 제출하다 보면 실행 시간이 다르게 나오는 경우도 있다. 따라서 A 풀이: 0.4ms B 풀이: 0.7ms 가 한두 번 나왔다고 해서 A는 언제나 B보다 정확히 이만큼 빠르다. 라고 일반화하면 안 된다. JVM에서는 JIT 컴파일 GC 실행 환경 워밍업 여부 측정 방식 등 여러 요소가 영향을 줄 수 있다. Java 코드의 미세한 성능 차이를 제대로 비교하려면 일반적으로 JMH(Java Microbenchmark Harness) 같은 벤치마크 도구를 사용하는 것이 적절하다. 코딩테스트 결과는 이 문제와 이 채점 환경에서 어떤 구현이 더 빠르게 측정되었는가 정도로 받아들이는 것이 좋다. 14. 코딩테스트에서는 어떻게 선택할까? 개인적으로는 다음 정도의 기준을 가져갈 수 있을 것 같다. 단순 반복 작업 for (...) 반복문을 우선 고려한다. 특히 배열의 인덱스를 직접 사용하거나 상태를 갱신해야 한다면 반복문이 자연스럽다. 컬렉션을 단순 변환하는 문제 입력 크기가 작고 Stream이 훨씬 간단하다면 Stream을 사용해도 된다. list.stream() .filter(...) .map(...) .toList(); primitive 연산 Stream을 사용한다면 가능하면 IntStream LongStream DoubleStream 을 활용해 불필요한 Boxing을 피할 수 있는지 확인한다. .boxed() 가 필요한 경우 .boxed() 가 등장한다면 정말 객체 Stream이 필요한지를 한 번 더 생각해 볼 수 있다. 15. 실무에서는 어떻게 선택할까? 실무에서는 기준이 조금 달라진다. 다음처럼 Stream으로 데이터 처리 의도를 명확하게 표현할 수 있다면 좋은 선택이 될 수 있다. List<OrderResponse> responses = orders.stream() .filter(Order::isCompleted) .map(OrderResponse::from) .toList(); 반대로 Stream 내부에서 상태를 계속 변경해야 한다. 복잡한 분기문이 많다. 여러 개의 외부 변수를 수정한다. 성능이 실제 병목으로 확인됐다. 와 같은 상황이라면 반복문이 더 적절할 수 있다. 결국 for문 vs Stream 중 하나가 항상 우월한 것이 아니다. 두 방식이 표현하기 좋은 문제가 서로 다르다. 정리 처음에는 단순히 Stream을 사용하면 코딩테스트에서 느리다. 정도로 생각했다. 하지만 실제로 살펴보면 성능 차이에는 여러 요소가 섞여 있다. Stream pipeline의 추가 비용 Boxing / Unboxing 객체 생성 primitive와 Wrapper의 차이 정렬이나 Collection 생성 비용 JVM 최적화 그리고 Stream을 평가할 때 성능만 보면 Stream이 만들어진 이유를 놓치게 된다. Stream의 가장 큰 가치는 데이터 처리 과정을 선언적으로 표현할 수 있다는 것 에 있다. 따라서 선택 기준은 다음처럼 정리할 수 있다. 코딩테스트 → 단순하고 성능이 중요한 반복은 for문을 우선 고려 → Stream이 훨씬 명확하고 입력이 작다면 사용할 수 있음 실무 → 데이터 변환 pipeline이라면 Stream이 매우 유용 → 상태 변경이 많거나 성능 병목이라면 반복문 고려 그리고 무엇보다 중요한 것은 Stream은 느리니까 쓰지 않는다 혹은 Stream이 코드가 짧으니까 항상 사용한다 와 같이 한쪽으로 결론 내리지 않는 것이다. 코드가 무엇을 표현하려는지, 데이터 크기는 어느 정도인지, 성능이 실제로 중요한 부분인지에 따라 적절한 방식을 선택하는 것이 더 중요하다.
알고리즘 Cheat Sheet 시리즈는 코딩 테스트를 풀다가 "이거 자바에선 뭐였지?", "파이썬은 어떻게 했더라?" 싶을 때 바로 펼쳐 보려고 만든 개인 참고용 정리입니다. Java와 Python을 나란히 놓고, 문법 차이 때문에 실수하기 쉬운 부분만 짧게 정리합니다. 이번 주제는 형변환, 비교, 정렬 입니다. 입력을 파싱하고, 값을 비교하고, 원하는 기준으로 정렬하는 건 거의 모든 문제에서 반복되는 작업이므로 꼭! 숙지할 필요가 있습니다. 1. 형변환 문자열 ↔ 숫자 변환 자바 파이썬 문자열 → 정수 Integer.parseInt("123") int("123") 문자열 → 실수 Double.parseDouble("3.14") float("3.14") 정수 → 문자열 String.valueOf(123) / Integer.toString(123) str(123) 실수 → 문자열 String.valueOf(3.14) / Double.toString(3.14) str(3.14) 정수 → 실수 double d = 123; (자동) / (double) i float(123) 실수 → 정수 (int) 3.14 → 3 int(3.14) → 3 int i = Integer.parseInt("123"); double d = Double.parseDouble("3.14"); String s = String.valueOf(123); int t = (int) 3.99; // 3 (소수점 버림) i = int("123") d = float("3.14") s = str(123) t = int(3.99) # 3 (소수점 버림) 문자 ↔ 숫자 (코테 단골) 변환 자바 파이썬 숫자 문자 → 정수 '7' - '0' → 7 int('7') → 7 정수 → 숫자 문자 (char) (7 + '0') → '7' str(7) → '7' 문자 → 아스키 코드 (int) 'a' → 97 ord('a') → 97 아스키 코드 → 문자 (char) 97 → 'a' chr(97) → 'a' 알파벳 인덱스 c - 'a' ord(c) - ord('a') 형변환 체크포인트 소수 문자열을 바로 정수로 바꾸면 에러가 난다. 자바 Integer.parseInt("3.14") → NumberFormatException , 파이썬 int("3.14") → ValueError . 실수로 바꾼 뒤 정수로 변환해야 한다: (int) Double.parseDouble("3.14") , int(float("3.14")) . (int) , int() 는 반올림이 아니라 0 방향 버림이다. (int) -3.7 과 int(-3.7) 은 둘 다 -3 이다. 내림이 필요하면 Math.floor() / math.floor() , 반올림은 Math.round() / round() 를 쓴다. 파이썬 round() 는 사사오입이 아니다. 은행가 반올림이라 round(2.5) 는 2 , round(3.5) 는 4 다. 나눗셈 결과 타입이 다르다. 연산 자바 파이썬 7 / 2 3 (정수끼리면 정수) 3.5 (항상 실수) 정수 몫 7 / 2 → 3 7 // 2 → 3 음수 몫 -7 / 2 → -3 (0 방향) -7 // 2 → -4 (내림) 음수 나머지 -7 % 2 → -1 -7 % 2 → 1 2. 비교 비교 상황 자바 파이썬 기본형 비교 Integer.compare(x, y) Character.compare(c1, c2) Double.compare(d1, d2) x < y , x == y 연산자 그대로 객체 기본 기준 x.compareTo(y) x < y (문자열은 사전순) 문자열 같은지 s1.equals(s2) s1 == s2 compare / compareTo 의 반환값은 음수(앞이 작음), 0(같음), 양수(앞이 큼) 이다. 비교 체크포인트 자바에서 문자열은 == 로 비교하면 안 된다. == 는 값이 아니라 참조(주소)를 비교한다. 반드시 equals() 를 쓴다. Integer 객체도 == 로 비교하면 안 된다. -128 ~ 127 범위만 캐시되어 있어서 그 밖의 값은 같은 숫자여도 false 가 나올 수 있다. equals() 나 intValue() 로 비교한다. 비교 함수에 a - b 를 쓰지 말자. 정수는 오버플로우가 날 수 있고( Integer.MIN_VALUE - 1 ), 실수는 int 로 잘리면서 오차가 생긴다. Integer.compare(a, b) 를 쓴다. 3. 정렬 기본 정렬 상황 자바 파이썬 배열 오름차순 Arrays.sort(arr) arr.sort() 리스트 오름차순 Collections.sort(list) / list.sort(null) lst.sort() 새 리스트로 정렬 stream().sorted() sorted(lst) 내림차순 Arrays.sort(arr, Collections.reverseOrder()) arr.sort(reverse=True) 기준을 정해서 정렬 상황 자바 (람다) 파이썬 ( key ) 오름차순 (a, b) -> a.compareTo(b) 기본값 내림차순 (a, b) -> b.compareTo(a) reverse=True 길이순 (a, b) -> Integer.compare(a.length(), b.length()) key=len 첫 원소 기준 (a, b) -> Integer.compare(a[0], b[0]) key=lambda x: x[0] 1순위 오름, 2순위 내림 아래 코드 참고 key=lambda x: (x[0], -x[1]) 자바는 (a, b) 순서면 오름차순, (b, a) 로 뒤집으면 내림차순 으로 외우면 된다. // 2차원 배열: 첫 번째 값 오름차순, 같으면 두 번째 값 내림차순 Arrays.sort(arr, (a, b) -> { if (a[0] != b[0]) return Integer.compare(a[0], b[0]); return Integer.compare(b[1], a[1]); }); // Comparator 체이닝으로도 가능 list.sort(Comparator.comparing((int[] a) -> a[0]) .thenComparing(a -> a[1], Comparator.reverseOrder())); arr.sort(key=lambda x: (x[0], -x[1])) 파이썬은 튜플을 key 로 주면 앞 원소부터 차례로 비교한다. 숫자 기준 내림차순은 - 만 붙이면 된다. 특수 정렬: 이어 붙였을 때 가장 큰 수 ["3", "30", "34", "5", "9"] → "9534330" 처럼 두 값을 붙여 보고 더 큰 쪽을 앞에 두는 정렬이다. String[] strs = {"3", "30", "34", "5", "9"}; Arrays.sort(strs, (a, b) -> (b + a).compareTo(a + b)); String answer = String.join("", strs); // "9534330" from functools import cmp_to_key strs = ["3", "30", "34", "5", "9"] strs.sort(key=cmp_to_key(lambda a, b: int(b + a) - int(a + b))) answer = "".join(strs) # "9534330" 파이썬은 key 만 받기 때문에, 두 값을 직접 비교하는 함수는 cmp_to_key 로 감싸야 한다. 정렬 체크포인트 자바 int[] 는 람다나 reverseOrder() 로 정렬할 수 없다. 비교자를 받는 Arrays.sort 는 Integer[] , String[] , int[][] 같은 객체 배열에만 쓸 수 있다. int[] 를 내림차순으로 정렬하려면 박싱하거나, 오름차순 정렬 후 뒤에서부터 읽는다. Integer[] boxed = Arrays.stream(arr).boxed().toArray(Integer[]::new); Arrays.sort(boxed, Collections.reverseOrder()); 파이썬 list.sort() 는 None 을 반환한다. arr = arr.sort() 라고 쓰면 arr 이 None 이 된다. 새 리스트가 필요하면 sorted() 를 쓴다. 두 언어 모두 객체 정렬은 안정 정렬이다. 기준 값이 같으면 원래 순서가 유지되므로, 정렬을 여러 번 나눠서 해도 된다. 💡 이것만 기억하자 소수 문자열 → 정수는 두 단계. 실수로 바꾼 다음 정수로. (int) 와 int() 는 0 방향 버림이다. 나눗셈이 다르다. 자바 / 는 정수끼리면 정수, 파이썬 / 는 항상 실수. 음수 몫은 자바 -3 , 파이썬 // 는 -4 . 자바 문자열· Integer 비교는 equals() . == 는 주소 비교다. 비교 함수에서 a - b 대신 Integer.compare(a, b) . 정렬 기준: 자바는 (a, b) 오름 / (b, a) 내림, 파이썬은 key= 와 튜플, - 붙여서 내림. 자바 int[] 는 비교자 정렬 불가. Integer[] 로 박싱하자.
2. Victim Caches Victim cache holds data that has been deleted from the cache, in case it is needed again This is fully associative cache and can help to reduce misses with direct-mapped or set associative caches A victim cache is a small buffer that sits between a cache and the next level of the memory hierarchy. It catches blocks the main cache has just evicted, so that if the processor asks for one of them again soon, it can be recovered quickly instead of fetched from the slower level below. How it works On a cache miss, the CPU looks in the main cache and, at the same time (or on the very next step), in the victim cache. If the block is in the victim cache (a victim hit), it is swapped with the block currently occupying that line in the main cache. The evicted block goes into the victim cache, and the requested block moves into the main cache. The penalty is only a cycle or two, far less than going to L2 or memory. If it misses in both, the block is fetched from the next level and placed in the main cache. Whatever it displaces is moved into the victim cache, pushing out the oldest entry (typically FIFO or LRU replacement). So blocks flow one way in the normal case: main cache → victim cache → discarded. The swap on a victim hit is the exception. Key properties Small: typically 4 to 16 entries, each holding a full cache block. Fully associative: any evicted block can go in any entry, and lookups compare against all tags in parallel. That is only affordable because it is so small. Holds only evicted blocks: it is not filled directly from memory, so it complements the main cache rather than duplicating it. Exclusive in spirit: a block lives in either the main cache or the victim cache, not both, thanks to the swap. The three cases to remember: Main cache hit: normal fast path, nothing else happens. Main miss, victim hit: swap the two blocks, so the requested block moves into the main cache and the evicted one takes its place in the victim cache. Miss in both: fetch from the next level into the main cache, and the displaced block goes into the victim cache (the oldest victim entry is dropped if it's full). only after update victim cache evict RLL = Read from Lower Level (fetching a block from memory) and WLL = Write to Lower Level (sending data down to memory). These are the two kinds of memory traffic that write policies and victim caches change. If your course defines them differently, tell me and I'll adjust. The four policy terms Two separate questions decide how a cache handles writes: On a write hit, when does memory get updated? Write-back (WB): only the cache is updated, and the block is marked dirty. Memory is updated later, when the dirty block is evicted. Write-through (WT): the cache and memory are both updated on every write. Blocks are never dirty. On a write miss, do we bring the block into the cache? Write-allocate (WA): yes. Read the block from memory (an RLL), then write it in the cache. No-write-allocate (NWA): no. Send the write straight to memory (a WLL) and leave the cache unchanged. These are usually paired. Write-back and write-allocate go together because both try to do writes in the cache instead of in memory, and allocating means a later write to the same block will be a hit. Write-through works naturally with no-write-allocate: every write goes to memory anyway, so loading the block on a write miss gains little Where the victim cache fits The victim cache sits between the main cache and memory and catches evicted blocks. Whether those blocks are dirty depends on the write policy: With WB-WA: evicted blocks may be dirty, so each victim-cache entry needs its own dirty bit. The victim cache becomes a waiting area for dirty data. A dirty block goes to memory (WLL) only when it is pushed out of the victim cache. If it is reused first, the write-back never happens. With WT-NWA: every block is clean, because memory is always up to date. A block leaving the victim cache is simply discarded, with no WLL. The victim cache saves only reads (RLLs). In both policies, a victim-cache hit is a swap. It saves an RLL, and in WB it can also save a WLL. Pick a policy and a situation below to see which paths are used and how many RLLs and WLLs happen. Takeaways for exams WB-WA pays for writes lazily. Repeated writes to one block cost zero WLL until that block finally leaves the victim cache. The victim cache gives dirty blocks a second chance, so it can save both RLLs and WLLs. WT-NWA pays one WLL on every write, hit or miss, and its victim cache can only save RLLs. In return, memory is always up to date and evictions are free. Real designs add a write buffer so the CPU doesn't stall waiting for each WLL. In both policies, a WB-WA write miss costs an RLL (allocate), while a WT-NWA write miss costs a WLL (bypass). Large last-level caches are often managed the same way. L3 is sometimes run as a victim cache, filled only with lines displaced from L2 (for example, AMD Barcelona and Apple A9). Handling dirty lines is a key design issue there too.
Announcing runtime instances in Amazon Bedrock AgentCore—persistent, managed EC2 infrastructure for production AI agents with multi-agent collaboration, GPU support, and sessions lasting up to 14 days.