Project Records/Project : Team Nova MJ Search

MJS 명대뉴스 반응형 구현

Frisbeen 2025. 8. 16. 19:09

 

 

 

 

대상 코드: NewsCard 컴포넌트 (article, aria-labelledby, aspect-ratio, onError 캐스팅, React.memo)

GPT의 흐름을 파악하기 위한 “왜 이렇게 썼는지”를 끝까지 파고드는 설명형 레퍼런스입니다.

시맨틱 태그부터 접근성, 이미지 비율, 이벤트 타입 캐스팅, 메모이제이션까지 한 번에 훑습니다.


1) <article>는 무엇이고, 언제 쓰나?

시맨틱 태그(의미 있는 HTML 태그) 중 하나로, 블로그 글/뉴스 카드/피드 아이템처럼 독립적으로 의미가 성립하는 콘텐츠 묶음을 감싸는 데 씁니다.

  • 특징
    • 문서 내에서 단독 재사용 가능한 콘텐츠 블록.
    • RSS, 북마크, 공유 등 콘텐츠 단위로 식별되길 원하는 영역.
    • 내부에 제목(<h1~h6>)이 있는 것이 일반적.
  • 언제 <div> 대신 <article>?
    • 이 카드 하나가 하나의 글/뉴스로 성립한다면 <article>이 더 적합.
    • 목록의 각 아이템이 독립 문서처럼 의미가 있으면 article, 단순 레이아웃 컨테이너면 div.

우리의 뉴스 카드: 링크/제목/요약/날짜가 있고, 단독으로 전달될 수 있으니 <article> 채택이 타당.


2) ARIA와 aria-labelledby 한 번에 이해하기

**ARIA(Accessible Rich Internet Applications)**는 스크린리더 같은 보조기술에 콘텐츠 의미를 확실히 알려주는 접근성 표준 속성 세트입니다.

  • aria-labelledby: 이 요소의 **이름(레이블)**이 어디에 쓰였는지를 id 참조로 가리킵니다.
    • 스크린리더는 <article>의 이름을 h3의 텍스트로 인식.
    • 의미: "이 카드의 대표 텍스트는 저 제목이다"를 명시.
  • <article aria-labelledby={`news-${id}-title`}> <h3 id={`news-${id}-title`}>제목</h3> </article>
  • 왜 필요한가?
    • 시각적으로는 제목이 눈에 보이지만, 보조기술은 구조를 따로 이해해야 함.
    • ID 연결로 요소 간 명확한 관계를 제공 → 접근성 점수/실사용성 향상.
  • 추가 팁
    • 날짜는 <time dateTime="ISO8601">표시용</time>으로 작성하면 기계가독성↑.
    • 이미지는 alt를 정확히: 의미 있는 썸네일이면 간결 설명, 장식이면 빈 문자열 alt="".

3) aspect-[16/9]는 무엇을 보장하나?

Tailwind의 aspect-[16/9]는 CSS aspect-ratio: 16 / 9;를 적용합니다. 가로:세로 비율 유지가 핵심.

  • 왜 필요한가?
    • 썸네일 원본 비율이 제각각이면 CLS(레이아웃 점프) 발생.
    • 래퍼에 고정 비율을 주고, 이미지를 object-cover로 채우면 안정적 그리드 유지.
  • 카드 구조
    • 래퍼: 비율 상자.
    • 이미지: 비율 상자 안에서 잘려도 좋으니 꽉 채우기.
  • <div className='aspect-[16/9] w-full overflow-hidden bg-gray-100'> <img className='h-full w-full object-cover' ... /> </div>
  • 대안
    • 고정 높이/너비 대신 aspect-ratio가 더 유연하고 반응형에 적합.

4) onError에서 (e.currentTarget as HTMLImageElement)로 캐스팅한 이유

React 이벤트 객체는 제네릭 타입으로, 핸들러 시그니처에 정확한 대상 타입을 지정하지 않으면 currentTarget의 타입이 느슨해집니다.

  • 현재 코드
    • currentTarget은 이벤트가 바인딩된 **그 요소(여기선 <img>)**를 가리킴.
    • 타입 시스템에 “이건 이미지야”를 알려줘야 src 접근을 허용.
  • onError={(e) => { (e.currentTarget as HTMLImageElement).src = fallback; }}
  • 더 타입 안전하게 쓰기
    • 핸들러 시그니처에 **명시적으로 HTMLImageElement**를 지정하면 캐스팅 불필요.
  • function handleImgError(e: React.SyntheticEvent<HTMLImageElement, Event>) { e.currentTarget.src = fallback; } <img onError={handleImgError} ... />
  • target vs currentTarget
    • target: 실제 이벤트가 발생한 가장 안쪽 요소.
    • currentTarget: 핸들러가 바인딩된 요소. 여기선 <img>.
    • 일반적으로 React에선 currentTarget을 신뢰.

5) React.memo로 내보낸 이유와 주의점

React.memo props가 바뀌지 않으면 리렌더를 건너뛰게 하는 고차 컴포넌트입니다.

export default memo(NewsCard);
  • 왜 유효한가?
    • 뉴스 리스트는 카드가 많이 반복됨.
    • 부모가 리렌더돼도 각 카드 props가 동일하면 재렌더 생략 → 성능 이점.
  • 주의 포인트
    • 참조 타입 props(객체/배열/함수)가 매번 새 인스턴스면 효과 감소.
    • 필요 시 memo(Comp, areEqual) 사용자 비교 함수 제공 가능.
  • 간단한 비교 함수 예시
    • 비교 범위를 최소화해 유지보수 난도를 낮추는 것도 현실적 선택.
  • export default memo(NewsCard, (prev, next) => { return ( prev.page === next.page && prev.index === next.index && prev.news.link === next.news.link && prev.news.title === next.news.title && prev.news.imageUrl === next.news.imageUrl && prev.news.summary === next.news.summary && prev.news.category === next.news.category && prev.news.date === next.news.date ); });
  • 언제 굳이 안 쓰나?
    • 카드 수가 적거나, 상태 구조 상 이미 리렌더 비용이 작을 때.
    • 불필요한 최적화는 복잡도만 추가.

6) default Parameter의 정적 자산 위치

  • fallbackSrc = '/default-thumbnail.png'처럼 루트 경로를 쓰면, 파일은 public/default-thumbnail.png에 있어야 함.
  • 번들링에 포함시키려면 import fallbackImg from '.../assets/...png'  src={news.imageUrl || fallbackImg} 사용.
  • onError에서도 같은 값을 대입해 무한 루프 방지 (이미 fallback이면 재설정하지 않기).
const fallback = fallbackSrc ?? '/default-thumbnail.png';
<img
  src={news.imageUrl?.trim() || fallback}
  onError={(e) => {
    if (e.currentTarget.src !== window.location.origin + fallback) {
      e.currentTarget.src = fallback;
    }
  }}
  ...
/>

7) 이미지 성능 디테일: loading, decoding, sizes

  • loading='lazy': 뷰포트 근처에서 로딩 시작. 초기 LCP 이미지는 eager 고려.
  • decoding='async': 디코딩 비동기 힌트(상황에 따라 체감 미미).
  • sizes: 반응형 레이아웃 입히기! 그리드 열 수와 실제 카드 폭에 맞춰 선언.

 


8) 타이포/라인 클램프/호버 효과의 의도

  • 라인 클램프: line-clamp-2, md:line-clamp-3로 화면이 넓어지면 더 많은 텍스트를 노출.
  • 호버 스케일: group-hover:scale-[1.02]로 카드 전체 호버 시 이미지만 미세 확대 → 생동감.
  • 타이포 단계: text-base → md:text-lg로 가독성/밀도 균형 조절.

 
interface NewsCardProps {
  news: NewsInfo;
  index: number;
  page: number;
  fallbackSrc?: string;
}

const ITEMS_PER_PAGE = 8;

function NewsCard({ news, index, page, fallbackSrc }: NewsCardProps) {
  const id = (page - 1) * ITEMS_PER_PAGE + index + 1;
  const fallback = fallbackSrc ?? '/default-thumbnail.png';

  function handleImgError(e: React.SyntheticEvent<HTMLImageElement, Event>) {
    // 이미 fallback이면 재설정 X → 무한루프 방지
    const fallbackUrl = new URL(fallback, window.location.origin).toString();
    if (e.currentTarget.src !== fallbackUrl) {
      e.currentTarget.src = fallbackUrl;
    }
  }

  return (
    <article className='group relative flex flex-col overflow-hidden rounded-2xl border-grey-20 bg-white shadow-sm transition hover:shadow-lg' aria-labelledby={`news-${id}-title`}>
      <a href={news.link} target='_blank' rel='noopener noreferrer' className='block'>
        <div className='aspect-[16/9] w-full overflow-hidden bg-gray-100'>
          <img
            src={news.imageUrl?.trim() || fallback}
            onError={handleImgError}
            alt={news.title}
            className='h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]'
            loading='lazy'
            sizes='(min-width:1280px) 25vw, (min-width:1024px) 33vw, (min-width:768px) 50vw, 100vw'
          />
        </div>

        <div className='flex flex-col gap-2 p-4 md:p-5'>
          <div className='flex items-center gap-2 text-xs md:text-[13px]'>
            <span className='inline-flex items-center rounded-full px-2 py-0.5 font-medium text-gray-600'>
              {news.category}
            </span>
            <time className='text-gray-500' dateTime={new Date(news.date).toISOString()}>{news.date}</time>
          </div>

          <h3 id={`news-${id}-title`} className='line-clamp-2 text-base font-semibold leading-snug md:text-lg'>
            {news.title}
          </h3>

          <p className='line-clamp-2 text-sm text-grey-40 md:line-clamp-3'>{news.summary}</p>
        </div>
      </a>
    </article>
  );
}

export default memo(NewsCard);

마무리

  • <article>/ARIA는 의미와 접근성을, aspect-ratio 레이아웃 안정성을, 이벤트 타입 명시는 타입 안전성을, memo 렌더링 성능을 잡습니다.