728x90

 

페이지네이션이란?

데이터의 양이 많아 한번에 보여주기 어려운 경우, 페이지를 번호로 구분해 데이터를 나눠 보여줍니다.

 

페이지네이션 vs 무한 스크롤

  페이지네이션 무한스크롤
장점 위치를 파악하기 쉽다.
(전자상거래 사이트에 어울리는 방식)
사용자가 오래 머무르게 됨
(SNS에 어울리는 방식)
단점 사용자 경험이 떨어짐 페이지 성능이 떨어짐

쿠팡 COUPANG
삼성 패션몰 SSF

 

페이지네이션 구현


 

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)를 사용하여 모든 정렬 순서를 쉼표로 구분된 문자열로 결합합니다.

 

728x90

클러스터 인덱스

클러스터 인덱스는 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를 구현했습니다.
컨트롤러는 이전과 같이 작성했습니다.
728x90

들어가며


여기까지  등록, 조회, 변경이 가능한 회원정보를 구현했고, 변경내역을 저장하며 회원이라는 도메인을 정의했습니다.

이번에는 회원들이 서비스를 이용하면서 다루게 되는 기능들을 만들어 볼 계획입니다.

 

사용되는 기술은 전과 같습니다.

Java 17

Spring Boot 3.x

MySQL 8.x

 

요구사항


팔로워와 팔로잉기능을 구현합니다.

단, 자기 자신에 대한 팔로우는 할 수 없습니다.

-> 팔로우를 하는 사람과 받는 사람 모두 "회원"이기 때문에 회원 도메인의 정보를 받아와야 합니다.

-> 따라서 다른 도메인과 연결고리 역할을 하는 클래스가 필요합니다.

그리고 팔로우 목록을 조회하는 기능을 구현합니다.

 

팔로우 기능

@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 함수를 통해 빈 리스트 반환 값에 대해 []를 반환하여 오류를 막았습니다.
728x90

2023.12.26 - [DB(MySQL, MongoDB, Redis, Kafka)/MySQL] - MySQL 테스트를 위한 SNS서비스 회원정보 등록

 

MySQL 테스트를 위한 SNS서비스 회원정보 등록

들어가며, 간단한 SNS 모델링을 통해 MySQL의 기능들을 사용해 볼 계획입니다. 아래 기술들을 활용해 Layered 구조로 작성했습니다. Java 17 Spring Boot 3.x MySQL 8.x 회원정보 등록을 구현 회원정보 아이디

wooltech.tistory.com

2023.12.26 - [DB(MySQL, MongoDB, Redis, Kafka)/MySQL] - MySQL 테스트를 위한 SNS서비스 회원정보 조회

 

MySQL 테스트를 위한 SNS서비스 회원정보 조회

2023.12.26 - [DB(MySQL, MongoDB, Redis, Kafka)/MySQL] - MySQL 테스트를 위한 SNS서비스 회원정보 등록 MySQL 테스트를 위한 SNS서비스 회원정보 등록 들어가며, 간단한 SNS 모델링을 통해 MySQL의 기능들을 사용해

wooltech.tistory.com

 

앞서 만든 내용에 회원 이름이 변경 가능하도록만 수정하고, 변경이력 조회를 진행했습니다.

 

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요청을 받고 있습니다.

 

 

 

 

 

728x90

2023.12.26 - [DB(MySQL, MongoDB, Redis, Kafka)/MySQL] - MySQL 테스트를 위한 SNS서비스 회원정보 등록

 

MySQL 테스트를 위한 SNS서비스 회원정보 등록

들어가며, 간단한 SNS 모델링을 통해 MySQL의 기능들을 사용해 볼 계획입니다. 아래 기술들을 활용해 Layered 구조로 작성했습니다. Java 17 Spring Boot 3.x MySQL 8.x 회원정보 등록을 구현 회원정보 아이디

wooltech.tistory.com

 

이번에는 등록에 이어 조회 기능을 만들겠습니다.

리포지토리에 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);
}

 

Controller는 간단하게 GetMapping을 통해 조회합니다.

 

 

728x90

들어가며,


간단한 SNS 모델링을 통해 MySQL의 기능들을 사용해 볼 계획입니다.

아래 기술들을 활용해 Layered 구조로 작성했습니다.

 

Java 17

Spring Boot 3.x

MySQL 8.x

 

회원정보 등록을 구현


회원정보

  • 아이디
  • 닉네임 -*닉네임 최대 길이는 10자.
  • 이메일
  • 생년월일
// 파라미터를 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 파라미터로 변환

 

Swagger UI : http://localhost:8080/swagger-ui/index.html#/

 

 

728x90

정규화와 반정규화

데이터 베이스의 가장 큰 기능인 CRUD 중에서도 읽기와 쓰기의 성능을 기준으로 정규화와 반정규화를 통해 엔터티를 구성한다.

정규화(쓰기) 반정규화(읽기)
중복 제거 중복 허용
데이터 정합성 유지가 쉬움 데이터 정합성 유지가 어려움
읽기 시 참조 필요 참조 없이 읽기

 


 

728x90

MySQL이란?

 

MySQL은 전세계에서 가장 많이 활용되고 있는 RDBMS다.

RDBMS는 관계형 데이터베이스를 의미하는데, 이는 데이터가 하나 이상의 열과 행의 테이블에 저장되어 서로 간의 사전 정의된 관계로 데이터를 구성하고 있다.

3 Tier Architecture -> N Tier Architecture로 변화

간단한 어플리케이션은 가장 전통적으로 3계층 아키텍처로 구성되어 왔다. 이 구조는 Client의 요청을 Server를 통해 Database와 통신하여 처리했으며, 반드시 Server를 거쳐야 하고 Client와 Database가 직접 소통하지 않는다.

Database서버는 관계형 데이터베이스(MySQL, Postgre, MariaDB 등) 또는 NoSQL 데이터베이스 서버 (MongoDB, Cassandra 등)로 구성되어 있다.

 

 

그러나, 어플리케이션이 복잡해지면서 캐싱, 메시징, 데이터 스토리지 등 다양한 기능이 필요해졌고, N계층 아키텍처로 변화되어 왔다.

 

N계층 구조로 변화해온 이유는?

 

어플리케이션이 복잡해지고, 다양한 기능이 필요한 이유는 많은 양의 데이터를 처리해야 하기 때문이다.

즉, 대용량 시스템을 구축하고 트래픽을 처리할 수 있도록 설계하는 것이 오늘날의 아키텍처를 선택하는 중요한 기준이다.

그리고 대용량 데이터를 처리하는 방식으로 Scale UP (성능을 늘리기)와 Scale OUT (서버 늘리기)가 있다.

 

Cache와 Load Balancer

 

Scale UP은 무한정으로 늘릴 수 있는 것이 아니고, Scale UP과 OUT모두 비용적인 측면을 고려해서 설계해야 한다. 이러한 관점에서 각 어플리케이션은 적정한 갯수의 서버를 가지고 있을 것이며, 다시 이 서버들은 Client요청을 받아 데이터 베이스와 통신한다.

 

Load Balancer

로드 밸런싱은 대규모 트래픽을 받기 위해 구축된 여러 대의 서버에게 트래픽을 분산하여 전달하는 역할을 한다.

 

Cache

캐시는 DB에 접근하여 데이터를 가져오는 시간을 절약하기 위해 임시적으로 데이터 값을 가지고 있는 저장소다. 임시 저장소이기 때문에 저장하고 있는 데이터 양과 시간적 제약 조건을 가진다. 따라서, 조건 범위를 적절하게 책정하는 것이 캐싱 기능을 활용하는 핵심이다.

 

+ Recent posts