데이터의 양이 많아 한번에 보여주기 어려운 경우, 페이지를 번호로 구분해 데이터를 나눠 보여줍니다.
페이지네이션 vs 무한 스크롤
페이지네이션
무한스크롤
장점
위치를 파악하기 쉽다. (전자상거래 사이트에 어울리는 방식)
사용자가 오래 머무르게 됨 (SNS에 어울리는 방식)
단점
사용자 경험이 떨어짐
페이지 성능이 떨어짐
페이지네이션 구현
public class PostReadService {
public Page<Post> getPosts(Long memberId, PageRequest pageRequest) {
}
}
스프링에서 제공하는 Page와 PageRequest를 사용하여 구현하려고 합니다.
Page는 <>안의 클래스를 받아서 페이지를 구현합니다. PageRequest는 페이지 번호, 페이지 크기, 정렬 방식을 지정하는데 도움을 줍니다.
public class PostRepository {
private static final RowMapper<Post> POST_ROW_MAPPER = (rs, rowNum) -> Post.builder()
.id(rs.getLong("id"))
.memberId(rs.getLong("memberId"))
.content(rs.getString("contents"))
.createdDate(rs.getDate("createdDate").toLocalDate())
.createdAt(rs.getDate("createdAt").toLocalDate())
.build();
public Page<Post> findAllByMemberId(Long memberId, Pageable pageable) {
/*
memberId로 게시글을 조회하는 쿼리를 작성해주세요.
*/
var sql = """
SELECT *
FROM post
WHERE memberId = :memberId
LIMIT :size OFFSET :offset;
""";
var params = new MapSqlParameterSource()
.addValue("memberId", memberId)
.addValue("size", pageable.getPageSize())
.addValue("offset", pageable.getOffset());
var posts = jdbcTemplate.query(sql, params, POST_ROW_MAPPER);
return new PageImpl(posts, pageable, getCount(memberId));
}
private Long getCount(Long memberId) {
var sql = """
SELECT count(*)
FROM post
WHERE memberId = :memberId;
""";
var params = new MapSqlParameterSource()
.addValue("memberId", memberId);
return jdbcTemplate.queryForObject(sql, params, Long.class);
}
POST_ROW_MAPPER를 통해 post 객체를 생성합니다. findAllByMemberId메서드는 memberId로 특정 회원의 게시글을 조회하는 기능을 하고 있습니다. SQL 쿼리를 사용하여 데이터베이스에서 게시글을 가져오는데 Pageable객체를 이용하여 파라미터 값을 넣어줍니다. jdbcTemplate.query를 통해 SQL 쿼리를 실행하고, POST_ROW_MAPPER를 사용하여 각 행을 Post객체로 매핑합니다. PageImpl을 사용하여 Page<Post>를 생성하고 반환합니다.
:size 페이지당 항목 수 :offset 몇 번째 페이지인지에 대한 정보
public PageImpl(List<T> content, Pageable pageable, long total)
PageImpl객체를 생성할 때, content에는 jdbcTemplate.query를 통해 가져온 Post객체의 목록이, pageable에는 사용자가 요청한 페이지 및 정렬 정보가, total에는 전체 게시글 수가 전달됩니다.
@GetMapping("/members/{memberId}")
public Page<Post> getPost(
@PathVariable Long memberId,
@RequestParam Integer page,
@RequestParam Integer size) {
return postReadService.getPosts(memberId, PageRequest.of(page, size));
}
구현된 엔드포인트를 통해 클라이언트는 특정 회원의 게시글을 페이지네이션하여 조회할 수 있습니다.
정렬추가
public class PageHelper {
public static String orderBy(Sort sort) {
if (sort.isEmpty()) {
return "id Desc";
}
List<Sort.Order> orders = sort.toList();
var orderBys = orders.stream()
.map(order -> order.getProperty() + " " + order.getDirection())
.toList();
return String.join(", ", orderBys);
}
PageHelper라는 클래스를 통해 앞서 구현된 리포지토리의 sql 쿼리문에 삽입될 ORDER BY 에 대한 내용을 정의합니다. 우선 sort역시 Spring Data에서 제공하는 것으로 SQL 쿼리의 ORDER BY 절을 생성하는 유틸리티 메서드입니다. 만일 sort가 비어있을 경우 기본적으로 id필드를 기준으로 내림차순 정렬합니다. 그렇지 않은 경우에는 String.join(", ", orderBys)를 사용하여 모든 정렬 순서를 쉼표로 구분된 문자열로 결합합니다.
클러스터 인덱스는 PK키 값을 가진정렬된 보조 테이블을 통해 키값에 해당하는 행을 빠르게 검색하는 방법입니다.
-> MySQL에서 기본 키는 자동으로 클러스터형 인덱스 역할을 합니다.
PK키와 정렬이라는 두가지 조건으로 클러스터형 인덱스는 업데이트가 자주 일어나지 않는 정적이고 범위를 검색하는 경우에 주로 사용됩니다.
성능 테스트 -> 100만건의 데이터
인덱스 성능을 테스트 하기 위해 아래와 같이 게시글 기능을 구현하고 100만 건의 데이터를 삽입했습니다.데이터는 EazyRandom을 통해 진행했습니다.
구현을 위해 사용되는 클래스는 아래와 같습니다.
FixtureFactory
Repository의 bulkInsert메서드 => 대량의 데이터를 효율적으로 삽입하는데 도움을 줍니다.
BulkInsertTest
public class PostFixtureFactory {
public static EasyRandom get(Long memberId, LocalDate firstDate, LocalDate lastDate) {
var idPrecicate = named("id")
.and(ofType(Long.class))
.and(inClass(Post.class));
var memberIdPrecicate = named("memberId")
.and(ofType(Long.class))
.and(inClass(Post.class));
var param = new EasyRandomParameters()
.excludeField(idPrecicate)
.dateRange(firstDate, lastDate)
.randomize(memberIdPrecicate, () -> memberId);
return new EasyRandom(param);
}
}
FixtureFactory 는 데이터의 Fixture를 생성하는 역할을 합니다. Fixture는 테스트 환경 설정과 데이터 생성에 중점을 둔 도구로 EasyRandom를 활용하여 테스트에 사용할 데이터를 생성합니다. id와 memberId를 정의하고 id는 제외, memberId는 고정, 날짜 범위를 지정해 EasyRandom 인스턴스를 생성하고 반환합니다.
public void bulkInsert(List<Post> posts) {
/*
[일괄 저장]을 구현해주세요.
*/
var sql = """
INSERT INTO post (memberId, contents, createdDate, createdAt)
VALUES (:memberId, :contents, :createdDate, :createdAt);
""";
SqlParameterSource[] params = posts
.stream()
.map(BeanPropertySqlParameterSource::new)
.toArray(SqlParameterSource[]::new);
jdbcTemplate.batchUpdate(sql, params);
}
sql쿼리를 통해 post에 삽입하기 위한 형태를 잡고 sqlParameterSource를 통해 posts 배열을 구현했습니다. 생성된 posts는 batchUpdate 메서드를 통해 일괄 삽입됩니다.
@SpringBootTest
public class PostBulkInsertTest {
@Autowired
private PostRepository postRepository;
@Test
public void bulkInsert() {
var easyRandom = PostFixtureFactory.get(
1L,
LocalDate.of(2022, 1, 1),
LocalDate.of(2022, 2, 1));
var post = IntStream.range(0, 10000)
.parallel()
.mapToObj(i -> easyRandom.nextObject(Post.class))
.toList();
postRepository.bulkInsert(post);
}
}
Repository에서 구현한 bulkInsert메서드를 테스트하는 클래스로 FixtureFactory에서 얻은 easyRandom 인스턴스를 활용해 Post 객체를 생성해 bulkInsert메서드의 변수로 활용했습니다.
.mapToObj(i -> easyRandom.nextObject(Post.class))을 통해 병렬로 easyRandom을 사용하여 Post객체를 1만 번 생성합니다.
Test코드에서는 @Transactional 어노테이션을 함께 사용하면, 테스트 완료 후 롤백하여 실제 데이터베이스에는 영향을 주지 않는 방법이 있습니다.
인덱스 설정
create index POST__index_member_id
on POST (memberId);
create index POST__index_created_date
on POST (createdDate);
create index POST__index_member_id_created_date
on POST (memberId, createdDate);
bulkInsert로 대용량의 데이터 삽입이 완료된 후 조회를 하게되면 대량의 데이터로 인해 많은 시간이 소요됩니다. 따라서 index를 설정해 색인 과정에서 선별적으로 검사하게 시켜야 합니다.
index를 사용하기에 앞서 데이터의 분포를 확인해야 합니다. 왜냐하면 인덱스를 사용하면 조회 시에 인덱스를 먼저 스캔하고 인덱스에 저장된 데이터 레코드를 통해 실제 데이터베이스에서 데이터를 읽어오기 때문입니다. 따라서, 인덱스와 실제 DB 두가지를 조회해야 하기 때문에 인덱스에서 실제 DB에 있는 데이터에 비해 크게 변별할 구색을 갖추지 못한다면 오히려 조회 성능이 떨어질 수 있습니다.
게시글 기능 구현
private final Long id;
private final Long memberId;
private final String content;
private final LocalDate createdDate;
private final LocalDate createdAt;
게시글을 위한 Post 클래스의 필드값은 위와 같습니다.
@Repository
@RequiredArgsConstructor
public class PostRepository {
private final NamedParameterJdbcTemplate jdbcTemplate;
private static final String TABLE_NAME = "Post";
public Post save(Post post) {
if (post.getId() == null) {
return insert(post);
} else {
throw new UnsupportedOperationException();
}
}
private Post insert(Post post) {
SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate.getJdbcTemplate())
.withTableName(TABLE_NAME)
.usingGeneratedKeyColumns("id");
SqlParameterSource params = new BeanPropertySqlParameterSource(post);
var id = jdbcInsert.executeAndReturnKey(params).longValue();
return Post.builder()
.id(id)
.memberId(post.getMemberId())
.content(post.getContent())
.createdDate(post.getCreatedDate())
.createdAt(post.getCreatedAt())
.build();
}
}
다른 Repository와 같이 NamedParameterJdbcTemplete을 주입받아 구현하고 있습니다. if 함수를 통해 id값이 null값인 경우, insert메서드로 삽입하게 되며 null 값이 아닌 경우, 즉 해당 번호 글이 존재하는 경우 경고를 통해 중복 게시를 막았습니다.
insert 메서드는 주어진 Post 객체를 데이터베이스에 삽입하고, 이 과정에서 자동으로 생성된 키(primary key)를 얻어와서 해당 값을 포함한 새로운 Post 객체를 반환합니다.
public Long createPost(PostCommand command) {
var post = Post
.builder()
.memberId(command.memberId())
.content(command.content())
.build();
return postRepository.save(post).getId();
}
}
서비스는 간단하게 빌더패턴을 사용해 Command에서 전달된 정보를 사용해 Post객체를 생성했습니다. 생성된 Post는 save되며, 생성 시 자동 생성된 id값(식별자)를 받아 Long형태로 반환됩니다.
조회 기능 추가
public static RowMapper<DailyPost> DAILY_POST_ROW_MAPPER = (rs, rowNum) -> new DailyPost(
rs.getLong("memberId"),
rs.getDate("createdDate").toLocalDate(),
rs.getLong("post_count")
);
public List<DailyPost> getCount(DailyPostCommand command) {
/*
[일자, 회원, 게시글수]를 조회하는 쿼리를 작성해주세요.
*/
var sql = """
SELECT createdDate, memberId, count(*) as post_count
FROM post
WHERE memberId = :memberId AND createdDate BETWEEN :firstDate AND :lastDate
GROUP BY createdDate, memberId;
""";
SqlParameterSource params = new BeanPropertySqlParameterSource(command);
return jdbcTemplate.query(sql, params, DAILY_POST_ROW_MAPPER);
}
일자, 회원ID, 게시글 수를 조회하는 DailyPostCount를 구현했습니다. 컨트롤러는 이전과 같이 작성했습니다.
그런 의미에서 잘 저장하는 것과 빠르게 읽어 오는 것이 좋은 성능을 가진 데이터베이스라고 할 수 있습니다.
잘 저장하고 빠르게 읽는 다는 것의 의미는?
Random과 Sequential의 차이와 같습니다. 순서대로 정리가 되어 있어야 조회와 저장 모두 손쉽게 이룰 수 있습니다. 만약 뒤죽박죽 섞여있다면 데이터를 제자리에 두지 못해 유실될수도 있고 찾지 못할 수도 있습니다. 이것은 데이터 보관의 중요한 속성 중에 하나인 무결성과도 연관이 깊습니다.
데이터 무결성이란?
데이터 무결성은 데이터베이스가 운영되는 동안 데이터의 정확성과 일관성을 유지하는 것을 의미합니다.
특히, 데이터가 저장 중에 서버가 다운되거나 하는 등의 이유로 데이터가 손상되지 않아야 합니다.
이를 방지하기 위해 WAL(Write Ahead Log)라는 기술이 등장했습니다.
말 그대로 WAL은 이를 사용하는 시스템에서 모든 수정 사항을 적용되기 전에 로그에 기록하게 됩니다. 그 후 일정량의 작업 후에 프로그램은 WAL에 지정된 모든 변경 사항을 데이터베이스에 기록하고 로그를 지우는 체크포인트를 수행하게 됩니다.
위의 그림은 WAL을 통해 데이터의 무결성을 지키는 동작을 그린 것입니다.
Disk에 수정된 정보가 반영되기 전에 Crash가 발생하였으므로 데이터베이스에는 수정사항이 반영되지 않은 비어있는 TBL 테이블만을 가지고 있습니다. 그러나, 우리는 어디까지 저장하였는지에 대한 정보인 “Checkpoint”와 “A를 반영하고 커밋했다는 로그”, “B를 반영하고 커밋했다는 로그”를 Disk 공간인 WAL Segment에 저장하였습니다. 이를 기반으로 TBL 테이블은 “Checkpoint 까지 반영되어 있으며, 따라서 A로 수정하고 커밋, B로 수정하고 커밋을 수행해야 복구가 완료된다.” 라는 사실을 추론할 수 있습니다. 실제로 WAL을 기반으로한 복구는 해당 과정으로 수행됩니다.
PostgreSQL의 WAL(Write Ahead Log) (tibero.com)
데이터 무결성은 지켰지만, 빠르게 저장, 조회하는 방법은?
빠르게 저장하는 방법은 위에서 설명한 WAL과 관련이 있습니다. 아무리 잘 정리를 했다하더라도 데이터의 쓰기 동작은 무작위로 입력받게 됩니다. 따라서 이를 매번 자리를 찾아 저장하게 되면 Random한 동작과 유사하게 동작하게 될 것입니다.
그러므로 메모리에 데이터를 받아 저장 빈도를 줄이는 방식을 이용해 속도를 개선할 수 있습니다. 그리고 메모리 단계에서 데이터가 휘발되는 문제를 해결해주는 것이 WAL입니다.
다음은 조회 성능을 향상시키기 위한 자료구조를 알아보겠습니다.
1) Hash Map
Hash Map은 (키, 값)을 통해 조회를 하기 때문에 단건 검색에서는 매우 빠른 속도를 자랑합니다. 다만, 범위 검색에서는 약점을 가지고 있습니다.
2) List
List는 정렬된 조건하에 빠른 검색을 할 수 있지만, 정렬이 되지 않았을 때는 정렬에 많은 시간이 소요됩니다.
또한, 삽입과 삭제를 하는 과정에 많은 비용을 들기도 합니다.
3) Binery Tree
Binery Tree는 트리 높이에 따라 속도가 정해지기 때문에 높이를 최소화하는 것이 중요합니다. Tree에 종류는 여러가지가 있지만, 검색, 삽입, 삭제와 같은 연산에서 일정한 높이를 유지하며 효율적으로 작동하기 때문에 B+ Tree를 주로 사용하게 됩니다.
-> 팔로우를 하는 사람과 받는 사람 모두 "회원"이기 때문에 회원 도메인의 정보를 받아와야 합니다.
-> 따라서 다른 도메인과 연결고리 역할을 하는 클래스가 필요합니다.
그리고 팔로우 목록을 조회하는 기능을 구현합니다.
팔로우 기능
@Service
@RequiredArgsConstructor
public class FollowMemberUsecase {
private final MemberReadService memberReadService;
private final FollowWriteService followWriteService;
public void excute(Long fromId, Long toId) {
var fromMember = memberReadService.getMember(fromId);
var toMember = memberReadService.getMember(toId);
followWriteService.createFollow(fromMember, toMember);
}
}
Usecase를 두면서 도메인 간의 흐름을 제어해줍니다. 앞서 Read와 Write로 서비스를 구분했었기 때문에 Usecase에 주입하는 과정에서 명료하게 전달할 수 있고 가독성도 좋습니다.
Usecase는 별도의 로직은 가지지 않고 member도메인의 값을 follow도메인에서 사용할 수 있도록 이어주기만 합니다.
@Service
@RequiredArgsConstructor
public class FollowWriteService {
private final FollowRepository followRepository;
public Follow createFollow(MemberDto fromMember, MemberDto toMember) {
Assert.isTrue(!fromMember.id().equals(toMember.id()), "자기 자신을 팔로우 할 수 없습니다.");
var follow = Follow.builder()
.fromMemberId(fromMember.id())
.toMemberId(toMember.id())
.build();
return followRepository.save(follow);
}
}
Assert.isTrue로 유효성 검사 후에 빌더 패턴을 사용해 follow 엔터티를 생성하고 Repository.save메서드로 데이터베이스에 저장하였습니다.
빌더 패턴에 대해서:
@RequiredArgsConstructor
@RestController
@RequestMapping("/follows")
public class FollowController {
private final FollowMemberUsecase followMemberUsecase;
@PostMapping("/{fromId}/{toId}")
public void createFollow(@PathVariable Long fromId, @PathVariable Long toId) {
followMemberUsecase.excute(fromId, toId);
}
}
간단하게 컨트롤러 작성 후 Swagger UI로 동작을 확인해보니, 잘 동작하고 있습니다.
팔로우 목록 조회
private static final RowMapper<Follow> rowMapper = (rs, rowNum) -> Follow.builder()
.id(rs.getLong("id"))
.fromMemberId(rs.getLong("fromMemberId"))
.toMemberId(rs.getLong("toMemberId"))
.createdAt(rs.getObject("createdAt", LocalDate.class))
.build();
public List<Follow> findAllByFromMemberId(Long fromMemberId) {
var sql = "select * from " + Table + " where fromMemberId = :fromMemberId";
var params = new MapSqlParameterSource().addValue("fromMemberId", fromMemberId);
return jdbcTemplate.query(sql, params, rowMapper);
}
Repository에서는 RowMapper와 NamedParameterJdbcTemplate를 통해 follow 객체를 생성하고 엔티티를 설정합니다. 즉, fromMemberId를 기준으로 데이터베이스에서 팔로우 목록을 조회하고, 조회된 결과를 Follow 엔티티로 변환하여 반환합니다. 똑같은 방법으로 toMemberId에 대한 follow 객체도 생성합니다.
@Service
@RequiredArgsConstructor
public class FollowReadService {
private final FollowRepository followRepository;
public List<Follow> getFollowing(Long memberId) {
return followRepository.findAllByFromMemberId(memberId);
}
public List<Follow> getFollower(Long memberId) {
return followRepository.findAllByToMemberId(memberId);
}
}
FromMemberId를 기준으로 하는 getFollowing 메서드와 ToMemberId를 기준으로 하는 getFollower메서드를 Service에서 정의합니다.
public List<MemberDto> excute(Long memberId) {
/*
memberId -> following을 조회
*/
var followings = followReadService.getFollowing(memberId);
var followingMemberIds = followings.stream()
.map(Follow::getToMemberId)
.toList();
return memberReadService.getMembers(followingMemberIds);
}
memberId(fromMemberId)에 해당하는 회원이 팔로우하는 회원들의 목록을 가져옵니다. followings 목록에서 각각의 팔로우 정보에서 toMemberId를 추출하여 리스트로 만듭니다. toMemberId리스트를 이용해서 얻은 정보를 memberReadService의 getMembers 메서드를 통해 리스트를 반환합니다.
주어진 회원이 팔로우하는 회원들의 정보를 조회하고, 이를 MemberDto로 변환하여 반환하고 있습니다.
@GetMapping("/members/{fromId}")
public List<MemberDto> getFollowing(@PathVariable Long fromId) {
return getFollowingUsecase.excute(fromId);
}
}
컨트롤러에서는 Usecase에서 정의한 excute메서드를 통해 fromId 요청을 처리하고 있습니다.
만약 fromId에 대해 빈 리스트가 반환된다면?
public List<Member> findAllByIdIn(List<Long> ids) {
if (ids.isEmpty()) {
return List.of();
}
finalAllByIdn에 if 함수를 통해 빈 리스트 반환 값에 대해 []를 반환하여 오류를 막았습니다.
public void changeNickname(String other) {
validateNickname(other);
nickname = other;
}
닉네임이 변경가능하도록 member 객체에 changeNickname 메서드를 따로 만들면 됩니다.
public void changeNickname(Long id, String nickname) {
var member = memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당하는 회원이 없습니다."));
member.changeNickname(nickname);
memberRepository.save(member);
}
WriteService에서는 리포지토리에 id값으로 조회를 한 뒤, 닉네임을 바꾸고 다시 저장하게 됩니다.
private Member update(Member member) {
var sql = "update " + TABLE_NAME + " set nickname = :nickname where id = :id";
SqlParameterSource params = new BeanPropertySqlParameterSource(member);
namedParameterJdbc.update(sql, params);
return member;
}
리포지토리에 update메서드를 통해 sql쿼리로 update된 member객체를 반환합니다.
@PostMapping("/members/{id}/nickname")
public MemberDto changeNickname(@PathVariable Long id, @RequestBody String nickname) {
memberWriteService.changeNickname(id, nickname);
return memberReadService.getMember(id);
}
}
Controller는 WriteService를 통해 수정하고, ReadService를 통해 조회한 값을 반환합니다.
회원이름 변경내역
create table Member
(
id int auto_increment,
email varchar(20) not null,
nickname varchar(20) not null,
birthdate date not null,
createdAt datetime not null,
constraint member_id_uindex
primary key (id)
);
create table MemberNicknameHistory
(
id int auto_increment,
memberId int not null,
nickname varchar(20) not null,
createdAt datetime not null,
constraint memberNicknameHistory_id_uindex
primary key (id)
);
변경이력은 기존의 회원정보 테이블과 데이터 중복을 피하기 위해 분리했습니다. 다만, 변경이력의 memberId는 member테이블의 id를 참조하고 있는데도 FK로 지정하고 있지 않습니다. 필수는 아니지만 데이터의 무결성을 위해 지정하는 것이 좋을 것 같습니다.
@Getter
public class MemberNickNameHistory {
private final Long id;
private final Long memberId;
private final String nickname;
private final LocalDate createdAt;
@Builder
public MemberNickNameHistory(Long id, Long memberId, String nickname, LocalDate createdAt) {
this.id = id;
this.memberId = Objects.requireNonNull(memberId);
this.nickname = Objects.requireNonNull(nickname);
this.createdAt = createdAt == null ? LocalDate.now() : createdAt; // 로그를 위해 생성시간 추가
}
public record NickNameHistoryDto (
Long id,
Long memberId,
String nickname,
LocalDate createdAt
){
}
엔티티와 Dto는 기존 member와 같은 형식으로 작성했습니다.
@Repository
@RequiredArgsConstructor
public class MemberNickNameHistoryRepository {
private final NamedParameterJdbcTemplate namedParameterJdbc;
private static final String TABLE_NAME = "MemberNickNameHistory";
public static final RowMapper<MemberNickNameHistory> rowMapper = (rs, rowNum) -> MemberNickNameHistory
.builder()
.id(rs.getLong("id"))
.memberId(rs.getLong("memberId"))
.nickname(rs.getString("nickname"))
.createdAt(rs.getObject("createdAt", LocalDate.class))
.build();
public List<MemberNickNameHistory> findAllByMemberId(Long memberId) {
/*
select * from Member where memberId = :memberId;
*/
var sql = "select * from " + TABLE_NAME + " where memberId = :memberId";
var params = new MapSqlParameterSource()
.addValue("memberId", memberId);
return namedParameterJdbc.query(sql, params, rowMapper);
}
public MemberNickNameHistory save(MemberNickNameHistory memberNickNameHistory) {
if (memberNickNameHistory.getId() == null) {
return insert(memberNickNameHistory);
}
throw new IllegalArgumentException("MemberNickNameHistory는 수정할 수 없습니다.");
}
private MemberNickNameHistory insert(MemberNickNameHistory memberNickNameHistory) {
SimpleJdbcInsert simpleJdbcInsert = new SimpleJdbcInsert(namedParameterJdbc.getJdbcTemplate())
.withTableName(TABLE_NAME)
.usingGeneratedKeyColumns("id");
SqlParameterSource params = new BeanPropertySqlParameterSource(memberNickNameHistory);
var id = simpleJdbcInsert.executeAndReturnKey(params).longValue();
return MemberNickNameHistory.builder()
.id(id)
.memberId(memberNickNameHistory.getMemberId())
.nickname(memberNickNameHistory.getNickname())
.createdAt(memberNickNameHistory.getCreatedAt())
.build();
}
}
Repository에서 데이터 액세스하는 메서드를 구현하고 있습니다. sql쿼리가 실행되고 그 결과가 RowMapper를 통해 객체로 반환됩니다.
Insert 역시 SimpleJdbcInsert를 사용하여 데이터를 삽입하고 있습니다. 그리고 생성된 id 키값을 가져와 MemberNickNameHistory객체를 빌더패턴으로 설정하고 있습니다.
Read 및 Write 서비스에서는 동일한 방식으로 기능을 구현하고 Controller를 통해 HTTP요청을 받고 있습니다.
리포지토리에 findById를 통해 Id값으로 모든 멤버를 조회하는 기능을 구현하려고 합니다.
public Optional<Member> findById(Long id) {
/*
select * from Member where id = :id;
*/
var sql = "select * from " + TABLE_NAME + " where id = :id";
var params = new MapSqlParameterSource()
.addValue("id", id);
RowMapper<Member> rowMapper = (rs, rowNum) -> Member.builder()
.id(rs.getLong("id"))
.nickname(rs.getString("nickname"))
.email(rs.getString("email"))
.birthdate(rs.getDate("birthdate").toLocalDate())
.createdAt(rs.getDate("createdAt").toLocalDate())
.build();
var Member = namedParameterJdbc.queryForObject(sql, params, rowMapper);
return Optional.ofNullable(Member);
sql, params, rowMapper를 생성하고 이를 변수로 받아 namedParameterJdbc를 통해 Member 객체를 반환받습니다.
sql 쿼리는 id를 기준으로 모든 값을 조회하는 구문을 작성합니다. 그리고 MapSqlParameterSource을 통해 sql쿼리에서 사용될 파라미터 맵을 생성하고 addValue메서드를 통해 id값을 저장합니다.
RowMapper인터페이스는 ResultSet에서 각 행의 데이터를 읽어와 객체로 변환하는 역할을 합니다. rs와 rowNum값으로 데이터를 읽어와서 Member객체를 빌더 패턴을 통해 생성하고 있습니다.
Optional은 null 대신에 사용되어, NullPointerException을 방지하고 코드에서 명시적으로 값이 없음을 나타낼 수 있도록 도와줍니다. 여기서는 Optional.ofNullable을 사용하여 Member객체가 값이 있다면, 해당 값을 감싸는 Optional 객체를, 없다면 빈 Optioanl객체를 생성합니다.
@Service
@RequiredArgsConstructor
public class MemberReadService {
private final MemberRepository memberRepository;
public Member getMember(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("해당하는 회원이 없습니다."));
}
}
Service 계층은 이전 등록에서 사용한 WriteService와 별개로 ReadService를 따로 두면서 비즈니스 로직을 관리합니다.
간단히 getMember 메서드를 통해 조회하며, 반환되는 값은 리포지토리의 findById 메서드를 이용합니다. orElseThrow메서드를 통해 예외 값을 던지고 있고, 람다식으로 구현된 이 함수식에서는 조회된 값이 Null일 경우에 발생하게 됩니다.
@GetMapping("/members/{id}")
public Member getMember(Long id) {
return memberReadService.getMember(id);
}
// 파라미터를 RegisterMemberCommand로 받는다.
//val member = Member of(registerMemberCommand)
//memberRepository.save(member)
public record RegisterMemberCommand(
String email,
String nickname,
LocalDate birthdate ) {
}
Java Spring Framework에서 Record는 Java 16부터 도입된 새로운 기능 중 하나입니다. Record는 불변(Immutable) 데이터를 표현하기 위한 간편한 방법을 제공합니다. Record를 사용하면 클래스를 정의할 때 간결하게 필드를 선언하고, 불변성과 자동으로 생성되는 메서드를 얻을 수 있습니다.
AS-IS
@Getter
public class Member {
private final Long id;
private final String nickname;
private final String email;
private final LocalDate birthdate;
private final LocalDate createdAt; // 로그를 위해 생성시간 추가
//nickname.length() <= 10
private static final Long MAX_NICKNAME_LENGTH = 10L;
@Builder
public Member(Long id, String nickname, String email, LocalDate birthdate, LocalDate createdAt) {
this.id = id;
this.nickname = Objects.requireNonNull(nickname);
this.email = Objects.requireNonNull(email);
this.birthdate = birthdate;
this.createdAt = createdAt == null ? LocalDate.now() : createdAt;
if (nickname.length() > MAX_NICKNAME_LENGTH) {
throw new IllegalArgumentException("nickname length must be less than " + MAX_NICKNAME_LENGTH);
}
이 엔터티 코드는 불변하는 필드값을 빌더 패턴을 통해 객체를 생성할 수 있습니다. 그러나 현재 코드는 생성자 내에 객체 생성과 유효성 검사를 동시에 수행하고 있어, SRP를 위반하고 있습니다.
TO-BE
@Builder
public Member(Long id, String nickname, String email, LocalDate birthdate, LocalDate createdAt) {
this.id = id;
this.email = Objects.requireNonNull(email);
this.birthdate = birthdate;
validateNickname(nickname);
this.nickname = nickname;
this.createdAt = createdAt == null ? LocalDate.now() : createdAt;
//
// if (nickname.length() > MAX_NICKNAME_LENGTH) {
// throw new IllegalArgumentException("nickname length must be less than " + MAX_NICKNAME_LENGTH);
// }
}
void validateNickname(String nickname) {
if (nickname == null || nickname.length() > MAX_NICKNAME_LENGTH) {
throw new IllegalArgumentException("Invalid nickname");
}
}
따라서 validateNickname 메서드를 통해 유효성 검사를 분리해, 각 메서드 또는 클래스가 하나의 책임을 갖도록 수정되어야 합니다.
@Service
@RequiredArgsConstructor
public class MemberWriteService {
private final MemberRepository memberRepository;
// member 객체 생성한다.
public void createMember(RegisterMemberCommand command) {
var member = Member.builder()
.nickname(command.nickname())
.email(command.email())
.birthdate(command.birthdate())
.build();
// member 객체를 Repository에 저장한다.
memberRepository.save(member);
}
서비스 계층에서는 비즈니스 로직을 수행합니다.
1. 회원정보 생성 우선 생성 단계에서는 Command 명령 객체를 통한 createMember 메서드를 정의합니다. var를 통해 초기화하고 빌더 패턴으로 객체를 생성합니다.
2. 회원정보 저장 Repository의 save메서드를 통해 저장한다.
Repository의 save메서드 정의 :
--> id값을 통해 null 값이면, 삽입(insert)한다.
--> null값이 아니면, 갱신(update)한다.
private Member insert(Member member) {
SimpleJdbcInsert simpleJdbcInsert = new SimpleJdbcInsert(namedParameterJdbc.getJdbcTemplate())
.withTableName("Member")
.usingGeneratedKeyColumns("id");
SqlParameterSource params = new BeanPropertySqlParameterSource(member);
var id = simpleJdbcInsert.executeAndReturnKey(params).longValue();
return Member.builder()
.id(id)
.nickname(member.getNickname())
.email(member.getEmail())
.birthdate(member.getBirthdate())
.createdAt(member.getCreatedAt())
.build();
}
Repository는 NamedParameterJdbcTemplete을 주입받아 insert와 update에 관한 Jdbc 메서드를 생성합니다. SimpleJdbcInsert를 통해 테이블과 키값을 지정하고 삽입을 수행 SqlParameterSource를 통해 객체 필드를 SQL 파라미터로 변환