10일 만에 풀스택 서점 앱을 만든 방법 (그리고 배운 점)

발행: (2025년 12월 10일 오후 06:56 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

도전 과제

10일. 나는 Amazon의 ATLAS 교육 프로그램의 일환으로 처음부터 프로덕션 수준의 온라인 서점을 구축해야 했습니다.

요구 사항은 처음엔 간단해 보였습니다: 사용자는 책을 탐색하고, 장바구니에 담고, 결제할 수 있어야 합니다. 하지만 곧 “있으면 좋은” 기능들이 내 머릿속에서 필수 기능으로 바뀌었습니다:

  • 개인화된 추천
  • 탐색 기록
  • 대량 작업이 가능한 관리자 패널
  • Amazon의 ASIN에서 영감을 받은 재고 시스템

이 프로젝트를 진행하면서 배운 점과 거의 나를 무너뜨릴 뻔한 버그들을 정리합니다.

기술 스택

백엔드

  • Java 17 + Spring Boot 3
  • DynamoDB (개발용 Local)
  • Maven

프론트엔드

  • React 18 + Vite
  • Material‑UI v5
  • React Router v6

DevOps

  • GitHub Actions for CI/CD
  • Docker for DynamoDB Local

왜 이 스택인가? Spring Boot는 내장된 의존성 주입 덕분에 빠른 개발을 가능하게 했습니다. DynamoDB는 NoSQL 패턴(조인 불가)을 생각하게 만들었고, React + MUI는 CSS 전쟁보다 기능 구현에 집중하게 해줬습니다.

도전 과제 #1: 중복 도서 악몽

문제점

3일 차. 관리자 패널이 동작하고, 대량 업로드도 정상이며 CSV 파일에서 100권을 가져올 수 있었습니다.
같은 파일을 다시 실행하니 중복 항목이 생겼습니다:

The Hobbit (ID: a1b2c3d4)
The Hobbit (ID: e5f6g7h8)  // 같은 책, 다른 ID!
The Hobbit (ID: i9j0k1l2)  // 세 번이나 실행...

원인? 무작위 UUID.

// 문제 코드
public void saveBook(Book book) {
    if (book.getId() == null) {
        book.setId(UUID.randomUUID().toString()); // 매번 다름!
    }
    bookTable.putItem(book);
}

해결책: 결정적 ID

같은 책은 언제나 같은 ID를 가져야 했습니다. 해결책은 책의 자연키(제목 + 저자)를 해시하는 것이었습니다.

public class DeterministicId {

    public static String forBook(String title, String author) {
        String input = (title + "|" + author).toLowerCase().trim();
        byte[] hash = sha1(input);
        return "b-" + toHex(hash).substring(0, 12);
    }

    private static byte[] sha1(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-1");
            return md.digest(input.getBytes(StandardCharsets.UTF_8));
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

이제 “J.R.R. Tolkien”의 “The Hobbit”은 언제나 b-7a3f2e1d9c8b를 생성합니다. 같은 CSV를 100번 가져와도 복사본은 하나뿐입니다.

핵심 교훈: 멱등성을 보장해야 할 때는 무작위 UUID 대신 비즈니스 키 기반의 결정적 식별자를 사용하세요.

도전 과제 #2: Amazon‑스타일 ASIN 시스템 구축

구현

public class AsinGenerator {

    private static final String CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";

    public static String generateFromBook(String title, String author) {
        String input = (title + "|" + author).toLowerCase().trim();
        byte[] hash = sha256(input);

        StringBuilder sb = new StringBuilder("B0"); // Amazon 형식
        for (int i = 0; i < 10; i++) {
            int idx = (hash[i] & 0xFF) % CHARS.length();
            sb.append(CHARS.charAt(idx));
        }
        return sb.toString();
    }

    private static byte[] sha256(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            return md.digest(input.getBytes(StandardCharsets.UTF_8));
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

탐색 기록 (관련 기능)

public class HistoryService {

    private static final int MAX_CAPACITY = 20;

    public void addToHistory(String username, String bookId) {
        Deque<String> history = getHistory(username);

        // 이미 존재하면 제거 (사용자가 이전에 본 경우)
        history.remove(bookId);

        // 앞쪽에 추가 (가장 최근)
        history.addFirst(bookId);

        // 용량 유지
        while (history.size() > MAX_CAPACITY) {
            history.removeLast();
        }

        saveHistory(username, history);
    }
}

addFirst 전에 remove(bookId)를 호출하면 사용자가 같은 책을 두 번 볼 때 중복이 아니라 가장 앞쪽으로 이동하게 됩니다.

도전 과제 #4: DynamoDB가 빈 문자열을 거부함

문제점

배포 과정에서 예외가 발생했습니다:

DynamoDbException: The AttributeValue for a key attribute 
cannot contain an empty string value.

React 프론트엔드가 id에 빈 문자열을 보내고 있었습니다:

const book = {
  id: '',  // 빈 문자열, null이 아님
  title: 'New Book',
  author: 'Some Author'
};

DynamoDB의 파티션 키는 빈 문자열을 허용하지 않습니다. null이나 undefined는 허용됩니다.

해결책: 방어적 깊이

프론트엔드

const bookToUpload = {
  id: book.id?.trim() || undefined,  // 빈 문자열을 undefined로 변환
  title: book.title,
  author: book.author,
};

백엔드

@PostMapping
public ResponseEntity createBook(@RequestBody Book book) {
    if (book.getId() == null || book.getId().isBlank()) {
        book.setId(DeterministicId.forBook(
            book.getTitle(),
            book.getAuthor()
        ));
    }
    adapter.save(book);
    return ResponseEntity.created(uri).build();
}

핵심 교훈: API 경계에서 항상 검증하세요. 한 언어에서는 “null”처럼 보이는 값이 다른 언어에서는 빈 문자열일 수 있습니다.

도전 과제 #5: 도서 표지가 잘려 보임

문제점

OpenLibrary 표지 API를 사용하고 있었습니다:

const coverUrl = `https://covers.openlibrary.org/b/isbn/${isbn}-L.jpg`;

CSS가 잘라내고 있었습니다:

img {
  width: 100%;
  height: 300px;
  object-fit: cover; /* 공간을 채우면서 초과 부분을 잘라냄 */
}

해결책

Amazon은 패딩을 두어 전체 이미지를 보여줍니다. 나는 object-fit: contain을 사용하고, 스타일이 적용된 컨테이너 안에 넣었습니다:

<img
  src={coverUrl}
  alt={title}
  style={{ width: '100%', height: '300px', objectFit: 'contain' }}
  onError={() => setShowFallback(true)}
/>

ISBN이 없는 도서의 경우, 그라디언트 배경을 이용한 대체 이미지를 만들었습니다(구현은 생략).

Back to Blog

관련 글

더 보기 »