[Sub Projects]/[Custom File Extension]

[Custom File Extension] Electron으로 나만의 파일 확장자 만들기

Juyear 2026. 5. 20. 16:03
728x90

👋 소개

안녕하세요! 대학생 개발자 주이어입니다.
오늘은 최근에 만들기 시작한 Custom File Extension 프로젝트에 대해서 간단하게 소개해보려고 합니다.
Custom File Extension 프로젝트는 이름에서 알 수 있듯이 저만의 파일 확장자를 만들기 위한 프로젝트입니다.
 
이 프로젝트를 생각하게 된 계기는 갑자기 "내 이름으로 된 확장자가 있으면 재밌겠는데?" 라는 생각이 들어서 바로 제작하게 되었습니다.
[Sub Projects]라는 카테고리를 새로 만든 이유도 이런식으로 개인적인 재미를 위해서 제작한 프로젝트를 정리하려 만들었습니다.
(뭔가 [Main Projects] 카테고리로 넣기엔 애매한 프로젝트들...)
 
기술적인 부분으로는 아직 크게 소개드릴 부분이 없어 간단하게 어떻게 만들었는지와 화면 위주로 보여드리려고 합니다.


🛠️ 기술 스택

  • Frontend : React, HTML, CSS, TypeScript
    - React 기반 프로그램 UI 구현
    - 파일 로직 제어 (저장, 수정, 삭제 등...)
  • Backend : Node.js, Electron
    - IPC 채널 설계를 통한 프로세스 간 데이터 통신
    - 시스템 레벨 API 제어 및 OS 고유 기능 연동
  • Storage : Electron-Storage
    - 사용자 데이터의 영속성을 위해 사용 (로컬 관리)
    - 파일 시스템 기반 데이터 입출력 로직 처리
  • AI : Gemini, Copilot
  • 기타
    - Milkdown : 마크다운 문법 실시간 해석 및 렌더링

특별히 새롭게 사용하게 된 기술은 ElectronCopilot이 있습니다.
 
Electron을 사용한 이유는 아무래도 확장자 자체가 OS단에서 설정하는 것이다 보니 웹과 앱보다는 컴퓨터에 설치해서 사용하는 프로그램 형식이 적합하다고 생각했고, 또 개인적으로 설치형 프로그램을 만들어보고 싶어서 사용하였습니다.
 
Copilot은 평소에 VSC에서 쓰라고 광고를 해도 사용하지 않았었는데, 이번에 SW마에스트로 활동을 하면서 AI를 잘 활용하고 이런 AI의 발전을 따라가는 것이 중요하다고 해서 한 번 사용해보자 하고 사용하였습니다.
근데 진짜 사용한지 몇 시간만에 기존 개발 방법으로는 못 돌아가겠다 생각이 들었습니다...


📄 나만의 확장자는 어떻게 설정할까?

생각보다 쉬운 커스텀 확장자

컴퓨터를 자주 사용하는 분이라면 아시겠지만, 사실 확장자를 새로 만드는 것은 어렵지 않습니다.
왜냐하면 파일을 저장할 때 확장자도 함께 지정할 수가 있고, 이때 원하는 확장자 명을 설정할 수 있기 때문입니다.

일반 txt 확장자

윈도우에 기본적으로 깔려있는 메모장을 자주 사용해보셨을 겁니다.
메모장에 글씨를 적고 저장을 하면 자동으로 .txt 확장자로 저장이 됩니다.

확장자 직접 수정

하지만 위와 같이 "이름 바꾸기"에서 확장자 이름을 바꾸면 그 확장자 이름으로 설정이 됩니다.
물론 이렇게 확장자 이름을 바꿔도 메모장에서 열 수 있습니다.
그럼 "나만의 확장자 만들기" 프로젝트가 끝난걸까요? 당연히 그럴리가 없겠죠.
 

내가 원하는 확장자의 조건은?

우선 제가 원하는 조건은 3가지 정도가 있었습니다.

  1.  다른 프로그램에서 열 수 없거나, 열리더라도 내용을 확인할 수 없다.
    이 조건을 넣은 이유는 나만의 확장자가 txt에서도 열리고, VSC에서도 열린다면 굳이 나만의 확장자를 만든 이유가 없어질 것 입니다.
  2. 나만의 확장자에서만 사용 가능한 프로그램이 있다.
    1번에서 다른 프로그램으로 열 수 없다고 했으니, 이 확장자를 열기 위한 전용 프로그램이 필요합니다.
  3. 해당 프로그램에서만 지원하는 특별한 기능이 있다.
    전용 프로그램이 있더라도 기능이 메모장과 똑같다면 힘들게 전용 프로그램을 만들 이유가 없습니다.

위의 3가지 조건을 한 문장으로 축약하자면,
"그냥 간지가 나야한다" 입니다. 즉, 나만의 확장자를 만들 이유가 있어야 한다는 겁니다.
 
이러한 조건을 만족하기 위해 어떻게 프로그램을 설계하고 제작했는지 밑에서 하나씩 소개할 예정입니다.


❌ 다른 프로그램에서는 열리면 안돼!

다른 프로그램에서 그냥 안열리게 하면 되지 않을까? 

첫 번째 조건인 "다른 프로그램에서 열 수 없거나, 열리더라도 내용을 확인할 수 없다."는 어떻게 만족시킬 수 있을까요?
저는 처음에 단순히 "그냥 안열리게 설정할 수 있지 않나?" 라고 생각했습니다.
근데 생각보다 메모장은 대단했습니다...
아무리 확장자 이름을 바꾸고, Buffer를 수정해도 메모장으로 열면 내용이 조금 깨지더라도 보이긴 했습니다.
 
만약 제 확장자가 애초에 텍스트 파일로는 읽을 수 없는 구조였다면(img, mp4 등..) 안열리게 하는 것이 가능할지 모르겠지만 기본적으로 텍스트를 저장하는 제 확장자를 메모장에서 안열리게 하는 것은 쉽지 않았습니다.
물론 방법이 있을거라고 생각하지만, 고민을 해보니 그냥 안열리는 것보단 열려도 내용을 알 수 없고 제 프로그램으로 열었을 때만 내용을 보여주는 것이 조금 더 "간지" 나겠다는 생각이 들었습니다.
 

어떻게 내용을 숨길 수 있을까?

내용을 안보여주는 방법은 파일을 열지 못하게 하는 방법보다는 훨씬 다양한 방법이 있었고, 적용도 어렵지 않았습니다.
제가 생각한 방법은 base64 인코딩을 이용해 다른 프로그램에서 열었을 때는 이상한 문자열로 보이게 하는 방법이었습니다.
물론 사용자가 굳이 굳이 base64 디코딩을 한다면 내용을 알 수 있겠지만, 이 부분은 이후에 AES와 같은 암호화 방식으로 바꿀 생각이었기 때문에 우선은 base64 인코딩을 사용하기로 결정했습니다.
 

구현하기 전에 Electron 구조부터 알아보자

  ipcMain.handle('save-file', async (_event, filePath, content) => {
    try {
      fileService.save(filePath, content)
      return { success: true }
    } catch (error) {
      console.error('파일 저장 실패 : ', error)
      return { success: false, error: error }
    }
  })

Electron 문법을 하나씩 설명하는 것은 의미가 없다고 생각하기 때문에 코드는 간단하게 설명하고 넘어가도록 하겠습니다.
 
위 코드는 IPC Handler를 등록하는 과정입니다.
웹 개발에 비유하자면, Service 레이어에 해당한다고 볼 수 있을 것 같습니다.
파일 저장 로직과 같은 '비즈니스 로직'이 수행되는 부분이기 때문입니다.
'save-file'이라는 일종의 라우팅 경로를 만들었으니 Controller가 아니냐 생각하실 수 있지만, Electron에서는 Preload 스크립트가 Client(Renderer)와 Server(Main)사이의 다리 역할을 하며, 실제 요청을 중계하는 Controller와 유사한 역할을 담당합니다.

// Preload로 라우팅 설정하는 법 
 try {
    contextBridge.exposeInMainWorld('electron', electronAPI)
    contextBridge.exposeInMainWorld('api', {
      saveFile: (filePath: string, content: string) =>
        ipcRenderer.invoke('save-file', filePath, content)
    })
  } catch (error) {
    console.error(error)
  }

// React에서 사용하는 법
const result = await window.api.saveFile(path, stringifiedData)

그럼 Preload 스크립트가 무엇인지 간단하게 살펴보겠습니다.
위 코드는 Preload에서의 라우팅 설정과 실제 React에서 이를 호출하는 코드가 같이 들어있습니다.
 
이렇게 Preload를 설정해두면, React에서는 마치 전역 객체를 사용하는 것처럼 'api.saveFile()' 형식으로 간편하게 호출할 수 있습니다.
웹 개발로 치면, 프론트엔드에서 호출할 '/api/saveFile' 같은 엔드포인트를 설정해준다고 볼 수 있습니다.
제가 앞서 Preload를 Controller와 비슷하다고 말씀드린 이유가 이 부분 때문입니다.
 
근데 설치형 프로그램은 주로 백엔드와 프론트엔드를 같이 사용하는데(내부통신) 굳이 이런식으로 Preload를 설정하는 이유가 뭘까요?
결정적인 이유는 보안 때문입니다. Electron의 Renderer 프로세스는 보안상 OS자원에 직접 접근할 권한이 없습니다. 만약 Renderer가 자유롭게 시스템에 접근할 수 있다면, OS자원이 너무 쉽게 외부로 드러나게 됩니다. 또한 개발자의 실수로 건드려서는 안 될 시스템 자원이나 중요한 설정 파일을 수정하는 문제가 발생할 수도 있습니다.
이러한 다양한 문제를 방지하기 위해 Preload라는 안전한 중간 다리를 통해 사용할 수 있는 함수로만 자원에 접근 가능하도록 설정해주는 것 입니다.
(운영체제를 공부해보신 분이라면 아시겠지만, OS접근은 애플리케이션에서 직접할 수 없고 System Call로만 가능하다 그런 느낌으로 생각하시면 될 것 같습니다.)
 

이제 진짜 구현해보자

  save(filePath: string, content: string): boolean {
    const header = Buffer.from([0x00, 0xff, 0x4a, 0x59, 0x01])
    const encodeBody = Buffer.from(content, 'utf-8').toString('base64')
    const finalData = Buffer.concat([header, Buffer.from(encodeBody)])

    writeFileSync(filePath, finalData)
    return true
  },

base64 인코딩을 적용하는 방법은 아까 말했듯 어렵지 않았습니다.
또한 base64 인코딩은 기본적으로 지원하는 기능이니 파일을 저장할 때 내용을 인코딩만 해주면 되는 부분이었습니다.
오히려 코드 구현보다는 평소에 "그렇구나" 정도로만 이해하고 있던 파일의 실제 구조를 어느정도 이해할 수 있었습니다.
예를 들면, 컴퓨터가 어떻게 확장자를 인식하고 실행하는지, 매직 넘버를 통해 어떤 정보들을 넣을 수 있는지 등이 있었습니다.
 
위 코드에서도 header라는 변수에 매직 넘버를 설정하고 저장하고 있는 것을 볼 수 있습니다.
제가 설정한 매직 넘버에 대해서 간단히 설명하자면,

  • 0x00, 0xff : 이 부분은 초반에 메모장에서 안열리게 하려고 넣었던 매직 넘버입니다.
    찾아보니 0x00과 0xff같은 메모장에서 표시할 수 없는 문자를 넣으면 메모장에서 읽을 수 없는 파일로 나온다고 해서 넣었습니다. 결론적으로는 메모장에서 잘 열렸지만...
  • 0x4a, 0x59 : 이 부분은 제 확장자가 맞는지 내부적으로 확인하기 위한 매직 넘버입니다.
    PDF에서는 '%PDF', PNG에서는 'PNG'라는 고유한 매직 넘버로 파일을 식별하듯이 저도 'JY'라는 제 확장자를 인식하기 위한 매직 넘버를 넣어주었습니다.
    이를 통해 해당 파일이 확장자 이름만 .juyear로 바꾼 것인지 아니면 진짜로 제 프로그램에서 생성된 파일인지 확인할 수 있습니다.
  • 0x01 : 마지막으로 들어간 매직 넘버는 해당 파일의 버전을 나타냅니다. 버전 매직 넘버가 필요한 이유는 이후에 프로그램이 확장되면서 복잡한 기능이 생기고 구조가 바뀌더라도, 파일 버전을 보고 어떤 방식을 적용해야하는지 판단할 수 있습니다.
    예를 들어 제가 이후에 버전 2에서 AES 암호화 방식으로 바꾸더라도 버전 1은 base64로 읽어야한다는 로직을 짤 수 있는 것 입니다.

이렇게 설정해주었습니다.
 
매직 넘버 말고는 코드 상에서 더 보여드릴 부분은 없는 것 같습니다.
 

진짜 읽을 수 없을까? 테스트 해보자

그럼 이제 메모장에서는 내용을 읽을 수 없는지 테스트를 해봐야겠죠?

전용 프로그램에서 작성한 내용

테스트를 위해 위와 같이 전용 프로그램에서 입력 후 파일을 저장하였습니다.

.juyear 확장자 파일 생성

위와 같이 .juyear 확장자로 파일이 잘 저장되었습니다.

메모장에서 열면 보이는 내용

그 후 메모장에서 열어보면, 저희가 예상했던 대로 알 수 없는 내용으로 나오는 것을 확인할 수 있습니다.
(사진 입력 테스트했던 파일인데 왜 중국어로 나오는건지는 모르겠습니다...)

새로운 파일 생성 후 다시 메모장으로 열어봄

새로운 파일을 만들어 테스트 해보니 이번에는 정상적으로 인코딩 값이 출력되는 것을 확인할 수 있었습니다.
 
즉, 저희의 첫 번째 조건 "다른 프로그램에서 열 수 없거나, 열리더라도 내용을 확인할 수 없다." 조건을 만족하였습니다.
글은 많이 적은 것 같은데 이제 하나 만족했네요...


💻 다른 확장자는 사용할 수 없어!

어떻게 다른 확장자는 못 읽게 할까?

1번에서 제 확장자가 다른 프로그램에서는 읽을 수 없도록 설정을 했으니, 이제 반대로 다른 확장자는 제 프로그램을 사용할 수 없도록 설정해야 합니다.
물론 범용성을 생각한다면 VSC처럼 읽을 수 있는 파일이라면 다 열리게 하는 것이 좋겠지만, 저는 제 확장자에서만 사용 가능한 프로그램을 만드는 것이 목표기 때문에 사용할 수 없도록 처리하였습니다. 
 
다른 파일이 열리지 않게 하는 방법은 사실 간단합니다.
아까 위에서 잠깐 말했던 매직 넘버를 활용해 제 확장자인지 확인만 하면 됩니다.
 

바로 구현해보자

매직 넘버에 대해서도 이미 어느정도 알고 있으니, 이제 파일을 읽어올 때 검증하는 부분만 보여드리도록 하겠습니다. 

  read(filePath: string): string {
    const fileBuffer = readFileSync(filePath)

    if (fileBuffer[2] != 0x4a || fileBuffer[3] !== 0x59) {
      throw new Error('올바른 확장자 형식이 아닙니다.')
    }

    const version = fileBuffer[4]
    console.log(version)

    const encodedBody = fileBuffer.subarray(5).toString()
    const decodedBody = Buffer.from(encodedBody, 'base64').toString('utf-8')

    return decodedBody
  }

위 코드를 보시면 알겠지만 구현도 크게 어려운 부분은 없었습니다.
전달받은 파일 경로를 통해 파일을 읽어오고(readFileSync), 버퍼 값이 우리가 설정한 매직 넘버와 같은지만 확인해주면 됩니다.
그리고 만약 매직 넘버가 일치한다면 우리 파일이라는 뜻이니, base64 디코딩을 해주고 파일 내용을 return 해주면 됩니다.
 

진짜 안열릴까? 테스트 해보자

구현은 간단했으니 바로 한 번 테스트를 해보도록 하겠습니다.

파일 생성 사진
txt 입력 내용

위와 같이 내용을 입력하고 txt 확장자로 저장을 해주었습니다.

강제로 실행시키기

그 이후 txt 확장자를 일부로 제 프로그램을 선택하여 열어보았습니다.

실행 사진

제가 의도한 로직은 오류가 뜨는 것인데, 이유는 모르겠지만 오류는 안뜨고 위와 같이 빈 화면을 보여주는 것을 확인할 수 있었습니다.

인위적으로 확장자 바꾸기

그래서 아예 직접 확장자까지 바꿔보았습니다.
이러면 자동으로 제 프로그램으로 연결이 될 것 입니다.

실행 사진

그 이후 실행을 해보면 보이는 것과 같이 오류를 출력하는 것을 알 수 있고 파일 내용도 읽어오지 않았습니다.
 
.juyear 확장자로 바꿨는데도 오류가 생기는 이유는 무엇일까요?
이미 답을 알고 계시겠지만, 위에서 설명했던 매직 넘버 불일치 때문입니다.
확장자는 .juyear일지 몰라도, 내부 확인 로직에서 매직 넘버 불일치로 다른 확장자라는 것을 판단한 것이죠.
 
물론 이 매직 넘버도 만능은 아닙니다. 파일 Buffer를 볼 수 있는 이미 다양한 프로그램이 존재하고, 이를 활용해 매직 넘버 유추 및 수정이 가능하기 때문입니다. 이후에 이 부분까지 막을 수 있는 방법을 찾아보고 해결 방법이 있다면 적용해볼 생각입니다.
 
 
결론적으로는 제가 의도한대로 다른 확장자는 사용할 수 없고, 읽어올 수 조차 없는 제 확장자만의 프로그램이 완성되었습니다.


💡 나만의 기능이 필요해! (차별점)

어떤 기능이 있을까...?

사실 마지막 조건인 '해당 프로그램에서만 지원하는 특별한 기능이 있다.'는 아직 만족시키지 못 했습니다.
개발을 시작한지 아직 이틀밖에 안되기도 했고, 아이디어도 떠오르지 않았기 때문입니다.
일단은 UI랑 핵심 기능만 구현된채로 블로그 글을 적어야겠다 생각했습니다.
이 부분의 경우 기능이 추가된다면 글을 하나 더 적어보도록 하겠습니다.


✅ 현재 진행 상황

메인 화면

메인 사진

우선 프로그램을 키면 제일 먼저 나오는 화면입니다.
제목과 텍스트는 임의로 입력해보았습니다.
왼쪽 위에는 메뉴 아이콘과 현재 파일의 경로가 나오고, 오른쪽 위에는 "SECRET ON"이라는 문구를 별 의미는 없지만 넣어보았습니다.

사이드 메뉴 바

메뉴 사진

메뉴 아이콘을 클릭해 메뉴바를 열었을 때 화면입니다.
몇 가지의 기본 기능을 제공하며, 밑에는 최근 연 파일의 경로가 나옵니다.
기본 기능에는 '새 파일', '파일 열기', '파일 저장', '다른 이름으로 저장'이 있습니다.
또한 최근 연 파일의 경로를 클릭할 경우 바로 켜지도록 구현하였습니다.

실시간 마크다운 변환

마크다운 예시 사진

마크다운 문법을 적용한 사진입니다.
평소에 노션을 자주 사용하기 때문에 단순한 메모장 보다는 실시간 마크다운 변환이 가능한 메모장이 좀 더 실용성 있겠다고 생각하여 적용하였습니다. 
이를 적용하기 위해서 Milkdown이라는 실시간 마크다운 변환 라이브러리를 사용하였습니다.
 
한 가지 문제가 있었다면, 몇 가지 마크다운 문법이 인식은 되지만 화면에 보이지 않는 문제가 있었습니다.
Milkdown에서 지원하는 Theme를 적용하면 자동으로 UI가 나온다고 들었지만, 어떤 이유인지 UI가 나오지 않았습니다.
이를 해결하기 위해 표와 체크박스 등 몇몇 UI를 직접 구현하였습니다.

Ctrl + S 단축키로 저장

  mainWindow.webContents.on('before-input-event', (event, input) => {
    if ((input.control || input.meta) && input.key.toLowerCase() === 's') {
      if (input.type === 'keyDown') {
        mainWindow.webContents.send('shortcut-save')
        event.preventDefault()
      }
    }
  })

Ctrl + S 단축키를 등록하는 코드입니다.
파일을 저장할 때 마다 메뉴를 열어서 마우스로 클릭하는 것은 굉장히 불편하고 비효율적입니다.
요즘 만들어지는 대부분의 프로그램에서 Ctrl + S 단축키로 저장이 안되는 프로그램은 보신 적이 거의 없을겁니다.
 
구현 중에 있었던 대표적인 문제로는 앱을 최소화 시켜둔 상태에서도 Ctrl + S를 인식하는 문제가 있었고, 이는 다른 앱에서의 Ctrl + S를 가로채는 문제가 있었습니다. 예를 들어 노션에서 Ctrl + S를 눌러도 노션은 저장이 되지 않고 제 프로그램에서 저장을 실행하는 문제였습니다.
 
보통 이러한 단축키는 OS단에서 설정을 하는데, 이때 설정을 잘 못 하여 Ctrl + S를 전역에서 인식하는 문제가 발생한 것입니다.
이를 해결하기 위해 OS단에서 설정하지 않고 프로그램에서만 인식하도록 구현했고, 이는 자동으로 프로그램 창에 포커싱이 되어있지 않으면 Ctrl + S를 인식할 수 없었습니다.
 
(추가로 Ctrl + Z 기능도 있지만, 이 기능은 Milkdown에서 지원해주는 기능이라 직접적인 구현은 없었습니다.)

저장 완료 Toast

마지막으로 위 사진은 저장했을 때 나오는 알림 UI 입니다.


😊 마무리

프로젝트를 소개하다 보니 글이 좀 길어졌던 것 같습니다.
사실 기술적인 부분보다는 그냥 제가 한 뻘짓을 소개하는 글이었습니다.
그래도 좀 의미있는 내용을 넣어보고자 중간 중간에 기술적인 내용이나 제가 겪었던 문제해결과정을 적었습니다.
 
이후에 이 프로그램이 좀 더 업데이트가 되고 적을 내용이 쌓인다면 다시 찾아오도록 하겠습니다.
 
그럼 지금까지 읽어주셔서 감사드리며, 다음에 더 유익한 글로 찾아오도록 하겠습니다.

by. 대학생 개발자 주이어

 
https://discord.gg/8Hh8WgM4zp

 

KYT CODING COMMUNITY Discord 서버에 가입하세요!

Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 25명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.

discord.com

KYT CODING COMMUNITY 가입하기!

728x90