10일 만에 풀스택 서점 앱을 만든 방법 (그리고 배운 점)
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이 없는 도서의 경우, 그라디언트 배경을 이용한 대체 이미지를 만들었습니다(구현은 생략).