🌿 스프링/JPA

@JoinColumn의 이해

le2donguk 2026. 2. 24. 00:05

@JoinColumn 이 JPA를 공부하면서 가장 많이 쓰이는데 초반에 공부를 할 때 대충 외우는 식으로 넘어갔다..

나중에 가니까 내가 생각한 방식으로 동작하지 않는 코드를 보면서 JoinColumn에 대해서 찾아봤고 학습한 내용을 정리해 보려고 합니다..!

 

 

DB의 Join

보통 DB에서 Join을 하려면 외래키를 활용해 JOIN을 한다. 예시를 통해 알아보자 

여러 명의 Member가 하나의 Team에 가입할 수 있다고 해보면 다음과 같이 Table을 설계하는 게 자연스러운 일이다 

 

member

member_id(pk) member_name team_id(fk)
1 MemberA 1
2 MemberB 1
3 MemberC 1

 

 

team

team_id(pk) team_name
1 팀A

 

이렇듯 DB에서는 1:N 관계에서 무조건 N 쪽에 FK 관련 칼럼을 만들어서 FK 를 관리하고, 해당 컬럼을 통해 JOIN 쿼리를 쓸 수 있다 


@JoinColumn에 대한 오해 

JPA를 공부하다 보면 가장 흔하게 볼 수 있고 , 또 초반에 배우는 내용이 바로 이 @JoinColumn이다. 그런데 조심해야 할 게 있다 

이름에서 Join이 들어가서 그런지 왠지  Join 하는 칼럼과 매우 밀접한 관계가 있을 것 같다고 생각이 들 수 있다 (나도 그랬다)

1. 이 어노테이션의 위치에 따라서 Join하는 컬럼 = FK 칼럼의 위치가 달라지고 (그래서 항상 N 쪽 엔티티 데 적어야 할 것 같고)

2. 내가 그 칼럼을 지정할 수 있을 것 같다 

 

하지만 절대 아니다 이름에서 그러한 유혹에서 벗어나야 한다 

 

@JoinColumn에는 상대 Entity 쪽에 있는 내가 Join 할 칼럼명을 지정하는 게 아니다!! 

JoinColumn은 대상 엔티티와 "매핑할 외래키의 현재 테이블에서의 변수명"을 지정해 주는 것이다!

 


@JoinColumn에 개념 바로잡기  

첫 번째 

가장 먼저 바로 잡아야 할 것은  언급했듯이 FK 가 있는 칼럼의 이름을 정해주는 것이다 

@JoinColumn(name = "team_id")
@JoinColumn(name="ddd")

 

해당 컬럼의 이름을 지정해 주는 것이지 내가 어떤 칼럼과 조인할 것인지 정하는 역할은 없다

이게 가장 오해를 많이 하는 부분인 것 같다

 

정리하면

@JoinColumn의 name 속성을 "team_id"로 하든, "ddd" 로하든  , "가나다라마바사아자차카타하"로 하든 JPA는

Join 할 칼럼을 바꾸지 않는다 (이미 알아서 정해져 있다) 


두 번째

두 번째 바로 잡을 개념은 @JoinColumn의 위치다. 흔히 위에서 봤듯이 DB를 설계할 때 FK 위치가 N 쪽에 항상 있기 때문에 

JPA에서도 @OneToMany 가 아닌 @ManyToOne 쪽에 항상 이 @JoinColumn 이 위치해야 할 것 같았다 

 

나도 그렇게 생각했었다 

특히 양방향 연관관계에서 연관관계 주인을 정할 때 다음 과 같은 어노테이션은 항상 세트로 붙어 다니는 줄 알았다 

@ManyToOne
@JoinColumn(name="")
@OneToMany(mappedby ="")

 

 

그래서 내가 할 일은 지금 연관관계가 ManyToOne 인지 OneToMany 인지만 판단하고 나머지는 템플릿처럼 저렇게 딱 딱 박으면 되는 줄 알았다 

 

이건 "반"만 맞는 말이다 

결론부터 말하면 이건 양방향 연관관계에서는 맞는 말이다 하지만 단반향 연관관계에서도 항상 저 어노테이션 세트가 성립하는 건 아니다 


양방향

먼저 어떻게 양방향에서 저 어노테이션 세트가 만들어졌는지 설명을 해보겠다!!

@JoinColumn의 위치를 결정하는 건 어떻게 보면 mappedBy다 (이것도 약간 오해의 소지가 있을 수 있지만 설명을 편하게 하기 위해서 이렇게 말하겠습니다 )

 

연관관계의 주인을 결정을 할 때 기준은

mappedBy 가 있으면 = 나는 주인 아님 (읽기 전용)
mappedBy 가 없으면 = 내가 주인 (FK 관리함)
위의 말을 다시 말하면 " 연관 관계 주인 쪽에만 @JoinColumn을 쓸 수 있다 "가 된다 

 

연관관계의 기준은 무조건 FK 가 가까운 쪽이 주인이 되고 ,

DB에 1: N 관계에서 N 쪽에 FK가 있음으로,

Many 쪽이 연관 관계의 주인을 가져가게 되고 자연스럽게 반대는 주인이 아니게 된다 

 

정리하면

  • 1:N에서 N 
    • 주인의 역할을 하기 때문에 @JoinColum을 가지게 되고 ,
    • N이기 때문에 @ManyToOne을 가져가서 이 둘이 하나의 세트 가 된다
  • 반대쪽 1
    • 주인이 아니기 때문에 mappedBy를 가지게 되고 , 
    • 1 이기 때문에 @OneToMany를 가져가서 이 둘이 하나의 세트가 된다

단방향

이제 단방향을 알아보자!

단방향은 말 그대로 객체 참조가 한쪽에만 존재한다 즉, mappedBy 자체가 등장할 수가 없다

 

그럼에도 불구하고 

DB에는 FK가 있어야
연관관계를 표현할 수 있다

 

그래서 연관 필드를 가진 엔티티가 자동으로 FK 관리자가 된다 

생각해 보면 당연하다 아니 오히려 쉽다 단방향이니깐 한쪽에만 연관 필드가 있을 텐데 자연스럽게 거기에 설정해주면 되는 것!!

 

근데 진짜 어려운 건 객체와 DB를 다르게 생각해야 한다 
이 둘은 그냥 태생부터 안 맞는 존재다. 좌뇌 우뇌 이것처럼 따로 있는 느낌이다 

 

그래서 객체 입장에서 보면 단방향 이니깐 한쪽에만 FK를 관리하는 필드가 생기게 되고 , DB 입장에선 객체가 어떻게 되는 상관없이 N 쪽에 무조건 FK 가 생긴다  (이게 진짜 핵심이고 어렵다

 

이론 은 이런데 솔직히 이것만 보면 이해 안 되는 걸 알아서 예제를 통해서 한번 싹 정리해 보겠다!

 


[@ManyToOne + @JoinColumn] - 단방향 ManyToOne case

@Entity
@NoArgsConstructor
@Getter
public class Team {
    @Id
    @GeneratedValue
    @Column(name="team_id")
    private Long id;

    private String teamName;

    public Team(final String teamName){
        this.teamName = teamName;
    }
}

 

@Entity
@Getter
public class Member {
    @Id @GeneratedValue
    @Column(name="member_id")
    private Long id;

    private String nickName;

    @ManyToOne
    @JoinColumn(name="teamId")
    private Team team;

    public Member(String nickName){
        this.nickName = nickName;
    }

    public void setTeam(Team team){
        this.team = team;
    }
}

 

 

어렵지 않게 이해할 수 있다 

@JoinColumn 이 연관관계 주인을 설정 하는 쪽에 사용하는 것을 기억해 보면 

Many의 입장의 Member 가 당연히 연관관계 주인을 가져가게 되고 그래서 해당 필드에 어노테이션이 붙었다 

 

그림으로 보자면 다음과 같다 

객체의 연관 관계의 주인과 실제 FK 가 있는 Table의 위치가 동일하다 

 


[@OneToMany + @JoinColumn]

이게 진짜 헷갈린다 

@Entity(name="OneToMany_Team")
@Getter
public class Team {
    @Id @GeneratedValue
    @Column(name="team_id")
    private int id;

    private String teamName;

    @OneToMany
    @JoinColumn(name = "team_id")
    private List<Member> members = new ArrayList<>();

    public Team(final String teamName){
        this.teamName = teamName;
    }

    public void addMember(Member member){
        members.add(member);
    }
}

 

여기서 객체와 DB를 분리해서 생각해야 한다 

DB 테이블은 무조건 일대 다 의 다 쪽에 외래키가 들어가게 만들어진다

 

그래서 @JoinColumn의 name은 이렇게 만들어지는 FK 칼럼의 이름을 정의하는 것이다 

그니까 이게 진짜 어려운데

 

객체의 연관 관계의 주인과 DB의 FK를 가진 Table이 서로 꼬인 상태다 

 

 

연관 관계의 주인은 Team의 필드이지만 실제 Fk는 Member 테이블에 있다 

 

이 방법의 문제점은 update 쿼리 하나가 더 나간다는 점이다

기존에는 Insert 쿼리를 한 번만 날리면 끝이었다 왜냐하면 FK를 관리하는 칼럼이 객체의 연관관계 주인과 일치했기 때문이다 

 

그런데 이 방법은 객체를 객체 관리용 Table에 한번 넣고 (insert 쿼리)

FK를 위해서 FK 칼럼이 있는 Member 테이블에 또 한 번 update 쿼리를 날려야 한다 

 

따라서 객체지향적으로는 위가 훨씬 직관적이지만,

주인을 외래키가 존재하는 N 쪽에 놓던지 양방향을 사용하는 게 좋다

 

즉 먼저 N이 주인인 다대일 단방향으로 한 후, 필요한 경우 양방향 매핑을 통해 해결하는 게 좋다


객체 입장에서 보면, 반대방향으로 참조할 필요가 없는데 관계를 하나 만드는 것이지만, DB의 입장으로 설계의
방향을 조금 더 맞춰서 운영상 유지보수하기 쉬운 쪽으로 선택할 수 있다.


[양방향 @ManyToOne + @JoinColumn]

@Entity(name="BI_MTO_Member")
@Getter
@Builder
public class Member {
    @Id
    @GeneratedValue
    private int id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="team_id")
    private Team team;

    public void setTeam(Team team){
        this.team = team;
    }
}

 

@Entity(name="BI_MTO_Team")
@Getter
@Builder
public class Team {
    @Id
    @GeneratedValue
    private int id;

    private String name;

    // CascadeType.ALL : 부모인 Team entity에 영속성 변화가 일어나면 Member에도 영속성 전이를 함
    // orphanRemoval : 부모인 Team과의 관계가 끊어져 Member가 고아가 되면 Member는 자동으로 삭제됨
    @OneToMany(mappedBy="team", fetch=FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<Member> members = new ArrayList<>();

}

 

 


[양방향 @OneToMany + @JoinColumn]

 

이건 성립 못한다 양방향 일대다 일 때 일쪽에는 JoinColumn을 못 쓰게 되어있다