✅ 이전 포스팅
2024.11.06 - [Java/쇼핑몰] - 쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD]
이전 포스팅에서는 최종적으로 CI/CD를 구성하여 프로젝트의 배포를 편이 하도록 만들었다.
이번 포스팅부터는 본격적인 코드 구성을 작성하려고 한다.
쇼핑몰 포스팅에서는 공통 예외처리, 프로젝트 구조 등은 제외하고 기능 구현만 기술합니다.
✅ DB 설계
프로젝트를 생성하기 앞서 중요한 것은 DB 설계가 아닐까 싶다.
프론트엔드는 화면을 퍼블리싱하기 전에 디자인이 제대로 갖춰줘야 하는 것처럼
백엔드는 DB가 제대로 설계되어 있어야 개발하는데 용이한 것 같다.
https://www.erdcloud.com/d/NhQeqSxtmF3Yz5NFQ
본인은 우선 위에 나와있는데로 설계를 했는데 굉장히 굉장히 간단하게 만들었다.
물론 테이블과 컬럼들이 항상 추가되거나 삭제될 수 있다.
쇼핑몰 포스팅은 완성한 후 작성하는 것이 아니기 때문에 ERD와 코드들이 지속적으로 변경될 수 있다.
✅ Domain 구현
우선 DB 설계에 맞게 도메인을 구현한다.
🟨 BaseEntity
package toy.shop.domain;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
BaseEntity는 작성일자, 수정일자와 같이 대부분의 도메인에서 사용하는 항목을 적어둔 것이다.
🟨 Role
package toy.shop.domain;
import lombok.Getter;
@Getter
public enum Role {
ROLE_USER("ROLE_USER"),
ROLE_COMPANY("ROLE_COMPANY"),
ROLE_ADMIN("ROLE_ADMIN");
private final String role;
Role(String role) {
this.role = role;
}
}
Role의 경우 enum으로 회원의 권한을 담는 정보이다.
🟨 Member
package toy.shop.domain.member;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import toy.shop.domain.BaseEntity;
import toy.shop.domain.Role;
@Entity
@Getter
@Table(name = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Setter
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String nickName;
@Column(nullable = false)
private String gender;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private Role role;
@Column(nullable = false)
private String imagePath;
@Column
private String phoneNumber;
@Column(nullable = false)
@ColumnDefault("'N'")
private char banType = 'N';
@Column(nullable = false)
@ColumnDefault("'N'")
private char deleteType = 'N';
@Builder
public Member(String email, String password, String nickName, String gender, Role role, String imagePath, String phoneNumber) {
this.email = email;
this.password = password;
this.nickName = nickName;
this.gender = gender;
this.role = role;
this.imagePath = imagePath;
this.phoneNumber = phoneNumber;
}
}
Member 도메인 또한 DB 설계에 맞게 잘 생성해 준다. password에만 @Setter가 있는 이유는 추후 비밀번호 변경
로직에 사용하기 위해서다.
✅ 회원가입
회원가입은 전달 받은 정보를 통해 DB에 값을 넣기만 하면 되기 때문에 간단하다.
🟨 DTO
package toy.shop.dto.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import toy.shop.domain.Role;
@Data
@NoArgsConstructor
@Schema(description = "회원가입에 필요한 요청 정보", requiredProperties = {"email", "password", "nickname", "gender", "role", "phone"})
public class SignupRequestDTO {
@Schema(description = "회원 이메일")
@NotBlank(message = "해당 값은 필수값 입니다.")
private String email;
@Schema(description = "회원 비밀번호")
@NotBlank(message = "해당 값은 필수값 입니다.")
private String password;
@Schema(description = "회원 닉네임")
@NotBlank(message = "해당 값은 필수값 입니다.")
private String nickname;
@Schema(description = "회원 성별", example = "W or M")
@NotBlank(message = "해당 값은 필수값 입니다.")
@Pattern(regexp = "W|M", message = "해당 값은 W 또는 M 이어야 합니다.")
private String gender;
@Schema(description = "회원 권한", example = "ROLE_USER or ROLE_COMPANY")
@NotNull(message = "해당 값은 필수값 입니다.")
private Role role;
@Schema(description = "휴대번호")
@NotBlank(message = "해당 값은 필수값 입니다.")
private String phone;
@Builder
public SignupRequestDTO(String email, String password, String nickname, String gender, Role role, String phone) {
this.email = email;
this.password = password;
this.nickname = nickname;
this.gender = gender;
this.role = role;
this.phone = phone;
}
}
Schema의 경우는 Swagger에 설명을 붙이기 위해 작성했기 때문에 swagger를 사용하지 않는다면 필요 없다.
🟨 Controller
package toy.shop.controller.global;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import toy.shop.dto.Response;
import toy.shop.dto.member.SignupRequestDTO;
import toy.shop.service.member.MemberService;
import static toy.shop.controller.ResponseBuilder.buildResponse;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController implements AuthControllerDocs {
private final MemberService memberService;
@PostMapping("/sign-up")
public ResponseEntity<Response<?>> joinMember(@RequestBody @Valid SignupRequestDTO parameter) {
Long result = memberService.signup(parameter);
return buildResponse(HttpStatus.CREATED, "회원가입 성공", result);
}
}
컨트롤러에서는 MemberService를 가져와서 호출하고 클라이언트에게 적절하게
Response 값을 전달하는 것만 들어있다.
AuthControllerDocs는 API 문서를 위해 Swagger에서 보여줄 SpringDoc를 작성한 것이니 필요하면
검색해서 적용해 보길 바란다.
🟨 Service
package toy.shop.service.member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import toy.shop.cmmn.exception.ConflictException;
import toy.shop.domain.member.Member;
import toy.shop.dto.member.SignupRequestDTO;
import toy.shop.repository.member.MemberRepository;
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Value("${path.profileImage}")
private String profileImagePath;
/**
* 사용자의 회원가입을 처리하는 메서드입니다.
*
* @param parameter 회원가입 요청 정보를 담고 있는 SignupRequestDTO 객체입니다.
* @return 저장된 회원의 ID를 반환합니다.
* @throws ConflictException 이메일이 이미 존재하는 경우 예외가 발생합니다.
*
* 이 메서드는 다음 작업을 수행합니다:
* 1. 이메일 중복 체크: 입력된 이메일이 이미 존재하는 경우 ConflictException이 발생합니다.
* 2. 새로운 회원 생성: 주어진 정보를 사용하여 새로운 회원 객체를 생성하고 암호화된 비밀번호를 설정합니다.
* 3. 회원 정보 저장: 생성된 회원 객체를 저장하고, 저장된 회원의 ID를 반환합니다.
*/
public Long signup(SignupRequestDTO parameter) {
memberRepository.findByEmail(parameter.getEmail())
.ifPresent(existingMember -> {
throw new ConflictException("이미 존재하는 이메일입니다: " + parameter.getEmail());
});
Member member = Member.builder()
.email(parameter.getEmail())
.password(passwordEncoder.encode(parameter.getPassword()))
.nickName(parameter.getNickname())
.gender(parameter.getGender())
.role(parameter.getRole())
.imagePath(profileImagePath + "/anonymous.png")
.phoneNumber(parameter.getPhone())
.build();
return memberRepository.save(member).getId();
}
}
서비스 로직에 대한 설명은 Javadoc을 통해서 간략하게 작성해 뒀으니 이해하는데 문제는 없을 것 같다.
🟨 Repository
package toy.shop.repository.member;
import org.springframework.data.jpa.repository.JpaRepository;
import toy.shop.domain.member.Member;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
/**
* 이메일 주소를 기반으로 회원 정보를 조회하는 메서드입니다.
*
* @param email 조회할 회원의 이메일 주소
* @return 주어진 이메일 주소를 가진 회원을 Optional로 반환하며, 존재하지 않으면 Optional.empty()를 반환합니다.
*/
Optional<Member> findByEmail(String email);
}
늘 느끼지만 JPA는 감동 그 잡채...
🟨 Test
package toy.shop.service.member;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import toy.shop.cmmn.exception.ConflictException;
import toy.shop.domain.Role;
import toy.shop.dto.member.SignupRequestDTO;
import toy.shop.repository.member.MemberRepository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Transactional
@SpringBootTest
class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
@DisplayName("정상 가입 테스트")
public void signup() {
SignupRequestDTO dto = SignupRequestDTO.builder()
.email("test@test.com")
.password("1234")
.gender("M")
.nickname("test")
.role(Role.ROLE_USER)
.phone("010-1234-5678")
.build();
Long result = memberService.signup(dto);
assertThat(result).isEqualTo(memberRepository.findByEmail("test@test.com").get().getId());
}
@Test
@DisplayName("중복 가입 테스트")
public void signupConflictEmail() {
SignupRequestDTO dto = SignupRequestDTO.builder()
.email("test@test.com")
.password("1234")
.gender("M")
.nickname("test")
.role(Role.ROLE_USER)
.phone("010-1234-5678")
.build();
SignupRequestDTO dto2 = SignupRequestDTO.builder()
.email("test@test.com")
.password("1234")
.gender("M")
.nickname("test")
.role(Role.ROLE_USER)
.phone("010-1234-5678")
.build();
Long result = memberService.signup(dto);
assertThrows(ConflictException.class, () -> memberService.signup(dto2));
}
}
서비스 로직의 테스트 코드를 작성한 뒤 실행 시켜보면
정상적으로 잘 동작한다.
✅ 마무리
원래 이번 포스팅에서 회원가입과 spring security + jwt + redis를 이용한 로그인까지 함께 작성하려고 했으나
생각보다 양이 많아져서 로그인은 별도로 작성해야 할 것 같다.
이번 프로젝트를 진행하면서 테스트 코드도 함께 작성하기 시작했는데 뭔가 엉성한 것 같지만
테스트 코드가 있어서 빌드 전에 컴파일 오류를 확인할 수 있어 좋은 것 같다.
https://github.com/sideProject-org/shop-back
'Java > 쇼핑몰' 카테고리의 다른 글
쇼핑몰 - 6 [Spring Security + JWT + OAuth2 + Redis] (2) | 2024.12.02 |
---|---|
쇼핑몰 - 5 [spring security + JWT + Redis 로그인] (0) | 2024.11.19 |
쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD] (3) | 2024.11.06 |
쇼핑몰 - 2 [Docker-compose 사용하기] (0) | 2024.11.03 |
쇼핑몰 만들기 - 1 [AWS EC2로 배포 서버 만들기] (3) | 2024.11.01 |