매년 유기견들이 늘어남에 따라 많은 유기견들에게 보호와 돌봄이 필요하지만 자원이 부족한 상황입니다.
새싹 애니멀은 이러한 상황을 개선하고 유기견에게 더 나은 미래를 제공하기 위해 유기견 입양/임보 서비스를 제공합니다.
개발 인원 : 4명 (박성수, 류명한, 이경진, 손승범)
개발 기간 : 2023.11.01 ~ 2023.11.17
박성수(BE) 팀장 |
류명한(BE) 팀원 |
이경진(BE) 팀원 |
손승범(BE) 팀원 |
- 📅 개발 일정
- 🌐 실서버 링크 (배포완료)
- 📌 Ground Rule
- 🤙 컨벤션
- 📁 디렉토리 구조
- 📜 API 명세서
- 🖼️ 와이어프레임
- 📚 다이어그램 (클래스, 시퀀스)
-
이메일 인증을 통한 회원가입 기능
- 📌 Code - [회원가입_서비스] [메일 인증_프로바이더]
- 🔀 Flow Chart
-
(Spring Security 활용) JWT 토큰 기반 방식의 로그인 기능
- 📌 Code - [로그인_컨트롤러] [JWT_예외 필터] [JWT_인증 필터] [JWT 토큰_프로바이더]
- 🔀 Flow Chart
-
카카오 플랫폼을 이용한 소셜 로그인 기능
- Code - 📌 [로그인_컨트롤러]
-
사용자 정보를 통한 아이디 찾기, SMS 문자 인증을 통한 비밀번호 찾기 기능
- 📌 Code - [아이디 찾기_서비스] [비밀번호 찾기_서비스] [문자 인증_프로바이더]
- 🔀 Flow Chart - [아이디 찾기] [비밀번호 찾기]
-
MyPage 에서의 회원 탈퇴, 비밀번호 변경, 내 게시글 목록 기능
- 📌 Code - [회원 탈퇴_컨트롤러] [비밀번호 변경_컨트롤러] [내 게시글 목록_컨트롤러]
- 🔀 Flow Chart - [회원 탈퇴] [비밀번호 변경] [내 게시글 목록]
-
Oracle Cloud 및 Docker를 활용한 기본 인프라 구축
-
Gitea 및 Jenkins CI/CD Pipeline 구축
-
Certbot, Let's Encrypt 이용하여 HTTPS 적용 (SSL-Offloading)
-
Redis를 사용하여 게시글 별로 '좋아요 count'를 캐싱 📌 [게시글_서비스] [좋아요_서비스] [캐시_서비스]
-> 게시글 목록에서 '좋아요 count' 노출시 효율성 향상 -
'좋아요' 호출시 Redis와 db 동시 갱신을 통한 동기화 📌 [좋아요_서비스] [캐시_서비스] [레디스_서비스]
-> Redis의 INCR와 DECR를 사용하여 동시성 확보 -
복합 조건으로 필터링하는 기능 구현 📌 [게시글_레포지토리]
-> Querydsl을 사용하여 동적 쿼리 구현 -
테스트 코드 작성 📌 [컨트롤러_테스트] [서비스_테스트]
-> Junit5를 사용하여 테스트 작성 -
메소드의 실행 속도 측정을 위한 AOP 작성 📌 [프로파일_AOP]
-> 클래스 레벨과 메소드 레벨에 모두 적용가능
-
계층 구조의 댓글, 대댓글 구현
- 📌 Code - [댓글 계층 구조_서비스] [댓글_레포지토리]
- 🔀 Flow Chart
-
제목, 작성자, 내용을 통한 검색 기능 구현
- 📌 Code - [게시글_레포지토리]
- 🔀 Flow Chart
-> Querydsl을 사용하여 동적 쿼리 구현
-
좋아요순, 조회순에 따른 정렬 기능 구현
- 📌 Code - [게시글 레포지토리]
- 🔀 Flow Chart
-
게시판 이미지 미니오 업로드
- 📌 Code - [미니오 서비스]
- 🔀 Flow Chart
-
테스트 코드 작성
- 📌 Code - [게시글 컨트롤러 테스트] [좋아요 컨트롤러 테스트][댓글 컨트롤러 테스트]
- 📌 Code - [게시글 서비스 테스트][좋아요 서비스 테스트][댓글 서비스 테스트]
-> Junit5를 사용하여 테스트
박성수
- 📌 [코드 확인]
문제 상황 | JWT 리프레시 토큰 만료 시, 토큰이 담긴 쿠키가 삭제되지 않음 |
원인 | HttpServletRequest 객체에 담긴 쿠키는 단순히 Key-Value 값만을 가지고 있기 Cookie 객체에 setMaxAge() 외에 추가적인 설정 필요 |
해결 | 만료 날짜 (setMaxage), 경로 (setPath), 값 (setValue)을 지정하여 쿠키를 삭제 |
[Before]
private void removeTokenInCookie(HttpServletRequest request, HttpServletResponse response) {
// request 객체에서 JWT Token이 담긴 Cookie를 List 형태로 가져 온다.
List cookielist = Arrays.stream(request.getCookies())
.filter(cookie -> {
return cookie.getName().equals(JWT_ACCESS_TOKEN) || cookie.getName().equals(JWT_REFRESH_TOKEN);})
.toList();
// cookie의 타임 아웃을 0으로 만들고 다시 response 객체에 저장한다.
cookielist.forEach(cookie -> {
cookie.setMaxAge(0);
response.addCookie(cookie);
});
}
[After]
private void removeTokenInCookie(HttpServletRequest request, HttpServletResponse response) {
// request 객체에서 JWT Token이 담긴 Cookie를 List 형태로 가져 온다.
List cookielist = Arrays.stream(request.getCookies())
.filter(cookie -> {
return cookie.getName().equals(JWT_ACCESS_TOKEN) || cookie.getName().equals(JWT_REFRESH_TOKEN);})
.toList();
// cookie의 타임 아웃을 0으로 만들고 다시 response 객체에 저장한다.
cookielist.forEach(cookie -> {
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setValue(null);
response.addCookie(cookie);
});
}
문제 상황 | Controller에서 Redirect 시, 브라우저에서 Redirect된 주소로 이동하지 못함 |
원인 | HTTP 요청은 로드밸런서를 통해 Tomcat으로 전달되고 외부 통신은 HTTPS, 내부 통신은 HTTP를 이용하기 때문에 Controller에서 Redirect 시, Location 헤더에는 "http://~~" 값이 들어가기 때문 |
해결 | 내부 통신도 Self-Signed Key를 생성하여 HTTPS 통신을 해도 되지만 HTTP(80)으로 요청 시, HTTPS(443)으로 Redirect 하도록 HAProxy 설정을 추가 |
[Before]
#---------------------------------------------------------------------
# main frontend which proxys to the backends
#---------------------------------------------------------------------
frontend main
bind *:443 ssl crt /etc/haproxy/server.pem
log 127.0.0.1:514 local1
default_backend app
#---------------------------------------------------------------------
# round robin balancing between the various backends
#---------------------------------------------------------------------
backend app
balance roundrobin
server was01 192.168.0.105:8001 check
server was02 192.168.0.105:8002 check
server was03 192.168.0.105:8003 check
[After]
#---------------------------------------------------------------------
# main frontend which proxys to the backends
#---------------------------------------------------------------------
frontend main
bind *:80
bind *:443 ssl crt /etc/haproxy/server.pem
http-request redirect scheme https unless { ssl_fc }
log 127.0.0.1:514 local1
default_backend app
#---------------------------------------------------------------------
# round robin balancing between the various backends
#---------------------------------------------------------------------
backend app
balance roundrobin
server was01 192.168.0.105:8001 check
server was02 192.168.0.105:8002 check
server was03 192.168.0.105:8003 check
류명한
- 📌 [코드 확인]
문제 #1 | Redis의 캐싱된 좋아요 Count를 갱신할 때 동시성 이슈 발생 |
원인 | get과 set 연산 사이에 다른 스레드의 요청에 의해 값이 변경될 수 있음 |
해결 | redis에서 제공하는 원자성을 보장하는 함수를 사용하여 해결 (incr, dec) |
[Before]
@Override
public void update(long postId, int status) {
String likeCountKey = cachePrefix + postId;
Optional maybeCurrentCount = redisServiceProvider.get(likeCountKey);
int currentCount = maybeCurrentCount.isPresent()
? Integer.parseInt(maybeCurrentCount.get())
: missingLikeRepository.likedCountByPostId(postId);
int nextCount = status == ADD
? addCount(currentCount)
: subCount(currentCount);
redisServiceProvider.save(likeCountKey, nextCount);
}
private int addCount(int currentCount) {
return currentCount + 1;
}
private int subCount(int currentCount) {
return currentCount > 0 ? currentCount - 1 : 0;
}
[After]
@Override
public Optional getCountByPostId(long postId) {
String likeCountKey = cachePrefix + postId;
Optional maybeCurrentCount = redisServiceProvider.get(likeCountKey);
Integer currentCount = maybeCurrentCount.isPresent()
? Integer.parseInt(maybeCurrentCount.get())
: null;
return Optional.ofNullable(currentCount);
}
@Override
public void updateLike(long postId, int status) {
String likeCountKey = cachePrefix + postId;
if (status == ADD) {
redisServiceProvider.increase(likeCountKey); // 함수 내부에서 incr 실행
} else {
redisServiceProvider.decrease(likeCountKey); // 함수 내부에서 decr 실행
}
}
문제 #2 | 게시판 목록에서 게시글 별로 좋아요 숫자를 표현하는 로직이 비효율적인 상황 |
원인 | 초기 테이블 설계 좋아요 숫자 표현이 고려되지 않음 |
옵션 | 좋아요 테이블을 반정규화 vs 미리 집계한 count를 별도의 장소에 캐싱 |
선택 | Redis를 사용하여 게시글 별 좋아요 숫자를 캐싱함 |
근거 |
1. 프로젝트에서 이미 Redis를 사용 중이기 때문에, 즉시 사용가능한 상황 2. 테이블 구조를 변경하는 것은 서비스 전반에 영향을 미치기 때문에 개발 후반부에 작업하기에 부적절하다고 판단 |
이경진
문제 상황 | 작성 예정 |
원인 | 작성 예정 |
해결 | 작성 예정 |
손승범
- 📌 [코드 확인]
문제#1 |
1. 좋아요순으로 게시글 조회시 데이터 정렬을 어플리케이션에서 처리 -> 이에 따른 어플리케이션 과부하 가능성 존재 |
원인 |
1. 초기 ERD 설계 시 게시글 좋아요 숫자 반영하지 않음 2. DB에서 조회시 쿼리를 통해 데이터 정렬하지 않음 |
옵션 | 좋아요 테이블을 반정규화 vs 각 게시글 별로 좋아요 숫자만큼 그룹화하여 그 크기별로 정렬 |
선택 | 각 게시글 별로 좋아요 숫자만큼 그룹화하여 그 크기별로 정렬 |
근거 | 개발 일정이 얼마 남지 않은 상황에서 테이블 구조를 변경하는 것은 협업, 코드 수정에 있어서 예측불가의 과업이 생길 수 있다고 판단 |
[Before]
public Page findAllByType(String type, Pageable pageable) {
QReviewPost reviewPost = QReviewPost.reviewPost;
QMember member = QMember.member;
QReviewImage reviewImage = QReviewImage.reviewImage;
BooleanBuilder builder = new BooleanBuilder();
builder.and(reviewPost.isActive.eq(1));
JPAQuery query = jpaQueryFactory
.selectFrom(reviewPost)
.leftJoin(reviewPost.member, member).fetchJoin()
.where(builder);
if ("view".equals(type)) {
query.orderBy(reviewPost.viewCount.desc());
} else if ("like".equals(type)) {
query.groupBy(reviewPost.id)
.orderBy(reviewPost.reviewPostLikes.size().desc());
}
List content = query
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = jpaQueryFactory
.selectFrom(reviewPost)
.leftJoin(reviewPost.member, member)
.leftJoin(reviewPost.reviewImages, reviewImage)
.where(builder)
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
[After]
public Page findAllByType(String type, Pageable pageable) {
BooleanBuilder builder = new BooleanBuilder();
builder.and(reviewPost.isActive.eq(1));
JPAQuery query = jpaQueryFactory
.selectFrom(reviewPost)
.leftJoin(reviewPost.member, member).fetchJoin()
.leftJoin(reviewPost.reviewPostLikes, reviewPostLike)
.where(builder);
if ("view".equals(type)) {
query.orderBy(reviewPost.viewCount.desc());
} else if ("like".equals(type)) {
query.groupBy(reviewPost.id)
.orderBy(reviewPostLike.count().desc());
}
List content = query
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = jpaQueryFactory
.selectFrom(reviewPost)
.where(builder)
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
- 📌 [코드 확인]
문제#2 | 데이터 중복 로드, N+1로 인한 불필요한 쿼리 발생 |
원인 | N+1 문제 발생을 위해 컬렉션을 패치조인하여 데이터 중복이 발생 |
해결 |
1. ToMany를 로드할 때는 Batch 활용 2. 가져와야 할 컬렉션의 양이 제한적이라면 Batch 활용이 아닌 직접 쿼리 작성 3. ToOne을 로드할 때는 Fetch Join 활용 |
[Before]
@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p. reviewImages i WHERE p.isActive = 1",
countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.isActive = 1")
Page findAllPrevious(Pageable pageable);
[After]
@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m WHERE p.isActive = 1",
countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.isActive = 1")
Page findAll(Pageable pageable);
//댓글의 데이터가 방대할 시에 Batch를 활용해 가져온다면 불필요한 데이터를 로드할 것이라고 판단
//후에 내부 결정에 따른 limit을 걸 수 있게 쿼리 직접 작성
public List findAllByPostId(Long reviewPostId){
QReviewComment reviewComment = QReviewComment.reviewComment;
QMember member = QMember.member;
return jpaQueryFactory.selectFrom(reviewComment)
.leftJoin(reviewComment.member)
.fetchJoin()
.leftJoin(reviewComment.parentComment)
.fetchJoin()
.where(reviewComment.reviewPost.id.eq(reviewPostId))
.orderBy(reviewComment.parentComment.id.asc().nullsFirst(), reviewComment.createdAt.desc())
.fetch();
}
박성수
- 📌 [코드 확인]
Before | 도메인별 Controller에서 사용자 정보 (MemberDto)를 얻기 위해, JWT 토큰에 담긴 클레임을 직접 파싱 |
After | MemberDto에 맞는 ArgumentResolver를 추가하여 Controller에서 직접 파싱하지 않도록 변경 (중복 코드 제거) |
@Slf4j @Component @RequiredArgsConstructor public class MemberDtoArgumentResolver implements HandlerMethodArgumentResolver { private final JwtTokenProvider jwtTokenProvider; @Override public boolean supportsParameter(MethodParameter parameter) { boolean hasMemberAnnotation = parameter.hasParameterAnnotation(Member.class); boolean hasMemberType = MemberDto.class.isAssignableFrom(parameter.getParameterType()); return hasMemberAnnotation && hasMemberType; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); String token = jwtTokenProvider.resolveToken(request, JWT_ACCESS_TOKEN); // 기존 쿠키에 JWT Access 토큰이 없는 경우, Request 영역에 저장해둔 newAccessToken을 사용 if (token == null && request.getAttribute(JWT_ACCESS_TOKEN) != null) token = (String) request.getAttribute(JWT_ACCESS_TOKEN); // 기존 쿠키에 JWT Access 토큰이 있는 경우, JWT를 파싱하여 MemberDto 객체로 리턴 if (token != null) return jwtTokenProvider.parseToken(token); // 없으면 null 값 리턴 return null; } }
류명한
- 📌 [코드 확인]
Before | BooleanBuilder와 반복되는 if문을 사용하여 필터를 위한 동적 쿼리 생성 |
After | BooleanExpression을 사용하여 조건문을 제거하고 쿼리를 보다 직관적으로 변경 |
[Before]
@Override
public Page findByFilter(MissingFilterDto filter, Pageable pageable) {
QMissingPost qMissing = QMissingPost.missingPost;
QMissingPostImage qImage = QMissingPostImage.missingPostImage;
BooleanBuilder builder = new BooleanBuilder();
builder.and(qMissing.isActive.eq(isActive));
if (filter.getAnimalType() != null) {
builder.and(qMissing.animalType.equalsIgnoreCase(filter.getAnimalType()));
}
if (filter.getFromDate() != null) {
builder.and(qMissing.missingTime.goe(filter.getFromDate()));
}
if (filter.getEndDate() != null) {
builder.and(qMissing.missingTime.loe(filter.getEndDate()));
}
if (filter.getSearch() != null && !filter.getSearch().isBlank() && !filter.getSearch().isEmpty()) {
builder.and(qMissing.title.containsIgnoreCase(filter.getSearch()));
}
List results = queryFactory
.selectFrom(qMissing).distinct()
.where(builder)
.innerJoin(qMissing.images, qImage)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy()
.fetch();
long total = queryFactory
.select(qMissing.missingId.count())
.where(qMissing.isActive.eq(isActive))
.from(qMissing)
.fetchOne();
return new PageImpl<>(results, pageable, total);
}
[After]
@Override
public Page findByFilter(MissingFilterDto filter, Pageable pageable) {
List results = queryFactory
.selectFrom(qMissing)
.innerJoin(qMissing.images, qImage).fetchJoin()
.where(getFilterExpressions(filter))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(qMissing.updatedAt.desc())
.fetch();
long total = queryFactory
.select(qMissing.missingId.count())
.where(getFilterExpressions(filter))
.from(qMissing)
.fetchOne();
return new PageImpl<>(results, pageable, total);
}
private BooleanExpression[] getFilterExpressions(MissingFilterDto filter) {
return new BooleanExpression[] {
eqAnimalType(filter.getAnimalType()),
eqSpecifics(filter.getSpecifics()),
containKeyword(filter.getSearch()),
eqColor(filter.getColor()),
goeFromDate(filter.getFromDate()),
loeEndDate(filter.getEndDate()),
eqIsActive(isActive)
};
}
private BooleanExpression eqAnimalType(String animalType) {
if (StringUtils.isNullOrEmpty(animalType)) {
return null;
}
return qMissing.animalType.equalsIgnoreCase(animalType);
}
private BooleanExpression containKeyword(String keyword) {
if (StringUtils.isNullOrEmpty(keyword)) {
return null;
}
return qMissing.title.containsIgnoreCase(keyword);
}
...
이경진
Before | 작성 예정 |
After | 작성 예정 |
손승범
- 📌 [코드 확인]
Before |
제목, 작성자, 내용에 따른 검색 시에 요구되는 api와 1대1 매핑관계 형성 추가되는 기능에 따른 메소드 증가로 인한 불필요한 코드 증가 |
After | 동적 쿼리를 작성하여 변경, 수정사항이 생길 시에 where절만 수정하게끔 변경 |
public ReadListGeneric readBySearch(String type, String keyword, Integer page, int size) {
switch (type){
case "view":
return readByView(page ,size, keyword);
case "author":
return readByName(page ,size, keyword);
case "title":
return readByTitle(page,size,keyword);
case "content":
return readByContent(page, size, keyword);
}
return null;
}
// 내용 검색
@Transactional(readOnly = true)
private ReadListGeneric readByContent(Integer page, int size, String content) {
Pageable pageable = createPageByCreatedAt(page,size);
Page postList = reviewRepository.findAllWithMemberAndImageByContent(content, pageable);
return entityToDtoByReadAll(postList);
}
// 제목 검색
@Transactional(readOnly = true)
private ReadListGeneric readByTitle(Integer page, int size, String title) {
Pageable pageable = createPageByCreatedAt(page,size);
Page postList = reviewRepository.findAllWithMemberAndImageByTitle(title,pageable);
return entityToDtoByReadAll(postList);
}
//유저 닉네임으로 검색
@Transactional(readOnly = true)
private ReadListGeneric readBynickName(Integer page, int size, String nickname) {
Pageable pageable = createPageByCreatedAt(page,size);
Page postList = reviewRepository.findAllWithMemberAndImageByNickname(nickname,pageable);
return entityToDtoByReadAll(postList);
}
// 닉네임 검색 쿼리
@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p.reviewImages i WHERE m.nickname = :nickname AND p.isActive = 1",
countQuery = "SELECT count(p.id) FROM ReviewPost p JOIN p.member m WHERE m.nickname = :nickname AND p.isActive = 1")
Page findAllWithMemberAndImageByNickname(@Param("nickname") String nickname, Pageable pageable);
// 제목 검색 쿼리
@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p.reviewImages i WHERE p.title LIKE %:title% AND p.isActive = 1",
countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.title LIKE %:title% AND p.isActive = 1")
Page findAllWithMemberAndImageByTitle(@Param("title") String title, Pageable pageable);
//내용 검색 쿼리
@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p.reviewImages i WHERE p.content LIKE %:content% AND p.isActive = 1",
countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.content LIKE %:content% AND p.isActive = 1")
Page findAllWithMemberAndImageByContent(@Param("content") String content, Pageable pageable);
// 검색 관련 서비스 로직
@Transactional(readOnly = true)
public readList readByKeyword(String type, Integer page, int size, String keyword) {
Pageable pageable = createPageByCreatedAt(page,size);
Page postList = reviewPostCustomRepository.findAllWithMemberAndImageByTypeAndKeyword(type, keyword,pageable);
return entityToDtoByReadAll(postList);
}
// 제목, 작성자, 내용 검색 따른 동적 쿼리 작성
// 투원 관계 - > 패치조인, 투 매니(컬렉션) 관계 -> 배치 활용
public Page findAllWithMemberAndImageByTypeAndKeyword(String type, String keyword, Pageable pageable) {
QReviewPost reviewPost = QReviewPost.reviewPost;
QMember member = QMember.member;
BooleanBuilder builder = new BooleanBuilder();
builder.and(reviewPost.isActive.eq(1));
if(type != null && keyword != null) {
switch(type) {
case "author":
builder.and(member.nickname.eq(keyword));
break;
case "title":
builder.and(reviewPost.title.eq(keyword));
break;
case "content":
builder.and(reviewPost.content.contains(keyword));
break;
}
}
List content = jpaQueryFactory
.selectFrom(reviewPost)
.leftJoin(reviewPost.member, member).fetchJoin()
.where(builder)
.orderBy(reviewPost.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = jpaQueryFactory
.selectFrom(reviewPost)
.where(builder)
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
박성수
-
프로젝트를 진행하며 현재 나의 위치와 수준을 파악할 수 있었습니다.
-
리팩토링 과정을 거치게 되면서, 테스트 코드의 중요성을 깨닫게 되었습니다.
류명한
-
효율적인 협업을 위해서는 커뮤니케이션이 중요하다고 다시 한번 느꼈습니다.
-
데일리 스크럼이 효과가 있다는 것을 느꼈습니다.
이경진
-
확장성을 고려한 설계가 중요하다고 생각했습니다. 설계를 하더라도 수정사항이 생기게 되는데 미리 구상하고 설계를 하면 업무가 수월하다는 걸 느꼈습니다.
-
효율적인 업무를 위해서 커뮤니케이션이 중요하다고 느꼈습니다. 공통의 작업과 일정과 업무를 정할 때도 커뮤니케이션이 원활해야 순조롭게 진행되는 점을 느꼈습니다.
-
데일리 스크럼을 통하여 작업 진행 사항을 바로 파악하여 조정해보았는데 좋은 경험이었습니다.
손승범
- 팀의 목표를 기간별로 나누어 수행하는 것이 2가지 측면에서 이점이 있음을 알게되었습니다.
-
나의 수준과 역량을 파악할 수 있다.
-
프로젝트 진행에 대한 피드백을 빠르게 가질 수 있다.
- 단순 코드를 치는 것보다 고민을 하는 시간을 가지는 것이 2가지 측면에서 이점이 있다는 것을 배웠습니다.
-
미시적 관점보다 거시적 관점에서 해당 로직을 바라볼 수 있다.
-
하고 있는 과업에 대해 단계별 접근이 가능하다.