궤도
[Spring] [면적면적[11]] 캐릭터 페이지 만들기 (MapStruct랑 lombok을 같이 쓰면 버그가 있나..?) 본문
[Spring] [면적면적[11]] 캐릭터 페이지 만들기 (MapStruct랑 lombok을 같이 쓰면 버그가 있나..?)
영이오 2021. 8. 4. 18:27북적북적의 백엔드를 클론하고 있다.
build.gradle
plugins {
id 'org.springframework.boot' version '2.5.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'jpa'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
all {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-log4j2'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.6.RELEASE'
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
implementation 'org.projectlombok:lombok:1.18.18'
runtimeOnly 'mysql:mysql-connector-java'
implementation 'com.googlecode.json-simple:json-simple:1.1.1'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
src/main/resources/application.yml
aladin:
url: http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?ttbkey=비밀키&itemIdType=ISBN13&output=js&Version=20131101&ItemId={isbn}
naver:
id: 아이디
secret: 비번
secret:
key: 비번
cloud:
aws:
credentials:
access-key: 엑세스 키
secret-key: 시크릿 키
s3:
bucket: 버킷 이름
region:
static: 지역
stack:
auto: false
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3305/데베?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 비번
jpa:
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
hibernate:
ddl-auto: none
properties:
hibernate:
format_sql: true
show-sql: true
#logging:
# level:
# org:
# hibernate:
# type:
# descriptor:
# sql: trace
server:
servlet:
encoding:
force-response: true
logging:
level:
com:
amazonaws:
util:
EC2MetadataUtils: error
요구사항 정리
캐릭터 리스트
[Request] {GET}
[Response] {items, HttpStatus.OK}
캐릭터 상세 정보
[Request] {characterId, GET}
1. 입력값 유효성 확인
[Response] {item, HttpStatus.OK}
나의 캐릭터
[Request] {GET}
1. 입력값 유효성 확인
[Response] {items, HttpStatus.OK}
대표 캐릭터 설정
[Request] {userCharacterId, PUT}
1. 입력값 유효성 확인
[Response] {HttpStatus.NO_CONTENT}
캐릭터 리스트
이건 그냥 사용자에 상관없이 DB에 있는 모든 캐릭터를 불러오면 된다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class CharacterListDto {
private Long id;
private String name;
private String img;
private String shortDescription;
}
dto를 만들고
/**
* 캐릭터 리스트
* localhost:8080/character
*
* @return ResponseEntity
*/
@GetMapping("")
public ResponseEntity<?> characterList() {
log.info("[Request] Character list");
return new ResponseEntity<>(charactersService.characterList(), HttpStatus.OK);
}
컨트롤러도 뭐 딱히 받을 인자가 없다.
/**
* characterList
*
* @return List<CharacterListDto>
*/
public List<CharacterListDto> characterList() {
return charactersRepository.findAll().stream()
.map(charactersMapper.INSTANCE::toCharacterListDto)
.collect(Collectors.toList());
}
MapStruct 통해서 dto로 담아서 반환
@Test
@DisplayName("Retrieve character list | Success")
void characterListSuccess() throws Exception {
mockMvc.perform(get("/character")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}
테스트 코드
캐릭터 상세 정보
이것도 유저 데이터와 상관없이 그냥 DB에서 보내는 데이터이다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class CharacterDto {
private Long id;
private String name;
private String img;
private double height;
private LocalDate birthday;
private String shortDescription;
private String longDescription;
}
엔티티랑 똑같은 dto를 굳이 왜 만드나 싶긴 하겠지만,
엔티티의 상태가 변해도 API의 스펙에는 변화가 없도록 하기 위해서다.
/**
* 상세 캐릭터
* localhost:8080/character/detail?characterId=1
*
* @param characterId
* @return ResponseEntity
*/
@GetMapping("/detail")
public ResponseEntity<?> characterDetail(@RequestParam Long characterId) {
log.info("[Request] Character detail info " + characterId);
return new ResponseEntity<>(charactersService.characterDetail(characterId), HttpStatus.OK);
}
컨트롤러
/**
* characterDetail
*
* @param id
* @return CharacterDto
*/
public CharacterDto characterDetail(Long id) {
return charactersMapper.INSTANCE.toCharacterDto(charactersRepository.findById(id)
.orElseThrow(() -> new NoSuchDataException("Character id = " + id)));
}
서비스
그냥 입력받은 id의 캐릭터가 실제로 존재하는지 확인하고 dto에 넣어서 리턴한다.
@BeforeEach
public void init() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.addFilters(new CharacterEncodingFilter("UTF-8", true))
.alwaysDo(print())
.build();
}
@Test
@DisplayName("Retrieve character | Success")
void characterSuccess() throws Exception {
CharacterDto dto = CharacterDto.builder()
.id(1L)
.name("사는가")
.img("https://drive.google.com/uc?export=view&id=1nCEX42C4w9-09kaxE9ZP5vcM7z4Ir9Ju")
.birthday(LocalDate.parse("1990-01-01"))
.height(0.0)
.shortDescription("얼마나 풍부하게 커다란 것은 약동하다. 뜨거운지라")
.longDescription("이상이 싹이 보이는 열락의 무엇을 그리하였는가? 할지니, 물방아 것은 그들은 바로 사라지지 갑 방황하였으며, 것이다. " +
"이성은 청춘의 생의 길을 그들의 곧 무엇이 심장의 아름다우냐? 열락의 새 위하여서 봄바람을 못하다 거친 청춘의 있으랴? " +
"많이 그것은 꽃 우리 품에 그리하였는가? 것은 옷을 눈이 별과 이것이다.")
.build();
String response = objectMapper.writeValueAsString(dto);
mockMvc.perform(get("/character/detail")
.contentType(MediaType.APPLICATION_JSON)
.param("characterId", "1"))
.andExpect(status().isOk())
.andExpect(content().string(response));
}
@Test
@DisplayName("Retrieve character | Fail : No such data")
void characterFailNoSuchDate() throws Exception {
Map<String, String> error = new HashMap<>();
error.put("NoSuchDataException", "Character id = 100");
String response = objectMapper.writeValueAsString(error);
mockMvc.perform(get("/character/detail")
.contentType(MediaType.APPLICATION_JSON)
.param("characterId", "100"))
.andExpect(status().isBadRequest())
.andExpect(content().string(response));
}
테스트 코드
데이터에 한글이 있어서 init으로 한글 인코딩을 해준다.
나의 캐릭터
넘겨줘야 하는 데이터가 Characters, UserCharacter 엔티티 두 곳에 있다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class UserCharacterDto {
private Long userCharacterId;
private Long characterId;
private String name;
private String img;
private double height;
private LocalDate achieve;
private boolean representation;
}
dto를 만들었다.
두 엔티티의 id를 모두 넘기는 이유는 다음과 같다.
해당화면에서 캐릭터를 누르면 캐릭터의 스토리를 보거나, 대표 캐릭터로 설정할 수 있는데
캐릭터 스토리 보기에는 characterId가 필요하고 대표 캐릭터 설정에는 userCharacterId가 필요해서 둘 다 넘긴다.
/**
* 사용자가 보유한 캐릭터 조회
* localhost:8080/character/user
*
* @param user
* @return ResponseEntity
*/
@GetMapping("/user")
public ResponseEntity<?> userCharacterList(@AuthenticationPrincipal User user) {
log.info("[Request] User character list " + user.getEmail());
return new ResponseEntity<>(charactersService.userCharacterList(user), HttpStatus.OK);
}
컨트롤러
/**
* userCharacterList
*
* @param user
* @return List<UserCharacterDto>
*/
public List<UserCharacterDto> userCharacterList(User user) {
return user.getUserCharacters().stream()
.map(o -> UserCharacterDto.builder()
.userCharacterId(o.getId())
.characterId(o.getCharacters().getId())
.name(o.getCharacters().getName())
.img(o.getCharacters().getImg())
.height(o.getCharacters().getHeight())
.achieve(o.getAchieve())
.representation(o.isRepresentation())
.build())
.collect(Collectors.toList());
}
서비스는 별거 없다.
사실 이 부분도 mapStruct로 처리하려고 했었다.
@Mapping(source = "characters.id", target = "characterId")
@Mapping(source = "userCharacter.id", target = "userCharacterId")
UserCharacterDto toUserCharacterDto(Characters characters, UserCharacter userCharacter);
대충 이런식으로...
이미 지워버린 코드를 대충 타이핑 한거라 사소한 오탈자가 있을 수 있다.
아무튼 검색해보니 lombok과 관련하여 이슈가 있는 것 같다. 이런저런 해결책이 있긴 했는데 그걸 시도해보기엔 지금 당장 나도 mapStruct를 사용하면서 이런저런 버그를 겪는지라 막 시도하기가 그랬다.
참고했던 블로그의 링크를 글의 마지막에 달아두겠다.
@Test
@DisplayName("User character list | Success")
void userCharacterListSuccess() throws Exception {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
List<UserCharacterDto> userCharacterDtos = charactersService.userCharacterList(user);
assertEquals(user.getUserCharacters().size(), userCharacterDtos.size());
assertTrue(userCharacterDtos.get(0).isRepresentation());
}
테스트 코드
실행한 사진은 마지막에 대표 캐릭터 업데이트 테스트 코드와 함께 올리도록 하겠다.
대표 캐릭터 설정
유저의 대표 캐릭터는 하나지 둘이 될 수 없다.
그래서 요청이 들어오면 기존의 대표 캐릭터의 representation을 false로 놓고, 새로운 대표 캐릭터의 representation을 true로 놓아야 한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserCharacter {
@Id
@Column(name = "uc_sn")
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_sn")
@NotNull
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_sn")
@NotNull
private Characters characters;
@NotNull
private LocalDate achieve;
@Convert(converter = BooleanToYNConverter.class)
@NotNull
private boolean representation;
public void updateRepresentation(boolean representation){
this.representation = representation;
}
}
UserCharacter 엔티티에 수정 로직을 넣고
/**
* 사용자의 대표 캐릭터 변경
* localhost:8080/character/user/44
*
* @param user
* @param userCharacterId
* @return ResponseEntity
*/
@PutMapping("/user/{userCharacterId}")
public ResponseEntity<?> updateCharacterRepresentation(@AuthenticationPrincipal User user,
@PathVariable Long userCharacterId) {
log.info("[Request] Update Representation " + userCharacterId);
charactersService.updateCharacterRepresentation(user, userCharacterId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
컨트롤러
/**
* updateCharacterRepresentation
*
* @param user
* @param id
*/
@Transactional
public void updateCharacterRepresentation(User user, Long id) {
UserCharacter newRepresentation = userCharacterRepository.findById(id)
.orElseThrow(() -> new NoSuchDataException("User character id = " + id));
if (!newRepresentation.getUser().equals(user))
throw new AccessDeniedException("User character id = " + id);
UserCharacter oldRepresentation = user.getUserCharacters().stream()
.filter(UserCharacter::isRepresentation)
.reduce((a, b) -> {
throw new NoSuchDataException("There are too many representation characters");
})
.orElseThrow(() -> new NoSuchDataException("There is no representation character"));
oldRepresentation.updateRepresentation(false);
newRepresentation.updateRepresentation(true);
userCharacterRepository.save(oldRepresentation);
userCharacterRepository.save(newRepresentation);
}
서비스
UserCharacter newRepresentation = userCharacterRepository.findById(id)
.orElseThrow(() -> new NoSuchDataException("User character id = " + id));
if (!newRepresentation.getUser().equals(user))
throw new AccessDeniedException("User character id = " + id);
일단 프론트에서 입력한 id에 해당하는 데이터가 있는지 확인하고,
그 데이터의 user와 프론트의 user가 일치하는지 확인한다.
UserCharacter oldRepresentation = user.getUserCharacters().stream()
.filter(UserCharacter::isRepresentation)
.reduce((a, b) -> {
throw new NoSuchDataException("There are too many representation characters");
})
.orElseThrow(() -> new NoSuchDataException("There is no representation character"));
내가 로직을 잘 짰다면 저 필터에 걸리는 데이터는 딱 하나여야 한다.
혹시 모르니까 데이터가 2개 이상 걸리는 경우와 하나도 안걸리는 경우를 처리한다.
oldRepresentation.updateRepresentation(false);
newRepresentation.updateRepresentation(true);
userCharacterRepository.save(oldRepresentation);
userCharacterRepository.save(newRepresentation);
셋팅하고 save하면 끝
@SpringBootTest
@DisplayName("Characters Service Test")
@Transactional
class CharactersServiceTest {
@Autowired
CharactersService charactersService;
@Autowired
UserRepository userRepository;
@Autowired
UserCharacterRepository userCharacterRepository;
@Test
@DisplayName("Update Representation | Success")
void updateRepresentationSuccess() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
//기존 대표 캐릭터
UserCharacter oldRepresentation = userCharacterRepository.findById(2L).orElse(null);
assertNotNull(oldRepresentation);
assertTrue(oldRepresentation.isRepresentation());
//새로운 대표 캐릭터
UserCharacter newRepresentation = userCharacterRepository.findById(44L).orElse(null);
assertNotNull(newRepresentation);
assertFalse(newRepresentation.isRepresentation());
charactersService.updateCharacterRepresentation(user, 44L);
assertFalse(oldRepresentation.isRepresentation());
assertTrue(newRepresentation.isRepresentation());
}
@Test
@DisplayName("Update Representation | Fail : No Such Data")
void updateRepresentationFailNoSuchData() throws Exception {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
NoSuchDataException e = assertThrows(NoSuchDataException.class, () ->
charactersService.updateCharacterRepresentation(user, 10000L));
assertEquals("User character id = 10000", e.getMessage());
}
@Test
@DisplayName("Update Representation | Fail : Access Deny")
void updateRepresentationFailAccessDeny() throws Exception {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
AccessDeniedException e = assertThrows(AccessDeniedException.class, () ->
charactersService.updateCharacterRepresentation(user, 87L));
assertEquals("User character id = 87", e.getMessage());
}
}
테스트 코드
참고 블로그
엔티티 2개 포함한 DTO를 MapStruct로 만들기