이번 실습의 목표는 “Board 서비스”에서 Author(회원) 도메인에 대한 기본 CRUD API(회원가입/목록/상세/삭제)를 직접 설계하고 구현해보는 것이었다.
원본 코드(Controller/Service/Repository/DTO/Domain)를 기준으로, 어떤 요구사항을 어떤 형태로 처리했는지 정리한다.
또한 이 떄 발생한 에러에 대해 중점적으로 정리해보았다.
1) 도메인(Author) 요구사항
Author는 회원을 표현하는 도메인(Entity)로, 아래 필드를 가진다.
id: 식별자(PK 역할)name: 이름password: 비밀번호
실습 단계에서는 DB가 없어서,id는 DB에서 생성되는 대신 Repository에서staticId로 “증가시키며 부여”하는 형태로 동작하도록 구성되어 있었다(원본 Repository 코드 기준).2) API 요구사항(원본 Controller 기준)
원본
AuthorController는/author를 prefix로 사용하고, 아래 4개의 엔드포인트를 제공한다.2-1) 회원가입(Create)
- URL:
POST /author/create- 요청 바디(JSON):
{"name":"lee","email":"lee@naver.com","password":"1234"}- 처리 흐름
- Controller가
AuthorCreateDto로 요청 바디를 받는다.- Service에서
Author엔티티를 조립한 뒤 저장을 위임한다.- 응답:
"OK"(문자열)
즉 “요청은 DTO로 받고, 저장은 Entity로 한다”가 기본 요구사항이다.2-2) 회원목록조회(Read - List)
- URL:
GET /author/list- 응답(JSON 배열):
List<AuthorListDto>- 처리 흐름
- Repository에서 전체 Author 목록을 가져온다.
- Service에서 목록 응답용 DTO(
AuthorListDto)로 변환해 반환한다.
목록 조회는 “리스트 응답”이며, 원본 코드 흐름상 목록 DTO는 보통 비밀번호 같은 민감정보를 제외하는 목적을 가진다(원본 DTO 설계 의도 기준).2-3) 회원상세조회(Read - Detail)
- URL:
GET /author/{id}- PathVariable:
id- 응답(JSON):
AuthorDetailDto- 처리 흐름
id로 단건 조회한다.- 없으면 예외를 발생시키는 방식으로 처리한다(
NoSuchElementException).
상세 조회는 “단건 응답”이고, 원본 코드에서는 detail DTO에 password까지 포함되어 있었다(즉 목록/상세의 응답 형태를 DTO로 분리한 요구사항).2-4) 회원탈퇴(Delete)
- URL:
DELETE /author/{id}- PathVariable:
id- 응답:
"OK"(문자열)- 원본 상태
- 실제 삭제 로직(Repository에서 제거)은 구현되어 있지 않고, 로그 출력만 수행한다.
즉 삭제 API는 “URL/메서드 설계까지 포함한 요구사항”으로 먼저 만들어두고, 내부 구현은 추후로 미뤄둔 상태다.3) DTO 요구사항(요청/응답 분리)
원본 코드 기준으로 DTO는 목적별로 분리되어 있다.
AuthorCreateDto: 회원가입 요청을 받기 위한 DTO (name/email/password)AuthorListDto: 목록 응답 DTO (id/name/email)AuthorDetailDto: 상세 응답 DTO (id/name/email/password)
여기서 핵심 요구사항은 “서비스 목적에 따라 응답 형태가 달라지므로, Entity 하나로 요청/응답을 다 커버하지 않는다”는 점이다.
즉 Entity는 DB 구조(원형)이고, 요청/응답은 DTO로 분리한다.4) 저장소 요구사항(DB 대신 List)
원본 Repository는 실제 DB 통신 대신, 아래 방식으로 동작한다.
- 내부 저장:
List<Author>- ID 부여:
staticId++로 증가시키며 부여- 조회
- 전체 조회: 리스트 반환
- 단건 조회: for문으로 id 탐색 후
Optional반환
즉 “DB를 붙이기 전 단계에서 CRUD 흐름과 계층 분리를 연습”하는 것이 원본 실습의 핵심 요구사항이다.
Author API(회원가입/목록/상세)를 만들면서 “코드는 맞는 것 같은데 결과가 계속 이상하게 나오는” 트러블을 겪었다.
결론부터 말하면, 대부분의 문제는 DB 없이 List로 레포지토리를 흉내 내는 구조 + DI 없이 new로 의존성을 연결한 구조에서 발생했다.
이번 글에서는 아래 3가지를 트러블슈팅 관점으로 정리한다.
Author를 저장하는데 id가 없거나, id를 어디서 세팅해야 하는지 애매해진다.
특히 “컨트롤러에서 값이 제대로 담겨서 서비스/레포지토리까지 내려갔나?” 같은 의문이 생긴다.
DB 대신 List를 쓰는 in-memory 레포지토리에서는 id 역할이 필요하니까, 레포지토리에서 static 값을 증가시키며 PK를 흉내낸다.
private List<Author> authorList = new ArrayList<>();
private static Long staticId = 1L;
author.setId(staticId++);
포인트
- 이건 “실제 설계”라기보다 DB가 없으니 식별자 개념만 흉내낸 임시 구현이다.
- 실무에서는 보통 DB가 ID를 생성하고(예: auto increment), 애플리케이션이 id를 직접 set하지 않는다.
요청을 여러 번 보내면 이전에 저장했던 데이터가 사라진 것처럼 보인다.
한 번 저장하고 목록 조회하면 잘 나오는데, 다음 요청부터 갑자기 비어있거나 id가 이상해지는 식으로 흐름이 깨진다.
컨트롤러 메서드가 실행될 때마다 new AuthorService() 같은 방식으로 서비스를 생성하면, 서비스 내부 상태/참조들도 매번 초기화된다.
즉 “서버는 계속 떠 있는데, 내 코드가 요청마다 새 객체를 만들어서 계속 리셋시키는 상황”이 된다.
컨트롤러 생성 시점에 서비스 객체를 1번만 만들고 필드로 재사용한다.
private AuthorService authorService;
public AuthorController() {
this.authorService = new AuthorService();
}
스프링 DI로 해결한다.
컨트롤러는 서비스를 직접 new로 만들지 않고 주입받는 구조로 바꾸는 게 정석이다(생성자 주입 등).
컨트롤러에서 서비스를 “재사용”하도록 바꿨는데도 데이터가 여전히 저장이 안 되는 느낌이 든다.
원인을 더 파보면 서비스 내부에서 레포지토리를 매번 생성하거나(또는 서비스 생성 시마다 새 레포지토리가 연결되는 구조) 상태가 유지되지 않는 케이스가 나온다.
레포지토리가 List로 데이터를 들고 있는 in-memory 구조일 때는, 레포지토리 인스턴스가 바뀌는 순간 데이터가 “다른 통”으로 들어가 버린다.
즉 new AuthorRepository()가 계속 일어나면 저장소가 계속 새로 만들어지는 것과 같다.
서비스 생성자에서 레포지토리를 1번만 만들고 필드로 들고 있는다.
private AuthorRepository authorRepository;
public AuthorService() {
authorRepository = new AuthorRepository();
}
@Repository로 빈 등록 + @Service에서 생성자 주입(DI)으로 받는 구조로 바꾼다.
이렇게 하면 스프링 컨텍스트가 싱글톤 빈을 관리하므로, in-memory 실습에서도 “상태가 유지되는 것처럼” 동작을 맞추기 쉽다.