배경 / 문제 상황 전광판(LCD)에 광고를 어떤 순서로 재생할지 정하는 "편성"을 관리하는 기능이 있다. 지금까지는 편성을 만들거나 바꿔도 LCD 단말이 그걸 알 방법이 없어서, 단말이 주기적으로 폴링해서 바뀐 걸 알아채는 구조였다. 이번에 편성이 생성·수정·삭제되는 시점에 대상 LCD로 "갱신됐다"는 신호를 MQTT로 즉시 보내는 기능을 연결했다. 단순히 "저장 성공하면 발행 한 번 호출" 정도로 끝날 줄 알았는데, 막상 만들다 보니 "언제 대상 목록을 확보해야 하는가", "발행이 실패하면 저장 결과는 어떻게 되어야 하는가" 같은 타이밍 문제가 더 까다로웠다. 접근 방법 가장 먼저 정한 건 신호의 내용이었다. 편성 데이터 자체를 MQTT payload에 실어 보내는 대신, "바뀌었다"는 신호만 보내고 단말이 REST API로 다시 조회하게 했다. MQTT는 트리거 역할만 하고 실제 데이터는 기존 REST 계약이 그대로 담당하는 구조다. 이렇게 하면 payload 크기나 포맷을 신경 쓸 필요가 없고, 단말이 신호를 못 받아도 다음 폴링 때 REST로 알아서 따라잡을 수 있다. 그다음 문제는 "발행이 실패하면 편성 저장(mutation)도 실패로 봐야 하는가"였다. 저장은 이미 DB에 커밋된 상태인데 발행 실패로 GraphQL 에러를 던지면, 관리자 입장에서는 "저장이 안 됐구나" 오해하고 같은 편성을 다시 등록할 수 있다. 그래서 발행은 저장 결과에 영향을 주지 않기로 했다 — 발행 모듈이 아예 연결 안 되어 있어도( getDownlinkPublisher() 가 undefined) 조용히 스킵하고, 발행 중 에러가 나도 로그만 남기고 저장 응답은 그대로 성공을 돌려준다. 실제로 막힌 부분은 수정(update)과 삭제(delete)에서 "언제 대상 LCD 목록을 조회해야 하는가"였다. 수정 : 편성의 대상 LCD를 바꾸는 경우, DB 트랜잭션은 기존 대상 행을 지우고 새 대상 행을 넣는다. 트랜잭션이 끝난 뒤에 대상을 조회하면 이미 새 목록만 남아 있어서, "이번에 빠진 LCD가 누구인지"를 영영 알 수 없다. 빠진 LCD에도 신호를 보내야 그 화면이 옛 편성을 계속 트는 대신 재조회를 시도하기 때문에, 이 목록은 트랜잭션을 시작하기 전에 미리 확보해둬야 했다. 삭제 : 반대로 삭제는 순서가 뒤집힌다. 신호를 먼저 보내고 삭제가 실패하면, 단말은 이미 지워진 것처럼 재조회를 시도하는데 서버에는 편성이 그대로 남아있는 상태가 된다. 그래서 삭제는 DB에서 완전히 확정된 뒤에만 신호를 보내도록 했다. 마지막으로, 편성 이름이나 설명(description)만 바뀐 경우는 발행을 생략했다. 이 필드들은 단말이 재생하는 내용에 영향을 주지 않으므로, 매번 발행할 이유가 없었다. 구현하면서 참고한 기존 코드는 lcdNotice.publish.service.ts 에 있던 "교체 전/후 대상을 비교해서 빠진 대상을 찾는" 로직이었다. 거의 그대로 재사용할 수 있었지만, 3줄 남짓한 짧은 로직 때문에 서로 다른 도메인(공지 vs 편성) 모듈을 의존 관계로 묶고 싶지 않아서 그대로 복붙하듯 별도로 다시 구현했다. Before / After Before (편성 저장만 하던 코드, updateAdSchedule 일부): const updated = await withPgTransaction(async (client) => { const updateSql = await mybatisMappers('adScheduleWriteMapper', 'updateAdSchedule', { ... }); // ... 대상 LCD 행을 지우고 새로 넣는 처리 }); if (!updated) return buildResponse('IS_NOT_EXIST_DATA'); return await getAdScheduleDetail(args.adScheduleId, companyId); After (발행 연결 후): // 🔴 교체 전 대상을 여기서 잡는다. 트랜잭션이 대상 행을 지우고 새로 넣으므로, 그 뒤에 // 조회하면 "빠진 단말"을 영영 알 수 없다. const targetsBefore = hasTargetLcdIds ? await loadAdSchedulePublishTargets(args.adScheduleId) : []; const updated = await withPgTransaction(async (client) => { // ... 기존 저장 로직 동일 }); if (!updated) return buildResponse('IS_NOT_EXIST_DATA'); // 방송기간(items) 또는 대상(targetLcdIds) 중 하나라도 바뀌었을 때만 발행한다 — // scheduleName/description만 바뀌면 단말에 영향이 없으므로 생략한다. if (hasItems || hasTargetLcdIds) { const version = await loadPlaylistVersion(args.adScheduleId); const publisher = getDownlinkPublisher(); const afterRows = await loadAdSchedulePublishTargets(args.adScheduleId); if (hasTargetLcdIds) { const removed = diffRemovedTargets(targetsBefore, afterRows); if (removed.length > 0) await publishAdSchedulePlaylist(removed, version, publisher); } await publishAdSchedulePlaylist(afterRows, version, publisher); } return await getAdScheduleDetail(args.adScheduleId, companyId); 삭제 쪽도 같은 원칙(대상은 미리 확보, 신호는 확정 후 발송)으로 구성했다: // 🔴 삭제 전에 대상을 확보한다. deleteAdSchedule은 is_active='F'로 만들고 발행 대상 // 조회는 활성 대상만 보므로, 삭제 후에는 어느 단말에 신호를 보내야 하는지 알 수 없다. const [targets, playlistVersion] = await Promise.all([ loadAdSchedulePublishTargets(adScheduleId), loadPlaylistVersion(adScheduleId), ]); const result = await queryDatabase(sql); // 실제 삭제 실행 if (result.rowCount !== 1) return buildResponse('IS_NOT_EXIST_DATA'); // 삭제가 확정된 뒤에 신호를 보낸다. 순서를 뒤집으면 삭제가 실패했는데 단말만 재조회를 // 시도하는 상태가 될 수 있다. await publishAdSchedulePlaylist(targets, playlistVersion, getDownlinkPublisher()); 결과 생성/수정(대상 변경 포함)/삭제 각각을 로컬에서 실행해 대상 LCD와 대상에서 빠진 LCD 모두에 신호가 발행되는지, 발행 모듈을 일부러 끊어놓았을 때도 저장 자체는 정상 응답을 반환하는지 확인했다. 배운 점 / 트레이드오프 "저장은 트랜잭션, 발행은 그 결과에 의존하는 후속 작업"이라는 조합에서는, 실제 로직의 난이도가 "발행을 어떻게 호출할까"가 아니라 "발행에 필요한 정보(대상 목록)를 트랜잭션의 어느 시점에 붙잡아 둘까"에 있었다. 트랜잭션이 상태를 지우고 다시 쓰는 구조라면, 그 상태에 의존하는 후속 작업은 트랜잭션 전후로 스냅샷을 따로 챙겨야 한다는 걸 체감했다. 발행 실패를 저장 실패와 분리한 판단은 "사용자가 오해해서 중복 생성하는 상황"을 막기 위한 것이었는데, 반대급부로 발행이 조용히 실패해도 겉으로는 아무 문제 없어 보인다. 지금은 로그만 남기고 있어서, 발행 실패가 누적되는지 감시할 별도 지표는 아직 없다 — 운영 중 발행 실패율을 볼 방법은 다음 과제로 남는다. 기존 코드( lcdNotice.publish.service.ts )와 거의 같은 로직을 굳이 재사용하지 않고 다시 짠 선택은 지금 시점에는 맞다고 보지만, 나중에 발행 대상이 세 번째 도메인으로 늘어나면 그때는 공통 유틸로 뽑는 게 나을 수도 있다. 지금은 두 곳뿐이라 추상화 비용이 재사용 이득보다 커 보여서 미뤘다.
Dubai's business landscape moves fast, and so do the systems that run it. As more companies across trading, manufacturing, retail, and services digitize their operations, the number of erp software companies in dubai promising to solve every problem has grown just as quickly. The real challenge isn't finding an ERP vendor — it's finding the right one for your business. This guide walks through what actually matters when evaluating erp software companies in dubai, how to tell a genuine implementation partner from a reseller, and what UAE businesses should expect from a modern erp solutions provider in 2026. Why So Many Businesses Struggle to Choose an ERP Provider Most ERP buying decisions go wrong for the same reason: businesses shop for software first and a partner second. An ERP system is only as good as the team that configures, deploys, and supports it. Two companies can sell the exact same platform and deliver completely different results, because implementation quality — not the software license — determines whether the system actually fits how your business works. This is why UAE businesses researching erp solution providers in uae should treat the search as a partner evaluation, not a software comparison. A demo can make almost any platform look capable. What it can't show you is how the provider handles the messy parts of a real rollout — data migration, staff training, and the inevitable edge cases your business runs into that no demo ever covers. What to Look For in ERP Software Companies in Dubai Industry-specific implementation experience A generic ERP rollout rarely survives contact with real operations. Look for a provider that has implemented systems for businesses similar to yours — manufacturing, trading, fisheries, healthcare, or professional services all have different workflows, and a provider familiar with your sector will configure the system faster and with fewer costly revisions. Local support, not just local sales Many so-called erp software solutions in dubai are sold by resellers who hand off support to an overseas team once the contract is signed. Ask directly: who handles support after go-live, and where are they based? Time-zone-aligned, UAE-based support matters when a process breaks during business hours and you need a same-day fix. Transparent pricing with no hidden modules Some vendors quote an attractively low base price, then charge extra for modules that most businesses consider essential — reporting, multi-currency, or e-invoicing compliance. A trustworthy erp solutions provider will scope the full cost upfront, including what happens if you add users or modules later. Proven UAE VAT and compliance handling UAE-specific compliance requirements — VAT reporting, e-invoicing readiness, and bilingual Arabic-English documentation — aren't optional extras. Confirm the provider has handled this for other UAE clients, not just in theory. A clear, documented implementation roadmap Ask for a project plan before you sign anything. A provider that can't lay out phases, timelines, and responsibilities in writing is not ready to manage your rollout. What Makes a Provider Genuinely "Best in UAE" Search results are full of companies claiming to be the best erp software in uae, but the claim only means something when it's backed by delivery. In practice, businesses that consistently rate their ERP provider highly tend to share three things in common with their partner: The provider recommends the right level of customization instead of over-engineering the system Training is built into the rollout, not treated as an afterthought Post-launch support is proactive, catching issues before they disrupt operations These are harder to judge from a website than from a technical spec sheet, which is why speaking with existing clients — not just reading testimonials — is worth the extra step before signing with any best erp software solution provider in dubai. Odoo as an ERP Platform for UAE Businesses Among the platforms available through erp software companies in dubai, Odoo has become one of the most widely adopted, largely because its modular structure lets businesses start with core finance and inventory modules and add manufacturing, CRM, or HR later without replacing the system. For small and mid-sized UAE businesses in particular, this reduces the upfront cost and risk that comes with large, rigid ERP platforms. The platform itself, though, is only half the equation. The value of an erp software solutions in dubai engagement comes from how well the implementation partner configures Odoo around your actual processes — chart of accounts, approval workflows, stock valuation methods, and reporting — rather than deploying it out of the box and leaving you to figure out the rest. Cloud vs On-Premise: What UAE Businesses Are Choosing Most UAE businesses evaluating erp solutions in 2026 are choosing cloud-hosted deployments over on-premise servers, largely for three reasons: lower upfront hardware cost, easier remote access across multiple branches or warehouses, and simpler compliance updates when regulations change. On-premise still makes sense for businesses with strict data residency requirements or existing IT infrastructure they want to keep using, but it's increasingly the exception rather than the default choice. Red Flags to Watch for During the Sales Process Before comparing pricing sheets, it helps to watch how each of the erp software companies in dubai behaves during the sales process itself — it's often a preview of how they'll behave after the contract is signed. Pressure to sign quickly. A provider pushing for a fast decision on a system your business will run for years is prioritizing their quarter over your fit. Vague answers about support ownership. If nobody can clearly say who handles a support ticket six months from now, assume the answer is "whoever picks up the phone." Demos that avoid your actual data. A demo built entirely on the vendor's sample data looks impressive but proves nothing about how the system will handle your real processes. No mention of training. If training isn't part of the proposal, it usually means the provider expects your team to figure the system out on their own after go-live. None of these are dealbreakers on their own, but two or more together are worth a direct conversation before moving forward with any erp solution providers in uae. Questions to Ask Before Signing With an ERP Provider Before committing to any of the erp solution providers in uae, get clear answers to: What does the implementation timeline actually look like, phase by phase? Who is our named point of contact after go-live? What happens if we need a customization outside the standard modules? How is support pricing structured after the first year? Can we speak with two or three existing clients in our industry? A provider confident in their delivery will answer all five without hesitation, and without pushing you toward a bigger package than you asked about. IdaA ERP: A Dubai-Based Odoo Partner IdaA ERP works with UAE businesses as an Odoo implementation and support partner, focusing on configuring the platform around each client's actual workflows rather than a one-size-fits-all rollout. From initial scoping through go-live and ongoing support, the goal is a system your team actually uses — not one that sits half-configured after the first month. If you're comparing erp software companies in dubai and want a straightforward conversation about what implementation would look like for your business, IdaA ERP is happy to walk through it.
Recap 포트폴리오 선택은 투자 가능한 조합 중 효율적인 조합을 찾고, 그 중 자신의 위험선호에 맞는 조합을 고르는 과정. 수익은 기대수익률 , 위험은 수익률의 표준편차 로 나타냄 1. 위험자산만으로 투자하는 경우 1-1. 지배원리와 효율적 투자선 여러 위험자산을 서로 다른 비중으로 결합하면 다양한 포트폴리오가 만들어짐. 이때 다음 지배원리 로 열등한 조합을 제외함. 위험이 같다면 기대수익률이 높은 포트폴리오를 선택 기대수익률이 같다면 위험이 낮은 포트폴리오를 선택 이렇게 남은 포트폴리오들이 Markowitz 효율적 투자선(Efficient Frontier) 을 이룸. 투자자는 자신의 무차별곡선 이 이 투자선에 접하는 지점에서 최적 포트폴리오를 고름. 1-2. 효용함수 최대화 (MVO) CAPM의 기반인 Markowitz 평균-분산 최적화는 다음 효용함수를 최대화하는 비중을 구함. $$\max_w \quad w'\mu - \frac{\lambda}{2} w'\Sigma w$$ $\mu$: 기대수익률 벡터 $\Sigma$: 공분산 행렬 $\lambda$: 위험회피계수 ($\lambda$가 크면 안전 지향, 작으면 공격 지향) 1차 조건(FOC)을 풀면 최적 비중: $$w^* = \frac{1}{\lambda} \Sigma^{-1} \mu$$ ** 직관적 해석** 기대수익률이 높고($\mu$ 큼) 다른 자산과 상관이 낮은($\Sigma^{-1}$에서 해당 성분이 큰) 자산에 더 많은 비중을 배분한다는 뜻임. MVO의 한계 기대수익률 추정치를 조금만 바꿔도 최적 비중이 극단적으로 변하는 불안정성 존재 공매도($w_i < 0$)를 빈번하게 추천 — 현실 제약과 충돌 2. 무위험자산을 포함하는 경우 별개의 과정이라기보다, 위험자산만 투자하는 경우에 추가적인 과정이 포함된다고 이해하면 쉬움 2-1. 무위험자산의 특성 무위험자산은 분석 기간의 수익률 $R_f$가 확정되어 있다고 가정. 이에 따라 수익률의 표준편차는 $0$임. (수익-위험 사분면의 y축에 표시 가능) 2-2. 자본배분선 (CAL, Capital Allocation Line) 무위험자산과 어떤 위험자산 포트폴리오 P를 결합하면, 위험·수익 그래프에서 두 점을 잇는 직선 위에 투자 조합이 만들어짐. 무위험자산에 $(1-a)$, 위험 포트폴리오 P에 $a$를 배분하면: $$E(R) = R_f + a \cdot [E(R_P) - R_f]$$ $$\sigma = a \cdot \sigma_P$$ 두 번째 식에서 $a = \sigma / \sigma_P$를 첫 번째 식에 대입하면: $$E(R) = R_f + \frac{E(R_P) - R_f}{\sigma_P} \cdot \sigma$$ 이것이 CAL의 직선 방정식이며, 기울기는: $$\text{기울기} = \frac{E(R_P) - R_f}{\sigma_P} = \text{Sharpe Ratio}$$ 2-3. 접점포트폴리오 (Tangency Portfolio) 무위험자산에서 출발하여 효율적 프론티어에 접하는 직선이 기울기(샤프비율)가 가장 큰 직선 — 이를 Best Possible CAL 이라 하고, 접점이 접점포트폴리오(Tangency Portfolio) 임. ** 무위험수익률이 달라지면 접점도 달라질까?** 위험자산의 투자 가능 집합이 같아도 $R_f$가 달라지면 자본배분선의 출발점이 바뀜. 따라서 샤프비율을 최대화하는 접점포트폴리오도 달라질 수 있음 . CAPM의 기본 모형은 모든 투자자가 동일한 무위험수익률을 이용한다고 가정하기 때문에, 서로 다른 기간이나 통화의 상품을 무위험자산으로 정해 비교할 때는 먼저 그 조건을 맞춰야 함. 3. Tobin의 분리정리 무위험자산을 이용할 수 있고 앞선 가정이 성립하면 투자 결정을 두 단계로 나눌 수 있음. 1단계 — 위험자산 조합 선택 : 효율적 투자선을 찾고, 샤프비율이 최대인 접점포트폴리오를 정함 2단계 — 최종 투자비중 선택 : 자신의 위험선호에 따라 접점포트폴리오와 무위험자산의 비중을 정함 CAPM의 시장균형 가정까지 더하면 이 공통 위험자산 포트폴리오가 시장포트폴리오(M) 가 됨. 4. CAPM (Capital Asset Pricing Model) 4-1. 기본 가정 CAPM은 자산의 위험과 기대수익률이 균형에서 어떻게 연결되는지 설명하는 모형. 접점포트폴리오 = 시장포트폴리오 라는 결론은 다음 가정에서 도출됨. 투자자는 같은 기간의 기대수익률·분산·공분산을 기준으로 포트폴리오를 선택하고, 동일한 정보로 같은 기대를 형성함 무위험이자율로 빌리거나 빌려줄 수 있고, 거래비용·세금 등의 시장 마찰이 없으며, 투자자는 가격을 주어진 것으로 받아들임 위험자산은 나누어 거래할 수 있고, 투자 가능한 위험자산의 공급량은 주어져 있음 이 조건에서 투자자 모두가 같은 접점포트폴리오 를 선택 → 시장 전체에서 발행된 위험자산을 누군가 모두 보유해야 하므로 접점포트폴리오 = 시장포트폴리오 M ✚ 왜 CML은 직선인가? 무위험자산과 시장포트폴리오를 비율만 바꾸어 결합하면 기대수익률과 표준편차가 그 비율에 따라 함께 움직임 → 효율적 조합을 그리면 직선인 CML이 나타남. SML도 CAPM 균형식 때문에 직선. 4-2. 시장포트폴리오의 구성 이론상 시장포트폴리오는 시장에서 거래되는 모든 위험자산을 시장가치 비중대로 담은 포트폴리오 임. $$w_i = \frac{V_i}{\sum_j V_j}, \qquad R_m = \sum_i w_i R_i$$ 주식만 예로 들면 $V_i = \text{주가} \times \text{발행주식 수}$로 계산. 실무에서는 시가총액 가중 주가지수 를 대용치로 사용함. 5. 위험의 분류 5-1. 총위험 $$\text{총위험} = \text{체계적 위험} + \text{비체계적 위험}$$ 수익률을 $R_i = \alpha_i + \beta_i R_m + \varepsilon_i$로 나타낼 때, 잔차와 시장수익률이 무상관이면: $$\text{Var}(R_i) = \beta_i^2 \text{Var}(R_m) + \text{Var}(\varepsilon_i)$$ 5-2. 체계적 위험 (Market Risk) 분산해도 남는 위험 경기, 금리, 경제성장률처럼 시장 전반에 영향을 주는 요인에서 발생 여러 종목을 담아도 시장이 함께 움직이는 영향은 사라지지 않음 CAPM에서 시장으로부터 추가 수익률을 요구하는 대상 은 바로 이 위험 5-3. 비체계적 위험 (Idiosyncratic Risk) 분산으로 줄일 수 있는 위험 신기술 개발 성패, 경영진의 결정, 개별 기업의 재무·영업 문제 등에서 발생 충분한 분산투자로 줄일 수 있으므로 CAPM 균형에서는 비체계적 위험에 대한 추가 위험프리미엄을 받지 않음 6. CML (Capital Market Line, 자본시장선) 위험과 기대수익률의 선형관계를 보자는 게 CAPM의 컨셉. 그 위험이 총위험(표준편차) 일 경우의 선형식 $$E(R_p) = R_f + \frac{E(R_m) - R_f}{\sigma_m} \sigma_p$$ 기울기 $[E(R_m) - R_f] / \sigma_m$: 시장포트폴리오가 총위험 한 단위당 제공하는 초과기대수익률 무위험자산 비중 100% → 출발점 $(0, R_f)$ 시장포트폴리오 비중 100% → $(\sigma_m, E(R_m))$ ** 적용 범위 ** CML은 무위험자산과 시장포트폴리오를 결합한 효율적 포트폴리오에만 적용 됨. 개별 주식이나 비효율적 포트폴리오에는 적용 불가 → 이때는 SML을 사용 7. SML (Security Market Line, 증권시장선) CML의 가로축은 총위험(표준편차) . 개별자산의 요구수익률을 설명할 때는 시장과 함께 움직이는 위험(베타) 만 측정해야 함 → 가로축을 베타로 바꾼 것이 SML 그래프 가로축 세로축 적용 대상 CML 표준편차 $\sigma_p$ 기대수익률 $E(R_p)$ 무위험자산과 시장포트폴리오를 결합한 효율적 포트폴리오 SML 베타 $\beta_i$ 기대수익률 $E(R_i)$ 개별자산과 포트폴리오 7-1. 베타 (β) 공분산에서 출발: $$\text{Cov}(R_i, R_m) = \rho_{im} \sigma_i \sigma_m$$ 이를 시장수익률의 분산으로 나누어 시장 변화에 대한 민감도 로 나타낸 것이 베타: $$\beta_i = \frac{\text{Cov}(R_i, R_m)}{\text{Var}(R_m)}$$ 베타 해석 $\beta > 1$ 시장과 같은 방향으로 더 민감하게 반응 $\beta = 1$ 시장포트폴리오와 민감도 동일 $\beta < 1$ 시장보다 덜 민감하게 반응 $\beta < 0$ 시장과 반대 방향으로 반응 7-2. SML의 식 (CAPM 균형 기대수익률) $$E(R_i) = R_f + \beta_i \left[E(R_m) - R_f\right]$$ $R_f$: 무위험수익률 $E(R_m) - R_f$: 시장위험프리미엄 (SML의 기울기) $\beta_i$: 자산 $i$의 시장위험 민감도 ✚ 같은 표준편차라도 베타가 다르면 요구수익률이 달라짐 예를 들어 표준편차가 동일한 A, B 두 자산이 있어도 베타가 다르면 CAPM이 제시하는 요구수익률은 달라짐. 분산 가능한 비체계적 위험은 보상받지 못하기 때문. Q. 모든 투자자가 동일한 정보와 기대를 가진다는 CAPM 가정은 현실적인가? Q. CAPM의 베타는 과거 데이터로 추정하는데, 미래 베타도 같다고 볼 수 있을까? 참고 문헌 Markowitz, H., 1952, Portfolio selection, Journal of Finance , 7(1), 77–91. Black, F. & Litterman, R., 1992, Global portfolio optimization, Financial Analysts Journal , 29–43. Cover, T. M., 1991, Universal portfolio, Mathematical Finance , 1(1), 1–29. KSIF 2017 Annual Report
이제 메일 발송 쪽은 어느 정도 연결됐다. 근데 수신자를 관리하려면 아직 문제가 하나 남아 있었다. 등록도 조회도 비활성화도 전부 API로 해야 했다. 개발할 때는 API만 있어도 확인할 수 있지만 실제로 업무에서 쓰려면 이건 좀 불편했다. 그래서 이번에는 알림 수신자를 관리할 수 있는 화면을 따로 만들었다. 어디에서 관리하게 할까? 처음에는 외부 중요공지 화면 안에 수신자 관리를 같이 넣을까도 생각했다. 근데 공지를 확인하는 기능이랑 알림 받을 사람을 관리하는 기능은 성격이 조금 달랐다. 공지 화면에 다 넣으면 점점 복잡해질 것 같았다. 그래서 아예 메뉴를 하나 분리했다. <a class="app-nav-link" href="/notifications/"> 알림 관리 </a> 기존 메뉴도 홈 입찰공고 외부 중요공지 알림 관리 이렇게 네 개가 됐다. /notifications 로 들어가면 알림 관리 화면이 열리도록 연결했다. @GetMapping({"/notifications", "/notifications/"}) public String notificationSubscriberPage() { return "forward:/notifications/index.html"; } 화면에 뭘 보여줘야 하지? 수신자 목록만 보여주는 건 조금 부족해 보였다. 관리할 때는 지금 몇 명이 등록돼 있고 그중 실제로 알림을 받는 사람이 몇 명인지 바로 보고 싶었다. 그래서 상단에 전체 신청자 수 활성 신청자 수 비활성 신청자 수 를 먼저 보여주고 아래에는 신청자 목록을 배치했다. 목록에서는 이름 이메일 알림 유형 상태 등록일 관리 를 확인할 수 있게 했다. 전체 신청자와 활성 / 비활성 상태를 한 화면에서 확인할 수 있게 했다 등록은 모달로 만들었다 신청자를 추가할 때 다른 페이지로 이동하게 할 필요는 없다고 생각했다. 관리 화면에서 바로 등록하고 등록이 끝나면 다시 목록을 확인하는 흐름이 더 자연스러웠다. 그래서 + 신청자 등록 버튼을 누르면 모달이 열리도록 만들었다. 입력하는 값도 많지 않다. 이름 이메일 알림 유형 지금은 알림 유형이 PIA 중요공지 하나뿐이지만 기존에 만들어둔 notificationType 을 그대로 사용했다. 등록하면 앞에서 만든 API를 호출한다. await requestJson(API_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: elements.name.value.trim(), email: elements.email.value.trim(), notificationType: elements.notificationType.value }) }); 등록이 끝나면 모달을 닫고 목록을 다시 불러오게 했다. closeModal(); showToast("신청자를 등록했습니다."); await loadSubscribers(); 따로 새로고침하지 않아도 바로 등록 결과를 확인할 수 있다. 이미 등록된 이메일이면? 화면을 만들면서 정상적인 경우만 생각하면 안 됐다. 이미 등록된 이메일을 또 입력할 수도 있다. 23편에서 백엔드에서는 중복 등록을 막아놨다. 이번에는 그 결과를 화면에서도 알아볼 수 있게 했다. function registrationErrorMessage(error) { if (error instanceof ApiError && error.status === 409) { return "이미 등록된 이메일입니다."; } if (error instanceof ApiError && error.status === 400) { return error.message || "입력값을 확인해주세요."; } return "신청자를 등록하지 못했습니다. 잠시 후 다시 시도해주세요."; } 백엔드에서 409가 오면 사용자에게 그냥 오류라고 보여주는 게 아니라 이미 등록된 이메일입니다. 라고 알려준다. 이메일 형식이 잘못된 경우도 등록 요청을 보내기 전에 한 번 확인하도록 했다. 삭제보다 비활성화 수신자를 관리할 때 삭제 버튼을 만들 수도 있었다. 근데 앞에서 DB 구조를 만들 때부터 수신자를 삭제하지 않고 비활성화하기로 했다. 발송 대상에서는 제외하되 등록했던 정보는 남겨두기 위해서였다. 그래서 화면에서도 삭제 대신 비활성화 버튼을 만들었다. await requestJson( `${API_URL}/${encodeURIComponent(subscriber.id)}/disable`, { method: "PATCH" } ); 비활성화하기 전에 한 번 더 확인하도록 했다. if (!window.confirm( `${targetName} 신청자를 비활성화하시겠습니까?` )) { return; } 처리가 끝나면 목록을 다시 불러오고 상태도 바로 비활성 으로 바뀐다. API로만 가능했던 수신자 관리를 화면에서 처리할 수 있게 됐다 모바일에서는 표가 좀 애매했다 데스크톱에서는 표가 보기 편했다. 근데 모바일에서 이름과 이메일 알림 유형과 등록일까지 전부 표에 넣으니까 공간이 부족했다. 표를 억지로 줄이는 것보다 모바일에서는 카드 형태가 낫겠다고 생각했다. 그래서 화면 크기에 따라 데스크톱 → 테이블 모바일 → 카드 목록 으로 다르게 보여주도록 했다. 390px 화면에서도 가로 스크롤이 생기지 않는지 확인하는 테스트도 추가했다. 화면도 실제 동작까지 확인했다 화면만 그려놓고 끝내지는 않았다. Playwright 테스트에서 목록 조회 신규 수신자 등록 수신자 비활성화 중복 이메일 안내 신청자가 없을 때 빈 화면 모바일 카드 화면 을 확인하도록 했다. 특히 등록 후 전체 신청자 수가 바뀌는지 비활성화 후 상태가 실제로 변경되는지도 같이 확인했다. 이제 23편에서 만든 API를 직접 호출하지 않아도 알림 관리 화면 ↓ 신청자 등록 ↓ 활성 / 비활성 상태 확인 ↓ 필요하면 비활성화 이 흐름으로 관리할 수 있게 됐다. 처음에는 메일 한 통 보내는 기능에서 시작했는데 이제는 누가 알림을 받을지도 화면에서 관리할 수 있게 됐다.
9월 28일, 주니어 세 명과 준비하던 SaaS를 가오픈했다. 3월에는 이 회사에 책임 개발자로 들어왔다. 그사이에 CTO가 바뀌었고, 내가 그 자리를 맡았고, 팀원 한 명이 나갔다. 남은 사람들끼리 일하다 부딪히기도 했다. 입사하고 반년 동안 참 많은 일이 있었다. 3~5월 · 개발자로 들어왔는데 3월에 입사했을 때 나는 그냥 책임 개발자였다. SQL을 고치고 QA를 자동화하고, 도면에서 정보를 읽어오는 기능도 만들고 있었다. 내가 맡은 개발을 하던 시기였다. 그런데 몇 달 사이 회사 사정이 정신없이 바뀌었다. 전임 CTO가 해임됐고, 그다음 CTO를 내가 맡게 됐다. 6월부터는 팀을 이끌어야 했다. 인수인계는 없었다. CTO를 맡는다고 개발만 챙기면 되는 것도 아니었다. 서비스를 언제 어떻게 열지 정해야 하고, 주니어들은 어떻게 키울지 생각해야 하고, 경영도 알아야 했다. 어디서부터 해야 하나 싶었다. 6월 · 나도 막막한데 결정은 내가 해야 했다 당시 팀에는 1~2년차 주니어 네 명이 있었다. 이 넷이랑 오픈 준비를 어떻게 할 것인가. 내가 막막해하고 있는 동안에도 일은 해야 했다. 처음 같이 한 일이 캐시 작업이었다. 화면에서 같은 목록을 받으려고 서버에 요청을 계속 보내고 있어서, 받아온 걸 저장해 두고 다시 쓰게 만드는 일이었다. 서버 쪽 Redis와 브라우저 저장소를 같이 썼다. 주니어들은 Redis가 뭔지부터 알아야 했다. 브라우저에 값을 저장했는데 새로고침하면 없어졌다. 방법을 바꾸니 이번엔 새 탭에서 같은 걸 다시 불러왔다. 이것도 고치고, 데이터가 바뀌면 예전 값을 계속 쓰지 않게 하는 것까지 붙였다. 해보니까 안 되는 게 있어서 계속 바꿨다. 그 와중에 커밋은 중간중간 해야 하는지 다 끝내고 해야 하는지 묻는 글도 올라왔다. 서로 어디까지 했는지, 테스트는 해봤는지도 물었다. 한 주니어가 “저도 대단히 한건 없지만 열심히 해서 설명해보도록하겠습니다 ᄏᄏᄏ”라고 썼다. 같은 날 캐시 구조를 정리해서 올렸는데 제목 옆에는 ‘정확하진 않음 ^^’이라고 붙여놨다. 나는 지금처럼 해보면서 얘기를 많이 나누자고 했다. 다만 캐시 붙인다고 원래 잘 되던 것까지 망가뜨리면 안 되니까, 우선 화면의 선택 목록부터 바꿔보고 확인하기로 했다. 중간에 작업이 겹쳐 코드가 충돌하기도 했다. 범위를 다시 맞추고 내가 코드를 합치면, 주니어들이 실제 화면에서 쓰는 조회와 변경 알림을 이어서 고쳤다. 7월 · 오픈 준비를 시작하자 한 명이 나갔다 7월부터 SaaS 오픈 준비를 본격적으로 시작했다. 그런데 중순쯤 주니어 한 명이 개발이 자신과 맞지 않는다며 그만뒀다. 네 명으로 어떻게 해볼까 하던 팀에 세 명이 남았다. 그때는 나도 좀 될 대로 되라, 내 식대로 가겠다는 마음이 들었다. 업무 지시는 더 직설적으로 했고 교육 일정도 타이트하게 잡았다. 코딩 기초도 빡세게 가르쳤다. 납기는 꼭 맞춰야 한다고 강조했고. 한편으로는 해야 할 일을 나눠서 정리했다. 개발만 끝내면 되는 게 아니었다. 테스트하고 데이터를 옮기고 배포하는 일정까지 있어야 했다. 어디까지 준비됐고 어디가 밀리는지 보면서 우선순위를 맞춰갔다. 주니어들이 Claude Code로 개발하도록 지도하고, 내가 먼저 만들던 SQL 튜닝과 QA 자동화도 팀의 기본 작업에 넣었다. AI가 고쳤다고 해도 실제 화면과 DB에서 맞게 동작하는지는 확인해야 했다. 세 명이 맡아 할 수 있는 일을 늘리려면 이런 작업 방식까지 같이 잡아야 했다. 8월 · 개발은 안정됐는데 사람 일은 또 달랐다 8월에는 팀이 어느 정도 안정됐다. 각자 맡은 개발에 집중할 수 있었고, 배포 준비도 진도가 나갔다. 주니어가 서버에 올리는 절차를 정리하고 파일이나 메일 같은 기능을 확인했다. 이후에는 배포를 자동화하는 코드도 들어갔다. 그 와중에 주니어 한 명과 업무량 때문에 부딪혔다. 일을 너무 많이 시킨다는 얘기였다. 나도 납기를 맞춰야 하니 일을 밀어붙이고 있었는데, 그게 같이 일하는 사람에게는 어떻게 받아들여지는지 고민하게 됐다. 어디까지 요구해야 하는지, 내가 생각하는 가능한 업무량이 주니어에게도 가능한 양인지. 일을 맡길 때는 내가 정해야 했지만, 정했다고 해서 그 판단이 늘 맞는 건 아니었다. 그 일을 겪으면서 내가 사람을 어떻게 이끌고 있는지 다시 생각했다. 같이 개발은 계속했고 일하는 방법도 계속 고쳤다. 작업하다 막힌 이유와 해결한 방법을 남겨 다른 사람이 가져다 쓰게 하는 일도 그중 하나였다. 사내 공유 도구는 내가 방향을 잡고 주니어가 구현했다. 개인이 AI로 해결한 일을 팀에서도 다시 쓸 수 있게 만들고 싶었다. 9월 · 남은 세 명과 가오픈까지 9월에는 주니어들이 맡는 일이 꽤 달라져 있었다. 6월에 쿼리의 테이블 이름을 고치던 주니어가 화면과 서버, DB를 같이 수정했다. 다른 주니어는 배포 자동화를 맡았고, 수정한 버그가 다시 생기지 않는지 확인하는 테스트도 작성했다. 내가 방향을 잡고 리뷰하는 동안 주니어들이 구현과 검증을 맡는 일이 늘었다. 공유 도구도 다른 팀원 PC에서 설치하고 업데이트해 쓰는 데까지 갔다. 지금은 기획팀도 그 도구로 시스템이 어떻게 바뀌어왔는지 찾아보고 있다. 개발할 때 쓰던 AI와 작업 기록을 다른 팀의 업무까지 연결한 셈이다. 주간보고도 손봤다. Slack에 쓴 보고가 시트에 모이고 원문을 수정하면 시트도 같이 바뀌도록 주니어가 만들었다. 같은 내용을 두 군데 옮겨 적는 수고는 덜었다. 다만 그 양식으로는 일이 얼마나 끝났는지 알기 어려워서, 진행도를 볼 수 있게 양식은 더 고쳐야 했다. AI로 구현하고 검증한 일을 기록으로 남기고, 다음 사람이 그걸 다시 쓰고, 보고 내용을 옮기는 일도 줄여갔다. CTO를 맡고 내가 만들고 싶었던 AX 흐름이 조금씩 팀의 일하는 방식으로 들어왔다. 그리고 9월 28일, 서비스를 가오픈했다. 정식 오픈은 10월 12일로 잡혀 있다. 아직 남은 일이 있지만 주니어 세 명과 여기까지 왔다. 대표님은 나한테 팀원들 눈빛이 달라졌다는 말을 자주 하신다. 주니어들한테는 내가 CTO가 돼서 행복하다는 말까지 들었다. 업무량 때문에 부딪혔던 일도 있었고 나도 어떻게 해야 할지 몰랐던 때가 많았는데, 그런 말을 해주니 고맙다. 서비스를 연 얘기와 같이 이 얘기도 남겨두고 싶었다.
4개로 정리 가능하다. 어디에 저장하나, 무엇을 저장하나, 언제까지 믿나, 어떻게 지우나. 계층 1. React 렌더 캐시 컴포넌트 메모리에 계산 결과, 함수, 렌더 결과 저장. 제어는 useMemo, useCallback, React.memo 2. 클라이언트 데이터 캐시 JS 메모리(탭 단위)에 API 응답. TanStack Query, SWR 3. 브라우저 HTTP 캐시 memory / disk cache에 HTTP 응답 전체, 응답의 Cache-Control 4. Service Worker 캐시 Cache Storag에 개발자가 고른 요청, SW 코드(PWA) 5. CDN 캐시 엣지 서버에 HTTP 응답, s-maxage, purge 6. 서버/프레임워크 캐시 Next.js 서버에 fetch 결과, 렌더된 HTML/RSC, revalidate, "use cache", 태그 7. DB 앞 캐시 Redis 등에 쿼리 결과, 백엔드 코드 흐름은 사용자 요청하면 1번부터 차례로 확인. 앞쪽에서 캐시가 맞으면 뒤로가지 않는다. 이전에는 2~6번이 비어 있어서 모든 요청이 Postgres 까지 가고있었다. 1. React 랜더 캐시 "같은 입력이면 다시 계산하지 않음" useMemo(fn, deps) 는 deps가 같으면 계산 결과를 재사용한다. useCallback(fn, deps) 는 함수 참조를 재사용한다. 이름이 달라도 useMemo(() -> fn, deps) 와 같음 React.memo(Component) 는 props가 얕은 비교로 같으면 렌더 자체를 건너뛴다. 컴포넌트 인스턴스 하나에 직전 값 1개만 기억한다. 컴포넌트가 언마운트되면 사라지고, 다른 컴포넌트와도 공유되지 않는다. 그리고 React.memo 에 객체나 함수를 props로 넘기면서 useMemo/useCallback 으로 감싸지 않으면, 매 렌더마다 참조가 바뀌어서 memo가 전혀 동작하지 않는다. 2. TanStack Query / SWR 가장 자주 사용하게 되는 캐시이다. 이름부터 연결되어있는게. SWR 라이브러리 이름은 stale-while-revalidate 에서 왔다. HTTP 지시자와 똑같은 전략을 JS 메모리에서 구현한 것. 캐시 키는 queryKey 이다. ['catalog', 'plans'] 처럼 사용하는데, 같은 키를 쓰는 컴포넌트 모두 요청 한 번을 공유한다. 이것을 dedupe 라고 불림. 반드시 구분해야 할 두 값이 있다. no-cache / no -store 함정 구조와 같음 staleTime 은 언제까지 신선하다고 믿을지. 기본 값이 0이라 쓸때마다 백그라운드에서 다시 가져온다. HTTP의 max-age 에 해당 gcTime(구 cacheTime)은 아무도 안 쓰는 데이터를 메모리에서 언제 지울지. 기본값은 5분이고, HTTP로 치면 "저장을 언제까지 하나"에 해당한다. stale이 됐다고 지워지는 게 아니다. 화면에는 stale 데이터를 보여주면서 다시 가져온다. 무효화는 queryClient.invalidateQueries({ queryKey: ['catalog'] }) 로 한다. 보통 mutation 성공 후 호출. 3. 브라우저 HTTP 캐시 정적 파일 전략 (cache busting) "브라우저 캐시는 지울 수 없다". index.html -> Cache-Control: no-cache /_next/static/app.3f2a1b.js -> Cache-Control: public, max-age=31536000, immutable JS/CSS/이미지 파일명에 내용 해시를 붙인다. 내용이 바뀌면 파일명도 바뀌니, 1년 캐시를 걸어도 안전하다. 옛 파일은 아무도 요청하지 않게 될 뿐이다. HTML만 no-cache 로 둬서 항상 쵯니 파일명을 가리키게 한다. 지우는 대신 URL을 바꾸는 방식 으로 무효화. Next.js 빌드가 이걸 자동으로 해준다. _next/static 응답 헤더를 DevTools에서 직접 보면 됨 4. Service Worker 오프라인 지원이나 PWA를 만들때 사용하는 캐시. 개발자가 fetch를 가로채 캐시 전략을 코드로 직접 짠다. "HTTP 캐시와 별개인 캐시가 하나 더 있다." 5. CDN s-maxage, stale-while-revalidate, purge 모두 여기 해당한다. 사용자끼리 공유되는 유일한 프론트 쪽 캐시. DB 부하를 줄이는 효과가 가장 크다. 6. Next.js캐시 App Router에는 캐시가 여러 겹 있다. Request Memoization: 서버 - 요청 1번 동안 한 렌더 안에서 같은 fetch 를 여러번 불러도 한번만 실행 Data Cache: 서버,영구 - fetch 결과를 요청 간에 저장, revalidate 로 갱신 Full Route Cache: 서버 - 렌더된 HTML/RSC 결과 저장 Router Cache: 브라우저 메모리 - 방문한 라우트의 RSC 결과를 저장, 뒤로가기 시 즉시 표시 쿼리 하나의 일생.. fresh -> stale -> inactive -> 삭제
📕 계기 최근에 멀티플레이 게임을 만들면서 멀티플레이에대한 기초적인 지식과, 엔진의 기능활용에 미숙한 점이 있다고 생각하게되어, [언리얼 엔진5로 개발하는 멀티플레이 게임] 서적을 통해 공부를 해볼 예정이다. 개념이나, 중요해 보이는 점을 위주로 정리 하면서 공부 해보자. 💡 개념 정리 근거리 통신망(Local Area Network - LAN)의 특징 가까운 거리에 있는 기기를 연결하는 통신 네트워크 클라이언트/서버 LAN : 여러기기(클라이언트)가 중앙 컴퓨터(서버)에 연결 P2P LAN : 각 기기가 네트워크 기능을 동일하게 공유 원거리 통신망(WAN) 보다 유지보수가 쉽고 구현 비용이 적다, 추가로 연결되 기기가 제한되어 있어 보안성이 더 향상됨 원거리 통신망(Wide Area Network - WAN) LAN보다 훨씬 넓은 영역을 커버하며 본질적으로 서로 연결된 LAN의 집합체 장거리에선 속도가 떨어짐으로 WAN 은 LAN보다 속도가 느림, 그러나 WAN은 공동 소유가 가능함 (EX: 인터넷) 프로토콜 : 2개이상의 컴퓨터 통신을 관리하려며 신뢰성의 보장하기위한 보안 조치와 데이터 송수신 방법을 결정한 규칙이 필요함 이러한 규칙을 프로토콜(Protocol)이라고 함. 패킷 교환 : 데이터를 작은 조각(패킷)의 형태로 네트워크에 저송 하는 것. 해당 과정에선 데이러틀 작은 세그먼트로 나누고 패킷의 내용, 출발지 및 목적지의 정보를 추가하는 것 까지 포함함 이러한 추가 정보를 헤더라고 함. TCP/IP 스위트 : TCP/IP 스위트는 다음과 같은 서로 다른 논리적 수준 또는 계층이 쌓여 구성된다 응용 계층 전송 계층 인터넷 계층 데이터 링크 계층 언리얼 엔진 멀티플레이어 시스템 네트워크 모드와 서버 유형 클라이언트 : 컴퓨터가 네트워크 멀티플레이 세션에서 서버에 연결하는 클라이언트 역활 독립형 : 네트워크에 연결되지 않은 게임에만 사용, 원격 클라이언트를 허용 하지 않음 전용 서버 : 세션을 호스팅하는 서버로서 실행, 원격 클라이언트를 허용 함 리슨 서버 : 원격 클라이언트 및 로컬 클라이언트 모두를 허용하는 서버로 실행됨. 복제 시스템 : 언리얼 엔진에서 서버와 클라이언트 간의 게임 상태 정보를 동기화 하는 과정을 복제(Replication)이라 한다. 네트워크 역활 권환이 있는 엑터 역활을 하는 기기는 엑터의 상태를 제어, 실시간으로 엑터의 정보를 다른 플레이어에게 복제하는 일을 담당 권한이 없는 원격 기기에 위치한 동일한 액터의 사본은 원격프록시 로 정의되며 권한이 있는 기기에서 복제된 모든 정보를 수신한다. 언리얼 엔진에서 권한은 일반적으로 서버에 있으며, 정보는 일반적으로 서버에서 클라이언트로 전달됨, 이를 서버권한 모델로 알려저 있음 원격 프로시저 호출(RPC) : 서버. 클라이언트 또는 여러 클라이언트에서 전송될수 있으며, 목작지에 도달하는 것이 보장될수도 있고(Reliable), 아닐수도있다(Unreliable).
개발할 때 AI 에이전트를 자주 활용하다 보면 항상 아쉬운 점이 있었습니다. 에디터와 터미널, AI 에이전트를 왔다 갔다 하느라 흐름이 깨지거나, 브랜치를 오갈 때마다 빌드 캐시가 날아가고 세션 맥락이 섞이는 불편함이었죠. 이 문제를 깔끔하게 해결하기 위해, Git Worktree 관리와 Claude Code 세션, 그리고 diff 확인을 한 창에서 완벽하게 연결한 IDE codeme를 만들었습니다. 📸 한눈에 보는 codeme 워크플로우 💡 codeme의 핵심 특징 UI 기반 Git Worktree 관리 (좌측 패널) 커맨드라인에서 일일이 명령어를 입력할 필요 없이, 좌측 패널에서 프로젝트별 Worktree와 브랜치를 직관적인 UI로 관리합니다. 브랜치를 갈아끼우지 않고 작업마다 독립된 Worktree 공간을 가지므로 빌드 캐시나 node_modules가 그대로 유지됩니다. 터미널 기반 Claude Code 통합 (중앙 패널) 중앙 패널에서 Claude Code CLI를 그 자리에서 바로 실행하여 AI와 소통합니다. AI가 코드를 분석하고 테스트를 돌리거나 수정하는 전체 과정을 CLI 터미널 diff 화면 형태로 실시간 확인할 수 있습니다. 세션 시작 시점 기준 Diff & 코드 작성 (우측 및 중앙) AI 에이전트 세션이 시작된 순간을 기준점으로 잡아, 에이전트가 바꾼 코드 변경 사항만 명확하게 구분하여 확인합니다. 터미널 명령어 결과와 코드 수정 내역이 같은 창에서 유기적으로 연결되어 빠른 피드백 루프를 제공합니다. 완전한 로컬 퍼스트 (No Account, Privacy First) 회원가입이나 서버 연동 없이 모든 데이터와 상태가 본인 컴퓨터의 디스크에만 저장됩니다. 소스코드 보안 걱정 없이 안심하고 사용할 수 있습니다. 🚀 다운로드 및 피드백 codeme는 개발자가 맥락 전환 없이 오직 코딩 본연의 흐름(Flow)에만 몰입할 수 있도록 지속해서 발전하고 있습니다. 직접 사용해 보시고 느낀 점이나 피드백을 자유롭게 남겨주세요! 👉 codeme 공식 사이트 방문하기 👉 macOS용 다운로드 받기
what i tried to do 지난 교육기간 동안 진행했던 프로젝트의 경험을 살려 이번엔 나만의 프로젝트을 시도해보았다. 배운 것을 활용하고 있다는 점에서 활기를 느끼고 직접 부딪히면서 무엇을 공부해야하는지, 내가 부족한 부분(maybe in all rounds)이 무엇인지, 그리고 가장 필요한 것이 무엇인지를 더 인지하고자 했다. what i did & learnd this week did 올해 상반기 작성했던 학사 논문의 실험을 여기서의 프로젝트 경험을 살려 다시 진행해보고 있다. 계획에 비해 앙상한 실험 과정과 뒤늦게 마감에 임박해 엑셀로 뽑아낸 시시한 결과물 산출표들을 다시 보니 포트폴리오로 쓰기엔 어림도 없었던 것 같다....... 개인적으로 AI선생님들과 프로젝트들을 시도하다보니 보이는 화면만 그럴듯하고 뭔가 부족함을 느끼지만 혼자서 수정하지 못하는 것에 '공부'가 진짜 부족했음을 느꼈다. 큰 그림과 코드에 대한 구조적 이해에 좀 더 비중을 두고 공부해야겠다 다짐하며 my own 교재를 만든다는 생각으로아래 벨로그 시리즈를 시작했다. https://velog.io/@olivia02/코드-독해-코드가-읽히기-시작하는-개발-기초-수학개념-for-수포자 수업 내용 RAG (검색 증강 생성)? 개념 : 질문과 관련된 외부 문서를 먼저 검색(Retrieval)한 뒤, 그 문서를 참고해 정확한 답변을 생성(Generation)하는 기술입니다. - 데이터 흐름 보기 RAG 시스템이 작동할 때 데이터는 아래와 같은 5단계의 흐름을 거치게 됩니다. 문서 로드 (Load) : PDF, 웹페이지, 데이터베이스 등 다양한 소스에서 원본 문서를 가져옵니다. 텍스트 분할 (Split) : LLM이 처리하기 좋게 문서를 적당한 크기(Chunk)로 잘게 쪼갭니다. 임베딩 및 저장 (Embedding & Vector DB) : 쪼갠 텍스트를 컴퓨터가 이해하는 숫자 형태(벡터)로 변환해 벡터 데이터베이스에 저장합니다. 검색 (Retrieve) : 사용자가 질문을 입력하면, 벡터 DB에서 질문과 가장 의미가 유사한 문서 조각을 찾아냅니다. ** 생성 (Generate)**: 사용자의 질문과 검색된 문서를 조합해 프롬프트(Prompt)를 만들고, 이를 LLM에 전달해 최종 답변을 완성합니다. LangChain? 개념 : LangChain은 LLM을 중심으로 다양한 외부 도구, 데이터, 메모리, 다른 프로그램들을 레고 블록처럼 연결(Chaining)해주는 개발용 프레임워크입니다. 비유하자면 자동차의 엔진(LLM)만 가지고는 도로를 달릴 수 없습니다. 핸들, 바퀴, 변속기, 연료탱크를 연결해 진짜 '자동차'로 만들어주는 차체 프레임(섀시)이자 조립 키트가 바로 LangChain입니다. - 역할과 핵심 기능 모델 I/O : OpenAI, Claude, 오픈소스 모델 등 다양한 LLM을 코드 변경 없이 쉽게 바꿔 끼울 수 있도록 표준화합니다. 체인 (Chains) : "문서 검색 -> 프롬프트 조합 -> LLM 전달" 같은 여러 단계를 하나의 파이프라인으로 연결합니다. 인덱스 (Indexes) : RAG 구현에 필수적인 문서 로딩, 텍스트 분할, 벡터 스토어 연동 기능을 모듈 형태로 제공합니다. 메모리 & 에이전트 : 이전 대화를 기억하게 하거나, LLM이 스스로 판단해 필요한 도구(웹 검색 등)를 쓰도록 제어합니다. pip install langchain langchain-openai faiss-cpu # 환경 변수에 OpenAI API Key 설정 필요 # export OPENAI_API_KEY="sk-..." from langchain.chains import create_retrieval_chain from langchain.chains.combine_documents import create_stuff_documents_chain from langchain_core.prompts import ChatPromptTemplate from langchain_core.vectorstores import InMemoryVectorStore from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter # 1. LLM 및 임베딩 모델 준비 llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) embeddings = OpenAIEmbeddings(model="text-embedding-3-small") # 2. 문서 준비 및 텍스트 분할 (Split) sample_text = """ [회사 내부 규정집] 제1조(휴가) 당사의 연차 휴가는 입사일 기준으로 부여됩니다. 제2조(재택근무) 주 2회까지 재택근무가 가능하며, 팀원과 사전 조율해야 합니다. 제3조(보안) 회사 자산을 반출할 때는 반드시 보안 담당자의 승인을 받아야 합니다. """ text_splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=20) docs = text_splitter.create_documents([sample_text]) # 3. 벡터 스토어 구축 및 검색기(Retriever) 설정 (Embedding & Vector DB) vectorstore = InMemoryVectorStore.from_documents(docs, embeddings) retriever = vectorstore.as_retriever(search_kwargs={"k": 2}) # 4. 프롬프트 템플릿 정의 (검색된 문맥을 프롬프트에 주입) system_prompt = ( "당신은 사내 규정을 안내하는 친절한 어시스턴트입니다.\n" "아래에 제공된 [문맥]을 바탕으로 질문에 답변하세요.\n" "문맥에 없는 내용은 '잘 모르겠습니다'라고 답하세요.\n\n" "[문맥]:\n{context}" ) prompt = ChatPromptTemplate.from_messages([ ("system", system_prompt), ("human", "{input}"), ]) # 5. RAG 체인 조립 (Retrieval Chain) question_answer_chain = create_stuff_documents_chain(llm, prompt) rag_chain = create_retrieval_chain(retriever, question_answer_chain) # 6. 질문 실행 및 결과 출력 response = rag_chain.invoke({"input": "재택근무는 일주일에 며칠까지 할 수 있어?"}) print("질문: 재택근무는 일주일에 며칠까지 할 수 있어?") print(f"답변: {response['answer']}") 🔍 코드 구조 한눈에 보기 - 문서 쪼개기 (RecursiveCharacterTextSplitter) : 긴 사내 규정 텍스트를 LLM과 검색기가 다루기 편한 크기로 잘게 쪼갰습니다. - 벡터화 및 저장 (InMemoryVectorStore) : 쪼갠 텍스트를 숫자로 변환해 메모리 공간에 안전하게 저장했습니다. - 검색기(retriever) : 사용자의 질문(재택근무는...)과 가장 의미가 통하는 문맥(제2조 내용)을 벡터 DB에서 쏙쏙 골라냅니다. 체인 결합(create_retrieval_chain): "검색해온 문서"와 "사용자의 질문"을 정해둔 프롬프트 틀에 맞춰 조립한 뒤, LLM에게 전달해 정확한 답변을 완성합니다. 실제 프로젝트에서는 위 코드의 InMemoryVectorStore 자리에 대규모 데이터를 견디는 Chroma, FAISS, 또는 Pinecone 같은 실제 벡터 데이터베이스를 연결하고, sample_text 대신 PDF나 웹페이지 로더를 연결하게 됩니다. looking back 6개월이라는 교육기간이 길게만 느껴지고 교육기간 동안 시간적으로나 여러가지면에서 하지 못하는 것에만 아쉬워했는데 벌써 날씨는 서늘해지고 과정의 절반 가까이 지나갔다. 부수적인 것에 아쉬움을 느끼면서도 지금의 온전히 공부할 수 있는 시기와 시간, 교육장에서 내가 얻어갈 수 있는 것들 중에 놓친게 많음을 더더 느끼고 있다. 뭐가 되었든 공부에 집중할 수 있는 이 시기를 헛되이 보내지 않겠다 다짐하며 새로운 주차를 시작하겠따!
MSE(Mean Squared Error) - 평균 제곱 오차 MSE에 간단 개념 오차(error)를 제곱한 값의 평균임 오차란 알고리즘이 예측한 값과 실제 정답과의 차이를 의미함 알고리즘이 정답을 잘 맞출수록 MSE 값은 작음 MSE 값은 작을수록 알고리즘의 성능이 좋다고 볼 수 있음 오차 대비 큰 손실 함수의 증가폭 MSE는 오차가 커질수록 손실 함수 값이 빠르게 증가하는 특징 하단 그림은 MSE를 좌표평면에 나타낸 것 손실 함수(E)의 크기는 오차의 제곱에 비례하여 변하는 것을 볼 수 있음 그만큼 미분값이 일정하지 않고 오차가 커질수록 미분값 역시 커지는 것을 알 수 있음 MSE와는 다르게, 평균절대오차(MAE)는 오차가 커질수록 손실 함수가 선형적으로 증가함 MAE와 비교했을 때, MSE가 비교적 오차의 변화량에 따라 손실 함수 값이 크게 변한다는 것을 알 수 있음 MSE는 회귀(Regression) 문제에 자주 활용 오차를 줄여가면서 업데이트 MAE(Mean Absolute Error) - 평균 절대 오차 MAE에 간단 개념 오차(error)를 절댓값을 계산한 뒤 평균한 값임 오차란 알고리즘이 예측한 값과 실제 정답과의 차이를 의미함 알고리즘이 정답을 잘 맞출수록 MAE 값은 작고, MAE 값이 작을수록 알고리즘의 성능이 좋다고 볼 수 있음 MSE와 달리 오차를 제곱하지 않기 때문에 큰 오차에 상대적으로 덜 민감함 RMSE(Root Mean Squared Error) - 평균 제곱근 오차 오차를 제곱하여 평균한 뒤 다시 제곱근을 취한 값임 오차란 알고리즘이 예측한 값과 실제 정답과의 차이를 의미함 알고리즘이 정답을 잘 맞출수록 RMSE 값은 작고, RMSE 값이 작을수록 알고리즘의 성능이 좋다고 볼 수 있음 오차를 제곱하기 때문에 큰 오차에 더 큰 페널티를 부여하는 특징이 있음 마지막에 제곱근을 취하기 때문에 실제 데이터와 동일한 단위로 해석할 수 있음
2026.09.28 문제 풀이 1차 실행 오류 83.9/100.0 효율성 테스트 시간 초과 오류 시간 초과 원인 분석 1,000,000 이하의 길이를 갖는 scoville 배열을 매번 sort() 를 통해 정렬하는 것이 원인이다. import java.util.Collections; import java.util.List; import java.util.ArrayList; import java.util.Comparator; class Solution { public int solution(int[] scoville, int K) { int cnt = 0; List<Integer> scovilles = new ArrayList<>(); for (int i = 0; i < scoville.length; i++) { scovilles.add(scoville[i]); } while(true) { scovilles.sort(Comparator.naturalOrder()); int first = scovilles.get(0); if (first >= K) { break; } int second; try { second = scovilles.get(1); } catch (IndexOutOfBoundsException e) { return -1; } scovilles.remove(0); scovilles.remove(0); scovilles.add(first + second * 2); cnt++; } return cnt; } } 2차 실행 오류 87.1/100.0 런타임 에러 런타임 에러 원인 분석 while 문은 내부 코드의 동작이 끝난 후 다시 시작할 때, 종료 조건을 검사한다. 따라서 첫번째 poll() 을 한 후에 queue 가 비어 있어도 종료하지 않고 두번째 poll() 을 진행하는데, 이 때 런타임 오류가 발생할 수 있다. import java.util.PriorityQueue; class Solution { public int solution(int[] scoville, int K) { int cnt = 0; PriorityQueue<Integer> queue = new PriorityQueue<>(); for (int i = 0; i < scoville.length; i++) { queue.add(scoville[i]); } while(!queue.isEmpty() && queue.peek() < K) { int first = queue.poll(); int second = queue.poll(); queue.add(first + second * 2); cnt++; } return cnt; } } 나의 코드 소요 시간: 30분 시간 복잡도: $O(nlogn)$ import java.util.PriorityQueue; class Solution { public int solution(int[] scoville, int K) { int cnt = 0; PriorityQueue<Integer> queue = new PriorityQueue<>(); for (int i = 0; i < scoville.length; i++) { queue.add(scoville[i]); } while(!queue.isEmpty() && queue.peek() < K) { int first = queue.poll(); if (queue.isEmpty()) { return -1; } int second = queue.poll(); queue.add(first + second * 2); cnt++; } return cnt; } } AI 코드 시간 복잡도: $O(nlogn)$ 코드 분석 구조는 동일하다. Arrays.stream(scoville).boxed().collect(Collectors.toList()) Arrays.stream() int 값들의 흐름 으로 바꿈 결과: IntStream: 1, 2, 3, 9, 10, 12 .boxed() IntStreeam 의 int 를 하나씩 오토박싱 하여 Integer 로 바꿈 PriorityQueue 의 경우 제네릭이기 때문에 int 를 담을 수 없음 결과: Stream<Integer> .collect(Collectors.toList()) 스트림의 원소들을 모아 List<Integer> 로 만듦 왜 하나씩 넣을 때에는 오토박싱이 적용되는데, stream 을 사용할 때에는 적용되지 않는가? IntStream 은 int 만 다루는 전용 인터페이스 이기 때문에 시그니처가 Stream<Integer> 와는 다름 import java.util.Arrays; import java.util.PriorityQueue; import java.util.stream.Collectors; class Solution { public int solution(int[] scoville, int K) { // 배열을 한 번에 넘기면 heapify로 O(N)에 힙 구성 PriorityQueue<Integer> pq = new PriorityQueue<>( Arrays.stream(scoville).boxed().collect(Collectors.toList()) ); int cnt = 0; while (pq.peek() < K) { if (pq.size() < 2) return -1; // 섞을 상대가 없음 int first = pq.poll(); int second = pq.poll(); pq.add(first + second * 2); cnt++; } return cnt; } } 문제 풀이 후기 처음 문제를 풀기 전, PriorityQueue 를 활용해서 푸는 문제라는 것을 깃허브 저장소의 README.md 를 통해 알게 되었다. 하지만 정답을 알고 푸는 것은 시간 낭비라는 것을 알기에 다른 방법으로도 한번 접근해서 풀어보고자 했으나 결국 시간 초과 오류로 인해 실패하고 말았다. 결국은 PriorityQueue 로 해결했다. 이번 문제를 풀며 "있는 것을 활용하자" 라는 생각이 들었다. 다른 사람들이 만들어 놓은 라이브러리를 적극 활용하면 내가 직접 짠 코드보다 훨씬 더 효율적인 코드를 짤 수 있다.