diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ImageDimensions.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ImageDimensions.java
new file mode 100644
index 0000000..3ad3551
--- /dev/null
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/ImageDimensions.java
@@ -0,0 +1,87 @@
+package dev.caskeleton.application.techlog.studio.service;
+
+import java.util.Optional;
+
+/**
+ * 업로드한 그림의 픽셀 크기를 헤더에서 읽는다.
+ *
+ *
업로드가 이 값을 기록하지 않아 모든 그림이 화면에서 사라졌다. 렌더러는 치수를 모르면 자리를
+ * 잡을 수 없고, 그때 쓰던 대체값 1×1 이 {@code loading="lazy"} 와 만나 브라우저가 영영 가져오지
+ * 않는 상자가 됐다. 화면 쪽은 모르는 치수를 정직하게 비우도록 고쳤고, 이쪽은 애초에 알 수 있는
+ * 값을 기록한다.
+ *
+ *
디코딩하지 않고 헤더만 읽는다. {@code ImageIO} 는 {@code java.desktop} 모듈을 끌어오고
+ * 런타임 이미지가 그것을 담고 있으리라는 보장이 없다 — 없는 배포에서 업로드가 통째로 실패하는
+ * 것보다, 아는 형식의 헤더 몇 바이트를 직접 읽는 편이 낫다. 모르는 형식은 비운다: 크기를 모르는
+ * 것과 크기가 0 인 것은 다르고, 화면은 그 둘을 구분한다.
+ */
+public final class ImageDimensions {
+
+ /** 픽셀 크기. 둘 다 양수일 때만 만든다. */
+ public record Size(int width, int height) {}
+
+ private ImageDimensions() {}
+
+ public static Optional of(String mediaType, byte[] content) {
+ if (mediaType == null || content == null) {
+ return Optional.empty();
+ }
+ return switch (mediaType) {
+ case "image/png" -> png(content);
+ case "image/gif" -> gif(content);
+ case "image/jpeg" -> jpeg(content);
+ // WebP 와 SVG 는 읽지 않는다. WebP 는 VP8/VP8L/VP8X 세 갈래로 형식이 갈리고, SVG 는
+ // 픽셀 크기가 없을 수도 있는 벡터다 — 둘 다 "모른다" 가 정직한 답이다.
+ default -> Optional.empty();
+ };
+ }
+
+ /** IHDR 은 항상 첫 청크이고, 폭·높이가 그 앞 8바이트다. */
+ private static Optional png(byte[] c) {
+ if (c.length < 24) {
+ return Optional.empty();
+ }
+ return size(int32(c, 16), int32(c, 20));
+ }
+
+ /** 논리 화면 기술자. 리틀엔디언 16비트 둘. */
+ private static Optional gif(byte[] c) {
+ if (c.length < 10) {
+ return Optional.empty();
+ }
+ return size((c[6] & 0xFF) | ((c[7] & 0xFF) << 8), (c[8] & 0xFF) | ((c[9] & 0xFF) << 8));
+ }
+
+ /**
+ * SOF 마커까지 세그먼트를 건너뛴다. 어느 SOF 인지는 상관없다 — 어떤 것이든 그 안의 높이·폭이
+ * 그림의 크기다.
+ */
+ private static Optional jpeg(byte[] c) {
+ int i = 2;
+ while (i + 9 < c.length) {
+ if ((c[i] & 0xFF) != 0xFF) {
+ i++;
+ continue;
+ }
+ int marker = c[i + 1] & 0xFF;
+ // SOF0..SOF15, 단 DHT(C4)·JPG(C8)·DAC(CC) 는 SOF 가 아니다.
+ if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) {
+ return size((c[i + 7] & 0xFF) << 8 | (c[i + 8] & 0xFF), (c[i + 5] & 0xFF) << 8 | (c[i + 6] & 0xFF));
+ }
+ int length = (c[i + 2] & 0xFF) << 8 | (c[i + 3] & 0xFF);
+ if (length < 2) {
+ return Optional.empty();
+ }
+ i += 2 + length;
+ }
+ return Optional.empty();
+ }
+
+ private static int int32(byte[] c, int at) {
+ return ((c[at] & 0xFF) << 24) | ((c[at + 1] & 0xFF) << 16) | ((c[at + 2] & 0xFF) << 8) | (c[at + 3] & 0xFF);
+ }
+
+ private static Optional size(int width, int height) {
+ return width > 0 && height > 0 ? Optional.of(new Size(width, height)) : Optional.empty();
+ }
+}
diff --git a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java
index 89bfcc6..bc258d2 100644
--- a/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java
+++ b/src/application-core/src/main/java/dev/caskeleton/application/techlog/studio/service/UploadStudioAssetUseCase.java
@@ -82,6 +82,11 @@ public class UploadStudioAssetUseCase implements CommandUseCase dimensions =
+ ImageDimensions.of(mediaType, content);
+
UUID assetId = idGenerator.get();
String assetKey = assetKeyFor(input.originalFilename(), assetId);
String objectKey = "techlog/assets/" + assetId;
@@ -107,8 +112,9 @@ public class UploadStudioAssetUseCase implements CommandUseCase>> 24);
+ bytes[at + 1] = (byte) (value >>> 16);
+ bytes[at + 2] = (byte) (value >>> 8);
+ bytes[at + 3] = (byte) value;
+ }
+
+ private static byte[] gif(int width, int height) {
+ byte[] bytes = new byte[10];
+ bytes[0] = 'G';
+ bytes[1] = 'I';
+ bytes[2] = 'F';
+ bytes[3] = '8';
+ // 리틀엔디언이다 — PNG 와 반대다.
+ bytes[6] = (byte) (width & 0xFF);
+ bytes[7] = (byte) (width >>> 8);
+ bytes[8] = (byte) (height & 0xFF);
+ bytes[9] = (byte) (height >>> 8);
+ return bytes;
+ }
+
+ /** APP0 세그먼트 하나를 건너뛴 뒤 SOF0 이 오는, 흔한 배치. */
+ private static byte[] jpeg(int width, int height) {
+ byte[] bytes = new byte[2 + 4 + 12 + 11];
+ int i = 0;
+ bytes[i++] = (byte) 0xFF;
+ bytes[i++] = (byte) 0xD8;
+ bytes[i++] = (byte) 0xFF;
+ bytes[i++] = (byte) 0xE0;
+ bytes[i++] = 0;
+ bytes[i++] = 14; // 길이 = 자기 자신 2 + 내용 12
+ i += 12;
+ bytes[i++] = (byte) 0xFF;
+ bytes[i++] = (byte) 0xC0;
+ bytes[i++] = 0;
+ bytes[i++] = 11;
+ bytes[i++] = 8; // 정밀도
+ bytes[i++] = (byte) (height >>> 8);
+ bytes[i++] = (byte) height;
+ bytes[i++] = (byte) (width >>> 8);
+ bytes[i] = (byte) width;
+ return bytes;
+ }
+
+ @Test
+ void readsPngDimensionsFromTheHeader() {
+ assertThat(ImageDimensions.of("image/png", png(1920, 1080)))
+ .contains(new ImageDimensions.Size(1920, 1080));
+ }
+
+ @Test
+ void readsGifDimensionsLittleEndian() {
+ assertThat(ImageDimensions.of("image/gif", gif(640, 480)))
+ .contains(new ImageDimensions.Size(640, 480));
+ }
+
+ @Test
+ void readsJpegDimensionsPastAnEarlierSegment() {
+ // 세그먼트를 건너뛰지 못하면 APP0 의 내용을 크기로 읽는다 — 그 실수가 여기서 걸린다.
+ assertThat(ImageDimensions.of("image/jpeg", jpeg(800, 600)))
+ .contains(new ImageDimensions.Size(800, 600));
+ }
+
+ @Test
+ void answersEmptyForFormatsItDoesNotRead() {
+ // 모르는 것과 0 은 다르다. 화면이 그 둘을 구분하므로 여기서 섞으면 안 된다.
+ assertThat(ImageDimensions.of("image/webp", new byte[64])).isEqualTo(Optional.empty());
+ assertThat(ImageDimensions.of("image/svg+xml", "".getBytes(java.nio.charset.StandardCharsets.UTF_8))).isEqualTo(Optional.empty());
+ }
+
+ @Test
+ void answersEmptyForTruncatedOrAbsentContent() {
+ assertThat(ImageDimensions.of("image/png", new byte[8])).isEqualTo(Optional.empty());
+ assertThat(ImageDimensions.of("image/png", null)).isEqualTo(Optional.empty());
+ assertThat(ImageDimensions.of(null, png(10, 10))).isEqualTo(Optional.empty());
+ }
+
+ @Test
+ void answersEmptyWhenTheHeaderClaimsNoArea() {
+ assertThat(ImageDimensions.of("image/png", png(0, 480))).isEqualTo(Optional.empty());
+ }
+}