개발자부분을 먼저 개발해보았다. 늘 하던대로 controller를 첫번째로 만들었다. api spec을 보고 만드는거라 크게 어렵지 않았다.
@RestController
public class ProgrammerRestController {
private final ProgrammerService programmerService;
public ProgrammerRestController(ProgrammerService programmerService) {
this.programmerService = programmerService;
}
//생성
@PostMapping("/programmers")
public ProgrammerResponse create(@RequestBody ProgrammerCreateRequest programmerRequest){
return programmerService.create(programmerRequest);
}
//간단한 조회
@GetMapping("/programmers")
public List<ProgrammerReadResponse> findAll(@LoginMember String authorization,
@RequestParam List<Field> field,
@RequestParam Integer personalHistory){
return programmerService.findAll(authorization,field,personalHistory);
}
//상세 조회
@GetMapping("/programmers/{programmerId}")
public ProgrammerDetailResponse findById (@LoginMember String authorization,
@PathVariable String programmerId){
return programmerService.findById(authorization, programmerId);
}
//상세 조회 - 내 정보
@GetMapping("/programmers/my")
public ProgrammerResponse findByMypage (@LoginMember String authorization){
return programmerService.findByMyPage(authorization);
}
//내 정보 수정
@PutMapping("/programmers/my")
public ProgrammerResponse updateMypage (@RequestBody ProgrammerRequest programmerRequest,
@LoginMember String authorization){
return programmerService.updateMyPage(programmerRequest, authorization);
}
@PatchMapping("/programmers/my")
public void updatePassword(@LoginMember String authorization,
@RequestBody ProgrammerPasswordRequest programmerPasswordRequest){
programmerService.updatePassword(authorization, programmerPasswordRequest);
}
//내 정보 삭제
@DeleteMapping("/programmers/my")
public void deleteMypage(@LoginMember String authorization){
programmerService.deleteById(authorization);
}
service, repository를 그 다음에 만들었다.
service를 만들면서 필요한 request들을 함께 만들었다.
userId,password,name, age,email,personalHistory,fieldName,selfIntroduction,certificate를 받고, public ProgrammerResponse create(ProgrammerCreateRequest programmerRequest) {
Programmer programmer = programmerRepository.save(new Programmer(
programmerRequest.userId(),
programmerRequest.password(),
programmerRequest.name(),
programmerRequest.age(),
programmerRequest.email(),
programmerRequest.personalHistory(),
programmerRequest.fieldName(),
programmerRequest.selfIntroduction(),
programmerRequest.certificate()));
return new ProgrammerResponse(
programmer.getId(),
programmer.getUserId(),
programmerRequest.name(),
programmerRequest.age(),
programmerRequest.email(),
programmerRequest.personalHistory(),
programmerRequest.fieldName(),
programmerRequest.selfIntroduction(),
programmerRequest.certificate());
}
❓왜 QueryDsl을 사용할까?
- JPA Query Methods를 사용하면 점점 메서드 이름이 지나치게 길어져서 가독성 떨어짐
- JPA Query Methods와 JPQL은 실행 전(컴파일 타임)에
기본적인 오류조차 확인하기 힘듦 (❌ Type-safe)
public ProgrammerDetailResponse findById(String authorization, String programmerId) {
Programmer programmer = programmerRepository.findById(programmerId)
.orElseThrow(() -> new NoSuchElementException("찾으시는 개발자가 없습니다."));
return new ProgrammerDetailResponse(
programmer.getName(),
programmer.getAge(),
programmer.getEmail(),
programmer.getPersonalHistory(),
programmer.getFieldName(),
programmer.getSelfIntroduction(),
programmer.getCertificate(),
isLiked(authorization, programmerId),
programmer.getLikeCount());
}
근데 이게 왜 필요한지 지금도 이해가 안간다.. 자기 정보를 조회하는건 왜? 그냥 자기 정보를 수정할 때만 있어도 되는거 아닌가?
-> 왜냐하면 자기정보를 수정할 때만 보는건 기본적으로 말이 안되지 않는가? 따라서 기본적으로 조회하는 것도 필요하다.
public ProgrammerResponse findByMyPage(String authorization) {
Programmer programmer = programmerRepository.findById(authorization)
.orElseThrow(() -> new NoSuchElementException("로그인 정보가 없습니다."));
return new ProgrammerResponse(
programmer.getId(),
programmer.getUserId(),
programmer.getName(),
programmer.getAge(),
programmer.getEmail(),
programmer.getPersonalHistory(),
programmer.getFieldName(),
programmer.getSelfIntroduction(),
programmer.getCertificate());
}
password는 수정이 아니라, 보통 재설정하기 때문에 비밀번호는 제외하고 만든다.
❓왜 paaword를 재설정하냐면,
비밀번호는 현재 해쉬화가 되어 있다. 따라서 서버에 저장된 비밀번호를 찾아주는 방식을 사용하면 해커가 해킹할 가능성이 크다. (해커들은 똑똑해서,,)해쉬화된 비밀번호를 복호화를 할 가능성이 크기 때문에 조심해야한다.
@Transactional
public ProgrammerResponse updateMyPage(ProgrammerRequest programmerRequest, String authorization) {
Programmer programmer = programmerRepository.findById(authorization)
.orElseThrow(() -> new NoSuchElementException("로그인 정보가 없습니다."));
programmer.setUserId(programmerRequest.userId());
programmer.setName(programmerRequest.name());
programmer.setAge(programmerRequest.age());
programmer.setEmail(programmerRequest.email());
programmer.setPersonalHistory(programmerRequest.personalHistory());
programmer.setFieldName(programmerRequest.fieldName());
programmer.setSelfIntroduction(programmerRequest.selfIntroduction());
programmer.setCertificate(programmerRequest.certificate());
return new ProgrammerResponse(
programmer.getId(),
programmer.getUserId(),
programmer.getName(),
programmer.getAge(),
programmer.getEmail(),
programmer.getPersonalHistory(),
programmer.getFieldName(),
programmer.getSelfIntroduction(),
programmer.getCertificate());
}
@Transactional
public void updatePassword(String authorization, ProgrammerPasswordRequest programmerPasswordRequest) {
Programmer programmer = programmerRepository.findById(authorization)
.orElseThrow(() -> new NoSuchElementException("로그인 정보가 없습니다."));
programmer.setPassword(programmerPasswordRequest.password());
}
public void deleteById(String authorization) {
programmerRepository.findById(authorization)
.orElseThrow(() -> new NoSuchElementException("로그인 정보가 없습니다."));
programmerRepository.deleteById(authorization);
}
좋아요를 눌렀는지 안눌렀는지 true, false로 판별해준다.
private Boolean isLiked(String senderId, String receiverId) {
return likeRepository.findBySenderIdAndReceiverId(senderId, receiverId) != null;
}
}
@Repository
public class ProgrammerQueryRepository {
private final JPAQueryFactory jpaQueryFactory;
private final QProgrammer programmer = QProgrammer.programmer;
public ProgrammerQueryRepository(JPAQueryFactory jpaQueryFactory) {
this.jpaQueryFactory = jpaQueryFactory;
}
최초 화면은 좋아요순 정렬이다.
좋아요순은 내림파차이니까 .desc()을 붙여야된다.
분야 선택, 경력 선택 2가지 선택지가 있다. 따라서 .where절로 조건을 걸어둔다.
최종적으로 필터링된 데이터를 리스트 형태로 가져옴(.fetch)
public List<Programmer> findAll(
List<Field> fieldNames, // 선택된 분야들
Integer personalHistory // 선택된 경력
) {
return jpaQueryFactory
.selectFrom(programmer)
.where(
// 분야 여러개 선택 필터링
fieldNameCriteria(fieldNames),
// 경력 필터링
personalHistoryCriteria(personalHistory)
)
// 좋아요 순 정렬 (내림차순)
.orderBy(programmer.likeCount.desc())
.fetch();
}
분야는 여러개를 선택할 수 있기 때문에 그것도 고려를 해야한다.
// 분야 다중 선택 메서드
private BooleanExpression fieldNameCriteria(List<Field> fieldNames) {
if (fieldNames == null || fieldNames.isEmpty()) {
return null;
}
return programmer.fieldName.in(fieldNames);
}
.in
programmer.fieldName에 fieldNames 리스트에 포함된 데이터만 조회
// 경력 필터링 메서드
private BooleanExpression personalHistoryCriteria(Integer personalHistory) {
if (personalHistory == null) {
return null;
}
return programmer.personalHistory.goe(personalHistory);
}
}
goe()는 >= 연산자
즉, personalHistory가 3이면 경력이 3년 이상인 프로그래머만 조회
해서 개발자 조회의 querydsl를 만들었다.
public List<ProgrammerReadResponse> findAll(String authorization, List<Field> field, Integer personalHistory) {
List<Programmer> programmerList = programmerQueryRepository.findAll(field, personalHistory);
List<ProgrammerReadResponse> programmerReadResponses = new ArrayList<>();
for (Programmer p : programmerList) {
programmerReadResponses.add(
new ProgrammerReadResponse(
p.getId(),
p.getName(),
p.getAge(),
p.getFieldName(),
isLiked(authorization, p.getId()),
p.getLikeCount()));
}
return programmerReadResponses;
}
그래서 이렇게 쿼리리포지토리를 가져와서 사용하게 된다.
나머지 개발자repository /
public interface ProgrammerRepository extends JpaRepository<Programmer, String> {
Programmer findByUserId(String s);
}
지금까지 짠 부분을 테스트해야한다.
테스트
문제는 테스트였다.
a. 테스트와 local에 있는 데이터를 분리를 해줘야한다. 그래서 다 분리를 해주면 서로 신경을 안쓰니까 좋다.
b. 테스트끼리도 영향을 받지 않았으면 좋겠어서 테스트 격리를 해주었다.
데이터가 들어있으면 테스트끼리도 서로서로 영향을 받기 때문에 격리가 필요하다.
@Service
public class DatabaseCleanup implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private List<String> tableNames;
@Override
public void afterPropertiesSet() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
.map(e -> e.getName()
.replaceAll("([a-z])([A-Z])", "$1_$2") // camel case to snake case
.toLowerCase())
.collect(Collectors.toList());
}
@Transactional
public void execute() {
entityManager.flush();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
//밑에꺼를 주의
entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
이렇게 테스트 격리 코드를 넣으니까 아래와 같은 에러가 났다.
Failed to load ApplicationContext for
[WebMergedContextConfiguration@5b55c3d6 testClass = ~~
이렇게 알 수 없는 에러가 났을 땐, 테스트 실행이 아닌 그냥 웹 실행을 해보면 답이 나온다. 거기서 나온 에러문은 ALTER를 사용할 수 없다는 것이다. postsql에서는 alter을 사용할 수 없다 한건가,, 여튼 alter문을 사용할 수 없어서 에러가 났다. 그래서 주석처리해서 사용했더니 테스트가 돌아갔다.
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class programmerTest {
@LocalServerPort
int port;
@Autowired
DatabaseCleanup databaseCleanup;
@Autowired
JwtProvider jwtProvider;
@BeforeEach
void setUp() {
databaseCleanup.execute();
RestAssured.port = port;
}
@Test
void 개발자생성() {
ProgrammerResponse 개발자 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
.body(new ProgrammerCreateRequest(
"userId",
"abc123!",
"chu",
24,
"email",
1,
"백엔드",
"안녕하세요",
"없음"))
.when()
.post("/programmers")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
assertThat(개발자).isNotNull();
}
@Test
void 개발자_상세조회() {
ProgrammerResponse 개발자 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
.body(new ProgrammerCreateRequest(
"userId",
"abc123!",
"chu",
24,
"email",
1,
"백엔드",
"안녕하세요",
"없음"))
.when()
.post("/programmers")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
ProgrammerDetailResponse 개발자상세 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
.pathParam("programmerId", 개발자.id())
.when()
.get("/programmers/{programmerId}")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerDetailResponse.class);
assertThat(개발자상세.name()).isNotNull();
assertThat(개발자상세.age()).isNotNull();
assertThat(개발자상세.email()).isNotNull();
assertThat(개발자상세.personalHistory()).isNotNull();
assertThat(개발자상세.fieldName()).isNotNull();
assertThat(개발자상세.selfIntroduction()).isNotNull();
assertThat(개발자상세.certificate()).isNotNull();
}
현재 로그인 토큰 생성하는 부분이 없으니까, 어떻게 해결할까 하다가 본게 대충 토큰 같이 생긴걸 넣어준다. 토큰은 string값이니까 string값 아무거나 넣어준다.
@Test
void 개발자_내정보_상세조회() {
ProgrammerResponse 개발자 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
.body(new ProgrammerCreateRequest(
"userId",
"abc123!",
"chu",
24,
"email",
1,
"백엔드",
"안녕하세요",
"없음"))
.when()
.post("/programmers")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
//로그인
String token = jwtProvider.createToken(개발자.id());
ProgrammerResponse 개발자상세 = RestAssured
.given().log().all()
// TODO: "token" 실제 코드 작성
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.when()
.get("/programmers/my")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
}
@Test
void 개발자_수정() {
String 수정전이름 = "수정 전 이름";
String 수정후이름 = "수정 후 이름";
ProgrammerResponse 개발자 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
.body(new ProgrammerCreateRequest(
"userId",
"abc123!",
수정전이름,
24,
"email",
1,
"백엔드",
"안녕하세요",
"없음"))
.when()
.post("/programmers")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
//로그인
String token = jwtProvider.createToken(개발자.id());
ProgrammerResponse 개발자수정 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
// TODO: "token" 실제 코드 작성
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.body(new ProgrammerRequest(
"userId",
수정후이름,
24,
"chu@gmail",
1,
"벡앤드",
"안뇽",
"없음"))
.when()
.put("/programmers/my")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
assertThat(개발자.name()).isEqualTo(수정전이름);
assertThat(개발자수정.name()).isEqualTo(수정후이름);
assertThat(!개발자.name().equals(개발자수정.name())).isTrue();
}
@Test
void 비밀번호_수정() {
String 수정전비번 = "수정 전 비번";
String 수정후비번 = "수정 후 비번";
ProgrammerResponse 개발자 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
.body(new ProgrammerCreateRequest(
"userId",
수정전비번,
"추민영",
24,
"email",
1,
"백엔드",
"안녕하세요",
"없음"))
.when()
.post("/programmers")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
//로그인
String token = jwtProvider.createToken(개발자.id());
RestAssured
.given().log().all()
.contentType(ContentType.JSON)
// TODO: "token" 실제 코드 작성
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.body(new ProgrammerPasswordRequest(
수정후비번
))
.when()
.patch("/programmers/my")
.then().log().all()
.statusCode(200);
}
@Test
void 개발자_삭제() {
ProgrammerResponse 개발자 = RestAssured
.given().log().all()
.contentType(ContentType.JSON)
.body(new ProgrammerCreateRequest(
"userId",
"abc123!",
"추민영",
24,
"email",
1,
"백엔드",
"안녕하세요",
"없음"))
.when()
.post("/programmers")
.then().log().all()
.statusCode(200)
.extract()
.as(ProgrammerResponse.class);
//로그인
String token = jwtProvider.createToken(개발자.id());
RestAssured
.given().log().all()
.contentType(ContentType.JSON)
// TODO: "token" 실제 코드 작성
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.when()
.delete("/programmers/my")
.then().log().all()
.statusCode(200);
}
내 테스트는 끝났는데, 다른 사람의 테스트가 문제가 생겼다. 내가 분리한 테스트 격리 부분에서 Alter를 뺀 부분을 사용해야되는 문제가 생겼다.
🔥 1. 첫번째 문제
like라는 엔티티가 있다. 근데 table에는 like라는 테이블이 이미 지정되어 있기 때문에 like라는 테이블은 생성되지가 않는다. 따라서 like의 테이블 이름을 likes 로 들어가게 된다.
❓근데 likes로 하면 내부에 List가 있는걸로 오해하지 않는가?
따라서 이걸 구분해주는 테스트격리 코드를 작성해야한다.
@Table(name = "likes")
이렇게 해준다고 테이블에 들어가는 이름이 likes라고 지정되진 않는다. 따라서
2. databaseCleanup코드를 수정해준다.
@Transactional
public void execute() {
entityManager.flush();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
for (String tableName : tableNames) {
tableName = tableName.equals("like") ? tableName + "s" : tableName;
entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
if (tableName.equals("company") || tableName.equals("programmer")) {
continue;
}
entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
}
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
}
테이블 이름이 like라면 like에 s를 붙여서 likes의 효과를 내주어 테이블 이름을 저장한다.
이렇게 수정하니까 alter도 사용하면서 테이블이름도 사용할 수 있게 된다.
추가로 set함수들을 따로따로 할 필요가 없다고 하셔서 update()함수로 묶어서 보기 편하게 하면 좋을 것 같다는 피드백이 있었다. 그래서 기존에 set함수였던 것들을 update()함수에 넣었다.
public void update(String userId,
String name,
String email,
int age,
int personalHistory,
Field fieldName,
String selfIntroduction,
String certificate){
}
이렇게 엔티티안에 넣어두면 기존 service에서 사용했던 set함수를
programmer.setUserId(programmerRequest.userId());
programmer.setName(programmerRequest.name());
programmer.setAge(programmerRequest.age());
programmer.setEmail(programmerRequest.email());
programmer.setPersonalHistory(programmerRequest.personalHistory());
programmer.setFieldName(programmerRequest.fieldName());
programmer.setSelfIntroduction(programmerRequest.selfIntroduction());
programmer.setCertificate(programmerRequest.certificate());
update()로 바꿔주면 된다.
programmer.update(programmerRequest.userId(),
programmerRequest.name(),
programmerRequest.email(),
programmerRequest.age(),
programmerRequest.personalHistory(),
programmerRequest.fieldName(),
programmerRequest.selfIntroduction(),
programmerRequest.certificate());
😐 느낀점
1. 오류 메세지를 잘 보자 - 오류 메세지가 이상하면 실행을 해보면 나온다.
2. 다른 개발자가 아직 다 못한 부분이 있어서 내가 영향을 받는 경우는
최대한 대체해서 처음에 만들고, 나중에 수정해야한다.
3. 테스트 격리!! 중요함!!