프론트엔드 개발 방법의 정석

IT 기술 중 웹을 위한 프론트엔드 개발을 위한 기술은 그 변화가 매우 심하고 변덕스럽습니다. 한때 jQuery는 프론트엔드 개발에서 매우 핵심적인 자리를 차지했다가 지금은 과거에 비해 사용되지 않으며 현재는 Vue나 React와 같은 기술이 그 자리를 차지하고 있습니다.

정상적으로 작동하는 웹 페이지를 만들기 위해 꼭 필요한 기술은 HTML, CSS, JS(이하 Javascript)입니다. HTML은 웹 페이지의 구조를 정의하고 CSS는 웹 페이지를 시각적으로 어떻게 보일지를 정의하며 JS는 정적인 웹 페이지를 동적으로 만드는 생명력을 부여해 줍니다. 이 3가지만 알면 웹 페이지 개발이 가능함에도 정작 실무에서는 다른 대안을 고민합니다. jQuery, Vue, React 등과 같은 것들을 말이죠.

문제는 이런 것들은 웹 표준이 아니므로 자주 변하고 변덕스럽습니다. 특정한 회사나 개발자에 의해 만들어지므로 해당 기술은 다양한 이유로 인해 시장에서 사라지기도 합니다. 사실 매우 유명한 오픈소스 중 많은 것들이 1인 개발자나 소규모의 개발 조직에 의해 만들어지고 유지됩니다. 물론 작다고 해서 문제라는 것은 아닙니다. 오히려 소프트웨어 개발에 있어서 작은 조직은 큰 조직 보다 훨씬 효율적이고 효과적으로 작동하고 더 적합할 때가 많습니다. 작고 크고는 필요에 의해 정해지는 것으로 서로 장단점이 있습니다.

jQuery나 Vue, React에 대한 다른 관점에서의 문제는 이런 기술들은 개발자에게 HTML, CSS, JavaScript에 대한 선행 학습을 요구하면서 동시에 추가적인 지식에 대한 학습을 강요합니다. 이런 추가적으로 알아야할 기술은 HTML, CSS, JavaScript에 대한 내용보다 훨씬 더 방대할 때가 많습니다. 또 자주 변하고 매우 독창적이기까지 합니다. 또 아니다 싶으면 그냥 폐기되고 다른 대안이 나옵니다. 프론트엔드 개발 기술의 흐름은 과거부터 지금까지 늘 이런 흐름의 반복입니다. 프론트엔드 개발자들은 분명 이러한 문제가 있음을 알면서도 결국에는 대세가 되는 프로트엔드 기술을 사용하데, 그 이유는 많은 개발자, 많은 개발사에서 사용되고 있다는 이유가 가장 절대적입니다.

저 역시도 제 스스로를 다독 거리며 React를 학습하고 프로젝트에 사용 했습니다. 현재 시점에서 React는 프론트엔드 분야에서 점유율 1위라 사실은 어떤 문제를 더 이상 문제가 아닌 것으로 바꿔 버립니다. React는 나쁘지 않았고 완전히 새로운 방법으로 웹 페이지를 매우 효과적으로 개발할 수 있었습니다. 하지만 프로젝트를 마무리하고 회상해 보면 웹의 기본 기술은 HTML, CSS, JS만으로도 충분히 개발이 가능하다는 것을 깨닫게 되었습니다.

이 글은 프론트엔드 개발을 위해 가장 기본적이며 근본적인, 하지만 매우 효과적인 방법이라고 생각되는 것들을 여러분에게 공유하고 더 개선해 나가기 위한 목적을 갖습니다. 대규모 프로젝트도 진행하기 위해 제 경험에 기반한 다음과 같은 기술을 사용합니다. 참고로는 저는 공간정보와 관련된 시스템 개발에 대한 백엔드와 프론트엔드 개발자입니다. 공간정보시스템에 대한 프론트엔드 개발은 대부분 웹 기반의 SPA(Single Page Application)으로 그 규모가 상대적으로 매우 큽니다. 회사에서 지오서비스웹이라는 서비스를 제공하고 있는데 이 서비스 개발 역시 아래의 기술 등을 사용했습니다.

  • HTML, CSS
  • JS 또는 TS(이하 TypeScript)
  • TS 지원과 번들링(Bundling)을 위한 vite
  • OOP(객체지향 프로그래밍)

4번째인 OOP 항목은 프론트엔드 개발에 한정되지 않은 일반적인 프로그래밍에서 언급되는 것으로 엄밀히 말하면 JS나 TS에 포함되는 것입니다. 프로젝트의 규모가 커서 작성해야할 소스 코드가 방대하다면 JS 대신 TS를 사용하는 것을 권장합니다. TS는 JS의 문제점으로 언급되는 불명확한 타입 선언을 해결하고 더 나은 class 작성 방법을 제공합니다. OOP를 사용하게 되면 훨씬 효과적인 개발이 가능합니다. OOP는 웹 어플리케이션(이하 앱)을 컴포넌트 단위로 개발하도록 유도하고 객체간의 관계를 효과적으로 맺도록 하며 기능 확장성과 효율적인 유지보수를 가능하게 됩니다. OOP가 생소한 분들이라면 OOP는 잠시 잊으시고 OOP 대신 JS나 TS의 class 키워드로 생각하시면 됩니다. 이 글에서는 OOP에 대한 깊이 있는 활용보다는 class에 대한 기초적인 사용을 통해 실습을 진행합니다.

vite는 TypeScript 언어에 대한 지원과 여러개의 소스 코드 파일을 하나로 묶어 주는 번들링 기능 제공, 웹 개발에 필요한 웹서버 제공, 배포 등과 같은 기능을 제공합니다. vite는 React 등과 같은 프로젝트를 생성하기 위해서도 사용되기도 합니다.

앞서 설명한 내용을 효과적으로 설명하기 위해서 실제 웹앱을 만들면서 설명하겠습니다. 만들 웹앱은 Tic-Tac-Toe이며 이를 선택한 이유는 리엑트 공식 사이트에서 리엑트를 설명하기 위한 웹앱으로 바로 이 Tic-Tac-Toe이기 때문입니다. 해당 사이트를 살펴보면 다음과 같습니다.

위의 화면은 react를 이용해 만든 웹앱인데, react에 대한 제법 많은 내용을 기반으로 작성되고 있습니다. 기본적인 Tic-Tac-Toe 게임의 규칙에 대한 플레이가 가능하고 게임 과정에 대한 이력을 관리하여 원하는 단계로 게임을 되돌릴 수 있는 기능을 가지고 있습니다. 이 웹앱을 앞서 언급했던 HTML/CSS와 TS를 이용해 동일하게 만들어 보겠습니다. TS가 아닌 JS로 만들수도 있지만 실무에 적합한 TS를 사용하겠습니다. TS는 타입에 대한 지정과 null 객체에 대한 엄격한 검사 등 JS보다 더 나은 코드 확장성과 유지보수적 특성을 제공합니다.

우리가 만들 이 웹앱의 클래스다이어그램(StarUML로 작성함)은 다음과 같습니다. 클래스다이어그램은 UML의 한 종류로 클래스 간의 관계를 매우 효과적으로 기술한 설계서라고 생각하면 됩니다.

App이라는 클래스가 있고 이 App은 Game이라는 클래스에 대한 객체를 생성합니다. 그리고 Game 클래스는 Board와 History 클래스의 객체를 맴버 필드로 가지고 있습니다. 그리고 Board와 History 클래스의 객체는 Game 클래스의 객체를 참조하고 있다는 것을 알 수 있습니다. Game은 Tic-Tac-Toe 게임의 진행을 관리합니다. 그리고 Board는 3×3의 게임판을 관리하며 History는 게임 플레이 과정에 대한 이력을 관리해 원하는 이전 과정으로 복구할 수 있습니다.

이제 프로젝트를 생성하겠습니다. 코드를 입력할 개발툴은 VS.Code를 사용합니다. 그리고 vite를 설치하기 위해 node.js가 필요합니다. VS.Code와 node.js에 대한 2개의 프로그램이 설치되었다면 실습을 진행하는데 충분합니다.

프로젝트 폴더를 하나 만들고 이 폴더를 VS.Code에서 오픈합니다. 그리고 터미널을 열고 다음처럼 vite를 통해 프로젝트를 생성합니다.

안내된 내용대로 다음 명령을 입력해 완료합니다.

npm install
npm run dev

웹브라우저에서 htttp://localhost/5173으로 접속해 보면 다음과 같은 결과를 볼 수 있습니다.

생성된 폴더와 파일을 보면, 먼저 node_modules 폴더에는 개발환경 또는 개발에 필요한 패키지들이 저장됩니다. public 폴더에는 이미지 등과 같은 배포될 리소스 파일이 존재하고 src에는 실제 우리가 코딩할 코드 파일이 위치하게 됩니다. src에 이미 몇가지 파일이 작성되어 있습니다. 그리고 이 index.html은 웹앱이 실행될때 가장 먼저 표시될 홈페이지입니다.

불필요한 내용을 정리하면, 먼저 index.html의 title 내용을 Tic-Tac-Toe로 변경하고 src/vite.svg 파일을 삭제합니다. 그리고 src 폴더에 있는 style.css 파일은 내용을 모두 지우고 파일만 남겨둡니다. counter.ts와 typescript.svg 파일을 삭제합니다. main.ts는 다음처럼 코드를 입력합니다.

import './style.css'

이제 새로운 코드를 작성할 준비가 모두 되었습니다. 다음에는 앞서 살펴본 클래스다이어그램에서 언급된 클래스들을 추가해 웹앱을 완성해 보겠습니다.

이후의 내용은 제 유튜브 채널을 통해 제공됩니다.

GLSL 코드와 Blender의 쉐이더 노드

쉐이더 프로그래밍 언어 중에 하나인 GLSL의 프레그먼트 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하는 내용에 대한 정리입니다. 먼저 GLSL에 대한 코드는 다음과 같습니다.

varying vec2 vUv;

void main() {
  float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.3));
  gl_FragColor = vec4(vec3(strength), 1.0);
}

결과는 다음과 같습니다. three.js를 사용했습니다.

위의 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하면 다음과 같습니다.

쉐이더의 코드는 블렌더의 쉐이더 노드로 구성할 수 있고, 그 반대로도 가능합니다. 쉐이더 코드는 매우 함축적이고 작은 코드 변화에도 그 결과는 예상하기 어려운 경우가 있습니다. 하지만 블렌더의 쉐이더 노드를 통해 먼저 그 결과를 만들고 이를 다시 쉐이더 코드로 변환한다면 좀 더 나은 개발 접근이 될 수 있습니다.

dFdx와 dFdy를 이용한 법선 벡터 계산

버텍스 쉐이더에서 정점을 흔들었을 경우 법선 벡터 역시 다시 계산을 해줘야 하는데, 이때 정점의 x와 y에 대한 편미분 값을 얻을 수 있는 dFdx와 dFdy를 이용하면 법선 벡터를 얻을 수 있습니다.

이런 경우 버텍스 쉐이더에 전달된 normal을 vary를 통해 프레그먼트 쉐이더로 전달할 필요가 없고 프레그먼트 쉐이더에서 법선 벡터를 계산해 주면 되는데, 프레그먼트 쉐이더에서 이에 대한 코드는 다음과 같습니다.

vec3 normal = normalize(
    cross(
        dFdx(vPosition.xyz),
        dFdy(vPosition.xyz)
    )
);

위의 vPosition은 버텍스 쉐이이더에서 재계산된 정점인데, 버텍스 쉐이더의 코드에서 보면 다음과 같습니다.

varying vec3 vPosition;

void main() {	
    vec3 posClone = position;
    posClone = /* 정점 흔들기(변경) */
    
    ...

    vPosition = modelMatrix * vec4(posClone, 1.0)).xyz;

    ...
}

적용 결과로 비교하면 먼저 dFdx와 dFdy를 통한 법선 벡터를 사용하지 않고 지오메트리를 통해 제공되는 법선 벡터를 그대로 사용한 경우는 아래와 같습니다.

버텍스 쉐이더에서 정점을 흔들어서 원래 제공된 법선 벡터가 맞지 않아 음영 효과가 제대로 표현되지 않는데, 이를 개선하기 위해 dFdx와 dFdy를 통한 법선 벡터를 사용한 결과는 다음과 같습니다.

Drei 컴포넌트 : Grid

Drei 컴포넌트는 R3F에서 사용할 수 있는 매우 유용한 컴포넌트의 집합입니다. 그 중 Grid 컴포넌트에 대한 사용인데 Grid 이외의 Drei 컴포넌트도 사용되고 있습니다. 예를 들어 StatsGl, Center, Grid, GizmoHelper, GizmoViewcube, GizmoViewport 입니다.

먼저 결과는 다음과 같습니다.

위의 결과에 대한 코드를 하나씩 살펴보면, 먼저 App.jsx 입니다.

import './App.css'
import { Canvas } from '@react-three/fiber'
import MyElement3D from './MyElement3D_Grid'

function App() {
  return (
    <>
      <Canvas shadows camera={{ position: [10, 12, 12], fov: 25 }}>
        <color args={["#303035"]} attach="background" />
        <MyElement3D />
      </Canvas>
    </>
  )
}

export default App

다음은 App 컴포넌트에서 사용하고 있는 MyElement3D_Grid 파일에 대한 코드입니다. 이 파일의 코드를 기능 별로 언급합니다.

먼저 모델 컴포넌트입니다.

import { AccumulativeShadows, Center, Environment, GizmoHelper, GizmoViewcube, GizmoViewport, Grid, OrbitControls, RandomizedLight, Stats, StatsGl, useGLTF } from "@react-three/drei"
import { useControls } from "leva"
import { memo } from "react"

function Robot(props) {
  const { nodes } = useGLTF('./models/model.glb')

  nodes.Scene.traverse(obj => {
    obj.castShadow = true
    obj.receiveShadow = true
  })

  console.log(nodes)
  return (
    <primitive object={nodes.Scene} {...props} />
  )
}

다음은 그림자 컴포넌트입니다. memo 고차 컴포넌트를 사용했는데 그림자일 경우 이렇게 처리하면 장점이 있다고 하네요.

const Shadows = memo(() => (
  <AccumulativeShadows temporal frames={100} color="#000000" colorBlend={0.5} alphaTest={0.9} scale={20}>
    <RandomizedLight amount={8} radius={4} position={[5, 5, -10]} />
  </AccumulativeShadows>
))

이 글의 주인공인 Grid 컴포넌트입니다. 이 컴포넌트의 속성을 UI로 설정하기 위해서 useControls를 사용했습니다.

function MyGrid() {
  const { gridSize, ...gridConfig } = useControls({
    gridSize: [10.5, 10.5],
    cellSize: { value: 0.6, min: 0, max: 10, step: 0.1 },
    cellThickness: { value: 1, min: 0, max: 5, step: 0.1 },
    cellColor: '#6f6f6f',
    sectionSize: { value: 3.3, min: 0, max: 10, step: 0.1 },
    sectionThickness: { value: 1.5, min: 0, max: 5, step: 0.1 },
    sectionColor: '#9d4b4b',
    fadeDistance: { value: 25, min: 0, max: 100, step: 1 },
    fadeStrength: { value: 1, min: 0, max: 1, step: 0.1 },
    followCamera: false,
    infiniteGrid: true
  })

  return (
    <Grid args={gridSize} position={[0, -0.01, 0]} {...gridConfig} />
  )
}

위에서 정의된 컴포넌트들을 사용한 실제 MyElement3D입니다. 리엑트의 좋은 코딩 습관은 말단 컴포넌트는 최대한 JSX 코드만을 반환하는 것으로 작성하는 것입니다. 그러면 코드 가독성이 높아지고 컴포넌트 분리가 효과적으로 됩니다.

function MyElement3D() {
  return (
    <>
      {/* <Stats /> */}
      <StatsGl />

      <OrbitControls makeDefault />
      <Environment preset="city" />
      
      {/* <Center top>
        <Suzi rotation={[-0.63, 0, 0]} scale={2} />
      </Center> */}
      
      <Center>
        <Robot scale={2} />
      </Center>

      <MyGrid />

      <GizmoHelper alignment="bottom-right" margin={[80, 80]}>
        <GizmoViewcube />
        {/* <GizmoViewport axisColors={['#9d4b4b', '#2f7f4f', '#3b5b9d']} labelColor="white" /> */}
      </GizmoHelper>
      
      <Shadows />
    </>
  )
}

export default MyElement3D

이 코드는 추후 제 유튜브 채널을 통해 영상으로 소개될 예정입니다.

Drei 컴포넌트 : RenderTexture

RenderTexture는 재질의 map 속성에 사용할 수 있는 텍스쳐를 별도의 카메라와 광원 그리고 모델로 구성된 씬을 통해 만들어 주는 컴포넌트로 그 활용성이 매우 높습니다.

RenderTexture가 적용된 Cube 컴포넌트입니다.

function Cube() {
  const textRef = useRef()
  useFrame((state) => (textRef.current.position.x = Math.sin(state.clock.elapsedTime) * 2))
  return (
    <mesh>
      <cylinderGeometry />
      <meshStandardMaterial>
        <RenderTexture attach="map" anisotropy={16}>
          <PerspectiveCamera makeDefault manual aspect={1 / 1} position={[0, 0, 5]} />
          <color attach="background" args={['orange']} />
          <ambientLight intensity={0.5} />
          <directionalLight position={[10, 10, 5]} />
          <Text font={suspend(inter).default} ref={textRef} fontSize={4} color="#555">
            hello
          </Text>
          <Dodecahedron />
        </RenderTexture>
      </meshStandardMaterial>
    </mesh>
  )
}

위의 코드에서 Dodecahedron 컴포넌트가 보이는데, 코드는 다음과 같습니다.

function Dodecahedron(props) {
  const meshRef = useRef()
  const [hovered, hover] = useState(false)
  const [clicked, click] = useState(false)
  useFrame(() => (meshRef.current.rotation.x += 0.01))
  return (
    <group {...props}>
      <mesh
        ref={meshRef}
        scale={clicked ? 1.5 : 1}
        onClick={() => click(!clicked)}
        onPointerOver={() => hover(true)}
        onPointerOut={() => hover(false)}>
        <dodecahedronGeometry args={[0.75]} />
        <meshStandardMaterial color={hovered ? 'hotpink' : '#5de4c7'} />
      </mesh>
    </group>
  )
}

실제 Cube는 다음처럼 렌더링됩니다.