Next.js에서 스토리북을 사용중입니다.
이미지를 포함하는 컴포넌트에서 오류가 발생하더군요. 뭐 하나 그냥 넘어가는게 없네
바로 이녀석이었습니다!
Unexpected error while loading ${ComponentName}.stories.tsx: Cannot read properties of undefined (reading 'src')
일단 이 오류 메시지는 개발자도구에서 확인할 수 있었습니다. 그리고 여기에 표시되는 컴포넌트는 스토리북에 노출되지 않았죠.
Next.js의 Image 태그에 prop으로 전달한 src 값에 문제가 있는 것 같았어요.
이 오류가 발생한 컴포넌트에서 문제가 되는 부분은 바로 이부분이었습니다.
import { AssetProduct } from '@public/assets/png/products/assetProduct';
...
return (
...
// src에 전달된 AssetProduct[product] 값이 문제!!
<Image src={AssetProduct[product]} alt={product} width={40} height={40} />
...
);
그럼 AssetProduct
의 내용도 살펴보겠습니다.
assetProduct.ts
export const AssetProduct = {
Prod1: require('./Prod1.png').default.src,
Prod2: require('./Prod2.png').default.src,
Prod3: require('./Prod3.png').default.src,
}
이렇습니다..!
assetProduct.ts 스크립트가 있는 위치에 Prod1.png
, Prod2.png
, Prod3.png
가 있는데, 이 assetProduct.ts 각 png 파일의 경로를 반환하는 역할을 하고 있는 것이죠.
이 코드가 분명 문제 없이 잘 실행되어 컴포넌트가 정상적으로 렌더링 되는 것을 확인했었는데 스토리북에서는 콘솔 에러를 발생시키며 렌더링되지 않았던 것입니다.
require('./Prod.png').default
가 스토리북에서는 undefined값이 되었던 것이죠.
이 문제는 간단하게 해결되었습니다.
default
가 undefined였으니 default.src를 지우니 되더군요! 스퇴북이 아닌 페이지에서도 정상적으로 실행되었습니다.
(그러나 이것은 뒤에 가서 다시 새로운 문제의 원인이 됩니다...)
이제 스토리북에 컴포넌트가 노출되었지만 다음 오류가 새롭게 등장합니다.. 아나ㅋㅋ
Failed to parse src "static/media/public/${ImageName}.png" on 'next/image',
if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)
컴포넌트는 스토리북에 노출되었으나 이 오류 메시지를 출력하면서 렌더링은 여전히 안되고 있습니다.
내용을 보면 src는 slash("/")로 시작하거나 http://
혹은 https://
로 시작하는 절대 경로가 되어야 한다는 것 같군요.
스토리북의 웹팩 설정 파일을 건드리며 삽질을 하다가 해결 못하고 구글링한 결과 해결 방법을 찾을 수 있었습니다.
Storybook mock module 만들기
위 블로그에서 언급하고 있는 에러 메시지와는 조금 다르긴 하지만 제가 앓고 있던 에러를 해결할 수 있는 방법이 적혀있었습니다. 블로그 포스팅 내용을 빌려 문제의 원인을 간단하게 언급하자면 Next.js에서는 i18n, react-router, image 등에 대한 Provider를 프레임워크가 주입해주기 때문에 개발자가 명시적으로 주입하지 않아도 되지만, 스토리북에서는 Provider를 직접 처리해주지 않기 떄문이었습니다.
이어서 해결 방법도 마찬가지로 빌려오자면 다음과 같습니다.
먼저 __mocks__
라는 이름의 폴더를 만들어주고 다음 스크립트를 작성합니다.
mocks > next > router.js
export const useRouter = () => ({
route: ‘/’,
pathname: ‘’,
query: ‘’,
asPath: ‘’,
prefetch: () => {},
push: () => {},
});
export default { useRouter };
mocks > next > link.js
import React from ‘react’;
export default function ({ children }) {
return <a>{children}</a>;
}
mocks > next > image.js
import React from ‘react’;
export default function (props) {
return <img {…props} />
}
그리고 스토리북의 main.js
에 다음 코드를 추가해줍니다.
.storybook > main.js
module.exports = {
…
webpackFinal: (config) => {
config.resolve.alias[‘next/router’] = require.resolve(‘../__mocks__/next/router.js’);
config.resolve.alias[‘next/link’] = require.resolve(‘../__mocks__/next/link.js’);
config.resolve.alias[‘next/image’] = require.resolve(‘../__mocks__/next/image.js’);
return config;
},
};
그럼 위 오류도 해결되는 것을 확인할 수 있습니다!
모든 것이 잘 해결된 줄 알았으나..
Image 태그 말고 css에서 background: url(${imgSrc});
와 같은 식으로 이미지를 넣은 컴포넌트에서 또 문제가 발생하기 시작했습니다. 이번에는 스토리북이 아니고 개발하는 페이지에서 말이죠.
css가 이렇게 적용되고 있었어요.
url() 안에 string이 들어가야하는데 object가 들어가고 있었던겁니다ㅠㅠ
이 문제는 아까 첫번째로 발생한 오류를 해결할 때
Prod1: require('./Prod1.png').default.src,
이 코드를
Prod1: require('./Prod1.png'),
이렇게 수정하면서 일어난 일이었습니다.
후.. 그렇다고 다시 default.src를 추가해주면 스토리북에서 문제가 발생하기 때문에........
이 문제를 어떻게 풀어야 할지 새로운 고민에 빠졌습니다.
일단 정리를 하자면 개발중인 페이지에서는 default.src가 붙어 있을때, 스토리북에서는 없을 때 정상적으로 동작하고 있습니다.
// 개발중 페이지에서 정상 동작, 스토리북에서 오류
Prod1: require('./Prod1.png').default.src,
// 스토리북에서 정상 동작, 개발 페이지에서 이미지가 보이지 않음
Prod1: require('./Prod1.png'),
default.src를 붙일 때 스토리북에서 다음 오류가 출력됩니다.
첫번째 오류랑 같은 내용같은데 스토리북에 컴포넌트는 노출되면서 렌더링만 안되는 상황이네요. 어쨌든 오류 메시지에서 require('./Prod1.png').default
가 undefined라고 알려주고 있군요.
그럼 require('./Prod1.png').default
가 undefined가 아니면 require('./Prod1.png').default.src
를 background url로 사용하고 require('./Prod1.png').default
가 undefined가 이면 require('./Prod1.png')
를 background url로 사용하면 되겠네요!! (이런 식으로 해도 되는지 잘 모르겠습니다.)
그래서 png 파일을 읽어주는 코드는 다음과 같이 변하게 됩니다.
Prod1: require('./Prod1.png')?.default?.src ?? require('./Prod1.png'),
이제 스토리북과 개발 페이지 모두 배경 이미지가 잘 출력되는 것을 확인할 수 있었습니다!!!! 드디어 끝!!!!
이 아니라 이미지마다 모두 이런식의 처리를 해주는 것이 굉장히 걸리는군요.
export const AssetProduct = {
Prod1: require('./Prod1.png')?.default?.src ?? require('./Prod1.png'),
Prod2: require('./Prod2.png')?.default?.src ?? require('./Prod2.png'),
Prod3: require('./Prod3.png')?.default?.src ?? require('./Prod3.png'),
}
좀 멋있게 바꿔보겠습니다.
관심사별로 폴더를 나누어 이미지 파일을 관리하고 있습니다.
현재 폴더에서는 Product 라는 카테고리에 대한 이미지만 관리하고 있다는 점 참고해주세요.
export const PRODUCTS = ['Prod1', 'Prod2', 'Prod3'] as const;
export type Product = typeof PRODUCTS[number];
export const assetProduct: {
[key in Product]?: any;
} = (() => {
return PRODUCTS.reduce((acc, type) => {
const asset = require(`./${type}.png`);
return { ...acc, [type]: asset?.default?.src ?? asset };
}, {});
})();
이렇게 작성하면 Product 타입은 'Prod1' | 'Prod2' | 'Prod3'
의 string Union 타입이 됩니다.
타입스크립트 string Union type의 멤버 string인지 검사하기 참조
Product 타입이 더 추가된다면 중복된 코드를 더 추가하는 것이 아닌 PRODUCTS 배열에 요소만 더 추가하면 되겠죠.
근데 관심사별로 폴더를 나누었기 때문에 이 이미지를 관리하는 폴더가 Product
만 있는 것이 아닙니다.
그럼 Solution
이라는 폴더가 있고 그 폴더 안에는 Solution1.png
, Solution2.png
, Solution3.png
, Solution4.png
라는 파일이 있다고 해볼게요. 그럼 Solution
폴더 안에 다음 스크립트도 있어야 합니다.
export const SOLUTIONS = ['Solution1', 'Solution2', 'Solution3', 'Solution4'] as const;
export type Solution = typeof SOLUTIONS[number];
export const assetSolution: {
[key in Solution]?: any;
} = (() => {
return SOLUTIONS.reduce((acc, type) => {
const asset = require(`./${type}.png`);
return { ...acc, [type]: asset?.default?.src ?? asset };
}, {});
})();
이제 Product
폴더 안에 있던 스크립트와 중복이 보이기 시작합니다.
중복되는 로직을 하나의 스크립트로 묶도록 하겠습니다.
assetLoader.ts
export function getAssets<T extends readonly string[]>(
names: T,
extension = 'png',
): {
[key in T[number]]?: any;
} {
return names.reduce((acc, name) => {
const asset = require(`./${name}.${extension}`);
return {
...acc,
[name]: asset?.default?.src ?? asset,
};
}, {});
}
이렇게 assetLoader.ts 파일을 작성했습니다.
그리고 각각의 폴더에 있던 스크립트는 다음과 같은 모양으로 바뀌게 됩니다.
export const AssetProduct = getAssets<typeof PRODUCTS>(PRODUCTS);
그런데 오잉?
이 assetLoader.ts를 public
폴더가 아닌 src
폴더에 위치시키니 다음 에러가 뜨기 시작합니다. 또..
Module not found: Can't resolve 'fs'
require()
때문에 그러겠구나~ 하면서 assetLoader.ts를 public
폴더로 옮겨보았습니다. 그랬더니 에러가 바뀌었습니다 ㅋㅋ
Error: Cannot find module: './Prod1.png'
아!!! 그렇습니다. 관심사별로 이미지 폴더를 분리했기 때문에 assetLoader.ts는 이미지들과 같은 경로에 위치하고 있을 수 없습니다. 그렇기 때문에 아래 코드로 이미지를 불러올 수 없었던 것입니다!
...
const asset = require(`./${name}.${extension}`);
...
그럼 각 폴더에서 현재 경로를 assetLoader.ts의 getAssets() 함수에 전달해주면 될 것 같아요.
node.js의 __dirname 키워드를 콘솔로 출력하여 경로를 올바르게 전달할 수 있을지 확인해보겠습니다.
public > assets > products > assetProduct.ts
export const PRODUCTS = ['Prod1', 'Prod2', 'Prod3'] as const;
export const AssetProduct = getAssets<typeof PRODUCTS>(PRODUCTS);
// 현재 경로 출력
console.log(__dirname);
기대 출력 결과
${public 경로}/assets/products
실제 출력 결과
또 문제에 봉착했군요.. 분명 __dirname을 출력한 스크립트가 위치한 폴더는 public > assets > products 이었습니다.
근데 실제로 출력되는 경로는 .next > server > pages 가 나왔습니다..ㅋ
그렇다는건 폴더 경로를 getAssets()로 전달해줄 수 없다는 것이죠. 그럼 require() 함수 호출을 각 관심사 폴더 내에 있는 스크립트에서 해주어야 했습니다. 그래서 require() 함수를 호출하는 부분을 getAssets()에 콜백으로 전달했습니다.
export const PRODUCTS = ['Prod1', 'Prod2', 'Prod3'] as const;
export const AssetSolution = getAssets<typeof SOLUTION_TYPES>(SOLUTION_TYPES, (filename) => require(`./${filename}`));
그렇게 해서 완성된 assetLoader.ts입니다.
export function getAssets<T extends readonly string[]>(
names: T,
callback: (filename: string) => any,
extension = 'png',
): {
[key in T[number]]?: any;
} {
return names.reduce((acc, name) => {
const asset = callback(`${name}.${extension}`);
return {
...acc,
[name]: asset?.default?.src ?? asset,
};
}, {});
}
이제 드디어 스토리북 문제도 해결되고 코드도 정리되었습니다!!ㅠㅠㅠㅠ