구현 기능
- 게시글 조회
- 게시글에 사진과 함께 글, 해시태그 작성하기
- 게시글에 댓글 및 대댓글 기능
- 게시글 댓글에 좋아요, 싫어요 기능
- 게시글, 댓글, 좋아요 삭제 기능
- 질문 (Post)
- 답변 (Answer)
- 좋아요/싫어요 (Like_dislike) + 댓글 (Comment)
- 한 명의 user은 여러개의 Post, Aswer, like_dislike, comment를 작성 가능 (User와 1:N 관계)
2. Post
- 하나의 Post에는 여러개의 Comment, Answer, like_dislike, image 작성 가능 (Post와 1:N 관계)
- Post와 Hashtag는 N:M 관계 -> 중간에 PostHash table 설정
3. Answer
- 하나의 Answer에는 여러개의 comment, Image, like_dislike 작성 가능 (Answer과 1:N 관계)
1. LikeDislike
@Enumerated(EnumType.STRING)
private LikeStatus likestatus;
@Enumerated(EnumType.STRING)
private TargetStatus targetstatus;
- 이 부분에서, LikeStatus는 Enum으로 관리하여 Like, Dislike 설정
- 좋아요/싫어요는 Post와 Answer에 달 수 있으므로 TargetStatus에서 Post, Answer로 관리
2. Comment
@Enumerated(EnumType.STRING)
private TargetStatus targetStatus;
- Comment 또한 Post와 Answer에 각각 작성 가능하므로 TargetStatus를 이용하여 하나의 테이블에서 관리
🌟 Comment는 갯수가 많지 않을 것 같고 코드 중복을 피하려고 이 방식을 사용했는데 Post_Comment와 Answer_Comment로 나누는게 나을까요? 의견 부탁드립니다 🌟
1. User 생성
@BeforeEach
public void setUp() {
// 테스트에 사용할 사용자 데이터 생성
user = User.builder()
.nickname("dohyun")
.email("dohyun@naver.com")
.password("1234")
.build();
userRepository.save(user);
}
2. 작성자를 기준으로 FindPost
- 첫번째 Post 생성
@Test
public void testFindByWriter() {
// given
//첫번째 질문글 (사진 X)
Post post1 = Post.builder()
.title("Post 1")
.content("hello")
.writer(user)
.build();
postRepository.save(post1);
- 두번째 Post 생성
Image image = Image.builder()
.imageUrl("image.jpg") // 이미지 URL 설정
.post(null) // 아직 Post와 연결되지 않음
.build();
//2번째 질문글 (사진 1장)
Post post2 = Post.builder()
.title("Post 2")
.content("one picture")
.images(Collections.singletonList(image))
.writer(user)
.build();
image.setPost(post2);
postRepository.save(post2);
- 세번째 Post 생성
//3번쨰 질문글 (사진 2장)
Post post3 = Post.builder()
.title("Post 3")
.content("two pictures")
.images(Arrays.asList())
.writer(user)
.build();
postRepository.save(post3);
Image image1 = Image.builder()
.imageUrl("image_url_1")
.post(post3)
.build();
Image image2 = Image.builder()
.imageUrl("image_url_2")
.post(post3)
.build();
imageRepository.save(image1);
imageRepository.save(image2);
- Post DB
- Image DB
- 나머지 when/then
// when
List<Post> posts = postRepository.findByWriter(user);
// then
assertThat(posts).hasSize(3);
assertThat(posts).extracting(Post::getTitle).containsExactly("Post 1", "Post 2","Post 3");
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByWriter(User writer);
}
-
Spring이 애플리케이션을 실행하면서 PostRepository의 프록시 객체를 생성
-
인터페이스만 정의하면 Spring이 동적으로 구현체를 만들어 주입 이 때, SimpleJpaRepository 클래스가 작동하며 메서드 이름을 분석해 쿼리 자동 생성
findByWriter(User writer) → "SELECT p FROM Post p WHERE p.writer = ?"
- Spring이 내부적으로 EntityManager를 사용하여 쿼리를 실행하고 결과 반환
- EntityManager은 싱글톤 객체가 아니다 !!
- 트랜잭션이 시작될 때 새로운 EntityManager 객체가 동적으로 생성되며, 트랜잭션이 끝날 때 EntityManager는 폐기됨.
❔ 그럼 왜 생성자 주입?
- EntityManager는 프록시 객체로 주입되며, 실제 트랜잭션 범위에서만 EntityManager가 생성되고 관리된다.
- 프록시 객체는 애플리케이션에서 하나의 인스턴스로 관리되며(싱글톤), 필요한 시점에 실제 EntityManager를 동적으로 생성한다.
- Fetch Join 이란?
: JPQL에서 성능 최적화를 위해 제공하는 기능
: 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능
- Fetch Join 사용
"select t From Team t join fetch t.members where t.name = "팀A";
: Name이 "팀A"인 Team을 조회하면서 해당 팀에 속한 members도 함께 즉시 로딩하여 가져오는 쿼리 (즉시 로딩)
-
만약 "팀 A"에 Member가 2명 있다면? : 팀 A가 2번 중복 됨
-
이 때 !! Distinct를 사용하면
"select distinct t From Team t join fetch t.members where t.name = "팀A";
: 중복되었던 "Team A"가 한번 만 나오게 된다.
(참고 https://9hyuk9.tistory.com/77)
- 좋아요/싫어요는 답변 글에만 달 수 있도록 수정
- 버킷에 잘 들어갔지요~
- 삭제 성공~
- 에러 발생!!
- 에러 발생 !!
- postId를 PathParameter로 입력하면 그 질문과 답변글들을 조회 가능
✨ 좋아요/싫어요 연타 방지를 어떻게 할까... 생각하다가
(1) 좋아요-> 좋아요/ (2) 좋아요-> 싫어요/ (3) 싫어요-> 싫어요/(4) 싫어요->좋아요
모두 에러 처리 나도록 했습니다.
(1) 의 경우
(2),(4)의 경우
결국, LIKE/DISLIKE가 있는 경우, 삭제한 후에만 새로 달 수 있습니다.
❔Hashtag를 이용한 질문글 찾기를 위해 HashtagController을 따로 둘지, PostController에 포함시킬지 고민중입니다. 어떻게 하셨나요❔
1. ErrorStatus + 성공 응답 처리
- exception과 ErrorStatus, SuccessStatus 등을 추가하였습니다.
- ErrorStatus에서는 에러 처리를 Custom하여 추가합니다.
2. Swagger
- SwaggerConfig를 이용한 Swagger 테스트 설정
3. Converter
- DTO <-> Entity 간 변환을 Converter에서 처리
- 서비스 로직의 간결성을 위해
4. Service + ServiceImpl 사용
- Service는 인터페이스 구현 + ServiceImpl은 비즈니스 로직 처리
- 확장성을 위해
5. AWS S3 BUCKET 사용
- 이미지 업로드를 위해 사용
- MultiPartFile 형식으로 이미지를 S3 버킷에 업로드 후, 이미지 URL을 반환하여 DB에 저장
1) 로그인 정보를 받아오기 위한 CustomUserDetails
public class CustomUserDetails implements UserDetails {
private Long userId;
private String username;
private String password;
private Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(Long userId, String username, String password, Collection<? extends GrantedAuthority> authorities) {
this.userId = userId;
this.username = username;
this.password = password;
this.authorities = authorities;
}이후 @AuthenticationPrincipal 로 로그인 정보를 주입받았다.
2) Spring Security
@Bean
public SecurityFilterChain myFilter(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(AbstractHttpConfigurer::disable) //csrf 비활성화
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(a -> a.requestMatchers("/user/create", "/user/login", "/user/logout", "/connect/**", "/v3/api-docs/**",
"/swagger-ui/**", "/swagger-ui.html","permit/**").permitAll().anyRequest().authenticated())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder makePassword() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}- 로그인/회원가입/스웨거 등은 인증 절차 없이 필터를 통과, 로그인하지 않은 사용자가 볼 수 있는 화면 (질문+답변 조회) 등은 엔드포인트를 "permit/"으로 시작하게 하여 필터 통과
- 비밀번호 암호화를 위한 인코더 생성
3) JwtAuthFilter
UserDetails userDetails = new CustomUserDetails(userId, username, null, authorities);
// Authentication 객체 설정
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}-
JWT 안의 정보로 CustomUserDetails 객체를 만든다. 이 때, 비밀번호는 이미 토큰으로 인증된 상태이므로 null 처리
-
만든 Authentication을 SecurityContextHolder에 심어 추후 @AuthenticationPrincipal을 통해 로그인 정보를 꺼냄.
-
post를 예로 들면
<내가 쓴 질문 조회/질문 작성/내가 쓴 질문 삭제> 등의 api는 로그인 정보를 받아와야 하므로 /post로 시작함 <해시태그별 글 조회>는 로그인하지 않은 사용자도 조회 가능하므로 /permit으로 시작해 필터 통과함
(1) 로그아웃
- 리프레시 토큰을 레디스에 저장하는 방법도 있다는데 일단 DB에 저장함.
- RefreshToken entity 추가
public class RefreshToken {
@Id
private Long userId;
private String refreshToken;- 로그아웃 시, 저장해두었던 사용자의 refreshToken이 삭제되고 재로그인 해야 한다.
(2)엑세스 토큰 재발급
- 엑세스 토큰의 유효기간은 30분, 리프레시 토큰의 유효기간은 30일로 설정
- 엑세스 토큰 만료 시, 리프레시 토큰을 이용해 엑세스 토큰을 재발급 받는다.
- 클라이언트가 리프레시 토큰을 요청과 함께 쿠키에서 보내면, 서버에서 이를 검증하여 엑세스 토큰을 갱신한다.
❶ 리프레시 토큰 검증
RefreshToken savedToken = refreshTokenRepository.findByUserId(userId)
.orElseThrow(() -> new CustomException(ErrorStatus.INVALID_REFRESH_TOKEN));
if (!savedToken.getRefreshToken().equals(refreshToken)) {
throw new CustomException(ErrorStatus.INVALID_REFRESH_TOKEN);
TokenDTO newTokenDTO = jwtTokenProvider.createToken(user);
}: DB에서 사용자의 리프레시 토큰을 조회하고 비교한 뒤, jwtTokenProvider.createToken(user)를 호출해 새 토큰 발급한다.
// DB에 리프레시 토큰 업데이트
/ savedToken.setRefreshToken(newTokenDTO.getRefreshToken());
쿠키에 새로운 리프레시 토큰 저장
jwtTokenProvider.setRefreshTokenInCookies(response, newTokenDTO.getRefreshToken());: 발급 받은 새 토큰을 cookie와 db에 업데이트한다.
❷ JwtTokenProvider
if (existingToken != null) {
try {
// 리프레시 토큰이 유효한지 확인
Jwts.parserBuilder()
.setSigningKey(SECRET_KEY)
.build()
.parseClaimsJws(existingToken.getRefreshToken());
//유효하면 재사용 (리프레시 토큰은 그대로)
refreshToken = existingToken.getRefreshToken();
} catch (ExpiredJwtException e) {
// 만료된 경우 새로 발급
refreshToken = createRefreshToken(user);
existingToken.setRefreshToken(refreshToken);
refreshTokenRepository.save(existingToken);
}
}
else {
refreshToken = createRefreshToken(user);
refreshTokenRepository.save(new RefreshToken(user.getId(), refreshToken));
}- 리프레시 토큰의 만료기한이 남았다면 그대로 반환, 만료기한이 지났다면 새로 발급 받아야한다.
- 리프레시 토큰이 만료된 경우, 재로그인해야 한다는 에러 터트림.
실행결과
(1) 회원가입, 로그인
- 회원가입 시 email, nickname, password 입력
- 로그인할 때 리프레시 토큰을 쿠키에 저장
// 쿠키에 리프레시 토큰 저장
jwtTokenProvider.setRefreshTokenInCookies(response, tokenDTO.getRefreshToken());(2) 해시태그별 글 조회
- post 삭제 시, post와 hashtag의 관계는 끊고 hashtag는 남겨둠
//4. Post 삭제시 hashtag는 그대로 -> 해당 hashtag의 postId를 null로 설정
List<PostHash> postHashtags = post.getPostHashtags();
for (PostHash postHash : postHashtags) {
postHash.setPost(null);
}
(3) 댓글 관련
- 댓글은 POST, ANSWER에 남길 수 있다. 이를 TargetStatus로 구분하였다.
❶ Post에 댓글 남김
❷ Answer에 댓글 남김
- Post 삭제 시 댓글과 답변이 모두 삭제되도록, Answer만 삭제시 댓글은 그대로 남도록 했다.
// answer삭제시 comment는 그대로 둠
List<Comment> comments = commentRepository.findAllByAnswer(answer);
for (Comment comment : comments) {
comment.setAnswer(null);
}

🤔이렇게 하면 나중에 어디에 달렸던 댓글인지 알 수 없지 않나 ..??
-> soft delete로 변경
- Answer 엔티티에 추가
@Where(clause = "is_deleted = false")
// @Where을 두어 isdeleted=false인 것만 조회하도록 함
@Column(name = "is_deleted")
private Boolean isDeleted = false;- Answer을 실제로 삭제하는 대신 is_deleted를 true로 설정하여 관계는 그대로 둔다.
- 애플리케이션을 패키징하는 툴
- 웹 애플리케이션을 실행하는 데 필요한 모든 환경을 패키징해 컨테이너 이미지를 만들고, 이 이미지를 이용해 컨테이너를 생성
(1) Docker file
- Copy files
- install dependencies
- set env
- run script 등
(2) Docker Image
- Application을 실행하는 데 필요한 모든 세팅 포함
- 만들어진 이미지는 불변
(3) Container
- image를 이용해 container 안에서 애플리케이션이 동작
- 격리된 환경에서 실행하며 각 컨테이너는 고유한 파일 시스템을 가짐
docker file 만들기 -> build해서 docker image 만들기 -> container 구동하기
- hello-world 도커 이미지를 다운로드 받은 후 run 실행
- -p 8080:80 --> 브라우저에서 http://localhost:8080으로 접근하면, 컨테이너의 80번 포트로 연결됨
- docker ps : 현재 실행 중인 컨테이너 목록 조회
- docker top <컨테이너 name> : 특정 컨테이너 안에서 실행 중인 프로세스 목록 조회
에러지옥에 빠졌다...
UnsatisfiedDependencyException. Message: Error creating bean w ith name 'jwtAuthFilter' defined in URL
-
JwtAuthFilter가 JwtTokenProvider를 생성자 인자로 받고 있는데, 이 과정에서 의존성이 해결되지 않는다고 한다..
-
의존성 문제라면 로컬에서도 에러가 떠야 하는데 잘 돌아갔다.
-
이것저것 고치다가 발견한..
Caused by: org.springframework.util.PlaceholderResolutionException: Circular placeholder reference 'jwt.secretKey' in value "
${jwt.secretKey}" <-- "${jwt.secretKey}" <-- "${jwt.secretKey}"
원래 구현한 application.yml이다.
jwt:
secretKey: `${jwt.secretKey}`
accessTokenExpirationMinutes: 30
refreshTokenExpirationDays: 30여기서 jwt.secretKey 순환참조 오류가 떴다.
jwt.secretKey:
${jwt.secretKey}를 jwt:secretKey:${JWT_SECRET_KEY}로 바꿔서 해결
jwt.secretKey를 설정할 때 다시 jwt.secretKey를 참조해 무한 루프가 발생하는 거였다..
아, 그리고 bootJar 사용 시 application.yml의 내용을 변경하면 jar 파일도 다시 빌드해야 한다. 여기서도 한참을 헤맸다..
두 번째, JDBC CONNECTION 에러
docker-compose.yml
services:
db:
image: mysql:8.0
ports:
- "3308:3306"docker 컨테이너를 3308 포트로 연결해 뒀다.
application.yml
spring:
datasource:
url: "jdbc:mysql://db:3306/naver?useSSL=false&allowPublicKeyRetrieval=true"- docker 호스트 포트는 3308이지만 내부에서는 MYSQL이 3306 포트에서 실행되기 때문에 jdbc:mysql://db:3306/naver를 이용해야 한다.
- 그리고 localhost:3306이 아니라 docker의 db:3306으로 url을 바꿔야 한다.
내가 설정해둔 docker-compose.yml
app:
image: doapp
container_name: spring-app
env_file:
- .env여기서 .env파일을 읽어 환경변수를 읽어오도록 했다. .env 파일에는
AWS_ACCESS_KEY_ID=~~
AWS_BUCKET=~~
AWS_SECRET_ACCESS_KEY=~~
JWT_SECRET_KEY=~~
DB_PASSWORD=~~application.yml은
spring:
datasource:
url: "jdbc:mysql://db:3306/naver?useSSL=false&allowPublicKeyRetrieval=true"
username: root
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver이렇게 되어있어 이들을 읽어올 거라 생각했는데 읽어오지 못한 듯 하다.
.env 파일에
SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/naver
SPRING_DATASOURCE_USERNAME=root
SPRING_DATASOURCE_PASSWORD=~~추가했더니 드디어 해결됐다.
💥 일단 해결은 됐는데 url과 username은 모두 application.yml에 하드코딩 해두었는데 왜 .env 파일에 추가로 설정해둬야 연결이 되는지 모르겠다..
application.yml을 읽어오지 못하는 것 같은데 누가 이유를 안다면 알려주세요,,, ㅠ
추가로, 에러 해결해보면서 시도해본
services:
db:
healthcheck:
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
retries: 5
app:
depends_on:
db:
condition: service_healthy이 방법으로 해결되진 않았지만, app 서비스가 db가 정상 작동(healthy)일 때만 시작되도록 제어하기 위한 것이다.
DB가 정상작동되기 전에 App이 실행되면 connection error가 뜰 수 있다고 하여 시도해보았다.
- EC2란 AWS에서 제공하는 클라우드 컴퓨팅 서비스로 사용자에게 독립된 컴퓨털르 임대해주는 서비스
- ceosBE라는 인스턴스를 만든 후, 탄력적 IP 주소로 13.209.190.91를 할당 받았다.
- 보안 규칙으로 HTTPS(443), 웹서버(8080), SSH(22)으로 접속을 허용하며 모든 IP에서 접근 가능하다.
sudo docker pull limdodod/dapp 을 실행한 뒤, spring application에서 만든 docker-compose.yml과 .env를 가져오면 된다.
이때, docker-compose.yml에 어떤 이미지를 실행할지 적어줘야 한다.
- 실행 docker compose up -d
- 이후 할당 받은 IP주소에서 swagger이 접속됨을 볼 수 있다.

① 탄력적 IP란?
동적인 클라우드 컴퓨팅 시스템에서 고정된 정적인 IPv4 주소를 가지는 주소
② 왜 쓰는가?
인스턴스의 Public IP는 고정된 ip주소가 아니라 유동적인 ip 주소이기 때문에 EC2 인스턴스를 재실행하게 되면 기존 IP주소가 변경된다.
Elastic IP는 이를 방지하기 위해 사용한다.

































