궤도

[Spring] [면적면적[11]] 캐릭터 페이지 만들기 (MapStruct랑 lombok을 같이 쓰면 버그가 있나..?) 본문

💻 현생/📕 면적면적(스프링 실습)

[Spring] [면적면적[11]] 캐릭터 페이지 만들기 (MapStruct랑 lombok을 같이 쓰면 버그가 있나..?)

영이오 2021. 8. 4. 18:27

북적북적의 백엔드를 클론하고 있다.

 

Github

 

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로 만들기

MapStruct 사용시 Unknown Property error (스택 오버플로우)

관련 이슈

Comments