diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx
index 2649ff8..b02535f 100644
--- a/src/app/(root)/page.tsx
+++ b/src/app/(root)/page.tsx
@@ -4,4 +4,4 @@ export default function Home() {
프로젝트 준비
);
-}
+}
\ No newline at end of file
diff --git a/src/components/profile-img/ProfileImage.module.css b/src/components/profile-img/ProfileImage.module.css
new file mode 100644
index 0000000..d509485
--- /dev/null
+++ b/src/components/profile-img/ProfileImage.module.css
@@ -0,0 +1,197 @@
+.frame {
+ display: inline-block;
+}
+
+.outer {
+ position: relative;
+ overflow: visible;
+ display: inline-block;
+}
+
+.box {
+ width: var(--pi-box-base);
+ height: var(--pi-box-base);
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ position: relative;
+
+ border: 0;
+ outline: 0;
+ box-shadow: none;
+
+ overflow: visible;
+}
+
+.mask {
+ width: 100%;
+ height: 100%;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ overflow: hidden;
+ border-radius: inherit;
+}
+
+/* avatar */
+.avatar {
+ width: var(--pi-img-base);
+ height: var(--pi-img-base);
+
+ position: relative;
+ overflow: hidden;
+
+ background: inherit;
+ border-radius: inherit;
+}
+
+.clickable {
+ cursor: pointer;
+}
+
+/* border: breakpoint별 var로 대응 */
+.avatarBorder {
+ border-style: solid;
+ border-color: #e2e8f0;
+ border-width: var(--pi-border-base);
+ box-sizing: border-box;
+}
+
+.img {
+ display: block;
+ border-radius: inherit;
+}
+
+.cover {
+ object-fit: cover;
+}
+
+.contain {
+ object-fit: contain;
+}
+
+/* radius */
+.r8 {
+ border-radius: 8px;
+}
+.r12 {
+ border-radius: 12px;
+}
+.r20 {
+ border-radius: 20px;
+}
+.r32 {
+ border-radius: 32px;
+}
+
+/* edit 버튼 */
+.editButton {
+ position: absolute;
+ right: 3px;
+ bottom: 6px;
+
+ background: var(--color-background-tertiary);
+ border-radius: 50%;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+
+ width: 18px;
+ height: 18px;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 아이콘 표시 제어 */
+.pencilSmall {
+ display: inline-flex;
+}
+.pencilLarge {
+ display: none;
+}
+
+/* breakpoint: sm */
+@media (min-width: 640px) {
+ .box {
+ width: var(--pi-box-sm);
+ height: var(--pi-box-sm);
+ }
+ .avatar {
+ width: var(--pi-img-sm);
+ height: var(--pi-img-sm);
+ }
+ .avatarBorder {
+ border-width: var(--pi-border-sm);
+ }
+}
+
+@media (max-width: 767px) {
+ .r32 {
+ border-radius: 20px;
+ }
+}
+
+/* md 이상 */
+@media (min-width: 768px) {
+ .box {
+ width: var(--pi-box-md);
+ height: var(--pi-box-md);
+ }
+ .avatar {
+ width: var(--pi-img-md);
+ height: var(--pi-img-md);
+ }
+ .avatarBorder {
+ border-width: var(--pi-border-md);
+ }
+
+ .editButton {
+ right: 0;
+ width: 32px;
+ height: 32px;
+
+ /* ✅ md 버전에서만 흰색 보더 */
+ border: 1px solid var(--color-background-inverse);
+ }
+
+ .pencilSmall {
+ display: none;
+ }
+ .pencilLarge {
+ display: inline-flex;
+ }
+}
+
+@media (min-width: 1024px) {
+ .box {
+ width: var(--pi-box-lg);
+ height: var(--pi-box-lg);
+ }
+ .avatar {
+ width: var(--pi-img-lg);
+ height: var(--pi-img-lg);
+ }
+ .avatarBorder {
+ border-width: var(--pi-border-lg);
+ }
+}
+
+@media (min-width: 1280px) {
+ .box {
+ width: var(--pi-box-xl);
+ height: var(--pi-box-xl);
+ }
+ .avatar {
+ width: var(--pi-img-xl);
+ height: var(--pi-img-xl);
+ }
+ .avatarBorder {
+ border-width: var(--pi-border-xl);
+ }
+}
diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx
new file mode 100644
index 0000000..ce4daaf
--- /dev/null
+++ b/src/components/profile-img/ProfileImage.tsx
@@ -0,0 +1,349 @@
+'use client';
+
+/**
+ * ProfileImage Component (Atomic)
+ *
+ * - 파일 선택은 onChange로 상위에 전달
+ *
+ * Props
+ * @param src 표시할 이미지 URL (없으면 fallback)
+ * @param variant 'profile' | 'team' (fallback 분기)
+ * @param size 'xl' | 'lg' | 'md' | 'sm' | 'xs'
+ * @param responsiveSize 브레이크포인트별 size 오버라이드 (선택)
+ * @param responsiveSpec 브레이크포인트별 box/image px 직접 지정 (선택)
+ * @param radius 'r8' | 'r12' | 'r20' | 'r32'
+ *
+ * @param editable true면 input(file) 활성화 + edit 버튼 표시 가능
+ * @param showEditButton edit 버튼 표시 여부 (default true)
+ * @param clickToEdit showEditButton=false일 때 avatar 클릭으로 업로드 열기 (선택)
+ *
+ * @param showBorder 보더 표시 제어 (undefined=기본: xl/lg만 2px, true=항상 2px, false=없음)
+ *
+ * @param onFileChange 파일 선택 시 상위로 File 전달 (API는 상위에서 처리)
+ *
+ * @param priority above-the-fold일 때 true (next/image)
+ * @param alt 이미지 alt
+ * @param className wrapper 클래스 추가
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ChangeEvent, CSSProperties, KeyboardEvent } from 'react';
+import Image from 'next/image';
+import styles from './ProfileImage.module.css';
+
+import HumanBig from '@/assets/buttons/human/humanBig.svg';
+import HumanSmall from '@/assets/buttons/human/humanSmall.svg';
+
+import PencilLarge from '@/assets/buttons/edit/editButtonLarge.svg';
+import PencilSmall from '@/assets/buttons/edit/editButtonSmall.svg';
+
+import TeamDefault from '@/assets/icons/img/img.svg';
+
+export type ProfileImageSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
+export type ProfileImageVariant = 'profile' | 'team';
+export type ProfileImageRadius = 'r8' | 'r12' | 'r20' | 'r32';
+
+type Breakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl';
+type SizeSpec = { box: number; image: number };
+type ResponsiveSize = Partial>;
+type ResponsiveSpec = Partial>;
+
+export type ProfileImageProps = {
+ src?: string | null;
+ variant?: ProfileImageVariant;
+ size?: ProfileImageSize;
+ responsiveSize?: ResponsiveSize;
+ responsiveSpec?: ResponsiveSpec;
+ radius?: ProfileImageRadius;
+
+ editable?: boolean;
+ showEditButton?: boolean;
+ clickToEdit?: boolean;
+
+ showBorder?: boolean;
+
+ onFileChange?: (file: File) => void;
+
+ priority?: boolean;
+ alt?: string;
+ className?: string;
+};
+
+const BORDER_WIDTH = 2;
+
+const SIZE_PRESET: Record = {
+ xl: { box: 112, image: 100 },
+ lg: { box: 78, image: 64 },
+ md: { box: 40, image: 32 },
+ sm: { box: 32, image: 24 },
+ xs: { box: 24, image: 16 },
+};
+
+const BP_ORDER: Breakpoint[] = ['base', 'sm', 'md', 'lg', 'xl'];
+
+const BORDER_BY_SIZE: Record = {
+ xl: BORDER_WIDTH,
+ lg: BORDER_WIDTH,
+ md: 0,
+ sm: 0,
+ xs: 0,
+};
+
+function getDefaultResponsiveSize(
+ baseSize: ProfileImageSize,
+): Record {
+ if (baseSize === 'xl') return { base: 'lg', sm: 'lg', md: 'xl', lg: 'xl', xl: 'xl' };
+ return { base: baseSize, sm: baseSize, md: baseSize, lg: baseSize, xl: baseSize };
+}
+
+function resolveResponsiveSpec(
+ baseSize: ProfileImageSize,
+ responsiveSize?: ResponsiveSize,
+ responsiveSpec?: ResponsiveSpec,
+): { spec: Record; sizeByBp: Record } {
+ const defaults = getDefaultResponsiveSize(baseSize);
+
+ const sizeByBp: Record = {
+ base: responsiveSize?.base ?? defaults.base,
+ sm: responsiveSize?.sm ?? defaults.sm,
+ md: responsiveSize?.md ?? defaults.md,
+ lg: responsiveSize?.lg ?? defaults.lg,
+ xl: responsiveSize?.xl ?? defaults.xl,
+ };
+
+ const spec: Record = {
+ base: responsiveSpec?.base ?? SIZE_PRESET[sizeByBp.base],
+ sm: responsiveSpec?.sm ?? SIZE_PRESET[sizeByBp.sm],
+ md: responsiveSpec?.md ?? SIZE_PRESET[sizeByBp.md],
+ lg: responsiveSpec?.lg ?? SIZE_PRESET[sizeByBp.lg],
+ xl: responsiveSpec?.xl ?? SIZE_PRESET[sizeByBp.xl],
+ };
+
+ return { spec, sizeByBp };
+}
+
+function resolveResponsiveBorder(
+ sizeByBp: Record,
+ responsiveSpec: ResponsiveSpec | undefined,
+ showBorder: boolean | undefined,
+): Record {
+ const result: Partial> = {};
+
+ if (showBorder === false) {
+ for (const bp of BP_ORDER) result[bp] = 0;
+ return result as Record;
+ }
+
+ if (showBorder === true) {
+ for (const bp of BP_ORDER) result[bp] = BORDER_WIDTH;
+ return result as Record;
+ }
+
+ for (const bp of BP_ORDER) {
+ if (responsiveSpec?.[bp]) {
+ result[bp] = bp === 'lg' || bp === 'xl' ? BORDER_WIDTH : 0;
+ continue;
+ }
+ result[bp] = BORDER_BY_SIZE[sizeByBp[bp]];
+ }
+
+ return result as Record;
+}
+
+function getFallback(variant: ProfileImageVariant, baseSize: ProfileImageSize) {
+ if (variant === 'team') return TeamDefault;
+ return baseSize === 'xs' || baseSize === 'sm' ? HumanSmall : HumanBig;
+}
+
+function buildSizesAttr(spec: Record) {
+ return [
+ `(min-width: 1280px) ${spec.xl.image}px`,
+ `(min-width: 1024px) ${spec.lg.image}px`,
+ `(min-width: 768px) ${spec.md.image}px`,
+ `(min-width: 640px) ${spec.sm.image}px`,
+ `${spec.base.image}px`,
+ ].join(', ');
+}
+
+export default function ProfileImage({
+ src,
+ variant = 'profile',
+ size = 'md',
+ responsiveSize,
+ responsiveSpec,
+ radius = 'r12',
+ editable = false,
+ showEditButton = true,
+ clickToEdit,
+ showBorder,
+ onFileChange,
+ priority = false,
+ alt = 'profile image',
+ className,
+}: ProfileImageProps) {
+ const inputRef = useRef(null);
+
+ const [previewUrl, setPreviewUrl] = useState(null);
+ const [erroredSrc, setErroredSrc] = useState(null);
+
+ // blob url 정리
+ useEffect(() => {
+ return () => {
+ if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
+ };
+ }, [previewUrl]);
+
+ const { spec: resolvedSpec, sizeByBp } = useMemo(
+ () => resolveResponsiveSpec(size, responsiveSize, responsiveSpec),
+ [size, responsiveSize, responsiveSpec],
+ );
+
+ const resolvedBorder = useMemo(
+ () => resolveResponsiveBorder(sizeByBp, responsiveSpec, showBorder),
+ [sizeByBp, responsiveSpec, showBorder],
+ );
+
+ const sizesAttr = useMemo(() => buildSizesAttr(resolvedSpec), [resolvedSpec]);
+ const fallback = useMemo(() => getFallback(variant, size), [variant, size]);
+
+ const imageSrc = previewUrl ?? src ?? null;
+ const currentSrcKey = typeof imageSrc === 'string' ? imageSrc : null;
+ const isErrored = !!currentSrcKey && erroredSrc === currentSrcKey;
+
+ const effectiveSrc = isErrored ? fallback : imageSrc || fallback;
+ const usingFallback = isErrored || !imageSrc;
+
+ const shouldClickToEdit = clickToEdit ?? (editable && !showEditButton);
+
+ const handleEditClick = useCallback(() => {
+ inputRef.current?.click();
+ }, []);
+
+ const handleAvatarClick = useCallback(() => {
+ if (editable && shouldClickToEdit) handleEditClick();
+ }, [editable, shouldClickToEdit, handleEditClick]);
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (!editable || !shouldClickToEdit) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleEditClick();
+ }
+ },
+ [editable, shouldClickToEdit, handleEditClick],
+ );
+
+ const handleFileChange = useCallback(
+ (e: ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setErroredSrc(null);
+
+ // local preview
+ const localUrl = URL.createObjectURL(file);
+ setPreviewUrl((prev) => {
+ if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
+ return localUrl;
+ });
+
+ onFileChange?.(file);
+
+ e.target.value = '';
+ },
+ [onFileChange],
+ );
+
+ const styleVars = useMemo(() => {
+ const s = resolvedSpec;
+ const b = resolvedBorder;
+
+ const vars: CSSProperties & Record = {
+ '--pi-box-base': `${s.base.box}px`,
+ '--pi-img-base': `${s.base.image}px`,
+ '--pi-border-base': `${b.base}px`,
+
+ '--pi-box-sm': `${s.sm.box}px`,
+ '--pi-img-sm': `${s.sm.image}px`,
+ '--pi-border-sm': `${b.sm}px`,
+
+ '--pi-box-md': `${s.md.box}px`,
+ '--pi-img-md': `${s.md.image}px`,
+ '--pi-border-md': `${b.md}px`,
+
+ '--pi-box-lg': `${s.lg.box}px`,
+ '--pi-img-lg': `${s.lg.image}px`,
+ '--pi-border-lg': `${b.lg}px`,
+
+ '--pi-box-xl': `${s.xl.box}px`,
+ '--pi-img-xl': `${s.xl.image}px`,
+ '--pi-border-xl': `${b.xl}px`,
+ };
+
+ return vars;
+ }, [resolvedSpec, resolvedBorder]);
+
+ const hasBorder =
+ resolvedBorder.base > 0 ||
+ resolvedBorder.sm > 0 ||
+ resolvedBorder.md > 0 ||
+ resolvedBorder.lg > 0 ||
+ resolvedBorder.xl > 0;
+
+ return (
+
+
+
+
+
+ {
+ if (typeof imageSrc === 'string') setErroredSrc(imageSrc);
+ }}
+ />
+
+
+
+ {editable && showEditButton && (
+
+ )}
+
+ {editable && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/profile-img/index.ts b/src/components/profile-img/index.ts
new file mode 100644
index 0000000..d2ba68e
--- /dev/null
+++ b/src/components/profile-img/index.ts
@@ -0,0 +1,7 @@
+export { default as ProfileImage } from './ProfileImage';
+export type {
+ ProfileImageProps,
+ ProfileImageSize,
+ ProfileImageVariant,
+ ProfileImageRadius,
+} from './ProfileImage';