CEOS 18th Backend Study - Carrot Market
- 회원 고유 번호
userId - 핸드폰번호
phone
→ 핸드폰 번호는 숫자이지만 연산이 없고 검색이 편하도록 varchar/String으로 설정 - 이메일
email
당근마켓은 우선 핸드폰 번호로 회원가입을 한 후 원한다면 이메일도 등록할 수 있다
비밀번호? 당근마켓에서 전송하는 인증번호?
- 닉네임
nickname - 프로필 사진
profileImage - 매너온도
manners - 재거래희망률
- 응답률
- 응답시간
townIdstateName,districtName,townNameex. 서울시 서초구 방배동
이렇게 나눠서 저장하는 게 맞는지
서울시 서초구 방배동으로 설정하고자 할 때
방배라고 검색해도 뜨고 서초라고 검색해도 관련 동네가 뜨는데
초구나 배동 이렇게 검색하면 안 뜬다 → 검색이 어떻게 이루어지는 거지???
처음에 행정구역별로 아예 세 개의 테이블로 나누었다가 그렇게까지 나누어야하나 싶었는데
근데 테이블이나 컬럼별로 굳이 분리해야하나 싶기도 하고 아무튼 고민
그리고 근처 동네는 어떻게 설정하는거지
유저 당 최대 두 개의 동네 정보
userTownIduserIdtownId- 동네 범위
townRange
📌range를 쓰면 mysql 예약어라 에러가 난다!! 나도 알고 싶지 않았다 🥹🥹 - 동네 인증 시간
townAuthTime - 동네 인증 여부
isTownAuth
유저 당 최대 2개의 주소를 설정할 수 있고,
주소마다 범위, 인증 시간, 인증 여부가 따로 관리되어 테이블 분리 - 판매 게시글 고유 번호
postId - 제목
title-> @Notnull - 카테고리
categoryId-> @Notnull - 거래방식
tradeMethod - 가격
price - 가격 제안 여부
isPriceOffer - 자세한 설명
description-> @Notnull - 거래 희망 장소
wishPlace - 판매자 user ->
seller - 보여줄 동네 설정
townRange - 판매 상태
postStatus
판매자는 본인이 올린 게시글에서 판매 상태를 판매 완료로 바꾸면 구매 확정인데
구매자는 어떻게 처리되어야하는지 고민 - 대표사진
thumbnail - 나머지 사진
image1~9 - 브랜드
brand
→ 카테고리에 따라 브랜드를 입력하는 칸이 뜨기도 하고 안 뜨기도 한다 신기
- 카테고리 고유 번호
categoryId - 카테고리 이름
name
- 채팅방 고유 번호
chatRoomId - 판매자/구매자 정보 user ->
seller/buyer
→ 채팅방 이름은 상대방 닉네임 - 판매 게시글 정보
postId - 안 읽은 채팅 수
- 채팅 고유 번호
chatId - 채팅방 번호
chatRoomId - 채팅 내용
content - 상대방이 읽었는지 여부
isRead - 누가 보내고 받았는지 user ->
sender/receiver
→sender컬럼만 있으면 채팅방이랑 연결해서 받은 사람 알 수 있지 않나?
- 거래 후기 고유 번호
reviewId - 작성자/대상자
reviewer/reviewee - 어떤 판매 게시글에 대한 리뷰인지
postId - 구매자가 적은 후기인지 판매자가 적은 후기인지
reviewType - 거래선호도
reviewLevel
이 리뷰로 매너온도가 변하는데 - 생성시간
created와 마지막 수정시간modified컬럼은 거의 모든 테이블이 가지고 있는 컬럼이기 때문에@MappedSuperClass로 엔티티 생성 @MappedSuperclass- 매핑 정보만 받는 부모 클래스, 상속과 관련된 것 아님
- 상속관계 매핑 아니고 엔티티가 아니어서 테이블과 매핑되지 않는다
→ 조회, 검색 당연히 불가(em.find(BaseEntity) 불가) - 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공
- 테이블과 관계 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할
- 복잡한 Object들을 단계별로 구축할 수 있는 생성 디자인 패턴으로
- 복잡한 객체를 생성하는 방법을 정의하는 클래스와 표현하는 방법을 정의하는 클래스를 별도로 분리해,
- 서로 다른 표현이라도 이를 생성할 수 있는 동일한 절차를 제공하는 패턴
- 객체를 만들고 동시에 값을 설정가능한 생성자를 많이 사용하는데, 생성자를 사용하는 경우
- 필수가 아닌 값도 null로 채워주거나,
- ex.주소를 뺀 생성자 함수를 다시 만들어야 하고
- 명확하게 어떤 값을 지정하는 지 알 수 없기 때문에 가독성이 좋지 않다
- 생성자를 가독성 좋게 만들어주는 도구
클래스 내부에서 Builder 클래스를 따로 정의해 사용할 수 있고
값을 설정하고 자기자신을 반환하기 때문에 함수를 연속적으로 체이닝하듯 사용할 수 있다
@Builder
빌더 클래스와 이를 반환하는 builder() 메서드 생성@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder어노테이션을 선언하면 전체 인자를 갖는 생성자를 자동으로 만드는데, 이를 private 생성자로 설정- 클래스 전체에 Builder를 적용할 수도 있고 특정 생성자에서만 적용할 수도 있다
@Getter @Builder //클래스 전체 필드를 빌더로 사용
public class User {
private Long id;
private String phone;
private String nickname;
}public class User {
...
@Builder //phone, nickname만 빌더 사용
public User(String phone, String nickname) {
this.phone = phone;
this.nickname = nickname;
}
}- JPA 관련된 Component만 로드
ApplicationContext 전체가 아닌 JPA에 필요한 설정들에 대해서만 Bean을 등록한다
→ 컴포넌트 스캔을 하지 않아, @Component 빈들이 등록되지 않는다 - @Transactional 어노테이션 포함 → 테스트 종료 후 롤백도 같이 수행된다
- 디폴트로 h2 드라이버 사용
- yml파일에서 DB를 MySql로 설정해 두었기 때문에 h2 의존성이 없으면 DataSource를 찾을 수 없다는 에러가 발생할 수 있다
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@ImportAutoConfiguration
@PropertyMapping("spring.test.database")
public @interface AutoConfigureTestDatabase {
@PropertyMapping(skip = SkipPropertyMapping.ON_DEFAULT_VALUE)
Replace replace() default Replace.ANY;
EmbeddedDatabaseConnection connection() default EmbeddedDatabaseConnection.NONE;
// ...
}@AutoConfigureTestDatabase은@DataJpaTest에서 설정을 자동으로 해주는 많은 어노테이션 중 하나- 디폴트값
Replace.ANY의replace속성과
디폴트값EmbeddedDatabaseConnection.NONE의connection속성을 설정할 수 있다 EmbeddedDatabaseConnection의 enum 값에는 H2, DERBY, HSQLDB 등이 있는데 MySql은 없다
→ MySql로 설정했다면 찾을 수 없기 때문에 에러 발생!!replace기본값이ANY이기 때문에 Embedded Database 를 찾게 된 것이고
→ Embedded Database를 쓰지 않도록replace값을@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
Replace.NONE으로 설정하면 우리가 사용하는 실제 Database를 사용할 수 있다
AssertJ는 assertion을 제공하는 자바 라이브러리로 테스트 코드와 에러 메세지의 가독성을 높여준다
import static org.assertj.core.api.Assertions.assertThat;
...
assertThat(actual).isEqualTo(expected)모든 테스트 코드는 assertThat() 메소드에서 출발하고, AssertJ에서 제공하는 다양한 메소드를 연쇄 호출 하면서 코드를 작성할 수 있다
assertThat(테스트 타겟).메소드1().메소드2().메소드3(); public Long registerPost(RegisterPostRequestDto requestDto) {
//로그인된 유저의 올바른 정보가 넘어온다고 가정
User seller = userRepository.findById(requestDto.getUser_id()).get();
Post post = requestDto.toEntity(seller);
TradeMethod tradeMethod = TradeMethod.valueOf(requestDto.getTradeMethod());
post.setTradeMethod(tradeMethod);
Category category = categoryRepository.findByName(requestDto.getCategory());
post.setCategory(category);
postRepository.save(post);
return post.getId();
}- RequestBody로 사용자 정보 및 게시글 등록에 필요한 정보 받기
부득이하게 사용자 정보도 RequestBody로 받음 RegisterPostRequestDto-toEntity메소드 : DTO로 받은 정보 Post Entity로 바꿔주기
연관 관계를 위해 userId로 User Entity 찾아서 사용자 정보만 따로 넘겨준다public Post toEntity(User seller) { return Post.builder() .seller(seller) .thumbnail(thumbnail) .title(title) .price(price) .isPriceOffer(isPriceOffer) .description(description) .wishPlace(wishPlace) .townRange(townRange) .build(); }
- TradeMethod 거래하기/나눔하기의 거래방식은 String으로 넘어오는데 Enum값으로 설정되어 있기 때문에 따로 설정해준다
카테고리도 String으로 넘어오기 때문에CategoryRepository에서 엔티티 찾아서 연관 관계 설정해주기 - 그리고 save 해주고 일단 Service에서는 postId 리턴해주었당 Controller에서는 ok 반환
- 정렬조건이 최신순이 아닌 것 같긴 한데 우선 Pageable 적용한 findAll로 갱신순으로 가져오려고 했다
- 근데 생각해보니 근처 동네의 게시물만 가져와야하고
- 또 생각해보니까 사용자가 두 개의 동네를 설정할 수 있는데
사용자의 현재 동네랑
판매자가 어느 동네를 현재로 설정하고 올린 게시물인지도 알아야할 거 같은데
그거는 포스트 엔티티에 컬럼이 있어야할 것 같다 - 타운 엔티티에 위도와 경도를 추가하긴 했는데
예를 들어 근처 동네 범위를 위도±50, 경도±50 으로 설정했을 때
그래서 정말로 그 위치의 동네 이름을 알려면 api가 필요할 것 같다
@Transactional(readOnly = true)
public PostListResponseDto getPostList(Pageable pageable) {
Page<Post> findPosts = postRepository.findByIsDel(false, pageable);
Page<PostDto> postDtos = findPosts.map(post -> new PostDto(post,
chatRoomRepository.getTotalChatRoom(post),
userTownRepository.findByUser(post.getSeller()).get(0).getTown().getTownName()));
//편의상 첫 번째 주소로 가정
return new PostListResponseDto(postDtos.getTotalPages(), postDtos.getNumber(), postDtos.getContent());
}- 현재 사용자의 동네로 설정된 근처 동네의 결과만 가져오는 방법은 적용하지 못했다
그냥 정렬 조건을
Page<Post> findByIsDel(boolean isDel, Pageable pageable);
modifiedAt의 ASC 순서로 Page 객체 생성 + 삭제 여부 확인
무한스크롤로 구현이 되어있는데, 잘 모르겠지만 프론트 측에서 스크롤 이벤트가 일어나거나 하는 상황에
벡으로 다음 페이지 번호로 요청하면, 일정 개수의 게시물 정보가 담긴 다음 페이지 반환
잘 모르겠지만 무한스크롤 형식이든 게시판 형식이든 그것은 프론트가 해야하는 일이 아닐까..? → - 찾아온 게시물들에서 map으로 각 게시물 하나씩의 정보를 담은
PostDto생성- post Entity 자체를 넘겨서 각 정보 뽑고,
@Query("SELECT COALESCE(COUNT(cr.id), 0) FROM ChatRoom cr WHERE cr.post = :post") int getTotalChatRoom(@Param("post") Post post);
- 채팅방 개수는
ChatRoomRepository에 쿼리 생성해서 계산 - 판매자 동네 정보 : post Entity의 seller 정보를 이용해
UserTownRepository에서findByUser로 UserTown 리스트를 뽑은 다음에,
편의상 0번째 인덱스 값의 UserTown Entity → 의 Town으로 넘어가서 동네 이름 값 받아오기..
- 마지막으로
PostListResponseDto에 Page 객체가 제공해주는 메소드를 사용해
전체 페이지 수와, 현재 페이지 수,
그리고 각 게시물 정보의 리스트를 담아서 ResponseBody로 반환
위시리스트 없다
3번 게시글은 isDel=1로 삭제된 게시글이라 나타나지 않는당👏🏻👏🏻
public PostResponseDto getPost(Long postId) {
Optional<Post> findPost = postRepository.findById(postId);
if (findPost.isPresent() && !findPost.get().isDel()) {
//조회수 올려주기!
postRepository.updateView(postId);
Post post = findPost.get();
//편의상 첫 번째 주소로 가정..
String sellerTown = userTownRepository.findByUser(post.getSeller()).get(0).getTown().getTownName();
return new PostDetailResponseDto(postId, post, sellerTown, chatRoomRepository.getTotalChatRoom(post));
}
else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 게시물 요청");
}
}- @PathVariable로 받아온
postId를 이용해postRepository에서 게시물 찾기 - 게시물이 있으면 해당 게시물의 조회수 올려주기 + 삭제되지 않았으면!
@Modifying @Query("UPDATE Post p set p.view = p.view + 1 where p.id = :postId") void updateView(@Param("postId") Long postId);
- 그리고 post Entity 받아오고, 판매자 주소 정보 찾은 거랑
채팅방 리포지토리에서 채팅방 개수 찾아서PostDetailResponseDto생성해서 반환public PostDetailResponseDto(Long postId, Post post, String sellerTown, int totalChatRoom) { this.post_id = postId; this.seller_profileImage = post.getSeller().getProfileImage(); this.seller_nickname = post.getSeller().getNickname(); this.seller_town = sellerTown; this.seller_manners = post.getSeller().getManners(); this.title = post.getTitle(); this.category = post.getCategory().getName(); this.description = post.getDescription(); this.wishplace = post.getWishPlace(); this.view = post.getView(); this.total_ChatRoom = totalChatRoom; }
- 게시물이 없으면
404반환
조회수가 1로 증가했고 채팅방 개수도 0으로 잘 반환됨😊😊
삭제된 게시글은 404 BAD REQUEST
public void deletePost(Long postId) {
postRepository.deletePost(postId);
}- Post Entity에
isDel컬럼 추가
DB에서 물리적으로 삭제하는 것이 아니라isDel컬럼을 이용해 논리적으로 삭제하는 로직으로 구현
Post Entity는 리뷰, 채팅방, 그리고 구현하지 않았지만 위시리스트 등
여러 엔티티와 연결되어 있기 때문에 논리적으로 삭제하는 것이 낫다고 판당@Modifying @Query("UPDATE Post p SET p.isDel = true WHERE p.id = :postId") void deletePost(@Param("postId") Long postId);
- 초기 DB에 값을 잘 넣어놓아야 했다
사용자랑 동네 넣고 UserTown 때문에 둘이 연결해 두어야 했고, 카테고리도 미리 생성해두어야 했음 Category랑Post연관 관계@ManyToOne으로 했다가 왜인지@OneToOne으로 바꿨는데
@ManyToOne이 맞았음- 모든 게시글 조회 API에서 계속
406 not acceptable에러가 떴는데
DTO에@Getter붙여서 해결
JSON과 관련된jackson라이브러리가 없어서 나는 오류라고 한다
생각보다 당근마켓의 DB와 로직은 매우 복잡한 거 같다
실제로 어떻게 구현되어 있는지 정말 궁금하다
- 서로 다른 기기에 데이터를 전달할 때 사용하는 방법 중 하나로,
Base64의 형태를 가진다 Header와Body(또는 Payload), 그리고Signature세 부분으로 나눠진다
- JWT의 metadata들을 나타낸다
- Sign에 사용된 Algorithms, format, 그리고 ContentType 등의 정보
Claim단위로 저장
Claim
- 사용자의 속성이나 권한, 정보의 한 조각 또는 Json의 필드라고 생각하면 된다
Claim에는 JWT 생성자가 원하는 정보들을 자유롭게 담을 수 있는데
> Json 형식을 가지고 있기 때문에 단일 필드도 가능하고,
> Object와 같은 complexible한 필드도 추가할 수 있다 > > ```java Claims claims = Jwts.claims(); //일종의 Map claims.put("userName", userName); ... Jwts.builder() .setClaims(claims) ...- Claim에 userName을 담아두면 따로 사용자 id를 입력받지 않아도 토큰에 들어있는 값을 꺼낼 수 있다
- Header와 Body는 Base64 형태로 인코딩되어 암호화되어 있지 않은데
공격자가 내용을 바꿀 수가 있다 - Signature로 서명을 통해 암호화 과정을 거친다
- 서명 이후 Header와 Body의 내용이 바뀐다면 Signature의 결과값이 바뀌어 받아들여지지 않는다
-
간편하고, 세션이나 쿠키와 달리 추가적인 저장소가 필요하지 않고,
한 번 발급되면 유효기간이 완료될 때까지는 계속 사용이 가능하지만, -
중간에 삭제가 불가능하기 때문에
Access Token이 탈취되면, 토큰이 만료되기 전까지 토큰을 가진 사람은 누구나 권한 인증이 가능해진다는 문제점이 발생할 수 있다
→ 이러한 문제점을 보완하기 위해 Access Token의 만료 기간을 짧게 주고, Refresh Token을 추가적으로 발급해 해결
Refresh Token은Access Token에 비해 훨씬 더 긴 유효 기간으로 발급되며,
Refresh Token의 경우 접근에 대한 권한을 가진 것이 아니라Access Token재발급에만 사용된다는 특징이 있다
Access Token유효 기간 30분 ~ 1시간 정도
Refresh Token유효 기간 1주일 ~ 1달 정도
Refresh Token역시 탈취될 수 있는 문제가 있는데,
최초 로그인 시 로그인 요청 ip를 저장하고,
재발급 요청이 왔을 때, 요청이 온 ip와 저장된 ip를 비교하여
다른 경우 토큰을 재발급하지 않거나 알림을 보내는 등의 추가적인 조치를 취할 수 있다
- Open Authorization
- 인터넷 사용자들이 특정 웹 사이트를 접근하고자 할 때, 접근하려는 웹 사이트에 비밀번호를 제공하지 않고,
서드파티 애플리케이션(구글, 카카오, 페이스북 등)의 연결을 통해 '인증 및 권한'을 부여받을 수 있는 프로토콜 - 외부서비스의 인증 및 권한부여를 관리하는 범용적인 프로토콜
-
Spring Boot OAuth 2 Client
- 외부 OAuth 2.0 서비스에 대한 인증을 처리하기 위한 모듈
- 간단한 설정만으로 OAuth 2.0 프로토콜을 따르는 서비스의 인증을 처리할 수 있다
-
Spring Boot OAuth 2 Server
- OAuth 2.0 서버를 빠르게 구축할 수 있도록 지원하는 모듈
- 간단한 설정만으로 OAuth 2.0 프로토콜을 따르는 서버를 구축할 수 있다
- 사용자가 서드파티 애플리케이션을 선택하면 로그인을 위해 해당 웹 사이트로 리다이렉션 된다
(User → Client)- 로그인에 성공하면, 특정 웹사이트에서 요청한 특정 데이터에 대한 액세스 권한을 부여할지 묻는 메시지가 표시되고,
원하는 옵션을 선택하면 인증 코드 또는 오류 코드와 함께 특정 사이트로 리다이렉션 된다
(Client ↔ Authorization Server)- 타사 리소스의 작업에 따라 로그인 성공 또는 실패 (Client ↔ Resource Server)
- 클라이언트는 권한 부여 서버에서 권한 부여 코드를 요청하고, 이를
Access Token으로 교환 - 사용자의 리소스에 액세스해야 하는 웹 서버 애플리케이션에서 일반적으로 사용된다
- 가장 대중적이고 많이 사용되는 방식
- 클라이언트 애플리케이션이
Access Token을 직접 발급받는 것이 아니라
사용자 에이전트(웹 브라우저 등)를 통해 인가 과정을 거쳐Access Token을 발급받는 방식 - 클라이언트가 권한 부여 코드를 먼저 요청하는 것이 아니라, 직접 액세스 토큰을 요청하는데,
보안 취약점 때문에 권장되지 않는다
- 클라이언트 애플리케이션이 자신의 이름과 비밀번호를 사용하여
Access Token을 직접 발급받는 방법 - 클라이언트 애플리케이션 자체의 인증에 사용됨
- 일반적인 로그인 방법
-
사용자의 정보는 세션 저장소에 저장되고, 쿠키는 그 저장소를 통과할 수 있는 출입증 역할
-
쿠키가 담긴 HTTP 요청이 도중에 노출되더라도 쿠키 자체에는 유의미한 값을 갖고있지 않아서 쿠키에 사용자 정보를 담아 인증을 거치는 것 보다 안전하다
-
각각의 사용자는 고유의 Session ID를 발급 받기 때문에 일일이 회원 정보를 확인할 필요가 없어 서버 자원에 접근하기 용이하다
-
세션 하이재킹 공격
- 쿠키에 사용자 정보를 담아 인증을 거치는 것 보다 안전하지만, 해커가 쿠키를 탈취한 후 그 쿠키를 이용해 HTTP 요청을 보내면 서버는 사용자로 오인해 정보를 전달하게 된다
- HTTPS 프로토콜 사용과 세션에 만료 시간을 넣어 어느 정도 보완할 수 있다
-
서버에서 세션 저장소를 사용하기 때문에 추가적인 저장공간이 필요하다
Http Request- 사용자가 로그인 정보와 함께 인증 요청
AuthenticationFilter가 요청을 가로채고,
> 가로챈 정보를 통해UsernamePasswordAuthenticationToken이라는 인증용 객체 생성해서
AuthenticationManager의 구현체인ProviderManager에게 생성한UsernamePasswordAuthenticationToken객체 전달
AuthenticationManager는 등록된AuthenticationProvider들을 조회하고 인증 요구
AuthenticationProvider는 실제 DB에서 사용자 인증정보를 가져오는UserDetailsService에 사용자 정보를 넘겨준다
UserDetailsService는AuthenticationProvider에게 넘겨받은 사용자 정보를 통해,
> DB에서 찾은 사용자 정보인UserDetails객체를 만든다
AuthenticationProvider들은UserDetails객체를 넘겨받고 사용자 정보 비교인증이 완료되면, 권한 등의 사용자 정보를 담은
Authentication객체를 반환한다다시 최초의
AuthenticationFilter에Authentication객체가 반환되고
Authenticaton객체를SecurityContext에 저장
-
현재 접근하는 주체의 정보와 권한을 담는 인터페이스
-
Authentication객체는SecurityContext에 저장되며,
SecurityContextHolder를 통해SecurityContext에 접근하고,
SecurityContext를 통해Authentication에 접근할 수 있다
-
Authentication을 implements한AbstractAuthenticationToken의 하위 클래스
즉,Authentication의 구현체이고, 그래서AuthenticationManager에서 인증과정을 수행할 수 있다 -
추후 인증이 끝나고
SecurityContextHolder에 등록될Authentication객체 -
User의 ID를
Principal로, Password를Credential로 생성한 인증 개체여기에서 말하는
Principal역할을 하는 User의 ID 또는 Username은 로그인 시 ID와 PW의 ID를 똣한다
로그인 시 email을 ID로 사용한다면 email이, 전화번호를 ID로 사용한다면 전화번호가 곧 Username이 된다 -
UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고,
두 번째는 인증이 완료된 객체를 생성한다
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}- 만들어진
UsernamePasswordAuthenticationToken은AuthenticationManager의 인증 메소드를 호출하는 데 사용된다 - 인증에 대한 부분은
AuthenticationManager를 통해서 처리하게 되는데,
실질적으로는AuthenticationManager에 등록된AuthenticationProvider에 의해 처리된다 - 인증에 성공하면 두 번째 생성자를 이용해 객체를 생성하여
SecurityContext에 저장한다
AuthenticationManager의 구현체AuthenticationProvider에서는 실제 인증에 대한 부분을 처리하는데,
인증 전의Authentication객체를 받아서 인증이 완료된 객체를 반환하는 역할을 한다- Custom한
AuthenticationProvider를 작성하고AuthenticationManager에 등록하면 된다
AuthenticationManager를 implements한 구현체ProviderManager는
AuthenticationProvider를 구성하는 목록을 갖는다
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}- Spring Security의 interface이고, 구현체는 직접 개발해야한다 (customize)
username을 기반으로 검색한UserDetails객체를 반환하는 하나의 메소드loadUserByUsername만을 가지고 있고, 일반적으로 이를 implements한 클래스에UserRepository를 주입받아 DB와 연결하여 처리한다UserDetailsService는 DB에 저장된 회원의 비밀번호와 비교하고,
일치하면UserDetails인터페이스를 구현한 객체를 반환한다
- 인증에 성공하여 생성된
UserDetails객체는Authentication객체를 구현한UsernamePasswordAuthenticationToken을 생성하기 위해 사용된다
- 보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 컨텍스트에 대한 세부 정보가 저장된다
SecurityContextHolder는ThreadLocal에 저장되어,Thread별로SecurityContextHolder인스턴스를 가지고 있기 때문에,
사용자 별로Authentication객체를 가질 수 있다
- 인증된 사용자 정보
Authentication을 보관하는 역할 SecurityContext를통해Authentication을 저장하거나 꺼내올 수 있다
SecurityContextHolder.getContext().setAuthentication(authentication);
SecurityContextHolder.getContext().getAuthentication(authentication);→ UsernamePasswordAuthenticationToken 객체
- 현재 사용자(Principal)가 가지고 있는 권한 의미
ROLE_ADMIN이나ROLE_USER와 같이ROLE_*의 형태로 사용한다GrantedAuthority객체는UserDetailsService에 의해 불러올 수 있고,- 특정 자원에 대한 권한이 있는지 검사해 접근 허용 여부를 결정한다
변경
스프링 부트 3.0 이상부터 스프링 시큐리티 6.0.0 이상의 버전이 적용되며
Deprecated된 코드 변경
//.httpBasic().disable()
.httpBasic(HttpBasicConfigurer::disable)- UI쪽으로 들어오는 설정
- Http basic Auth 기반으로 로그인 인증창이 뜨는데, JWT를 사용할 거라 뜨지 않도록 설정
+formLogin.disable(): formLogin 대신 JWT를 사용하기 때문에 disable로 설정
//.csrf.disable()
//.cors().and()
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())- API를 작성하는데 프론트가 정해져있지 않기 때문에 csrf 설정 우선 꺼놓기
- Cross Site Request Forgery : 사이트 간 위조 요청
- 웹 사이트 취약점 공격 방법 중 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹 사이트에 요청하게 하는 공격
- Spring Security에서는 CSRF에 대한 예방 기능을 제공한다
- 근데 이 좋은 기능을 왜 disable?
- 스프링 시큐리티 문서에서는 일반 사용자가 브라우저에서 처리할 수 있는 모든 요청에 CSRF 보호를 사용할 것을 권장하고,
브라우저를 사용하지 않는 클라이언트만 사용하는 서비스를 만드는 경우 CSRF 보호를 비활성화하는 것이 좋다고 함 - 여기에서 브라우저를 사용하지 않는 클라이언트만 사용하는 서비스 → 대부분의 REST API 서비스라고 이해함
즉 대부분의 가이드는 REST API 서버 기준으로 disable을 적용하고 있다
- 스프링 시큐리티 문서에서는 일반 사용자가 브라우저에서 처리할 수 있는 모든 요청에 CSRF 보호를 사용할 것을 권장하고,
- Cross-Origin Resource Sharing : 서로 다른 Orgin 간의 상호작용 시 브라우저에서 이를 중지하기 위해 제공하는 기본 보호 기능, 프로토콜
- HTTP 요청은 기본적으로 Cross-Site HTTP Requests가 가능 (다른 도메인 사용 가능)
하지만 Cross-Site HTTP Requests는 Same Origin Policy를 적용받기 때문에,
프로토콜, 호스트명, 포트가 같아야만 요청이 가능하다 cors()로 cors에 대한 커스텀 설정 허용addAllowedOrigin(): 허용할 URL 설정addAllowedHeader(): 허용할 Header 설정addAllowedMethod(): 허용할 Http Method 설정
//.authorizeRequests()
//.requestMatchers("/api/**").permitAll()
//.requestMatchers("/api/**/users/join", "/api/**/users/login").permitAll()
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/**").permitAll()
.requestMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll())-
특정한 경로에 특정한 권한을 가진 사용자만 접근할 수 있도록 하는 설정
-
authorizeRequests(): 시큐리티 처리에 HttpServletRequest를 이용한다는 것, 각 경로별 권한 처리 -
requestMatchers(): 특정한 경로 지정- 만약 spring-security 5.8 이상의 버전을 사용하는 경우에는
antMatchers,mvcMatchers,regexMatchers가 더 이상 사용되지 않기 때문에,
requestMatchers를 사용해야 한다고 함
URL 패턴
/*과/**/*: 경로의 바로 하위에 있는 모든 경로 매핑
ex.
AAA/*:AAA/BBB,AAA/CCC해당,AAA/BBB/CCC해당하지 않음/**: 경로의 모든 하위 경로(디렉토리) 매핑
ex.
AAA/**:AAA/BBB,AAA/CCC,AAA/BBB/CCC,AAA/.../.../DDD/...,AAA/BBB/CCC/.../.../...전부 해당 - 만약 spring-security 5.8 이상의 버전을 사용하는 경우에는
-
permitAll(): 모든 사용자가 인증 절차 없이 접근할 수 있음 -
authenticated(): 인증된 사용자만 접근 가능 -
hasRole(): 시스템 상에서 특정 권한을 가진 사람만이 접근할 수 있음 -
anyRequest().authenticated(): 나머지 모든 리소스들은 무조건 인증을 완료해야 접근이 가능하다는 의미
//.sessionManagement()
//.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.sessionManagement((sessionManagement) -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))- 스프링 시큐리티는 기본적으로 session을 사용해 웹을 처리하는데,
JWT를 사용하기 때문에 session을 stateless로 설정, 세션 사용하지 않음
- Spring Seurity 프레임워크에서 제공하는 클래스 중 하나로 비밀번호를 암호화하는 데 사용할 수 있는 메서드를 가진 클래스
- 패스워드를 암호화해주는 메서드,
String반환 - 똑같은 비밀번호를 인코딩하더라도 매번 다른 문자열을 반환한다
- 제출된 인코딩 되지 않은 패스워드(일치 여부를 확인하고자 하는 패스워드)와 인코딩 된 패스워드의 일치 여부 확인
- 첫 번째 파라미터로 일치 여부를 확인하고자 하는 인코딩 되지 않은 패스워드,
두 번째 파라미터로 인코딩된 패스워드 입력 boolean반환
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'- JWT 라이브러리의 핵심 API를 제공하고 JWT의 생성 및 검증을 다룰 수 있다
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'jjwt-impl의존성을 추가하지 않은 채Jwts.builder()를 호출하게 되면 오류가 발생한다
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'jjwt-impl의 구현체 라이브러리로,jjwt-jackson외에도jjwt-gson이 있다jjwt-jackson의존성을 추가하지 않으면compact메서드를 처리하던 도중 오류가 발생한다
→jjwt-impl에서 구현체를 찾아보지만 없기에 오류가 발생
jjwt-api는 패키지 관리에 있어서implemenation과runtimeonly로 구분하여 의존성 추가를 권장하고 있다
경고 없이 언제든 변할 수 있는 패키지는runtimeonly로 관리하고 그렇지 않은 것은implemenation으로 관리해
안정적으로jjwt-api라이브러리를 사용하겠다는 의도
즉,jjwt-impl,jjwt-jackson또는jjwt-gson은 경고없이 언제든 변화할 수 있고
jjwt-api는 하위호환성을 맞춰가며 개발한다는 의미
실제로 코드를 보면서 하위호환성에 대한 언급과@Deprecated를 통해 코드를 유지하려는 노력을 살펴볼 수 있다
- JWT 인스턴스를 생성하는 역할을 하는 팩토리 클래스
public static String createToken(String userName, Key key, long expireTimeMs) {
Claims claims = Jwts.claims(); //일종의 Map
claims.put("userName", userName);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}- Header 설정
.setHeaderParam("key", "value")또는.setHeader(header)와 같은 방식 사용 가능
-
setClaims(): JWT에 포함시킬 Custom Claims 추가 - 주로 인증된 사용자 정보.claim("key", "value")또는.setClaims(claims)와 같은 방식 사용 가능
-
setSubject(): JWT에 대한 제목 -
setIssuedAt(): JWT 발행 일자 - 파라미터 타입은java.util.Date -
setExpiration(): JWT의 만료기한 - 파라미터 타입은java.util.Date -
signWith(): 서명을 위한Key(java.security.Key)객체 설정//.signWith(SignatureAlgorithm.HS256, key) .signWith(key, SignatureAlgorithm.HS256)
- 특정 문자열(String)이나 byte를 인수로 받는 메서드로 사용이 중단되었는데,
많은 사용자가 안전하지 않은 원시적인 암호 문자열을 키 인수로 사용하려고 시도하며 혼란스러워했기 때문이라고 한다
String이 아니라Key값을 생성하고 서명을 진행해야 한다
- 특정 문자열(String)이나 byte를 인수로 받는 메서드로 사용이 중단되었는데,
-
compact(): JWT 생성하고 직렬화
토큰을 생성하기 위한 Key
String keyBase64Encoded = Base64.getEncoder().encodeToString(key.getBytes());
SecretKey key = Keys.hmacShaKeyFor(keyBase64Encoded.getBytes());- 사용하고자 하는
plain secretKey(암호화 되지 않음, 첫 번째 줄의key)를byte배열로 변환해주고, - HMAC-SHA 알고리즘을 통해 암호화해주는
Keys.hmacShaKeyFor를 통해 암호화된Key객체로 만들어주는 코드
secretKey가256bit보다 커야 한다는Exception- 알파벳 한 글자당8bit이므로 32글자 이상이어야 한다는 뜻- 한글은 한 글자 당
16bit인데 16글자이면 생성될까? → 생성된다
Jwts.parserBuilder()메소드로JwtParserBuilder인스턴스 생성- JWS 서명 검증을 위한
SecretKey또는비대칭 공개키지정 > >TOKEN발급 시 사용했던secretKeybuild()메소드를 호출하면 thread-safe한JwtParser가 반환된다parseClaimsJws(jwtString)메소드를 호출하면 오리지널 signed JWT가 반환된다- 검증에 실패하면
Exception발생
Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token); -
parseClaimsJws(token)- 파라미터로 주어진
JWT 토큰파싱 JWT 토큰의 구성 요소 Header, Body(Payload), Signature를 분석하고,
서명을 확인해 JWT의 무결성 검증JWT 토큰생성 시의Claim정보를 추출할 수 있다
- 파라미터로 주어진
-
parseClaimsJwt()parseClaimsJws()가 아니라parseClaimsJwt()를 사용하면 오류 발생- 처음에
TOKEN을 생성할 때signWith()를 통해 서명을 했기 때문에
복호화 시에도 서명에 대한 검증을 진행해야 한다 parseClaimsJwt()는 서명 검증 없이 단순히 헤더와 클레임만 추출한다parseClaimsJwt()를 사용하고 싶다면TOKEN생성 시signWith()를 통해 서명에 대한 정보를 넘겨주지 않으면 된다
Claims claims = jws.getBody();-
getBody()TOKEN의Claim정보 또는 토큰에 포함된 데이터,
즉,TOKEN생성 시 포함한 사용자 정보, 권한, 만료 시간 등을 추출할 수 있다
-
이 외에도
getHeader()와getSignature()를 통해 각각TOKEN의 메타데이터와 서명을 추출할 수 있다
String username = claims.get("username", String.class); // "username" 클레임 값 추출
String role = claims.get("role", String.class); // "role" 클레임 값 추출
Date expiration = claims.getExpiration();
Date issuedAt = claims.getIssuedAt();-
get()- 키와 값의 쌍으로 저장된
Claim은 키를 통해 값을 찾을 수 있다
public abstract <T> T get(String claimName, Class<T> requiredType)
Claim키와 타입에 맞는 값 반환
- 키와 값의 쌍으로 저장된
-
이 외에도
TOKEN만료 시간을 추출하는getExpiration()이나
TOKEN생성 시간을 추출하는getIssuedAt()등의 메소드가 있다
- 중복 체크
UserDuplicatedException()- 회원가입
BCryptPasswordEncoder.encode()- 비밀번호 암호화해서 저장
public ResponseEntity<Void> signUp(SignUpDto signUpDto) {
//중복체크
userRepository.findByPhone(signUpDto.getPhone())
.ifPresent(user -> {
throw new UserDuplicatedException();
});
//회원가입
userRepository.save(User.builder()
.phone(signUpDto.getPhone())
.nickname(signUpDto.getNickname())
.role(Role.USER)
.password(passwordEncoder.encode(signUpDto.getPassword()))
.build()
);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
- 로그인용 ID 확인
UserNotFoundException- 비밀번호 확인
InvalidPasswordException()TOKEN발행
public SignInResponseDto signIn(SignInDto signInDto) {
//전화번호 확인
User user = userRepository.findByPhone(signInDto.getPhone())
.orElseThrow(UserNotFoundException::new);
//비밀번호 확인
if (!passwordEncoder.matches(signInDto.getPassword(), user.getPassword())) {
throw new InvalidPasswordException();
}
//TOKEN 발행
String accessToken = jwtTokenProvider.createAccessToken(user.getId(), signInDto.getPhone(), user.getRole().toString());
return SignInResponseDto.builder().accessToken(accessToken).build();
}
- 모든
POST접근 막기
- JwtAuthenticationFilter 인증 계층 추가하기
- 모든 요청에 권한 부여하기
TOKEN여부 확인
- TOKEN 있으면 권한 부여
- TOKEN이 없으면 권한 부여하지 않기
TOKEN유효성 검증
- TOKEN의 유효시간이 지났는지 확인하기
TOKEN에서 userName(id) 꺼내서 Controller에서 사용하기
-
증명하다라는 의미로, 예를 들어 아이디와 비밀번호를 이용하여 로그인 하는 과정
-
해당 사용자가 본인이 맞는지 확인하는 과정
-
권한부여나 허가와 같은 의미로 사용되고, 어떤 대상이 특정 목적을 실현하도록 허용(Access) 하는 것 의미
-
해당 사용자가 요청하는 자원을 실행할 수 있는 권한이 있는가를 확인하는 과정
앞서 로그인에서 설정했던 SecurityConfig의 SecurityFilterChain 재정의 이용
→ @EnableWebSecurity
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/*/*/signup", "/api/*/*/signin").permitAll()
.requestMatchers(HttpMethod.GET).permitAll()
.requestMatchers(HttpMethod.POST, "/api/**").authenticated())- 회원가입과 로그인은 누구나 권한 없이 언제나 접근할 수 있지만
- 리뷰 쓰기 등 다른 모든 요청에 대해서는 권한 필요
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)addFilterBefore()- JWT 인증 필터
JwtAuthenticationFilter를UsernamePasswordAuthenticationFilter이전에 추가하는 역할 - 토큰이 있는지 매번 항상 확인해야 한다
public HttpSecurity addFilterBefore( @NotNull jakarta.servlet.Filter filter, Class<? extends jakarta.servlet.Filter> beforeFilter)
- JWT 인증 필터
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { ... }Filter인터페이스를 구현하는 클래스에서 오버라이드할 메소드 중 하나- HTTP 요청을 필터링하고, 필터가 적용된 요청을 처리하는 역할
-
- Header에서 TOKEN 꺼내기
- TOKEN 여부와 유효성 확인
- TOKEN이 유효하면 - 권한 부여
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);- 현재 사용자의 인증 정보를
authentication으로 변경 SecurityContextHolder.getContext()- 현재 사용자 및 인증 정보를 관리하는
SecurityContextHolder객체에서 - 현재 사용자와 관련된 정보가 저장되는 보안 컨텍스트 가져오기
- 현재 사용자 및 인증 정보를 관리하는
.setAuthentication(authentication)- 현재 사용자의 인증 정보
authentication으로 설정
- 현재 사용자의 인증 정보
filterChain.doFilter(request, response);doFilter()public abstract void doFilter( jakarta.servlet.ServletRequest request, jakarta.servlet.ServletResponse response)
Filter인터페이스를 구현한 필터에서 정의된 메소드- 필터가 요청(request) 및 응답(response)을 처리하는 메소드
- 필터는 이 메소드를 통해 요청과 응답을 가로채고 수정할 수 있다
ex. 요청을 가로채 권한 확인하기 - 현재 필터에서 요청 및 응답을 처리하고,
이후에 실행될 다음 필터를 호출하기 위해FilterChain의doFilter()를 호출하는데,
이 때, 다음 필터로 요청 및 응답 계속 전달
- TOKEN 있으면 권한 부여
- TOKEN이 없으면 권한 부여하지 않기
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);- 토큰이 없으면 작동하지 않음!
![]() |
근데 아무 TOKEN을 넣어도 작동하는 문제! |
|---|
- TOKEN의 유효시간이 지났는지 확인하기
public boolean validateToken(String token) {
//Token 만료 시간 또는 null 반환
Date expiration = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration();
boolean isExpired = expiration.before(new Date());
return !isExpired;
}TOKEN만료로 인한ExpiredJwtException발생
public String getUserId(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}-
TOKEN에서userName(ID)의Claim추출하는 메소드JwtUtil.getUsername()생성 -
그리고 추출한
로그인ID를UsernamePasswordAuthenticationToken에 넣어주면Controller에서로그인ID를 사용할 수 있다
import org.springframework.security.core.Authentication;
...
@PostMapping
public ResponseEntity<String> writeReview(Authentication authentication) {
return ResponseEntity.ok().body(authentication.getName());
}또는
@PostMapping
public ResponseEntity<Void> registerPost(@RequestBody RegisterPostRequestDto requestDto, @AuthenticationPrincipal User user) {
postService.registerPost(requestDto, user);
return ResponseEntity.ok().build();
}Docker client: 도커 설치했을 때 그게 바로 client이고, build, pull, run 등의 도커 명령어 수행DOCKER_HOST: 도커가 띄어져있는 서버 의미,DOCKER_HOST에서 컨테이너와 이미지 관리Docker daemon: 도커 엔진Registry: 외부(remote) 이미지 저장소로 다른 사람들이 공유한 이미지를 내부(local) 도커 호스트에 pull할 수 있다- 이렇게 가져온 이미지를 run하면 컨테이너가 됨
- public 저장소 : Docker Hub, QUAY
- private 저장소 : AWS 또는 Docker Registry 직접 띄워서 비공개로 사용
- 도커 엔진에서 사용하는 기본단위, 도커 엔진의 핵심
- 도커 이미지와 컨테이너는
1:N관계 - 도커 이미지와 컨테이너의 관계는 운영체제에서의 프로그램-프로세스, 객체지향 프로그래밍에서의 클래스-인스턴스 관계
-
Docker File → Docker Imagedocker build명령어로 Docker File을 통해 Docker Image 생성
-
Docker Image → Docker Container- Docker Image를
docker run으로 실행시켜 Docker Container 생성
- Docker Image를
-
Docker Image
- 컨테이너를 생성할 때 필요한 요소
[저장소 이름]/[이미지 이름]:[태그]
저장소 이름: 이미지가 저장된 장소, 저장소 이름이 명시되지 않은 이미지는 도커 허브의 공식 이미지를 똣한다이미지 이름: 해당 이미지가 어떤 역할을 하는지 나타내고 필수로 설정해야 한다- ex.
ubuntu:latest: 우분투 컨테이너를 생성하기 위한 이미지
- ex.
태그: 이미지의 버전을 나타내고, 생략 시 도커 엔진은latest로 인식
-
Docker Container
- 도커 이미지로 생성할 수 있다
- 컨테이너를 생성하면 해당 이미지의 목적에 맞는 파일이 들어 있는, 호스트와 다른 컨테이너로부터 격리된 시스템 자원 및 네트워크를 사용할 수 있는 독립된 공간(프로세스)이 생성된다
- 대부분의 도커 컨테이너는 생성될 때 사용된 도커 이미지의 종류에 따라 알맞은 설정과 파일을 가지고 있기 때문에 도커 이미지의 목적에 맞도록 사용되는 것이 일반적
- 컨테이너는 이미지를 읽기 전용으로 사용하고, 이미지에서 변경된 사항만 컨테이너 계층에 저장하므로 컨테이너에서 무엇을 하든지 원래 이미지는 영향을 받지 않는다
- 생성된 각 컨테이너는 각기 독립된 파일시스템을 제공받고 호스트와 분리되어 있어, 특정 컨테이너에서 어떤 어플리케이션을 설치하거나 삭제해도 다른 컨테이너와 호스트는 변화가 없다
- ex. 같은 도커 이미지로 A, B 두 개의 컨테이너를 생성한 뒤에 A 컨테이너를 수정해도 B 컨테이너에는 영향을 주지 않는다
-
도커는 기본적으로 독립적인 환경에서 실행되기 때문에 컨테이너 밖에서 접근할 수 없다
-
컨테이너와 통신하기 위해서는 컨테이너를 가동시키면서
-p옵션을 사용해 호스트의 포트와 컨테이너의 포트를 설정해야 한다
-p ${host_port}:${container_port}- 이 설정을 사용하기 위해서는 호스트(서버 또는 PC)에서 사용 중인 포트와 번호가 겹치지 않는지 확인이 필요하다
docker run --name test1 -d httpd
docker run --name test1 -d -p 8080:80 httpd--name test1: test1이라는 이름으로 컨테이너 생성-d: 백그라운드로 동작-p 8080:80: 호스트의 포트는 8080, 컨테이너의 포트는 80으로 세팅해 네트워크 설정
docker ps -a
docker container ls -a- 동일한 두 개의 명령어
-a옵션 : 없으면 실행 중인 컨테이너만 보여줌- 붙여주면 다양한 상태의 컨테이너 확인 가능
- 위의 명령어를 입력해 컨테이너의 상태를 확인할 수 있다
docker stop test1
docker rm test1- 컨테이너 실행 중지 및 삭제 명령어
- 도커 이미지를 생성하기 위한 스크립트 파일
- 여러 키워드를 사용해 dockerfile을 작성해 빌드를 보다 쉽게 수행할 수 있다
FROM: base가 되는 image 지정, 주로 OS 이미지나 런타임 이미지를 지정RUN: 이미지를 빌드할 때 사용하는 커맨드를 설정할 때 사용ADD: 이미지에 호스트의 파일이나 폴더를 추가하기 위해 사용- 만약 이미지에 복사하려는 디렉토리가 존재하지 않으면 docker가 자동으로 생성
COPY: 호스트 환경의 파일이나 폴더를 이미지 안으로 복사하기 위해 사용ADD와 동일하게 동작하지만 가장 확실한 차이점은 URL을 지정하거나 압축파일을 자동으로 풀지 않음
EXPOSE: 이미지가 통신에 사용할 포트를 지정할 때 사용ENV: 환경 변수 지정 시 사용$name,${name}의 형태로 사용 가능${name:-else}: name이 정의되어 있지 않다면 else가 사용됨
CMD: 도커 컨테이너가 실행될 때 실행할 커맨드 지정RUN과 비슷하지만 도커 이미지를 빌드할 때 실행되는 것이 아니라 컨테이너를 시작할 때 실행된다는 것이 다르다
ENTRYPOINT: 도커 이미지가 실행될 때 사용되는 기본 커맨드 지정 (강제)WORKDIR: RUN, CMD, ENTRYPOINT 등을 사용한 커맨드를 실행하는 디렉토리 지정-W옵션으로 오버라이딩 가능
VOLUME: 퍼시스턴스 데이터를 저장할 경로를 지정할 때 사용- 호스트의 디렉토리를 도커 컨테이너에 연결
- 주로 휘발성으로 사용되면 안되는 데이터를 저장할 때 사용
docker build ${option} ${dockerfile directory}
docker build -t test1 . - dockerfile을 실행하기 위한 docker build 커맨드
- 이미지의 이름 test
- .으로 도커 파일의 위치
docker run --name test_app -p 80:80 test1- 생성된 이미지를 컨테이너로 사용하기 위함
FROM openjdk:17-jdk-slim
#이 Docker 이미지는 OpenJDK 17를 기반으로 함, Java 17을 설치하고 실행할 수 있는 환경 제공
ARG JAR_FILE=/build/libs/*.jar
#Docker 빌드 시에 전달되는 인자(Argument)로, 어플리케이션 JAR 파일의 경로를 지정
COPY ${JAR_FILE} app.jar
# 앞서 정의한 JAR_FILE 변수를 이용해 빌드된 JAR 파일을 Docker 이미지 내부로 복사
# 이때, app.jar로 파일을 복사하게 된다
ENTRYPOINT ["java","-jar", "/app.jar"]
#컨테이너가 시작될 때 실행되는 명령어 설정
#이 경우, Java로 JAR 파일을 실행하는 명령어 지정- jdbc 의존성 추가 → 아님
implementation 'org.springframework.boot:spring-boot-starter-jdbc'application.yml에서host.docker.internal:3306으로 연결
- 도커 애플리케이션의 서비스, 네트워크, 볼륨 등의 설정을 yml 형식으로 저장하는 파일
| 설명 | 공식 문서의 예제 파일 |
|---|---|
| 큰 틀에서의 구성 요소는 service, volumn, config, secret, network, 그리고 version이 있는데, 이 중 version은 derprecated되어 더 이상 설정하지 않아도 된다 |
- 여러 컨테이너를 정의하는 데 사용된다
services:
frontend:
image: awesome/webapp
backend:
image: awesome/database- 'frontend'와 'backend'는 각
container를 정의하고, 각container의 이름이 된다 awesome/database라는 도커image를 가지고container를 가동하게 되면container의 이름이 'backend'가 된다는 의미
image: 컨테이너의 이미지 정의build: 이미지를 활용하는 방식이 아닌 dockerfile의 경로를 지정해 빌드하여 사용하는 방법- 이미지를 어디서 가져오는 게 아니라,
이build를 통해 dockerfile의 경로를 설정해 직접 빌드해서 컨테이너를 띄울 때 사용되는 방법
- 이미지를 어디서 가져오는 게 아니라,
dockerfile: 빌드할 dockerfile의 이름이Dockerfile이 아닌 경우 이름을 지정하기 위해 사용ports: 호스트와 컨테이너의 포트 바인딩 설정에 사용됨volumes: 호스트의 지정된 경로로 컨테이너의 볼륨을 마운트 하도록 설정container_name: 컨테이너 이름 설정command: 컨테이너가 실행된 후 컨테이너 쉘에서 실행시킬 쉘 명령어environment: 환경 변수 설정env_file:environment와 동일한 기능을 수행하지만, 이 키워드를 사용하면env파일을 이용해 적용할 수 있다depends_on: 다른 컨테이너와 의존관계 설정restart: 컨테이너의 재시작과 관련한 설정- 어떤 오류로 인해 이미지가 실행이 안 됐을 때 멈출 건지 다시 실행할 건지
docker-compose up- 해당 명령어를 실행하는 경로에서
docker-compose.yml파일을 찾아서 실행
docker-compose -f docker-compose-custom.yml up-f옵션 :docker-compose는 기본적으로docker-compose.yml의 이름을 사용하는데,
만약 다른 이름으로 파일을 관리하고 사용하는 경우 해당 옵션을 이용할 수 있다
docker-compose up -d-d옵션 : 백그라운드에서docker-compose를 실행하기 위해 사용-d옵션 없이 up 하면, 테스트 끝날 때까지 해당 터미널은 더 이상 사용할 수 없기 때문에 사용하는 옵션
- Redis 같은 데이터베이스 등의 외부 환경이 필요한 경우, 즉, 인프라 구축 시
로컬에 설치하기 싫을 때 도커 이미지를 이용해 컨테이너로 쓰고 내리는 식으로 사용 가능
version: "3"
services:
db:
container_name: dangn_db # 컨테이너 이름 설정
image: mysql:8.0 # MySQL 8.0 버전 이미지 사용
environment: # MySQL에 전달하는 환경 변수
MYSQL_ROOT_PASSWORD: mysql # 루트 사용자 비밀번호와
MYSQL_DATABASE: ceos_dangn # 데이터베이스 이름
volumes: # 호스트 시스템과 컨테이너 간에 데이터를 공유하기 위한 볼륨 설정
- dbdata:/var/lib/mysql # MySQL 데이터 디렉토리를 호스트 시스템의 dbdata 볼륨과 연결
ports: # 호스트 시스템과 컨테이너 간의 포트 매핑을 설정
- 3307:3306 # 호스트의 3307 포트를 컨테이너 내의 3306 포트로 매핑
restart: always # 컨테이너가 종료될 때 항상 다시 시작하도록 설정
web:
container_name: dangn_web # 컨테이너 이름 설정
build: . # 현재 디렉토리에서 Dockerfile을 사용해 이미지 빌드
ports: # 호스트 시스템과 컨테이너 간의 포트 매핑 설정
- "8080:8080" # 웹 어플리케이션의 8080 포트를 호스트의 8080 포트와 연결
depends_on: # 의존하는 서비스 설정
- db # web 서비스가 시작되기 전에 db 서비스가 먼저 시작되도록 설정
environment: # 어플리케이션에서 사용할 환경 변수를 설정
mysql_host: db # MySQL 호스트를 db로 설정
restart: always # 컨테이너가 종료될 때 항상 다시 시작하도록 설정
volumes: # 호스트 시스템과 컨테이너 간에 데이터를 공유하기 위한 볼륨 설정
- .:/app # 현재 디렉토리를 호스트의 /app 디렉토리와 연결
volumes:
app: # 호스트 시스템과 web 컨테이너 간에 데이터를 공유하기 위한 볼륨
dbdata: # 호스트 시스템과 db 컨테이너 간에 MySQL 데이터를 공유하기 위한 볼륨| Containers | Images | Volumes |
|---|---|---|
GET:/api/v1/users/profile-getUserInfo()
- 에러 처리와 허용 url 수정
POST:/api/v1/review/create-createReview()
- VPC는 기본 default 이용함
-
SSH,HTTP,HTTPS,MYSQL에 대해 IPv4와 IPv6 모두 설정해줌 -
설정 끝 보안 그룹 생성 클릭
- 다음과 같이 새 키 페어 생성해줌
- 생성해준 키 페어는
C:\Users\yoonsseo\.ssh\ceos_dangn.pem경로에 저장해 줌
- 앞에서 만들어놨던 보안 그룹 연결
- 스토리지 크기는 30GB (프리티어 가능 최대 용량)로 설정해줌
- EC2 생성 확인
- 마스터 사용자 이름과 암호는 나중에 DB 연결 시 사용
- 위 템플릿에서 프리티어 선택했기 때문에 가능한 옵션 아무거나 선택
- 스토리지 용량은 20GB, 스토리지 자동 조정을 비활성화 (의도치 않은 과금 방지)
- 따로 설정하지 않음
-
Workflow
- 자동화된 전체 프로세스로, 하나 이상의 Job으로 구성되고, Event에 의해 예약되거나 트리거될 수 있는 자동화된 절차를 말한다
- Workflow 파일은 YAML으로 작성되고, Github Repository의 .github/workflows 폴더 아래에 저장된다
- Github에게 YAML 파일로 정의한 자동화 동작을 전달하면, Github Actions는 해당 파일을 기반으로 그대로 실행시킨다
-
Event
- Workflow를 트리거(실행)하는 특정 활동이나 규칙
- 예를 들어, 누군가가 커밋을 리포지토리에 푸시하거나 풀 요청이 생성 될 때 GitHub에서 활동이 시작될 수 있다
-
Job
- Job은 여러 Step으로 구성되고, 단일 가상 환경에서 실행된다
- 다른 Job에 의존 관계를 가질 수도 있고, 독립적으로 병렬로 실행될 수도 있다
-
Step
- Job 안에서 순차적으로 실행되는 프로세스 단위
- Step에서 명령을 내리거나, Action을 실행할 수 있다.
-
Action
- Job을 구성하기 위한 Step들의 조합으로 구성된 독립적인 명령
- Workflow의 가장 작은 빌드 단위
- Workflow에서 Action을 사용하기 위해서는 Action이 Step을 포함해야 한다
- Action을 구성하기 위해서 레포지토리와 상호작용하는 커스텀 코드를 만들 수도 있다
- 사용자가 직접 커스터마이징하거나, 마켓플레이스에 있는 Action을 가져다 사용할 수도 있다
-
Runner
- Gitbub Action Runner 어플리케이션이 설치된 머신으로, Workflow가 실행될 인스턴스
- 깃헙 레포지토리의 액션 탭에 노출되는 Workflow의 이름으로 옵셔널한 값
name: Deploy Development Server- 어떤 조건에 Workflow를 자동으로 Trigger 시킬지 Event 명시
- push(Branch or Tag), pull_request, schedule을 사용할 수 있다
push이벤트를 명시하면, 누군가가 깃 레포지토리에 변경사항을 push 하는 시점마다 job이 실행된다
- 단일 Event를 사용할 수도 있고, array로 작성할 수도 있다
on: push
# 또는
on: [pull_request, issues]## develop 브랜치에 push가 되면 실행됩니다
on:
push:
branches: [ "develop" ]- 특정한 브랜치나, tag, 또는 path에서만 실행되도록 할 수도 있고,
아래 예시와 같이paths로 특정 패턴을 설정하여 해당 패턴에 일치하는 파일이 변경되었을 때 Workflow가 실행되도록 하고,
!paths나paths-ignore를 사용하여 무시할 패턴을 설정할 수도 있다
on:
push:
branches: [ master, dev ]
pull_request:
branches: [ master ]
paths:
- "**.js"
paths-ignore:
- "doc/**"- 워크 플로우가 깃 레포에 대한 권한을 읽기만 가능하게 설정한다
permissions:
contents: readjobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3
## 여러분이 사용하는 버전을 사용하세요
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
## gradle build
- name: Build with Gradle
run: ./gradlew bootJarbuild라는job을 생성하고, 그 아래에 3개의step이 존재하는 구조runs-on: 어느 운영체제에서job을 실행할 지 지정uses: 어떤 액션을 사용할 지 지정- 이미 만들어진 action(제 3자가 만든 action)을 사용할 때 지정
actions/checkout@v3: 우리의 branch를 현재 비어있는 ubuntu에 내려받도록 함actions/setup-java@v3: java 다운받기
run: bash에서 실행할 명령어를 정의chmod +x gradlew: gradlew 실행할 권한 부여./gradlew build: 해당 java 코드 빌드
## 웹 이미지 빌드 및 도커허브에 push
- name: web docker build and push
run: |
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
docker build -t my-repo/my-web-image .
docker push my-repo/my-web-image
docker build -f dockerfile-nginx -t my-repo/my-nginx-image .
docker push my-repo/my-nginx-image
- name: executing remote ssh commands using password
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.HOST }}
username: ubuntu
key: ${{ secrets.KEY }}
script: |
## 여러분이 원하는 경로로 이동합니다.
cd /home/ubuntu/
## .env 파일을 생성합니다.
sudo touch .env
echo "${{ secrets.ENV_VARS }}" | sudo tee .env > /dev/null
## docker-compose.yaml 파일을 생성합니다.
sudo touch docker-compose.yaml
echo "${{ vars.DOCKER_COMPOSE }}" | sudo tee docker-compose.yaml > /dev/null
## docker-compose를 실행합니다.
sudo chmod 666 /var/run/docker.sock
sudo docker rm -f $(docker ps -qa)
sudo docker pull my-repo/my-web-image
sudo docker pull my-repo/my-nginx-image
docker-compose -f docker-compose.yaml --env-file ./.env up -d
docker image prune -f- 도커 관련 스크립트
-
DOCKER_USERNAME: 도커 계정 유저네임 -
DOCKER_PASSWORD: 도커 계정 비밀번호 -
KEY: EC2를 생성하며 같이 생성했던 .pem 파일의 내용
- 이 때,
-----BEGIN부터END ... KEY-----까지 입력해주어야 한다-----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAidvIJTS/UYMxf3G5fWC3tPkHiD35xttdsez++y2EO5vWKtpE wHcNCeHzwKiadand2VLDNnKi8/r+e3oPRrDCKQI8he5siDs6qyZuHOm2qd+jiQ+S ZeD ... 7Kzfn3eqHh+sMt4t9iX8 gdO2R6Z0TI3dfFpNKJU2WehZ7TZEA3qDJNqTg7008IJaUcuAEeWULtDwiwx/hkZ7 9kt5/TEA8jEoJw4gPakNlfEPEsQ2Sv7zpPPquZEGTqIjWXVMvPE0 -----END RSA PRIVATE KEY-----
ENV_VARS: 환경 변수를 key-value로 담아둔다
=을 기준으로 좌측이 key, 우측이 value
DB_URL=jdbc:mysql://ceos-dangn-rds.cp0xntend9ra.ap-northeast-2.rds.amazonaws.com:3306/ceos-dangn-rds
DB_USERNAME=root
DB_PASSWORD=blahblah- 저장해둔 환경변수 사용하기 : application.yaml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 10DOCKER_COMPOSE: docker-compose.yaml 를 생성할 때 참고하는 변수- 위의 secrets과는 다르게 변수로 등록
- docker-compose 파일 작성 후 레포지토리 변수로 등록
FROM openjdk:17
ARG JAR_FILE=/build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar", "/app.jar"]FROM nginx
# 기본 Nginx 이미지 사용
RUN rm -rf /etc/nginx/conf.d/default.conf \
# 기본 Nginx 설정 파일을 삭제
COPY ./nginx/conf.d/nginx.conf /etc/nginx/conf.d
# 호스트 머신의 ./nginx/conf.d/nginx.conf 파일을 컨테이너 내부의 /etc/nginx/conf.d 경로에 복사
CMD ["nginx", "-g", "daemon off;"]
# 컨테이너가 시작될 때 실행될 명령 정의- Nginx를 기반으로 하는 Docker 이미지 정의하는 스크립트
deamon off: Nginx는 기본적으로 백그라운드에서 실행되도록 설계되어있는데,
Nginx를 백그라운드에서 동작하지 않고 프로세스를 foreground에서 실행하도록 지정
version: '3'
services:
web:
container_name: dangn_web
image: my-repo/my-web-image
env_file:
- .env
expose:
- 8080
ports:
- 8080:8080
tty: true
environment:
- TZ=Asia/Seoul
nginx:
container_name: dangn_nginx
image: my-repo/my-nignx-image
ports:
- 80:80
depends_on:
- webserver {
listen 80;
# 이 서버 블록은 80번 포트에서 들어오는 요청을 처리
server_name *.compute.amazonaws.com;
# 이 서버 블록은 *.compute.amazonaws.com 도메인에 대한 요청을 처리
access_log /var/log/nginx/access.log;
# 각각 접근 로그와 오류 로그를 기록할 파일 경로를 설정
error_log /var/log/nginx/error.log;
# 이 블록은 모든 경로에 대한 요청을 처리
#
location / {
proxy_pass http://web:8080;
# proxy_pass 지시문을 사용하여 이 서버가 받은 요청을 http://web:8080 주소로 전달
# 여기서 web은 Docker 네트워크 상에서 해당 서비스에 할당된 이름
# 서비스가 8080 포트에서 실행 중이라고 가정
proxy_set_header Host $host:$server_port;
# proxy_set_header : 프록시 서버로 전달될 때 추가적인 HTTP 헤더 설정
# 프록시 서버로 전달되는 요청의 Host 헤더 설정
# 프록시 서버는 클라이언트 요청을 백엔드 서버로 전달할 때 원래 호스트 정보를 유지할 수 있다
proxy_set_header X-Forwarded-Host $server_name;
# 프록시 서버가 클라이언트로부터 받은 원래 호스트 주소를 전달하는 데 사용된다
proxy_set_header X-Real-IP $remote_addr;
# 클라이언트의 실제 IP 주소를 포함하며, 프록시 서버가 이 정보를 백엔드 서버로 전달할 수 있도록 함
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 클라이언트에서 프록시까지의 이전 요청의 IP 주소를 포함
# 이를 통해 백엔드 서버는 클라이언트의 원래 IP 주소를 알 수 있다
}
}- reverse proxy 역할을 하는 구성
- 깃허브 액션에서는 빌드 성공으로 초록불이 뜨는데
docker ps하면 아무것도 안 뜬다
docker run -d -p 8080:8080 --name my_ceos_container yoonsseo/ceos18dangn-d옵션이랑-p옵션을 이용해 백그라운드로 실행 하고 8080으로 매핑
4. 이제
docker ps 하면 컨테이너 목록 확인할 수 있다
gradle.yml워크플로우에 위에서 수동으로 입력해주었던 다음 명령어 추가
docker run -d -p 8080:8080 --name ceos_container yoonsseo/ceos18dangn- name: Deploy to EC2
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.EC2_PUBLIC_DNS }}
username: ubuntu
key: ${{ secrets.PEM_KEY }}
script: |
cd /home/ubuntu/
sudo touch docker-compose.yml
echo "${{ vars.DOCKER_COMPOSE }}" | sudo tee docker-compose.yml > /dev/null
sudo chmod 666 /var/run/docker.sock
sudo docker rm -f $(sudo docker ps -qa)
sudo docker pull ${{ secrets.DOCKER_USERNAME }}/ceos18dangn
docker-compose -f docker-compose.yml up -d
docker run -d -p 8080:8080 --name ceos_container yoonsseo/ceos18dangn
docker image prune -f- 결과
- 근데 왜 추가하지 않으면 안 되는 건지는 알 수 없었다..🥹🤯😱🫠🥲😢🥺🫣













